map and chainage calculation

This commit is contained in:
2026-07-07 22:42:47 +05:30
parent 05ce1f2cdb
commit 308d6bc129
17 changed files with 4178 additions and 48 deletions

View 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 &amp; road types</h3>
<button className="btn" onClick={() => setOpen(true)}>Open </button>
</div>
</section>
);
}
return (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; 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 &amp; 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 &amp; 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>
);
}