chainage compute and map view

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

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { unzipSync } from "fflate";
import {
api, fmtChainage, fmtChainageRange,
type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
api, fmtChainage, fmtChainageRange, fmtWhen,
type CalibrationRow, type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
} from "./api";
/** The import format clients should follow: one Point placemark per survey point,
@@ -102,6 +102,8 @@ export function ChainagePanel({
const [open, setOpen] = useState(false);
const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos)
const [points, setPoints] = useState<ChainagePoint[]>([]);
const [calibs, setCalibs] = useState<CalibrationRow[]>([]);
const [calibsErr, setCalibsErr] = useState<string | null>(null);
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -109,6 +111,8 @@ export function ChainagePanel({
const [latlon, setLatlon] = useState("");
const [pointKm, setPointKm] = useState("");
const [pointNote, setPointNote] = useState("");
// inline note edit on an existing point
const [noteEdit, setNoteEdit] = useState<{ id: number; text: string } | null>(null);
// quick two-point form
const [startKm, setStartKm] = useState("0");
const [endKm, setEndKm] = useState("");
@@ -126,6 +130,15 @@ export function ChainagePanel({
} catch (e) {
setError(String(e));
}
// Calibration list failing must not hide the points editor — but it must not
// fail silently either (a broken endpoint/auth issue should be visible).
try {
setCalibs(await api.chainageCalibrations(projectId));
setCalibsErr(null);
} catch (e) {
setCalibs([]);
setCalibsErr(String(e));
}
}, [projectId]);
useEffect(() => {
@@ -143,6 +156,25 @@ export function ChainagePanel({
[points, group],
);
// Suggest quick-assign values from the group's own videos: an existing
// calibration's range when one exists, else 0 → the summed GPS length rounded to
// the nearest whole km. Re-suggested when the group changes (still editable).
useEffect(() => {
if (!open) return;
const ch = groupVideos
.flatMap((v) => [v.chainage_start_m, v.chainage_end_m])
.filter((x): x is number => x != null);
if (ch.length > 0) {
setStartKm((Math.min(...ch) / 1000).toFixed(4));
setEndKm((Math.max(...ch) / 1000).toFixed(4));
} else {
const len = groupVideos.reduce((s, v) => s + (v.gps_len_m ?? 0), 0);
setStartKm("0");
setEndKm(len > 0 ? String(Math.max(1, Math.round(len / 1000))) : "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, group]);
const run = async (fn: () => Promise<CalibrationSummary>) => {
setBusy(true);
setError(null);
@@ -197,6 +229,19 @@ export function ChainagePanel({
}
};
/** Persist an inline note edit (Enter or blur). */
const saveNote = async () => {
if (!noteEdit) return;
const { id, text } = noteEdit;
setNoteEdit(null);
try {
await api.updateChainagePointNote(id, text);
await loadPoints();
} catch (e) {
setError(String(e));
}
};
const quick = () => {
const s = parseFloat(startKm);
const e = parseFloat(endKm);
@@ -374,6 +419,21 @@ export function ChainagePanel({
</span>
</div>
{/* Saved calibrations — persisted server-side, so every user (dashboard AND
desktop exports) sees the same computed chainage without re-running anything. */}
{(calibs.length > 0 || calibsErr) && (
<div className="chainage-block">
<div className="dim block-label">Saved calibrations (shared all users see these chainages)</div>
{calibsErr && <div className="err-tag" style={{ fontSize: 12 }}> calibrations unavailable: {calibsErr}</div>}
{calibs.map((c) => (
<div key={c.road_type || "(any)"} className="dim" style={{ fontSize: 12 }}>
<b>{c.road_type || "(ungrouped)"}</b> · route {fmtChainage(c.route_len_m)} km
· {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)}
</div>
))}
</div>
)}
<div className="grid2">
{/* Control points */}
<div className="chainage-block">
@@ -385,10 +445,36 @@ export function ChainagePanel({
<tbody>
{groupPoints.map((p) => (
<tr key={p.id}>
<td>{fmtChainage(p.chainage_m)}</td>
<td>
{fmtChainage(p.chainage_m)}
{p.origin === "quick" && (
<span className="dim" title="Generated by the two-point quick assign — replaced automatically on the next quick run"> · auto</span>
)}
</td>
<td className="mono">{p.lat.toFixed(6)}, {p.lon.toFixed(6)}</td>
<td className="dim">{p.road_type || "any"}</td>
<td className="dim">{p.note || "—"}</td>
<td
className="dim"
style={{ cursor: "pointer" }}
title="Click to edit — e.g. 'start point', 'toll plaza', a survey remark"
onClick={() => { if (noteEdit?.id !== p.id) setNoteEdit({ id: p.id, text: p.note }); }}
>
{noteEdit?.id === p.id ? (
<input
autoFocus
value={noteEdit.text}
style={{ width: 140 }}
onChange={(e) => setNoteEdit({ id: p.id, text: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void saveNote();
if (e.key === "Escape") setNoteEdit(null);
}}
onBlur={() => void saveNote()}
/>
) : (
p.note || "— (click to add)"
)}
</td>
<td><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
</tr>
))}
@@ -464,18 +550,28 @@ export function ChainagePanel({
{/* Quick assign + recompute */}
<div className="chainage-block">
<div className="dim block-label">
Two-point quick assign client gives just first &amp; last chainage; the error is
distributed over the whole route
Two-point quick assign client gives just the chainage at the route's two ends; the
error is distributed over the whole route. Values are pre-filled from the {group ? `"${group}"` : "selected"} videos.
</div>
<div className="form-row">
<label className="dim">Route starts at</label>
<label className="dim">One end</label>
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} style={{ width: 90 }} /> km
<label className="dim">ends at</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} placeholder="49.5" style={{ width: 90 }} /> km
<label className="dim">other end</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} style={{ width: 90 }} /> km
<button className="btn" disabled={busy}
title="The 0 landed on the wrong physical end? Swap the two values and re-compute."
onClick={() => { const s = startKm; setStartKm(endKm); setEndKm(s); }}>
⇄ swap
</button>
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
Assign &amp; compute
</button>
</div>
<div className="dim" style={{ marginTop: 6, fontSize: 12 }}>
The two auto points ("quick: chain start/end") are saved into group <b>{group || "any"}</b>.
After computing, check the km stones on the map — if 0 sits at the wrong end of the road,
hit ⇄ swap and compute again. Footage direction doesn't matter (handled automatically).
</div>
<div className="form-row" style={{ marginTop: 10 }}>
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
title="Re-run the calibration with the points above">
@@ -494,6 +590,20 @@ export function ChainagePanel({
· {summary.points_used} point(s) used
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
{summary.reversed && <span className="dim"> · direction auto-reversed</span>}
{summary.majority_flipped && (
<span className="dim" title="Most videos here were filmed driving toward decreasing chainage — that's this route's normal pattern, so only videos running AGAINST it are flagged below.">
{" "}· footage mostly runs high low chainage
</span>
)}
{summary.videos.length > 1 && (() => {
const ag = summary.videos.filter((v) => v.against_flow).length;
return (
<span className={ag > 0 ? "err-tag" : "dim"}
title={ag > 0 ? "Videos running against the route's dominant direction — on a divided road this often means the video belongs to the other carriageway (wrong group)." : "Every video runs in the route's dominant direction."}>
{" "}· {summary.videos.length - ag}/{summary.videos.length} follow the dominant direction{ag > 0 ? `, ${ag} against` : ""}
</span>
);
})()}
</div>
{summary.warnings.length > 0 && (
<ul className="chainage-warnings">
@@ -528,9 +638,9 @@ export function ChainagePanel({
<td className="mono">{v.file_name}</td>
<td>{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}</td>
<td className="dim">{fmtChainageRange(v.sr_start_m, v.sr_end_m)}</td>
<td className="dim">{v.flipped && (
<span title="This footage was filmed driving toward DECREASING chainage (e.g. the return carriageway), so frame 0 sits at the higher value — chainage runs high → low through the video. Handled automatically everywhere (per-annotation chainage included). If about half a group is flipped, you probably have both directions mixed in one group — split them by road type.">
flipped
<td className="dim">{v.against_flow && (
<span className="err-tag" title="This video's travel direction is OPPOSITE to most of the route. Chainage is still computed correctly — but if it's unexpected, the video may belong to the other carriageway (wrong group).">
against flow
</span>
)}</td>
</tr>