import { useEffect, useMemo, useRef, useState } from "react"; import { invoke, IS_BROWSER_MODE } from "./tauriShim"; import { open, save } from "@tauri-apps/plugin-dialog"; const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder"; const POPUP_SIZE_KEY = "kml_map_tool.popup_size"; const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled"; const BBOX_MODE_KEY = "kml_map_tool.bbox_mode"; const BBOX_OVERLAY_KEY = "kml_map_tool.bbox_overlay"; const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect"; const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m"; const PERF_MODE_KEY = "kml_map_tool.perf_mode"; type PopupSize = "S" | "M" | "L" | "XL"; const POPUP_WIDTH_PX: Record = { S: 220, M: 330, L: 440, XL: 600, }; type PopupAspect = "16:9" | "4:3" | "3:2" | "1:1"; const POPUP_ASPECT_VAL: Record = { "16:9": 16 / 9, "4:3": 4 / 3, "3:2": 3 / 2, "1:1": 1, }; type Filters = { videos: Set; // empty = all sides: Set; // empty = all types: Set<"fixed" | "range">; // empty = all names: Set; // empty = all (asset_name) }; import { MapView, Viewport, BoundsBox, Vertex, LayerVisibility, OsmRoadFC, } from "./MapView"; import { LensView } from "./LensView"; import { BASEMAPS, DEFAULT_BASEMAP_ID } from "./basemaps"; import type { ActionRow, Asset, ScopePolygon } from "./types"; import "./App.css"; import { LoginScreen } from "./LoginScreen"; const USER_KEY = "kml_map_tool.user"; const NA_VIDEO = "NA"; const NA_SENTINELS = new Set([ "", "na", "n/a", "none", "null", "not available", "unknown", ]); const displayVideo = (v: string | null | undefined) => { if (!v) return NA_VIDEO; const t = v.trim(); if (t === "" || NA_SENTINELS.has(t.toLowerCase())) return NA_VIDEO; return v; }; const INITIAL_VIEWPORT: Viewport = { center: [51.4, 25.5], zoom: 9, }; const BBOX_FETCH_DEBOUNCE_MS = 400; type CurrentUser = { username: string; displayName: string }; export default function App() { const [currentUser, setCurrentUser] = useState(() => { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(USER_KEY); if (!raw) return null; return JSON.parse(raw) as CurrentUser; } catch { return null; } }); // Re-establish backend session whenever we have a stored user. useEffect(() => { if (currentUser) { invoke("set_current_user", { username: currentUser.username }).catch( () => {}, ); } }, [currentUser]); if (!currentUser) { return ( { const u = { username, displayName }; window.localStorage.setItem(USER_KEY, JSON.stringify(u)); setCurrentUser(u); }} /> ); } return { window.localStorage.removeItem(USER_KEY); setCurrentUser(null); }} />; } // Diagnostic FPS overlay. Mounted only when Settings → Performance mode is on. // Updates twice a second so the readout is stable enough to read while panning. function FpsCounter() { const [fps, setFps] = useState(0); useEffect(() => { let raf = 0; let frames = 0; let last = performance.now(); const tick = () => { frames++; const now = performance.now(); if (now - last >= 500) { setFps(Math.round((frames * 1000) / (now - last))); frames = 0; last = now; } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); return (
= 50 ? "#7CFC00" : fps >= 30 ? "#FFD700" : "#FF6B6B", padding: "4px 10px", borderRadius: 4, fontFamily: "monospace", fontSize: 12, fontWeight: 700, pointerEvents: "none", }} > {fps} fps · perf
); } // Lightweight indicator while the Overpass fetch is in flight. Single line, // no animation, no fixed positioning — just a small chip in the corner so // the user knows the click is doing something. function OsmFetchIndicator() { const [elapsed, setElapsed] = useState(0); useEffect(() => { const start = performance.now(); const id = window.setInterval(() => { setElapsed(Math.floor((performance.now() - start) / 1000)); }, 500); return () => window.clearInterval(id); }, []); return (
Fetching OSM… {elapsed}s
); } function AppShell({ currentUser, onLogout, }: { currentUser: CurrentUser; onLogout: () => void; }) { const [viewport, setViewport] = useState( IS_BROWSER_MODE ? { center: [78.347, 17.398], zoom: 14 } : INITIAL_VIEWPORT, ); const [bounds, setBounds] = useState(null); const [primaryId, setPrimaryId] = useState(DEFAULT_BASEMAP_ID); const [secondaryId, setSecondaryId] = useState("esri-imagery"); const [compare, setCompare] = useState(false); const [assets, setAssets] = useState([]); const [scopePolygons, setScopePolygons] = useState([]); const [dataCounts, setDataCounts] = useState<{ fixed_assets: number; range_assets: number; osm_roads: number; scope_features: number; video_metadata: number; }>({ fixed_assets: 0, range_assets: 0, osm_roads: 0, scope_features: 0, video_metadata: 0, }); const [recentActions, setRecentActions] = useState([]); const [status, setStatus] = useState(""); const [busy, setBusy] = useState(false); const [selectedAssetId, setSelectedAssetId] = useState(null); // Ctrl/Cmd+click extends an additive selection that the Delete shortcut acts // on in bulk. Plain click (single-select) clears it so the two paths don't // mix and surprise the user with stale halos. const [multiSelectedIds, setMultiSelectedIds] = useState>( () => new Set(), ); const [bulkVideoName, setBulkVideoName] = useState(""); const [mergePrimary, setMergePrimary] = useState(null); const [mergeSecondary, setMergeSecondary] = useState(null); const [panelOpen, setPanelOpen] = useState(true); const [placeMode, setPlaceMode] = useState(false); const [imageFolder, setImageFolder] = useState(() => typeof window !== "undefined" ? window.localStorage.getItem(IMAGE_FOLDER_KEY) : null, ); const OSM_FOLDER_KEY = "kml_map_tool.osm_folder"; const [osmFolder, setOsmFolder] = useState(() => typeof window !== "undefined" ? window.localStorage.getItem(OSM_FOLDER_KEY) : null, ); const [osmRoads, setOsmRoads] = useState({ type: "FeatureCollection", features: [], }); const [metadataTrack, setMetadataTrack] = useState>([]); // Per-video metadata polylines loaded from a multi-video JSON. When set, // snap-to-road and direction inference look up each asset's track by video. // The single-track `metadataTrack` is still used as a fallback (e.g. when // only one video is loaded via the legacy importer). const [metadataByVideo, setMetadataByVideo] = useState< Record> >({}); const [selectedMetadataVideo, setSelectedMetadataVideo] = useState< string | null >(null); const [layerVisibility, setLayerVisibility] = useState({ scope: true, metadata: true, osm: true, fixed: true, range: true, }); const [settingsOpen, setSettingsOpen] = useState(false); // Cursor-following magnifier. Toggle from the right-side panel; ESC exits. // While active, the main map fires onCursorWorld and we render LensView at // (cursorScreen.x + offset, cursorScreen.y + offset) showing the world at // viewport.zoom + LENS_ZOOM_DELTA. const [lensActive, setLensActive] = useState(false); const [cursorWorld, setCursorWorld] = useState<{ lng: number; lat: number; } | null>(null); const [cursorScreen, setCursorScreen] = useState<{ x: number; y: number; } | null>(null); // rAF throttle: mousemove fires up to 60+ Hz; coalescing to one React state // commit per animation frame keeps the lens setCenter cheap and avoids the // App tree thrashing on every pixel of pointer movement. const lensPendingRef = useRef<{ lng: number; lat: number; x: number; y: number; } | null>(null); const lensRafRef = useRef(null); useEffect(() => { return () => { if (lensRafRef.current !== null) cancelAnimationFrame(lensRafRef.current); }; }, []); const [qualityOffTrackM, setQualityOffTrackM] = useState(() => { if (typeof window === "undefined") return 30; const v = window.localStorage.getItem(QUALITY_OFF_TRACK_KEY); const n = v ? parseFloat(v) : NaN; return Number.isFinite(n) && n > 0 ? n : 30; }); const [popupSize, setPopupSize] = useState(() => { if (typeof window === "undefined") return "L"; const v = window.localStorage.getItem(POPUP_SIZE_KEY) as PopupSize | null; return v && v in POPUP_WIDTH_PX ? v : "L"; }); const [popupEnabled, setPopupEnabled] = useState(() => { if (typeof window === "undefined") return true; const v = window.localStorage.getItem(POPUP_ENABLED_KEY); return v === null ? true : v === "1"; }); const [bboxMode, setBboxMode] = useState(() => { if (typeof window === "undefined") return false; return window.localStorage.getItem(BBOX_MODE_KEY) === "1"; }); useEffect(() => { window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0"); }, [bboxMode]); // Independent of edit mode — whether the bbox rectangle/label is shown on // the popup image at all. Default ON so users see annotations immediately. const [bboxOverlay, setBboxOverlay] = useState(() => { if (typeof window === "undefined") return true; const v = window.localStorage.getItem(BBOX_OVERLAY_KEY); return v === null ? true : v === "1"; }); useEffect(() => { window.localStorage.setItem(BBOX_OVERLAY_KEY, bboxOverlay ? "1" : "0"); }, [bboxOverlay]); const [popupAspect, setPopupAspect] = useState(() => { if (typeof window === "undefined") return "4:3"; const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null; return v && v in POPUP_ASPECT_VAL ? v : "4:3"; }); // Diagnostic: while pan/zoom is active, render only basemap + asset // ScatterplotLayer + range PathLayer. Lets us isolate whether residual // lag is from layer complexity vs the WebView itself. const [perfMode, setPerfMode] = useState(() => { if (typeof window === "undefined") return false; return window.localStorage.getItem(PERF_MODE_KEY) === "1"; }); const [osmFetchActive, setOsmFetchActive] = useState(false); // Data-quality dashboard. Counts + sample id lists per category. Refreshed // on demand via the Quality panel (not auto-recomputed — too expensive at // 50 k+ assets). type QualityReport = { moved_far: number[]; far_from_metadata: number[]; missing_side: number[]; moved_far_total: number; far_from_metadata_total: number; missing_side_total: number; }; const [qualityReport, setQualityReport] = useState( null, ); const [qualityLoading, setQualityLoading] = useState(false); const [qualityHighlight, setQualityHighlight] = useState([]); // Step-through state for the Quality View action: cycle one issue at a // time, zoomed in tight, with Prev/Next buttons. The bbox-fit approach // didn't work for issues 50 km apart — z=8 is unreadable. const [qualityStep, setQualityStep] = useState<{ label: string; coords: Array<{ id: number; lat: number; lng: number }>; idx: number; } | null>(null); // Toast shown after a snap operation reporting whether the snap left // the data clean. Total = sum of the three Quality categories. const [snapQualityToast, setSnapQualityToast] = useState<{ label: string; total: number; breakdown: { moved: number; offTrack: number; missingSide: number }; } | null>(null); const [filters, setFilters] = useState({ videos: new Set(), sides: new Set(), types: new Set(), names: new Set(), }); const [videosExpanded, setVideosExpanded] = useState(false); const [namesExpanded, setNamesExpanded] = useState(true); const [filterPanelOpen, setFilterPanelOpen] = useState(true); const [videoSearch, setVideoSearch] = useState(""); const [gotoQuery, setGotoQuery] = useState(""); const [gotoPin, setGotoPin] = useState<{ lat: number; lng: number } | null>( null, ); const [markedVertices, setMarkedVertices] = useState>(new Set()); const [simplifyTolM, setSimplifyTolM] = useState(2); const [allAssetNames, setAllAssetNames] = useState([]); const [osmEditMode, setOsmEditMode] = useState(false); const [editingRoadId, setEditingRoadId] = useState(null); // Multi-select + temporary hide for the focused-video OSM workflow. Both // sets are session-only: cleared when leaving OSM edit mode or switching // the focused video. const [selectedRoadIds, setSelectedRoadIds] = useState>( new Set(), ); const [hiddenRoadIds, setHiddenRoadIds] = useState>(new Set()); // Reset the marked-vertex set whenever the user switches roads or leaves // OSM-edit mode. Indices are road-relative, so they're meaningless across // roads. useEffect(() => { setMarkedVertices(new Set()); }, [editingRoadId, osmEditMode]); useEffect(() => { if (!osmEditMode) { // Clear transient visual selection. Hidden roads and the undo ring // buffer survive exit/re-entry so the user can pop into the layer to // sanity-check elsewhere and come back without losing their work. setSelectedRoadIds(new Set()); } }, [osmEditMode]); // Per-session OSM edit undo: ring buffer (last 3) covering flip / simplify // / vertex-delete / drag-vertex / snap-ignore toggle, plus client-only hide // and restore. Persists across OSM-mode toggles within a session so users // can exit/re-enter without losing their last few edits. Merges aren't // undoable here (they delete source rows; this ring buffer doesn't // recreate them). type OsmRoadSnap = { id: number; name: string | null; geojson: string; oneway: number; highway: string | null; snap_ignored: number; }; type OsmUndoEntry = | { kind: "restore-roads"; snapshots: OsmRoadSnap[]; // Rows that were *created* by the operation (e.g. the right-half row // from a section split). Undo deletes them before restoring snapshots, // so the original geometry is the only survivor. createdIds?: number[]; label: string; } | { kind: "unhide"; ids: number[]; label: string } | { kind: "rehide"; ids: number[]; label: string }; const [osmUndoStack, setOsmUndoStack] = useState([]); const pushOsmUndo = (entry: OsmUndoEntry) => { setOsmUndoStack((prev) => [...prev, entry].slice(-3)); }; async function snapshotRoadsForUndo( ids: number[], label: string, ): Promise { if (!osmEditMode || ids.length === 0) return; try { const snapshots = await invoke("osm_road_snapshot", { ids, }); if (snapshots.length > 0) { pushOsmUndo({ kind: "restore-roads", snapshots, label }); } } catch (e) { console.warn("osm_road_snapshot failed", e); } } // For ops that mutate AND create new rows (delete_road_section split), the // caller should snapshot first, run the op, then call this with the ids // that were freshly inserted so undo can drop them on the way back. function attachCreatedIdsToLastUndo(createdIds: number[]) { if (createdIds.length === 0) return; setOsmUndoStack((prev) => { if (prev.length === 0) return prev; const last = prev[prev.length - 1]; if (last.kind !== "restore-roads") return prev; const next = prev.slice(); next[next.length - 1] = { ...last, createdIds: [...(last.createdIds ?? []), ...createdIds], }; return next; }); } async function performOsmUndo() { if (osmUndoStack.length === 0) return; const last = osmUndoStack[osmUndoStack.length - 1]; setOsmUndoStack((prev) => prev.slice(0, -1)); try { if (last.kind === "restore-roads") { // Delete any rows that the operation created before restoring the // original geometry — otherwise a split-section undo leaves a stale // duplicate alongside the resurrected original. if (last.createdIds && last.createdIds.length > 0) { for (const cid of last.createdIds) { try { await invoke("delete_osm_road", { id: cid }); } catch (e) { console.warn(`undo: delete_osm_road(${cid}) failed`, e); } } } const n = await invoke("restore_road_state", { snapshots: last.snapshots, }); await refreshOsmRoads(); setStatus(`Undid ${last.label} (${n} road${n === 1 ? "" : "s"} restored).`); } else if (last.kind === "unhide") { setHiddenRoadIds((prev) => { const next = new Set(prev); for (const id of last.ids) next.delete(id); return next; }); setStatus(`Undid ${last.label} (${last.ids.length} road(s) restored from hidden).`); } else { setHiddenRoadIds((prev) => { const next = new Set(prev); for (const id of last.ids) next.add(id); return next; }); setStatus(`Undid ${last.label} (${last.ids.length} road(s) hidden again).`); } } catch (e) { setStatus(`Undo failed: ${e}`); } } const [drawRoadMode, setDrawRoadMode] = useState(false); const [drawRoadCoords, setDrawRoadCoords] = useState< Array<[number, number]> >([]); const [lassoMode, setLassoMode] = useState(false); const [lassoShape, setLassoShape] = useState<"circle" | "rect" | "polygon">( "circle", ); const [lassoSelected, setLassoSelected] = useState<{ ids: number[]; label: string; } | null>(null); const [lassoPolygonPoints, setLassoPolygonPoints] = useState< Array<[number, number]> | null >(null); const [linkPickMode, setLinkPickMode] = useState(false); const [rightClickFirstId, setRightClickFirstId] = useState(null); const [selectedLink, setSelectedLink] = useState<[number, number] | null>(null); // GPS-bias anchors: each anchor stashes the asset's lat/lng *at the time of // marking* so we can compute the user's hand-drag delta later. Drags are // committed to the DB normally; Distribute reads each asset's current position // and compares to its origLat/origLng to derive Δ. const [anchors, setAnchors] = useState< Map >(new Map()); const [autoLinkMaxM, setAutoLinkMaxM] = useState(() => { if (typeof window === "undefined") return 30; const v = window.localStorage.getItem("kml_map_tool.autolink_max_m"); const n = v ? parseFloat(v) : NaN; return Number.isFinite(n) && n > 0 ? n : 30; }); useEffect(() => { window.localStorage.setItem( "kml_map_tool.autolink_max_m", String(autoLinkMaxM), ); }, [autoLinkMaxM]); const [lassoListOpen, setLassoListOpen] = useState(false); const [showDeleted, setShowDeleted] = useState(false); // "Only deleted" inverts the deleted-asset filter: live rows are hidden and // the bbox fetch implicitly includes deleted rows. Useful for triaging the // trash without scrolling through everything else. const [onlyDeleted, setOnlyDeleted] = useState(false); const [showOutOfScope, setShowOutOfScope] = useState(true); const [showInScope, setShowInScope] = useState(true); const [visibilityOpen, setVisibilityOpen] = useState(false); const [allVideoNames, setAllVideoNames] = useState([]); // Some Save dialogs (notably GTK on Linux) hide the file-filter dropdown, // so users couldn't tell they were even picking a format. We surface the // choice directly in the side panel — this is the format the next Export // click will use. const [exportFormat, setExportFormat] = useState< "source" | "geojson" | "kml" | "csv" >("source"); const DUP_EPS_KEY = "kml_map_tool.dup_eps_m"; const [dupEpsM, setDupEpsM] = useState(() => { if (typeof window === "undefined") return 3; const v = parseFloat(window.localStorage.getItem(DUP_EPS_KEY) ?? ""); return Number.isFinite(v) && v > 0 ? v : 3; }); useEffect(() => { window.localStorage.setItem(DUP_EPS_KEY, String(dupEpsM)); }, [dupEpsM]); type DupMember = { id: number; row_id: string; asset_name: string; video_name: string; side: string | null; lat: number | null; lng: number | null; asset_type: string; }; type DupCluster = { id: number; asset_name: string; members: DupMember[] }; const [dupClusters, setDupClusters] = useState([]); const [dupActive, setDupActive] = useState(null); const [dupKeep, setDupKeep] = useState>(new Map()); const [dupCrossOnly, setDupCrossOnly] = useState(false); const [classNames, setClassNames] = useState([]); const [renameOpen, setRenameOpen] = useState< null | { ids: number[]; current: string } >(null); const [renameInput, setRenameInput] = useState(""); // Auto-dismissing toast for transient confirmations (delete, rename, side // flip). Separate from the persistent status bar at the top — toasts // float at the bottom and disappear after ~2s. const [toast, setToast] = useState(null); const toastTimerRef = useRef(null); const showToast = (text: string) => { setToast(text); if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); } toastTimerRef.current = window.setTimeout(() => { setToast(null); toastTimerRef.current = null; }, 2200); }; const [osmToolsOpen, setOsmToolsOpen] = useState(false); const [importsOpen, setImportsOpen] = useState(false); const [osmToolPrune, setOsmToolPrune] = useState(true); const [osmToolPruneClasses, setOsmToolPruneClasses] = useState( "service,residential,unclassified,footway,path,track,cycleway,pedestrian,living_street,busway", ); const savedVisRef = useRef(null); const CENTERLINE_NAMES_KEY = "kml_map_tool.centerline_names"; const [centerlineNames, setCenterlineNames] = useState>(() => { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(CENTERLINE_NAMES_KEY); return new Set(raw ? (JSON.parse(raw) as string[]) : []); } catch { return new Set(); } }); useEffect(() => { window.localStorage.setItem( CENTERLINE_NAMES_KEY, JSON.stringify(Array.from(centerlineNames)), ); }, [centerlineNames]); const ROAD_OFFSET_KEY = "kml_map_tool.road_offset_m"; const [roadOffsetMeters, setRoadOffsetMeters] = useState(() => { if (typeof window === "undefined") return 4; const v = parseFloat(window.localStorage.getItem(ROAD_OFFSET_KEY) ?? ""); return Number.isFinite(v) && v > 0 ? v : 4; }); useEffect(() => { window.localStorage.setItem(ROAD_OFFSET_KEY, String(roadOffsetMeters)); }, [roadOffsetMeters]); // Per-road lane-offset overrides. The slider above sets the default; when // one or more roads are selected, the slider writes an override into this // map so each road's parallels can sit at its own width without dragging // the rest of the network with it. In-memory only — overrides reset across // reloads, but the default persists. const [roadOffsetById, setRoadOffsetById] = useState>( () => new Map(), ); useEffect(() => { const w = POPUP_WIDTH_PX[popupSize]; const h = Math.round(w / POPUP_ASPECT_VAL[popupAspect]); document.documentElement.style.setProperty("--popup-img-w", `${w}px`); document.documentElement.style.setProperty("--popup-img-h", `${h}px`); window.localStorage.setItem(POPUP_SIZE_KEY, popupSize); window.localStorage.setItem(POPUP_ASPECT_KEY, popupAspect); }, [popupSize, popupAspect]); useEffect(() => { window.localStorage.setItem(POPUP_ENABLED_KEY, popupEnabled ? "1" : "0"); }, [popupEnabled]); useEffect(() => { window.localStorage.setItem( QUALITY_OFF_TRACK_KEY, String(qualityOffTrackM), ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [qualityOffTrackM]); useEffect(() => { window.localStorage.setItem(PERF_MODE_KEY, perfMode ? "1" : "0"); }, [perfMode]); // Auto-dismiss the post-snap quality toast after a few seconds. Fast // enough not to linger; slow enough to read. useEffect(() => { if (!snapQualityToast) return; const t = window.setTimeout(() => setSnapQualityToast(null), 8000); return () => window.clearTimeout(t); }, [snapQualityToast]); // Counts on the current bbox slice (live update as user pans/zooms). const counts = useMemo(() => { const byVideo = new Map(); const bySide = new Map(); const byType = new Map(); for (const a of assets) { const vn = displayVideo(a.video_name); byVideo.set(vn, (byVideo.get(vn) ?? 0) + 1); const s = a.side ?? "(none)"; bySide.set(s, (bySide.get(s) ?? 0) + 1); byType.set(a.asset_type, (byType.get(a.asset_type) ?? 0) + 1); } return { byVideo, bySide, byType }; }, [assets]); const filteredAssets = useMemo(() => { return assets.filter((a) => { if (filters.videos.size && !filters.videos.has(displayVideo(a.video_name))) return false; if (filters.sides.size) { const s = a.side ?? "(none)"; if (!filters.sides.has(s)) return false; } if (filters.types.size && !filters.types.has(a.asset_type)) return false; if (filters.names.size && !filters.names.has(a.asset_name)) return false; // Video filter: explicit list. If allVideoNames is loaded and the asset's // video isn't in filters.videos, hide it. if ( allVideoNames.length > 0 && !filters.videos.has(displayVideo(a.video_name)) ) return false; if (onlyDeleted) { if (a.deleted === 0) return false; } const inScope = a.in_scope !== 0; if (!showOutOfScope && !inScope) return false; if (!showInScope && inScope) return false; return true; }); }, [assets, filters, showOutOfScope, showInScope, onlyDeleted, allVideoNames]); // Per-asset_name counts within the current video/side/type filter context // (but ignoring the names filter itself, so toggling a name doesn't make the // others disappear). const namesCounts = useMemo(() => { const m = new Map(); for (const a of assets) { if (filters.videos.size && !filters.videos.has(displayVideo(a.video_name))) continue; if (filters.sides.size) { const s = a.side ?? "(none)"; if (!filters.sides.has(s)) continue; } if (filters.types.size && !filters.types.has(a.asset_type)) continue; m.set(a.asset_name, (m.get(a.asset_name) ?? 0) + 1); } return m; }, [assets, filters.videos, filters.sides, filters.types]); function toggleSet(set: Set, value: T): Set { const next = new Set(set); if (next.has(value)) next.delete(value); else next.add(value); return next; } function clearFilters() { setFilters({ videos: new Set(), sides: new Set(), types: new Set(), names: new Set(), }); } const NUDGE_METERS = 0.5; const selectedAsset = useMemo( () => assets.find((a) => a.id === selectedAssetId) ?? null, [assets, selectedAssetId], ); const navigableAssets = useMemo(() => { return [...filteredAssets].sort((a, b) => { const va = displayVideo(a.video_name).localeCompare( displayVideo(b.video_name), ); if (va !== 0) return va; return a.id - b.id; }); }, [filteredAssets]); // Memoize colorBy decision — at 50k filtered assets, rebuilding two Sets on // every parent render was burning meaningful CPU. const colorBy = useMemo<"video" | "asset" | "side">(() => { const vids = new Set(); const names = new Set(); for (const a of filteredAssets) { vids.add(displayVideo(a.video_name)); names.add(a.asset_name); if (vids.size > 1 && names.size > 1) break; // early-out: result will be "video" } // Single video visible → fall back to asset / side coloring so the user // can tell individual assets apart instead of seeing one solid color. if (vids.size <= 1 && names.size === 1) return "side"; if (vids.size <= 1) return "asset"; // Multiple videos visible → match each asset's color to its metadata // polyline (when loaded) by coloring by video. return "video"; }, [filteredAssets]); // Video-focus filter: when the user picks a single video — either by tapping // its metadata polyline on the map, or by leaving exactly one video checked // in the sidebar filter — restrict the map to that video's track + OSM roads // near it. Tap the line again (or pick more videos) to restore everything. // Filter labels (collapsed via displayVideo — e.g. "Not available" → NA // sentinel) → the raw `video_name` strings each label spans. Built from // the DB-wide list of video_names (allVideoNames + metadataByVideo keys), // not the viewport-bounded `assets`, so panning never drops focus. Used // to resolve a user-picked filter label back to its single raw key for // metadataByVideo lookup and any backend command that does // `WHERE video_name = ?`. Returns null focus when the label spans more // than one raw (ambiguous) or the chosen video isn't in either source. const labelToRaws = useMemo(() => { const m = new Map(); const seen = new Set(); const sources = [...allVideoNames, ...Object.keys(metadataByVideo)]; for (const raw of sources) { if (seen.has(raw)) continue; seen.add(raw); const label = displayVideo(raw); const arr = m.get(label); if (arr) { if (!arr.includes(raw)) arr.push(raw); } else { m.set(label, [raw]); } } return m; }, [allVideoNames, metadataByVideo]); // Helper: resolve a user-supplied video string (could be a display label // like the NA sentinel or a raw video_name) to a raw DB key. Returns the // input unchanged if it already matches a raw; null when the resolution is // ambiguous (label spans multiple raws). Centralised so every video-action // call site routes through the same logic. const resolveVideoToRaw = (s: string): { raw: string | null; ambiguous: boolean } => { const t = s.trim(); if (!t) return { raw: null, ambiguous: false }; // Exact raw hit beats any label resolution — handles the case where a // raw value happens to equal the NA sentinel string itself. if (allVideoNames.includes(t) || metadataByVideo[t] !== undefined) { return { raw: t, ambiguous: false }; } const raws = labelToRaws.get(t); if (!raws || raws.length === 0) return { raw: null, ambiguous: false }; if (raws.length === 1) return { raw: raws[0], ambiguous: false }; return { raw: null, ambiguous: true }; }; const focusedVideo = useMemo(() => { // selectedMetadataVideo arrives from the polyline-tap path with the raw // key already (metadataByVideo is keyed raw), so pass through. if (selectedMetadataVideo !== null) return selectedMetadataVideo; if (filters.videos.size === 1) { const label = filters.videos.values().next().value as string | undefined; if (!label) return null; const raws = labelToRaws.get(label); if (raws && raws.length === 1) return raws[0]; // Multiple raw video_names collapse to the same display label // (e.g. several NA-ish sentinels in one bucket) — focusing is // ambiguous, so don't enter focus mode. return null; } return null; }, [selectedMetadataVideo, filters.videos, labelToRaws]); const displayMetadataTracks = useMemo(() => { if (focusedVideo === null) return metadataByVideo; const t = metadataByVideo[focusedVideo]; return t ? { [focusedVideo]: t } : metadataByVideo; }, [metadataByVideo, focusedVideo]); const displayOsmRoads = useMemo(() => { if (!osmRoads || osmRoads.features.length === 0) return osmRoads; // Hidden roads: dropped from view in any mode. They're cleared on exit. const dropHidden = (fc: OsmRoadFC): OsmRoadFC => { if (hiddenRoadIds.size === 0) return fc; return { type: "FeatureCollection", features: fc.features.filter((f) => !hiddenRoadIds.has(f.properties.id)), }; }; if (focusedVideo === null) return dropHidden(osmRoads); const track = metadataByVideo[focusedVideo]; if (!track || track.length < 2) return dropHidden(osmRoads); // Coarse spatial hash: bucket the track into ~55m × 55m cells with one // ring of neighbour padding, so a road vertex landing in any of those // cells is within ~75m of some track point. This is far cheaper than // O(roads × roadVerts × trackLen) point-to-point distance. const cellDeg = 0.0005; const cells = new Set(); for (const [lng, lat] of track) { if (!isFinite(lng) || !isFinite(lat)) continue; const gx = Math.floor(lng / cellDeg); const gy = Math.floor(lat / cellDeg); for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { cells.add(`${gx + dx},${gy + dy}`); } } } // Rasterize each road *segment* (not just the vertices) — a long sparse // segment can pass through a track buffer cell without any vertex // landing in one. We sample each segment at sub-cell intervals until any // sample lands in a marked cell. const features = osmRoads.features.filter((f) => { if (hiddenRoadIds.has(f.properties.id)) return false; const g = f.geometry as | { type: string; coordinates: Array<[number, number]> } | undefined; if (!g || g.type !== "LineString" || !Array.isArray(g.coordinates)) return false; const coords = g.coordinates; // Quick check: any vertex inside? for (const [lng, lat] of coords) { if (!isFinite(lng) || !isFinite(lat)) continue; const gx = Math.floor(lng / cellDeg); const gy = Math.floor(lat / cellDeg); if (cells.has(`${gx},${gy}`)) return true; } // Sub-sample each segment. const stepDeg = cellDeg * 0.5; for (let i = 1; i < coords.length; i++) { const a = coords[i - 1]; const b = coords[i]; if ( !isFinite(a[0]) || !isFinite(a[1]) || !isFinite(b[0]) || !isFinite(b[1]) ) continue; const dx = b[0] - a[0]; const dy = b[1] - a[1]; const len = Math.hypot(dx, dy); const steps = Math.ceil(len / stepDeg); if (steps <= 1) continue; for (let s = 1; s < steps; s++) { const t = s / steps; const lng = a[0] + dx * t; const lat = a[1] + dy * t; const gx = Math.floor(lng / cellDeg); const gy = Math.floor(lat / cellDeg); if (cells.has(`${gx},${gy}`)) return true; } } return false; }); return { type: "FeatureCollection", features }; }, [osmRoads, metadataByVideo, focusedVideo, hiddenRoadIds]); // Reset selection + hidden state whenever the focused video changes — sets // are video-scoped, so they shouldn't leak across switches. useEffect(() => { setSelectedRoadIds(new Set()); setHiddenRoadIds(new Set()); }, [focusedVideo]); // In focus mode, vertex-edit is implicit: it activates whenever exactly one // road is multi-selected. The existing vertex-edit panel + simplify slider // + flip/delete buttons all key off editingRoadId, so this single derivation // gives the user inline access without a separate submode toggle. useEffect(() => { if (focusedVideo === null) return; if (selectedRoadIds.size === 1) { const id = selectedRoadIds.values().next().value as number; setEditingRoadId(id); } else { setEditingRoadId(null); } }, [selectedRoadIds, focusedVideo]); function gotoAdjacent(dir: -1 | 1) { if (navigableAssets.length === 0) return; let idx = -1; if (selectedAssetId !== null) { idx = navigableAssets.findIndex((a) => a.id === selectedAssetId); } const next = idx === -1 ? navigableAssets[0] : navigableAssets[ (idx + dir + navigableAssets.length) % navigableAssets.length ]; setSelectedAssetId(next.id); if (next.lat !== null && next.lng !== null) { setViewport((v) => ({ center: [next.lng as number, next.lat as number], zoom: Math.max(v.zoom, 16), })); } setStatus( `Selected ${next.asset_name} · ${next.row_id} (${navigableAssets.indexOf(next) + 1}/${navigableAssets.length})`, ); } const debounceRef = useRef(null); const fetchSeqRef = useRef(0); const boundsRef = useRef(null); async function fetchBboxAssets(b: BoundsBox) { const seq = ++fetchSeqRef.current; const list = await invoke("get_assets_in_bbox", { minLat: b.south, maxLat: b.north, minLng: b.west, maxLng: b.east, includeDeleted: showDeleted || onlyDeleted, }); if (seq === fetchSeqRef.current) { setAssets(list); } return list; } useEffect(() => { if (!bounds) return; boundsRef.current = bounds; // Skip fetch when assets are hidden — OSM edit mode hides the asset layers, // and lasso/draw modes intercept all pointer events. Refetching would just // burn CPU rebuilding deck layers that nobody can see or interact with. if (osmEditMode || lassoMode || drawRoadMode) return; if (debounceRef.current !== null) { window.clearTimeout(debounceRef.current); } debounceRef.current = window.setTimeout(() => { fetchBboxAssets(bounds).catch((e) => setStatus(`Fetch failed: ${e}`)); }, BBOX_FETCH_DEBOUNCE_MS); void showDeleted; void onlyDeleted; return () => { if (debounceRef.current !== null) { window.clearTimeout(debounceRef.current); debounceRef.current = null; } }; }, [bounds, showDeleted, onlyDeleted, osmEditMode, lassoMode, drawRoadMode]); async function handlePositionChange( id: number, vertex: Vertex, lat: number, lng: number, ) { try { await invoke("update_asset_position", { id, vertex, lat, lng }); setStatus(`Moved ${vertex} to ${lat.toFixed(6)}, ${lng.toFixed(6)}`); showToast(`Moved ${vertex} point`); await refreshAfterMutation(); } catch (e) { setStatus(`Move failed: ${e}`); showToast(`Move failed`); } } function defaultVertex(a: Asset): Vertex { return a.asset_type === "fixed" ? "fixed" : "mid"; } async function handlePlaceAt(lat: number, lng: number) { if (!selectedAsset) return; setPlaceMode(false); await handlePositionChange( selectedAsset.id, defaultVertex(selectedAsset), lat, lng, ); } // ESC: deselect editing road (or cancel a draw-in-progress). useEffect(() => { const handler = (e: KeyboardEvent) => { // Vertex delete wins over bulk-hide whenever the user has marked // vertices on an actively-edited road. Without this order, Del in // focus + vertex-edit submode would hide the whole road instead of // dropping the marked vertices. if ( (e.key === "Delete" || e.key === "Backspace") && osmEditMode && editingRoadId !== null && markedVertices.size > 0 ) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } e.preventDefault(); const idx = Array.from(markedVertices); const roadId = editingRoadId; snapshotRoadsForUndo([roadId], "delete vertices") .then(() => invoke("delete_road_vertices", { id: roadId, indices: idx }), ) .then((nn) => { setStatus(`Removed ${nn} vertex(s) from road #${roadId}`); setMarkedVertices(new Set()); return refreshOsmRoads(); }) .catch((err) => setStatus(`Vertex delete failed: ${err}`)); return; } // Bulk action on selected roads in OSM mode (not in vertex-edit submode): // focusedVideo != null → hide (non-destructive, video-scoped) // focusedVideo == null → real DB delete with snapshot-based undo if ( (e.key === "Delete" || e.key === "Backspace") && osmEditMode && editingRoadId === null && selectedRoadIds.size > 0 ) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } e.preventDefault(); const ids = Array.from(selectedRoadIds); if (focusedVideo !== null) { setHiddenRoadIds((prev) => { const next = new Set(prev); for (const id of ids) next.add(id); return next; }); setSelectedRoadIds(new Set()); pushOsmUndo({ kind: "unhide", ids, label: "hide selected" }); setStatus(`${ids.length} road(s) hidden.`); return; } // Global OSM mode: snapshot, delete each id, then refresh. Undo via // the existing restore-roads upsert path resurrects them. (async () => { try { await snapshotRoadsForUndo(ids, "delete selected roads"); for (const id of ids) { await invoke("delete_osm_road", { id }); } setSelectedRoadIds(new Set()); if (editingRoadId !== null && ids.includes(editingRoadId)) { setEditingRoadId(null); } setMarkedVertices(new Set()); await refreshOsmRoads(); setStatus(`${ids.length} road(s) deleted. Use Undo to bring them back.`); } catch (err) { setStatus(`Bulk delete failed: ${err}`); } })(); return; } if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } e.preventDefault(); const [idA, idB] = selectedLink; setSelectedLink(null); invoke("clear_link", { id: idA }) .then(() => setStatus(`Unlinked #${idA} ↔ #${idB}`)) .then(refreshAfterMutation) .catch((err) => setStatus(`Unlink failed: ${err}`)); return; } // 'O' / 'o' toggles OSM edit mode. Ignored while typing in inputs. if (e.key === "o" || e.key === "O") { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); toggleOsmEditModeRef.current(); return; } // 'R' / 'r' opens the rename modal for the currently selected asset. // Input-guard pattern matches the other shortcuts. if (e.key === "r" || e.key === "R") { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } if (e.metaKey || e.ctrlKey || e.altKey) return; if (!selectedAsset) return; e.preventDefault(); setRenameOpen({ ids: [selectedAsset.id], current: selectedAsset.asset_name ?? "", }); setRenameInput(selectedAsset.asset_name ?? ""); return; } // Delete / Backspace: when an asset (or multi-selection) is active and // we're not in any edit mode that already claims those keys (OSM // vertex/road delete, link deletion handled above), delete and toast. // Multi-selection (Ctrl/Cmd+click) wins over single-select so a stray // single-selected asset isn't accidentally deleted alone. if ( (e.key === "Delete" || e.key === "Backspace") && (multiSelectedIds.size > 0 || selectedAsset) && !osmEditMode && !selectedLink ) { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } e.preventDefault(); if (multiSelectedIds.size > 0) { const ids = Array.from(multiSelectedIds); (async () => { try { await invoke("delete_assets_bulk", { ids }); setMultiSelectedIds(new Set()); const deletedSet = new Set(ids); setAssets((prev) => prev.map((a) => deletedSet.has(a.id) ? { ...a, deleted: 1 } : a, ), ); if (selectedAssetId !== null && deletedSet.has(selectedAssetId)) { setSelectedAssetId(null); } showToast(`Deleted ${ids.length}`); void Promise.all([ refreshActions().catch(() => {}), refreshDataCounts().catch(() => {}), ]); } catch (err) { showToast(`Delete failed: ${err}`); } })(); return; } if (!selectedAsset) return; const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`; (async () => { try { await invoke("delete_asset", { id: selectedAsset.id }); setSelectedAssetId(null); // Surgical local update — drop the row instead of refetching // everything, so the rest of the UI stays put. const deletedId = selectedAsset.id; setAssets((prev) => prev.map((a) => a.id === deletedId ? { ...a, deleted: 1 } : a, ), ); showToast(`Deleted ${label}`); void Promise.all([ refreshActions().catch(() => {}), refreshDataCounts().catch(() => {}), ]); } catch (err) { showToast(`Delete failed: ${err}`); } })(); return; } // 'B' / 'b' toggles bbox editing. No-op when popups are disabled. if (e.key === "b" || e.key === "B") { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); if (!popupEnabled) { setStatus("Enable image popup first to use BBox mode."); return; } setBboxMode((v) => !v); return; } // 'H' / 'h' toggles the asset image popup. Ctrl/Cmd+H is intentionally // allowed (and preventDefault'd) so the user can hide the popup while // multi-selecting — they're already holding Ctrl to extend the // selection. Alt+H stays browser/menu-bound (no preventDefault). if (e.key === "h" || e.key === "H") { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } if (e.altKey) return; e.preventDefault(); setPopupEnabled((v) => !v); return; } // 'C' / 'c': fit to focused video (focus mode) or all data (global). if (e.key === "c" || e.key === "C") { const target = e.target as HTMLElement | null; const tag = target?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { return; } if (e.metaKey || e.ctrlKey || e.altKey) return; e.preventDefault(); fitToFocusOrAllRef.current(); return; } if (e.key !== "Escape") return; if (lensActive) { e.preventDefault(); setLensActive(false); setCursorWorld(null); setCursorScreen(null); return; } if (lassoMode) { e.preventDefault(); setLassoMode(false); setLassoSelected(null); setLassoPolygonPoints(null); setStatus("Lasso cancelled."); return; } if (selectedMetadataVideo !== null) { e.preventDefault(); setSelectedMetadataVideo(null); setStatus(""); return; } if (gotoPin !== null) { e.preventDefault(); setGotoPin(null); setStatus(""); return; } if (linkPickMode) { e.preventDefault(); setLinkPickMode(false); } else if (rightClickFirstId !== null) { e.preventDefault(); setRightClickFirstId(null); setStatus("Pair link cancelled."); } else if (drawRoadMode) { e.preventDefault(); cancelDrawRoad(); } else if (editingRoadId !== null) { e.preventDefault(); setEditingRoadId(null); setStatus("Cleared road selection."); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, selectedAssetId, popupEnabled, multiSelectedIds]); // Image prefetch: when an asset is selected, warm the browser cache for its // immediate neighbours so [/] navigation feels instant. useEffect(() => { if (!selectedAsset) return; const idx = navigableAssets.findIndex((a) => a.id === selectedAsset.id); if (idx === -1) return; const targets = [ navigableAssets[idx - 1], navigableAssets[idx + 1], navigableAssets[idx + 2], navigableAssets[idx - 2], ].filter(Boolean); const handles: HTMLImageElement[] = []; for (const a of targets) { const urls = [ a.image_path, a.image_path1, a.image_path2, a.image_path3, ].filter((u): u is string => !!u); for (const u of urls) { const i = new Image(); i.decoding = "async"; i.src = u; // bypass resolveImageSrc — http(s) URLs go direct anyway handles.push(i); } } return () => { // Allow GC; aborting in-flight image fetches is best-effort. for (const i of handles) { i.src = ""; } }; }, [selectedAsset, navigableAssets]); // Global prev/next navigation: `[` and `]` cycle through filtered assets. useEffect(() => { const handler = (e: KeyboardEvent) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement ) { return; } if (e.key === "]" || e.key === "}") { e.preventDefault(); gotoAdjacent(1); } else if (e.key === "[" || e.key === "{") { e.preventDefault(); gotoAdjacent(-1); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [navigableAssets, selectedAssetId]); // Keyboard arrow nudge for the selected asset (mid for range, fixed for fixed). useEffect(() => { if (!selectedAsset) return; const a = selectedAsset; const handler = (e: KeyboardEvent) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement ) { return; } const arrows: Record = { ArrowUp: [1, 0], ArrowDown: [-1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], }; const dir = arrows[e.key]; if (!dir) return; const baseLat = (a.asset_type === "fixed" ? a.lat : a.lat) ?? viewport.center[1]; const baseLng = (a.asset_type === "fixed" ? a.lng : a.lng) ?? viewport.center[0]; if (baseLat === null || baseLng === null) return; e.preventDefault(); const dLat = (dir[0] * NUDGE_METERS) / 111320; const dLng = (dir[1] * NUDGE_METERS) / (111320 * Math.cos((baseLat * Math.PI) / 180)); handlePositionChange( a.id, defaultVertex(a), (baseLat as number) + dLat, (baseLng as number) + dLng, ).catch(() => {}); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [selectedAsset, viewport.center]); // Set an asset's side. Used both by the popup flip button and the auto-flip // from bbox editing (when the bbox center is in the left vs right half of // the image). Updates the local assets array in place so the popup tag // and any downstream UI refresh without a full re-fetch. async function handleSideChange(assetId: number, side: "Left" | "Right") { const cur = assets.find((a) => a.id === assetId); if (cur && cur.side === side) return; try { await invoke("change_assets_side", { ids: [assetId], side, }); setAssets((prev) => prev.map((a) => (a.id === assetId ? { ...a, side } : a)), ); showToast(`Side → ${side}`); } catch (e) { showToast(`Side change failed: ${e}`); } } async function handleDeleteSelected() { if (!selectedAsset) return; const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`; try { await invoke("delete_asset", { id: selectedAsset.id }); setStatus(`Deleted ${label}`); showToast(`Deleted ${label}`); setSelectedAssetId(null); await refreshAfterMutation(); } catch (e) { setStatus(`Delete failed: ${e}`); showToast(`Delete failed: ${e}`); } } async function handleBulkDeleteByVideo() { const typed = bulkVideoName.trim(); if (!typed) { setStatus("Enter a video name first."); return; } const r = resolveVideoToRaw(typed); if (r.ambiguous) { setStatus( `"${typed}" matches multiple raw video names — pick a specific one from the suggestions.`, ); return; } if (!r.raw) { setStatus(`No video matches "${typed}".`); return; } const name = r.raw; try { const count = await invoke("delete_assets_by_video", { videoName: name, }); if (count === 0) { setStatus(`No live assets matched video "${name}"`); showToast(`No matches for "${name}"`); } else { setStatus(`Deleted ${count} assets from video "${name}"`); showToast(`Deleted ${count} assets in "${name}"`); setBulkVideoName(""); } await refreshAfterMutation(); } catch (e) { setStatus(`Bulk delete failed: ${e}`); showToast(`Bulk delete failed`); } } async function handleSetImageFolder() { if (busy) return; try { const picked = await open({ directory: true, multiple: false }); if (typeof picked !== "string") return; setImageFolder(picked); window.localStorage.setItem(IMAGE_FOLDER_KEY, picked); setStatus(`Image folder: ${picked}`); } catch (e) { setStatus(`Folder pick failed: ${e}`); } } function handleClearImageFolder() { setImageFolder(null); window.localStorage.removeItem(IMAGE_FOLDER_KEY); setStatus("Cleared image folder; using image_path as-is."); } async function handleResetDatabase() { if (busy) return; const ok = window.confirm( "Wipe ALL assets, scope polygons, OSM roads, per-video metadata, and action history?\n\nThis cannot be undone.", ); if (!ok) return; setBusy(true); try { await invoke("reset_database"); setAssets([]); setScopePolygons([]); setOsmRoads({ type: "FeatureCollection", features: [] }); setMetadataByVideo({}); setMetadataTrack([]); setSelectedMetadataVideo(null); setRecentActions([]); setSelectedAssetId(null); setMergePrimary(null); setMergeSecondary(null); setSelectedRoadIds(new Set()); setHiddenRoadIds(new Set()); setEditingRoadId(null); setOsmUndoStack([]); setQualityReport(null); setSnapQualityToast(null); await Promise.all([ refreshDataCounts().catch(() => {}), refreshVideoNames().catch(() => {}), refreshVideoEntries().catch(() => {}), refreshAssetNames().catch(() => {}), ]); setStatus("Database reset."); showToast("Database reset"); } catch (e) { setStatus(`Reset failed: ${e}`); showToast(`Reset failed`); } finally { setBusy(false); } } async function handleUndo(actionId: number) { try { await invoke("undo_action", { actionId }); setStatus(`Undone action #${actionId}`); showToast(`Undone #${actionId}`); await refreshAfterMutation(); } catch (e) { setStatus(`Undo failed: ${e}`); showToast(`Undo failed`); } } async function handleRedo(actionId: number) { try { await invoke("redo_action", { actionId }); setStatus(`Redone action #${actionId}`); showToast(`Redone #${actionId}`); await refreshAfterMutation(); } catch (e) { setStatus(`Redo failed: ${e}`); showToast(`Redo failed`); } } function pickMergeSlot(slot: "primary" | "secondary") { if (!selectedAsset) { setStatus("Select a range asset first."); return; } if (selectedAsset.asset_type !== "range") { setStatus("Merge only works on range assets."); return; } if (slot === "primary") setMergePrimary(selectedAsset); else setMergeSecondary(selectedAsset); } async function handleMerge() { if (!mergePrimary || !mergeSecondary) return; if (mergePrimary.id === mergeSecondary.id) { setStatus("Primary and secondary must differ."); return; } try { await invoke("merge_polylines", { primaryId: mergePrimary.id, secondaryId: mergeSecondary.id, }); setStatus( `Merged ${mergeSecondary.row_id} into ${mergePrimary.row_id}`, ); showToast( `Merged ${mergeSecondary.row_id} → ${mergePrimary.row_id}`, ); setMergePrimary(null); setMergeSecondary(null); setSelectedAssetId(null); await refreshAfterMutation(); } catch (e) { setStatus(`Merge failed: ${e}`); showToast(`Merge failed`); } } function formatAction(a: ActionRow): string { let count = 1; try { count = (JSON.parse(a.asset_ids) as number[]).length; } catch { /* ignore */ } switch (a.action_type) { case "move": return `Moved asset (${count})`; case "delete": return `Deleted asset (${count})`; case "delete_by_video": return `Deleted ${count} assets by video`; case "merge": return `Merged ${count} polylines`; case "bulk_snap": return `Bulk-snapped ${count} to road`; case "set_scope": return `Set scope on ${count}`; case "rename": return `Renamed ${count}`; case "change_side": return `Side change on ${count}`; case "link": return a.scope === "single" ? `Link change` : `Auto-linked ${count}`; case "restore": return `Restored ${count}`; case "reset_position": return `Reset ${count} to original`; case "bulk_translate": return `Distributed correction across ${count}`; default: return `${a.action_type} (${count})`; } } async function refreshScopePolygons() { const list = await invoke("get_scope_polygons"); setScopePolygons(list); return list; } async function refreshOsmRoads() { const list = await invoke< Array<{ id: number; name: string | null; geojson: string; snap_ignored?: boolean; modified?: boolean; }> >("get_osm_roads"); let mnLat = Infinity; let mxLat = -Infinity; let mnLng = Infinity; let mxLng = -Infinity; const features = list.map((r) => { const geom = JSON.parse(r.geojson) as { type: string; coordinates: Array<[number, number]>; }; if (geom.type === "LineString" && Array.isArray(geom.coordinates)) { for (const c of geom.coordinates) { const lng = c[0]; const lat = c[1]; if ( typeof lat === "number" && typeof lng === "number" && isFinite(lat) && isFinite(lng) ) { if (lat < mnLat) mnLat = lat; if (lat > mxLat) mxLat = lat; if (lng < mnLng) mnLng = lng; if (lng > mxLng) mxLng = lng; } } } return { type: "Feature" as const, geometry: geom, properties: { id: r.id, name: r.name, snap_ignored: r.snap_ignored === true, modified: r.modified === true, }, }; }); setOsmRoads({ type: "FeatureCollection", features }); const bounds = Number.isFinite(mnLat) ? { minLat: mnLat, maxLat: mxLat, minLng: mnLng, maxLng: mxLng } : null; return { list, bounds }; } async function refreshAssetNames() { try { const list = await invoke("list_asset_names"); setAllAssetNames(list); } catch { /* ignore */ } } // Annotated video list from the backend: raw names, deterministic numbers // (timestamp + name sort), and completion flags shared across clients. type VideoEntry = { video_name: string; video_number: number; completed: boolean; completed_at: string | null; completed_by: string | null; }; const [videoEntries, setVideoEntries] = useState([]); const [completedVideosExpanded, setCompletedVideosExpanded] = useState(false); async function refreshVideoEntries() { try { const list = await invoke("list_videos"); setVideoEntries(list); } catch { /* ignore */ } } async function refreshVideoNames() { try { const raw = await invoke("list_video_names"); const list = Array.from(new Set(raw.map(displayVideo))).sort(); // Auto-include only videos we haven't seen before, so user de-selections // survive every mutation refresh. setFilters((f) => { const previouslyKnown = new Set(allVideoNames); const next = new Set(f.videos); if (allVideoNames.length === 0) { // First run: include everything. for (const v of list) next.add(v); } else { // Subsequent runs: only auto-include genuinely new entries. for (const v of list) { if (!previouslyKnown.has(v)) next.add(v); } } return { ...f, videos: next }; }); setAllVideoNames(list); } catch { /* ignore */ } } async function refreshClasses() { try { const list = await invoke("list_classes"); setClassNames(list); } catch { /* ignore */ } } async function refreshActions() { const list = await invoke("list_recent_actions", { limit: 10 }); setRecentActions(list); return list; } async function refreshAfterMutation() { const tasks: Promise[] = [ refreshActions(), refreshAssetNames(), refreshVideoNames(), refreshVideoEntries(), refreshClasses(), refreshDataCounts(), ]; if (boundsRef.current) tasks.push(fetchBboxAssets(boundsRef.current)); await Promise.all(tasks); } // Fly the map to a specific video (uses any combination of metadata polyline // + asset bboxes, whichever exists). // Fit the map to either the focused video or every loaded asset+track. The // C shortcut and the on-map "Fit" button both call this. Held in a ref so // the keydown handler always sees the current closure. function fitToFocusOrAll() { if (focusedVideo !== null) { flyToVideo(focusedVideo); return; } let mnLat = Infinity, mxLat = -Infinity, mnLng = Infinity, mxLng = -Infinity; for (const a of assets) { if (a.deleted !== 0) continue; const lats = [a.lat, a.start_lat, a.end_lat].filter( (v): v is number => v !== null, ); const lngs = [a.lng, a.start_lng, a.end_lng].filter( (v): v is number => v !== null, ); for (const v of lats) { if (v < mnLat) mnLat = v; if (v > mxLat) mxLat = v; } for (const v of lngs) { if (v < mnLng) mnLng = v; if (v > mxLng) mxLng = v; } } for (const track of Object.values(metadataByVideo)) { for (const [lng, lat] of track) { if (lng < mnLng) mnLng = lng; if (lng > mxLng) mxLng = lng; if (lat < mnLat) mnLat = lat; if (lat > mxLat) mxLat = lat; } } if (!Number.isFinite(mnLat)) { setStatus("No data loaded to fit."); return; } setViewport({ center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2], zoom: 13, }); setStatus("Fit to all data."); } const fitToFocusOrAllRef = useRef(fitToFocusOrAll); fitToFocusOrAllRef.current = fitToFocusOrAll; function flyToVideo(name: string) { let mnLat = Infinity, mxLat = -Infinity, mnLng = Infinity, mxLng = -Infinity; const track = metadataByVideo[name]; if (track && track.length > 0) { for (const [lng, lat] of track) { if (lng < mnLng) mnLng = lng; if (lng > mxLng) mxLng = lng; if (lat < mnLat) mnLat = lat; if (lat > mxLat) mxLat = lat; } } for (const a of assets) { if (a.deleted !== 0) continue; if (displayVideo(a.video_name) !== name) continue; const lats = [a.lat, a.start_lat, a.end_lat].filter( (v): v is number => v !== null, ); const lngs = [a.lng, a.start_lng, a.end_lng].filter( (v): v is number => v !== null, ); for (const v of lats) { if (v < mnLat) mnLat = v; if (v > mxLat) mxLat = v; } for (const v of lngs) { if (v < mnLng) mnLng = v; if (v > mxLng) mxLng = v; } } if (!Number.isFinite(mnLat)) { setStatus(`No location for "${name}" (load metadata or assets first).`); return; } setViewport({ center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2], zoom: 14, }); // If the video has a metadata track, select it so the user sees direction // arrows + S/E markers + name badge — same effect as tapping the line. if (metadataByVideo[name]) { setSelectedMetadataVideo(name); } setStatus(name); } function handleGoto() { const q = gotoQuery.trim(); if (q === "") return; // Try lat,lng first. const m = q.match(/^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/); if (m) { const lat = parseFloat(m[1]); const lng = parseFloat(m[2]); if ( Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 ) { setViewport({ center: [lng, lat], zoom: 18 }); setGotoPin({ lat, lng }); setStatus(`📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}`); return; } setStatus(`Out-of-range lat/lng: ${q}`); return; } // Else treat as a video name — exact, then prefix, then substring (case-insensitive). const videos = Object.keys(metadataByVideo).concat(allVideoNames); const lower = q.toLowerCase(); const exact = videos.find((v) => v === q); const prefix = videos.find((v) => v.toLowerCase().startsWith(lower)); const sub = videos.find((v) => v.toLowerCase().includes(lower)); const hit = exact ?? prefix ?? sub; if (hit) { flyToVideo(hit); return; } setStatus( `No match for "${q}". Use "lat,lng" (e.g. 17.398, 78.347) or a video name.`, ); } async function refreshDataCounts() { try { const c = await invoke<{ fixed_assets: number; range_assets: number; osm_roads: number; scope_features: number; video_metadata: number; }>("data_source_counts"); setDataCounts(c); } catch { /* ignore */ } } useEffect(() => { refreshScopePolygons().catch((e) => setStatus(`Scope load failed: ${e}`), ); // Surface OSM load failures — silent catch made startup look fine // even when the roads table couldn't be read. refreshOsmRoads().catch((e) => setStatus(`OSM roads load failed: ${e}`), ); refreshActions().catch(() => {}); refreshAssetNames().catch(() => {}); refreshVideoNames().catch(() => {}); refreshVideoEntries().catch(() => {}); refreshClasses().catch(() => {}); refreshDataCounts().catch(() => {}); // Per-video metadata persists in DB; load it so users don't have to // re-pick the JSON every session. invoke>>("get_video_metadata") .then((map) => { const keys = Object.keys(map); if (keys.length === 0) return; setMetadataByVideo(map); const flat: Array<[number, number]> = []; for (const k of keys) flat.push(...map[k]); setMetadataTrack(flat); }) .catch(() => {}); invoke("list_assets") .then((list) => { if (list.length > 0) { setStatus(`${list.length} assets in DB`); const first = list.find((a) => a.lat !== null && a.lng !== null); if (first) { setViewport({ center: [first.lng as number, first.lat as number], zoom: 13, }); } } }) .catch((e) => setStatus(`Load failed: ${e}`)); }, []); function toggleOsmEditMode() { if (osmEditMode) { // Exit: restore previous layer visibility. if (savedVisRef.current) { setLayerVisibility(savedVisRef.current); savedVisRef.current = null; } setEditingRoadId(null); setDrawRoadMode(false); setDrawRoadCoords([]); setOsmEditMode(false); setStatus("Exited OSM edit mode."); } else { // Enter: snapshot visibility and hide non-OSM layers. savedVisRef.current = layerVisibility; setLayerVisibility({ scope: false, metadata: false, osm: true, fixed: false, range: false, }); setSelectedAssetId(null); setOsmEditMode(true); setStatus("OSM edit mode: click a road to edit its vertices."); } } // Ref so the keyboard handler always sees the latest closure (otherwise // toggling via 'O' would capture stale layerVisibility / osmEditMode from // whichever render the keydown effect last re-ran on). const toggleOsmEditModeRef = useRef(toggleOsmEditMode); toggleOsmEditModeRef.current = toggleOsmEditMode; function startDrawRoad() { setDrawRoadCoords([]); setDrawRoadMode(true); setEditingRoadId(null); setStatus("Click on the map to add vertices. Finish to save."); } function cancelDrawRoad() { setDrawRoadMode(false); setDrawRoadCoords([]); setStatus("Cancelled new road."); } function handleDrawRoadAddPoint(lat: number, lng: number) { setDrawRoadCoords((prev) => [...prev, [lng, lat] as [number, number]]); } async function finishDrawRoad() { if (drawRoadCoords.length < 2) { setStatus("A road needs at least 2 vertices."); return; } const name = window.prompt("Road name (optional):", "") || null; try { const newId = await invoke("create_osm_road", { name, coords: drawRoadCoords, }); await refreshOsmRoads(); await refreshDataCounts(); setDrawRoadMode(false); setDrawRoadCoords([]); setEditingRoadId(newId); setStatus( `Created road #${newId} with ${drawRoadCoords.length} vertices.`, ); showToast(`Created road #${newId}`); } catch (e) { setStatus(`Create road failed: ${e}`); showToast(`Create road failed`); } } async function handleDeleteRoad() { if (editingRoadId === null) return; const ok = window.confirm(`Delete road #${editingRoadId}?`); if (!ok) return; const roadId = editingRoadId; try { await invoke("delete_osm_road", { id: roadId }); await refreshOsmRoads(); await refreshDataCounts(); setEditingRoadId(null); setStatus(`Deleted road #${roadId}.`); showToast(`Deleted road #${roadId}`); } catch (e) { setStatus(`Delete failed: ${e}`); showToast(`Delete road failed`); } } async function handleUpdateRoad( id: number, coords: Array<[number, number]>, ) { try { await snapshotRoadsForUndo([id], "vertex drag"); await invoke("update_osm_road", { id, coords }); await refreshOsmRoads(); setStatus(`Updated road #${id} (${coords.length} vertices).`); } catch (e) { setStatus(`Road update failed: ${e}`); } } async function handleImportOsm() { if (busy) return; setBusy(true); setStatus("Picking GeoJSON…"); try { const picked = await open({ multiple: false, directory: false, filters: [{ name: "GeoJSON", extensions: ["geojson", "json"] }], }); if (typeof picked !== "string") { setStatus(""); return; } const majorOnly = window.confirm( "Import MAJOR roads only (motorway/trunk/primary/secondary/tertiary)?\n\nClick OK to filter; Cancel to import all road classes (warning: can be very slow on large GeoJSONs).", ); const args: Record = { path: picked }; if (majorOnly) { args.highwayClasses = [ "motorway", "motorway_link", "trunk", "trunk_link", "primary", "primary_link", "secondary", "secondary_link", "tertiary", "tertiary_link", ]; } setStatus(`Importing OSM roads from ${picked.split(/[\\/]/).pop()}…`); const count = await invoke("import_osm_geojson", args); const { bounds } = await refreshOsmRoads(); // Keep the sidebar Loaded-data card in sync. Without this the OSM // count stayed at its last value, which made imports look like they // hadn't done anything. await refreshDataCounts(); // Auto-recentre the map on the imported road bbox. Without this, if // the user's current viewport is far from the imported area, the // roads render off-screen and the import looks silently broken. if (count === 0) { // Explicit message — silent "Loaded 0" looked like the import // did nothing. Common cause: the major-only filter removed every // road class in the file, or the GeoJSON had no LineStrings. setStatus( majorOnly ? `0 road segments matched the major-only filter. Re-import and click Cancel on the filter prompt to import all classes.` : `0 road segments imported. Check that the GeoJSON has LineString features.`, ); } else if (bounds) { const center: [number, number] = [ (bounds.minLng + bounds.maxLng) / 2, (bounds.minLat + bounds.maxLat) / 2, ]; const dLat = bounds.maxLat - bounds.minLat; const dLng = bounds.maxLng - bounds.minLng; const span = Math.max(dLat, dLng); // Pick a zoom level that roughly fits the bbox in the viewport. // Web Mercator: at z=0 the world spans 360°, so zoom for a span s // is ~log2(360 / s) − a small margin for padding. const zoom = span > 0 ? Math.max(8, Math.min(17, Math.log2(360 / span) - 0.5)) : 14; setViewport({ center, zoom }); setStatus( `Loaded ${count} road segment(s). Centred on imported area.`, ); } else { setStatus(`Loaded ${count} road segment(s)`); showToast(`Loaded ${count} road segment(s)`); } } catch (e) { setStatus(`OSM import failed: ${e}`); showToast(`OSM import failed`); } finally { setBusy(false); } } async function pickOsmFolder() { const picked = await open({ directory: true, multiple: false }); if (typeof picked !== "string") return; setOsmFolder(picked); window.localStorage.setItem(OSM_FOLDER_KEY, picked); setStatus(`OSM folder: ${picked}`); } async function ensureOsmFolder(): Promise { if (osmFolder) return osmFolder; const picked = await open({ directory: true, multiple: false }); if (typeof picked !== "string") return null; setOsmFolder(picked); window.localStorage.setItem(OSM_FOLDER_KEY, picked); return picked; } async function handleGenerateOsm() { if (busy) return; const folder = await ensureOsmFolder(); if (!folder) return; const sep = folder.endsWith("/") || folder.endsWith("\\") ? "" : "/"; const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const hasAssets = dataCounts.fixed_assets + dataCounts.range_assets > 0; const hasMetadata = (dataCounts.video_metadata ?? 0) > 0 || Object.keys(metadataByVideo).length > 0; if (!hasAssets && !hasMetadata) { setStatus( "Import per-video metadata or assets first — the generator filters Overpass results against them.", ); return; } // We need a bbox to query Overpass against. Prefer the current visible // viewport (gives the user direct control over scope); fall back to the // bbox of all loaded assets if the viewport isn't valid yet (e.g. user // never panned the map). Either way the bbox is what Overpass filters // on, and the post-filter then narrows by metadata + asset proximity. let b = boundsRef.current; if (!b) { try { const ab = await invoke<{ min_lat: number; min_lng: number; max_lat: number; max_lng: number; } | null>("assets_bbox"); if (ab) { b = { south: ab.min_lat, north: ab.max_lat, west: ab.min_lng, east: ab.max_lng, }; } } catch { /* fall through to error below */ } } if (!b) { setStatus( "No visible map bbox and no asset bbox available — pan/zoom the map or import assets first.", ); return; } setBusy(true); setOsmFetchActive(true); try { const sourceHint = hasMetadata && hasAssets ? "metadata + assets" : hasMetadata ? "metadata" : "assets"; setStatus( `Fetching OSM for visible bbox from Overpass, then filtering by ${sourceHint}…`, ); const path = `${folder}${sep}osm_on_asset_roads_${ts}.geojson`; const r = await invoke<{ ways_kept: number; source: string; points_used: number; videos_used: number; }>("generate_osm_near_assets", { minLat: b.south, minLng: b.west, maxLat: b.north, maxLng: b.east, keepThresholdM: 50, path, }); const refDesc = r.videos_used > 0 ? `${r.points_used} ref points (${r.videos_used} video track(s) + assets)` : `${r.points_used} asset ref points`; setStatus( `Generated ${r.ways_kept} road(s) [bbox + ${r.source}, ${refDesc}] → ${path}. Use Import → OSM roads to load.`, ); return; } catch (e) { setStatus(`Generate OSM failed: ${e}`); return; } finally { setOsmFetchActive(false); setBusy(false); } } async function handleExportOsm() { if (busy) return; const folder = await ensureOsmFolder(); if (!folder) return; const sep = folder.endsWith("/") || folder.endsWith("\\") ? "" : "/"; const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const path = `${folder}${sep}osm_export_${ts}.geojson`; setBusy(true); try { const n = await invoke("export_osm_roads", { path }); setStatus(`Exported ${n} road(s) → ${path}`); } catch (e) { setStatus(`Export OSM failed: ${e}`); } finally { setBusy(false); } } async function applyOsmTools() { if (busy) return; setBusy(true); try { const summary: string[] = []; if (osmToolPrune) { const classes = osmToolPruneClasses .split(",") .map((s) => s.trim()) .filter((s) => s !== ""); if (classes.length > 0) { const n = await invoke("delete_roads_by_highway", { classes }); summary.push(`pruned ${n}`); } } await refreshOsmRoads(); // Sidebar count would otherwise show pre-prune total. await refreshDataCounts(); setOsmToolsOpen(false); setStatus( summary.length > 0 ? `OSM tools: ${summary.join("; ")}.` : "Nothing selected.", ); } catch (e) { setStatus(`OSM tools failed: ${e}`); } finally { setBusy(false); } } async function handleLoadMetadataPolyline() { if (busy) return; setBusy(true); setStatus("Picking metadata polyline JSON…"); try { const picked = await open({ multiple: false, directory: false, filters: [{ name: "JSON", extensions: ["json"] }], }); if (typeof picked !== "string") { setStatus(""); return; } const track = await invoke>( "read_metadata_polyline", { path: picked }, ); setMetadataTrack(track); setStatus(`Loaded ${track.length}-point metadata polyline`); } catch (e) { setStatus(`Metadata polyline failed: ${e}`); } finally { setBusy(false); } } async function handleSnapByVideo() { if (busy) return; const typed = bulkVideoName.trim(); if (!typed) { setStatus("Pick a video name first (use the bulk-delete input)."); return; } const r = resolveVideoToRaw(typed); if (r.ambiguous) { setStatus( `"${typed}" matches multiple raw video names — pick a specific one from the suggestions.`, ); return; } if (!r.raw) { setStatus(`No video matches "${typed}".`); return; } const name = r.raw; const ok = window.confirm( `Snap every asset (fixed + range) from video "${name}" to the nearest OSM road?\n\n` + `Single undoable action.`, ); if (!ok) return; setBusy(true); try { const res = await invoke<{ snapped: number; skipped_far: number; skipped_no_geom: number; }>("snap_assets_in_bbox", { minLat: null, maxLat: null, minLng: null, maxLng: null, videoName: name, maxDistanceM: 50, offsetM: roadOffsetMeters, centerlineNames: Array.from(centerlineNames), metadataTrack: metadataByVideo[name]?.length >= 2 ? metadataByVideo[name] : metadataTrack.length >= 2 ? metadataTrack : null, }); setStatus( `Snap by video: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`, ); await refreshAfterMutation(); await runPostSnapQualityCheck(`Snap by video (${name})`); } catch (e) { setStatus(`Snap by video failed: ${e}`); } finally { setBusy(false); } } function handleLassoComplete( shape: "circle" | "rect" | "polygon", points: Array<[number, number]>, ) { const R = 6371000; const toRad = (d: number) => (d * Math.PI) / 180; const haversine = (a: [number, number], b: [number, number]) => { const dLat = toRad(b[1] - a[1]); const dLng = toRad(b[0] - a[0]); const s = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)); }; // OSM-edit-mode lasso: convert shape → polygon and bulk-delete the road // sections that fall inside it. Skips the asset-bulk-action flow below. if (osmEditMode) { const m_per_lat = 111_320; let polygon: Array<[number, number]> = []; if (shape === "circle") { const c = points[0]; const edge = points[1]; const radius_m = haversine(c, edge); if (radius_m < 1) { setStatus("Lasso too small."); return; } const m_per_lng = 111_320 * Math.cos(toRad(c[1])); const N = 64; for (let i = 0; i < N; i++) { const a = (i / N) * Math.PI * 2; polygon.push([ c[0] + (radius_m * Math.cos(a)) / m_per_lng, c[1] + (radius_m * Math.sin(a)) / m_per_lat, ]); } } else if (shape === "rect") { const [a, b] = points; const minLng = Math.min(a[0], b[0]); const maxLng = Math.max(a[0], b[0]); const minLat = Math.min(a[1], b[1]); const maxLat = Math.max(a[1], b[1]); polygon = [ [minLng, minLat], [maxLng, minLat], [maxLng, maxLat], [minLng, maxLat], ]; } else { if (points.length < 3) { setStatus("Need at least 3 polygon vertices."); return; } polygon = points.map((p) => [p[0], p[1]] as [number, number]); } const ok = window.confirm( `Bulk-delete OSM road sections inside this lasso?\n\n` + `Roads fully inside are removed; roads partially inside are clipped at the lasso boundary — the inside portion is dropped, the outside portion(s) survive as separate roads. ` + `Covered by OSM Undo: the original rows are restored and the split-out rows are deleted on undo.`, ); if (!ok) { setLassoMode(false); return; } setBusy(true); invoke<{ roads_examined: number; roads_deleted: number; roads_split: number; new_road_ids: number[]; before_snapshots: OsmRoadSnap[]; }>("delete_roads_in_lasso", { polygon }) .then(async (r) => { setStatus( `Lasso roads: examined ${r.roads_examined}, fully deleted ${r.roads_deleted}, split ${r.roads_split} (→ ${r.new_road_ids.length} new sub-road(s)).`, ); // Push to the OSM undo ring: restore_road_state upserts the // snapshots (re-inserts deleted rows by id) and the createdIds // path drops the split-out children before that runs. if (r.before_snapshots.length > 0 || r.new_road_ids.length > 0) { pushOsmUndo({ kind: "restore-roads", snapshots: r.before_snapshots, createdIds: r.new_road_ids, label: "lasso road delete", }); } // The currently-edited / selected / marked road(s) may have been // deleted or split — drop those UI references so the next click // doesn't act on a stale id. setEditingRoadId(null); setMarkedVertices(new Set()); setSelectedRoadIds(new Set()); await refreshOsmRoads(); }) .catch((e) => setStatus(`Bulk road delete failed: ${e}`)) .finally(() => { setBusy(false); setLassoMode(false); }); return; } const ids: number[] = []; let label = ""; if (shape === "circle") { const center = points[0]; const edge = points[1]; const radius = haversine(center, edge); if (radius < 1) { setStatus("Lasso too small."); return; } for (const a of filteredAssets) { if (a.deleted !== 0) continue; if (a.lat === null || a.lng === null) continue; if (haversine(center, [a.lng, a.lat]) <= radius) ids.push(a.id); } label = `circle r=${radius.toFixed(0)} m`; } else if (shape === "rect") { const [a, b] = points; const minLng = Math.min(a[0], b[0]); const maxLng = Math.max(a[0], b[0]); const minLat = Math.min(a[1], b[1]); const maxLat = Math.max(a[1], b[1]); if (maxLng - minLng < 1e-6 || maxLat - minLat < 1e-6) { setStatus("Lasso too small."); return; } for (const x of filteredAssets) { if (x.deleted !== 0) continue; if (x.lat === null || x.lng === null) continue; if ( x.lng >= minLng && x.lng <= maxLng && x.lat >= minLat && x.lat <= maxLat ) ids.push(x.id); } label = "bbox"; } else { // polygon: ray-casting test const verts = points; if (verts.length < 3) { setStatus("Need at least 3 polygon vertices."); return; } setLassoPolygonPoints(verts); const insidePoly = (lng: number, lat: number) => { let inside = false; for (let i = 0, j = verts.length - 1; i < verts.length; j = i++) { const xi = verts[i][0], yi = verts[i][1]; const xj = verts[j][0], yj = verts[j][1]; const intersect = yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi + 1e-12) + xi; if (intersect) inside = !inside; } return inside; }; for (const x of filteredAssets) { if (x.deleted !== 0) continue; if (x.lat === null || x.lng === null) continue; if (insidePoly(x.lng, x.lat)) ids.push(x.id); } label = `polygon (${verts.length})`; } if (ids.length === 0) { setStatus(`Lasso (${label}) selected nothing.`); return; } setLassoSelected({ ids, label }); setLassoMode(false); setStatus(`Lasso (${label}) selected ${ids.length} asset(s)`); } function naturalCompare(a: string, b: string): number { return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); } // Run a quality scan immediately after a snap and surface the result // as a toast. Shared by all three snap entrypoints (single asset, // visible bulk, by-video). The Quality state is also updated so the // sidebar panel reflects the post-snap counts without the user needing // to click Refresh. async function runPostSnapQualityCheck(label: string) { try { const r = await invoke("quality_report", { movedThresholdM: 50, metadataThresholdM: qualityOffTrackM, metadata: metadataByVideo, cap: 500, }); setQualityReport(r); const total = r.moved_far_total + r.far_from_metadata_total + r.missing_side_total; setSnapQualityToast({ label, total, breakdown: { moved: r.moved_far_total, offTrack: r.far_from_metadata_total, missingSide: r.missing_side_total, }, }); } catch { // Swallow — the snap itself succeeded; the quality scan is a // post-flight check and shouldn't surface an error toast on top of // the snap's own status message. } } async function refreshQuality() { setQualityLoading(true); try { const r = await invoke("quality_report", { movedThresholdM: 50, // User-configurable via Settings → Quality. Default 30 m. Note: // tightening below ~25 m produces noise on curved/sparse metadata // polylines where the chord cuts a corner the asset is on. metadataThresholdM: qualityOffTrackM, // Send the loaded per-video metadata so the backend can flag assets // that drifted off the actual GPS track. Videos without metadata // are silently skipped — the check just doesn't fire for them. metadata: metadataByVideo, cap: 500, }); setQualityReport(r); } catch (e) { setStatus(`Quality report failed: ${e}`); } finally { setQualityLoading(false); } } // Set up step-through over a list of issue ids. Loads their coords and // zooms tight (z=18) to the first one — Prev/Next buttons in the panel // walk through the rest. async function viewQualityIssues(ids: number[], label: string) { if (ids.length === 0) return; try { const coordsRaw = await invoke< Array<{ id: number; lat: number | null; lng: number | null }> >("assets_coords_for_ids", { ids }); const coords = coordsRaw .filter( (c): c is { id: number; lat: number; lng: number } => c.lat !== null && c.lng !== null, ) .map((c) => ({ id: c.id, lat: c.lat, lng: c.lng })); if (coords.length === 0) { setStatus(`${label}: no coords found.`); return; } setQualityHighlight(ids); setQualityStep({ label, coords, idx: 0 }); setSelectedAssetId(coords[0].id); setViewport({ center: [coords[0].lng, coords[0].lat], zoom: 18 }); setStatus( coords.length === 1 ? `${label}: 1 issue (zoomed in).` : `${label}: 1 of ${coords.length} (use Next to step through).`, ); } catch (e) { setStatus(`View failed: ${e}`); } } function stepQualityBy(delta: number) { setQualityStep((prev) => { if (!prev || prev.coords.length === 0) return prev; const next = (prev.idx + delta + prev.coords.length) % prev.coords.length; const c = prev.coords[next]; setSelectedAssetId(c.id); setViewport({ center: [c.lng, c.lat], zoom: 18 }); setStatus( `${prev.label}: ${next + 1} of ${prev.coords.length}`, ); return { ...prev, idx: next }; }); } async function handleDistribute() { if (anchors.size === 0) return; // Snapshot anchor deltas from the current DB state vs the captured originals. type Anchor = { id: number; origLat: number; origLng: number; curLat: number; curLng: number; rowId: string; dlat: number; dlng: number; }; const anchorList: Anchor[] = []; for (const [id, info] of anchors) { const cur = filteredAssets.find((a) => a.id === id); if (!cur || cur.lat === null || cur.lng === null) continue; anchorList.push({ id, origLat: info.origLat, origLng: info.origLng, curLat: cur.lat, curLng: cur.lng, rowId: info.rowId, dlat: cur.lat - info.origLat, dlng: cur.lng - info.origLng, }); } if (anchorList.length === 0) { setStatus("No usable anchors (assets may have been deleted)."); return; } const moved = anchorList.filter( (a) => Math.abs(a.dlat) + Math.abs(a.dlng) > 1e-9, ); if (moved.length === 0) { setStatus( "Anchors haven't been moved yet. Drag the gold-ringed asset(s) to where they belong, then click Distribute.", ); return; } anchorList.sort((a, b) => naturalCompare(a.rowId, b.rowId)); const anchorIds = new Set(anchorList.map((a) => a.id)); // Build the corrections payload for every visible filtered asset that // isn't itself an anchor (anchors already moved, no need to re-translate). const targets = filteredAssets .filter( (a) => a.deleted === 0 && a.lat !== null && a.lng !== null && !anchorIds.has(a.id), ) .slice() .sort((a, b) => naturalCompare(a.row_id, b.row_id)); const corrections: Array<{ id: number; dlat: number; dlng: number }> = []; if (anchorList.length === 1) { const { dlat, dlng } = anchorList[0]; for (const t of targets) corrections.push({ id: t.id, dlat, dlng }); } else { // Piecewise linear interp by row_id ordering. Build a single sorted // sequence of {kind, rowId, payload} and assign each entry a rank. type Entry = | { kind: "anchor"; anchor: Anchor; rank: number } | { kind: "target"; asset: Asset; rank: number }; const merged: Entry[] = [ ...anchorList.map( (a) => ({ kind: "anchor", anchor: a, rank: 0 }) as Entry, ), ...targets.map( (t) => ({ kind: "target", asset: t, rank: 0 }) as Entry, ), ]; merged.sort((x, y) => { const xr = x.kind === "anchor" ? x.anchor.rowId : x.asset.row_id; const yr = y.kind === "anchor" ? y.anchor.rowId : y.asset.row_id; return naturalCompare(xr, yr); }); merged.forEach((e, i) => (e.rank = i)); const anchorByRank = merged.filter((e) => e.kind === "anchor") as Array< Extract >; for (const e of merged) { if (e.kind !== "target") continue; // Find bracketing anchors using row_id ordering (rank in the merged list). let left: (typeof anchorByRank)[number] | null = null; let right: (typeof anchorByRank)[number] | null = null; for (const a of anchorByRank) { if (a.rank <= e.rank) left = a; else { right = a; break; } } let dlat: number; let dlng: number; if (left && right) { // Interpolate by **geographic position** of the target projected // onto the line segment between the bracketing anchors' original // positions — i.e. along the road direction. This replaces the // previous rank-based tt, which over-corrected clustered targets // toward the next anchor regardless of how close they actually // were geographically. Latitude is scaled by cos(centre_lat) so // the projection isn't distorted by lat/lng's non-isotropic // metric at the dataset's latitude (Hyderabad ≈ 17°: 1° lng is // ~95 % of 1° lat there). const lx = left.anchor.origLng; const ly = left.anchor.origLat; const rx = right.anchor.origLng; const ry = right.anchor.origLat; const lat0 = ((ly + ry) / 2) * (Math.PI / 180); const cosLat = Math.cos(lat0); const tx = (e.asset.lng as number) - lx; const ty = (e.asset.lat as number) - ly; const sx = rx - lx; const sy = ry - ly; // Scale lng deltas by cosLat so the dot product is in metres²-equiv. const seg2 = sx * sx * cosLat * cosLat + sy * sy; let tt: number; if (seg2 > 0) { const dot = tx * sx * cosLat * cosLat + ty * sy; tt = Math.max(0, Math.min(1, dot / seg2)); } else { // Degenerate: anchors coincide. Fall back to left's offset. tt = 0; } dlat = left.anchor.dlat + (right.anchor.dlat - left.anchor.dlat) * tt; dlng = left.anchor.dlng + (right.anchor.dlng - left.anchor.dlng) * tt; } else { // Asset is outside the [firstAnchor, lastAnchor] span. Leave it alone // — multi-anchor mode only corrects what's between the bookends. continue; } corrections.push({ id: e.asset.id, dlat, dlng }); } } if (corrections.length === 0) { setStatus("Nothing to distribute (no other visible assets)."); return; } const ok = window.confirm( `Distribute correction across ${corrections.length} asset(s) using ${moved.length} anchor(s)?`, ); if (!ok) return; setBusy(true); try { const n = await invoke("bulk_translate", { corrections }); setStatus(`Distributed correction across ${n} asset(s). Use Undo to revert.`); setAnchors(new Map()); await refreshAfterMutation(); } catch (e) { setStatus(`Distribute failed: ${e}`); } finally { setBusy(false); } } async function handleAutoLink(splitBy: "side" | "video" | "none") { if (!lassoPolygonPoints || lassoPolygonPoints.length < 3) { setStatus("Use the polygon lasso to define a region first."); return; } setBusy(true); try { const res = await invoke<{ linked: number; leftover_a: number; leftover_b: number; }>("auto_link_in_polygon", { polygon: lassoPolygonPoints, splitBy, maxDistanceM: autoLinkMaxM, }); setStatus( `Auto-linked ${res.linked} pair(s) by ${splitBy}; leftover ${res.leftover_a} + ${res.leftover_b}. Right-click pairs to correct, then re-run to rebalance.`, ); // Keep lassoPolygonPoints + lassoSelected so the user can right-click to // correct individual pairs and then re-run auto-link to rebalance the // orphans. Locked manual links survive every re-run. await refreshAfterMutation(); } catch (e) { setStatus(`Auto-link failed: ${e}`); } finally { setBusy(false); } } async function handleSetLink(otherId: number) { if (!selectedAsset) return; setBusy(true); try { await invoke("set_link", { aId: selectedAsset.id, bId: otherId }); setStatus(`Linked ${selectedAsset.id} ↔ ${otherId} (locked)`); setLinkPickMode(false); await refreshAfterMutation(); } catch (e) { setStatus(`Set link failed: ${e}`); } finally { setBusy(false); } } async function handleClearLink() { if (!selectedAsset) return; setBusy(true); try { await invoke("clear_link", { id: selectedAsset.id }); setStatus(`Unlinked ${selectedAsset.id}`); await refreshAfterMutation(); } catch (e) { setStatus(`Clear link failed: ${e}`); } finally { setBusy(false); } } async function handleLassoChangeSide(side: "Left" | "Right") { if (!lassoSelected) return; setBusy(true); try { const n = await invoke("change_assets_side", { ids: lassoSelected.ids, side, }); setStatus(`Set ${n} asset(s) to ${side}`); setLassoSelected(null); await refreshAfterMutation(); } catch (e) { setStatus(`Side change failed: ${e}`); } finally { setBusy(false); } } async function handleLassoSetScope(inScope: boolean) { if (!lassoSelected) return; setBusy(true); try { const n = await invoke("set_assets_in_scope", { ids: lassoSelected.ids, inScope, }); setStatus( `Marked ${n} asset(s) as ${inScope ? "in-scope" : "out-of-scope"}`, ); setLassoSelected(null); setLassoMode(false); await refreshAfterMutation(); } catch (e) { setStatus(`Set scope failed: ${e}`); } finally { setBusy(false); } } async function handleLassoClearOverride() { if (!lassoSelected) return; setBusy(true); try { const n = await invoke("clear_manual_scope", { ids: lassoSelected.ids, }); setStatus(`Cleared manual scope on ${n} asset(s); re-derived from KML.`); setLassoSelected(null); setLassoMode(false); await refreshAfterMutation(); } catch (e) { setStatus(`Clear scope failed: ${e}`); } finally { setBusy(false); } } async function handleLassoDelete() { if (!lassoSelected) return; const ok = window.confirm( `Delete ${lassoSelected.ids.length} asset(s) inside the lasso?`, ); if (!ok) return; setBusy(true); try { const n = await invoke("delete_assets_bulk", { ids: lassoSelected.ids, }); setStatus(`Deleted ${n} asset(s) via lasso.`); setLassoSelected(null); setLassoMode(false); await refreshAfterMutation(); } catch (e) { setStatus(`Bulk delete failed: ${e}`); } finally { setBusy(false); } } async function handleFindDuplicates(restrictToBbox: boolean) { if (busy) return; setBusy(true); try { const args: Record = { epsM: dupEpsM, crossVideoOnly: dupCrossOnly, }; if (restrictToBbox && boundsRef.current) { args.minLat = boundsRef.current.south; args.maxLat = boundsRef.current.north; args.minLng = boundsRef.current.west; args.maxLng = boundsRef.current.east; } const list = await invoke( "find_duplicate_clusters", args, ); setDupClusters(list); setDupActive(list[0]?.id ?? null); const km = new Map(); for (const c of list) { // Default keep-pick: the most-recently-modified or just first. km.set(c.id, c.members[0].id); } setDupKeep(km); setStatus( list.length === 0 ? "No duplicate clusters found." : `Found ${list.length} duplicate cluster(s); ${list.reduce((s, c) => s + c.members.length, 0)} member(s).`, ); } catch (e) { setStatus(`Find duplicates failed: ${e}`); } finally { setBusy(false); } } async function handleResolveCluster( cluster: DupCluster, action: "delete" | "out_of_scope", ) { const keepId = dupKeep.get(cluster.id); if (keepId === undefined) return; const losers = cluster.members .filter((m) => m.id !== keepId) .map((m) => m.id); if (losers.length === 0) return; setBusy(true); try { if (action === "delete") { await invoke("delete_assets_bulk", { ids: losers }); } else { await invoke("set_assets_in_scope", { ids: losers, inScope: false, }); } setStatus( `Resolved cluster ${cluster.id} (${action}, ${losers.length} losers).`, ); setDupClusters((cs) => cs.filter((c) => c.id !== cluster.id)); await refreshAfterMutation(); } catch (e) { setStatus(`Resolve failed: ${e}`); } finally { setBusy(false); } } async function handleExport() { if (busy) return; try { // The dropdown next to the button is the source of truth for format. // We seed the Save dialog with the matching extension so the user // doesn't have to hunt for the file-filter menu inside it. const extByFmt: Record = { source: "json", geojson: "geojson", kml: "kml", csv: "csv", }; const wantExt = extByFmt[exportFormat]; const picked = await save({ defaultPath: `assets.${wantExt}`, filters: [{ name: exportFormat.toUpperCase(), extensions: [wantExt] }], }); if (typeof picked !== "string") return; // Auto-append the chosen extension if the user typed a bare name. let outPath = picked; if (!outPath.toLowerCase().endsWith(`.${wantExt}`)) { outPath = `${outPath}.${wantExt}`; } setBusy(true); const count = await invoke("export_assets", { path: outPath, format: exportFormat, }); setStatus(`Exported ${count} asset(s) to ${outPath}`); showToast(`Exported ${count} asset(s) (${exportFormat})`); } catch (e) { setStatus(`Export failed: ${e}`); showToast(`Export failed: ${e}`); } finally { setBusy(false); } } async function handleBulkSnap() { if (busy) return; if (!boundsRef.current) { setStatus("Pan/zoom the map first."); return; } const b = boundsRef.current; const ok = window.confirm( `Snap all visible fixed assets to the nearest OSM road (within 50 m)?\n\n` + `This is a single Undo-able action (one Undo reverts every snap).`, ); if (!ok) return; setBusy(true); try { const res = await invoke<{ snapped: number; skipped_far: number; skipped_no_geom: number; }>("snap_assets_in_bbox", { minLat: b.south, maxLat: b.north, minLng: b.west, maxLng: b.east, maxDistanceM: 50, offsetM: roadOffsetMeters, centerlineNames: Array.from(centerlineNames), metadataTrack: (() => { // If only one video is selected in the filters, use that video's // track. Otherwise fall back to the concatenated single track. const sel = Array.from(filters.videos); if (sel.length === 1 && metadataByVideo[sel[0]]?.length >= 2) { return metadataByVideo[sel[0]]; } return metadataTrack.length >= 2 ? metadataTrack : null; })(), }); setStatus( `Bulk snap: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`, ); await refreshAfterMutation(); await runPostSnapQualityCheck("Bulk snap (visible)"); } catch (e) { setStatus(`Bulk snap failed: ${e}`); } finally { setBusy(false); } } async function handleSnapToRoad() { if (!selectedAsset) return; setBusy(true); try { const useCenter = centerlineNames.has(selectedAsset.asset_name); const perVideo = metadataByVideo[selectedAsset.video_name]; const trackForAsset = perVideo && perVideo.length >= 2 ? perVideo : metadataTrack.length >= 2 ? metadataTrack : null; const [lat, lng] = await invoke<[number, number]>("snap_to_road", { assetId: selectedAsset.id, maxDistanceM: 50, offsetM: useCenter ? 0 : roadOffsetMeters, metadataTrack: trackForAsset, }); setStatus( `Snapped to road @ ${lat.toFixed(6)}, ${lng.toFixed(6)}`, ); await refreshAfterMutation(); await runPostSnapQualityCheck("Snap to road"); } catch (e) { setStatus(`Snap failed: ${e}`); } finally { setBusy(false); } } async function handleImportScope() { if (busy) return; setBusy(true); setStatus("Picking KML…"); try { const picked = await open({ multiple: false, directory: false, filters: [{ name: "KML", extensions: ["kml"] }], }); if (typeof picked !== "string") { setStatus(""); return; } setStatus("Parsing KML…"); const count = await invoke("import_kml_scope", { path: picked }); await refreshScopePolygons(); if (boundsRef.current) { await fetchBboxAssets(boundsRef.current); } setStatus(`Loaded ${count} scope polygon(s)`); showToast(`Loaded ${count} scope polygon(s)`); } catch (e) { setStatus(`KML import failed: ${e}`); showToast(`KML import failed`); } finally { setBusy(false); } } async function handleImportVideoMetadata() { if (busy) return; setBusy(true); setStatus("Picking per-video metadata JSON…"); try { const picked = await open({ multiple: false, directory: false, filters: [{ name: "Per-video metadata JSON", extensions: ["json"] }], }); if (typeof picked !== "string") { setStatus(""); return; } setStatus("Parsing video polylines…"); const map = await invoke>>( "read_video_polylines_json", { path: picked }, ); // Persist so subsequent app starts auto-load without a re-pick. try { await invoke("save_video_metadata", { tracks: map }); } catch (e) { console.warn("save_video_metadata failed", e); } setMetadataByVideo(map); const keys = Object.keys(map); // Concatenated fallback for callers that don't yet pass a video. const flat: Array<[number, number]> = []; for (const k of keys) flat.push(...map[k]); setMetadataTrack(flat); // Recenter the map on the metadata bbox so the user can actually see // what they just imported (otherwise the polylines may sit outside the // current viewport and look "missing"). if (flat.length > 0) { let minLng = Infinity, maxLng = -Infinity; let minLat = Infinity, maxLat = -Infinity; for (const [lng, lat] of flat) { if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng; if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; } setViewport({ center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2], zoom: 12, }); } setStatus( `Loaded metadata for ${keys.length} video(s). Toggle "Metadata polyline" in Layers if it's off. Snap & direction now use each asset's matching video track.`, ); } catch (e) { setStatus(`Video metadata import failed: ${e}`); } finally { setBusy(false); } } // Auto-detect importer for combined fixed+range JSON. Accepts either a // top-level array (rows can be mixed fixed and range) or the round-trip // export shape `{ "fixed": [...], "range": [...] }`. Each row's shape is // sniffed by the backend (presence of `start_coord_*` → range). async function handleImportAuto() { if (busy) return; setBusy(true); setStatus("Picking file…"); try { const picked = await open({ multiple: false, directory: false, filters: [{ name: "JSON", extensions: ["json"] }], }); if (typeof picked !== "string") { setStatus(""); return; } setStatus("Importing (auto-detect)…"); const r = await invoke<{ fixed: number; range: number }>( "import_assets_auto", { path: picked }, ); setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`); showToast(`Imported ${r.fixed + r.range} asset(s)`); await Promise.all([ refreshVideoNames(), refreshVideoEntries(), refreshAssetNames(), refreshClasses(), ]); if (boundsRef.current) { await fetchBboxAssets(boundsRef.current); } const all = await invoke("list_assets"); const first = all.find((a) => a.lat !== null && a.lng !== null); if (first) { setViewport({ center: [first.lng as number, first.lat as number], zoom: 13, }); } } catch (e) { setStatus(`Import failed: ${e}`); } finally { setBusy(false); } } async function handleImport(kind: "fixed" | "range") { if (busy) return; setBusy(true); setStatus("Picking file…"); try { const picked = await open({ multiple: false, directory: false, filters: [{ name: "JSON", extensions: ["json"] }], }); if (typeof picked !== "string") { setStatus(""); return; } setStatus("Importing…"); const cmd = kind === "fixed" ? "import_fixed_assets" : "import_range_assets"; const count = await invoke(cmd, { path: picked }); setStatus(`Imported ${count} ${kind}`); showToast(`Imported ${count} ${kind} asset(s)`); // Pull in the new video / class names so the filter auto-includes them. await Promise.all([ refreshVideoNames(), refreshVideoEntries(), refreshAssetNames(), refreshClasses(), ]); // Re-applied scope (server-side) means in_scope flags may have changed. if (boundsRef.current) { await fetchBboxAssets(boundsRef.current); } const all = await invoke("list_assets"); const first = all.find( (a) => a.asset_type === kind && a.lat !== null && a.lng !== null, ); if (first) { setViewport({ center: [first.lng as number, first.lat as number], zoom: 13, }); } } catch (e) { setStatus(`Import failed: ${e}`); } finally { setBusy(false); } } return (
{perfMode && } {osmFetchActive && } {snapQualityToast && (
{snapQualityToast.label} complete. {snapQualityToast.total === 0 ? "No quality issues." : `${snapQualityToast.total} quality issue${snapQualityToast.total === 1 ? "" : "s"} found ` + `(${snapQualityToast.breakdown.moved} moved · ` + `${snapQualityToast.breakdown.offTrack} off track · ` + `${snapQualityToast.breakdown.missingSide} no side)`}
)} {lensActive && cursorWorld && cursorScreen && (() => { // Place the lens cursor +24px / +24px by default; flip to the other // side when that would clip the viewport edge. Clamp to a small // margin so the lens never sits flush with the window edge. const dia = 240; const offset = 24; const margin = 8; const vw = typeof window !== "undefined" ? window.innerWidth : 1024; const vh = typeof window !== "undefined" ? window.innerHeight : 768; let left = cursorScreen.x + offset; let top = cursorScreen.y + offset; if (left + dia + margin > vw) { left = cursorScreen.x - dia - offset; } if (top + dia + margin > vh) { top = cursorScreen.y - dia - offset; } if (left < margin) left = margin; if (top < margin) top = margin; return (
); })()}
setBounds(b)} assets={filteredAssets} scopePolygons={scopePolygons} osmRoads={displayOsmRoads} metadataTrack={focusedVideo === null ? metadataTrack : []} metadataTracks={displayMetadataTracks} selectedMetadataVideo={selectedMetadataVideo} gotoPin={gotoPin} onCursorWorld={(lng, lat, x, y) => { if (!lensActive) return; if (!Number.isFinite(lng) || !Number.isFinite(lat)) { lensPendingRef.current = null; if (lensRafRef.current !== null) { cancelAnimationFrame(lensRafRef.current); lensRafRef.current = null; } setCursorWorld(null); setCursorScreen(null); return; } lensPendingRef.current = { lng, lat, x, y }; if (lensRafRef.current !== null) return; lensRafRef.current = requestAnimationFrame(() => { lensRafRef.current = null; const p = lensPendingRef.current; if (!p) return; setCursorWorld({ lng: p.lng, lat: p.lat }); setCursorScreen({ x: p.x, y: p.y }); }); }} markedVertices={markedVertices} onVertexShiftClick={(idx) => { setMarkedVertices((prev) => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }} onMetadataLineClick={(name) => { setSelectedMetadataVideo((cur) => { const next = cur === name ? null : name; setStatus(next === null ? "" : name); return next; }); }} layerVisibility={layerVisibility} selectedAsset={selectedAsset} multiSelectedIds={Array.from(multiSelectedIds)} onSelect={(a, additive) => { if (additive) { if (!a) return; setMultiSelectedIds((prev) => { const next = new Set(prev); if (next.has(a.id)) next.delete(a.id); else next.add(a.id); return next; }); setStatus(`Multi-select: ${a.asset_name} · ${a.row_id}`); return; } if (linkPickMode && a && selectedAsset && a.id !== selectedAsset.id) { handleSetLink(a.id); return; } setMultiSelectedIds(new Set()); setSelectedAssetId(a?.id ?? null); setSelectedLink(null); setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : ""); }} onLinkClick={(idA, idB) => { setSelectedLink([idA, idB]); setStatus( `Link #${idA} ↔ #${idB} selected — press Del to unlink, or click elsewhere to dismiss.`, ); }} onAssetRightClick={(a) => { if (rightClickFirstId === null) { setRightClickFirstId(a.id); setStatus( `Pair link: anchor #${a.id} (${a.asset_name}) — right-click another asset to link, or the same asset again to cancel.`, ); return; } if (rightClickFirstId === a.id) { setRightClickFirstId(null); setStatus("Pair link cancelled."); return; } const aId = rightClickFirstId; const bId = a.id; setRightClickFirstId(null); // If the two assets are already linked to each other, treat the // gesture as "unlink" instead of replacing/relocking. const anchor = filteredAssets.find((x) => x.id === aId); const partner = filteredAssets.find((x) => x.id === bId); const alreadyLinked = anchor?.link_pair_id === bId && partner?.link_pair_id === aId; if (alreadyLinked) { invoke("clear_link", { id: aId }) .then(() => setStatus(`Unlinked #${aId} ↔ #${bId}`)) .then(refreshAfterMutation) .catch((e) => setStatus(`Unlink failed: ${e}`)); return; } invoke("set_link", { aId, bId }) .then(() => setStatus(`Linked #${aId} ↔ #${bId} (locked)`)) .then(refreshAfterMutation) .catch((e) => setStatus(`Link failed: ${e}`)); }} onPositionChange={handlePositionChange} placeMode={placeMode} onPlaceAt={handlePlaceAt} imageFolder={imageFolder} popupEnabled={popupEnabled} bboxMode={bboxMode} bboxOverlay={bboxOverlay} onSideChange={handleSideChange} perfMode={perfMode} anchorIds={Array.from(anchors.keys())} colorBy={colorBy} osmEditMode={osmEditMode} editingRoadId={editingRoadId} selectedRoadIds={selectedRoadIds} multiSelectActive={focusedVideo !== null} onPickRoad={(id) => { // In single-video focus, left-click always toggles multi-select. // Vertex-edit tools appear automatically when the selection // narrows to a single road (see auto-derive effect below). if (focusedVideo !== null) { setSelectedRoadIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return; } setEditingRoadId(id); setStatus(`Editing road #${id}. Drag the gold vertex dots.`); }} onRoadRightClick={(id) => { // Behavior depends on whether a video is focused: // focusedVideo != null → hide for this video (non-destructive, // frontend-only, undoable via the unhide entry). // focusedVideo == null → real DB delete with undo via the // restore-roads upsert path. The explicit "Delete road" // button + Del-on-selected stay aligned with this. if (focusedVideo !== null) { setHiddenRoadIds((prev) => { const next = new Set(prev); next.add(id); return next; }); setSelectedRoadIds((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); return next; }); pushOsmUndo({ kind: "unhide", ids: [id], label: "hide road" }); setStatus(`Road #${id} hidden for "${focusedVideo}".`); return; } (async () => { try { await snapshotRoadsForUndo([id], "delete road (right-click)"); await invoke("delete_osm_road", { id }); setSelectedRoadIds((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); return next; }); if (editingRoadId === id) setEditingRoadId(null); setMarkedVertices(new Set()); await refreshOsmRoads(); setStatus(`Road #${id} deleted. Use Undo to bring it back.`); } catch (e) { setStatus(`Delete failed: ${e}`); } })(); }} onUpdateRoad={handleUpdateRoad} roadOffsetMeters={roadOffsetMeters} roadOffsetById={roadOffsetById} drawRoadMode={drawRoadMode} drawRoadCoords={drawRoadCoords} onDrawRoadAddPoint={handleDrawRoadAddPoint} lassoMode={lassoMode} lassoShape={lassoShape} onLassoComplete={handleLassoComplete} highlightedIds={ dupActive !== null ? (dupClusters.find((c) => c.id === dupActive)?.members.map( (m) => m.id, ) ?? []) : qualityHighlight.length > 0 ? qualityHighlight : (lassoSelected?.ids ?? []) } /> {compare && ( setBounds(b)} assets={filteredAssets} scopePolygons={scopePolygons} osmRoads={displayOsmRoads} metadataTrack={focusedVideo === null ? metadataTrack : []} metadataTracks={displayMetadataTracks} selectedMetadataVideo={selectedMetadataVideo} gotoPin={gotoPin} markedVertices={markedVertices} onVertexShiftClick={(idx) => { setMarkedVertices((prev) => { const next = new Set(prev); if (next.has(idx)) next.delete(idx); else next.add(idx); return next; }); }} onMetadataLineClick={(name) => { setSelectedMetadataVideo((cur) => { const next = cur === name ? null : name; setStatus(next === null ? "" : name); return next; }); }} layerVisibility={layerVisibility} selectedAsset={selectedAsset} multiSelectedIds={Array.from(multiSelectedIds)} onSelect={(a, additive) => { if (additive) { if (!a) return; setMultiSelectedIds((prev) => { const next = new Set(prev); if (next.has(a.id)) next.delete(a.id); else next.add(a.id); return next; }); return; } setMultiSelectedIds(new Set()); setSelectedAssetId(a?.id ?? null); setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : ""); }} onPositionChange={handlePositionChange} imageFolder={imageFolder} popupEnabled={popupEnabled} bboxMode={bboxMode} bboxOverlay={bboxOverlay} onSideChange={handleSideChange} perfMode={perfMode} anchorIds={Array.from(anchors.keys())} colorBy={ new Set(filteredAssets.map((a) => displayVideo(a.video_name))) .size <= 1 ? "asset" : "video" } /> )} {/* Floating "Fit" button — same action as the C shortcut. Tooltip still carries the wordy description; the button itself is just a map icon so it stays compact at the bottom of the map. */} {/* BBox mode quick-toggle — green when on, dark when off. Same as the Settings checkbox + 'B' shortcut. Disabled when popups themselves are off because there's nothing to render onto. */}
{toast !== null && (
{toast}
)}
{ if (window.confirm(`Logout ${currentUser.displayName}?`)) onLogout(); }} > {currentUser.displayName}
{filterPanelOpen ? (

Filters

Showing {filteredAssets.length} / {assets.length} loaded

setVisibilityOpen((v) => !v)} title="Click to expand/collapse advanced visibility options" > {visibilityOpen ? "▼" : "▶"} ⚙ Visibility

{visibilityOpen && ( <> )}

Asset type

{(["fixed", "range"] as const).map((t) => ( ))}

Side

{Array.from(counts.bySide.entries()) .sort() .map(([side, n]) => ( ))}

setVideosExpanded((v) => !v)} title="Click to expand/collapse" > {videosExpanded ? "▼" : "▶"} Videos — all {counts.byVideo.size} {filters.videos.size > 0 && ` (${filters.videos.size} picked)`}

{videosExpanded && ( <>
setVideoSearch(e.target.value)} autoComplete="off" style={{ width: "100%", padding: 4, boxSizing: "border-box", border: "1px solid #ccc", borderRadius: 3, fontSize: 11, marginBottom: 4, }} /> {(() => { // Merge: videoEntries is the authoritative ordered list from // the backend (number + completion). Names present only in // metadataByVideo (track loaded but no assets imported yet) // get pushed to the end with a synthetic number so the user // can still pick them. const known = new Set( videoEntries.map((e) => displayVideo(e.video_name)), ); const extras = Array.from( new Set(Object.keys(metadataByVideo)), ) .filter((v) => !known.has(displayVideo(v))) .sort() .map( (v, i): VideoEntry => ({ video_name: v, video_number: videoEntries.length + i + 1, completed: false, completed_at: null, completed_by: null, }), ); const all = videoEntries.concat(extras); const term = videoSearch.trim().toLowerCase(); const matchSearch = (e: VideoEntry) => { if (term === "") return true; const label = displayVideo(e.video_name).toLowerCase(); return ( label.includes(term) || String(e.video_number).includes(term) ); }; const active = all.filter((e) => !e.completed && matchSearch(e)); const completed = all.filter( (e) => e.completed && matchSearch(e), ); const renderRow = (e: VideoEntry) => { const label = displayVideo(e.video_name); const n = counts.byVideo.get(label) ?? 0; return ( ); }; return ( <>
{active.map(renderRow)} {active.length === 0 && (
(no active videos)
)}
{completed.length > 0 && (
setCompletedVideosExpanded((v) => !v) } title="Toggle the completed-videos section" > {completedVideosExpanded ? "▼" : "▶"} Completed ( {completed.length})
{completedVideosExpanded && (
{completed.map(renderRow)}
)}
)} ); })()} )}

