698 lines
28 KiB
TypeScript
698 lines
28 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { unzipSync } from "fflate";
|
||
import {
|
||
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,
|
||
* 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 [calibs, setCalibs] = useState<CalibrationRow[]>([]);
|
||
const [calibsErr, setCalibsErr] = useState<string | null>(null);
|
||
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
|
||
// true = loaded from the server (someone's earlier compute); false = just run here.
|
||
const [summaryIsSaved, setSummaryIsSaved] = useState(false);
|
||
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("");
|
||
// 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("");
|
||
// 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));
|
||
}
|
||
// 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(() => {
|
||
if (open) void loadPoints();
|
||
}, [open, loadPoints]);
|
||
|
||
// On open / group change, show the SAVED result of the last recompute (persisted
|
||
// server-side) so a freshly signed-in admin sees the existing compute for LHS/RHS
|
||
// instead of an empty panel asking to recompute.
|
||
useEffect(() => {
|
||
setSummary(null);
|
||
setSummaryIsSaved(false);
|
||
if (!open) return;
|
||
let alive = true;
|
||
api.chainageSummary(projectId, group)
|
||
.then((s) => {
|
||
if (alive && s && typeof s === "object") {
|
||
setSummary(s);
|
||
setSummaryIsSaved(true);
|
||
}
|
||
})
|
||
.catch(() => {}); // calibrations block surfaces endpoint failures already
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [projectId, group, open]);
|
||
|
||
/** 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],
|
||
);
|
||
|
||
// 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);
|
||
try {
|
||
setSummary(await fn());
|
||
setSummaryIsSaved(false);
|
||
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);
|
||
}
|
||
};
|
||
|
||
/** 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);
|
||
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(4) : "", 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(4) : "", 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>
|
||
|
||
{/* 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)}
|
||
{!c.has_summary && (
|
||
<span className="err-tag" title="This calibration predates saved results — select its group and hit 'Recompute from points' once (same points ⇒ same values); after that every admin sees the full result here.">
|
||
{" "}· result not saved yet — recompute once
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</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)}
|
||
{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"
|
||
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>
|
||
))}
|
||
{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 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">One end</label>
|
||
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} 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 & 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">
|
||
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 — freshly run, or the saved result of the last recompute */}
|
||
{summary && (
|
||
<div className="chainage-block">
|
||
{summaryIsSaved && (() => {
|
||
const c = calibs.find((x) => x.road_type === group);
|
||
return (
|
||
<div className="dim" style={{ fontSize: 12, marginBottom: 4 }}>
|
||
Saved result of the last compute
|
||
{c ? <> — by <b>{c.computed_by}</b> · {fmtWhen(c.computed_at)}</> : null}
|
||
{" "}(recompute only if points or videos changed)
|
||
</div>
|
||
);
|
||
})()}
|
||
<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>}
|
||
{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">
|
||
{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.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>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{summary.skipped.length > 0 && (
|
||
<div className="dim" style={{ marginTop: 6 }}>
|
||
Skipped (no GPS): {summary.skipped.join(", ")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|