From e7ab0f3368a7e420449369f7665aeba92f8a77b0 Mon Sep 17 00:00:00 2001 From: ravi Date: Wed, 8 Jul 2026 17:03:24 +0530 Subject: [PATCH] chainage compute and map view --- README.md | 22 +++- dashboard/src/ChainagePanel.tsx | 134 ++++++++++++++++++-- dashboard/src/MapView.tsx | 217 ++++++++++++++++++++++++++------ dashboard/src/api.ts | 22 ++++ dashboard/src/styles.css | 17 ++- db/init.sql | 4 + server/src/chainage.rs | 177 ++++++++++++++++++++++++-- server/src/gps.rs | 104 +++++++++++++-- server/src/main.rs | 3 +- 9 files changed, 622 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 045f431..8d81749 100644 --- a/README.md +++ b/README.md @@ -201,12 +201,30 @@ Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist the dashboard's **KML import** uses this (placemark names like `12+400` or `12.4` become chainage) (admin) - `DELETE /api/chainage/points/:id` — remove a point (admin) +- `POST /api/chainage/points/:id` `{note}` — edit a point's note in place; position/chainage + stay immutable (admin). Points carry an `origin` field: + `''` = manual, `'quick'` = generated by the quick assign + (replaced wholesale on the next quick run — identity is + the column, so renaming the note is safe) +- `GET /api/projects/:id/chainage/calibrations` — the saved calibrations, one per road-type group + (`road_type, route_len_m, anchor_count, computed_by, + computed_at`) — every recompute persists one, so any user + sees what's already calibrated (member/admin) - `POST /api/projects/:id/chainage/recompute` `{video_ids, road_type}` — order the videos into a route, snap the points, distribute the client-vs-GPS error piecewise-linearly; writes `chainage_*` (client) + - `sr_chainage_*` (measured, immutable) + `route_seq` (admin) + `sr_chainage_*` (measured, immutable) + `route_seq` (admin). + Summary reports `majority_flipped` + per-video + `against_flow` (opposite to the route's dominant direction — + the only direction flag the UI shows). Warnings include + mixed directions (≥25% against the majority → probably both + carriageways in one group) and ambiguous untagged points + (used by this route but also within 150 m of another + road-type group's videos — tag them) - `POST /api/projects/:id/chainage/quick` `{video_ids, start_m, end_m, road_type}` — the - two-point case: points at the route ends + same recompute (admin) + two-point case: points at the route's two geometric ends + (notes `quick: chain start/end`, tagged with the group) + + same recompute (admin) ### Env diff --git a/dashboard/src/ChainagePanel.tsx b/dashboard/src/ChainagePanel.tsx index 146f5c3..f86ed62 100644 --- a/dashboard/src/ChainagePanel.tsx +++ b/dashboard/src/ChainagePanel.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { unzipSync } from "fflate"; import { - api, fmtChainage, fmtChainageRange, - type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow, + 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, @@ -102,6 +102,8 @@ export function ChainagePanel({ 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); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -109,6 +111,8 @@ export function ChainagePanel({ 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(""); @@ -126,6 +130,15 @@ export function ChainagePanel({ } 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(() => { @@ -143,6 +156,25 @@ export function ChainagePanel({ [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); @@ -197,6 +229,19 @@ export function ChainagePanel({ } }; + /** 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); @@ -374,6 +419,21 @@ export function ChainagePanel({ + {/* 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)} +
+ ))} +
+ )} +
{/* Control points */}
@@ -385,10 +445,36 @@ export function ChainagePanel({ {groupPoints.map((p) => ( - {fmtChainage(p.chainage_m)} + + {fmtChainage(p.chainage_m)} + {p.origin === "quick" && ( + · auto + )} + {p.lat.toFixed(6)}, {p.lon.toFixed(6)} {p.road_type || "any"} - {p.note || "—"} + { 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)" + )} + ))} @@ -464,18 +550,28 @@ export function ChainagePanel({ {/* Quick assign + recompute */}
- Two-point quick assign — client gives just first & last chainage; the error is - distributed over the whole route + 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)} placeholder="49.5" 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). +
{summary.warnings.length > 0 && (
    @@ -528,9 +638,9 @@ export function ChainagePanel({ {v.file_name} {fmtChainageRange(v.chainage_start_m, v.chainage_end_m)} {fmtChainageRange(v.sr_start_m, v.sr_end_m)} - {v.flipped && ( - - ⇄ flipped + {v.against_flow && ( + + ⇄ against flow )} diff --git a/dashboard/src/MapView.tsx b/dashboard/src/MapView.tsx index d2773bd..2da4dea 100644 --- a/dashboard/src/MapView.tsx +++ b/dashboard/src/MapView.tsx @@ -106,6 +106,59 @@ const SPEED_LEGEND: [string, string][] = [ ["60–80", "#f5c838"], ["80–100", "#f5a623"], ["100+", "#ff5252"], ]; +/** GPS dropout duration, readable at any size ("57 s" / "12 min" / "2.5 h"). */ +function fmtGap(s: number): string { + if (s < 120) return `${s.toFixed(0)} s`; + if (s < 7200) return `${Math.round(s / 60)} min`; + return `${(s / 3600).toFixed(1)} h`; +} + +/** + * GPS coverage bar over the video's duration: green = real fixes, amber = a gap + * bridged by time-interpolation, red = no GPS at the start/end (positions clamp), + * blue tick = the start was stitched from the previous video. QC can see at a + * glance whether per-frame annotation geo rests on real samples or interpolation. + */ +function GpsBar({ v }: { v: MapVideo }) { + const t = v.track; + if (!t || t.length < 2) return null; + const lastT = t[t.length - 1][2]; + const dur = v.duration_s && v.duration_s > lastT ? v.duration_s : lastT; + if (!(dur > 0)) return null; + // Gap threshold relative to the track's own median cadence — the map track is + // downsampled, so a fixed "5 s" would flag artifacts on long videos. + const dts: number[] = []; + for (let i = 1; i < t.length; i++) dts.push(t[i][2] - t[i - 1][2]); + const median = [...dts].sort((a, b) => a - b)[Math.floor(dts.length / 2)] || 1; + const thr = Math.max(5, 3 * median); + // Merge consecutive same-kind stretches so the bar is a handful of divs. + const segs: { x: number; w: number; kind: "ok" | "gap" }[] = []; + for (let i = 1; i < t.length; i++) { + const kind: "ok" | "gap" = t[i][2] - t[i - 1][2] > thr ? "gap" : "ok"; + const last = segs[segs.length - 1]; + if (last && last.kind === kind) last.w += (t[i][2] - t[i - 1][2]) / dur; + else segs.push({ x: t[i - 1][2] / dur, w: (t[i][2] - t[i - 1][2]) / dur, kind }); + } + const startMiss = t[0][2] / dur; + const endMiss = (dur - lastT) / dur; + const stitched = (v.gps_corrected_note ?? "").toLowerCase().includes("stitch"); + const title = + `GPS coverage across the video's ${Math.round(dur)} s — green: real fixes; ` + + `amber: dropout bridged by time-interpolation; red: no GPS at the start/end (positions clamp to the nearest fix)` + + (stitched ? "; blue tick: start borrowed from the previous video" : ""); + return ( +
    + {startMiss > 0.005 &&
    } + {segs.map((s, i) => ( +
    + ))} + {endMiss > 0.005 &&
    } + {stitched &&
    } +
    + ); +} + /** Bearing (deg, 0 = north) from a → b, for the direction arrows. */ function bearingDeg(a: [number, number, number], b: [number, number, number]): number { const dLat = b[0] - a[0]; @@ -174,9 +227,12 @@ export function MapView({ const [error, setError] = useState(null); const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null); + const [zoom, setZoom] = useState(5); const mapDiv = useRef(null); const mapRef = useRef(null); const trackLayer = useRef(null); + const arrowLayer = useRef(null); + const kmLayer = useRef(null); const pointLayer = useRef(null); const correctionLayer = useRef(null); const searchLayer = useRef(null); @@ -229,24 +285,29 @@ export function MapView({ if (!mapDiv.current || mapRef.current) return; const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5, maxZoom: 22 }); // Two basemaps (both optional — tracks render fine when tiles can't load): - // OSM streets and Esri satellite. maxNativeZoom 19 + maxZoom 22 over-zooms the - // deepest tiles so you can inspect a single junction ("deep zoom"). + // OSM streets and Google satellite. Google serves imagery down to z≈21 in most + // regions ("deep zoom"); maxZoom 22 over-zooms the deepest native tile. const osm = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 22, maxNativeZoom: 19, attribution: '© OpenStreetMap contributors', }); - const sat = L.tileLayer( - "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - { maxZoom: 22, maxNativeZoom: 19, attribution: "Esri, Maxar, Earthstar Geographics" }, - ); + const sat = L.tileLayer("https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", { + subdomains: ["mt0", "mt1", "mt2", "mt3"], + maxZoom: 22, + maxNativeZoom: 21, + attribution: "Google", + }); osm.addTo(map); baseLayers.current = { map: osm, sat }; trackLayer.current = L.layerGroup().addTo(map); + arrowLayer.current = L.layerGroup().addTo(map); + kmLayer.current = L.layerGroup().addTo(map); coverageLayer.current = L.layerGroup().addTo(map); pointLayer.current = L.layerGroup().addTo(map); correctionLayer.current = L.layerGroup().addTo(map); searchLayer.current = L.layerGroup().addTo(map); + map.on("zoomend", () => setZoom(map.getZoom())); mapRef.current = map; setTimeout(() => map.invalidateSize(), 0); return () => { @@ -481,30 +542,19 @@ export function MapView({ if (sel) line.bringToFront(); } - // Selected video: travel-direction arrows + green start / red end dots. + // Selected video: green start / red end dots, labeled with the CHAINAGE at + // each end (coordinates live in the details card, not on the map). if (sel) { const t = v.track!; + const chTip = (m: number | null, fallback: [number, number]) => + m != null ? `${(m / 1000).toFixed(4)}` : `${fallback[0].toFixed(6)}, ${fallback[1].toFixed(6)}`; L.circleMarker([t[0][0], t[0][1]], { radius: 6, color: "#0a1020", weight: 2, fillColor: "#5ccf75", fillOpacity: 1 }) - .bindTooltip(`start · ${t[0][0].toFixed(6)}, ${t[0][1].toFixed(6)}`) + .bindTooltip(`start · ${chTip(v.chainage_start_m, [t[0][0], t[0][1]])}`) .addTo(layer); const last = t[t.length - 1]; L.circleMarker([last[0], last[1]], { radius: 6, color: "#0a1020", weight: 2, fillColor: "#ff5252", fillOpacity: 1 }) - .bindTooltip(`end · ${last[0].toFixed(6)}, ${last[1].toFixed(6)}`) + .bindTooltip(`end · ${chTip(v.chainage_end_m, [last[0], last[1]])}`) .addTo(layer); - const n = Math.min(7, Math.floor(t.length / 2)); - for (let k = 1; k <= n; k++) { - const i = Math.min(Math.floor((t.length - 2) * (k / (n + 1))), t.length - 2); - const rot = bearingDeg(t[i], t[i + 1]); - L.marker([t[i][0], t[i][1]], { - interactive: false, - icon: L.divIcon({ - className: "dir-arrow-wrap", - html: `
    `, - iconSize: [16, 16], - iconAnchor: [8, 8], - }), - }).addTo(layer); - } } } if (!fitted.current && visible.length > 0) { @@ -515,6 +565,85 @@ export function MapView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, colorBy, selectedId, correction, select]); + // ---- travel-direction arrows (own layer; density from ON-SCREEN track length) ---- + // An arrow every ~140 screen-px (cap 3, selected 8). Zoomed out a whole video is a + // few pixels wide and gets none — the track colors stay readable; zoom in and the + // arrows appear. + useEffect(() => { + const layer = arrowLayer.current; + const map = mapRef.current; + if (!layer || !map) return; + layer.clearLayers(); + for (const v of visible) { + if (correction && correction.videoId === v.id) continue; + const t = v.track!; + if (t.length < 2) continue; + const stride = Math.max(1, Math.floor(t.length / 16)); + let px = 0; + let prev = map.project([t[0][0], t[0][1]]); + for (let i = stride; i < t.length; i += stride) { + const q = map.project([t[i][0], t[i][1]]); + px += prev.distanceTo(q); + prev = q; + } + const sel = v.id === selectedId; + const n = Math.min(sel ? 8 : 3, Math.floor(px / 140), Math.floor(t.length / 2)); + for (let k = 1; k <= n; k++) { + const i = Math.min(Math.floor((t.length - 2) * (k / (n + 1))), t.length - 2); + const rot = bearingDeg(t[i], t[i + 1]); + L.marker([t[i][0], t[i][1]], { + interactive: false, + icon: L.divIcon({ + className: "dir-arrow-wrap", + html: `
    `, + iconSize: sel ? [16, 16] : [12, 12], + iconAnchor: sel ? [8, 8] : [6, 6], + }), + }).addTo(layer); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, zoom, selectedId, correction]); + + // ---- km milestone markers along calibrated routes (zoom-adaptive density) ---- + // Whole-km chainage labels ("1"…"150") pinned to the track; the step widens as you + // zoom out so the view never crowds. Deduped per (road, km) — LHS and RHS each get + // their own stones. + useEffect(() => { + const layer = kmLayer.current; + if (!layer) return; + layer.clearLayers(); + const step = zoom >= 14 ? 1 : zoom >= 13 ? 2 : zoom >= 12 ? 5 : zoom >= 10 ? 10 : zoom >= 8 ? 20 : 50; + const seen = new Set(); + let drawn = 0; + for (const v of visible) { + if (v.chainage_start_m == null || v.chainage_end_m == null) continue; + const cum = cumFor(v); + if (!cum) continue; + const loKm = Math.min(v.chainage_start_m, v.chainage_end_m) / 1000; + const hiKm = Math.max(v.chainage_start_m, v.chainage_end_m) / 1000; + for (let k = Math.ceil(loKm / step) * step; k <= hiKm && drawn < 400; k += step) { + const key = `${v.road_type}|${k}`; + if (seen.has(key)) continue; + const pt = pointAtChainage(v, cum, k * 1000); + if (!pt) continue; + seen.add(key); + drawn += 1; + L.marker(pt, { + icon: L.divIcon({ + className: "km-mark-wrap", + html: `
    ${k}
    `, + iconSize: [26, 15], + iconAnchor: [13, 7], + }), + }) + .bindTooltip(`chainage ${k.toFixed(4)}${v.road_type ? ` · ${v.road_type}` : ""}`) + .addTo(layer); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, zoom]); + // ---- draw coverage findings (gaps amber, overlaps purple halo) ---- useEffect(() => { const layer = coverageLayer.current; @@ -683,7 +812,7 @@ export function MapView({
    + title="Google satellite imagery — deep zoom, inspect a single junction">Satellite
    {roadTypesPresent.length > 1 && (
    @@ -753,6 +882,21 @@ export function MapView({ Road type {selected.road_type || "—"} + Chainage + {fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)} + SR chainage + + {fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)} + + {selected.speed_avg_mps != null && ( + <> + Speed + + avg {(selected.speed_avg_mps * 3.6).toFixed(0)} km/h + {selected.speed_max_mps != null && <> · max {(selected.speed_max_mps * 3.6).toFixed(0)} km/h} + + + )} {selected.track && selected.track.length >= 2 && ( <> First fix @@ -763,21 +907,6 @@ export function MapView({ )} - {selected.speed_avg_mps != null && ( - <> - Speed - - avg {(selected.speed_avg_mps * 3.6).toFixed(0)} km/h - {selected.speed_max_mps != null && <> · max {(selected.speed_max_mps * 3.6).toFixed(0)} km/h} - - - )} - Chainage - {fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)} - SR chainage - - {fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)} - GPS {selected.gps_status === "ok" ? ( @@ -786,7 +915,7 @@ export function MapView({ {selected.gps_sample_count != null && · {selected.gps_sample_count} fixes} {(selected.gps_max_gap_s ?? 0) > 5 && ( - {" "}· gap {selected.gps_max_gap_s!.toFixed(0)}s ⚠ + {" "}· gap {fmtGap(selected.gps_max_gap_s!)} ⚠ )} @@ -801,6 +930,12 @@ export function MapView({
    )} + {selected.gps_status === "ok" && selected.track && selected.track.length >= 2 && ( + <> + Coverage + + + )} Annotations {selected.annotation_count}
    @@ -977,7 +1112,7 @@ export function MapView({ {v.file_name} - dropout {(v.gps_max_gap_s ?? 0).toFixed(0)} s + dropout {fmtGap(v.gps_max_gap_s ?? 0)}
    ))} diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 110666e..0e9025f 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -146,6 +146,8 @@ export interface ChainagePoint { chainage_m: number; note: string; road_type: string; + /** '' = manual; 'quick' = generated by quick assign (replaced on the next run). */ + origin: string; created_by: string; created_at: string; } @@ -163,6 +165,8 @@ export interface VideoChainageRow { file_name: string; seq: number; flipped: boolean; + /** Runs OPPOSITE to the majority of the route — the only direction flag shown. */ + against_flow: boolean; sr_start_m: number; sr_end_m: number; chainage_start_m: number; @@ -172,6 +176,8 @@ export interface VideoChainageRow { export interface CalibrationSummary { route_len_m: number; reversed: boolean; + /** Most footage runs toward decreasing chainage — the route's normal pattern. */ + majority_flipped: boolean; points_used: number; points_excluded: number; spans: SpanRow[]; @@ -180,6 +186,16 @@ export interface CalibrationSummary { skipped: string[]; } +/** A saved calibration (persisted per road-type group by every recompute) — any + * member who opens the project sees what's already computed, by whom and when. */ +export interface CalibrationRow { + road_type: string; + route_len_m: number; + anchor_count: number; + computed_by: string; + computed_at: string; +} + /** Result of the GPS correction preview/keep — original + corrected tracks + stats. * The pipeline = spike removal + (when a temporal predecessor exists) a * missing-start stitch that re-bases the clock to the true video start. */ @@ -428,6 +444,12 @@ export const api = { points: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }[], ) => post<{ added: number }>(`/projects/${projectId}/chainage/points/bulk`, { points }), deleteChainagePoint: (pointId: number) => del<{ deleted: boolean }>(`/chainage/points/${pointId}`), + // Edit a point's note in place (admin); position/chainage stay immutable. + updateChainagePointNote: (pointId: number, note: string) => + post<{ updated: boolean }>(`/chainage/points/${pointId}`, { note }), + // Saved calibrations per group (member or admin) — proof the chainage is computed. + chainageCalibrations: (projectId: number) => + get(`/projects/${projectId}/chainage/calibrations`), chainageRecompute: (projectId: number, video_ids: number[], road_type: string) => post(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }), chainageQuick: ( diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 6b55435..091b74e 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -298,12 +298,27 @@ tr.rank-top { background: rgba(245,200,56,0.08); } font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .map-nometa-name:hover { color: var(--info); } .btn.mini { padding: 1px 7px; font-size: 11px; } -/* Travel-direction arrows along the selected track */ +/* Travel-direction arrows along every track (bigger on the selected one) */ .dir-arrow-wrap { background: none; border: none; } .dir-arrow { color: #ffffff; font-size: 13px; line-height: 16px; text-align: center; text-shadow: 0 0 3px #000, 0 0 5px #000; pointer-events: none; } +.dir-arrow.sm { font-size: 10px; line-height: 12px; opacity: 0.85; } +/* Whole-km chainage milestones pinned on calibrated tracks */ +.km-mark-wrap { background: none; border: none; } +.km-mark { min-width: 22px; padding: 0 4px; text-align: center; font-size: 10px; font-weight: 700; + color: #0a1020; background: #f5c838; border: 1px solid #0a1020; border-radius: 8px; + line-height: 13px; box-shadow: 0 0 3px rgba(0,0,0,.6); } /* Idle / dropout tags in the data-quality panel */ .idle-tag { color: var(--warn); font-size: 11px; white-space: nowrap; } +/* Per-video GPS coverage bar (details card): green real fixes, amber interpolated + gaps, red missing start/end, blue tick = stitched start */ +.gps-bar { position: relative; height: 8px; min-width: 120px; background: #2a3350; + border-radius: 4px; overflow: hidden; margin-top: 4px; } +.gps-bar-seg { position: absolute; top: 0; bottom: 0; } +.gps-bar-seg.ok { background: #5ccf75; } +.gps-bar-seg.gap { background: #f5a623; } +.gps-bar-seg.miss { background: #ff5252; } +.gps-bar-stitch { position: absolute; top: 0; bottom: 0; width: 3px; background: #6ea8ff; } /* Leaflet dark-theme nudges */ .leaflet-container { font: inherit; } diff --git a/db/init.sql b/db/init.sql index 3d5d39a..b5013dd 100644 --- a/db/init.sql +++ b/db/init.sql @@ -291,6 +291,10 @@ CREATE TABLE IF NOT EXISTS chainage_points ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(project_id); +-- origin marks generated points ('quick' = the two-point quick assign) so they can +-- be replaced on the next quick run regardless of the (freely editable) note text. +ALTER TABLE chainage_points ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT ''; +UPDATE chainage_points SET origin='quick' WHERE origin='' AND note LIKE 'quick:%'; ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_start_m DOUBLE PRECISION; ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_end_m DOUBLE PRECISION; ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION; diff --git a/server/src/chainage.rs b/server/src/chainage.rs index fdbe35b..ef020f0 100644 --- a/server/src/chainage.rs +++ b/server/src/chainage.rs @@ -223,6 +223,26 @@ fn route_quality_warnings(route: &Route) -> Vec { out } +/// Min perpendicular distance (m) from a point to a polyline track — used by the +/// contamination detector to test a calibration point against OTHER groups' videos. +fn min_dist_to_track(track: &[Pt], lat: f64, lon: f64) -> f64 { + let (m_lat, m_lon) = wgs84_m_per_deg(lat); + let (px, py) = (lon * m_lon, lat * m_lat); + let mut best = f64::INFINITY; + for w in track.windows(2) { + let (ax, ay) = (w[0][1] * m_lon, w[0][0] * m_lat); + let (bx, by) = (w[1][1] * m_lon, w[1][0] * m_lat); + let (dx, dy) = (bx - ax, by - ay); + let l2 = dx * dx + dy * dy; + let t = if l2 > 0.0 { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; + best = best.min(((px - (ax + t * dx)).powi(2) + (py - (ay + t * dy)).powi(2)).sqrt()); + } + if track.len() == 1 { + best = best.min(dist_m(lat, lon, track[0][0], track[0][1])); + } + best +} + /// Project (lat, lon) onto the nearest route segment → (s along the route, /// perpendicular distance in meters). Local flat projection using the WGS84 /// per-degree factors at the point's latitude — same meter as `dist_m`. @@ -443,6 +463,9 @@ pub struct ChainagePoint { pub chainage_m: f64, pub note: String, pub road_type: String, + /// '' = manually entered; 'quick' = generated by the two-point quick assign + /// (replaced wholesale on the next quick run — identity independent of the note). + pub origin: String, pub created_by: String, pub created_at: chrono::DateTime, } @@ -493,6 +516,10 @@ pub struct VideoChainageRow { pub file_name: String, pub seq: i32, pub flipped: bool, + /// Runs opposite to the MAJORITY of the route's videos — the only direction flag + /// worth surfacing (when the whole road was filmed toward decreasing chainage, + /// "flipped" is the norm, not an anomaly). + pub against_flow: bool, pub sr_start_m: f64, pub sr_end_m: f64, pub chainage_start_m: f64, @@ -503,6 +530,9 @@ pub struct VideoChainageRow { pub struct CalibrationSummary { pub route_len_m: f64, pub reversed: bool, + /// Most of the footage runs toward DECREASING chainage — that's this route's + /// normal pattern, so per-video direction flags are relative to it. + pub majority_flipped: bool, pub points_used: usize, pub points_excluded: usize, pub spans: Vec, @@ -523,7 +553,7 @@ pub async fn list_points( ) -> ApiResult> { crate::require_project_access(&s, &user, id).await?; let rows = sqlx::query_as::<_, ChainagePoint>( - r#"SELECT id, lat, lon, chainage_m, note, road_type, created_by, created_at + r#"SELECT id, lat, lon, chainage_m, note, road_type, origin, created_by, created_at FROM chainage_points WHERE project_id=$1 ORDER BY road_type, chainage_m"#, ) .bind(id) @@ -547,7 +577,7 @@ pub async fn add_point( let row = sqlx::query_as::<_, ChainagePoint>( r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7) - RETURNING id, lat, lon, chainage_m, note, road_type, created_by, created_at"#, + RETURNING id, lat, lon, chainage_m, note, road_type, origin, created_by, created_at"#, ) .bind(id) .bind(req.lat) @@ -612,6 +642,64 @@ pub async fn add_points_bulk( Ok(Json(json!({ "added": req.points.len() }))) } +#[derive(Deserialize)] +pub struct PointNoteRequest { + pub note: String, +} + +/// `POST /api/chainage/points/:id` — edit a calibration point's note (admin). +/// Lat/lon/chainage stay immutable — delete + re-add to change the measurement. +pub async fn update_point_note( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + Json(req): Json, +) -> ApiResult { + user.require_admin()?; + let updated: Option = + sqlx::query_scalar("UPDATE chainage_points SET note=$2 WHERE id=$1 RETURNING id") + .bind(id) + .bind(req.note.trim()) + .fetch_optional(&s.db) + .await + .map_err(err)?; + if updated.is_none() { + return Err((StatusCode::NOT_FOUND, "point not found".into())); + } + Ok(Json(json!({ "updated": true }))) +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct CalibrationRow { + pub road_type: String, + pub route_len_m: f64, + pub anchor_count: i32, + pub computed_by: String, + pub computed_at: chrono::DateTime, +} + +/// `GET /api/projects/:id/chainage/calibrations` — the saved calibrations (one per +/// group), so anyone who opens the project sees what's already computed and by whom +/// (member or admin). The per-video chainage itself lives on `videos` and is in every +/// list/map response regardless. +pub async fn list_calibrations( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, +) -> ApiResult> { + crate::require_project_access(&s, &user, id).await?; + let rows = sqlx::query_as::<_, CalibrationRow>( + r#"SELECT road_type, route_len_m, jsonb_array_length(anchors)::int AS anchor_count, + computed_by, computed_at + FROM chainage_calibrations WHERE project_id=$1 ORDER BY road_type"#, + ) + .bind(id) + .fetch_all(&s.db) + .await + .map_err(err)?; + Ok(Json(rows)) +} + /// `DELETE /api/chainage/points/:id` — remove a calibration point (admin). pub async fn delete_point( State(s): State, @@ -665,18 +753,22 @@ pub async fn quick_assign( let last = route.pts.last().copied().unwrap(); // Replace any previous quick points for this group, then add the fresh pair. + // Identity = origin='quick' (a real column), NOT the note text — notes are freely + // editable, so matching on them would leave stale generated anchors behind. + // "chain start/end" = the geometric ends of the ordered route — WHICH physical + // end is which is the admin's call (the swap button in the UI flips the values). sqlx::query( - "DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND note IN ('quick: route start','quick: route end')", + "DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND origin='quick'", ) .bind(id) .bind(&road_type) .execute(&s.db) .await .map_err(err)?; - for (pt, chainage, note) in [(first, req.start_m, "quick: route start"), (last, req.end_m, "quick: route end")] { + for (pt, chainage, note) in [(first, req.start_m, "quick: chain start"), (last, req.end_m, "quick: chain end")] { sqlx::query( - r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by) - VALUES ($1,$2,$3,$4,$5,$6,$7)"#, + r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by, origin) + VALUES ($1,$2,$3,$4,$5,$6,$7,'quick')"#, ) .bind(id) .bind(pt.0) @@ -746,8 +838,8 @@ async fn run_recompute( warnings.extend(route_quality_warnings(&route)); // Control points for this calibration group (tagged with it, or untagged). - let points: Vec<(i32, f64, f64, f64)> = sqlx::query_as( - r#"SELECT id, lat, lon, chainage_m FROM chainage_points + let points: Vec<(i32, f64, f64, f64, String)> = sqlx::query_as( + r#"SELECT id, lat, lon, chainage_m, road_type FROM chainage_points WHERE project_id=$1 AND (road_type=$2 OR road_type='') ORDER BY chainage_m"#, ) @@ -766,7 +858,8 @@ async fn run_recompute( // Snap each point to the route; drop the ones that don't belong to it. let mut anchors: Vec<(f64, f64)> = Vec::new(); // (s, chainage) let mut excluded = 0usize; - for (pid, lat, lon, chainage_m) in &points { + let mut used_untagged: Vec<(i32, f64, f64, f64)> = Vec::new(); + for (pid, lat, lon, chainage_m, ptag) in &points { let (sv, d) = snap(&route, *lat, *lon); if d > SNAP_MAX_M { excluded += 1; @@ -776,6 +869,44 @@ async fn run_recompute( )); } else { anchors.push((sv, *chainage_m)); + if ptag.is_empty() { + used_untagged.push((*pid, *lat, *lon, *chainage_m)); + } + } + } + + // Contamination detector: an UNTAGGED point is shared by every group's + // calibration — if it also sits within snap distance of a DIFFERENT road-type + // group's videos (parallel carriageway, ramp, service road), it's ambiguous + // which road it belongs to and can silently distort both calibrations. + if !used_untagged.is_empty() { + let others: Vec<(String, Value)> = sqlx::query_as( + r#"SELECT road_type, COALESCE(gps_track_corrected, gps_track) + FROM videos + WHERE project_id=$1 AND road_type <> '' AND road_type <> $2 + AND COALESCE(gps_track_corrected, gps_track) IS NOT NULL"#, + ) + .bind(project_id) + .bind(road_type) + .fetch_all(&s.db) + .await + .map_err(err)?; + let others: Vec<(String, Vec)> = + others.into_iter().map(|(rt, tr)| (rt, parse_track(&tr))).collect(); + for (pid, lat, lon, chainage_m) in &used_untagged { + let mut near: Vec<&str> = others + .iter() + .filter(|(_, tr)| tr.len() >= 2 && min_dist_to_track(tr, *lat, *lon) <= SNAP_MAX_M) + .map(|(rt, _)| rt.as_str()) + .collect(); + near.sort_unstable(); + near.dedup(); + if !near.is_empty() { + warnings.push(format!( + "point #{pid} ({:.4} km) is untagged and ambiguous — it also lies within {SNAP_MAX_M:.0} m of group(s) {}; tag it to the road it belongs to", + chainage_m / 1000.0, near.join(", ") + )); + } } } if anchors.len() < 2 { @@ -837,6 +968,17 @@ async fn run_recompute( let calib = Calib { anchors }; + // Direction pattern: when most videos were filmed toward decreasing chainage, + // "flipped" is the route's norm — only the exceptions are worth flagging. + let majority_flipped = route.videos.iter().filter(|v| v.flipped).count() * 2 > route.videos.len(); + let against = route.videos.iter().filter(|v| v.flipped != majority_flipped).count(); + if route.videos.len() >= 4 && against * 4 >= route.videos.len() { + warnings.push(format!( + "mixed directions: {against} of {} videos run against the dominant direction — both carriageways may be mixed in one group (split LHS/RHS by road type)", + route.videos.len() + )); + } + // Persist: calibrated videos get chainage + sr + seq; GPS-less selections are // cleared (they're not on the route). let mut out_videos = Vec::new(); @@ -862,6 +1004,7 @@ async fn run_recompute( file_name: v.file_name.clone(), seq: v.seq, flipped: v.flipped, + against_flow: v.flipped != majority_flipped, sr_start_m: v.sr_start_m, sr_end_m: v.sr_end_m, chainage_start_m: ch_start, @@ -905,6 +1048,7 @@ async fn run_recompute( Ok(CalibrationSummary { route_len_m: route.len_m(), reversed, + majority_flipped, points_used: calib.anchors.len(), points_excluded: excluded, spans, @@ -980,6 +1124,21 @@ mod tests { assert!((beyond - (2200.0 + 100.0 * last_scale)).abs() < 1.0); } + #[test] + fn min_dist_flags_nearby_foreign_tracks() { + // A parallel carriageway 0.0002° (~22 m) north: a point on our route is + // "near" it (ambiguous); a point 0.01° (~1.1 km) away is not. + let other = seg(9, 0.0, 0.01, 50); + let mut par = other.track.clone(); + for p in par.iter_mut() { + p[0] = 0.0002; + } + let d_near = min_dist_to_track(&par, 0.0, 0.005); + assert!((d_near - 22.0).abs() < 3.0, "d_near {d_near}"); + let d_far = min_dist_to_track(&par, 0.01, 0.005); + assert!(d_far > 1000.0, "d_far {d_far}"); + } + #[test] fn snap_measures_perpendicular_distance() { let tracks = vec![seg(1, 0.0, 0.01, 100)]; diff --git a/server/src/gps.rs b/server/src/gps.rs index 7b70710..64e6838 100644 --- a/server/src/gps.rs +++ b/server/src/gps.rs @@ -227,22 +227,47 @@ fn collect_samples(obj: &serde_json::Map) -> (Vec, Option entry.lat = v.as_f64(), "GPSLongitude" => entry.lon = v.as_f64(), - "GPSDateTime" => entry.t = v.as_str().and_then(parse_gps_datetime), + // Plausibility gate: cameras write garbage dates (1970/zero) on some + // fixes — treat anything outside [2000, ~2096] as unstamped. + "GPSDateTime" => { + entry.t = v + .as_str() + .and_then(parse_gps_datetime) + .filter(|&t| (946_684_800.0..4_100_000_000.0).contains(&t)) + } _ => {} } } - let mut samples: Vec = Vec::with_capacity(by_doc.len()); - let mut t0: Option = None; - let mut t0_abs: Option = None; - for (idx, p) in by_doc { - let (Some(lat), Some(lon)) = (p.lat, p.lon) else { continue }; - if t0.is_none() { - t0_abs = p.t; // real epoch only when the camera stamped it - } - let t_abs = p.t.unwrap_or(idx as f64); - let t_rel = t_abs - *t0.get_or_insert(t_abs); - samples.push([lat, lon, t_rel]); + let raw: Vec<(u32, f64, f64, Option)> = by_doc + .into_iter() + .filter_map(|(idx, p)| Some((idx, p.lat?, p.lon?, p.t))) + .collect(); + let t0_abs = raw.first().and_then(|r| r.3); // real epoch only when stamped + + // ONE timebase for the whole track. Mixing them (epoch for stamped samples, + // doc index for unstamped ones) once produced billion-second "dropouts" when a + // camera skipped GPSDateTime on some fixes — so unless EVERY sample is stamped, + // fall back to index-based 1 Hz for all of them. + let index_based = |raw: &[(u32, f64, f64, Option)]| -> Vec { + let idx0 = raw.first().map(|r| r.0).unwrap_or(0); + raw.iter().map(|&(idx, lat, lon, _)| [lat, lon, (idx - idx0) as f64]).collect() + }; + let all_stamped = !raw.is_empty() && raw.iter().all(|r| r.3.is_some()); + let mut samples: Vec = if all_stamped { + let t0 = raw[0].3.unwrap(); + raw.iter().map(|&(_, lat, lon, t)| [lat, lon, t.unwrap() - t0]).collect() + } else { + index_based(&raw) + }; + // Clock-glitch guard: a negative or >1 h step between consecutive fixes means + // the stamps themselves are unusable (camera clock jumped) — rebuild the whole + // track on the index timebase rather than trust any of them. + if samples + .windows(2) + .any(|w| !(0.0..=3600.0).contains(&(w[1][2] - w[0][2]))) + { + samples = index_based(&raw); } (samples, t0_abs) } @@ -846,6 +871,61 @@ mod tests { assert_eq!(fixed, t); } + #[test] + fn mixed_gps_datetime_never_mixes_timebases() { + // Doc1 has NO GPSDateTime, Doc2/Doc3 do (real epoch ~1.69e9). The old code + // seeded t0 from the index and then subtracted it from the epoch — a + // 1,693,958,401-second "dropout". Every sample must share one timebase. + let mut obj = serde_json::Map::new(); + let ins = |o: &mut serde_json::Map, d: u32, lat: f64, t: Option<&str>| { + o.insert(format!("Doc{d}:GPSLatitude"), serde_json::json!(lat)); + o.insert(format!("Doc{d}:GPSLongitude"), serde_json::json!(78.0)); + if let Some(t) = t { + o.insert(format!("Doc{d}:GPSDateTime"), serde_json::json!(t)); + } + }; + ins(&mut obj, 1, 21.0, None); + ins(&mut obj, 2, 21.0001, Some("2023:09:06 00:00:01Z")); + ins(&mut obj, 3, 21.0002, Some("2023:09:06 00:00:02Z")); + let (samples, t0_abs) = collect_samples(&obj); + assert_eq!(samples.len(), 3); + let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max); + assert!(max_gap <= 1.0 + 1e-9, "timebases mixed: max gap {max_gap}"); + assert_eq!(samples[0][2], 0.0); + assert!(t0_abs.is_none(), "first fix is unstamped — no reliable epoch"); + + // Fully-stamped tracks still use real GPS time (t0_abs = first fix's epoch). + let mut obj2 = serde_json::Map::new(); + ins(&mut obj2, 1, 21.0, Some("2023:09:06 00:00:00Z")); + ins(&mut obj2, 2, 21.0001, Some("2023:09:06 00:00:03Z")); + let (s2, t0b) = collect_samples(&obj2); + assert_eq!(s2[1][2], 3.0); + assert!(t0b.is_some()); + + // The live-data case: a GARBAGE first stamp (1970) followed by real ones — + // "all stamped", but the epoch jump would be the whole epoch. The + // plausibility gate (>2000) turns the 1970 stamp into "unstamped" → index + // timebase for the whole track, and no epoch is trusted for stitching. + let mut obj3 = serde_json::Map::new(); + ins(&mut obj3, 1, 21.0, Some("1970:01:01 00:00:00Z")); + ins(&mut obj3, 2, 21.0001, Some("2023:09:06 00:00:01Z")); + ins(&mut obj3, 3, 21.0002, Some("2023:09:06 00:00:02Z")); + let (s3, t0c) = collect_samples(&obj3); + let g3 = s3.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max); + assert!(g3 <= 1.0 + 1e-9, "garbage stamp leaked: max gap {g3}"); + assert!(t0c.is_none()); + + // Clock glitch WITHIN plausible dates (2023 → 2026 jump): the >1 h step + // guard rebuilds on the index timebase too. + let mut obj4 = serde_json::Map::new(); + ins(&mut obj4, 1, 21.0, Some("2023:09:06 00:00:00Z")); + ins(&mut obj4, 2, 21.0001, Some("2026:05:28 12:00:00Z")); + ins(&mut obj4, 3, 21.0002, Some("2026:05:28 12:00:01Z")); + let (s4, _) = collect_samples(&obj4); + let g4 = s4.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max); + assert!(g4 <= 1.0 + 1e-9, "clock glitch leaked: max gap {g4}"); + } + #[test] fn smoother_removes_a_burst() { let mut t = straight(80); diff --git a/server/src/main.rs b/server/src/main.rs index 6b4bbba..53621cb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -207,9 +207,10 @@ async fn main() -> anyhow::Result<()> { .route("/api/videos/:id/gps/correct", post(gps::correct)) .route("/api/projects/:id/chainage/points", get(chainage::list_points).post(chainage::add_point)) .route("/api/projects/:id/chainage/points/bulk", post(chainage::add_points_bulk)) - .route("/api/chainage/points/:id", delete(chainage::delete_point)) + .route("/api/chainage/points/:id", delete(chainage::delete_point).post(chainage::update_point_note)) .route("/api/projects/:id/chainage/recompute", post(chainage::recompute)) .route("/api/projects/:id/chainage/quick", post(chainage::quick_assign)) + .route("/api/projects/:id/chainage/calibrations", get(chainage::list_calibrations)) // Phase 3 admin (token-gated). .route("/api/admin/storage", get(storage_info)) .route("/api/users", get(admin::list_users).post(admin::create_user))