setNamesExpanded((v) => !v)} title="Click to expand/collapse" > {namesExpanded ? "▼" : "▶"} Assets ({namesCounts.size}) {filters.names.size > 0 && ` (${filters.names.size} picked)`}

{namesExpanded && (
{Array.from(namesCounts.entries()) .sort((a, b) => a[0].localeCompare(b[0], undefined, { sensitivity: "base" }), ) .map(([name, n]) => ( ))} {namesCounts.size === 0 && (
No assets in current view.
)}
)}
) : ( )} {renameOpen && (
setRenameOpen(null)} >
e.stopPropagation()}>

Rename class ({renameOpen.ids.length})

{renameOpen.current && (
Current: {renameOpen.current}
)}

Pick existing class

{classNames.map((c) => ( ))} {classNames.length === 0 && ( No classes loaded. Add one below. )}

Or type a new class

setRenameInput(e.target.value)} placeholder="e.g. Crash_Barrier" style={{ width: "100%", padding: 6, boxSizing: "border-box", border: "1px solid #ccc", borderRadius: 4, fontSize: 13, }} />
)} {importsOpen && (
setImportsOpen(false)} >
e.stopPropagation()}>

Import data

Pick what to import. Each opens a file dialog.
)} {osmToolsOpen && (
setOsmToolsOpen(false)} >
e.stopPropagation()}>

