6033 lines
207 KiB
TypeScript
6033 lines
207 KiB
TypeScript
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 L↔R
|
||
</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>
|
||
);
|
||
}
|