Files
KML-Verification/src/App.tsx
2026-05-04 21:02:20 +05:30

6033 lines
207 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 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);
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 [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) {
setSelectedRoadIds(new Set());
setHiddenRoadIds(new Set());
setOsmUndoStack([]);
}
}, [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. Cleared on exit OSM edit mode. 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[]; 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);
}
}
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") {
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);
const [showOutOfScope, setShowOutOfScope] = useState<boolean>(true);
const [showInScope, setShowInScope] = useState<boolean>(true);
const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false);
const [allVideoNames, setAllVideoNames] = useState<string[]>([]);
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>("");
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]);
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;
const inScope = a.in_scope !== 0;
if (!showOutOfScope && !inScope) return false;
if (!showInScope && inScope) return false;
return true;
});
}, [assets, filters, showOutOfScope, showInScope, 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,
});
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;
return () => {
if (debounceRef.current !== null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
}, [bounds, showDeleted, 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)}`);
await refreshAfterMutation();
} catch (e) {
setStatus(`Move failed: ${e}`);
}
}
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 hide: in focused-video OSM mode and not in vertex-edit submode,
// Del moves the multi-select set into the hidden bucket.
if (
(e.key === "Delete" || e.key === "Backspace") &&
osmEditMode &&
focusedVideo !== null &&
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);
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;
}
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;
}
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]);
// 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]);
async function handleDeleteSelected() {
if (!selectedAsset) return;
try {
await invoke("delete_asset", { id: selectedAsset.id });
setStatus(`Deleted ${selectedAsset.asset_name} · ${selectedAsset.row_id}`);
setSelectedAssetId(null);
await refreshAfterMutation();
} catch (e) {
setStatus(`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}"`);
} else {
setStatus(`Deleted ${count} assets from video "${name}"`);
setBulkVideoName("");
}
await refreshAfterMutation();
} catch (e) {
setStatus(`Bulk delete failed: ${e}`);
}
}
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(() => {}),
refreshAssetNames().catch(() => {}),
]);
setStatus("Database reset.");
} catch (e) {
setStatus(`Reset failed: ${e}`);
} finally {
setBusy(false);
}
}
async function handleUndo(actionId: number) {
try {
await invoke("undo_action", { actionId });
setStatus(`Undone action #${actionId}`);
await refreshAfterMutation();
} catch (e) {
setStatus(`Undo failed: ${e}`);
}
}
async function handleRedo(actionId: number) {
try {
await invoke("redo_action", { actionId });
setStatus(`Redone action #${actionId}`);
await refreshAfterMutation();
} catch (e) {
setStatus(`Redo failed: ${e}`);
}
}
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}`,
);
setMergePrimary(null);
setMergeSecondary(null);
setSelectedAssetId(null);
await refreshAfterMutation();
} catch (e) {
setStatus(`Merge failed: ${e}`);
}
}
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 }>
>("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,
},
};
});
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 */
}
}
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(),
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).
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(() => {});
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.");
}
}
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.`,
);
} catch (e) {
setStatus(`Create road failed: ${e}`);
}
}
async function handleDeleteRoad() {
if (editingRoadId === null) return;
const ok = window.confirm(`Delete road #${editingRoadId}?`);
if (!ok) return;
try {
await invoke("delete_osm_road", { id: editingRoadId });
await refreshOsmRoads();
await refreshDataCounts();
setEditingRoadId(null);
setStatus(`Deleted road #${editingRoadId}.`);
} catch (e) {
setStatus(`Delete failed: ${e}`);
}
}
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)`);
}
} catch (e) {
setStatus(`OSM import failed: ${e}`);
} 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));
};
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 {
const picked = await save({
filters: [
{ name: "Source JSON (round-trip)", extensions: ["json"] },
{ name: "GeoJSON", extensions: ["geojson"] },
{ name: "KML", extensions: ["kml"] },
{ name: "CSV", extensions: ["csv"] },
],
});
if (typeof picked !== "string") return;
const lower = picked.toLowerCase();
const fmt = lower.endsWith(".kml")
? "kml"
: lower.endsWith(".csv")
? "csv"
: lower.endsWith(".json")
? "source"
: "geojson";
setBusy(true);
const count = await invoke<number>("export_assets", {
path: picked,
format: fmt,
});
setStatus(`Exported ${count} asset(s) to ${picked}`);
} catch (e) {
setStatus(`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)`);
} catch (e) {
setStatus(`KML import failed: ${e}`);
} 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)`);
await Promise.all([
refreshVideoNames(),
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}`);
// Pull in the new video / class names so the filter auto-includes them.
await Promise.all([
refreshVideoNames(),
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}
onSelect={(a) => {
if (linkPickMode && a && selectedAsset && a.id !== selectedAsset.id) {
handleSetLink(a.id);
return;
}
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}
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) => {
// Right-click hides immediately. Hide is session-only and clears
// when OSM edit mode exits or focused video changes.
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 (session-only).`);
}}
onUpdateRoad={handleUpdateRoad}
roadOffsetMeters={roadOffsetMeters}
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}
onSelect={(a) => {
setSelectedAssetId(a?.id ?? null);
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
}}
onPositionChange={handlePositionChange}
imageFolder={imageFolder}
popupEnabled={popupEnabled}
perfMode={perfMode}
anchorIds={Array.from(anchors.keys())}
colorBy={
new Set(filteredAssets.map((a) => displayVideo(a.video_name)))
.size <= 1
? "asset"
: "video"
}
/>
)}
</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}
onChange={(e) => setShowDeleted(e.target.checked)}
/>
<span className="label">Show 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,
}}
/>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{Array.from(
new Set(
allVideoNames.concat(Object.keys(metadataByVideo)),
),
)
.sort()
.filter((v) =>
videoSearch.trim() === ""
? true
: v
.toLowerCase()
.includes(videoSearch.trim().toLowerCase()),
)
.map((v) => {
const n = counts.byVideo.get(v) ?? 0;
return [v, n] as [string, number];
})
.map(([v, n]) => (
<label key={v} className="filter-row">
<input
type="checkbox"
checked={filters.videos.has(v)}
onChange={() =>
setFilters((f) => ({
...f,
videos: toggleSet(f.videos, v),
}))
}
/>
<span className="label" title={v}>
{v}
</span>
<span className="count">{n}</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
flyToVideo(v);
}}
title={`Fly to ${v}`}
style={{
marginLeft: 4,
padding: "0 6px",
fontSize: 11,
border: "1px solid #ccc",
borderRadius: 3,
background: "#fff",
cursor: "pointer",
}}
>
</button>
</label>
))}
</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 {
if (!classNames.includes(name)) {
await invoke("add_class", { name });
}
await invoke<number>("rename_assets", {
ids: renameOpen.ids,
assetName: name,
});
setStatus(
`Renamed ${renameOpen.ids.length} asset(s) → ${name}`,
);
setRenameOpen(null);
setRenameInput("");
await refreshAfterMutation();
} 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>
<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>
</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 && (
<label
className="field"
style={{ marginTop: 6, display: "block" }}
>
<span>Lane offset (m): {roadOffsetMeters.toFixed(1)}</span>
<input
type="range"
min={0.1}
max={30}
step={0.1}
value={roadOffsetMeters}
onChange={(e) => setRoadOffsetMeters(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</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);
setStatus(
lassoMode
? "Lasso off."
: lassoShape === "polygon"
? "Lasso on: click to add polygon vertices, double-click to finish."
: "Lasso on: drag to select.",
);
}}
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>
)}
<button
className="primary-btn"
style={{ marginTop: 6, background: "#2c3e50" }}
onClick={handleExport}
disabled={busy}
title="Export all live assets. Pick .json for round-trip (re-importable), .geojson / .kml for GIS, .csv for spreadsheet."
>
Export data
</button>
<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>
);
}