OSM tools

Cleanup operations on the imported OSM road layer. Pick what to run, then Apply. Reversible by re-importing OSM.

OSM folder

{osmFolder ? ( {osmFolder} ) : ( not set — Generate / Export will prompt )}

Fetch & write

Prune small road classes

setOsmToolPruneClasses(e.target.value)} disabled={!osmToolPrune} style={{ width: "100%", padding: 6, boxSizing: "border-box", border: "1px solid #ccc", borderRadius: 4, fontSize: 12, marginTop: 4, }} />
)} {settingsOpen && (
setSettingsOpen(false)} >
e.stopPropagation()}>

Settings

Image popup

Quality thresholds

Diagnostics

Popup width

{(["S", "M", "L", "XL"] as PopupSize[]).map((s) => { const w = POPUP_WIDTH_PX[s]; const h = Math.round(w / POPUP_ASPECT_VAL[popupAspect]); return ( ); })}

Popup aspect ratio

{(["16:9", "4:3", "3:2", "1:1"] as PopupAspect[]).map((a) => ( ))}

Snap to centerline (instead of offset lane)

By default, "Snap to nearest road" moves assets to the LHS/RHS parallel offset line (controlled by Lane offset). Tick asset names below to snap them to the road centerline instead — e.g. kilometer stones or things that sit on the median.
{allAssetNames.length === 0 ? ( No assets loaded yet. ) : ( allAssetNames.map((name) => ( )) )}

