Files
KML-Verification/src/App.tsx
2026-05-26 17:01:23 +05:30

7057 lines
247 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PopupSize, number> = {
S: 220,
M: 330,
L: 440,
XL: 600,
};
type PopupAspect = "16:9" | "4:3" | "3:2" | "1:1";
const POPUP_ASPECT_VAL: Record<PopupAspect, number> = {
"16:9": 16 / 9,
"4:3": 4 / 3,
"3:2": 3 / 2,
"1:1": 1,
};
type Filters = {
videos: Set<string>; // empty = all
sides: Set<string>; // empty = all
types: Set<"fixed" | "range">; // empty = all
names: Set<string>; // 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<CurrentUser | null>(() => {
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 (
<LoginScreen
onLogin={(username, displayName) => {
const u = { username, displayName };
window.localStorage.setItem(USER_KEY, JSON.stringify(u));
setCurrentUser(u);
}}
/>
);
}
return <AppShell currentUser={currentUser} onLogout={() => {
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 (
<div
style={{
position: "fixed",
top: 8,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9000,
background: "rgba(0,0,0,0.75)",
color: fps >= 50 ? "#7CFC00" : fps >= 30 ? "#FFD700" : "#FF6B6B",
padding: "4px 10px",
borderRadius: 4,
fontFamily: "monospace",
fontSize: 12,
fontWeight: 700,
pointerEvents: "none",
}}
>
{fps} fps · perf
</div>
);
}
// 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 (
<div
style={{
position: "fixed",
bottom: 12,
right: 12,
zIndex: 9500,
background: "rgba(20, 20, 20, 0.8)",
color: "#fff",
padding: "6px 10px",
borderRadius: 4,
fontFamily: "system-ui, sans-serif",
fontSize: 12,
pointerEvents: "none",
}}
>
Fetching OSM {elapsed}s
</div>
);
}
function AppShell({
currentUser,
onLogout,
}: {
currentUser: CurrentUser;
onLogout: () => void;
}) {
const [viewport, setViewport] = useState<Viewport>(
IS_BROWSER_MODE
? { center: [78.347, 17.398], zoom: 14 }
: INITIAL_VIEWPORT,
);
const [bounds, setBounds] = useState<BoundsBox | null>(null);
const [primaryId, setPrimaryId] = useState<string>(DEFAULT_BASEMAP_ID);
const [secondaryId, setSecondaryId] = useState<string>("esri-imagery");
const [compare, setCompare] = useState<boolean>(false);
const [assets, setAssets] = useState<Asset[]>([]);
const [scopePolygons, setScopePolygons] = useState<ScopePolygon[]>([]);
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<ActionRow[]>([]);
const [status, setStatus] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(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<Set<number>>(
() => new Set(),
);
const [bulkVideoName, setBulkVideoName] = useState<string>("");
const [mergePrimary, setMergePrimary] = useState<Asset | null>(null);
const [mergeSecondary, setMergeSecondary] = useState<Asset | null>(null);
const [panelOpen, setPanelOpen] = useState<boolean>(true);
const [placeMode, setPlaceMode] = useState<boolean>(false);
const [imageFolder, setImageFolder] = useState<string | null>(() =>
typeof window !== "undefined"
? window.localStorage.getItem(IMAGE_FOLDER_KEY)
: null,
);
const OSM_FOLDER_KEY = "kml_map_tool.osm_folder";
const [osmFolder, setOsmFolder] = useState<string | null>(() =>
typeof window !== "undefined"
? window.localStorage.getItem(OSM_FOLDER_KEY)
: null,
);
const [osmRoads, setOsmRoads] = useState<OsmRoadFC>({
type: "FeatureCollection",
features: [],
});
const [metadataTrack, setMetadataTrack] = useState<Array<[number, number]>>([]);
// 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<string, Array<[number, number]>>
>({});
const [selectedMetadataVideo, setSelectedMetadataVideo] = useState<
string | null
>(null);
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>({
scope: true,
metadata: true,
osm: true,
fixed: true,
range: true,
});
const [settingsOpen, setSettingsOpen] = useState<boolean>(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<boolean>(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<number | null>(null);
useEffect(() => {
return () => {
if (lensRafRef.current !== null)
cancelAnimationFrame(lensRafRef.current);
};
}, []);
const [qualityOffTrackM, setQualityOffTrackM] = useState<number>(() => {
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<PopupSize>(() => {
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<boolean>(() => {
if (typeof window === "undefined") return true;
const v = window.localStorage.getItem(POPUP_ENABLED_KEY);
return v === null ? true : v === "1";
});
const [bboxMode, setBboxMode] = useState<boolean>(() => {
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<boolean>(() => {
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<PopupAspect>(() => {
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<boolean>(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(PERF_MODE_KEY) === "1";
});
const [osmFetchActive, setOsmFetchActive] = useState<boolean>(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<QualityReport | null>(
null,
);
const [qualityLoading, setQualityLoading] = useState<boolean>(false);
const [qualityHighlight, setQualityHighlight] = useState<number[]>([]);
// 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<Filters>({
videos: new Set(),
sides: new Set(),
types: new Set(),
names: new Set(),
});
const [videosExpanded, setVideosExpanded] = useState<boolean>(false);
const [namesExpanded, setNamesExpanded] = useState<boolean>(true);
const [filterPanelOpen, setFilterPanelOpen] = useState<boolean>(true);
const [videoSearch, setVideoSearch] = useState<string>("");
const [gotoQuery, setGotoQuery] = useState<string>("");
const [gotoPin, setGotoPin] = useState<{ lat: number; lng: number } | null>(
null,
);
const [markedVertices, setMarkedVertices] = useState<Set<number>>(new Set());
const [simplifyTolM, setSimplifyTolM] = useState<number>(2);
const [allAssetNames, setAllAssetNames] = useState<string[]>([]);
const [osmEditMode, setOsmEditMode] = useState<boolean>(false);
const [editingRoadId, setEditingRoadId] = useState<number | null>(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<Set<number>>(
new Set(),
);
const [hiddenRoadIds, setHiddenRoadIds] = useState<Set<number>>(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<OsmUndoEntry[]>([]);
const pushOsmUndo = (entry: OsmUndoEntry) => {
setOsmUndoStack((prev) => [...prev, entry].slice(-3));
};
async function snapshotRoadsForUndo(
ids: number[],
label: string,
): Promise<void> {
if (!osmEditMode || ids.length === 0) return;
try {
const snapshots = await invoke<OsmRoadSnap[]>("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<number>("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<boolean>(false);
const [drawRoadCoords, setDrawRoadCoords] = useState<
Array<[number, number]>
>([]);
const [lassoMode, setLassoMode] = useState<boolean>(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<boolean>(false);
const [rightClickFirstId, setRightClickFirstId] = useState<number | null>(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<number, { origLat: number; origLng: number; rowId: string }>
>(new Map());
const [autoLinkMaxM, setAutoLinkMaxM] = useState<number>(() => {
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<boolean>(false);
const [showDeleted, setShowDeleted] = useState<boolean>(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<boolean>(false);
const [showOutOfScope, setShowOutOfScope] = useState<boolean>(true);
const [showInScope, setShowInScope] = useState<boolean>(true);
const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false);
const [allVideoNames, setAllVideoNames] = useState<string[]>([]);
// 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<number>(() => {
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<DupCluster[]>([]);
const [dupActive, setDupActive] = useState<number | null>(null);
const [dupKeep, setDupKeep] = useState<Map<number, number>>(new Map());
const [dupCrossOnly, setDupCrossOnly] = useState<boolean>(false);
const [classNames, setClassNames] = useState<string[]>([]);
const [renameOpen, setRenameOpen] = useState<
null | { ids: number[]; current: string }
>(null);
const [renameInput, setRenameInput] = useState<string>("");
// 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<string | null>(null);
const toastTimerRef = useRef<number | null>(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<boolean>(false);
const [importsOpen, setImportsOpen] = useState<boolean>(false);
const [osmToolPrune, setOsmToolPrune] = useState<boolean>(true);
const [osmToolPruneClasses, setOsmToolPruneClasses] = useState<string>(
"service,residential,unclassified,footway,path,track,cycleway,pedestrian,living_street,busway",
);
const savedVisRef = useRef<LayerVisibility | null>(null);
const CENTERLINE_NAMES_KEY = "kml_map_tool.centerline_names";
const [centerlineNames, setCenterlineNames] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(CENTERLINE_NAMES_KEY);
return new Set<string>(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<number>(() => {
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<Map<number, number>>(
() => 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<string, number>();
const bySide = new Map<string, number>();
const byType = new Map<string, number>();
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<string, number>();
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<T>(set: Set<T>, value: T): Set<T> {
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<string>();
const names = new Set<string>();
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<string, string[]>();
const seen = new Set<string>();
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<string | null>(() => {
// 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<OsmRoadFC>(() => {
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<string>();
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<number | null>(null);
const fetchSeqRef = useRef<number>(0);
const boundsRef = useRef<BoundsBox | null>(null);
async function fetchBboxAssets(b: BoundsBox) {
const seq = ++fetchSeqRef.current;
const list = await invoke<Asset[]>("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<number>("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<number>("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<string, [number, number]> = {
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<number>("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<number>("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<ScopePolygon[]>("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<string[]>("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<VideoEntry[]>([]);
const [completedVideosExpanded, setCompletedVideosExpanded] =
useState<boolean>(false);
async function refreshVideoEntries() {
try {
const list = await invoke<VideoEntry[]>("list_videos");
setVideoEntries(list);
} catch {
/* ignore */
}
}
async function refreshVideoNames() {
try {
const raw = await invoke<string[]>("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<string[]>("list_classes");
setClassNames(list);
} catch {
/* ignore */
}
}
async function refreshActions() {
const list = await invoke<ActionRow[]>("list_recent_actions", { limit: 10 });
setRecentActions(list);
return list;
}
async function refreshAfterMutation() {
const tasks: Promise<unknown>[] = [
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<Record<string, Array<[number, number]>>>("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<Asset[]>("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<number>("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<string, unknown> = { 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<number>("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<string | null> {
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<number>("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<number>("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<Array<[number, number]>>(
"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<QualityReport>("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<QualityReport>("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<Entry, { kind: "anchor" }>
>;
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<number>("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<number>("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<number>("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<number>("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<number>("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<string, unknown> = {
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<DupCluster[]>(
"find_duplicate_clusters",
args,
);
setDupClusters(list);
setDupActive(list[0]?.id ?? null);
const km = new Map<number, number>();
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<number>("delete_assets_bulk", { ids: losers });
} else {
await invoke<number>("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<typeof exportFormat, string> = {
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<number>("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<number>("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<Record<string, Array<[number, number]>>>(
"read_video_polylines_json",
{ path: picked },
);
// Persist so subsequent app starts auto-load without a re-pick.
try {
await invoke<number>("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<Asset[]>("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<number>(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<Asset[]>("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 (
<div className="app">
{perfMode && <FpsCounter />}
{osmFetchActive && <OsmFetchIndicator />}
{snapQualityToast && (
<div
style={{
position: "fixed",
top: 12,
left: "50%",
transform: "translateX(-50%)",
zIndex: 9500,
background:
snapQualityToast.total === 0
? "rgba(39, 174, 96, 0.95)"
: "rgba(192, 57, 43, 0.95)",
color: "#fff",
padding: "10px 16px",
borderRadius: 6,
boxShadow: "0 4px 14px rgba(0,0,0,0.4)",
fontSize: 13,
fontFamily: "system-ui, sans-serif",
display: "flex",
alignItems: "center",
gap: 12,
maxWidth: "90vw",
}}
role="status"
>
<strong>{snapQualityToast.label} complete.</strong>
<span>
{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)`}
</span>
<button
style={{
background: "rgba(255,255,255,0.2)",
border: 0,
color: "#fff",
padding: "2px 8px",
borderRadius: 3,
fontSize: 12,
cursor: "pointer",
}}
onClick={() => setSnapQualityToast(null)}
aria-label="Dismiss"
>
×
</button>
</div>
)}
{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 (
<div
style={{
position: "fixed",
left,
top,
zIndex: 1000,
pointerEvents: "none",
}}
>
<LensView
basemapId={primaryId}
centerLng={cursorWorld.lng}
centerLat={cursorWorld.lat}
zoom={Math.min(22, viewport.zoom + 3)}
diameterPx={dia}
osmRoads={displayOsmRoads}
assets={filteredAssets}
metadataTrack={
focusedVideo !== null
? metadataByVideo[focusedVideo] ?? []
: metadataTrack
}
/>
</div>
);
})()}
<div className="map-area">
<MapView
basemapId={primaryId}
viewport={viewport}
onUserMove={setViewport}
onMoveEnd={({ bounds: b }) => 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 && (
<MapView
basemapId={secondaryId}
viewport={viewport}
onUserMove={setViewport}
onMoveEnd={({ bounds: b }) => 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.
*/}
<button
type="button"
onClick={() => fitToFocusOrAll()}
title={
focusedVideo
? `Fit map to "${focusedVideo}" (C)`
: "Fit map to all loaded data (C)"
}
aria-label={
focusedVideo
? `Fit map to ${focusedVideo}`
: "Fit map to all loaded data"
}
style={{
position: "absolute",
bottom: 16,
right: 16,
zIndex: 5,
width: 40,
height: 40,
padding: 0,
background: "rgba(44, 62, 80, 0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
borderRadius: "50%",
fontSize: 20,
lineHeight: 1,
cursor: "pointer",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
🗺
</button>
{/*
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.
*/}
<button
type="button"
onClick={() => {
if (!popupEnabled) {
setStatus("Enable image popup first to use BBox mode.");
return;
}
setBboxMode((v) => !v);
}}
title={
!popupEnabled
? "Enable image popup first to use BBox mode"
: bboxMode
? "BBox mode ON — drag to draw/move/resize bounding boxes. Click to turn off (B)"
: "Click to turn on BBox mode (B)"
}
aria-label="Toggle BBox mode"
style={{
position: "absolute",
bottom: 16,
right: 66,
zIndex: 5,
width: 40,
height: 40,
padding: 0,
background: bboxMode
? "rgba(39, 174, 96, 0.95)"
: "rgba(44, 62, 80, 0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
borderRadius: "50%",
fontSize: 13,
fontWeight: 700,
lineHeight: 1,
cursor: popupEnabled ? "pointer" : "not-allowed",
opacity: popupEnabled ? 1 : 0.5,
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
</button>
</div>
{toast !== null && (
<div
// Bottom-center transient confirmation. Fixed position so it
// overlays everything; pointerEvents:none so it never blocks
// clicks on whatever's behind it.
style={{
position: "fixed",
left: "50%",
bottom: 24,
transform: "translateX(-50%)",
zIndex: 9999,
background: "rgba(44, 62, 80, 0.95)",
color: "#fff",
padding: "8px 16px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 4px 12px rgba(0,0,0,0.35)",
pointerEvents: "none",
}}
>
{toast}
</div>
)}
<button
className="panel-toggle"
onClick={() => setPanelOpen((v) => !v)}
aria-label={panelOpen ? "Hide panel" : "Show panel"}
title={panelOpen ? "Hide panel" : "Show panel"}
>
{panelOpen ? "✕" : "☰"}
</button>
<button
className="gear-btn"
onClick={() => setSettingsOpen(true)}
aria-label="Settings"
title="Settings"
>
</button>
<div
className="user-chip"
title={`Logged in as ${currentUser.displayName} (@${currentUser.username}). Click to logout.`}
onClick={() => {
if (window.confirm(`Logout ${currentUser.displayName}?`)) onLogout();
}}
>
{currentUser.displayName}
</div>
{filterPanelOpen ? (
<div className="filter-panel">
<button
className="panel-toggle filter-close"
onClick={() => setFilterPanelOpen(false)}
title="Hide filters"
>
</button>
<h3>Filters</h3>
<div className="filter-summary">
Showing {filteredAssets.length} / {assets.length} loaded
<button
type="button"
onClick={clearFilters}
disabled={
filters.videos.size +
filters.sides.size +
filters.types.size +
filters.names.size ===
0
}
style={{
marginLeft: 8,
padding: "1px 6px",
fontSize: 10,
background: "#ecf0f1",
border: "1px solid #ccc",
borderRadius: 3,
cursor: "pointer",
}}
>
Clear
</button>
</div>
<h4
style={{ cursor: "pointer", userSelect: "none" }}
onClick={() => setVisibilityOpen((v) => !v)}
title="Click to expand/collapse advanced visibility options"
>
{visibilityOpen ? "▼" : "▶"} Visibility
</h4>
{visibilityOpen && (
<>
<label className="filter-row">
<input
type="checkbox"
checked={showInScope}
onChange={(e) => setShowInScope(e.target.checked)}
/>
<span className="label">Show in-scope</span>
</label>
<label className="filter-row">
<input
type="checkbox"
checked={showOutOfScope}
onChange={(e) => setShowOutOfScope(e.target.checked)}
/>
<span className="label">Show out-of-scope</span>
</label>
<label className="filter-row">
<input
type="checkbox"
checked={showDeleted}
disabled={onlyDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
/>
<span className="label">Show deleted</span>
</label>
<label className="filter-row">
<input
type="checkbox"
checked={onlyDeleted}
onChange={(e) => setOnlyDeleted(e.target.checked)}
/>
<span className="label">Only deleted</span>
</label>
</>
)}
<h4>Asset type</h4>
{(["fixed", "range"] as const).map((t) => (
<label key={t} className="filter-row">
<input
type="checkbox"
checked={filters.types.has(t)}
onChange={() =>
setFilters((f) => ({ ...f, types: toggleSet(f.types, t) }))
}
/>
<span className="label">{t}</span>
<span className="count">{counts.byType.get(t) ?? 0}</span>
</label>
))}
<h4>Side</h4>
{Array.from(counts.bySide.entries())
.sort()
.map(([side, n]) => (
<label key={side} className="filter-row">
<input
type="checkbox"
checked={filters.sides.has(side)}
onChange={() =>
setFilters((f) => ({
...f,
sides: toggleSet(f.sides, side),
}))
}
/>
<span className="label">{side}</span>
<span className="count">{n}</span>
</label>
))}
<h4
style={{ cursor: "pointer", userSelect: "none" }}
onClick={() => setVideosExpanded((v) => !v)}
title="Click to expand/collapse"
>
{videosExpanded ? "▼" : "▶"} Videos all {counts.byVideo.size}
{filters.videos.size > 0 && ` (${filters.videos.size} picked)`}
</h4>
{videosExpanded && (
<>
<div style={{ display: "flex", gap: 4, marginBottom: 4 }}>
<button
type="button"
onClick={() =>
setFilters((f) => ({
...f,
videos: new Set(allVideoNames),
}))
}
style={{
flex: 1,
padding: "2px 6px",
fontSize: 10,
background: "#ecf0f1",
border: "1px solid #ccc",
borderRadius: 3,
cursor: "pointer",
}}
>
Select all
</button>
<button
type="button"
onClick={() =>
setFilters((f) => ({ ...f, videos: new Set() }))
}
style={{
flex: 1,
padding: "2px 6px",
fontSize: 10,
background: "#ecf0f1",
border: "1px solid #ccc",
borderRadius: 3,
cursor: "pointer",
}}
>
Deselect all
</button>
</div>
<input
type="text"
placeholder="Search videos…"
list="video-name-options"
value={videoSearch}
onChange={(e) => 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 (
<label key={e.video_name} className="filter-row">
<input
type="checkbox"
checked={filters.videos.has(label)}
onChange={() =>
setFilters((f) => ({
...f,
videos: toggleSet(f.videos, label),
}))
}
/>
<span
className="label"
title={
e.completed && e.completed_by
? `${e.video_name}\nCompleted by ${e.completed_by}${
e.completed_at ? ` on ${e.completed_at}` : ""
}`
: e.video_name
}
style={
e.completed
? { textDecoration: "line-through", opacity: 0.7 }
: undefined
}
>
<span style={{ color: "#7f8c8d", marginRight: 4 }}>
#{e.video_number}
</span>
{label}
</span>
<span className="count">{n}</span>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
invoke("set_video_completed", {
videoName: e.video_name,
completed: !e.completed,
})
.then(() => refreshVideoEntries())
.catch((err) =>
setStatus(`Mark complete failed: ${err}`),
);
}}
title={
e.completed
? "Click to mark as not complete"
: "Mark this video as reviewed"
}
style={{
marginLeft: 4,
padding: "0 6px",
fontSize: 12,
border: "1px solid #ccc",
borderRadius: 3,
background: e.completed ? "#27ae60" : "#fff",
color: e.completed ? "#fff" : "#27ae60",
cursor: "pointer",
}}
>
</button>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
flyToVideo(label);
}}
title={`Fly to ${label}`}
style={{
marginLeft: 4,
padding: "0 6px",
fontSize: 11,
border: "1px solid #ccc",
borderRadius: 3,
background: "#fff",
cursor: "pointer",
}}
>
</button>
</label>
);
};
return (
<>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{active.map(renderRow)}
{active.length === 0 && (
<div
style={{
fontSize: 11,
color: "#7f8c8d",
padding: 4,
}}
>
(no active videos)
</div>
)}
</div>
{completed.length > 0 && (
<div style={{ marginTop: 6 }}>
<div
style={{
cursor: "pointer",
userSelect: "none",
fontSize: 12,
fontWeight: 600,
color: "#27ae60",
padding: "4px 0",
}}
onClick={() =>
setCompletedVideosExpanded((v) => !v)
}
title="Toggle the completed-videos section"
>
{completedVideosExpanded ? "▼" : "▶"} Completed (
{completed.length})
</div>
{completedVideosExpanded && (
<div
style={{
maxHeight: 200,
overflowY: "auto",
borderTop: "1px dashed #ccc",
paddingTop: 4,
}}
>
{completed.map(renderRow)}
</div>
)}
</div>
)}
</>
);
})()}
</>
)}
<h4
style={{ cursor: "pointer", userSelect: "none" }}
onClick={() => setNamesExpanded((v) => !v)}
title="Click to expand/collapse"
>
{namesExpanded ? "▼" : "▶"} Assets ({namesCounts.size})
{filters.names.size > 0 && ` (${filters.names.size} picked)`}
</h4>
{namesExpanded && (
<div style={{ maxHeight: 280, overflowY: "auto" }}>
{Array.from(namesCounts.entries())
.sort((a, b) =>
a[0].localeCompare(b[0], undefined, { sensitivity: "base" }),
)
.map(([name, n]) => (
<label key={name} className="filter-row">
<input
type="checkbox"
checked={filters.names.has(name)}
onChange={() =>
setFilters((f) => ({
...f,
names: toggleSet(f.names, name),
}))
}
/>
<span className="label" title={name}>
{name}
</span>
<span className="count">{n}</span>
</label>
))}
{namesCounts.size === 0 && (
<div className="filter-summary">No assets in current view.</div>
)}
</div>
)}
</div>
) : (
<button
className="filter-open-btn"
onClick={() => setFilterPanelOpen(true)}
title="Show filters"
>
Filters
</button>
)}
{renameOpen && (
<div
className="modal-backdrop"
onClick={() => setRenameOpen(null)}
>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<button
className="modal-close"
onClick={() => setRenameOpen(null)}
>
</button>
<h2>Rename class ({renameOpen.ids.length})</h2>
{renameOpen.current && (
<div className="status">
Current: <code>{renameOpen.current}</code>
</div>
)}
<h4>Pick existing class</h4>
<div
style={{
maxHeight: 220,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 4,
padding: 4,
}}
>
{classNames.map((c) => (
<label key={c} className="filter-row">
<input
type="radio"
name="rename-pick"
checked={renameInput === c}
onChange={() => setRenameInput(c)}
/>
<span className="label">{c}</span>
</label>
))}
{classNames.length === 0 && (
<small style={{ opacity: 0.7 }}>
No classes loaded. Add one below.
</small>
)}
</div>
<h4>Or type a new class</h4>
<input
type="text"
value={renameInput}
onChange={(e) => 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,
}}
/>
<div
style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}
>
<button
className="primary-btn"
style={{ flex: 1, background: "#27ae60" }}
onClick={async () => {
const name = renameInput.trim();
if (!name) return;
try {
const isNewClass = !classNames.includes(name);
if (isNewClass) {
await invoke("add_class", { name });
}
await invoke<number>("rename_assets", {
ids: renameOpen.ids,
assetName: name,
});
setStatus(
`Renamed ${renameOpen.ids.length} asset(s) → ${name}`,
);
showToast(
renameOpen.ids.length === 1
? `Renamed → ${name}`
: `Renamed ${renameOpen.ids.length}${name}`,
);
setRenameOpen(null);
setRenameInput("");
// Surgical update so scroll position / selection / list
// panels don't churn: just patch asset_name on the renamed
// rows in the existing array, and append the new class
// locally if there is one. The audit log / counts / video
// panels are refreshed in the background — they don't
// affect the user's place in the UI.
const renamedIds = new Set(renameOpen.ids);
setAssets((prev) =>
prev.map((a) =>
renamedIds.has(a.id) ? { ...a, asset_name: name } : a,
),
);
if (isNewClass) {
setClassNames((prev) =>
prev.includes(name) ? prev : [...prev, name].sort(),
);
}
void Promise.all([
refreshActions().catch(() => {}),
refreshAssetNames().catch(() => {}),
refreshDataCounts().catch(() => {}),
]);
} catch (e) {
setStatus(`Rename failed: ${e}`);
}
}}
disabled={busy || renameInput.trim() === ""}
>
Apply
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#7f8c8d" }}
onClick={() => setRenameOpen(null)}
>
Cancel
</button>
</div>
</div>
</div>
)}
{importsOpen && (
<div
className="modal-backdrop"
onClick={() => setImportsOpen(false)}
>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<button
className="modal-close"
onClick={() => setImportsOpen(false)}
>
</button>
<h2>Import data</h2>
<div className="status" style={{ marginTop: 0 }}>
Pick what to import. Each opens a file dialog.
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 8 }}>
<button
className="primary-btn"
style={{ background: "#16a085" }}
onClick={() => {
setImportsOpen(false);
handleImportAuto();
}}
disabled={busy}
title="Import a combined JSON file containing fixed and/or range rows. Rows are dispatched per-shape: any row with start_coord_* + end_coord_* is treated as range, others as fixed. Also accepts the round-trip export object {fixed: [...], range: [...]}."
>
Combined assets (auto-detect)
</button>
<button
className="primary-btn"
onClick={() => {
setImportsOpen(false);
handleImport("fixed");
}}
disabled={busy}
title="Import a JSON file where every row is a fixed asset."
>
Fixed assets (JSON)
</button>
<button
className="primary-btn"
onClick={() => {
setImportsOpen(false);
handleImport("range");
}}
disabled={busy}
title="Import a JSON file where every row is a range asset (start/mid/end + 3 images)."
>
Range assets (JSON)
</button>
<button
className="primary-btn"
style={{ background: "#8e44ad" }}
onClick={() => {
setImportsOpen(false);
handleImportScope();
}}
disabled={busy}
title="Import a KML scope polygon or polyline (geometric only)."
>
KML scope
</button>
<button
className="primary-btn"
style={{ background: "#9b59b6" }}
onClick={() => {
setImportsOpen(false);
handleImportVideoMetadata();
}}
disabled={busy}
title="Import per-video metadata polylines ({video_name: [[[lat,lng],...]]}). Snap & direction inference will use each asset's matching video track."
>
Per-video metadata (JSON)
</button>
<button
className="primary-btn"
style={{ background: "#34495e" }}
onClick={() => {
setImportsOpen(false);
handleImportOsm();
}}
disabled={busy}
>
OSM roads (GeoJSON)
</button>
<button
className="primary-btn"
style={{ background: "#5d6d7e" }}
onClick={() => {
setImportsOpen(false);
handleLoadMetadataPolyline();
}}
disabled={busy}
>
Metadata polyline (JSON)
</button>
</div>
</div>
</div>
)}
{osmToolsOpen && (
<div
className="modal-backdrop"
onClick={() => setOsmToolsOpen(false)}
>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<button
className="modal-close"
onClick={() => setOsmToolsOpen(false)}
>
</button>
<h2>OSM tools</h2>
<div className="status" style={{ marginTop: 0 }}>
Cleanup operations on the imported OSM road layer. Pick what to
run, then Apply. Reversible by re-importing OSM.
</div>
<h4>OSM folder</h4>
<div
className="status"
style={{ marginTop: 0, fontSize: 11 }}
>
{osmFolder ? (
<code>{osmFolder}</code>
) : (
<em>not set Generate / Export will prompt</em>
)}
</div>
<button
className="primary-btn"
style={{ marginTop: 4, fontSize: 11 }}
onClick={pickOsmFolder}
>
{osmFolder ? "Change OSM folder…" : "Set OSM folder…"}
</button>
<h4>Fetch & write</h4>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<button
className="primary-btn"
style={{ flex: 1, background: "#16a085", fontSize: 11 }}
onClick={() => {
setOsmToolsOpen(false);
handleGenerateOsm();
}}
disabled={busy}
title="Fetch OSM ways from Overpass for the imported assets' bbox (with a small buffer for nearby highways). Falls back to the visible map area when no assets are loaded."
>
Generate roads KML
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#34495e", fontSize: 11 }}
onClick={() => {
setOsmToolsOpen(false);
handleExportOsm();
}}
disabled={busy}
title="Dump the current roads table (with edits) as GeoJSON to your OSM folder for sharing"
>
Export current roads
</button>
</div>
<h4>Prune small road classes</h4>
<label className="filter-row">
<input
type="checkbox"
checked={osmToolPrune}
onChange={(e) => setOsmToolPrune(e.target.checked)}
/>
<span className="label">
Delete ways whose <code>highway</code> is in:
</span>
</label>
<input
type="text"
value={osmToolPruneClasses}
onChange={(e) => setOsmToolPruneClasses(e.target.value)}
disabled={!osmToolPrune}
style={{
width: "100%",
padding: 6,
boxSizing: "border-box",
border: "1px solid #ccc",
borderRadius: 4,
fontSize: 12,
marginTop: 4,
}}
/>
<div style={{ display: "flex", gap: 6, marginTop: 12 }}>
<button
className="primary-btn"
style={{ flex: 1, background: "#27ae60" }}
onClick={applyOsmTools}
disabled={busy}
>
Apply
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#7f8c8d" }}
onClick={() => setOsmToolsOpen(false)}
>
Cancel
</button>
</div>
</div>
</div>
)}
{settingsOpen && (
<div
className="modal-backdrop"
onClick={() => setSettingsOpen(false)}
>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<button
className="modal-close"
onClick={() => setSettingsOpen(false)}
>
</button>
<h2>Settings</h2>
<h4>Image popup</h4>
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input
type="checkbox"
checked={popupEnabled}
onChange={(e) => setPopupEnabled(e.target.checked)}
/>
Show image popup when an asset is selected
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
}}
title="Render the saved bounding box on top of the image. Read-only by itself — flip BBox mode below to edit."
>
<input
type="checkbox"
checked={bboxOverlay}
onChange={(e) => setBboxOverlay(e.target.checked)}
disabled={!popupEnabled}
/>
Show bbox overlay on images
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
}}
title="When ON, the popup shows the full image and overlays an editable bounding box. Drag a corner/edge to resize, drag inside to move, drag empty space to draw new."
>
<input
type="checkbox"
checked={bboxMode}
onChange={(e) => setBboxMode(e.target.checked)}
disabled={!popupEnabled || !bboxOverlay}
/>
BBox mode (edit bounding boxes on images)
</label>
<button
type="button"
onClick={async () => {
try {
const { save } = await import(
"@tauri-apps/plugin-dialog"
);
const p = await save({
title: "Export bboxes to JSON",
defaultPath: "bboxes.json",
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!p) return;
const n = await invoke<number>("export_bboxes_to_json", {
path: p,
});
setStatus(`Exported ${n} bbox(es) to ${p}`);
} catch (e) {
setStatus(`Export failed: ${e}`);
}
}}
style={{
marginTop: 6,
fontSize: 12,
padding: "4px 8px",
border: "1px solid #16a085",
background: "#fff",
color: "#16a085",
borderRadius: 3,
cursor: "pointer",
}}
title="Write every bbox row to a sidecar JSON file. Source JSONs are never modified."
>
Export bboxes to JSON
</button>
<h4 style={{ marginTop: 12 }}>Quality thresholds</h4>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
}}
title="Off-track flag fires when an asset's perpendicular distance to its video's metadata polyline exceeds this. Lower = stricter; below ~25 m starts producing noise on curved/sparse polylines."
>
<span style={{ minWidth: 130 }}>Off-track distance:</span>
<input
type="number"
min={5}
max={200}
step={1}
value={qualityOffTrackM}
onChange={(e) => {
const n = parseFloat(e.target.value);
if (Number.isFinite(n) && n > 0) setQualityOffTrackM(n);
}}
style={{ width: 70 }}
/>
<span>m</span>
<button
type="button"
onClick={() => setQualityOffTrackM(30)}
style={{
marginLeft: "auto",
fontSize: 11,
padding: "2px 8px",
}}
>
Reset to 30 m
</button>
</label>
<h4 style={{ marginTop: 12 }}>Diagnostics</h4>
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input
type="checkbox"
checked={perfMode}
onChange={(e) => setPerfMode(e.target.checked)}
/>
Performance mode: while panning/zooming, show only basemap +
asset markers + range paths (hides roads, scope, metadata, links,
labels). Diagnostic leave off for normal use.
</label>
<h4 style={{ marginTop: 12 }}>Popup width</h4>
<div className="size-picker">
{(["S", "M", "L", "XL"] as PopupSize[]).map((s) => {
const w = POPUP_WIDTH_PX[s];
const h = Math.round(w / POPUP_ASPECT_VAL[popupAspect]);
return (
<button
key={s}
className={popupSize === s ? "active" : ""}
onClick={() => setPopupSize(s)}
disabled={!popupEnabled}
>
{s}
<br />
<small style={{ fontSize: 10, opacity: 0.7 }}>
{w}×{h}
</small>
</button>
);
})}
</div>
<h4 style={{ marginTop: 12 }}>Popup aspect ratio</h4>
<div className="size-picker">
{(["16:9", "4:3", "3:2", "1:1"] as PopupAspect[]).map((a) => (
<button
key={a}
className={popupAspect === a ? "active" : ""}
onClick={() => setPopupAspect(a)}
disabled={!popupEnabled}
>
{a}
</button>
))}
</div>
<h4>Snap to centerline (instead of offset lane)</h4>
<div className="status" style={{ marginTop: 0 }}>
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.
</div>
<div
style={{
maxHeight: 220,
overflowY: "auto",
marginTop: 6,
border: "1px solid #ddd",
borderRadius: 4,
padding: 6,
}}
>
{allAssetNames.length === 0 ? (
<small style={{ opacity: 0.7 }}>
No assets loaded yet.
</small>
) : (
allAssetNames.map((name) => (
<label key={name} className="filter-row">
<input
type="checkbox"
checked={centerlineNames.has(name)}
onChange={() =>
setCenterlineNames((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
})
}
/>
<span className="label" title={name}>
{name}
</span>
</label>
))
)}
</div>
<h4>Image folder (offline)</h4>
<button
className="primary-btn"
onClick={handleSetImageFolder}
disabled={busy}
>
{imageFolder ? "Change image folder…" : "Set image folder…"}
</button>
{imageFolder && (
<>
<div className="status" style={{ marginTop: 4 }}>
<code style={{ fontSize: 11 }}>{imageFolder}</code>
</div>
<button
className="primary-btn"
style={{ marginTop: 6, background: "#7f8c8d" }}
onClick={handleClearImageFolder}
>
Use image_path as-is
</button>
</>
)}
</div>
</div>
)}
{panelOpen && (
<div className="control-panel">
<h3>Basemap</h3>
<label className="field">
<span>{compare ? "Primary" : "Active"}</span>
<select
value={primaryId}
onChange={(e) => setPrimaryId(e.target.value)}
>
{BASEMAPS.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
{compare && (
<label className="field">
<span>Secondary</span>
<select
value={secondaryId}
onChange={(e) => setSecondaryId(e.target.value)}
>
{BASEMAPS.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
)}
<label className="field checkbox">
<input
type="checkbox"
checked={compare}
onChange={(e) => setCompare(e.target.checked)}
/>
<span>Compare basemaps (side-by-side)</span>
</label>
<h3 style={{ marginTop: 16 }}>Go to</h3>
<div style={{ display: "flex", gap: 4 }}>
<input
type="text"
placeholder="lat, lng — or video name"
value={gotoQuery}
onChange={(e) => 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,
}}
/>
<datalist id="goto-video-options">
{Array.from(
new Set(Object.keys(metadataByVideo).concat(allVideoNames)),
)
.sort()
.map((v) => (
<option key={v} value={v} />
))}
</datalist>
<button
className="primary-btn"
style={{ padding: "2px 8px", fontSize: 11 }}
onClick={handleGoto}
disabled={busy || gotoQuery.trim() === ""}
>
Go
</button>
</div>
<h3 style={{ marginTop: 16 }}>Data</h3>
<button
className="primary-btn"
onClick={() => setImportsOpen(true)}
disabled={busy}
>
Import
</button>
<div
style={{
marginTop: 6,
padding: 6,
background: "#f6f8fa",
border: "1px solid #d0d7de",
borderRadius: 4,
fontSize: 11,
}}
>
<div style={{ fontWeight: 600, marginBottom: 4 }}>Loaded data</div>
{([
{
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<number>("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<number>("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<number>("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<number>("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<number>("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) => (
<div
key={row.label}
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginBottom: 2,
}}
>
<span style={{ flex: 1 }}>
{row.label}:{" "}
<strong>
{row.count > 0 ? row.count.toLocaleString() : "—"}
</strong>
</span>
<button
className="primary-btn"
style={{
padding: "2px 6px",
fontSize: 10,
background: "#0969da",
}}
onClick={row.onReplace}
disabled={busy}
title={row.count > 0 ? "Replace with a new file" : "Add"}
>
{row.count > 0 ? "Replace…" : "Add…"}
</button>
<button
className="primary-btn"
style={{
padding: "2px 6px",
fontSize: 10,
background: "#cf222e",
}}
onClick={row.onClear}
disabled={busy || row.count === 0}
title="Permanently remove this data source"
>
Clear
</button>
</div>
))}
</div>
<button
className="primary-btn"
style={{
marginTop: 6,
background: osmEditMode ? "#e67e22" : "#2c3e50",
}}
onClick={toggleOsmEditMode}
disabled={busy}
title="Drag OSM road vertices to fix the road network. Other layers are hidden while in this mode."
>
{osmEditMode ? "Exit OSM edit mode" : "Edit OSM roads"}
</button>
<button
className="primary-btn"
style={{
marginTop: 6,
background: lensActive ? "#16a085" : "#34495e",
}}
onClick={() => {
setLensActive((v) => {
if (v) {
setCursorWorld(null);
setCursorScreen(null);
}
return !v;
});
}}
title="Magnifier: while on, hover the map to see a circular zoomed view around the cursor with OSM roads + assets + metadata polyline. Press ESC to exit."
>
{lensActive ? "Exit magnifier" : "🔍 Magnifier"}
</button>
{osmEditMode && (
<button
className="primary-btn"
style={{
marginTop: 6,
background: osmUndoStack.length > 0 ? "#8e44ad" : "#bdc3c7",
}}
onClick={performOsmUndo}
disabled={busy || osmUndoStack.length === 0}
title={
osmUndoStack.length === 0
? "Nothing to undo. Last 3 OSM edits in this session are stored here."
: `Undo: ${osmUndoStack[osmUndoStack.length - 1].label} (${osmUndoStack.length}/3 in stack)`
}
>
Undo last edit
{osmUndoStack.length > 0 ? ` (${osmUndoStack.length})` : ""}
</button>
)}
{osmEditMode && focusedVideo !== null && (
<div
style={{
marginTop: 6,
padding: 8,
background: "#eef5ff",
border: "1px solid #b6d4f7",
borderRadius: 4,
fontSize: 11,
}}
>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Bulk road tools focus: {focusedVideo}
</div>
<div style={{ marginBottom: 4, color: "#555" }}>
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.
</div>
<div style={{ marginBottom: 6 }}>
Selected: <strong>{selectedRoadIds.size}</strong> · Hidden:{" "}
<strong>{hiddenRoadIds.size}</strong>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
<button
className="primary-btn"
style={{ background: "#c0392b", fontSize: 11 }}
disabled={busy || selectedRoadIds.size === 0}
onClick={() => {
const ids = Array.from(selectedRoadIds);
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.`);
}}
title="Hide all currently-selected roads"
>
Hide selected
</button>
<button
className="primary-btn"
style={{ background: "#7f8c8d", fontSize: 11 }}
disabled={busy || selectedRoadIds.size === 0}
onClick={() => {
setSelectedRoadIds(new Set());
setStatus("Selection cleared.");
}}
>
Clear selection
</button>
<button
className="primary-btn"
style={{ background: "#27ae60", fontSize: 11 }}
disabled={busy || hiddenRoadIds.size === 0}
onClick={() => {
const ids = Array.from(hiddenRoadIds);
setHiddenRoadIds(new Set());
pushOsmUndo({
kind: "rehide",
ids,
label: "restore hidden",
});
setStatus(`Restored ${ids.length} hidden road(s).`);
}}
title="Bring all hidden roads back into view"
>
Restore hidden
</button>
<button
className="primary-btn"
style={{ background: "#16a085", fontSize: 11 }}
disabled={busy || selectedRoadIds.size < 2}
onClick={async () => {
const ids = Array.from(selectedRoadIds);
// Same destructive + non-undoable warning as "Merge all
// visible" — easy to misclick a 50-road selection and lose
// the source rows.
const ok = window.confirm(
`Merge ${ids.length} selected road(s) into connected clusters?\n\n` +
`This deletes the source rows and is NOT covered by the OSM Undo. ` +
`Disconnected clusters stay separate.`,
);
if (!ok) return;
setBusy(true);
try {
const r = await invoke<{
ways_consumed: number;
merged_into: number;
skipped_branched: number;
}>("merge_roads_by_ids", { ids });
setStatus(
`Merged ${r.ways_consumed} road(s) into ${r.merged_into}` +
(r.skipped_branched > 0
? ` (skipped ${r.skipped_branched} branched cluster${
r.skipped_branched === 1 ? "" : "s"
})`
: ""),
);
setSelectedRoadIds(new Set());
await refreshOsmRoads();
} catch (e) {
setStatus(`Merge failed: ${e}`);
} finally {
setBusy(false);
}
}}
title="Concatenate ≥2 selected roads into one (clusters are merged independently)"
>
Merge selected
</button>
<button
className="primary-btn"
style={{ background: "#2c3e50", fontSize: 11 }}
disabled={
busy || (displayOsmRoads?.features.length ?? 0) < 2
}
onClick={async () => {
const ids = (displayOsmRoads?.features ?? []).map(
(f) => f.properties.id,
);
if (ids.length < 2) return;
// Destructive + not undoable via the OSM ring buffer (merge
// consumes source rows). Confirm loudly so the user can't
// wipe a video's worth of road geometry by misclick.
const ok = window.confirm(
`Merge all ${ids.length} visible road(s) into connected clusters?\n\n` +
`This deletes the source rows and is NOT covered by the OSM Undo. ` +
`Hidden roads stay hidden. Disconnected clusters stay separate.`,
);
if (!ok) return;
setBusy(true);
try {
const r = await invoke<{
ways_consumed: number;
merged_into: number;
skipped_branched: number;
}>("merge_roads_by_ids", { ids });
setStatus(
`Merged ${r.ways_consumed} road(s) into ${r.merged_into}` +
(r.skipped_branched > 0
? ` (skipped ${r.skipped_branched} branched cluster${
r.skipped_branched === 1 ? "" : "s"
})`
: ""),
);
setSelectedRoadIds(new Set());
await refreshOsmRoads();
} catch (e) {
setStatus(`Merge failed: ${e}`);
} finally {
setBusy(false);
}
}}
title="Merge all currently-visible roads (non-touching clusters stay separate)"
>
Merge all visible
</button>
</div>
</div>
)}
{osmEditMode && editingRoadId !== null && (
<>
<button
className="primary-btn"
style={{ marginTop: 6, background: "#9b59b6" }}
onClick={async () => {
if (editingRoadId === null) return;
try {
await snapshotRoadsForUndo([editingRoadId], "flip direction");
const next = await invoke<number>("flip_road_direction", {
id: editingRoadId,
});
const label =
next === 1 ? "forward" : next === -1 ? "reverse" : "none";
setStatus(`Flipped road #${editingRoadId}${label}`);
await refreshOsmRoads();
} catch (e) {
setStatus(`Flip failed: ${e}`);
}
}}
disabled={busy}
title="Toggle this road's oneway direction (forward ↔ reverse). Useful when OSM has the wrong direction."
>
Flip direction
</button>
{(() => {
const cur = osmRoads.features.find(
(f) => f.properties.id === editingRoadId,
);
const ignored = cur?.properties.snap_ignored === true;
return (
<button
className="primary-btn"
style={{
marginTop: 6,
background: ignored ? "#7f8c8d" : "#34495e",
}}
onClick={async () => {
if (editingRoadId === null) return;
try {
await snapshotRoadsForUndo(
[editingRoadId],
ignored ? "re-enable for snap" : "ignore for snap",
);
await invoke("set_road_snap_ignored", {
id: editingRoadId,
ignored: !ignored,
});
setStatus(
ignored
? `Road #${editingRoadId} re-enabled for snapping.`
: `Road #${editingRoadId} marked as ignored — snap will skip it.`,
);
await refreshOsmRoads();
} catch (e) {
setStatus(`Toggle failed: ${e}`);
}
}}
disabled={busy}
title="Excluded roads stay visible (greyed out) but won't be picked as snap targets."
>
{ignored ? "Re-enable for snap" : "Ignore for snap"}
</button>
);
})()}
<div
style={{
marginTop: 6,
padding: 6,
background: "#fff8e1",
border: "1px solid #f1c40f",
borderRadius: 4,
fontSize: 11,
}}
>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
Bulk vertex tools
</div>
<div style={{ marginBottom: 4 }}>
Shift-click vertices to mark (red ). Press{" "}
<strong>Del</strong> to remove all marked.
</div>
<div style={{ marginBottom: 4 }}>
Currently marked:{" "}
<strong>{markedVertices.size}</strong>
{markedVertices.size > 0 && (
<button
type="button"
style={{
marginLeft: 6,
padding: "0 6px",
fontSize: 10,
border: "1px solid #ccc",
borderRadius: 3,
background: "#fff",
cursor: "pointer",
}}
onClick={() => setMarkedVertices(new Set())}
>
Clear marks
</button>
)}
</div>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
}}
title="Douglas-Peucker tolerance in metres. Higher = more aggressive simplification."
>
<span>Simplify (m):</span>
<input
type="range"
min={0.5}
max={20}
step={0.5}
value={simplifyTolM}
onChange={(e) =>
setSimplifyTolM(parseFloat(e.target.value))
}
style={{ flex: 1 }}
/>
<span style={{ width: 36, textAlign: "right" }}>
{simplifyTolM} m
</span>
</label>
<button
className="primary-btn"
style={{
marginTop: 4,
width: "100%",
background: "#16a085",
fontSize: 11,
}}
onClick={async () => {
if (editingRoadId === null) return;
setBusy(true);
try {
await snapshotRoadsForUndo(
[editingRoadId],
"simplify road",
);
const [before, after] = await invoke<[number, number]>(
"simplify_road",
{ id: editingRoadId, toleranceM: simplifyTolM },
);
setStatus(
`Road #${editingRoadId}: ${before}${after} vertices (${before - after} removed)`,
);
setMarkedVertices(new Set());
await refreshOsmRoads();
} catch (e) {
setStatus(`Simplify failed: ${e}`);
} finally {
setBusy(false);
}
}}
disabled={busy}
>
Simplify this road
</button>
<button
className="primary-btn"
style={{
marginTop: 4,
width: "100%",
background: "#c0392b",
fontSize: 11,
}}
disabled={busy || markedVertices.size < 2}
onClick={async () => {
if (editingRoadId === null) return;
const idx = Array.from(markedVertices).sort((a, b) => a - b);
if (idx.length < 2) return;
const ok = window.confirm(
`Delete the road section between vertex #${idx[0]} and vertex #${idx[idx.length - 1]}?\n\n` +
"If both halves keep ≥2 vertices the road splits in two; otherwise the original road is shrunk.",
);
if (!ok) return;
setBusy(true);
try {
await snapshotRoadsForUndo([editingRoadId], "delete section");
const origId = editingRoadId;
const ids = await invoke<number[]>("delete_road_section", {
id: origId,
indices: idx,
});
// Anything other than the original id was freshly inserted
// by the split — attach those to the snapshot so undo
// deletes them before restoring the original geometry.
const createdIds = ids.filter((i) => i !== origId);
if (createdIds.length > 0) {
attachCreatedIdsToLastUndo(createdIds);
}
setMarkedVertices(new Set());
setStatus(
ids.length === 1
? `Section deleted; road #${ids[0]} kept.`
: `Section deleted; road split into #${ids[0]} and #${ids[1]}.`,
);
await refreshOsmRoads();
} catch (e) {
setStatus(`Delete section failed: ${e}`);
} finally {
setBusy(false);
}
}}
title="Drop the marked vertex range from this road. With 2 marks: deletes everything strictly between them. Splits the road into two if both halves still have ≥2 vertices."
>
Delete section between marked
</button>
</div>
{focusedVideo === null && (
<button
className="primary-btn"
style={{ marginTop: 6, background: "#7f8c8d" }}
onClick={() => {
setEditingRoadId(null);
setStatus("Cleared road selection.");
}}
>
Deselect road
</button>
)}
<button
className="primary-btn"
style={{ marginTop: 6, background: "#c0392b" }}
onClick={handleDeleteRoad}
disabled={busy}
>
Delete this road
</button>
</>
)}
{osmEditMode && !drawRoadMode && (
<button
className="primary-btn"
style={{ marginTop: 6, background: "#27ae60" }}
onClick={startDrawRoad}
disabled={busy}
title="Click on the map to drop vertices for a new road"
>
+ Draw new road
</button>
)}
{osmEditMode && drawRoadMode && (
<>
<div
className="status"
style={{ marginTop: 6, fontWeight: 600 }}
>
Drawing: {drawRoadCoords.length} vertex
{drawRoadCoords.length === 1 ? "" : "es"}
</div>
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button
className="primary-btn"
style={{ flex: 1, background: "#16a085" }}
onClick={finishDrawRoad}
disabled={busy || drawRoadCoords.length < 2}
>
Finish
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#7f8c8d" }}
onClick={cancelDrawRoad}
>
Cancel
</button>
</div>
{drawRoadCoords.length > 0 && (
<button
className="primary-btn"
style={{ marginTop: 6, background: "#95a5a6" }}
onClick={() =>
setDrawRoadCoords((p) => p.slice(0, -1))
}
>
Undo last point
</button>
)}
</>
)}
{osmEditMode && (() => {
// Roads the slider should affect: editing road + multi-selected
// set. Empty → slider edits the default for all roads.
const sliderIds = new Set<number>(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 (
<label
className="field"
style={{ marginTop: 6, display: "block" }}
>
<span>
Lane offset (m){hasSelection ? ` [${sliderIds.size} selected]` : " [default]"}
: {effective.toFixed(1)}
</span>
<input
type="range"
min={0.1}
max={30}
step={0.1}
value={effective}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!hasSelection) {
setRoadOffsetMeters(v);
return;
}
setRoadOffsetById((prev) => {
const next = new Map(prev);
for (const id of sliderIds) next.set(id, v);
return next;
});
}}
style={{ width: "100%" }}
/>
{hasSelection && (
<button
className="primary-btn"
style={{ marginTop: 4, background: "#7f8c8d", fontSize: 11 }}
onClick={() => {
setRoadOffsetById((prev) => {
const next = new Map(prev);
for (const id of sliderIds) next.delete(id);
return next;
});
}}
title="Drop the per-road override on the selected road(s) so they follow the default again."
>
Reset selected to default ({roadOffsetMeters.toFixed(1)} m)
</button>
)}
</label>
);
})()}
<button
className="primary-btn"
style={{ marginTop: 6, background: "#1abc9c" }}
onClick={() => setOsmToolsOpen(true)}
disabled={busy}
title="Cleanup operations on the imported OSM road layer"
>
OSM tools
</button>
{anchors.size > 0 && (
<div
style={{
marginTop: 6,
padding: 6,
background: "#fff8e1",
border: "1px solid #f1c40f",
borderRadius: 4,
fontSize: 11,
}}
>
<div style={{ marginBottom: 4 }}>
<strong>{anchors.size}</strong> 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).
</div>
<div style={{ display: "flex", gap: 4 }}>
<button
className="primary-btn"
style={{ flex: 1, background: "#f39c12", color: "#222" }}
onClick={handleDistribute}
disabled={busy}
>
Distribute correction
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#bdc3c7", color: "#222" }}
onClick={() => setAnchors(new Map())}
disabled={busy}
>
Clear anchors
</button>
</div>
</div>
)}
<button
className="primary-btn"
style={{ marginTop: 6, background: "#16a085" }}
onClick={handleBulkSnap}
disabled={busy}
title="Snap every fixed asset visible in the current viewport to the nearest road (50 m max)"
>
Snap visible to road (bulk)
</button>
<button
className="primary-btn"
style={{ marginTop: 6, background: "#1abc9c" }}
onClick={handleSnapByVideo}
disabled={busy || !bulkVideoName.trim()}
title="Snap every asset (fixed + range) for the entered video to the nearest road"
>
Snap by video to road
</button>
<div style={{ display: "flex", gap: 4, marginTop: 6 }}>
{(["circle", "rect", "polygon"] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setLassoShape(s)}
style={{
flex: 1,
fontSize: 11,
padding: "3px 4px",
background: lassoShape === s ? "#e91e63" : "#ecf0f1",
color: lassoShape === s ? "#fff" : "#222",
border: "1px solid #ccc",
borderRadius: 3,
cursor: "pointer",
}}
>
{s === "circle" ? "Circle" : s === "rect" ? "Bbox" : "Polygon"}
</button>
))}
</div>
<button
className="primary-btn"
style={{
marginTop: 4,
background: lassoMode ? "#e91e63" : "#9b59b6",
}}
onClick={() => {
setLassoSelected(null);
setLassoPolygonPoints(null);
setLassoMode((v) => !v);
const turningOn = !lassoMode;
const targetHint = osmEditMode
? "OSM edit mode is on — lasso will bulk-delete road sections, NOT assets."
: "";
setStatus(
!turningOn
? "Lasso off."
: (lassoShape === "polygon"
? "Lasso on: click to add polygon vertices, double-click to finish."
: "Lasso on: drag to select.") +
(targetHint ? ` ${targetHint}` : ""),
);
}}
disabled={busy}
>
{lassoMode ? "Cancel lasso" : "Start lasso"}
</button>
{lassoSelected && (
<div
style={{
marginTop: 6,
padding: 8,
border: "2px solid #e91e63",
borderRadius: 4,
background: "#fff",
}}
>
<button
type="button"
onClick={() => setLassoListOpen((v) => !v)}
style={{
width: "100%",
background: "transparent",
border: 0,
cursor: "pointer",
fontWeight: 600,
textAlign: "left",
padding: 0,
}}
title="Show / hide the list of selected assets"
>
{lassoListOpen ? "▼" : "▶"}{" "}
<strong>{lassoSelected.ids.length}</strong> selected (
{lassoSelected.label})
</button>
{lassoListOpen && (
<div
style={{
maxHeight: 180,
overflowY: "auto",
marginTop: 6,
border: "1px solid #eee",
borderRadius: 3,
padding: 4,
fontSize: 11,
}}
>
{lassoSelected.ids.map((id) => {
const a = assets.find((x) => x.id === id);
if (!a) return null;
return (
<div
key={id}
className="filter-row"
style={{
borderBottom: "1px dotted #eee",
cursor: "pointer",
}}
onClick={() => {
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"
>
<input
type="checkbox"
checked
onClick={(e) => e.stopPropagation()}
onChange={() => {
setLassoSelected((s) =>
s
? {
...s,
ids: s.ids.filter((x) => x !== id),
}
: s,
);
}}
/>
<span className="label" title={a.row_id}>
{a.asset_name}
{a.deleted ? " ⌫" : ""}
{a.in_scope === 0 ? " ◌" : ""}
</span>
<span className="count">{a.row_id.slice(-6)}</span>
</div>
);
})}
</div>
)}
<div
style={{
display: "flex",
gap: 4,
marginTop: 6,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{ background: "#27ae60", flex: 1, fontSize: 11 }}
onClick={() => handleLassoSetScope(true)}
disabled={busy || lassoSelected.ids.length === 0}
>
In-scope
</button>
<button
className="primary-btn"
style={{ background: "#7f8c8d", flex: 1, fontSize: 11 }}
onClick={() => handleLassoSetScope(false)}
disabled={busy || lassoSelected.ids.length === 0}
>
Out-of-scope
</button>
<button
className="primary-btn"
style={{ background: "#34495e", flex: 1, fontSize: 11 }}
onClick={handleLassoClearOverride}
disabled={busy || lassoSelected.ids.length === 0}
title="Remove manual override; re-derive from KML scope polygons"
>
Auto
</button>
<button
className="primary-btn"
style={{ background: "#c0392b", flex: 1, fontSize: 11 }}
onClick={handleLassoDelete}
disabled={busy || lassoSelected.ids.length === 0}
>
Delete
</button>
</div>
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() =>
invoke<number>("restore_assets", {
ids: lassoSelected.ids,
})
.then((n) => setStatus(`Restored ${n} asset(s)`))
.then(refreshAfterMutation)
.catch((e) => setStatus(`Restore failed: ${e}`))
}
disabled={busy || lassoSelected.ids.length === 0}
>
Restore
</button>
<button
className="primary-btn"
style={{ background: "#8e44ad", flex: 1, fontSize: 11 }}
onClick={() =>
setRenameOpen({ ids: lassoSelected.ids, current: "" })
}
disabled={busy || lassoSelected.ids.length === 0}
>
Rename
</button>
</div>
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{ background: "#e67e22", flex: 1, fontSize: 11 }}
onClick={() => handleLassoChangeSide("Left")}
disabled={busy || lassoSelected.ids.length === 0}
>
Set Left
</button>
<button
className="primary-btn"
style={{ background: "#3498db", flex: 1, fontSize: 11 }}
onClick={() => handleLassoChangeSide("Right")}
disabled={busy || lassoSelected.ids.length === 0}
>
Set Right
</button>
</div>
{lassoPolygonPoints && lassoPolygonPoints.length >= 3 && (
<>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
fontSize: 11,
}}
title="Maximum distance between two assets to be considered a pair (greedy nearest-neighbour)."
>
<span>Pair max:</span>
<input
type="range"
min={2}
max={60}
step={1}
value={autoLinkMaxM}
onChange={(e) => setAutoLinkMaxM(parseFloat(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ width: 36, textAlign: "right" }}>
{autoLinkMaxM} m
</span>
</label>
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("side")}
disabled={busy}
title="Auto-pair Left↔Right within this polygon by proximity"
>
Auto-link LR
</button>
<button
className="primary-btn"
style={{ background: "#1abc9c", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("video")}
disabled={busy}
title="Auto-pair across the two videos in this polygon"
>
Auto-link by video
</button>
<button
className="primary-btn"
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("none")}
disabled={busy}
title="Pair nearest neighbours in the polygon, ignoring side and video. Use when video_name is missing."
>
Auto-link nearest
</button>
</div>
</>
)}
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{ background: "#7f8c8d", flex: 1, fontSize: 11 }}
onClick={async () => {
let n = 0;
for (const id of lassoSelected.ids) {
try {
await invoke("clear_link", { id });
n++;
} catch {
/* asset wasn't linked; ignore */
}
}
setStatus(`Cleared links on ${n} asset(s)`);
await refreshAfterMutation();
}}
disabled={busy || lassoSelected.ids.length === 0}
title="Remove auto-link / locked-link from every asset in the lasso"
>
Clear links
</button>
<button
className="primary-btn"
style={{ background: "#d35400", flex: 1, fontSize: 11 }}
onClick={() =>
invoke<number>("reset_assets_to_original", {
ids: lassoSelected.ids,
})
.then((n) =>
setStatus(`Reset ${n} asset(s) to original position`),
)
.then(refreshAfterMutation)
.catch((e) => setStatus(`Reset failed: ${e}`))
}
disabled={busy || lassoSelected.ids.length === 0}
title="Restore the lat/lng captured at import time (undoes snap, drag, etc.)"
>
Reset to original
</button>
</div>
<button
className="primary-btn"
style={{
marginTop: 6,
background: "#bdc3c7",
fontSize: 11,
}}
onClick={() => setLassoSelected(null)}
>
Clear selection
</button>
</div>
)}
<div
style={{
display: "flex",
gap: 6,
marginTop: 6,
alignItems: "stretch",
}}
>
<select
value={exportFormat}
onChange={(e) =>
setExportFormat(
e.target.value as "source" | "geojson" | "kml" | "csv",
)
}
disabled={busy}
title="File format for the next export"
style={{ fontSize: 12, padding: "2px 4px" }}
>
<option value="source">JSON (round-trip + bbox)</option>
<option value="geojson">GeoJSON</option>
<option value="kml">KML</option>
<option value="csv">CSV</option>
</select>
<button
className="primary-btn"
style={{ background: "#2c3e50", flex: 1 }}
onClick={handleExport}
disabled={busy}
title="Export all live assets in the selected format. JSON is the only re-importable format and includes bbox overlays."
>
Export data
</button>
</div>
<h3 style={{ marginTop: 16 }}>Layers</h3>
<div className="layer-toggles">
{(
[
["scope", "Scope (KML)"],
["osm", "OSM roads"],
["metadata", "Metadata polyline"],
["fixed", "Fixed assets"],
["range", "Range polylines"],
] as const
).map(([key, label]) => (
<label key={key} className="layer-toggle">
<input
type="checkbox"
checked={layerVisibility[key]}
onChange={(e) =>
setLayerVisibility((v) => ({
...v,
[key]: e.target.checked,
}))
}
/>
<span>{label}</span>
</label>
))}
</div>
<button
className="primary-btn"
style={{ marginTop: 6, background: "#000" }}
onClick={handleResetDatabase}
disabled={busy}
title="Wipe assets, scope, and action history"
>
Reset DB (wipe + start fresh)
</button>
{scopePolygons.length > 0 && (
<div className="status">
{scopePolygons.length} scope polygon(s) loaded
</div>
)}
{status && <div className="status">{status}</div>}
<h3 style={{ marginTop: 16 }}>
Navigate ({navigableAssets.length})
</h3>
<div style={{ display: "flex", gap: 6 }}>
<button
className="primary-btn"
style={{ flex: 1, background: "#34495e" }}
onClick={() => gotoAdjacent(-1)}
disabled={navigableAssets.length === 0}
title="Previous asset (keyboard: [ )"
>
Prev
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#34495e" }}
onClick={() => gotoAdjacent(1)}
disabled={navigableAssets.length === 0}
title="Next asset (keyboard: ] )"
>
Next
</button>
</div>
{selectedAsset && (
<>
<h3 style={{ marginTop: 16 }}>Selected</h3>
<div className="status" style={{ marginTop: 0 }}>
{selectedAsset.asset_type} · {selectedAsset.asset_name}
{selectedAsset.side && (
<span
className={`side-chip side-${selectedAsset.side.toLowerCase()}`}
>
{selectedAsset.side}
</span>
)}
<br />
<code>{selectedAsset.row_id}</code>
<br />
<small>video: {displayVideo(selectedAsset.video_name)}</small>
</div>
<button
className="primary-btn"
style={{
background: placeMode ? "#e67e22" : "#2980b9",
marginTop: 6,
}}
onClick={() => setPlaceMode((v) => !v)}
disabled={busy}
title="Click anywhere on the map to move the asset there"
>
{placeMode
? "Cancel place mode"
: "Move on next click"}
</button>
<button
className="primary-btn"
style={{ background: "#16a085", marginTop: 6 }}
onClick={handleSnapToRoad}
disabled={busy}
title="Snap to nearest OSM road centerline (within 50 m)"
>
Snap to nearest road
</button>
<div className="hint">
Tip: arrow keys nudge selected by ~0.5 m.
</div>
{selectedAsset.link_pair_id !== null ? (
<div className="status" style={{ marginTop: 4, fontSize: 11 }}>
🔗 Linked to #{selectedAsset.link_pair_id}
{(selectedAsset.link_locked ?? 0) === 1 ? " (locked)" : ""}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
<button
className="primary-btn"
style={{ flex: 1, background: "#16a085", fontSize: 11 }}
onClick={() => {
const partner = assets.find(
(x) => x.id === selectedAsset.link_pair_id,
);
if (partner && partner.lat !== null && partner.lng !== null) {
setViewport((v) => ({
center: [
partner.lng as number,
partner.lat as number,
],
zoom: Math.max(v.zoom, 18),
}));
setSelectedAssetId(partner.id);
}
}}
>
Go to partner
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#e91e63", fontSize: 11 }}
onClick={() => setLinkPickMode(true)}
disabled={busy || linkPickMode}
title="Click another asset on the map to re-link"
>
Change partner
</button>
<button
className="primary-btn"
style={{ flex: 1, background: "#7f8c8d", fontSize: 11 }}
onClick={handleClearLink}
disabled={busy}
>
Unlink
</button>
</div>
</div>
) : (
<div style={{ marginTop: 4 }}>
<button
className="primary-btn"
style={{
width: "100%",
background: linkPickMode ? "#e91e63" : "#9b59b6",
fontSize: 11,
}}
onClick={() => setLinkPickMode((v) => !v)}
disabled={busy}
title="Click another asset on the map to pair with this one"
>
{linkPickMode ? "Cancel link pick" : "Link to another asset…"}
</button>
</div>
)}
<div
style={{
display: "flex",
gap: 4,
marginTop: 6,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{ background: "#27ae60", flex: 1, fontSize: 11 }}
onClick={() =>
invoke("set_assets_in_scope", {
ids: [selectedAsset.id],
inScope: true,
})
.then(refreshAfterMutation)
.then(() => setStatus("Marked in-scope"))
.catch((e) => setStatus(`Failed: ${e}`))
}
disabled={busy}
title="Force in-scope (overrides KML)"
>
In-scope
</button>
<button
className="primary-btn"
style={{ background: "#7f8c8d", flex: 1, fontSize: 11 }}
onClick={() =>
invoke("set_assets_in_scope", {
ids: [selectedAsset.id],
inScope: false,
})
.then(refreshAfterMutation)
.then(() => setStatus("Marked out-of-scope"))
.catch((e) => setStatus(`Failed: ${e}`))
}
disabled={busy}
title="Force out-of-scope (overrides KML)"
>
Out-of-scope
</button>
<button
className="primary-btn"
style={{ background: "#34495e", flex: 1, fontSize: 11 }}
onClick={() =>
invoke("clear_manual_scope", {
ids: [selectedAsset.id],
})
.then(refreshAfterMutation)
.then(() => setStatus("Cleared override"))
.catch((e) => setStatus(`Failed: ${e}`))
}
disabled={busy}
title="Drop manual override; re-derive from KML"
>
Auto
</button>
{selectedAsset.deleted ? (
<button
className="primary-btn"
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() =>
invoke("restore_assets", { ids: [selectedAsset.id] })
.then(refreshAfterMutation)
.then(() => setStatus("Restored"))
.catch((e) => setStatus(`Restore failed: ${e}`))
}
disabled={busy}
>
Restore
</button>
) : (
<button
className="primary-btn"
style={{ background: "#c0392b", flex: 1, fontSize: 11 }}
onClick={handleDeleteSelected}
disabled={busy}
>
Delete
</button>
)}
</div>
<button
className="primary-btn"
style={{ background: "#8e44ad", marginTop: 4, fontSize: 11 }}
onClick={() =>
setRenameOpen({
ids: [selectedAsset.id],
current: selectedAsset.asset_name,
})
}
disabled={busy}
title="Rename asset class (undoable)"
>
Rename class
</button>
<button
className="primary-btn"
style={{ background: "#d35400", marginTop: 4, fontSize: 11 }}
onClick={() =>
invoke<number>("reset_assets_to_original", {
ids: [selectedAsset.id],
})
.then((n) =>
setStatus(
n > 0
? "Reset to original position"
: "No original position recorded for this asset",
),
)
.then(refreshAfterMutation)
.catch((e) => setStatus(`Reset failed: ${e}`))
}
disabled={busy}
title="Restore the lat/lng captured at import time (undoes snap, drag, etc.)"
>
Reset to original
</button>
{(() => {
const isAnchor = anchors.has(selectedAsset.id);
return (
<button
className="primary-btn"
style={{
background: isAnchor ? "#f1c40f" : "#f39c12",
color: "#222",
marginTop: 4,
fontSize: 11,
}}
onClick={() => {
setAnchors((prev) => {
const next = new Map(prev);
if (next.has(selectedAsset.id)) {
next.delete(selectedAsset.id);
} else if (
selectedAsset.lat !== null &&
selectedAsset.lng !== null
) {
next.set(selectedAsset.id, {
origLat: selectedAsset.lat,
origLng: selectedAsset.lng,
rowId: selectedAsset.row_id,
});
}
return next;
});
}}
disabled={busy}
title="Mark this asset as a GPS-correction anchor. Drag it after marking, then press Distribute to propagate the offset to other visible assets."
>
{isAnchor ? "★ Anchor (click to unmark)" : "Mark as anchor"}
</button>
);
})()}
</>
)}
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 16, fontWeight: 600 }}>Merge range polylines</summary>
<div className="merge-slots">
<div className="merge-slot">
<span className="merge-label">Primary</span>
<code className="merge-value">
{mergePrimary ? mergePrimary.row_id : "(empty)"}
</code>
<button
className="merge-pick"
onClick={() => pickMergeSlot("primary")}
disabled={busy || !selectedAsset}
>
Use selected
</button>
</div>
<div className="merge-slot">
<span className="merge-label">Secondary</span>
<code className="merge-value">
{mergeSecondary ? mergeSecondary.row_id : "(empty)"}
</code>
<button
className="merge-pick"
onClick={() => pickMergeSlot("secondary")}
disabled={busy || !selectedAsset}
>
Use selected
</button>
</div>
<div style={{ display: "flex", gap: 6 }}>
<button
className="primary-btn"
style={{ background: "#16a085", flex: 1 }}
onClick={handleMerge}
disabled={busy || !mergePrimary || !mergeSecondary}
>
Merge
</button>
<button
className="primary-btn"
style={{ background: "#7f8c8d", flex: 1 }}
onClick={() => {
setMergePrimary(null);
setMergeSecondary(null);
}}
disabled={busy || (!mergePrimary && !mergeSecondary)}
>
Clear
</button>
</div>
</div>
</details>
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 16, fontWeight: 600 }}>Bulk delete by video</summary>
<input
type="text"
placeholder="video_name"
list="video-name-options"
value={bulkVideoName}
onChange={(e) => setBulkVideoName(e.target.value)}
autoComplete="off"
style={{
width: "100%",
padding: 6,
boxSizing: "border-box",
border: "1px solid #ccc",
borderRadius: 4,
fontSize: 13,
marginBottom: 6,
}}
/>
<datalist id="video-name-options">
{/*
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 (
<option key={raw} value={raw}>
{text}
</option>
);
})}
</datalist>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
<button
className="primary-btn"
style={{ background: "#27ae60", flex: 1, fontSize: 11 }}
onClick={() => {
const typed = bulkVideoName.trim();
if (!typed) return;
const r = resolveVideoToRaw(typed);
if (r.ambiguous) {
setStatus(`"${typed}" matches multiple raw video names — pick a specific one.`);
return;
}
if (!r.raw) {
setStatus(`No video matches "${typed}".`);
return;
}
invoke<number>("set_assets_by_video_in_scope", {
videoName: r.raw,
inScope: true,
})
.then((n) => setStatus(`Marked ${n} in-scope`))
.then(refreshAfterMutation)
.catch((e) => setStatus(`Failed: ${e}`));
}}
disabled={busy || !bulkVideoName.trim()}
>
In-scope
</button>
<button
className="primary-btn"
style={{ background: "#7f8c8d", flex: 1, fontSize: 11 }}
onClick={() => {
const typed = bulkVideoName.trim();
if (!typed) return;
const r = resolveVideoToRaw(typed);
if (r.ambiguous) {
setStatus(`"${typed}" matches multiple raw video names — pick a specific one.`);
return;
}
if (!r.raw) {
setStatus(`No video matches "${typed}".`);
return;
}
invoke<number>("set_assets_by_video_in_scope", {
videoName: r.raw,
inScope: false,
})
.then((n) => setStatus(`Marked ${n} out-of-scope`))
.then(refreshAfterMutation)
.catch((e) => setStatus(`Failed: ${e}`));
}}
disabled={busy || !bulkVideoName.trim()}
>
Out-of-scope
</button>
<button
className="primary-btn"
style={{ background: "#c0392b", flex: 1, fontSize: 11 }}
onClick={handleBulkDeleteByVideo}
disabled={busy || !bulkVideoName.trim()}
>
Delete
</button>
</div>
</details>
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 16, fontWeight: 600 }}>Duplicates</summary>
<div style={{ display: "flex", gap: 4, alignItems: "center" }}>
<span style={{ fontSize: 11 }}>ε:</span>
<input
type="number"
min={0.5}
max={50}
step={0.5}
value={dupEpsM}
onChange={(e) =>
setDupEpsM(Math.max(0.1, parseFloat(e.target.value) || 3))
}
style={{
width: 50,
padding: "2px 4px",
border: "1px solid #ccc",
borderRadius: 3,
fontSize: 11,
}}
/>
<span style={{ fontSize: 11 }}>m</span>
<button
className="primary-btn"
style={{ flex: 1, background: "#2980b9", fontSize: 11 }}
onClick={() => handleFindDuplicates(true)}
disabled={busy || !boundsRef.current}
title="Find duplicates inside the visible map area. Zoom out to widen the search; zoom out fully to scan everywhere."
>
Find duplicates
</button>
</div>
<label
className="filter-row"
style={{ marginTop: 4, fontSize: 11 }}
title="When ON, only flag pairs that come from different videos. When OFF (default), also flag duplicates within the same video."
>
<input
type="checkbox"
checked={dupCrossOnly}
onChange={(e) => setDupCrossOnly(e.target.checked)}
/>
<span className="label">Cross-video only</span>
</label>
{dupClusters.length > 0 && (
<div
style={{
marginTop: 6,
maxHeight: 280,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 4,
}}
>
{dupClusters.map((c) => {
const isActive = dupActive === c.id;
const keepId = dupKeep.get(c.id);
return (
<div
key={c.id}
style={{
borderBottom: "1px dotted #eee",
padding: 6,
background: isActive ? "#fff3e0" : "transparent",
}}
>
<div
style={{ cursor: "pointer", fontWeight: 600, fontSize: 12 }}
onClick={() => {
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
</div>
{isActive && (
<div style={{ marginTop: 4 }}>
{c.members.map((m) => (
<label
key={m.id}
className="filter-row"
style={{ fontSize: 11 }}
>
<input
type="radio"
name={`dup-keep-${c.id}`}
checked={keepId === m.id}
onChange={() => {
setDupKeep((prev) => {
const n = new Map(prev);
n.set(c.id, m.id);
return n;
});
}}
/>
<span
className="label"
title={`${m.row_id} · ${displayVideo(m.video_name)}`}
onClick={() => {
if (m.lat !== null && m.lng !== null) {
setViewport((v) => ({
center: [
m.lng as number,
m.lat as number,
],
zoom: Math.max(v.zoom, 18),
}));
setSelectedAssetId(m.id);
}
}}
style={{ cursor: "pointer" }}
>
{displayVideo(m.video_name).slice(0, 16)}
</span>
<span className="count">{m.side ?? "—"}</span>
</label>
))}
<div
style={{
display: "flex",
gap: 4,
marginTop: 4,
flexWrap: "wrap",
}}
>
<button
className="primary-btn"
style={{
background: "#7f8c8d",
flex: 1,
fontSize: 11,
}}
onClick={() => handleResolveCluster(c, "out_of_scope")}
disabled={busy}
title="Mark every member except the one you picked as out-of-scope"
>
Keep · others Out
</button>
<button
className="primary-btn"
style={{
background: "#c0392b",
flex: 1,
fontSize: 11,
}}
onClick={() => handleResolveCluster(c, "delete")}
disabled={busy}
>
Keep · delete others
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{dupClusters.length > 0 && (
<button
className="primary-btn"
style={{ marginTop: 4, background: "#bdc3c7", fontSize: 11 }}
onClick={() => {
setDupClusters([]);
setDupActive(null);
setDupKeep(new Map());
}}
>
Clear duplicates
</button>
)}
</details>
<details style={{ marginTop: 16 }}>
<summary style={{ cursor: "pointer", fontSize: 16, fontWeight: 600 }}>
Quality{qualityReport
? ` (${
qualityReport.moved_far_total +
qualityReport.far_from_metadata_total +
qualityReport.missing_side_total
})`
: ""}
</summary>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginTop: 6 }}>
<button
className="primary-btn"
style={{ flex: 1, fontSize: 11 }}
onClick={refreshQuality}
disabled={qualityLoading}
>
{qualityLoading ? "Scanning…" : qualityReport ? "Refresh" : "Run scan"}
</button>
{qualityHighlight.length > 0 && (
<button
className="primary-btn"
style={{ flex: 1, fontSize: 11, background: "#bdc3c7" }}
onClick={() => {
setQualityHighlight([]);
setQualityStep(null);
}}
>
Clear
</button>
)}
</div>
{qualityStep && qualityStep.coords.length > 1 && (
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
marginTop: 6,
fontSize: 11,
}}
>
<button
className="primary-btn"
style={{ flex: 1, fontSize: 11 }}
onClick={() => stepQualityBy(-1)}
>
Prev
</button>
<span style={{ minWidth: 50, textAlign: "center" }}>
{qualityStep.idx + 1} / {qualityStep.coords.length}
</span>
<button
className="primary-btn"
style={{ flex: 1, fontSize: 11 }}
onClick={() => stepQualityBy(1)}
>
Next
</button>
</div>
)}
{qualityReport && (
<div style={{ marginTop: 8 }}>
{[
{
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) => (
<div
key={r.key}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "3px 0",
fontSize: 12,
}}
>
<span style={{ flex: 1 }}>
{r.label}
{r.hint && (
<span
style={{ marginLeft: 6, color: "#888", fontSize: 10 }}
>
({r.hint})
</span>
)}
</span>
<span
style={{
minWidth: 32,
textAlign: "right",
fontWeight: 600,
color: r.count > 0 ? "#c0392b" : "#27ae60",
}}
>
{r.count}
</span>
<button
className="primary-btn"
style={{ fontSize: 10, padding: "2px 8px" }}
disabled={r.count === 0 || r.ids.length === 0}
onClick={() => viewQualityIssues(r.ids, r.label)}
title={
r.count > r.ids.length
? `Showing first ${r.ids.length} of ${r.count}`
: ""
}
>
View
</button>
</div>
))}
</div>
)}
</details>
<h3 style={{ marginTop: 16 }}>Recent actions</h3>
{recentActions.length === 0 ? (
<div className="status" style={{ marginTop: 0 }}>
No actions yet
</div>
) : (
<ul className="action-list">
{recentActions.map((a) => (
<li
key={a.id}
className={a.undone ? "action-undone" : undefined}
>
<div className="action-text">{formatAction(a)}</div>
{a.undone ? (
<button
className="redo-btn"
onClick={() => handleRedo(a.id)}
disabled={busy}
>
Redo
</button>
) : (
<button
className="undo-btn"
onClick={() => handleUndo(a.id)}
disabled={busy}
>
Undo
</button>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}