chainage compute and map view
This commit is contained in:
22
README.md
22
README.md
@@ -201,12 +201,30 @@ Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist
|
|||||||
the dashboard's **KML import** uses this (placemark
|
the dashboard's **KML import** uses this (placemark
|
||||||
names like `12+400` or `12.4` become chainage) (admin)
|
names like `12+400` or `12.4` become chainage) (admin)
|
||||||
- `DELETE /api/chainage/points/:id` — remove a point (admin)
|
- `DELETE /api/chainage/points/:id` — remove a point (admin)
|
||||||
|
- `POST /api/chainage/points/:id` `{note}` — edit a point's note in place; position/chainage
|
||||||
|
stay immutable (admin). Points carry an `origin` field:
|
||||||
|
`''` = manual, `'quick'` = generated by the quick assign
|
||||||
|
(replaced wholesale on the next quick run — identity is
|
||||||
|
the column, so renaming the note is safe)
|
||||||
|
- `GET /api/projects/:id/chainage/calibrations` — the saved calibrations, one per road-type group
|
||||||
|
(`road_type, route_len_m, anchor_count, computed_by,
|
||||||
|
computed_at`) — every recompute persists one, so any user
|
||||||
|
sees what's already calibrated (member/admin)
|
||||||
- `POST /api/projects/:id/chainage/recompute` `{video_ids, road_type}` — order the videos into a
|
- `POST /api/projects/:id/chainage/recompute` `{video_ids, road_type}` — order the videos into a
|
||||||
route, snap the points, distribute the client-vs-GPS error
|
route, snap the points, distribute the client-vs-GPS error
|
||||||
piecewise-linearly; writes `chainage_*` (client) +
|
piecewise-linearly; writes `chainage_*` (client) +
|
||||||
`sr_chainage_*` (measured, immutable) + `route_seq` (admin)
|
`sr_chainage_*` (measured, immutable) + `route_seq` (admin).
|
||||||
|
Summary reports `majority_flipped` + per-video
|
||||||
|
`against_flow` (opposite to the route's dominant direction —
|
||||||
|
the only direction flag the UI shows). Warnings include
|
||||||
|
mixed directions (≥25% against the majority → probably both
|
||||||
|
carriageways in one group) and ambiguous untagged points
|
||||||
|
(used by this route but also within 150 m of another
|
||||||
|
road-type group's videos — tag them)
|
||||||
- `POST /api/projects/:id/chainage/quick` `{video_ids, start_m, end_m, road_type}` — the
|
- `POST /api/projects/:id/chainage/quick` `{video_ids, start_m, end_m, road_type}` — the
|
||||||
two-point case: points at the route ends + same recompute (admin)
|
two-point case: points at the route's two geometric ends
|
||||||
|
(notes `quick: chain start/end`, tagged with the group) +
|
||||||
|
same recompute (admin)
|
||||||
|
|
||||||
### Env
|
### Env
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { unzipSync } from "fflate";
|
import { unzipSync } from "fflate";
|
||||||
import {
|
import {
|
||||||
api, fmtChainage, fmtChainageRange,
|
api, fmtChainage, fmtChainageRange, fmtWhen,
|
||||||
type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
|
type CalibrationRow, type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
/** The import format clients should follow: one Point placemark per survey point,
|
/** 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 [open, setOpen] = useState(false);
|
||||||
const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos)
|
const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos)
|
||||||
const [points, setPoints] = useState<ChainagePoint[]>([]);
|
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 [summary, setSummary] = useState<CalibrationSummary | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -109,6 +111,8 @@ export function ChainagePanel({
|
|||||||
const [latlon, setLatlon] = useState("");
|
const [latlon, setLatlon] = useState("");
|
||||||
const [pointKm, setPointKm] = useState("");
|
const [pointKm, setPointKm] = useState("");
|
||||||
const [pointNote, setPointNote] = 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
|
// quick two-point form
|
||||||
const [startKm, setStartKm] = useState("0");
|
const [startKm, setStartKm] = useState("0");
|
||||||
const [endKm, setEndKm] = useState("");
|
const [endKm, setEndKm] = useState("");
|
||||||
@@ -126,6 +130,15 @@ export function ChainagePanel({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(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]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,6 +156,25 @@ export function ChainagePanel({
|
|||||||
[points, group],
|
[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>) => {
|
const run = async (fn: () => Promise<CalibrationSummary>) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
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 quick = () => {
|
||||||
const s = parseFloat(startKm);
|
const s = parseFloat(startKm);
|
||||||
const e = parseFloat(endKm);
|
const e = parseFloat(endKm);
|
||||||
@@ -374,6 +419,21 @@ export function ChainagePanel({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="grid2">
|
||||||
{/* Control points */}
|
{/* Control points */}
|
||||||
<div className="chainage-block">
|
<div className="chainage-block">
|
||||||
@@ -385,10 +445,36 @@ export function ChainagePanel({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{groupPoints.map((p) => (
|
{groupPoints.map((p) => (
|
||||||
<tr key={p.id}>
|
<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="mono">{p.lat.toFixed(6)}, {p.lon.toFixed(6)}</td>
|
||||||
<td className="dim">{p.road_type || "any"}</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>
|
<td><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -464,18 +550,28 @@ export function ChainagePanel({
|
|||||||
{/* Quick assign + recompute */}
|
{/* Quick assign + recompute */}
|
||||||
<div className="chainage-block">
|
<div className="chainage-block">
|
||||||
<div className="dim block-label">
|
<div className="dim block-label">
|
||||||
Two-point quick assign — client gives just first & last chainage; the error is
|
Two-point quick assign — client gives just the chainage at the route's two ends; the
|
||||||
distributed over the whole route
|
error is distributed over the whole route. Values are pre-filled from the {group ? `"${group}"` : "selected"} videos.
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<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
|
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} style={{ width: 90 }} /> km
|
||||||
<label className="dim">ends at</label>
|
<label className="dim">other end</label>
|
||||||
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} placeholder="49.5" style={{ width: 90 }} /> km
|
<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}>
|
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
|
||||||
Assign & compute
|
Assign & compute
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 }}>
|
<div className="form-row" style={{ marginTop: 10 }}>
|
||||||
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
|
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
|
||||||
title="Re-run the calibration with the points above">
|
title="Re-run the calibration with the points above">
|
||||||
@@ -494,6 +590,20 @@ export function ChainagePanel({
|
|||||||
· {summary.points_used} point(s) used
|
· {summary.points_used} point(s) used
|
||||||
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
|
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
|
||||||
{summary.reversed && <span className="dim"> · direction auto-reversed</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>
|
</div>
|
||||||
{summary.warnings.length > 0 && (
|
{summary.warnings.length > 0 && (
|
||||||
<ul className="chainage-warnings">
|
<ul className="chainage-warnings">
|
||||||
@@ -528,9 +638,9 @@ export function ChainagePanel({
|
|||||||
<td className="mono">{v.file_name}</td>
|
<td className="mono">{v.file_name}</td>
|
||||||
<td>{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}</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">{fmtChainageRange(v.sr_start_m, v.sr_end_m)}</td>
|
||||||
<td className="dim">{v.flipped && (
|
<td className="dim">{v.against_flow && (
|
||||||
<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.">
|
<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).">
|
||||||
⇄ flipped
|
⇄ against flow
|
||||||
</span>
|
</span>
|
||||||
)}</td>
|
)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -106,6 +106,59 @@ const SPEED_LEGEND: [string, string][] = [
|
|||||||
["60–80", "#f5c838"], ["80–100", "#f5a623"], ["100+", "#ff5252"],
|
["60–80", "#f5c838"], ["80–100", "#f5a623"], ["100+", "#ff5252"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** GPS dropout duration, readable at any size ("57 s" / "12 min" / "2.5 h"). */
|
||||||
|
function fmtGap(s: number): string {
|
||||||
|
if (s < 120) return `${s.toFixed(0)} s`;
|
||||||
|
if (s < 7200) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${(s / 3600).toFixed(1)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPS coverage bar over the video's duration: green = real fixes, amber = a gap
|
||||||
|
* bridged by time-interpolation, red = no GPS at the start/end (positions clamp),
|
||||||
|
* blue tick = the start was stitched from the previous video. QC can see at a
|
||||||
|
* glance whether per-frame annotation geo rests on real samples or interpolation.
|
||||||
|
*/
|
||||||
|
function GpsBar({ v }: { v: MapVideo }) {
|
||||||
|
const t = v.track;
|
||||||
|
if (!t || t.length < 2) return null;
|
||||||
|
const lastT = t[t.length - 1][2];
|
||||||
|
const dur = v.duration_s && v.duration_s > lastT ? v.duration_s : lastT;
|
||||||
|
if (!(dur > 0)) return null;
|
||||||
|
// Gap threshold relative to the track's own median cadence — the map track is
|
||||||
|
// downsampled, so a fixed "5 s" would flag artifacts on long videos.
|
||||||
|
const dts: number[] = [];
|
||||||
|
for (let i = 1; i < t.length; i++) dts.push(t[i][2] - t[i - 1][2]);
|
||||||
|
const median = [...dts].sort((a, b) => a - b)[Math.floor(dts.length / 2)] || 1;
|
||||||
|
const thr = Math.max(5, 3 * median);
|
||||||
|
// Merge consecutive same-kind stretches so the bar is a handful of divs.
|
||||||
|
const segs: { x: number; w: number; kind: "ok" | "gap" }[] = [];
|
||||||
|
for (let i = 1; i < t.length; i++) {
|
||||||
|
const kind: "ok" | "gap" = t[i][2] - t[i - 1][2] > thr ? "gap" : "ok";
|
||||||
|
const last = segs[segs.length - 1];
|
||||||
|
if (last && last.kind === kind) last.w += (t[i][2] - t[i - 1][2]) / dur;
|
||||||
|
else segs.push({ x: t[i - 1][2] / dur, w: (t[i][2] - t[i - 1][2]) / dur, kind });
|
||||||
|
}
|
||||||
|
const startMiss = t[0][2] / dur;
|
||||||
|
const endMiss = (dur - lastT) / dur;
|
||||||
|
const stitched = (v.gps_corrected_note ?? "").toLowerCase().includes("stitch");
|
||||||
|
const title =
|
||||||
|
`GPS coverage across the video's ${Math.round(dur)} s — green: real fixes; ` +
|
||||||
|
`amber: dropout bridged by time-interpolation; red: no GPS at the start/end (positions clamp to the nearest fix)` +
|
||||||
|
(stitched ? "; blue tick: start borrowed from the previous video" : "");
|
||||||
|
return (
|
||||||
|
<div className="gps-bar" title={title}>
|
||||||
|
{startMiss > 0.005 && <div className="gps-bar-seg miss" style={{ left: 0, width: `${startMiss * 100}%` }} />}
|
||||||
|
{segs.map((s, i) => (
|
||||||
|
<div key={i} className={`gps-bar-seg ${s.kind}`}
|
||||||
|
style={{ left: `${s.x * 100}%`, width: `${Math.max(s.w * 100, 0.4)}%` }} />
|
||||||
|
))}
|
||||||
|
{endMiss > 0.005 && <div className="gps-bar-seg miss" style={{ left: `${(1 - endMiss) * 100}%`, width: `${endMiss * 100}%` }} />}
|
||||||
|
{stitched && <div className="gps-bar-stitch" style={{ left: `${startMiss * 100}%` }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Bearing (deg, 0 = north) from a → b, for the direction arrows. */
|
/** Bearing (deg, 0 = north) from a → b, for the direction arrows. */
|
||||||
function bearingDeg(a: [number, number, number], b: [number, number, number]): number {
|
function bearingDeg(a: [number, number, number], b: [number, number, number]): number {
|
||||||
const dLat = b[0] - a[0];
|
const dLat = b[0] - a[0];
|
||||||
@@ -174,9 +227,12 @@ export function MapView({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null);
|
const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null);
|
||||||
|
|
||||||
|
const [zoom, setZoom] = useState(5);
|
||||||
const mapDiv = useRef<HTMLDivElement>(null);
|
const mapDiv = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const trackLayer = useRef<L.LayerGroup | null>(null);
|
const trackLayer = useRef<L.LayerGroup | null>(null);
|
||||||
|
const arrowLayer = useRef<L.LayerGroup | null>(null);
|
||||||
|
const kmLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const pointLayer = useRef<L.LayerGroup | null>(null);
|
const pointLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const correctionLayer = useRef<L.LayerGroup | null>(null);
|
const correctionLayer = useRef<L.LayerGroup | null>(null);
|
||||||
const searchLayer = useRef<L.LayerGroup | null>(null);
|
const searchLayer = useRef<L.LayerGroup | null>(null);
|
||||||
@@ -229,24 +285,29 @@ export function MapView({
|
|||||||
if (!mapDiv.current || mapRef.current) return;
|
if (!mapDiv.current || mapRef.current) return;
|
||||||
const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5, maxZoom: 22 });
|
const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5, maxZoom: 22 });
|
||||||
// Two basemaps (both optional — tracks render fine when tiles can't load):
|
// Two basemaps (both optional — tracks render fine when tiles can't load):
|
||||||
// OSM streets and Esri satellite. maxNativeZoom 19 + maxZoom 22 over-zooms the
|
// OSM streets and Google satellite. Google serves imagery down to z≈21 in most
|
||||||
// deepest tiles so you can inspect a single junction ("deep zoom").
|
// regions ("deep zoom"); maxZoom 22 over-zooms the deepest native tile.
|
||||||
const osm = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
const osm = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
maxZoom: 22,
|
maxZoom: 22,
|
||||||
maxNativeZoom: 19,
|
maxNativeZoom: 19,
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
});
|
});
|
||||||
const sat = L.tileLayer(
|
const sat = L.tileLayer("https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", {
|
||||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
subdomains: ["mt0", "mt1", "mt2", "mt3"],
|
||||||
{ maxZoom: 22, maxNativeZoom: 19, attribution: "Esri, Maxar, Earthstar Geographics" },
|
maxZoom: 22,
|
||||||
);
|
maxNativeZoom: 21,
|
||||||
|
attribution: "Google",
|
||||||
|
});
|
||||||
osm.addTo(map);
|
osm.addTo(map);
|
||||||
baseLayers.current = { map: osm, sat };
|
baseLayers.current = { map: osm, sat };
|
||||||
trackLayer.current = L.layerGroup().addTo(map);
|
trackLayer.current = L.layerGroup().addTo(map);
|
||||||
|
arrowLayer.current = L.layerGroup().addTo(map);
|
||||||
|
kmLayer.current = L.layerGroup().addTo(map);
|
||||||
coverageLayer.current = L.layerGroup().addTo(map);
|
coverageLayer.current = L.layerGroup().addTo(map);
|
||||||
pointLayer.current = L.layerGroup().addTo(map);
|
pointLayer.current = L.layerGroup().addTo(map);
|
||||||
correctionLayer.current = L.layerGroup().addTo(map);
|
correctionLayer.current = L.layerGroup().addTo(map);
|
||||||
searchLayer.current = L.layerGroup().addTo(map);
|
searchLayer.current = L.layerGroup().addTo(map);
|
||||||
|
map.on("zoomend", () => setZoom(map.getZoom()));
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
setTimeout(() => map.invalidateSize(), 0);
|
setTimeout(() => map.invalidateSize(), 0);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -481,30 +542,19 @@ export function MapView({
|
|||||||
if (sel) line.bringToFront();
|
if (sel) line.bringToFront();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selected video: travel-direction arrows + green start / red end dots.
|
// Selected video: green start / red end dots, labeled with the CHAINAGE at
|
||||||
|
// each end (coordinates live in the details card, not on the map).
|
||||||
if (sel) {
|
if (sel) {
|
||||||
const t = v.track!;
|
const t = v.track!;
|
||||||
|
const chTip = (m: number | null, fallback: [number, number]) =>
|
||||||
|
m != null ? `${(m / 1000).toFixed(4)}` : `${fallback[0].toFixed(6)}, ${fallback[1].toFixed(6)}`;
|
||||||
L.circleMarker([t[0][0], t[0][1]], { radius: 6, color: "#0a1020", weight: 2, fillColor: "#5ccf75", fillOpacity: 1 })
|
L.circleMarker([t[0][0], t[0][1]], { radius: 6, color: "#0a1020", weight: 2, fillColor: "#5ccf75", fillOpacity: 1 })
|
||||||
.bindTooltip(`start · ${t[0][0].toFixed(6)}, ${t[0][1].toFixed(6)}`)
|
.bindTooltip(`start · ${chTip(v.chainage_start_m, [t[0][0], t[0][1]])}`)
|
||||||
.addTo(layer);
|
.addTo(layer);
|
||||||
const last = t[t.length - 1];
|
const last = t[t.length - 1];
|
||||||
L.circleMarker([last[0], last[1]], { radius: 6, color: "#0a1020", weight: 2, fillColor: "#ff5252", fillOpacity: 1 })
|
L.circleMarker([last[0], last[1]], { radius: 6, color: "#0a1020", weight: 2, fillColor: "#ff5252", fillOpacity: 1 })
|
||||||
.bindTooltip(`end · ${last[0].toFixed(6)}, ${last[1].toFixed(6)}`)
|
.bindTooltip(`end · ${chTip(v.chainage_end_m, [last[0], last[1]])}`)
|
||||||
.addTo(layer);
|
.addTo(layer);
|
||||||
const n = Math.min(7, Math.floor(t.length / 2));
|
|
||||||
for (let k = 1; k <= n; k++) {
|
|
||||||
const i = Math.min(Math.floor((t.length - 2) * (k / (n + 1))), t.length - 2);
|
|
||||||
const rot = bearingDeg(t[i], t[i + 1]);
|
|
||||||
L.marker([t[i][0], t[i][1]], {
|
|
||||||
interactive: false,
|
|
||||||
icon: L.divIcon({
|
|
||||||
className: "dir-arrow-wrap",
|
|
||||||
html: `<div class="dir-arrow" style="transform:rotate(${rot}deg)">▲</div>`,
|
|
||||||
iconSize: [16, 16],
|
|
||||||
iconAnchor: [8, 8],
|
|
||||||
}),
|
|
||||||
}).addTo(layer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!fitted.current && visible.length > 0) {
|
if (!fitted.current && visible.length > 0) {
|
||||||
@@ -515,6 +565,85 @@ export function MapView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [visible, colorBy, selectedId, correction, select]);
|
}, [visible, colorBy, selectedId, correction, select]);
|
||||||
|
|
||||||
|
// ---- travel-direction arrows (own layer; density from ON-SCREEN track length) ----
|
||||||
|
// An arrow every ~140 screen-px (cap 3, selected 8). Zoomed out a whole video is a
|
||||||
|
// few pixels wide and gets none — the track colors stay readable; zoom in and the
|
||||||
|
// arrows appear.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = arrowLayer.current;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!layer || !map) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
for (const v of visible) {
|
||||||
|
if (correction && correction.videoId === v.id) continue;
|
||||||
|
const t = v.track!;
|
||||||
|
if (t.length < 2) continue;
|
||||||
|
const stride = Math.max(1, Math.floor(t.length / 16));
|
||||||
|
let px = 0;
|
||||||
|
let prev = map.project([t[0][0], t[0][1]]);
|
||||||
|
for (let i = stride; i < t.length; i += stride) {
|
||||||
|
const q = map.project([t[i][0], t[i][1]]);
|
||||||
|
px += prev.distanceTo(q);
|
||||||
|
prev = q;
|
||||||
|
}
|
||||||
|
const sel = v.id === selectedId;
|
||||||
|
const n = Math.min(sel ? 8 : 3, Math.floor(px / 140), Math.floor(t.length / 2));
|
||||||
|
for (let k = 1; k <= n; k++) {
|
||||||
|
const i = Math.min(Math.floor((t.length - 2) * (k / (n + 1))), t.length - 2);
|
||||||
|
const rot = bearingDeg(t[i], t[i + 1]);
|
||||||
|
L.marker([t[i][0], t[i][1]], {
|
||||||
|
interactive: false,
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: "dir-arrow-wrap",
|
||||||
|
html: `<div class="dir-arrow${sel ? "" : " sm"}" style="transform:rotate(${rot}deg)">▲</div>`,
|
||||||
|
iconSize: sel ? [16, 16] : [12, 12],
|
||||||
|
iconAnchor: sel ? [8, 8] : [6, 6],
|
||||||
|
}),
|
||||||
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible, zoom, selectedId, correction]);
|
||||||
|
|
||||||
|
// ---- km milestone markers along calibrated routes (zoom-adaptive density) ----
|
||||||
|
// Whole-km chainage labels ("1"…"150") pinned to the track; the step widens as you
|
||||||
|
// zoom out so the view never crowds. Deduped per (road, km) — LHS and RHS each get
|
||||||
|
// their own stones.
|
||||||
|
useEffect(() => {
|
||||||
|
const layer = kmLayer.current;
|
||||||
|
if (!layer) return;
|
||||||
|
layer.clearLayers();
|
||||||
|
const step = zoom >= 14 ? 1 : zoom >= 13 ? 2 : zoom >= 12 ? 5 : zoom >= 10 ? 10 : zoom >= 8 ? 20 : 50;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let drawn = 0;
|
||||||
|
for (const v of visible) {
|
||||||
|
if (v.chainage_start_m == null || v.chainage_end_m == null) continue;
|
||||||
|
const cum = cumFor(v);
|
||||||
|
if (!cum) continue;
|
||||||
|
const loKm = Math.min(v.chainage_start_m, v.chainage_end_m) / 1000;
|
||||||
|
const hiKm = Math.max(v.chainage_start_m, v.chainage_end_m) / 1000;
|
||||||
|
for (let k = Math.ceil(loKm / step) * step; k <= hiKm && drawn < 400; k += step) {
|
||||||
|
const key = `${v.road_type}|${k}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
const pt = pointAtChainage(v, cum, k * 1000);
|
||||||
|
if (!pt) continue;
|
||||||
|
seen.add(key);
|
||||||
|
drawn += 1;
|
||||||
|
L.marker(pt, {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: "km-mark-wrap",
|
||||||
|
html: `<div class="km-mark">${k}</div>`,
|
||||||
|
iconSize: [26, 15],
|
||||||
|
iconAnchor: [13, 7],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.bindTooltip(`chainage ${k.toFixed(4)}${v.road_type ? ` · ${v.road_type}` : ""}`)
|
||||||
|
.addTo(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [visible, zoom]);
|
||||||
|
|
||||||
// ---- draw coverage findings (gaps amber, overlaps purple halo) ----
|
// ---- draw coverage findings (gaps amber, overlaps purple halo) ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const layer = coverageLayer.current;
|
const layer = coverageLayer.current;
|
||||||
@@ -683,7 +812,7 @@ export function MapView({
|
|||||||
<div className="map-colorby">
|
<div className="map-colorby">
|
||||||
<button className={basemap === "map" ? "active" : ""} onClick={() => setBasemap("map")}>Map</button>
|
<button className={basemap === "map" ? "active" : ""} onClick={() => setBasemap("map")}>Map</button>
|
||||||
<button className={basemap === "sat" ? "active" : ""} onClick={() => setBasemap("sat")}
|
<button className={basemap === "sat" ? "active" : ""} onClick={() => setBasemap("sat")}
|
||||||
title="Esri satellite imagery — zoom all the way in to inspect a junction">Satellite</button>
|
title="Google satellite imagery — deep zoom, inspect a single junction">Satellite</button>
|
||||||
</div>
|
</div>
|
||||||
{roadTypesPresent.length > 1 && (
|
{roadTypesPresent.length > 1 && (
|
||||||
<div className="map-chips">
|
<div className="map-chips">
|
||||||
@@ -753,6 +882,21 @@ export function MapView({
|
|||||||
</span>
|
</span>
|
||||||
<span className="dim">Road type</span>
|
<span className="dim">Road type</span>
|
||||||
<span>{selected.road_type || "—"}</span>
|
<span>{selected.road_type || "—"}</span>
|
||||||
|
<span className="dim">Chainage</span>
|
||||||
|
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
|
||||||
|
<span className="dim">SR chainage</span>
|
||||||
|
<span title="GPS-measured (immutable)">
|
||||||
|
{fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)}
|
||||||
|
</span>
|
||||||
|
{selected.speed_avg_mps != null && (
|
||||||
|
<>
|
||||||
|
<span className="dim">Speed</span>
|
||||||
|
<span>
|
||||||
|
avg {(selected.speed_avg_mps * 3.6).toFixed(0)} km/h
|
||||||
|
{selected.speed_max_mps != null && <> · max {(selected.speed_max_mps * 3.6).toFixed(0)} km/h</>}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{selected.track && selected.track.length >= 2 && (
|
{selected.track && selected.track.length >= 2 && (
|
||||||
<>
|
<>
|
||||||
<span className="dim">First fix</span>
|
<span className="dim">First fix</span>
|
||||||
@@ -763,21 +907,6 @@ export function MapView({
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selected.speed_avg_mps != null && (
|
|
||||||
<>
|
|
||||||
<span className="dim">Speed</span>
|
|
||||||
<span>
|
|
||||||
avg {(selected.speed_avg_mps * 3.6).toFixed(0)} km/h
|
|
||||||
{selected.speed_max_mps != null && <> · max {(selected.speed_max_mps * 3.6).toFixed(0)} km/h</>}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="dim">Chainage</span>
|
|
||||||
<span title="client-calibrated">{fmtChainageRange(selected.chainage_start_m, selected.chainage_end_m)}</span>
|
|
||||||
<span className="dim">SR chainage</span>
|
|
||||||
<span title="GPS-measured (immutable)">
|
|
||||||
{fmtChainageRange(selected.sr_chainage_start_m, selected.sr_chainage_end_m)}
|
|
||||||
</span>
|
|
||||||
<span className="dim">GPS</span>
|
<span className="dim">GPS</span>
|
||||||
<span>
|
<span>
|
||||||
{selected.gps_status === "ok" ? (
|
{selected.gps_status === "ok" ? (
|
||||||
@@ -786,7 +915,7 @@ export function MapView({
|
|||||||
{selected.gps_sample_count != null && <span className="dim"> · {selected.gps_sample_count} fixes</span>}
|
{selected.gps_sample_count != null && <span className="dim"> · {selected.gps_sample_count} fixes</span>}
|
||||||
{(selected.gps_max_gap_s ?? 0) > 5 && (
|
{(selected.gps_max_gap_s ?? 0) > 5 && (
|
||||||
<span className="err-tag" title="Largest dropout between GPS fixes — positions in the gap are time-interpolated">
|
<span className="err-tag" title="Largest dropout between GPS fixes — positions in the gap are time-interpolated">
|
||||||
{" "}· gap {selected.gps_max_gap_s!.toFixed(0)}s ⚠
|
{" "}· gap {fmtGap(selected.gps_max_gap_s!)} ⚠
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -801,6 +930,12 @@ export function MapView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{selected.gps_status === "ok" && selected.track && selected.track.length >= 2 && (
|
||||||
|
<>
|
||||||
|
<span className="dim">Coverage</span>
|
||||||
|
<span><GpsBar v={selected} /></span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="dim">Annotations</span>
|
<span className="dim">Annotations</span>
|
||||||
<span>{selected.annotation_count}</span>
|
<span>{selected.annotation_count}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -977,7 +1112,7 @@ export function MapView({
|
|||||||
{v.file_name}
|
{v.file_name}
|
||||||
</button>
|
</button>
|
||||||
<span className="idle-tag" title="longest stretch without a GPS fix — positions there are interpolated">
|
<span className="idle-tag" title="longest stretch without a GPS fix — positions there are interpolated">
|
||||||
dropout {(v.gps_max_gap_s ?? 0).toFixed(0)} s
|
dropout {fmtGap(v.gps_max_gap_s ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ export interface ChainagePoint {
|
|||||||
chainage_m: number;
|
chainage_m: number;
|
||||||
note: string;
|
note: string;
|
||||||
road_type: string;
|
road_type: string;
|
||||||
|
/** '' = manual; 'quick' = generated by quick assign (replaced on the next run). */
|
||||||
|
origin: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -163,6 +165,8 @@ export interface VideoChainageRow {
|
|||||||
file_name: string;
|
file_name: string;
|
||||||
seq: number;
|
seq: number;
|
||||||
flipped: boolean;
|
flipped: boolean;
|
||||||
|
/** Runs OPPOSITE to the majority of the route — the only direction flag shown. */
|
||||||
|
against_flow: boolean;
|
||||||
sr_start_m: number;
|
sr_start_m: number;
|
||||||
sr_end_m: number;
|
sr_end_m: number;
|
||||||
chainage_start_m: number;
|
chainage_start_m: number;
|
||||||
@@ -172,6 +176,8 @@ export interface VideoChainageRow {
|
|||||||
export interface CalibrationSummary {
|
export interface CalibrationSummary {
|
||||||
route_len_m: number;
|
route_len_m: number;
|
||||||
reversed: boolean;
|
reversed: boolean;
|
||||||
|
/** Most footage runs toward decreasing chainage — the route's normal pattern. */
|
||||||
|
majority_flipped: boolean;
|
||||||
points_used: number;
|
points_used: number;
|
||||||
points_excluded: number;
|
points_excluded: number;
|
||||||
spans: SpanRow[];
|
spans: SpanRow[];
|
||||||
@@ -180,6 +186,16 @@ export interface CalibrationSummary {
|
|||||||
skipped: string[];
|
skipped: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A saved calibration (persisted per road-type group by every recompute) — any
|
||||||
|
* member who opens the project sees what's already computed, by whom and when. */
|
||||||
|
export interface CalibrationRow {
|
||||||
|
road_type: string;
|
||||||
|
route_len_m: number;
|
||||||
|
anchor_count: number;
|
||||||
|
computed_by: string;
|
||||||
|
computed_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Result of the GPS correction preview/keep — original + corrected tracks + stats.
|
/** Result of the GPS correction preview/keep — original + corrected tracks + stats.
|
||||||
* The pipeline = spike removal + (when a temporal predecessor exists) a
|
* The pipeline = spike removal + (when a temporal predecessor exists) a
|
||||||
* missing-start stitch that re-bases the clock to the true video start. */
|
* missing-start stitch that re-bases the clock to the true video start. */
|
||||||
@@ -428,6 +444,12 @@ export const api = {
|
|||||||
points: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }[],
|
points: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }[],
|
||||||
) => post<{ added: number }>(`/projects/${projectId}/chainage/points/bulk`, { points }),
|
) => post<{ added: number }>(`/projects/${projectId}/chainage/points/bulk`, { points }),
|
||||||
deleteChainagePoint: (pointId: number) => del<{ deleted: boolean }>(`/chainage/points/${pointId}`),
|
deleteChainagePoint: (pointId: number) => del<{ deleted: boolean }>(`/chainage/points/${pointId}`),
|
||||||
|
// Edit a point's note in place (admin); position/chainage stay immutable.
|
||||||
|
updateChainagePointNote: (pointId: number, note: string) =>
|
||||||
|
post<{ updated: boolean }>(`/chainage/points/${pointId}`, { note }),
|
||||||
|
// Saved calibrations per group (member or admin) — proof the chainage is computed.
|
||||||
|
chainageCalibrations: (projectId: number) =>
|
||||||
|
get<CalibrationRow[]>(`/projects/${projectId}/chainage/calibrations`),
|
||||||
chainageRecompute: (projectId: number, video_ids: number[], road_type: string) =>
|
chainageRecompute: (projectId: number, video_ids: number[], road_type: string) =>
|
||||||
post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }),
|
post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }),
|
||||||
chainageQuick: (
|
chainageQuick: (
|
||||||
|
|||||||
@@ -298,12 +298,27 @@ tr.rank-top { background: rgba(245,200,56,0.08); }
|
|||||||
font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||||
.map-nometa-name:hover { color: var(--info); }
|
.map-nometa-name:hover { color: var(--info); }
|
||||||
.btn.mini { padding: 1px 7px; font-size: 11px; }
|
.btn.mini { padding: 1px 7px; font-size: 11px; }
|
||||||
/* Travel-direction arrows along the selected track */
|
/* Travel-direction arrows along every track (bigger on the selected one) */
|
||||||
.dir-arrow-wrap { background: none; border: none; }
|
.dir-arrow-wrap { background: none; border: none; }
|
||||||
.dir-arrow { color: #ffffff; font-size: 13px; line-height: 16px; text-align: center;
|
.dir-arrow { color: #ffffff; font-size: 13px; line-height: 16px; text-align: center;
|
||||||
text-shadow: 0 0 3px #000, 0 0 5px #000; pointer-events: none; }
|
text-shadow: 0 0 3px #000, 0 0 5px #000; pointer-events: none; }
|
||||||
|
.dir-arrow.sm { font-size: 10px; line-height: 12px; opacity: 0.85; }
|
||||||
|
/* Whole-km chainage milestones pinned on calibrated tracks */
|
||||||
|
.km-mark-wrap { background: none; border: none; }
|
||||||
|
.km-mark { min-width: 22px; padding: 0 4px; text-align: center; font-size: 10px; font-weight: 700;
|
||||||
|
color: #0a1020; background: #f5c838; border: 1px solid #0a1020; border-radius: 8px;
|
||||||
|
line-height: 13px; box-shadow: 0 0 3px rgba(0,0,0,.6); }
|
||||||
/* Idle / dropout tags in the data-quality panel */
|
/* Idle / dropout tags in the data-quality panel */
|
||||||
.idle-tag { color: var(--warn); font-size: 11px; white-space: nowrap; }
|
.idle-tag { color: var(--warn); font-size: 11px; white-space: nowrap; }
|
||||||
|
/* Per-video GPS coverage bar (details card): green real fixes, amber interpolated
|
||||||
|
gaps, red missing start/end, blue tick = stitched start */
|
||||||
|
.gps-bar { position: relative; height: 8px; min-width: 120px; background: #2a3350;
|
||||||
|
border-radius: 4px; overflow: hidden; margin-top: 4px; }
|
||||||
|
.gps-bar-seg { position: absolute; top: 0; bottom: 0; }
|
||||||
|
.gps-bar-seg.ok { background: #5ccf75; }
|
||||||
|
.gps-bar-seg.gap { background: #f5a623; }
|
||||||
|
.gps-bar-seg.miss { background: #ff5252; }
|
||||||
|
.gps-bar-stitch { position: absolute; top: 0; bottom: 0; width: 3px; background: #6ea8ff; }
|
||||||
|
|
||||||
/* Leaflet dark-theme nudges */
|
/* Leaflet dark-theme nudges */
|
||||||
.leaflet-container { font: inherit; }
|
.leaflet-container { font: inherit; }
|
||||||
|
|||||||
@@ -291,6 +291,10 @@ CREATE TABLE IF NOT EXISTS chainage_points (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(project_id);
|
CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(project_id);
|
||||||
|
-- origin marks generated points ('quick' = the two-point quick assign) so they can
|
||||||
|
-- be replaced on the next quick run regardless of the (freely editable) note text.
|
||||||
|
ALTER TABLE chainage_points ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT '';
|
||||||
|
UPDATE chainage_points SET origin='quick' WHERE origin='' AND note LIKE 'quick:%';
|
||||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_start_m DOUBLE PRECISION;
|
ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_start_m DOUBLE PRECISION;
|
||||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_end_m DOUBLE PRECISION;
|
ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_end_m DOUBLE PRECISION;
|
||||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION;
|
ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION;
|
||||||
|
|||||||
@@ -223,6 +223,26 @@ fn route_quality_warnings(route: &Route) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Min perpendicular distance (m) from a point to a polyline track — used by the
|
||||||
|
/// contamination detector to test a calibration point against OTHER groups' videos.
|
||||||
|
fn min_dist_to_track(track: &[Pt], lat: f64, lon: f64) -> f64 {
|
||||||
|
let (m_lat, m_lon) = wgs84_m_per_deg(lat);
|
||||||
|
let (px, py) = (lon * m_lon, lat * m_lat);
|
||||||
|
let mut best = f64::INFINITY;
|
||||||
|
for w in track.windows(2) {
|
||||||
|
let (ax, ay) = (w[0][1] * m_lon, w[0][0] * m_lat);
|
||||||
|
let (bx, by) = (w[1][1] * m_lon, w[1][0] * m_lat);
|
||||||
|
let (dx, dy) = (bx - ax, by - ay);
|
||||||
|
let l2 = dx * dx + dy * dy;
|
||||||
|
let t = if l2 > 0.0 { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||||
|
best = best.min(((px - (ax + t * dx)).powi(2) + (py - (ay + t * dy)).powi(2)).sqrt());
|
||||||
|
}
|
||||||
|
if track.len() == 1 {
|
||||||
|
best = best.min(dist_m(lat, lon, track[0][0], track[0][1]));
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
/// Project (lat, lon) onto the nearest route segment → (s along the route,
|
/// Project (lat, lon) onto the nearest route segment → (s along the route,
|
||||||
/// perpendicular distance in meters). Local flat projection using the WGS84
|
/// perpendicular distance in meters). Local flat projection using the WGS84
|
||||||
/// per-degree factors at the point's latitude — same meter as `dist_m`.
|
/// per-degree factors at the point's latitude — same meter as `dist_m`.
|
||||||
@@ -443,6 +463,9 @@ pub struct ChainagePoint {
|
|||||||
pub chainage_m: f64,
|
pub chainage_m: f64,
|
||||||
pub note: String,
|
pub note: String,
|
||||||
pub road_type: String,
|
pub road_type: String,
|
||||||
|
/// '' = manually entered; 'quick' = generated by the two-point quick assign
|
||||||
|
/// (replaced wholesale on the next quick run — identity independent of the note).
|
||||||
|
pub origin: String,
|
||||||
pub created_by: String,
|
pub created_by: String,
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
@@ -493,6 +516,10 @@ pub struct VideoChainageRow {
|
|||||||
pub file_name: String,
|
pub file_name: String,
|
||||||
pub seq: i32,
|
pub seq: i32,
|
||||||
pub flipped: bool,
|
pub flipped: bool,
|
||||||
|
/// Runs opposite to the MAJORITY of the route's videos — the only direction flag
|
||||||
|
/// worth surfacing (when the whole road was filmed toward decreasing chainage,
|
||||||
|
/// "flipped" is the norm, not an anomaly).
|
||||||
|
pub against_flow: bool,
|
||||||
pub sr_start_m: f64,
|
pub sr_start_m: f64,
|
||||||
pub sr_end_m: f64,
|
pub sr_end_m: f64,
|
||||||
pub chainage_start_m: f64,
|
pub chainage_start_m: f64,
|
||||||
@@ -503,6 +530,9 @@ pub struct VideoChainageRow {
|
|||||||
pub struct CalibrationSummary {
|
pub struct CalibrationSummary {
|
||||||
pub route_len_m: f64,
|
pub route_len_m: f64,
|
||||||
pub reversed: bool,
|
pub reversed: bool,
|
||||||
|
/// Most of the footage runs toward DECREASING chainage — that's this route's
|
||||||
|
/// normal pattern, so per-video direction flags are relative to it.
|
||||||
|
pub majority_flipped: bool,
|
||||||
pub points_used: usize,
|
pub points_used: usize,
|
||||||
pub points_excluded: usize,
|
pub points_excluded: usize,
|
||||||
pub spans: Vec<SpanRow>,
|
pub spans: Vec<SpanRow>,
|
||||||
@@ -523,7 +553,7 @@ pub async fn list_points(
|
|||||||
) -> ApiResult<Vec<ChainagePoint>> {
|
) -> ApiResult<Vec<ChainagePoint>> {
|
||||||
crate::require_project_access(&s, &user, id).await?;
|
crate::require_project_access(&s, &user, id).await?;
|
||||||
let rows = sqlx::query_as::<_, ChainagePoint>(
|
let rows = sqlx::query_as::<_, ChainagePoint>(
|
||||||
r#"SELECT id, lat, lon, chainage_m, note, road_type, created_by, created_at
|
r#"SELECT id, lat, lon, chainage_m, note, road_type, origin, created_by, created_at
|
||||||
FROM chainage_points WHERE project_id=$1 ORDER BY road_type, chainage_m"#,
|
FROM chainage_points WHERE project_id=$1 ORDER BY road_type, chainage_m"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -547,7 +577,7 @@ pub async fn add_point(
|
|||||||
let row = sqlx::query_as::<_, ChainagePoint>(
|
let row = sqlx::query_as::<_, ChainagePoint>(
|
||||||
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by)
|
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||||
RETURNING id, lat, lon, chainage_m, note, road_type, created_by, created_at"#,
|
RETURNING id, lat, lon, chainage_m, note, road_type, origin, created_by, created_at"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(req.lat)
|
.bind(req.lat)
|
||||||
@@ -612,6 +642,64 @@ pub async fn add_points_bulk(
|
|||||||
Ok(Json(json!({ "added": req.points.len() })))
|
Ok(Json(json!({ "added": req.points.len() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PointNoteRequest {
|
||||||
|
pub note: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/chainage/points/:id` — edit a calibration point's note (admin).
|
||||||
|
/// Lat/lon/chainage stay immutable — delete + re-add to change the measurement.
|
||||||
|
pub async fn update_point_note(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
AxPath(id): AxPath<i32>,
|
||||||
|
Json(req): Json<PointNoteRequest>,
|
||||||
|
) -> ApiResult<Value> {
|
||||||
|
user.require_admin()?;
|
||||||
|
let updated: Option<i32> =
|
||||||
|
sqlx::query_scalar("UPDATE chainage_points SET note=$2 WHERE id=$1 RETURNING id")
|
||||||
|
.bind(id)
|
||||||
|
.bind(req.note.trim())
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(err)?;
|
||||||
|
if updated.is_none() {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "point not found".into()));
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "updated": true })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, sqlx::FromRow)]
|
||||||
|
pub struct CalibrationRow {
|
||||||
|
pub road_type: String,
|
||||||
|
pub route_len_m: f64,
|
||||||
|
pub anchor_count: i32,
|
||||||
|
pub computed_by: String,
|
||||||
|
pub computed_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/projects/:id/chainage/calibrations` — the saved calibrations (one per
|
||||||
|
/// group), so anyone who opens the project sees what's already computed and by whom
|
||||||
|
/// (member or admin). The per-video chainage itself lives on `videos` and is in every
|
||||||
|
/// list/map response regardless.
|
||||||
|
pub async fn list_calibrations(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
user: AuthUser,
|
||||||
|
AxPath(id): AxPath<i32>,
|
||||||
|
) -> ApiResult<Vec<CalibrationRow>> {
|
||||||
|
crate::require_project_access(&s, &user, id).await?;
|
||||||
|
let rows = sqlx::query_as::<_, CalibrationRow>(
|
||||||
|
r#"SELECT road_type, route_len_m, jsonb_array_length(anchors)::int AS anchor_count,
|
||||||
|
computed_by, computed_at
|
||||||
|
FROM chainage_calibrations WHERE project_id=$1 ORDER BY road_type"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(err)?;
|
||||||
|
Ok(Json(rows))
|
||||||
|
}
|
||||||
|
|
||||||
/// `DELETE /api/chainage/points/:id` — remove a calibration point (admin).
|
/// `DELETE /api/chainage/points/:id` — remove a calibration point (admin).
|
||||||
pub async fn delete_point(
|
pub async fn delete_point(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
@@ -665,18 +753,22 @@ pub async fn quick_assign(
|
|||||||
let last = route.pts.last().copied().unwrap();
|
let last = route.pts.last().copied().unwrap();
|
||||||
|
|
||||||
// Replace any previous quick points for this group, then add the fresh pair.
|
// Replace any previous quick points for this group, then add the fresh pair.
|
||||||
|
// Identity = origin='quick' (a real column), NOT the note text — notes are freely
|
||||||
|
// editable, so matching on them would leave stale generated anchors behind.
|
||||||
|
// "chain start/end" = the geometric ends of the ordered route — WHICH physical
|
||||||
|
// end is which is the admin's call (the swap button in the UI flips the values).
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND note IN ('quick: route start','quick: route end')",
|
"DELETE FROM chainage_points WHERE project_id=$1 AND road_type=$2 AND origin='quick'",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(&road_type)
|
.bind(&road_type)
|
||||||
.execute(&s.db)
|
.execute(&s.db)
|
||||||
.await
|
.await
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
for (pt, chainage, note) in [(first, req.start_m, "quick: route start"), (last, req.end_m, "quick: route end")] {
|
for (pt, chainage, note) in [(first, req.start_m, "quick: chain start"), (last, req.end_m, "quick: chain end")] {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by)
|
r#"INSERT INTO chainage_points (project_id, lat, lon, chainage_m, note, road_type, created_by, origin)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7)"#,
|
VALUES ($1,$2,$3,$4,$5,$6,$7,'quick')"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(pt.0)
|
.bind(pt.0)
|
||||||
@@ -746,8 +838,8 @@ async fn run_recompute(
|
|||||||
warnings.extend(route_quality_warnings(&route));
|
warnings.extend(route_quality_warnings(&route));
|
||||||
|
|
||||||
// Control points for this calibration group (tagged with it, or untagged).
|
// Control points for this calibration group (tagged with it, or untagged).
|
||||||
let points: Vec<(i32, f64, f64, f64)> = sqlx::query_as(
|
let points: Vec<(i32, f64, f64, f64, String)> = sqlx::query_as(
|
||||||
r#"SELECT id, lat, lon, chainage_m FROM chainage_points
|
r#"SELECT id, lat, lon, chainage_m, road_type FROM chainage_points
|
||||||
WHERE project_id=$1 AND (road_type=$2 OR road_type='')
|
WHERE project_id=$1 AND (road_type=$2 OR road_type='')
|
||||||
ORDER BY chainage_m"#,
|
ORDER BY chainage_m"#,
|
||||||
)
|
)
|
||||||
@@ -766,7 +858,8 @@ async fn run_recompute(
|
|||||||
// Snap each point to the route; drop the ones that don't belong to it.
|
// Snap each point to the route; drop the ones that don't belong to it.
|
||||||
let mut anchors: Vec<(f64, f64)> = Vec::new(); // (s, chainage)
|
let mut anchors: Vec<(f64, f64)> = Vec::new(); // (s, chainage)
|
||||||
let mut excluded = 0usize;
|
let mut excluded = 0usize;
|
||||||
for (pid, lat, lon, chainage_m) in &points {
|
let mut used_untagged: Vec<(i32, f64, f64, f64)> = Vec::new();
|
||||||
|
for (pid, lat, lon, chainage_m, ptag) in &points {
|
||||||
let (sv, d) = snap(&route, *lat, *lon);
|
let (sv, d) = snap(&route, *lat, *lon);
|
||||||
if d > SNAP_MAX_M {
|
if d > SNAP_MAX_M {
|
||||||
excluded += 1;
|
excluded += 1;
|
||||||
@@ -776,6 +869,44 @@ async fn run_recompute(
|
|||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
anchors.push((sv, *chainage_m));
|
anchors.push((sv, *chainage_m));
|
||||||
|
if ptag.is_empty() {
|
||||||
|
used_untagged.push((*pid, *lat, *lon, *chainage_m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contamination detector: an UNTAGGED point is shared by every group's
|
||||||
|
// calibration — if it also sits within snap distance of a DIFFERENT road-type
|
||||||
|
// group's videos (parallel carriageway, ramp, service road), it's ambiguous
|
||||||
|
// which road it belongs to and can silently distort both calibrations.
|
||||||
|
if !used_untagged.is_empty() {
|
||||||
|
let others: Vec<(String, Value)> = sqlx::query_as(
|
||||||
|
r#"SELECT road_type, COALESCE(gps_track_corrected, gps_track)
|
||||||
|
FROM videos
|
||||||
|
WHERE project_id=$1 AND road_type <> '' AND road_type <> $2
|
||||||
|
AND COALESCE(gps_track_corrected, gps_track) IS NOT NULL"#,
|
||||||
|
)
|
||||||
|
.bind(project_id)
|
||||||
|
.bind(road_type)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.map_err(err)?;
|
||||||
|
let others: Vec<(String, Vec<Pt>)> =
|
||||||
|
others.into_iter().map(|(rt, tr)| (rt, parse_track(&tr))).collect();
|
||||||
|
for (pid, lat, lon, chainage_m) in &used_untagged {
|
||||||
|
let mut near: Vec<&str> = others
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, tr)| tr.len() >= 2 && min_dist_to_track(tr, *lat, *lon) <= SNAP_MAX_M)
|
||||||
|
.map(|(rt, _)| rt.as_str())
|
||||||
|
.collect();
|
||||||
|
near.sort_unstable();
|
||||||
|
near.dedup();
|
||||||
|
if !near.is_empty() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"point #{pid} ({:.4} km) is untagged and ambiguous — it also lies within {SNAP_MAX_M:.0} m of group(s) {}; tag it to the road it belongs to",
|
||||||
|
chainage_m / 1000.0, near.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if anchors.len() < 2 {
|
if anchors.len() < 2 {
|
||||||
@@ -837,6 +968,17 @@ async fn run_recompute(
|
|||||||
|
|
||||||
let calib = Calib { anchors };
|
let calib = Calib { anchors };
|
||||||
|
|
||||||
|
// Direction pattern: when most videos were filmed toward decreasing chainage,
|
||||||
|
// "flipped" is the route's norm — only the exceptions are worth flagging.
|
||||||
|
let majority_flipped = route.videos.iter().filter(|v| v.flipped).count() * 2 > route.videos.len();
|
||||||
|
let against = route.videos.iter().filter(|v| v.flipped != majority_flipped).count();
|
||||||
|
if route.videos.len() >= 4 && against * 4 >= route.videos.len() {
|
||||||
|
warnings.push(format!(
|
||||||
|
"mixed directions: {against} of {} videos run against the dominant direction — both carriageways may be mixed in one group (split LHS/RHS by road type)",
|
||||||
|
route.videos.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Persist: calibrated videos get chainage + sr + seq; GPS-less selections are
|
// Persist: calibrated videos get chainage + sr + seq; GPS-less selections are
|
||||||
// cleared (they're not on the route).
|
// cleared (they're not on the route).
|
||||||
let mut out_videos = Vec::new();
|
let mut out_videos = Vec::new();
|
||||||
@@ -862,6 +1004,7 @@ async fn run_recompute(
|
|||||||
file_name: v.file_name.clone(),
|
file_name: v.file_name.clone(),
|
||||||
seq: v.seq,
|
seq: v.seq,
|
||||||
flipped: v.flipped,
|
flipped: v.flipped,
|
||||||
|
against_flow: v.flipped != majority_flipped,
|
||||||
sr_start_m: v.sr_start_m,
|
sr_start_m: v.sr_start_m,
|
||||||
sr_end_m: v.sr_end_m,
|
sr_end_m: v.sr_end_m,
|
||||||
chainage_start_m: ch_start,
|
chainage_start_m: ch_start,
|
||||||
@@ -905,6 +1048,7 @@ async fn run_recompute(
|
|||||||
Ok(CalibrationSummary {
|
Ok(CalibrationSummary {
|
||||||
route_len_m: route.len_m(),
|
route_len_m: route.len_m(),
|
||||||
reversed,
|
reversed,
|
||||||
|
majority_flipped,
|
||||||
points_used: calib.anchors.len(),
|
points_used: calib.anchors.len(),
|
||||||
points_excluded: excluded,
|
points_excluded: excluded,
|
||||||
spans,
|
spans,
|
||||||
@@ -980,6 +1124,21 @@ mod tests {
|
|||||||
assert!((beyond - (2200.0 + 100.0 * last_scale)).abs() < 1.0);
|
assert!((beyond - (2200.0 + 100.0 * last_scale)).abs() < 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_dist_flags_nearby_foreign_tracks() {
|
||||||
|
// A parallel carriageway 0.0002° (~22 m) north: a point on our route is
|
||||||
|
// "near" it (ambiguous); a point 0.01° (~1.1 km) away is not.
|
||||||
|
let other = seg(9, 0.0, 0.01, 50);
|
||||||
|
let mut par = other.track.clone();
|
||||||
|
for p in par.iter_mut() {
|
||||||
|
p[0] = 0.0002;
|
||||||
|
}
|
||||||
|
let d_near = min_dist_to_track(&par, 0.0, 0.005);
|
||||||
|
assert!((d_near - 22.0).abs() < 3.0, "d_near {d_near}");
|
||||||
|
let d_far = min_dist_to_track(&par, 0.01, 0.005);
|
||||||
|
assert!(d_far > 1000.0, "d_far {d_far}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snap_measures_perpendicular_distance() {
|
fn snap_measures_perpendicular_distance() {
|
||||||
let tracks = vec![seg(1, 0.0, 0.01, 100)];
|
let tracks = vec![seg(1, 0.0, 0.01, 100)];
|
||||||
|
|||||||
@@ -227,22 +227,47 @@ fn collect_samples(obj: &serde_json::Map<String, Value>) -> (Vec<Pt>, Option<f64
|
|||||||
match field {
|
match field {
|
||||||
"GPSLatitude" => entry.lat = v.as_f64(),
|
"GPSLatitude" => entry.lat = v.as_f64(),
|
||||||
"GPSLongitude" => entry.lon = v.as_f64(),
|
"GPSLongitude" => entry.lon = v.as_f64(),
|
||||||
"GPSDateTime" => entry.t = v.as_str().and_then(parse_gps_datetime),
|
// Plausibility gate: cameras write garbage dates (1970/zero) on some
|
||||||
|
// fixes — treat anything outside [2000, ~2096] as unstamped.
|
||||||
|
"GPSDateTime" => {
|
||||||
|
entry.t = v
|
||||||
|
.as_str()
|
||||||
|
.and_then(parse_gps_datetime)
|
||||||
|
.filter(|&t| (946_684_800.0..4_100_000_000.0).contains(&t))
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut samples: Vec<Pt> = Vec::with_capacity(by_doc.len());
|
let raw: Vec<(u32, f64, f64, Option<f64>)> = by_doc
|
||||||
let mut t0: Option<f64> = None;
|
.into_iter()
|
||||||
let mut t0_abs: Option<f64> = None;
|
.filter_map(|(idx, p)| Some((idx, p.lat?, p.lon?, p.t)))
|
||||||
for (idx, p) in by_doc {
|
.collect();
|
||||||
let (Some(lat), Some(lon)) = (p.lat, p.lon) else { continue };
|
let t0_abs = raw.first().and_then(|r| r.3); // real epoch only when stamped
|
||||||
if t0.is_none() {
|
|
||||||
t0_abs = p.t; // real epoch only when the camera stamped it
|
// ONE timebase for the whole track. Mixing them (epoch for stamped samples,
|
||||||
}
|
// doc index for unstamped ones) once produced billion-second "dropouts" when a
|
||||||
let t_abs = p.t.unwrap_or(idx as f64);
|
// camera skipped GPSDateTime on some fixes — so unless EVERY sample is stamped,
|
||||||
let t_rel = t_abs - *t0.get_or_insert(t_abs);
|
// fall back to index-based 1 Hz for all of them.
|
||||||
samples.push([lat, lon, t_rel]);
|
let index_based = |raw: &[(u32, f64, f64, Option<f64>)]| -> Vec<Pt> {
|
||||||
|
let idx0 = raw.first().map(|r| r.0).unwrap_or(0);
|
||||||
|
raw.iter().map(|&(idx, lat, lon, _)| [lat, lon, (idx - idx0) as f64]).collect()
|
||||||
|
};
|
||||||
|
let all_stamped = !raw.is_empty() && raw.iter().all(|r| r.3.is_some());
|
||||||
|
let mut samples: Vec<Pt> = if all_stamped {
|
||||||
|
let t0 = raw[0].3.unwrap();
|
||||||
|
raw.iter().map(|&(_, lat, lon, t)| [lat, lon, t.unwrap() - t0]).collect()
|
||||||
|
} else {
|
||||||
|
index_based(&raw)
|
||||||
|
};
|
||||||
|
// Clock-glitch guard: a negative or >1 h step between consecutive fixes means
|
||||||
|
// the stamps themselves are unusable (camera clock jumped) — rebuild the whole
|
||||||
|
// track on the index timebase rather than trust any of them.
|
||||||
|
if samples
|
||||||
|
.windows(2)
|
||||||
|
.any(|w| !(0.0..=3600.0).contains(&(w[1][2] - w[0][2])))
|
||||||
|
{
|
||||||
|
samples = index_based(&raw);
|
||||||
}
|
}
|
||||||
(samples, t0_abs)
|
(samples, t0_abs)
|
||||||
}
|
}
|
||||||
@@ -846,6 +871,61 @@ mod tests {
|
|||||||
assert_eq!(fixed, t);
|
assert_eq!(fixed, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_gps_datetime_never_mixes_timebases() {
|
||||||
|
// Doc1 has NO GPSDateTime, Doc2/Doc3 do (real epoch ~1.69e9). The old code
|
||||||
|
// seeded t0 from the index and then subtracted it from the epoch — a
|
||||||
|
// 1,693,958,401-second "dropout". Every sample must share one timebase.
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
let ins = |o: &mut serde_json::Map<String, Value>, d: u32, lat: f64, t: Option<&str>| {
|
||||||
|
o.insert(format!("Doc{d}:GPSLatitude"), serde_json::json!(lat));
|
||||||
|
o.insert(format!("Doc{d}:GPSLongitude"), serde_json::json!(78.0));
|
||||||
|
if let Some(t) = t {
|
||||||
|
o.insert(format!("Doc{d}:GPSDateTime"), serde_json::json!(t));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ins(&mut obj, 1, 21.0, None);
|
||||||
|
ins(&mut obj, 2, 21.0001, Some("2023:09:06 00:00:01Z"));
|
||||||
|
ins(&mut obj, 3, 21.0002, Some("2023:09:06 00:00:02Z"));
|
||||||
|
let (samples, t0_abs) = collect_samples(&obj);
|
||||||
|
assert_eq!(samples.len(), 3);
|
||||||
|
let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||||
|
assert!(max_gap <= 1.0 + 1e-9, "timebases mixed: max gap {max_gap}");
|
||||||
|
assert_eq!(samples[0][2], 0.0);
|
||||||
|
assert!(t0_abs.is_none(), "first fix is unstamped — no reliable epoch");
|
||||||
|
|
||||||
|
// Fully-stamped tracks still use real GPS time (t0_abs = first fix's epoch).
|
||||||
|
let mut obj2 = serde_json::Map::new();
|
||||||
|
ins(&mut obj2, 1, 21.0, Some("2023:09:06 00:00:00Z"));
|
||||||
|
ins(&mut obj2, 2, 21.0001, Some("2023:09:06 00:00:03Z"));
|
||||||
|
let (s2, t0b) = collect_samples(&obj2);
|
||||||
|
assert_eq!(s2[1][2], 3.0);
|
||||||
|
assert!(t0b.is_some());
|
||||||
|
|
||||||
|
// The live-data case: a GARBAGE first stamp (1970) followed by real ones —
|
||||||
|
// "all stamped", but the epoch jump would be the whole epoch. The
|
||||||
|
// plausibility gate (>2000) turns the 1970 stamp into "unstamped" → index
|
||||||
|
// timebase for the whole track, and no epoch is trusted for stitching.
|
||||||
|
let mut obj3 = serde_json::Map::new();
|
||||||
|
ins(&mut obj3, 1, 21.0, Some("1970:01:01 00:00:00Z"));
|
||||||
|
ins(&mut obj3, 2, 21.0001, Some("2023:09:06 00:00:01Z"));
|
||||||
|
ins(&mut obj3, 3, 21.0002, Some("2023:09:06 00:00:02Z"));
|
||||||
|
let (s3, t0c) = collect_samples(&obj3);
|
||||||
|
let g3 = s3.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||||
|
assert!(g3 <= 1.0 + 1e-9, "garbage stamp leaked: max gap {g3}");
|
||||||
|
assert!(t0c.is_none());
|
||||||
|
|
||||||
|
// Clock glitch WITHIN plausible dates (2023 → 2026 jump): the >1 h step
|
||||||
|
// guard rebuilds on the index timebase too.
|
||||||
|
let mut obj4 = serde_json::Map::new();
|
||||||
|
ins(&mut obj4, 1, 21.0, Some("2023:09:06 00:00:00Z"));
|
||||||
|
ins(&mut obj4, 2, 21.0001, Some("2026:05:28 12:00:00Z"));
|
||||||
|
ins(&mut obj4, 3, 21.0002, Some("2026:05:28 12:00:01Z"));
|
||||||
|
let (s4, _) = collect_samples(&obj4);
|
||||||
|
let g4 = s4.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||||
|
assert!(g4 <= 1.0 + 1e-9, "clock glitch leaked: max gap {g4}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn smoother_removes_a_burst() {
|
fn smoother_removes_a_burst() {
|
||||||
let mut t = straight(80);
|
let mut t = straight(80);
|
||||||
|
|||||||
@@ -207,9 +207,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/api/videos/:id/gps/correct", post(gps::correct))
|
.route("/api/videos/:id/gps/correct", post(gps::correct))
|
||||||
.route("/api/projects/:id/chainage/points", get(chainage::list_points).post(chainage::add_point))
|
.route("/api/projects/:id/chainage/points", get(chainage::list_points).post(chainage::add_point))
|
||||||
.route("/api/projects/:id/chainage/points/bulk", post(chainage::add_points_bulk))
|
.route("/api/projects/:id/chainage/points/bulk", post(chainage::add_points_bulk))
|
||||||
.route("/api/chainage/points/:id", delete(chainage::delete_point))
|
.route("/api/chainage/points/:id", delete(chainage::delete_point).post(chainage::update_point_note))
|
||||||
.route("/api/projects/:id/chainage/recompute", post(chainage::recompute))
|
.route("/api/projects/:id/chainage/recompute", post(chainage::recompute))
|
||||||
.route("/api/projects/:id/chainage/quick", post(chainage::quick_assign))
|
.route("/api/projects/:id/chainage/quick", post(chainage::quick_assign))
|
||||||
|
.route("/api/projects/:id/chainage/calibrations", get(chainage::list_calibrations))
|
||||||
// Phase 3 admin (token-gated).
|
// Phase 3 admin (token-gated).
|
||||||
.route("/api/admin/storage", get(storage_info))
|
.route("/api/admin/storage", get(storage_info))
|
||||||
.route("/api/users", get(admin::list_users).post(admin::create_user))
|
.route("/api/users", get(admin::list_users).post(admin::create_user))
|
||||||
|
|||||||
Reference in New Issue
Block a user