import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { unzipSync } from "fflate"; import { api, fmtChainage, fmtChainageRange, fmtWhen, type CalibrationRow, type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow, } from "./api"; /** The import format clients should follow: one Point placemark per survey point, * chainage in the name ("12+400" = 12 km + 400 m, or decimal km). */ const SAMPLE_KML = ` chainage points — sample 0+00078.185718,21.105570,0 5+00078.158000,21.092045,0 10+00078.123885,21.091447,0 `; function downloadSampleKml() { const url = URL.createObjectURL(new Blob([SAMPLE_KML], { type: "application/vnd.google-earth.kml+xml" })); const a = document.createElement("a"); a.href = url; a.download = "chainage_points_sample.kml"; a.click(); URL.revokeObjectURL(url); } /** One placemark parsed out of an uploaded KML, editable before import. */ interface KmlRow { name: string; lat: number; lon: number; /** Editable chainage in km (prefilled from the placemark name/description). */ km: string; include: boolean; } /** Chainage from free text: road notation "12+400" (km+m) always wins; otherwise the * first number, interpreted in `unit`. Returns meters, or null when nothing parses. */ function parseChainageText(text: string, unit: "km" | "m"): number | null { const plus = text.match(/(\d+)\s*\+\s*(\d+(?:\.\d+)?)/); if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]); const num = text.match(/-?\d+(?:[.,]\d+)?/); if (!num) return null; const v = parseFloat(num[0].replace(",", ".")); if (isNaN(v)) return null; return unit === "km" ? v * 1000 : v; } /** Extract Point placemarks (name + lat/lon) from a KML document. LineStrings and * multi-coordinate placemarks are skipped (counted) — only survey points import. */ function parseKmlPoints(text: string): { rows: { name: string; lat: number; lon: number }[]; skipped: number } { const doc = new DOMParser().parseFromString(text, "application/xml"); if (doc.getElementsByTagName("parsererror").length > 0) { throw new Error("not a valid KML/XML file (KMZ must be unzipped first)"); } const rows: { name: string; lat: number; lon: number }[] = []; let skipped = 0; for (const pm of Array.from(doc.getElementsByTagNameNS("*", "Placemark"))) { const coords = pm.getElementsByTagNameNS("*", "coordinates")[0]?.textContent?.trim() ?? ""; const tuples = coords.split(/\s+/).filter(Boolean); if (tuples.length !== 1) { skipped += 1; // a LineString/Polygon (or empty) — not a survey point continue; } const [lon, lat] = tuples[0].split(",").map(parseFloat); if (isNaN(lat) || isNaN(lon)) { skipped += 1; continue; } const name = pm.getElementsByTagNameNS("*", "name")[0]?.textContent?.trim() || pm.getElementsByTagNameNS("*", "description")[0]?.textContent?.trim() || ""; rows.push({ name, lat, lon }); } return { rows, skipped }; } /** * Admin chainage panel: manage road-type labels, enter the client's calibration * points (lat/lon ↔ chainage), run the two-point quick assign ("first video starts * at X, last ends at Y") or a full recompute over the current group, and read the * resulting per-span scales + per-video chainage. `sr` (GPS-measured) chainage is * shown but never editable — only the client-calibrated values come from points. */ export function ChainagePanel({ projectId, videos, roadTypes, onChanged, }: { projectId: number; videos: VideoRow[]; roadTypes: RoadType[]; onChanged: () => void; }) { const [open, setOpen] = useState(false); const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos) const [points, setPoints] = useState([]); const [calibs, setCalibs] = useState([]); const [calibsErr, setCalibsErr] = useState(null); const [summary, setSummary] = useState(null); // true = loaded from the server (someone's earlier compute); false = just run here. const [summaryIsSaved, setSummaryIsSaved] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // add-point form const [latlon, setLatlon] = useState(""); const [pointKm, setPointKm] = useState(""); const [pointNote, setPointNote] = useState(""); // inline note edit on an existing point const [noteEdit, setNoteEdit] = useState<{ id: number; text: string } | null>(null); // quick two-point form const [startKm, setStartKm] = useState("0"); const [endKm, setEndKm] = useState(""); // road-type editor const [newType, setNewType] = useState(""); // KML import preview const [kmlRows, setKmlRows] = useState(null); const [kmlUnit, setKmlUnit] = useState<"km" | "m">("km"); const [kmlSkipped, setKmlSkipped] = useState(0); const kmlFileRef = useRef(null); const loadPoints = useCallback(async () => { try { setPoints(await api.chainagePoints(projectId)); } catch (e) { setError(String(e)); } // Calibration list failing must not hide the points editor — but it must not // fail silently either (a broken endpoint/auth issue should be visible). try { setCalibs(await api.chainageCalibrations(projectId)); setCalibsErr(null); } catch (e) { setCalibs([]); setCalibsErr(String(e)); } }, [projectId]); useEffect(() => { if (open) void loadPoints(); }, [open, loadPoints]); // On open / group change, show the SAVED result of the last recompute (persisted // server-side) so a freshly signed-in admin sees the existing compute for LHS/RHS // instead of an empty panel asking to recompute. useEffect(() => { setSummary(null); setSummaryIsSaved(false); if (!open) return; let alive = true; api.chainageSummary(projectId, group) .then((s) => { if (alive && s && typeof s === "object") { setSummary(s); setSummaryIsSaved(true); } }) .catch(() => {}); // calibrations block surfaces endpoint failures already return () => { alive = false; }; }, [projectId, group, open]); /** The route's videos = current group, ignored ones excluded. */ const groupVideos = useMemo( () => videos.filter((v) => !v.ignored && (group === "" || v.road_type === group)), [videos, group], ); const groupPoints = useMemo( () => points.filter((p) => p.road_type === group || p.road_type === ""), [points, group], ); // Suggest quick-assign values from the group's own videos: an existing // calibration's range when one exists, else 0 → the summed GPS length rounded to // the nearest whole km. Re-suggested when the group changes (still editable). useEffect(() => { if (!open) return; const ch = groupVideos .flatMap((v) => [v.chainage_start_m, v.chainage_end_m]) .filter((x): x is number => x != null); if (ch.length > 0) { setStartKm((Math.min(...ch) / 1000).toFixed(4)); setEndKm((Math.max(...ch) / 1000).toFixed(4)); } else { const len = groupVideos.reduce((s, v) => s + (v.gps_len_m ?? 0), 0); setStartKm("0"); setEndKm(len > 0 ? String(Math.max(1, Math.round(len / 1000))) : ""); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, group]); const run = async (fn: () => Promise) => { setBusy(true); setError(null); try { setSummary(await fn()); setSummaryIsSaved(false); await loadPoints(); onChanged(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; const addPoint = async () => { const m = latlon.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*$/); const km = parseFloat(pointKm); if (!m || isNaN(km)) { setError('Point needs "lat, lon" and a chainage in km.'); return; } setBusy(true); setError(null); try { await api.addChainagePoint(projectId, { lat: parseFloat(m[1]), lon: parseFloat(m[2]), chainage_m: km * 1000, note: pointNote.trim(), road_type: group, }); setLatlon(""); setPointKm(""); setPointNote(""); await loadPoints(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; const deletePoint = async (id: number) => { setBusy(true); try { await api.deleteChainagePoint(id); await loadPoints(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; /** Persist an inline note edit (Enter or blur). */ const saveNote = async () => { if (!noteEdit) return; const { id, text } = noteEdit; setNoteEdit(null); try { await api.updateChainagePointNote(id, text); await loadPoints(); } catch (e) { setError(String(e)); } }; const quick = () => { const s = parseFloat(startKm); const e = parseFloat(endKm); if (isNaN(s) || isNaN(e)) { setError("Quick assign needs start and end chainage in km."); return; } void run(() => api.chainageQuick(projectId, { video_ids: groupVideos.map((v) => v.id), start_m: s * 1000, end_m: e * 1000, road_type: group, }), ); }; const recompute = () => run(() => api.chainageRecompute(projectId, groupVideos.map((v) => v.id), group)); const addRoadType = async () => { const name = newType.trim(); if (!name) return; setBusy(true); setError(null); try { await api.setRoadTypes(projectId, [...roadTypes.map((r) => r.name), name]); setNewType(""); onChanged(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; const onKmlFile = async (file: File) => { setError(null); try { let text: string; if (/\.kmz$/i.test(file.name)) { // KMZ = zipped KML; take the first .kml entry (usually doc.kml). const entries = unzipSync(new Uint8Array(await file.arrayBuffer())); const key = Object.keys(entries).find((k) => k.toLowerCase() === "doc.kml") ?? Object.keys(entries).find((k) => /\.kml$/i.test(k)); if (!key) { setError(`${file.name} contains no .kml document.`); return; } text = new TextDecoder().decode(entries[key]); } else { text = await file.text(); } const { rows, skipped } = parseKmlPoints(text); if (rows.length === 0) { setError(`No point placemarks found in ${file.name}${skipped ? ` (${skipped} non-point entries skipped)` : ""}.`); return; } setKmlSkipped(skipped); setKmlRows(rows.map((r) => { const m = parseChainageText(r.name, kmlUnit); return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null }; })); } catch (e) { setError(String(e)); } finally { if (kmlFileRef.current) kmlFileRef.current.value = ""; // allow re-picking the same file } }; /** Re-read every row's chainage from its placemark name under the new unit. */ const setKmlUnitAndReparse = (u: "km" | "m") => { setKmlUnit(u); setKmlRows((rows) => rows?.map((r) => { const m = parseChainageText(r.name, u); return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null }; }) ?? null); }; const importKml = async () => { if (!kmlRows) return; const points = kmlRows .filter((r) => r.include && r.km.trim() !== "" && !isNaN(parseFloat(r.km))) .map((r) => ({ lat: r.lat, lon: r.lon, chainage_m: parseFloat(r.km) * 1000, note: r.name, road_type: group, })); if (points.length === 0) { setError("No rows selected with a valid chainage."); return; } setBusy(true); setError(null); try { await api.addChainagePointsBulk(projectId, points); setKmlRows(null); await loadPoints(); onChanged(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; const removeRoadType = async (name: string) => { setBusy(true); setError(null); try { await api.setRoadTypes(projectId, roadTypes.map((r) => r.name).filter((n) => n !== name)); if (group === name) setGroup(""); onChanged(); } catch (e) { setError(String(e)); } finally { setBusy(false); } }; if (!open) { return (

Chainage & road types

); } return (

Chainage & road types

{error &&
⚠ {error}
} {/* Road-type labels (project-wide) */}
Road types (assign them to selected videos in the table below)
{roadTypes.map((r) => ( {r.name} ({r.video_count}) ))} setNewType(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }} placeholder="Add: LHS / RHS / MCW…" />
{/* Calibration group + the videos it covers */}
{groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s)
{/* Saved calibrations — persisted server-side, so every user (dashboard AND desktop exports) sees the same computed chainage without re-running anything. */} {(calibs.length > 0 || calibsErr) && (
Saved calibrations (shared — all users see these chainages)
{calibsErr &&
⚠ calibrations unavailable: {calibsErr}
} {calibs.map((c) => (
{c.road_type || "(ungrouped)"} · route {fmtChainage(c.route_len_m)} km · {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)} {!c.has_summary && ( {" "}· result not saved yet — recompute once )}
))}
)}
{/* Control points */}
Client calibration points (lat/lon ↔ chainage)
{groupPoints.map((p) => ( ))} {groupPoints.length === 0 && ( )}
ChainageLat, LonGroupNote
{fmtChainage(p.chainage_m)} {p.origin === "quick" && ( · auto )} {p.lat.toFixed(6)}, {p.lon.toFixed(6)} {p.road_type || "any"} { if (noteEdit?.id !== p.id) setNoteEdit({ id: p.id, text: p.note }); }} > {noteEdit?.id === p.id ? ( setNoteEdit({ id: p.id, text: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter") void saveNote(); if (e.key === "Escape") setNoteEdit(null); }} onBlur={() => void saveNote()} /> ) : ( p.note || "— (click to add)" )}
no points yet — add them, or use the quick assign
setLatlon(e.target.value)} placeholder="lat, lon" style={{ width: 190 }} /> setPointKm(e.target.value)} placeholder="chainage (km)" style={{ width: 110 }} /> setPointNote(e.target.value)} placeholder="note (optional)" style={{ width: 140 }} /> { const f = e.target.files?.[0]; if (f) void onKmlFile(f); }} />
{kmlRows && (
{kmlRows.filter((r) => r.include).length} of {kmlRows.length} points will import into group "{group || "any"}" {kmlSkipped > 0 && ` · ${kmlSkipped} non-point placemark(s) skipped`} · values in names are ("12+400" always = 12 km + 400 m)
{kmlRows.map((r, i) => ( ))}
PlacemarkLat, LonChainage (km)
setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, include: !x.include } : x))} /> {r.name || (unnamed)} {r.lat.toFixed(6)}, {r.lon.toFixed(6)} setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, km: e.target.value, include: true } : x))} />
)}
{/* Quick assign + recompute */}
Two-point quick assign — client gives just the chainage at the route's two ends; the error is distributed over the whole route. Values are pre-filled from the {group ? `"${group}"` : "selected"} videos.
setStartKm(e.target.value)} style={{ width: 90 }} /> km setEndKm(e.target.value)} style={{ width: 90 }} /> km
The two auto points ("quick: chain start/end") are saved into group {group || "any"}. After computing, check the km stones on the map — if 0 sits at the wrong end of the road, hit ⇄ swap and compute again. Footage direction doesn't matter (handled automatically).
orders the {groupVideos.length} video(s) into a route, snaps the points, distributes the error
{/* Result summary — freshly run, or the saved result of the last recompute */} {summary && (
{summaryIsSaved && (() => { const c = calibs.find((x) => x.road_type === group); return (
Saved result of the last compute {c ? <> — by {c.computed_by} · {fmtWhen(c.computed_at)} : null} {" "}(recompute only if points or videos changed)
); })()}
Route {(summary.route_len_m / 1000).toFixed(3)} km (GPS-measured) · {summary.points_used} point(s) used {summary.points_excluded > 0 && · {summary.points_excluded} excluded} {summary.reversed && · direction auto-reversed} {summary.majority_flipped && ( {" "}· footage mostly runs high → low chainage )} {summary.videos.length > 1 && (() => { const ag = summary.videos.filter((v) => v.against_flow).length; return ( 0 ? "err-tag" : "dim"} title={ag > 0 ? "Videos running against the route's dominant direction — on a divided road this often means the video belongs to the other carriageway (wrong group)." : "Every video runs in the route's dominant direction."}> {" "}· {summary.videos.length - ag}/{summary.videos.length} follow the dominant direction{ag > 0 ? `, ${ag} against` : ""} ); })()}
{summary.warnings.length > 0 && (
    {summary.warnings.map((w, i) =>
  • ⚠ {w}
  • )}
)} {summary.spans.length > 0 && ( {summary.spans.map((sp, i) => ( 0.05 ? "span-warn" : undefined}> ))}
Span (client)GPS lengthClient lengthScale
{fmtChainage(sp.from_chainage_m)} → {fmtChainage(sp.to_chainage_m)} {(sp.gps_len_m / 1000).toFixed(3)} km {(sp.client_len_m / 1000).toFixed(3)} km ×{sp.scale.toFixed(4)}
)} {summary.videos.map((v) => ( ))}
#VideoChainage (client)SR chainage (GPS)
{v.seq + 1} {v.file_name} {fmtChainageRange(v.chainage_start_m, v.chainage_end_m)} {fmtChainageRange(v.sr_start_m, v.sr_end_m)} {v.against_flow && ( ⇄ against flow )}
{summary.skipped.length > 0 && (
Skipped (no GPS): {summary.skipped.join(", ")}
)}
)}
); }