chainage compute and map view

This commit is contained in:
2026-07-08 17:03:24 +05:30
parent 78b1666402
commit e7ab0f3368
9 changed files with 622 additions and 78 deletions

View File

@@ -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<ChainagePoint[]>([]);
const [calibs, setCalibs] = useState<CalibrationRow[]>([]);
const [calibsErr, setCalibsErr] = useState<string | null>(null);
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<CalibrationSummary>) => {
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({
</span>
</div>
{/* 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) && (
<div className="chainage-block">
<div className="dim block-label">Saved calibrations (shared all users see these chainages)</div>
{calibsErr && <div className="err-tag" style={{ fontSize: 12 }}> calibrations unavailable: {calibsErr}</div>}
{calibs.map((c) => (
<div key={c.road_type || "(any)"} className="dim" style={{ fontSize: 12 }}>
<b>{c.road_type || "(ungrouped)"}</b> · route {fmtChainage(c.route_len_m)} km
· {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)}
</div>
))}
</div>
)}
<div className="grid2">
{/* Control points */}
<div className="chainage-block">
@@ -385,10 +445,36 @@ export function ChainagePanel({
<tbody>
{groupPoints.map((p) => (
<tr key={p.id}>
<td>{fmtChainage(p.chainage_m)}</td>
<td>
{fmtChainage(p.chainage_m)}
{p.origin === "quick" && (
<span className="dim" title="Generated by the two-point quick assign — replaced automatically on the next quick run"> · auto</span>
)}
</td>
<td className="mono">{p.lat.toFixed(6)}, {p.lon.toFixed(6)}</td>
<td className="dim">{p.road_type || "any"}</td>
<td className="dim">{p.note || "—"}</td>
<td
className="dim"
style={{ cursor: "pointer" }}
title="Click to edit — e.g. 'start point', 'toll plaza', a survey remark"
onClick={() => { if (noteEdit?.id !== p.id) setNoteEdit({ id: p.id, text: p.note }); }}
>
{noteEdit?.id === p.id ? (
<input
autoFocus
value={noteEdit.text}
style={{ width: 140 }}
onChange={(e) => 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)"
)}
</td>
<td><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
</tr>
))}
@@ -464,18 +550,28 @@ export function ChainagePanel({
{/* Quick assign + recompute */}
<div className="chainage-block">
<div className="dim block-label">
Two-point quick assign client gives just first &amp; 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.
</div>
<div className="form-row">
<label className="dim">Route starts at</label>
<label className="dim">One end</label>
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} style={{ width: 90 }} /> km
<label className="dim">ends at</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} placeholder="49.5" style={{ width: 90 }} /> km
<label className="dim">other end</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} style={{ width: 90 }} /> km
<button className="btn" disabled={busy}
title="The 0 landed on the wrong physical end? Swap the two values and re-compute."
onClick={() => { const s = startKm; setStartKm(endKm); setEndKm(s); }}>
⇄ swap
</button>
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
Assign &amp; compute
</button>
</div>
<div className="dim" style={{ marginTop: 6, fontSize: 12 }}>
The two auto points ("quick: chain start/end") are saved into group <b>{group || "any"}</b>.
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).
</div>
<div className="form-row" style={{ marginTop: 10 }}>
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
title="Re-run the calibration with the points above">
@@ -494,6 +590,20 @@ export function ChainagePanel({
· {summary.points_used} point(s) used
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
{summary.reversed && <span className="dim"> · direction auto-reversed</span>}
{summary.majority_flipped && (
<span className="dim" title="Most videos here were filmed driving toward decreasing chainage — that's this route's normal pattern, so only videos running AGAINST it are flagged below.">
{" "}· footage mostly runs high low chainage
</span>
)}
{summary.videos.length > 1 && (() => {
const ag = summary.videos.filter((v) => v.against_flow).length;
return (
<span className={ag > 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` : ""}
</span>
);
})()}
</div>
{summary.warnings.length > 0 && (
<ul className="chainage-warnings">
@@ -528,9 +638,9 @@ export function ChainagePanel({
<td className="mono">{v.file_name}</td>
<td>{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}</td>
<td className="dim">{fmtChainageRange(v.sr_start_m, v.sr_end_m)}</td>
<td className="dim">{v.flipped && (
<span title="This footage was filmed driving toward DECREASING chainage (e.g. the return carriageway), so frame 0 sits at the higher value — chainage runs high → low through the video. Handled automatically everywhere (per-annotation chainage included). If about half a group is flipped, you probably have both directions mixed in one group — split them by road type.">
flipped
<td className="dim">{v.against_flow && (
<span className="err-tag" title="This video's travel direction is OPPOSITE to most of the route. Chainage is still computed correctly — but if it's unexpected, the video may belong to the other carriageway (wrong group).">
against flow
</span>
)}</td>
</tr>

View File

@@ -106,6 +106,59 @@ const SPEED_LEGEND: [string, string][] = [
["6080", "#f5c838"], ["80100", "#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 (
<div className="gps-bar" title={title}>
{startMiss > 0.005 && <div className="gps-bar-seg miss" style={{ left: 0, width: `${startMiss * 100}%` }} />}
{segs.map((s, i) => (
<div key={i} className={`gps-bar-seg ${s.kind}`}
style={{ left: `${s.x * 100}%`, width: `${Math.max(s.w * 100, 0.4)}%` }} />
))}
{endMiss > 0.005 && <div className="gps-bar-seg miss" style={{ left: `${(1 - endMiss) * 100}%`, width: `${endMiss * 100}%` }} />}
{stitched && <div className="gps-bar-stitch" style={{ left: `${startMiss * 100}%` }} />}
</div>
);
}
/** 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<string | null>(null);
const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null);
const [zoom, setZoom] = useState(5);
const mapDiv = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const trackLayer = useRef<L.LayerGroup | null>(null);
const arrowLayer = useRef<L.LayerGroup | null>(null);
const kmLayer = useRef<L.LayerGroup | null>(null);
const pointLayer = useRef<L.LayerGroup | null>(null);
const correctionLayer = useRef<L.LayerGroup | null>(null);
const searchLayer = useRef<L.LayerGroup | null>(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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `<div class="dir-arrow" style="transform:rotate(${rot}deg)">▲</div>`,
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: `<div class="dir-arrow${sel ? "" : " sm"}" style="transform:rotate(${rot}deg)">▲</div>`,
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<string>();
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: `<div class="km-mark">${k}</div>`,
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({
<div className="map-colorby">
<button className={basemap === "map" ? "active" : ""} onClick={() => setBasemap("map")}>Map</button>
<button className={basemap === "sat" ? "active" : ""} onClick={() => setBasemap("sat")}
title="Esri satellite imagery — zoom all the way in to inspect a junction">Satellite</button>
title="Google satellite imagery — deep zoom, inspect a single junction">Satellite</button>
</div>
{roadTypesPresent.length > 1 && (
<div className="map-chips">
@@ -753,6 +882,21 @@ export function MapView({
</span>
<span className="dim">Road type</span>
<span>{selected.road_type || "—"}</span>
<span className="dim">Chainage</span>
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
<span className="dim">SR chainage</span>
<span title="GPS-measured (immutable)">
{fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)}
</span>
{selected.speed_avg_mps != null && (
<>
<span className="dim">Speed</span>
<span>
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</>}
</span>
</>
)}
{selected.track && selected.track.length >= 2 && (
<>
<span className="dim">First fix</span>
@@ -763,21 +907,6 @@ export function MapView({
</span>
</>
)}
{selected.speed_avg_mps != null && (
<>
<span className="dim">Speed</span>
<span>
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</>}
</span>
</>
)}
<span className="dim">Chainage</span>
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
<span className="dim">SR chainage</span>
<span title="GPS-measured (immutable)">
{fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)}
</span>
<span className="dim">GPS</span>
<span>
{selected.gps_status === "ok" ? (
@@ -786,7 +915,7 @@ export function MapView({
{selected.gps_sample_count != null && <span className="dim"> · {selected.gps_sample_count} fixes</span>}
{(selected.gps_max_gap_s ?? 0) > 5 && (
<span className="err-tag" title="Largest dropout between GPS fixes — positions in the gap are time-interpolated">
{" "}· gap {selected.gps_max_gap_s!.toFixed(0)}s
{" "}· gap {fmtGap(selected.gps_max_gap_s!)}
</span>
)}
</>
@@ -801,6 +930,12 @@ export function MapView({
</div>
)}
</span>
{selected.gps_status === "ok" && selected.track && selected.track.length >= 2 && (
<>
<span className="dim">Coverage</span>
<span><GpsBar v={selected} /></span>
</>
)}
<span className="dim">Annotations</span>
<span>{selected.annotation_count}</span>
</div>
@@ -977,7 +1112,7 @@ export function MapView({
{v.file_name}
</button>
<span className="idle-tag" title="longest stretch without a GPS fix — positions there are interpolated">
dropout {(v.gps_max_gap_s ?? 0).toFixed(0)} s
dropout {fmtGap(v.gps_max_gap_s ?? 0)}
</span>
</div>
))}

View File

@@ -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<CalibrationRow[]>(`/projects/${projectId}/chainage/calibrations`),
chainageRecompute: (projectId: number, video_ids: number[], road_type: string) =>
post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }),
chainageQuick: (

View File

@@ -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; }