Image folder (offline)

{imageFolder && ( <>
{imageFolder}
)}
)} {panelOpen && (

Basemap

{compare && ( )}

Go to

setGotoQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleGoto(); }} list="goto-video-options" autoComplete="off" style={{ flex: 1, padding: 4, border: "1px solid #ccc", borderRadius: 3, fontSize: 11, }} /> {Array.from( new Set(Object.keys(metadataByVideo).concat(allVideoNames)), ) .sort() .map((v) => (

Data

Loaded data
{([ { label: "Fixed assets", count: dataCounts.fixed_assets, onReplace: () => { setImportsOpen(false); handleImport("fixed"); }, onClear: async () => { if ( !window.confirm( `Permanently delete all ${dataCounts.fixed_assets} fixed asset(s)?`, ) ) return; try { const n = await invoke("clear_assets_by_type", { assetType: "fixed", }); setStatus(`Cleared ${n} fixed asset(s)`); await refreshAfterMutation(); } catch (e) { setStatus(`Clear failed: ${e}`); } }, }, { label: "Range assets", count: dataCounts.range_assets, onReplace: () => { setImportsOpen(false); handleImport("range"); }, onClear: async () => { if ( !window.confirm( `Permanently delete all ${dataCounts.range_assets} range asset(s)?`, ) ) return; try { const n = await invoke("clear_assets_by_type", { assetType: "range", }); setStatus(`Cleared ${n} range asset(s)`); await refreshAfterMutation(); } catch (e) { setStatus(`Clear failed: ${e}`); } }, }, { label: "OSM roads", count: dataCounts.osm_roads, onReplace: () => handleImportOsm(), onClear: async () => { if ( !window.confirm( `Permanently delete all ${dataCounts.osm_roads} OSM road(s)?`, ) ) return; try { const n = await invoke("clear_osm_roads"); setStatus(`Cleared ${n} OSM road(s)`); await refreshOsmRoads(); await refreshDataCounts(); } catch (e) { setStatus(`Clear failed: ${e}`); } }, }, { label: "KML scope", count: dataCounts.scope_features, onReplace: () => handleImportScope(), onClear: async () => { if ( !window.confirm( `Permanently delete all ${dataCounts.scope_features} scope feature(s)?`, ) ) return; try { const n = await invoke("clear_scope_polygons"); setStatus(`Cleared ${n} scope feature(s)`); await refreshScopePolygons(); await refreshAfterMutation(); } catch (e) { setStatus(`Clear failed: ${e}`); } }, }, { label: "Per-video metadata", count: Object.keys(metadataByVideo).length, onReplace: () => handleImportVideoMetadata(), onClear: async () => { try { const n = await invoke("clear_video_metadata"); setMetadataByVideo({}); setMetadataTrack([]); setSelectedMetadataVideo(null); await refreshDataCounts(); setStatus(`Cleared per-video metadata (${n} video(s))`); } catch (e) { setStatus(`Clear failed: ${e}`); } }, }, ] as const).map((row) => (
{row.label}:{" "} {row.count > 0 ? row.count.toLocaleString() : "—"}
))}
{osmEditMode && ( )} {osmEditMode && focusedVideo !== null && (
Bulk road tools — focus: {focusedVideo}
Click roads to multi-select · Right-click to hide one · Del to hide selected. Select exactly one road to edit / simplify its vertices. Hidden roads return when you exit OSM edit mode or change focused video.
Selected: {selectedRoadIds.size} · Hidden:{" "} {hiddenRoadIds.size}
)} {osmEditMode && editingRoadId !== null && ( <> {(() => { const cur = osmRoads.features.find( (f) => f.properties.id === editingRoadId, ); const ignored = cur?.properties.snap_ignored === true; return ( ); })()}
Bulk vertex tools
Shift-click vertices to mark (red ✕). Press{" "} Del to remove all marked.
Currently marked:{" "} {markedVertices.size} {markedVertices.size > 0 && ( )}
{focusedVideo === null && ( )} )} {osmEditMode && !drawRoadMode && ( )} {osmEditMode && drawRoadMode && ( <>
Drawing: {drawRoadCoords.length} vertex {drawRoadCoords.length === 1 ? "" : "es"}
{drawRoadCoords.length > 0 && ( )} )} {osmEditMode && (() => { // Roads the slider should affect: editing road + multi-selected // set. Empty → slider edits the default for all roads. const sliderIds = new Set(selectedRoadIds); if (editingRoadId !== null) sliderIds.add(editingRoadId); const hasSelection = sliderIds.size > 0; // Effective value to display. With a selection, prefer the first // selected road's override (falling back to default). Without one, // show the default. const firstId = hasSelection ? (Array.from(sliderIds)[0] as number) : null; const effective = firstId !== null ? (roadOffsetById.get(firstId) ?? roadOffsetMeters) : roadOffsetMeters; return ( ); })()} {anchors.size > 0 && (
{anchors.size} anchor(s) active. Drag any of the gold-ringed assets, then click Distribute to spread the GPS correction across every visible asset (1 anchor → uniform shift; 2+ → piecewise interp by row_id).
)}
{(["circle", "rect", "polygon"] as const).map((s) => ( ))}
{lassoSelected && (
{lassoListOpen && (
{lassoSelected.ids.map((id) => { const a = assets.find((x) => x.id === id); if (!a) return null; return (
{ if (a.lat !== null && a.lng !== null) { setViewport((v) => ({ center: [a.lng as number, a.lat as number], zoom: Math.max(v.zoom, 18), })); setSelectedAssetId(a.id); } }} title="Click to center map on this asset" > e.stopPropagation()} onChange={() => { setLassoSelected((s) => s ? { ...s, ids: s.ids.filter((x) => x !== id), } : s, ); }} /> {a.asset_name} {a.deleted ? " ⌫" : ""} {a.in_scope === 0 ? " ◌" : ""} {a.row_id.slice(-6)}
); })}
)}
{lassoPolygonPoints && lassoPolygonPoints.length >= 3 && ( <>
)}
)}

Layers

{( [ ["scope", "Scope (KML)"], ["osm", "OSM roads"], ["metadata", "Metadata polyline"], ["fixed", "Fixed assets"], ["range", "Range polylines"], ] as const ).map(([key, label]) => ( ))}
{scopePolygons.length > 0 && (
{scopePolygons.length} scope polygon(s) loaded
)} {status &&
{status}
}

Navigate ({navigableAssets.length})

{selectedAsset && ( <>

Selected

{selectedAsset.asset_type} · {selectedAsset.asset_name} {selectedAsset.side && ( {selectedAsset.side} )}
{selectedAsset.row_id}
video: {displayVideo(selectedAsset.video_name)}
Tip: arrow keys nudge selected by ~0.5 m.
{selectedAsset.link_pair_id !== null ? (
🔗 Linked to #{selectedAsset.link_pair_id} {(selectedAsset.link_locked ?? 0) === 1 ? " (locked)" : ""}
) : (
)}
{selectedAsset.deleted ? ( ) : ( )}
{(() => { const isAnchor = anchors.has(selectedAsset.id); return ( ); })()} )}
Merge range polylines
Primary {mergePrimary ? mergePrimary.row_id : "(empty)"}
Secondary {mergeSecondary ? mergeSecondary.row_id : "(empty)"}
Bulk delete by video setBulkVideoName(e.target.value)} autoComplete="off" style={{ width: "100%", padding: 6, boxSizing: "border-box", border: "1px solid #ccc", borderRadius: 4, fontSize: 13, marginBottom: 6, }} /> {/* Source from the DB-wide raw video list so offscreen videos still show up in suggestions. Empty raws are filtered (the input would display blank and confuse the user). Displayed text shows the raw with the displayVideo() label parenthesised when the two differ (e.g. "Not available (NA)") so the user can still recognise the collapsed bucket; the picked value is always the raw key. */} {allVideoNames .filter((v) => v.trim() !== "") .map((raw) => { const label = displayVideo(raw); const text = label !== raw ? `${raw} (${label})` : raw; return ( ); })}
Duplicates
ε: setDupEpsM(Math.max(0.1, parseFloat(e.target.value) || 3)) } style={{ width: 50, padding: "2px 4px", border: "1px solid #ccc", borderRadius: 3, fontSize: 11, }} /> m
{dupClusters.length > 0 && (
{dupClusters.map((c) => { const isActive = dupActive === c.id; const keepId = dupKeep.get(c.id); return (
{ setDupActive(c.id); const m = c.members[0]; if (m.lat !== null && m.lng !== null) { setViewport((v) => ({ center: [m.lng as number, m.lat as number], zoom: Math.max(v.zoom, 18), })); } }} > {c.asset_name} · {c.members.length} dup
{isActive && (
{c.members.map((m) => ( ))}
)}
); })}
)} {dupClusters.length > 0 && ( )}
Quality{qualityReport ? ` (${ qualityReport.moved_far_total + qualityReport.far_from_metadata_total + qualityReport.missing_side_total })` : ""}
{qualityHighlight.length > 0 && ( )}
{qualityStep && qualityStep.coords.length > 1 && (
{qualityStep.idx + 1} / {qualityStep.coords.length}
)} {qualityReport && (
{[ { key: "moved_far", label: "Moved > 50 m", count: qualityReport.moved_far_total, ids: qualityReport.moved_far, }, { key: "far_from_metadata", label: "Off metadata track", count: qualityReport.far_from_metadata_total, ids: qualityReport.far_from_metadata, hint: Object.keys(metadataByVideo).length === 0 ? "load metadata to enable" : null, }, { key: "missing_side", label: "Missing side", count: qualityReport.missing_side_total, ids: qualityReport.missing_side, }, ].map((r) => (
{r.label} {r.hint && ( ({r.hint}) )} 0 ? "#c0392b" : "#27ae60", }} > {r.count}
))}
)}

Recent actions

{recentActions.length === 0 ? (
No actions yet
) : (
    {recentActions.map((a) => (
  • {formatAction(a)}
    {a.undone ? ( ) : ( )}
  • ))}
)}
)}
); }