map updates

This commit is contained in:
2026-07-08 15:06:57 +05:30
parent 5c129bbdfe
commit 78b1666402
9 changed files with 457 additions and 71 deletions

View File

@@ -171,11 +171,14 @@ pass back. All Phase 3 routes require `Authorization: Bearer <token>`.
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.

View File

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

View File

@@ -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"], ["2040", "#74add1"], ["4060", "#5ccf75"],
["6080", "#f5c838"], ["80100", "#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<MapVideo[]>([]);
const [points, setPoints] = useState<ChainagePoint[]>([]);
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<string | null>(null); // null = all, '' = unassigned
const [showCoverage, setShowCoverage] = useState(true);
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
@@ -151,6 +180,8 @@ export function MapView({
const pointLayer = useRef<L.LayerGroup | null>(null);
const correctionLayer = useRef<L.LayerGroup | null>(null);
const searchLayer = useRef<L.LayerGroup | null>(null);
const coverageLayer = useRef<L.LayerGroup | null>(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<number, number[]>());
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<string, number> = {};
@@ -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<string>();
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<string, MapVideo[]>();
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: `<div class="dir-arrow" style="transform:rotate(${rot}deg)">▲</div>`,
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({
<span className="mono">{mt.v.file_name}</span>
{mt.ch != null ? (
// Same chainage can exist on LHS, RHS AND a ramp — one row per group.
<span className="dim"> · <b>{mt.v.road_type || "no group"}</b> · contains {(mt.ch / 1000).toFixed(3)} km</span>
<span className="dim"> · <b>{mt.v.road_type || "no group"}</b> · contains {(mt.ch / 1000).toFixed(4)}</span>
) : mt.away != null ? (
<span className="dim"> · {mt.away.toFixed(0)} m away{mt.v.road_type ? ` · ${mt.v.road_type}` : ""}</span>
) : mt.v.track ? (
@@ -476,12 +670,37 @@ export function MapView({
</div>
<div className="map-colorby">
<button className={colorBy === "video" ? "active" : ""} onClick={() => setColorBy("video")}>
Color by video
Video
</button>
<button className={colorBy === "status" ? "active" : ""} onClick={() => setColorBy("status")}>
Color by status
Status
</button>
<button className={colorBy === "speed" ? "active" : ""} onClick={() => setColorBy("speed")}
title="Color each video by its average speed; the selected video shows a per-stretch speed gradient">
Speed
</button>
</div>
<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>
</div>
{roadTypesPresent.length > 1 && (
<div className="map-chips">
<button className={`map-chip${roadFilter === null ? "" : " off"}`} onClick={() => setRoadFilter(null)}>
all roads
</button>
{roadTypesPresent.map((rt) => (
<button
key={rt || "(none)"}
className={`map-chip${roadFilter === rt ? "" : " off"}`}
onClick={() => setRoadFilter(roadFilter === rt ? null : rt)}
>
{rt || "(no road)"}
</button>
))}
</div>
)}
<a className="btn" href={tracksKmlUrl(projectId)} download
title="All tracks (status colors) + calibration points — opens in Google Earth">
KML
@@ -501,6 +720,16 @@ export function MapView({
))}
</div>
)}
{colorBy === "speed" && (
<div className="map-chips" title="km/h">
{SPEED_LEGEND.map(([lbl, col]) => (
<span key={lbl} className="map-chip" style={{ borderColor: col, color: col, cursor: "default" }}>
{lbl}
</span>
))}
<span className="dim">km/h</span>
</div>
)}
</div>
{error && <div className="error"> {error}</div>}
@@ -524,6 +753,25 @@ export function MapView({
</span>
<span className="dim">Road type</span>
<span>{selected.road_type || "—"}</span>
{selected.track && selected.track.length >= 2 && (
<>
<span className="dim">First fix</span>
<span className="mono">{selected.track[0][0].toFixed(6)}, {selected.track[0][1].toFixed(6)}</span>
<span className="dim">Last fix</span>
<span className="mono">
{selected.track[selected.track.length - 1][0].toFixed(6)}, {selected.track[selected.track.length - 1][1].toFixed(6)}
</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>
@@ -643,14 +891,50 @@ export function MapView({
</div>
)}
{/* Requirement: videos without metadata must be listed visibly. */}
{/* Coverage: holes and double-coverage between chainage-adjacent videos. */}
{coverage.length > 0 && (
<div className="map-nometa">
<div className="map-nometa-head">
Coverage
{" "}<b>{coverage.filter((c) => c.kind === "gap").length}</b> gap(s)
{" · "}<b>{coverage.filter((c) => c.kind === "overlap").length}</b> overlap(s)
<button className="btn mini" style={{ marginLeft: 8 }}
onClick={() => setShowCoverage((s) => !s)}
title="Show/hide the gap + overlap markers on the map">
{showCoverage ? "hide" : "show"}
</button>
</div>
<div className="map-nometa-list">
{coverage.slice(0, 40).map((c, i) => (
<div key={i} className="map-nometa-row">
<button
className="map-nometa-name mono"
title={`${c.a.file_name} ${c.kind === "gap" ? "→" : "↔"} ${c.b.file_name}`}
onClick={() => {
if (c.pos && mapRef.current) mapRef.current.setView(c.pos, 15);
}}
>
{c.a.file_name} {c.kind === "gap" ? "→" : "↔"} {c.b.file_name}
</button>
<span className={c.kind === "gap" ? "err-tag" : "dim"} style={c.kind === "overlap" ? { color: "#b48cff" } : undefined}>
{c.kind} {c.size_m < 1000 ? `${c.size_m.toFixed(0)} m` : `${(c.size_m / 1000).toFixed(2)} km`}
</span>
</div>
))}
{coverage.length > 40 && <div className="dim">and {coverage.length - 40} more</div>}
</div>
</div>
)}
{/* Data quality: no metadata / corrupted / idle / long dropouts — all
the videos that need a human look, in one place. */}
<div className="map-nometa">
<div className="map-nometa-head">
Without GPS metadata <b>{noMeta.length}</b>
Data quality <b>{noMeta.length + idle.length + dropouts.length}</b>
{pendingScan.length > 0 && <span className="dim"> · {pendingScan.length} awaiting scan</span>}
{isAdmin && (
<button className="btn mini" disabled={busy} style={{ marginLeft: 8 }}
title="Queue a fresh scan of EVERY video (background, one at a time). Needed once to enable start-stitching on videos scanned before the upgrade."
title="Queue a fresh scan of EVERY video (background, one at a time)."
onClick={() => {
if (window.confirm("Re-scan GPS metadata for every video in this project? The sweep works through them in the background."))
void act(() => api.gpsRescanAll(projectId));
@@ -666,9 +950,9 @@ export function MapView({
{v.file_name}
</button>
{v.gps_status === "error" ? (
<span className="err-tag" title={v.gps_error ?? "scan error"}>error</span>
<span className="err-tag" title={v.gps_error ?? "scan error"}>corrupt/unreadable</span>
) : (
<span className="dim">none</span>
<span className="dim">no GPS</span>
)}
{isAdmin && (
<button className="btn mini" disabled={busy} onClick={() => rescan(v)} title="Queue a fresh scan">
@@ -677,7 +961,29 @@ export function MapView({
)}
</div>
))}
{noMeta.length === 0 && <div className="dim">every scanned video has GPS </div>}
{idle.map((v) => (
<div key={v.id} className="map-nometa-row">
<button className="map-nometa-name mono" onClick={() => select(v)} title={v.rel_path}>
{v.file_name}
</button>
<span className="idle-tag" title={`vehicle moved only ~${(v.gps_len_m ?? 0).toFixed(0)} m — parked/idling footage`}>
idle {(v.gps_len_m ?? 0).toFixed(0)} m
</span>
</div>
))}
{dropouts.map((v) => (
<div key={v.id} className="map-nometa-row">
<button className="map-nometa-name mono" onClick={() => select(v)} title={v.rel_path}>
{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
</span>
</div>
))}
{noMeta.length + idle.length + dropouts.length === 0 && (
<div className="dim">every scanned video has healthy GPS </div>
)}
</div>
</div>
</div>

View File

@@ -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 +Δ, …

View File

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

View File

@@ -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<String, Value>, 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]

View File

@@ -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<f64>, Option<f64>) {
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::<f64>, |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<Pt>, max: usize) -> Vec<Pt> {
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.

View File

@@ -434,7 +434,8 @@ async fn map_data(State(s): State<AppState>, 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<AppState>, 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!(
"<Placemark><name>{}</name><description>{}</description><styleUrl>#st-{}</styleUrl><LineString><tessellate>1</tessellate><coordinates>",
@@ -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<f64>) -> Option<f64> {
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);
}
}
}

View File

@@ -214,6 +214,15 @@ pub struct MapVideo {
// Data quality from the raw scan: fix count + biggest dropout between fixes.
pub gps_sample_count: Option<i32>,
pub gps_max_gap_s: Option<f64>,
pub duration_s: Option<f64>,
// Recording window (absolute UTC epoch of first/last fix) — temporal ordering.
pub gps_start_epoch: Option<f64>,
pub gps_end_epoch: Option<f64>,
// Speed over the full-resolution track (m/s) — the map's speed mode/stats.
#[sqlx(default)]
pub speed_avg_mps: Option<f64>,
#[sqlx(default)]
pub speed_max_mps: Option<f64>,
}
/// `GET /api/projects/:id/road_types` — a label + how many videos carry it.