From 78b16664026cc66fa1e6289732325f6aa33c0c0e Mon Sep 17 00:00:00 2001 From: ravi Date: Wed, 8 Jul 2026 15:06:57 +0530 Subject: [PATCH] map updates --- README.md | 11 +- dashboard/src/ChainagePanel.tsx | 4 +- dashboard/src/MapView.tsx | 396 ++++++++++++++++++++++++++++---- dashboard/src/api.ts | 14 +- dashboard/src/styles.css | 7 + server/src/chainage.rs | 28 ++- server/src/gps.rs | 31 +++ server/src/main.rs | 28 ++- server/src/models.rs | 9 + 9 files changed, 457 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index cd7d088..045f431 100644 --- a/README.md +++ b/README.md @@ -171,11 +171,14 @@ pass back. All Phase 3 routes require `Authorization: Bearer `. The server image bundles exiftool; a serial background sweep extracts each video's GPS track (`gps_status`: `pending → ok | none | error`). The raw track is immutable — a kept "Viterbi" correction lives beside it and is preferred by the map + chainage. -`road_type` + the four chainage values are injected into every claim/export doc and +`road_type` + the four chainage values (`chainage_start`, `chainage_end`, +`sr_chainage_start`, `sr_chainage_end`) are injected into every claim/export doc and re-stamped on push (server-authoritative). **Every annotation** additionally gets its -**frame-exact** `lat`/`lon`/`chainage_m`/`sr_chainage_m` (range annotations also -`end_*`): frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS -samples — so it gets the halfway position/chainage, not a second/video boundary. +**frame-exact** `lat`/`lon`/`chainage`/`sr_chainage` (range annotations also `end_*`): +frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS samples — so it +gets the halfway position/chainage, not a second/video boundary. +**Chainage unit convention (everywhere — JSON + UI): kilometres with 4 decimals**, +e.g. `22.4456` (`chainage` = client-calibrated, `sr_chainage` = GPS-measured). Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist in `chainage_calibrations` so this works at any time after a recompute. diff --git a/dashboard/src/ChainagePanel.tsx b/dashboard/src/ChainagePanel.tsx index 3c506c0..146f5c3 100644 --- a/dashboard/src/ChainagePanel.tsx +++ b/dashboard/src/ChainagePanel.tsx @@ -258,7 +258,7 @@ export function ChainagePanel({ setKmlSkipped(skipped); setKmlRows(rows.map((r) => { const m = parseChainageText(r.name, kmlUnit); - return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null }; + return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null }; })); } catch (e) { setError(String(e)); @@ -272,7 +272,7 @@ export function ChainagePanel({ setKmlUnit(u); setKmlRows((rows) => rows?.map((r) => { const m = parseChainageText(r.name, u); - return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null }; + return { ...r, km: m != null ? (m / 1000).toFixed(4) : "", include: m != null }; }) ?? null); }; diff --git a/dashboard/src/MapView.tsx b/dashboard/src/MapView.tsx index 168ddc1..d2773bd 100644 --- a/dashboard/src/MapView.tsx +++ b/dashboard/src/MapView.tsx @@ -96,6 +96,32 @@ function pointAtChainage(v: MapVideo, cum: number[], m: number): [number, number ]; } +/** Speed bucket color (km/h): blue slow → red fast, colorblind-tolerant ramp. */ +function speedColor(mps: number): string { + const k = mps * 3.6; + return k < 20 ? "#4575b4" : k < 40 ? "#74add1" : k < 60 ? "#5ccf75" : k < 80 ? "#f5c838" : k < 100 ? "#f5a623" : "#ff5252"; +} +const SPEED_LEGEND: [string, string][] = [ + ["<20", "#4575b4"], ["20–40", "#74add1"], ["40–60", "#5ccf75"], + ["60–80", "#f5c838"], ["80–100", "#f5a623"], ["100+", "#ff5252"], +]; + +/** 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]; + const dLon = (b[1] - a[1]) * Math.cos((a[0] * Math.PI) / 180); + return (Math.atan2(dLon, dLat) * 180) / Math.PI; +} + +/** A coverage finding between two chainage-adjacent videos of the same group. */ +interface CoverageItem { + kind: "gap" | "overlap"; + a: MapVideo; + b: MapVideo; + size_m: number; + pos: [number, number] | null; +} + /** "12+400" (km+m) or "12.4"/"12.4 km" → meters; null when the query isn't a chainage. */ function parseChainageQuery(q: string): number | null { const t = q.trim(); @@ -135,7 +161,10 @@ export function MapView({ }) { const [videos, setVideos] = useState([]); const [points, setPoints] = useState([]); - const [colorBy, setColorBy] = useState<"video" | "status">("video"); + const [colorBy, setColorBy] = useState<"video" | "status" | "speed">("video"); + const [basemap, setBasemap] = useState<"map" | "sat">("map"); + const [roadFilter, setRoadFilter] = useState(null); // null = all, '' = unassigned + const [showCoverage, setShowCoverage] = useState(true); const [hidden, setHidden] = useState>(new Set()); const [search, setSearch] = useState(""); const [selectedId, setSelectedId] = useState(null); @@ -151,6 +180,8 @@ export function MapView({ const pointLayer = useRef(null); const correctionLayer = useRef(null); const searchLayer = useRef(null); + const coverageLayer = useRef(null); + const baseLayers = useRef<{ map: L.TileLayer; sat: L.TileLayer } | null>(null); const fitted = useRef(false); // Per-video cumulative track distances, computed lazily for hover/search readouts. const cumCache = useRef(new Map()); @@ -196,14 +227,23 @@ export function MapView({ // ---- Leaflet init (once) ---- useEffect(() => { if (!mapDiv.current || mapRef.current) return; - const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5 }); - // OSM raster tiles (internet). The basemap is an optional layer — tracks render - // fine even when tiles can't load (offline VM). - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, + 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"). + const osm = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 22, + maxNativeZoom: 19, attribution: '© OpenStreetMap contributors', - }).addTo(map); + }); + 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" }, + ); + osm.addTo(map); + baseLayers.current = { map: osm, sat }; trackLayer.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); @@ -212,14 +252,36 @@ export function MapView({ return () => { map.remove(); mapRef.current = null; + baseLayers.current = null; }; }, []); + // Swap the basemap (streets ↔ satellite). + useEffect(() => { + const map = mapRef.current; + const bl = baseLayers.current; + if (!map || !bl) return; + const on = basemap === "sat" ? bl.sat : bl.map; + const off = basemap === "sat" ? bl.map : bl.sat; + if (map.hasLayer(off)) map.removeLayer(off); + if (!map.hasLayer(on)) on.addTo(map); + }, [basemap]); + useEffect(() => cumCache.current.clear(), [videos]); const withTrack = useMemo(() => videos.filter((v) => v.track && v.track.length >= 2), [videos]); const noMeta = useMemo(() => videos.filter((v) => v.gps_status === "none" || v.gps_status === "error"), [videos]); const pendingScan = useMemo(() => videos.filter((v) => v.gps_status === "pending"), [videos]); + /** Idle = GPS fine but the vehicle barely moved (parked/idling footage). */ + const idle = useMemo( + () => videos.filter((v) => v.gps_status === "ok" && (v.gps_len_m ?? 0) < 50), + [videos], + ); + /** Long GPS dropouts inside otherwise-fine videos. */ + const dropouts = useMemo( + () => videos.filter((v) => v.gps_status === "ok" && (v.gps_max_gap_s ?? 0) > 30 && (v.gps_len_m ?? 0) >= 50), + [videos], + ); const statusCounts = useMemo(() => { const c: Record = {}; @@ -227,11 +289,60 @@ export function MapView({ return c; }, [withTrack]); + /** Road types present in this project's map data ('' = unassigned exists). */ + const roadTypesPresent = useMemo(() => { + const s = new Set(); + for (const v of videos) s.add(v.road_type); + return [...s].sort(); + }, [videos]); + const visible = useMemo( - () => withTrack.filter((v) => !hidden.has(mapStatus(v))), - [withTrack, hidden], + () => + withTrack.filter( + (v) => !hidden.has(mapStatus(v)) && (roadFilter === null || v.road_type === roadFilter), + ), + [withTrack, hidden, roadFilter], ); + // Coverage analysis: within each calibrated road-type group, sort videos by + // chainage and flag holes (>30 m uncovered) and overlaps (>30 m double-covered) + // between chainage-adjacent videos. + const coverage = useMemo(() => { + const out: CoverageItem[] = []; + const groups = new Map(); + for (const v of videos) { + if (v.ignored || v.chainage_start_m == null || v.chainage_end_m == null) continue; + const g = groups.get(v.road_type) ?? []; + g.push(v); + groups.set(v.road_type, g); + } + for (const vs of groups.values()) { + vs.sort( + (x, y) => + Math.min(x.chainage_start_m!, x.chainage_end_m!) - Math.min(y.chainage_start_m!, y.chainage_end_m!), + ); + for (let i = 0; i + 1 < vs.length; i++) { + const a = vs[i]; + const b = vs[i + 1]; + const aMax = Math.max(a.chainage_start_m!, a.chainage_end_m!); + const bMin = Math.min(b.chainage_start_m!, b.chainage_end_m!); + const bMax = Math.max(b.chainage_start_m!, b.chainage_end_m!); + const at = (v: MapVideo, m: number) => { + const cum = cumFor(v); + return cum ? pointAtChainage(v, cum, m) : null; + }; + if (bMin - aMax > 30) { + out.push({ kind: "gap", a, b, size_m: bMin - aMax, pos: at(a, aMax) ?? at(b, bMin) }); + } else if (aMax - bMin > 30) { + const size = Math.min(aMax, bMax) - bMin; + out.push({ kind: "overlap", a, b, size_m: size, pos: at(b, bMin + size / 2) ?? at(a, aMax - size / 2) }); + } + } + } + return out.sort((x, y) => y.size_m - x.size_m); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [videos]); + // Search matches by name, by chainage ("12+400" / "12.4" — EVERY calibrated group // containing it: LHS, RHS and a ramp can each match the same chainage), or by // coordinates ("21.1055, 78.1857" — nearby tracks + a bare go-to-location entry). @@ -248,9 +359,16 @@ export function MapView({ .slice(0, 10); return [{ v: null, ll } as SearchMatch, ...near]; } + const lq = q.toLowerCase(); + const byName: SearchMatch[] = videos + .filter((v) => v.file_name.toLowerCase().includes(lq) || v.rel_path.toLowerCase().includes(lq)) + .slice(0, 10) + .map((v): SearchMatch => ({ v })); + // A bare number is ambiguous: file names start with dates ("2026…") AND it could + // be a chainage — show name matches first, chainage hits after. const m = parseChainageQuery(q); if (m != null) { - return videos + const byCh: SearchMatch[] = videos .filter((v) => { if (v.chainage_start_m == null || v.chainage_end_m == null) return false; const lo = Math.min(v.chainage_start_m, v.chainage_end_m); @@ -260,12 +378,9 @@ export function MapView({ .sort((a, b) => a.road_type.localeCompare(b.road_type) || a.file_name.localeCompare(b.file_name)) .slice(0, 12) .map((v): SearchMatch => ({ v, ch: m })); + return [...byName, ...byCh].slice(0, 14); } - const lq = q.toLowerCase(); - return videos - .filter((v) => v.file_name.toLowerCase().includes(lq) || v.rel_path.toLowerCase().includes(lq)) - .slice(0, 12) - .map((v): SearchMatch => ({ v })); + return byName; // eslint-disable-next-line react-hooks/exhaustive-deps }, [videos, search]); @@ -291,7 +406,7 @@ export function MapView({ const pt = cum ? pointAtChainage(v, cum, m) : null; if (pt && mapRef.current && searchLayer.current) { L.circleMarker(pt, { radius: 9, color: "#ff3b3b", weight: 3, fillOpacity: 0.15 }) - .bindTooltip(`${(m / 1000).toFixed(3)} km`, { permanent: true, direction: "top" }) + .bindTooltip(`${(m / 1000).toFixed(4)}`, { permanent: true, direction: "top" }) .addTo(searchLayer.current); mapRef.current.setView(pt, 16); } @@ -318,35 +433,114 @@ export function MapView({ for (const v of visible) { if (correction && correction.videoId === v.id) continue; // overlay owns this one const sel = v.id === selectedId; - const color = colorBy === "video" ? videoColor(v.id) : STATUS_COLORS[mapStatus(v)] ?? "#999"; - const line = L.polyline(latlngs(v.track!), { - color, - weight: sel ? 6 : 3, - opacity: sel ? 1 : 0.8, - dashArray: v.ignored ? "6 6" : undefined, - }); - line.bindTooltip(`${v.file_name} · ${statusLabel(mapStatus(v))}`, { sticky: true }); - const cum = cumFor(v); - if (cum && v.chainage_start_m != null) { - // Live chainage readout under the cursor. - line.on("mousemove", (e: L.LeafletMouseEvent) => { - const ch = chainageAtCursor(v, cum, e.latlng); - line.setTooltipContent( - `${v.file_name} · ${statusLabel(mapStatus(v))}${ch != null ? ` · ${(ch / 1000).toFixed(3)} km` : ""}`, - ); + const color = + colorBy === "video" ? videoColor(v.id) + : colorBy === "speed" ? (v.speed_avg_mps != null ? speedColor(v.speed_avg_mps) : "#8f95ae") + : STATUS_COLORS[mapStatus(v)] ?? "#999"; + + const attachHandlers = (line: L.Polyline) => { + line.bindTooltip(`${v.file_name} · ${statusLabel(mapStatus(v))}`, { sticky: true }); + const cum = cumFor(v); + if (cum && v.chainage_start_m != null) { + // Live chainage readout under the cursor. + line.on("mousemove", (e: L.LeafletMouseEvent) => { + const ch = chainageAtCursor(v, cum, e.latlng); + line.setTooltipContent( + `${v.file_name} · ${statusLabel(mapStatus(v))}${ch != null ? ` · ${(ch / 1000).toFixed(4)}` : ""}`, + ); + }); + } + line.on("click", () => select(v)); + }; + + if (sel && colorBy === "speed" && v.track!.length > 3) { + // Selected video in speed mode: per-chunk gradient (Strava-style). + const t = v.track!; + const chunk = Math.max(2, Math.ceil(t.length / 24)); + for (let i = 0; i < t.length - 1; i += chunk) { + const part = t.slice(i, Math.min(i + chunk + 1, t.length)); + const dt = Math.max(part[part.length - 1][2] - part[0][2], 0.5); + let d = 0; + for (let k = 1; k < part.length; k++) { + const mLat = 110574, mLon = 111320 * Math.cos((part[k][0] * Math.PI) / 180); + d += Math.hypot((part[k][0] - part[k - 1][0]) * mLat, (part[k][1] - part[k - 1][1]) * mLon); + } + const seg = L.polyline(latlngs(part), { color: speedColor(d / dt), weight: 6, opacity: 1 }); + attachHandlers(seg); + seg.addTo(layer); + } + } else { + const line = L.polyline(latlngs(v.track!), { + color, + weight: sel ? 6 : 3, + opacity: sel ? 1 : 0.8, + dashArray: v.ignored ? "6 6" : undefined, }); + attachHandlers(line); + line.addTo(layer); + if (sel) line.bringToFront(); + } + + // Selected video: travel-direction arrows + green start / red end dots. + if (sel) { + const t = v.track!; + 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)}`) + .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)}`) + .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); + } } - line.on("click", () => select(v)); - line.addTo(layer); - if (sel) line.bringToFront(); } if (!fitted.current && visible.length > 0) { const all = visible.flatMap((v) => latlngs(v.track!)); map.fitBounds(L.latLngBounds(all), { padding: [30, 30] }); fitted.current = true; } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, colorBy, selectedId, correction, select]); + // ---- draw coverage findings (gaps amber, overlaps purple halo) ---- + useEffect(() => { + const layer = coverageLayer.current; + if (!layer) return; + layer.clearLayers(); + if (!showCoverage) return; + for (const c of coverage) { + if (!c.pos) continue; + if (roadFilter !== null && c.a.road_type !== roadFilter) continue; + const label = + c.kind === "gap" + ? `gap ${c.size_m < 1000 ? `${c.size_m.toFixed(0)} m` : `${(c.size_m / 1000).toFixed(2)} km`}: ${c.a.file_name} → ${c.b.file_name}` + : `overlap ~${c.size_m < 1000 ? `${c.size_m.toFixed(0)} m` : `${(c.size_m / 1000).toFixed(2)} km`}: ${c.a.file_name} ↔ ${c.b.file_name}`; + L.circleMarker(c.pos, { + radius: c.kind === "gap" ? 10 : 13, + color: c.kind === "gap" ? "#f5a623" : "#b48cff", + weight: c.kind === "gap" ? 3 : 7, + opacity: c.kind === "gap" ? 0.95 : 0.5, + fillOpacity: 0.12, + dashArray: c.kind === "gap" ? "4 4" : undefined, + }) + .bindTooltip(label) + .addTo(layer); + } + }, [coverage, showCoverage, roadFilter]); + // ---- draw calibration points ---- useEffect(() => { const layer = pointLayer.current; @@ -360,7 +554,7 @@ export function MapView({ fillOpacity: 0.9, }) .bindTooltip( - `chainage ${(p.chainage_m / 1000).toFixed(3)} km${p.road_type ? ` · ${p.road_type}` : ""}${p.note ? ` · ${p.note}` : ""}`, + `chainage ${(p.chainage_m / 1000).toFixed(4)}${p.road_type ? ` · ${p.road_type}` : ""}${p.note ? ` · ${p.note}` : ""}`, ) .addTo(layer); } @@ -459,7 +653,7 @@ export function MapView({ {mt.v.file_name} {mt.ch != null ? ( // Same chainage can exist on LHS, RHS AND a ramp — one row per group. - · {mt.v.road_type || "no group"} · contains {(mt.ch / 1000).toFixed(3)} km + · {mt.v.road_type || "no group"} · contains {(mt.ch / 1000).toFixed(4)} ) : mt.away != null ? ( · {mt.away.toFixed(0)} m away{mt.v.road_type ? ` · ${mt.v.road_type}` : ""} ) : mt.v.track ? ( @@ -476,12 +670,37 @@ export function MapView({
+
+
+ + +
+ {roadTypesPresent.length > 1 && ( +
+ + {roadTypesPresent.map((rt) => ( + + ))} +
+ )} ⬇ KML @@ -501,6 +720,16 @@ export function MapView({ ))} )} + {colorBy === "speed" && ( +
+ {SPEED_LEGEND.map(([lbl, col]) => ( + + {lbl} + + ))} + km/h +
+ )} {error &&
⚠ {error}
} @@ -524,6 +753,25 @@ export function MapView({ Road type {selected.road_type || "—"} + {selected.track && selected.track.length >= 2 && ( + <> + First fix + {selected.track[0][0].toFixed(6)}, {selected.track[0][1].toFixed(6)} + Last fix + + {selected.track[selected.track.length - 1][0].toFixed(6)}, {selected.track[selected.track.length - 1][1].toFixed(6)} + + + )} + {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 @@ -643,14 +891,50 @@ export function MapView({ )} - {/* Requirement: videos without metadata must be listed visibly. */} + {/* Coverage: holes and double-coverage between chainage-adjacent videos. */} + {coverage.length > 0 && ( +
+
+ Coverage + {" "}{coverage.filter((c) => c.kind === "gap").length} gap(s) + {" · "}{coverage.filter((c) => c.kind === "overlap").length} overlap(s) + +
+
+ {coverage.slice(0, 40).map((c, i) => ( +
+ + + {c.kind} {c.size_m < 1000 ? `${c.size_m.toFixed(0)} m` : `${(c.size_m / 1000).toFixed(2)} km`} + +
+ ))} + {coverage.length > 40 &&
…and {coverage.length - 40} more
} +
+
+ )} + + {/* Data quality: no metadata / corrupted / idle / long dropouts — all + the videos that need a human look, in one place. */}
- Without GPS metadata {noMeta.length} + Data quality {noMeta.length + idle.length + dropouts.length} {pendingScan.length > 0 && · {pendingScan.length} awaiting scan} {isAdmin && ( {v.gps_status === "error" ? ( - error + corrupt/unreadable ) : ( - none + no GPS )} {isAdmin && (
))} - {noMeta.length === 0 &&
every scanned video has GPS ✓
} + {idle.map((v) => ( +
+ + + idle {(v.gps_len_m ?? 0).toFixed(0)} m + +
+ ))} + {dropouts.map((v) => ( +
+ + + dropout {(v.gps_max_gap_s ?? 0).toFixed(0)} s + +
+ ))} + {noMeta.length + idle.length + dropouts.length === 0 && ( +
every scanned video has healthy GPS ✓
+ )}
diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 1be4479..110666e 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -127,6 +127,11 @@ export interface MapVideo { gps_corrected_note: string | null; gps_sample_count: number | null; // raw fixes found by the scan gps_max_gap_s: number | null; // biggest dropout between fixes + duration_s: number | null; + gps_start_epoch: number | null; // recording window (UTC epoch of first/last fix) + gps_end_epoch: number | null; + speed_avg_mps: number | null; // over the full-resolution track + speed_max_mps: number | null; } export interface RoadType { @@ -441,17 +446,18 @@ export function statusLabel(s: string): string { return s; } -/** Chainage in decimal km ("12.345 km"); em-dash when not computed yet. */ +/** Chainage convention (user-fixed): km with FOUR decimals — "22.4456". + * Input is meters (internal unit); em-dash when not computed yet. */ export function fmtChainage(m: number | null | undefined): string { if (m == null || isNaN(m)) return "—"; - return `${(m / 1000).toFixed(3)} km`; + return (m / 1000).toFixed(4); } -/** "12.400 → 13.750 km" for a start/end pair (direction preserved — a flipped video +/** "12.4000 → 13.7500" for a start/end pair (direction preserved — a flipped video * legitimately runs high→low); em-dash when not computed. */ export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string { if (a == null || b == null) return "—"; - return `${(a / 1000).toFixed(3)} → ${(b / 1000).toFixed(3)} km`; + return `${(a / 1000).toFixed(4)} → ${(b / 1000).toFixed(4)}`; } /** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, … diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 673e00f..6b55435 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -298,6 +298,13 @@ 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 */ +.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; } +/* Idle / dropout tags in the data-quality panel */ +.idle-tag { color: var(--warn); font-size: 11px; white-space: nowrap; } + /* Leaflet dark-theme nudges */ .leaflet-container { font: inherit; } .leaflet-container a.leaflet-attribution-flag { display: none !important; } diff --git a/server/src/chainage.rs b/server/src/chainage.rs index 1afd963..fdbe35b 100644 --- a/server/src/chainage.rs +++ b/server/src/chainage.rs @@ -390,18 +390,19 @@ pub async fn load_calibration(db: &sqlx::PgPool, project_id: i32, road_type: &st v.as_ref().and_then(Calib::from_value) } -fn round3(x: f64) -> f64 { - (x * 1000.0).round() / 1000.0 +/// Meters → km with 4 decimals (the user's chainage convention: 22.4456). +fn km4(m: f64) -> f64 { + (m * 10.0).round() / 10000.0 } fn round6(x: f64) -> f64 { (x * 1e6).round() / 1e6 } /// Stamp every annotation in an export doc with its frame-exact geo: `lat`, `lon`, -/// `sr_chainage_m` (measured) and `chainage_m` (client-calibrated) — range -/// annotations additionally get `end_*` values at their end frame. Server- -/// authoritative: applied on claim, re-applied on push and in both exports, so the -/// values always reflect the CURRENT calibration. +/// `sr_chainage` (measured) and `chainage` (client-calibrated), both in **km with +/// 4 decimals** — range annotations additionally get `end_*` values at their end +/// frame. Server-authoritative: applied on claim, re-applied on push and in both +/// exports, so the values always reflect the CURRENT calibration. pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) { let Some(obj) = doc.as_object_mut() else { return }; let stamp = |m: &mut serde_json::Map, frame_key: &str, prefix: &str| { @@ -409,8 +410,11 @@ pub fn stamp_annotation_geo(doc: &mut Value, geo: &VideoGeo) { let g = geo.at_frame(frame); m.insert(format!("{prefix}lat"), serde_json::json!(round6(g.lat))); m.insert(format!("{prefix}lon"), serde_json::json!(round6(g.lon))); - m.insert(format!("{prefix}sr_chainage_m"), serde_json::json!(g.sr_m.map(round3))); - m.insert(format!("{prefix}chainage_m"), serde_json::json!(g.chainage_m.map(round3))); + m.insert(format!("{prefix}sr_chainage"), serde_json::json!(g.sr_m.map(km4))); + m.insert(format!("{prefix}chainage"), serde_json::json!(g.chainage_m.map(km4))); + // Drop earlier meter-suffixed variants from previously-stamped docs. + m.remove(&format!("{prefix}sr_chainage_m")); + m.remove(&format!("{prefix}chainage_m")); }; if let Some(arr) = obj.get_mut("fixed_annotations").and_then(|v| v.as_array_mut()) { for a in arr { @@ -1035,10 +1039,14 @@ mod tests { }); stamp_annotation_geo(&mut doc, &geo); let f = &doc["fixed_annotations"][0]; - assert!(f["chainage_m"].as_f64().unwrap() > 15.0); + // km, 4 decimals: frame 30 = t 1 s ≈ 22.7 m ≈ 0.0204 km after calibration. + let ch = f["chainage"].as_f64().unwrap(); + assert!(ch > 0.015 && ch < 0.025, "{ch}"); + assert_eq!(ch, (ch * 10000.0).round() / 10000.0, "not 4-decimal km"); assert!(f["lat"].as_f64().unwrap().abs() < 1e-6); + assert!(f.get("chainage_m").is_none(), "old meter key must be gone"); let r = &doc["range_annotations"][0]; - assert!(r["chainage_m"].as_f64().unwrap() < r["end_chainage_m"].as_f64().unwrap()); + assert!(r["chainage"].as_f64().unwrap() < r["end_chainage"].as_f64().unwrap()); } #[test] diff --git a/server/src/gps.rs b/server/src/gps.rs index 6dfafc6..7b70710 100644 --- a/server/src/gps.rs +++ b/server/src/gps.rs @@ -103,6 +103,22 @@ pub fn track_value(track: &[Pt]) -> Value { ) } +/// Average + maximum speed over a track (m/s), from the full-resolution samples. +/// Max is measured over single hops (dt clamped ≥ 0.5 s so duplicate timestamps +/// can't fabricate teleports). None when the track has no elapsed time. +pub fn speed_stats(track: &[Pt]) -> (Option, Option) { + if track.len() < 2 { + return (None, None); + } + let elapsed = track.last().unwrap()[2] - track[0][2]; + let avg = (elapsed > 0.0).then(|| polyline_len_m(track) / elapsed); + let max = track + .windows(2) + .map(|w| dist_m(w[0][0], w[0][1], w[1][0], w[1][1]) / (w[1][2] - w[0][2]).max(0.5)) + .fold(None::, |acc, v| Some(acc.map_or(v, |a| a.max(v)))); + (avg, max) +} + /// Cap a track at `max` points (uniform stride, always keeping the last point). pub fn downsample(track: Vec, max: usize) -> Vec { if track.len() <= max || max < 2 { @@ -851,6 +867,21 @@ mod tests { assert_eq!(d.last(), t.last()); } + #[test] + fn speed_stats_measures_avg_and_max() { + // ~11.13 m per second for 60 s → avg ≈ max ≈ 11.1 m/s (40 km/h). + let t = straight(60); + let (avg, max) = speed_stats(&t); + assert!((avg.unwrap() - 11.13).abs() < 0.1, "{avg:?}"); + assert!((max.unwrap() - 11.13).abs() < 0.1, "{max:?}"); + // One fast hop dominates max but barely moves avg. + let mut t2 = straight(60); + t2[30][1] += 0.0002; // extra ~22 m in that second + let (avg2, max2) = speed_stats(&t2); + assert!(max2.unwrap() > 25.0, "{max2:?}"); + assert!(avg2.unwrap() < 13.0, "{avg2:?}"); + } + #[test] fn stitch_planning_rules() { // Healthy 1 Hz rollover (1 s gap) → nothing missing. diff --git a/server/src/main.rs b/server/src/main.rs index 1eb99a6..6b4bbba 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -434,7 +434,8 @@ async fn map_data(State(s): State, user: AuthUser, AxPath(id): AxPath< COALESCE((SELECT count(*) FROM video_remarks r WHERE r.video_id = videos.id), 0) AS remark_count, COALESCE(gps_track_corrected, gps_track) AS track, (gps_track_corrected IS NOT NULL) AS corrected, - gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s + gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s, + duration_s, gps_start_epoch, gps_end_epoch FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"# ); let mut rows = sqlx::query_as::<_, MapVideo>(&q) @@ -444,9 +445,13 @@ async fn map_data(State(s): State, user: AuthUser, AxPath(id): AxPath< .map_err(err)?; // Downsample tracks for transport — hundreds of videos × 5k points would be tens // of MB; ~400 points per video keeps the payload in the low single-digit MB. + // Speed stats are computed BEFORE downsampling (max speed needs 1 Hz hops). for r in rows.iter_mut() { if let Some(t) = r.track.take() { let pts = gps::parse_track(&t); + let (avg, max) = gps::speed_stats(&pts); + r.speed_avg_mps = avg; + r.speed_max_mps = max; r.track = Some(gps::track_value(&gps::downsample(pts, MAP_MAX_PTS))); } } @@ -613,7 +618,7 @@ async fn export_tracks_kml( desc.push_str(&format!(" · road: {road_type}")); } if let (Some(a), Some(b)) = (ch_s, ch_e) { - desc.push_str(&format!(" · chainage {:.3} → {:.3} km", a / 1000.0, b / 1000.0)); + desc.push_str(&format!(" · chainage {:.4} → {:.4}", a / 1000.0, b / 1000.0)); } kml.push_str(&format!( "{}{}#st-{}1", @@ -641,9 +646,16 @@ async fn export_tracks_kml( .map_err(err) } +/// Meters → the wire/display convention: kilometers with 4 decimals (0.1 m). +/// 151779.98 m → 151.78 km; 22445.6 m → 22.4456 km. +pub fn km4(m: Option) -> Option { + m.map(|v| (v * 10.0).round() / 10000.0) +} + /// Server-authoritative wire-format metadata: inject/overwrite `road_type` + chainage /// keys at the top level of an export doc. Applied on claim (pull), push (store) and /// both export endpoints, so a worker's old client can never drop them. +/// Chainage convention (user-fixed): plain keys, **km with 4 decimals** (22.4456). pub fn stamp_road_meta( doc: &mut serde_json::Value, road_type: &str, @@ -651,10 +663,14 @@ pub fn stamp_road_meta( ) { if let Some(obj) = doc.as_object_mut() { obj.insert("road_type".into(), json!(road_type)); - obj.insert("chainage_start_m".into(), json!(chainage.0)); - obj.insert("chainage_end_m".into(), json!(chainage.1)); - obj.insert("sr_chainage_start_m".into(), json!(chainage.2)); - obj.insert("sr_chainage_end_m".into(), json!(chainage.3)); + obj.insert("chainage_start".into(), json!(km4(chainage.0))); + obj.insert("chainage_end".into(), json!(km4(chainage.1))); + obj.insert("sr_chainage_start".into(), json!(km4(chainage.2))); + obj.insert("sr_chainage_end".into(), json!(km4(chainage.3))); + // Drop earlier meter-suffixed variants so re-stamped docs don't carry both. + for k in ["chainage_start_m", "chainage_end_m", "sr_chainage_start_m", "sr_chainage_end_m"] { + obj.remove(k); + } } } diff --git a/server/src/models.rs b/server/src/models.rs index 4394ee3..bc94eb6 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -214,6 +214,15 @@ pub struct MapVideo { // Data quality from the raw scan: fix count + biggest dropout between fixes. pub gps_sample_count: Option, pub gps_max_gap_s: Option, + pub duration_s: Option, + // Recording window (absolute UTC epoch of first/last fix) — temporal ordering. + pub gps_start_epoch: Option, + pub gps_end_epoch: Option, + // Speed over the full-resolution track (m/s) — the map's speed mode/stats. + #[sqlx(default)] + pub speed_avg_mps: Option, + #[sqlx(default)] + pub speed_max_mps: Option, } /// `GET /api/projects/:id/road_types` — a label + how many videos carry it.