map and chainage calculation
This commit is contained in:
549
dashboard/src/ChainagePanel.tsx
Normal file
549
dashboard/src/ChainagePanel.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { unzipSync } from "fflate";
|
||||
import {
|
||||
api, fmtChainage, fmtChainageRange,
|
||||
type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
|
||||
} from "./api";
|
||||
|
||||
/** The import format clients should follow: one Point placemark per survey point,
|
||||
* chainage in the name ("12+400" = 12 km + 400 m, or decimal km). */
|
||||
const SAMPLE_KML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<name>chainage points — sample</name>
|
||||
<!-- One Placemark per survey point.
|
||||
name = the chainage at that point: "12+400" (12 km + 400 m) or "12.4" (km)
|
||||
coordinates = lon,lat[,alt] (KML order: longitude first!)
|
||||
The dashboard reads the name (or description) and the Point coordinates.
|
||||
LineStrings/paths are ignored — only Point placemarks import. -->
|
||||
<Placemark><name>0+000</name><Point><coordinates>78.185718,21.105570,0</coordinates></Point></Placemark>
|
||||
<Placemark><name>5+000</name><Point><coordinates>78.158000,21.092045,0</coordinates></Point></Placemark>
|
||||
<Placemark><name>10+000</name><Point><coordinates>78.123885,21.091447,0</coordinates></Point></Placemark>
|
||||
</Document></kml>
|
||||
`;
|
||||
|
||||
function downloadSampleKml() {
|
||||
const url = URL.createObjectURL(new Blob([SAMPLE_KML], { type: "application/vnd.google-earth.kml+xml" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "chainage_points_sample.kml";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** One placemark parsed out of an uploaded KML, editable before import. */
|
||||
interface KmlRow {
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
/** Editable chainage in km (prefilled from the placemark name/description). */
|
||||
km: string;
|
||||
include: boolean;
|
||||
}
|
||||
|
||||
/** Chainage from free text: road notation "12+400" (km+m) always wins; otherwise the
|
||||
* first number, interpreted in `unit`. Returns meters, or null when nothing parses. */
|
||||
function parseChainageText(text: string, unit: "km" | "m"): number | null {
|
||||
const plus = text.match(/(\d+)\s*\+\s*(\d+(?:\.\d+)?)/);
|
||||
if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]);
|
||||
const num = text.match(/-?\d+(?:[.,]\d+)?/);
|
||||
if (!num) return null;
|
||||
const v = parseFloat(num[0].replace(",", "."));
|
||||
if (isNaN(v)) return null;
|
||||
return unit === "km" ? v * 1000 : v;
|
||||
}
|
||||
|
||||
/** Extract Point placemarks (name + lat/lon) from a KML document. LineStrings and
|
||||
* multi-coordinate placemarks are skipped (counted) — only survey points import. */
|
||||
function parseKmlPoints(text: string): { rows: { name: string; lat: number; lon: number }[]; skipped: number } {
|
||||
const doc = new DOMParser().parseFromString(text, "application/xml");
|
||||
if (doc.getElementsByTagName("parsererror").length > 0) {
|
||||
throw new Error("not a valid KML/XML file (KMZ must be unzipped first)");
|
||||
}
|
||||
const rows: { name: string; lat: number; lon: number }[] = [];
|
||||
let skipped = 0;
|
||||
for (const pm of Array.from(doc.getElementsByTagNameNS("*", "Placemark"))) {
|
||||
const coords = pm.getElementsByTagNameNS("*", "coordinates")[0]?.textContent?.trim() ?? "";
|
||||
const tuples = coords.split(/\s+/).filter(Boolean);
|
||||
if (tuples.length !== 1) {
|
||||
skipped += 1; // a LineString/Polygon (or empty) — not a survey point
|
||||
continue;
|
||||
}
|
||||
const [lon, lat] = tuples[0].split(",").map(parseFloat);
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const name = pm.getElementsByTagNameNS("*", "name")[0]?.textContent?.trim()
|
||||
|| pm.getElementsByTagNameNS("*", "description")[0]?.textContent?.trim()
|
||||
|| "";
|
||||
rows.push({ name, lat, lon });
|
||||
}
|
||||
return { rows, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin chainage panel: manage road-type labels, enter the client's calibration
|
||||
* points (lat/lon ↔ chainage), run the two-point quick assign ("first video starts
|
||||
* at X, last ends at Y") or a full recompute over the current group, and read the
|
||||
* resulting per-span scales + per-video chainage. `sr` (GPS-measured) chainage is
|
||||
* shown but never editable — only the client-calibrated values come from points.
|
||||
*/
|
||||
export function ChainagePanel({
|
||||
projectId,
|
||||
videos,
|
||||
roadTypes,
|
||||
onChanged,
|
||||
}: {
|
||||
projectId: number;
|
||||
videos: VideoRow[];
|
||||
roadTypes: RoadType[];
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos)
|
||||
const [points, setPoints] = useState<ChainagePoint[]>([]);
|
||||
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// add-point form
|
||||
const [latlon, setLatlon] = useState("");
|
||||
const [pointKm, setPointKm] = useState("");
|
||||
const [pointNote, setPointNote] = useState("");
|
||||
// quick two-point form
|
||||
const [startKm, setStartKm] = useState("0");
|
||||
const [endKm, setEndKm] = useState("");
|
||||
// road-type editor
|
||||
const [newType, setNewType] = useState("");
|
||||
// KML import preview
|
||||
const [kmlRows, setKmlRows] = useState<KmlRow[] | null>(null);
|
||||
const [kmlUnit, setKmlUnit] = useState<"km" | "m">("km");
|
||||
const [kmlSkipped, setKmlSkipped] = useState(0);
|
||||
const kmlFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadPoints = useCallback(async () => {
|
||||
try {
|
||||
setPoints(await api.chainagePoints(projectId));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadPoints();
|
||||
}, [open, loadPoints]);
|
||||
useEffect(() => setSummary(null), [projectId, group]);
|
||||
|
||||
/** The route's videos = current group, ignored ones excluded. */
|
||||
const groupVideos = useMemo(
|
||||
() => videos.filter((v) => !v.ignored && (group === "" || v.road_type === group)),
|
||||
[videos, group],
|
||||
);
|
||||
const groupPoints = useMemo(
|
||||
() => points.filter((p) => p.road_type === group || p.road_type === ""),
|
||||
[points, group],
|
||||
);
|
||||
|
||||
const run = async (fn: () => Promise<CalibrationSummary>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setSummary(await fn());
|
||||
await loadPoints();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addPoint = async () => {
|
||||
const m = latlon.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*$/);
|
||||
const km = parseFloat(pointKm);
|
||||
if (!m || isNaN(km)) {
|
||||
setError('Point needs "lat, lon" and a chainage in km.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.addChainagePoint(projectId, {
|
||||
lat: parseFloat(m[1]),
|
||||
lon: parseFloat(m[2]),
|
||||
chainage_m: km * 1000,
|
||||
note: pointNote.trim(),
|
||||
road_type: group,
|
||||
});
|
||||
setLatlon("");
|
||||
setPointKm("");
|
||||
setPointNote("");
|
||||
await loadPoints();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePoint = async (id: number) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteChainagePoint(id);
|
||||
await loadPoints();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quick = () => {
|
||||
const s = parseFloat(startKm);
|
||||
const e = parseFloat(endKm);
|
||||
if (isNaN(s) || isNaN(e)) {
|
||||
setError("Quick assign needs start and end chainage in km.");
|
||||
return;
|
||||
}
|
||||
void run(() =>
|
||||
api.chainageQuick(projectId, {
|
||||
video_ids: groupVideos.map((v) => v.id),
|
||||
start_m: s * 1000,
|
||||
end_m: e * 1000,
|
||||
road_type: group,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const recompute = () =>
|
||||
run(() => api.chainageRecompute(projectId, groupVideos.map((v) => v.id), group));
|
||||
|
||||
const addRoadType = async () => {
|
||||
const name = newType.trim();
|
||||
if (!name) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setRoadTypes(projectId, [...roadTypes.map((r) => r.name), name]);
|
||||
setNewType("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKmlFile = async (file: File) => {
|
||||
setError(null);
|
||||
try {
|
||||
let text: string;
|
||||
if (/\.kmz$/i.test(file.name)) {
|
||||
// KMZ = zipped KML; take the first .kml entry (usually doc.kml).
|
||||
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
||||
const key = Object.keys(entries).find((k) => k.toLowerCase() === "doc.kml")
|
||||
?? Object.keys(entries).find((k) => /\.kml$/i.test(k));
|
||||
if (!key) {
|
||||
setError(`${file.name} contains no .kml document.`);
|
||||
return;
|
||||
}
|
||||
text = new TextDecoder().decode(entries[key]);
|
||||
} else {
|
||||
text = await file.text();
|
||||
}
|
||||
const { rows, skipped } = parseKmlPoints(text);
|
||||
if (rows.length === 0) {
|
||||
setError(`No point placemarks found in ${file.name}${skipped ? ` (${skipped} non-point entries skipped)` : ""}.`);
|
||||
return;
|
||||
}
|
||||
setKmlSkipped(skipped);
|
||||
setKmlRows(rows.map((r) => {
|
||||
const m = parseChainageText(r.name, kmlUnit);
|
||||
return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null };
|
||||
}));
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
if (kmlFileRef.current) kmlFileRef.current.value = ""; // allow re-picking the same file
|
||||
}
|
||||
};
|
||||
|
||||
/** Re-read every row's chainage from its placemark name under the new unit. */
|
||||
const setKmlUnitAndReparse = (u: "km" | "m") => {
|
||||
setKmlUnit(u);
|
||||
setKmlRows((rows) => rows?.map((r) => {
|
||||
const m = parseChainageText(r.name, u);
|
||||
return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null };
|
||||
}) ?? null);
|
||||
};
|
||||
|
||||
const importKml = async () => {
|
||||
if (!kmlRows) return;
|
||||
const points = kmlRows
|
||||
.filter((r) => r.include && r.km.trim() !== "" && !isNaN(parseFloat(r.km)))
|
||||
.map((r) => ({
|
||||
lat: r.lat,
|
||||
lon: r.lon,
|
||||
chainage_m: parseFloat(r.km) * 1000,
|
||||
note: r.name,
|
||||
road_type: group,
|
||||
}));
|
||||
if (points.length === 0) {
|
||||
setError("No rows selected with a valid chainage.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.addChainagePointsBulk(projectId, points);
|
||||
setKmlRows(null);
|
||||
await loadPoints();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeRoadType = async (name: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setRoadTypes(projectId, roadTypes.map((r) => r.name).filter((n) => n !== name));
|
||||
if (group === name) setGroup("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h3>Chainage & road types</h3>
|
||||
<button className="btn" onClick={() => setOpen(true)}>Open ▾</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h3>Chainage & road types</h3>
|
||||
<button className="btn" onClick={() => setOpen(false)}>Close ▴</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">⚠ {error}</div>}
|
||||
|
||||
{/* Road-type labels (project-wide) */}
|
||||
<div className="chainage-block">
|
||||
<div className="dim block-label">Road types (assign them to selected videos in the table below)</div>
|
||||
<div className="member-chips">
|
||||
{roadTypes.map((r) => (
|
||||
<span key={r.name} className="member-chip">
|
||||
{r.name} <span className="dim">({r.video_count})</span>
|
||||
<button className="chip-x" disabled={busy} onClick={() => removeRoadType(r.name)} title="Remove label">×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="chip-input"
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }}
|
||||
placeholder="Add: LHS / RHS / MCW…"
|
||||
/>
|
||||
<button className="btn" disabled={busy || !newType.trim()} onClick={addRoadType}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calibration group + the videos it covers */}
|
||||
<div className="chainage-block form-row">
|
||||
<label className="dim">Calibration group</label>
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)}>
|
||||
<option value="">(all videos)</option>
|
||||
{roadTypes.map((r) => (
|
||||
<option key={r.name} value={r.name}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="dim">
|
||||
{groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid2">
|
||||
{/* Control points */}
|
||||
<div className="chainage-block">
|
||||
<div className="dim block-label">Client calibration points (lat/lon ↔ chainage)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Chainage</th><th>Lat, Lon</th><th>Group</th><th>Note</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupPoints.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{fmtChainage(p.chainage_m)}</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><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{groupPoints.length === 0 && (
|
||||
<tr><td colSpan={5} className="dim">no points yet — add them, or use the quick assign</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="form-row">
|
||||
<input value={latlon} onChange={(e) => setLatlon(e.target.value)} placeholder="lat, lon" style={{ width: 190 }} />
|
||||
<input value={pointKm} onChange={(e) => setPointKm(e.target.value)} placeholder="chainage (km)" style={{ width: 110 }} />
|
||||
<input value={pointNote} onChange={(e) => setPointNote(e.target.value)} placeholder="note (optional)" style={{ width: 140 }} />
|
||||
<button className="btn" disabled={busy} onClick={addPoint}>Add point</button>
|
||||
<input
|
||||
ref={kmlFileRef}
|
||||
type="file"
|
||||
accept=".kml,.kmz,.xml"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onKmlFile(f); }}
|
||||
/>
|
||||
<button className="btn" disabled={busy} onClick={() => kmlFileRef.current?.click()}
|
||||
title="Upload a KML/KMZ of survey points — chainage read from each placemark's name (12+400 or a number)">
|
||||
Import KML/KMZ…
|
||||
</button>
|
||||
<button className="btn" onClick={downloadSampleKml}
|
||||
title="Download the template your client's points file should follow">
|
||||
Sample KML
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{kmlRows && (
|
||||
<div className="kml-preview">
|
||||
<div className="form-row">
|
||||
<b>{kmlRows.filter((r) => r.include).length}</b>
|
||||
<span className="dim">of {kmlRows.length} points will import into group "{group || "any"}"
|
||||
{kmlSkipped > 0 && ` · ${kmlSkipped} non-point placemark(s) skipped`}</span>
|
||||
<span className="dim">· values in names are</span>
|
||||
<select value={kmlUnit} onChange={(e) => setKmlUnitAndReparse(e.target.value as "km" | "m")}>
|
||||
<option value="km">km</option>
|
||||
<option value="m">meters</option>
|
||||
</select>
|
||||
<span className="dim">("12+400" always = 12 km + 400 m)</span>
|
||||
</div>
|
||||
<div className="kml-preview-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th></th><th>Placemark</th><th>Lat, Lon</th><th>Chainage (km)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kmlRows.map((r, i) => (
|
||||
<tr key={i} className={r.include ? undefined : "row-ignored"}>
|
||||
<td><input type="checkbox" checked={r.include}
|
||||
onChange={() => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, include: !x.include } : x))} /></td>
|
||||
<td className="mono">{r.name || <span className="dim">(unnamed)</span>}</td>
|
||||
<td className="mono dim">{r.lat.toFixed(6)}, {r.lon.toFixed(6)}</td>
|
||||
<td><input value={r.km} style={{ width: 90 }} placeholder="?"
|
||||
onChange={(e) => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, km: e.target.value, include: true } : x))} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<button className="btn primary" disabled={busy} onClick={importKml}>
|
||||
Import {kmlRows.filter((r) => r.include).length} point(s)
|
||||
</button>
|
||||
<button className="btn" disabled={busy} onClick={() => setKmlRows(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick assign + recompute */}
|
||||
<div className="chainage-block">
|
||||
<div className="dim block-label">
|
||||
Two-point quick assign — client gives just first & last chainage; the error is
|
||||
distributed over the whole route
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="dim">Route starts at</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
|
||||
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
|
||||
Assign & compute
|
||||
</button>
|
||||
</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">
|
||||
Recompute from points
|
||||
</button>
|
||||
<span className="dim">orders the {groupVideos.length} video(s) into a route, snaps the points, distributes the error</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result summary */}
|
||||
{summary && (
|
||||
<div className="chainage-block">
|
||||
<div className="block-label">
|
||||
Route <b>{(summary.route_len_m / 1000).toFixed(3)} km</b> (GPS-measured)
|
||||
· {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>}
|
||||
</div>
|
||||
{summary.warnings.length > 0 && (
|
||||
<ul className="chainage-warnings">
|
||||
{summary.warnings.map((w, i) => <li key={i}>⚠ {w}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{summary.spans.length > 0 && (
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Span (client)</th><th>GPS length</th><th>Client length</th><th>Scale</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.spans.map((sp, i) => (
|
||||
<tr key={i} className={Math.abs(sp.scale - 1) > 0.05 ? "span-warn" : undefined}>
|
||||
<td>{fmtChainage(sp.from_chainage_m)} → {fmtChainage(sp.to_chainage_m)}</td>
|
||||
<td>{(sp.gps_len_m / 1000).toFixed(3)} km</td>
|
||||
<td>{(sp.client_len_m / 1000).toFixed(3)} km</td>
|
||||
<td>×{sp.scale.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<table style={{ marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr><th>#</th><th>Video</th><th>Chainage (client)</th><th>SR chainage (GPS)</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.videos.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td className="dim">{v.seq + 1}</td>
|
||||
<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
|
||||
</span>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{summary.skipped.length > 0 && (
|
||||
<div className="dim" style={{ marginTop: 6 }}>
|
||||
Skipped (no GPS): {summary.skipped.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user