chainage compute and map view
This commit is contained in:
@@ -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 & 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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user