map and chainage calculation
This commit is contained in:
39
README.md
39
README.md
@@ -166,6 +166,45 @@ pass back. All Phase 3 routes require `Authorization: Bearer <token>`.
|
||||
- `POST /api/videos/:id/push` — body = export doc + `verify_time_ms`; replace annotations,
|
||||
mark `verified`, record verifier + time, clear claim
|
||||
|
||||
### Map / GPS / road_type / chainage
|
||||
|
||||
The server image bundles exiftool; a serial background sweep extracts each video's GPS
|
||||
track (`gps_status`: `pending → ok | none | error`). The raw track is immutable —
|
||||
a kept "Viterbi" correction lives beside it and is preferred by the map + chainage.
|
||||
`road_type` + the four chainage values are injected into every claim/export doc and
|
||||
re-stamped on push (server-authoritative). **Every annotation** additionally gets its
|
||||
**frame-exact** `lat`/`lon`/`chainage_m`/`sr_chainage_m` (range annotations also
|
||||
`end_*`): frame 15 of a 30 fps video is t = 0.5 s — halfway between two 1 Hz GPS
|
||||
samples — so it gets the halfway position/chainage, not a second/video boundary.
|
||||
Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist in
|
||||
`chainage_calibrations` so this works at any time after a recompute.
|
||||
|
||||
- `GET /api/projects/:id/map` — per-video map rows: track (≤400 pts), status,
|
||||
road_type, chainage, `gps_status` (member/admin)
|
||||
- `GET /api/projects/:id/tracks.kml?token=` — all tracks as KML LineStrings colored by
|
||||
status + calibration points (Google Earth)
|
||||
- `POST /api/videos/:id/gps/rescan` — re-queue a video for the exiftool sweep (admin)
|
||||
- `POST /api/projects/:id/gps/rescan_all` — re-queue every video (admin; background)
|
||||
- `POST /api/videos/:id/gps/correct` `{action: preview|keep|discard}` — spike smoothing +
|
||||
missing-start stitch from the previous file (by GPS
|
||||
timestamp, duration-guarded); the scan sweep applies
|
||||
the same pipeline automatically as 'auto' (raw never
|
||||
changes; discard blocks re-auto-correction) (admin)
|
||||
- `GET/POST /api/projects/:id/road_types` — list / replace label set (`{names}`, admin)
|
||||
- `POST /api/projects/:id/road_type_assign` `{video_ids, road_type}` — bulk-assign ('' clears, admin)
|
||||
- `GET/POST /api/projects/:id/chainage/points` — client calibration points (POST admin:
|
||||
`{lat, lon, chainage_m, note?, road_type?}`)
|
||||
- `POST /api/projects/:id/chainage/points/bulk` `{points: […]}` — transactional bulk insert;
|
||||
the dashboard's **KML import** uses this (placemark
|
||||
names like `12+400` or `12.4` become chainage) (admin)
|
||||
- `DELETE /api/chainage/points/:id` — remove a point (admin)
|
||||
- `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
|
||||
piecewise-linearly; writes `chainage_*` (client) +
|
||||
`sr_chainage_*` (measured, immutable) + `route_seq` (admin)
|
||||
- `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)
|
||||
|
||||
### Env
|
||||
|
||||
| var | default | meaning |
|
||||
|
||||
32
dashboard/package-lock.json
generated
32
dashboard/package-lock.json
generated
@@ -8,10 +8,13 @@
|
||||
"name": "central-dashboard",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -1241,6 +1244,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1467,6 +1487,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1524,6 +1550,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
api, exportProjectUrl, exportVideoUrl, fmtDuration, fmtWhen, passDeltas, setAuthToken,
|
||||
type Activity, type Member, type ProjectSummary, type Stats, type UserRow, type VideoRow,
|
||||
api, exportProjectUrl, exportVideoUrl, fmtChainageRange, fmtDuration, fmtWhen, passDeltas,
|
||||
setAuthToken, statusLabel,
|
||||
type Activity, type Member, type ProjectSummary, type RoadType, type Stats, type UserRow, type VideoRow,
|
||||
} from "./api";
|
||||
import { RemarkCell } from "./RemarkCell";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { MembersPicker } from "./MembersPicker";
|
||||
import { FoldersPicker } from "./FoldersPicker";
|
||||
import { SignIn } from "./SignIn";
|
||||
import { MapView } from "./MapView";
|
||||
import { ChainagePanel } from "./ChainagePanel";
|
||||
|
||||
const TOKEN_KEY = "central-token";
|
||||
const POLL_MS = 10_000;
|
||||
@@ -88,6 +91,10 @@ export function App() {
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [assignee, setAssignee] = useState("");
|
||||
// Videos panel: table ↔ map toggle + road types (labels for assignment/calibration).
|
||||
const [videoView, setVideoView] = useState<"table" | "map">("table");
|
||||
const [roadTypes, setRoadTypes] = useState<RoadType[]>([]);
|
||||
const [rtAssign, setRtAssign] = useState("");
|
||||
// All ml_support users (admin only) — the pool the members picker chooses from.
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const mlUsers = users.filter((u) => u.role === "ml_support");
|
||||
@@ -102,14 +109,16 @@ export function App() {
|
||||
|
||||
const loadProject = useCallback(async (id: number) => {
|
||||
try {
|
||||
const [s, v, a, m] = await Promise.all([
|
||||
const [s, v, a, m, rt] = await Promise.all([
|
||||
api.stats(id), api.videos(id), api.activity(id),
|
||||
api.members(id).catch(() => [] as Member[]),
|
||||
api.roadTypes(id).catch(() => [] as RoadType[]),
|
||||
]);
|
||||
setStats(s);
|
||||
setVideos(v);
|
||||
setActivity(a);
|
||||
setMembers(m);
|
||||
setRoadTypes(rt);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -142,6 +151,29 @@ export function App() {
|
||||
}
|
||||
}, [isAdmin, selected, assignee, projectId, loadProject]);
|
||||
|
||||
// Bulk-assign the picked road type to the selected videos (admin; "" clears).
|
||||
const applyRoadType = useCallback(async () => {
|
||||
if (!isAdmin || selected.size === 0 || projectId == null) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.assignRoadType(projectId, [...selected], rtAssign);
|
||||
setSelected(new Set());
|
||||
await loadProject(projectId);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [isAdmin, selected, rtAssign, projectId, loadProject]);
|
||||
|
||||
// A deleted road-type label can leave a stale dropdown selection: the select
|
||||
// LOOKS like "(clear road type)" but still sends the dead label, which the server
|
||||
// rejects — making un-assign impossible. Snap back to '' whenever that happens.
|
||||
useEffect(() => {
|
||||
if (rtAssign !== "" && !roadTypes.some((r) => r.name === rtAssign)) setRtAssign("");
|
||||
}, [roadTypes, rtAssign]);
|
||||
|
||||
useEffect(() => { if (auth) void loadProjects(); }, [auth, loadProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -438,15 +470,21 @@ export function App() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Per-video table */}
|
||||
{/* Per-video table / map */}
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h3>Videos ({videos.length})</h3>
|
||||
<h3>
|
||||
Videos ({videos.length})
|
||||
<span className="viewtabs videos-toggle">
|
||||
<button className={videoView === "table" ? "active" : ""} onClick={() => setVideoView("table")}>Table</button>
|
||||
<button className={videoView === "map" ? "active" : ""} onClick={() => setVideoView("map")}>Map</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div className="panel-actions">
|
||||
{isAdmin && projectId != null && (
|
||||
<FoldersPicker projectId={projectId} onChange={() => loadProject(projectId)} />
|
||||
)}
|
||||
{isAdmin && (
|
||||
{isAdmin && videoView === "table" && (
|
||||
<span className="assign-bar">
|
||||
<span className="dim">{selected.size} selected →</span>
|
||||
<select value={assignee} onChange={(e) => setAssignee(e.target.value)} title="Assign to a project member (or unassign)">
|
||||
@@ -454,7 +492,14 @@ export function App() {
|
||||
{members.map((m) => <option key={m.username} value={m.username}>{m.username}</option>)}
|
||||
</select>
|
||||
<button className="btn" disabled={busy || selected.size === 0 || (assignee !== "" && members.length === 0)} onClick={assignSelected}>Assign</button>
|
||||
{members.length === 0 && <span className="dim">— add members in Admin first</span>}
|
||||
<select value={rtAssign} onChange={(e) => setRtAssign(e.target.value)} title="Set the road type of the selected videos (or clear it)">
|
||||
<option value="">(clear road type)</option>
|
||||
{roadTypes.map((r) => <option key={r.name} value={r.name}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="btn" disabled={busy || selected.size === 0} onClick={applyRoadType}
|
||||
title={rtAssign ? "Apply the picked road type to the selected videos" : "Remove the road type from the selected videos"}>
|
||||
{rtAssign ? "Set road" : "Clear road"}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{projectId != null && videos.some((v) => v.annotation_count > 0) && (
|
||||
@@ -464,13 +509,18 @@ export function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{videoView === "map" && projectId != null && (
|
||||
<MapView projectId={projectId} isAdmin={isAdmin} onChanged={() => loadProject(projectId)} />
|
||||
)}
|
||||
{videoView === "table" && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{isAdmin && <th><input type="checkbox" title="select all"
|
||||
checked={selected.size > 0 && selected.size === videos.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? new Set(videos.map((v) => v.id)) : new Set())} /></th>}
|
||||
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Annotator</th><th>Annotations</th>
|
||||
<th>#</th><th>Folder</th><th>File</th><th>Status</th><th>Road · Chainage</th><th>Annotator</th><th>Annotations</th>
|
||||
<th>Review</th><th>Verify time</th><th>Resolution</th><th>Annotated</th><th>Remarks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -482,7 +532,7 @@ export function App() {
|
||||
<td className="dim mono" title={v.rel_path}>{subfolder(v.rel_path) || "—"}</td>
|
||||
<td className="mono">{v.file_name}</td>
|
||||
<td>
|
||||
<span className={`badge ${v.workflow_status}`}>{v.workflow_status}</span>
|
||||
<span className={`badge ${v.workflow_status}`}>{statusLabel(v.workflow_status)}</span>
|
||||
{v.ignored && <span className="badge ignored-tag" title={v.ignored_by ? `ignored by ${v.ignored_by}` : "ignored"}>ignored</span>}
|
||||
{isAdmin && (
|
||||
<button className="ignore-btn" onClick={() => toggleIgnore(v)}
|
||||
@@ -500,6 +550,22 @@ export function App() {
|
||||
<div className="claim-line" title={`last push ${fmtWhen(v.completed_at)}`}>✓ <b>{v.verifiers || v.completed_by}</b> · {fmtWhen(v.completed_at)}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{v.road_type ? <span className="badge road-tag">{v.road_type}</span> : <span className="dim">—</span>}
|
||||
<div className="claim-line" title={
|
||||
v.sr_chainage_start_m != null
|
||||
? `sr (GPS-measured): ${fmtChainageRange(v.sr_chainage_start_m, v.sr_chainage_end_m)}`
|
||||
: v.gps_status === "ok" ? "GPS scanned — chainage not calibrated yet"
|
||||
: v.gps_status === "error" ? `GPS scan failed: ${v.gps_error ?? ""}`
|
||||
: v.gps_status === "none" ? "no GPS metadata in this video"
|
||||
: "GPS scan pending"
|
||||
}>
|
||||
{v.chainage_start_m != null
|
||||
? fmtChainageRange(v.chainage_start_m, v.chainage_end_m)
|
||||
: <span className="dim">{v.gps_status === "ok" ? "gps ✓" : v.gps_status === "pending" ? "gps …" : `gps ${v.gps_status}`}</span>}
|
||||
</div>
|
||||
<GpsPoints v={v} />
|
||||
</td>
|
||||
<td>{v.verifiers || v.primary_annotator || (v.assigned_to ? <span className="dim">assigned: {v.assigned_to}</span> : "—")}</td>
|
||||
<td>
|
||||
{v.annotation_count > 0
|
||||
@@ -543,10 +609,22 @@ export function App() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{videos.length === 0 && <tr><td colSpan={isAdmin ? 12 : 11} className="dim">no videos ingested</td></tr>}
|
||||
{videos.length === 0 && <tr><td colSpan={isAdmin ? 13 : 12} className="dim">no videos ingested</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Chainage calibration + road-type labels (admin) */}
|
||||
{isAdmin && projectId != null && (
|
||||
<ChainagePanel
|
||||
projectId={projectId}
|
||||
videos={videos}
|
||||
roadTypes={roadTypes}
|
||||
onChanged={() => loadProject(projectId)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -571,6 +649,56 @@ function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][]
|
||||
return order.map((k) => [k, map.get(k)!]);
|
||||
}
|
||||
|
||||
/** GPS fix count vs the ~1 fix/s expected for the video's duration. A mismatch gets
|
||||
* a ⚠ that expands (on click) into a plain-language explanation of how the missing/
|
||||
* extra data is handled — interpolation, start-stitching, end clamping. */
|
||||
function GpsPoints({ v }: { v: VideoRow }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (v.gps_status !== "ok" || v.gps_sample_count == null) return null;
|
||||
const durS = v.duration_s ?? (v.frame_count && v.fps ? v.frame_count / v.fps : null);
|
||||
const expected = durS ? Math.round(durS) : null;
|
||||
const diff = expected != null ? v.gps_sample_count - expected : 0;
|
||||
const off = expected != null && Math.abs(diff) > Math.max(3, expected * 0.05);
|
||||
const bigGap = (v.gps_max_gap_s ?? 0) > 5;
|
||||
return (
|
||||
<div className="claim-line gps-pts">
|
||||
{v.gps_sample_count} gps pts
|
||||
{expected != null && <span className="dim"> / ~{expected}</span>}
|
||||
{(off || bigGap) && (
|
||||
<button
|
||||
className="gps-warn"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Fix count doesn't match the video duration — click to see how the gaps are handled"
|
||||
>
|
||||
⚠
|
||||
</button>
|
||||
)}
|
||||
{open && (off || bigGap) && (
|
||||
<div className="gps-explain" onClick={() => setOpen(false)}>
|
||||
{diff < 0 || (!off && bigGap) ? (
|
||||
<>
|
||||
Expected ~{expected ?? "?"} fixes (1 per second of video), found {v.gps_sample_count}
|
||||
{bigGap && <> — largest dropout <b>{v.gps_max_gap_s!.toFixed(0)} s</b></>}.
|
||||
Positions inside a dropout are <b>time-interpolated</b>: each frame is placed
|
||||
proportionally on a straight line between the two nearest real fixes (constant
|
||||
speed assumed), and its chainage follows the same interpolation. A missing{" "}
|
||||
<b>start</b> is stitched from the previous video via GPS timestamps when possible;
|
||||
a missing <b>end</b> clamps to the last fix. The longer the dropout, the rougher
|
||||
the approximation.
|
||||
</>
|
||||
) : diff > 0 ? (
|
||||
<>
|
||||
Found {v.gps_sample_count} fixes but only ~{expected} expected — the camera wrote
|
||||
more than one fix per second (or duplicated samples). Extra fixes are used as-is;
|
||||
nothing is approximated.
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value, tone }: { label: string; value: number; tone?: string }) {
|
||||
return (
|
||||
<div className={`card ${tone ?? ""}`}>
|
||||
|
||||
549
dashboard/src/ChainagePanel.tsx
Normal file
549
dashboard/src/ChainagePanel.tsx
Normal 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 & road types</h3>
|
||||
<button className="btn" onClick={() => setOpen(true)}>Open ▾</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h3>Chainage & road types</h3>
|
||||
<button className="btn" onClick={() => setOpen(false)}>Close ▴</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">⚠ {error}</div>}
|
||||
|
||||
{/* Road-type labels (project-wide) */}
|
||||
<div className="chainage-block">
|
||||
<div className="dim block-label">Road types (assign them to selected videos in the table below)</div>
|
||||
<div className="member-chips">
|
||||
{roadTypes.map((r) => (
|
||||
<span key={r.name} className="member-chip">
|
||||
{r.name} <span className="dim">({r.video_count})</span>
|
||||
<button className="chip-x" disabled={busy} onClick={() => removeRoadType(r.name)} title="Remove label">×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="chip-input"
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }}
|
||||
placeholder="Add: LHS / RHS / MCW…"
|
||||
/>
|
||||
<button className="btn" disabled={busy || !newType.trim()} onClick={addRoadType}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calibration group + the videos it covers */}
|
||||
<div className="chainage-block form-row">
|
||||
<label className="dim">Calibration group</label>
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)}>
|
||||
<option value="">(all videos)</option>
|
||||
{roadTypes.map((r) => (
|
||||
<option key={r.name} value={r.name}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="dim">
|
||||
{groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
692
dashboard/src/MapView.tsx
Normal file
692
dashboard/src/MapView.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import {
|
||||
api, fmtChainageRange, statusLabel, tracksKmlUrl,
|
||||
type ChainagePoint, type CorrectResult, type MapVideo, type Remark,
|
||||
} from "./api";
|
||||
|
||||
/** Colors for the "color by status" mode (chips use the same). */
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: "#ff5252", // annotations missing — loud on purpose
|
||||
annotated: "#f5a623", // awaiting verification
|
||||
assigned: "#b48cff",
|
||||
"in-progress": "#6ea8ff",
|
||||
verified: "#5ccf75",
|
||||
"re-verified": "#2bbfa8",
|
||||
ignored: "#8f95ae",
|
||||
};
|
||||
const STATUS_ORDER = ["pending", "annotated", "assigned", "in-progress", "verified", "re-verified", "ignored"];
|
||||
|
||||
/** Unique per-video color: golden-angle hue spacing keeps neighbors distinct. */
|
||||
const videoColor = (id: number) => `hsl(${(id * 137.508) % 360}, 70%, 45%)`;
|
||||
|
||||
/** Bucket used for coloring/filtering: ignored trumps the workflow status. */
|
||||
const mapStatus = (v: MapVideo) => (v.ignored ? "ignored" : v.workflow_status);
|
||||
|
||||
const latlngs = (track: [number, number, number][]) =>
|
||||
track.map((p) => [p[0], p[1]] as [number, number]);
|
||||
|
||||
/** Cumulative meters along a track (local flat approximation — fine for readouts). */
|
||||
function trackCum(track: [number, number, number][]): number[] {
|
||||
const cum = [0];
|
||||
for (let i = 1; i < track.length; i++) {
|
||||
const mLat = 110574;
|
||||
const mLon = 111320 * Math.cos((track[i][0] * Math.PI) / 180);
|
||||
const dy = (track[i][0] - track[i - 1][0]) * mLat;
|
||||
const dx = (track[i][1] - track[i - 1][1]) * mLon;
|
||||
cum.push(cum[i - 1] + Math.hypot(dx, dy));
|
||||
}
|
||||
return cum;
|
||||
}
|
||||
|
||||
/** Project a point onto a track: meters ALONG it at the nearest spot + meters AWAY
|
||||
* (perpendicular). Local flat approximation — fine for readouts and proximity. */
|
||||
function projectOnTrack(
|
||||
track: [number, number, number][],
|
||||
cum: number[],
|
||||
lat: number,
|
||||
lon: number,
|
||||
): { along: number; away: number } {
|
||||
let along = 0;
|
||||
let away = Infinity;
|
||||
const mLat = 110574;
|
||||
const mLon = 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
const px = lon * mLon;
|
||||
const py = lat * mLat;
|
||||
for (let i = 0; i + 1 < track.length; i++) {
|
||||
const ax = track[i][1] * mLon, ay = track[i][0] * mLat;
|
||||
const bx = track[i + 1][1] * mLon, by = track[i + 1][0] * mLat;
|
||||
const dx = bx - ax, dy = by - ay;
|
||||
const l2 = dx * dx + dy * dy;
|
||||
const t = l2 > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / l2)) : 0;
|
||||
const d = Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
if (d < away) {
|
||||
away = d;
|
||||
along = cum[i] + t * (cum[i + 1] - cum[i]);
|
||||
}
|
||||
}
|
||||
return { along, away };
|
||||
}
|
||||
|
||||
/** Client chainage under the cursor: linear within the video (exact unless a
|
||||
* calibration point sits mid-video). Null when the video isn't calibrated. */
|
||||
function chainageAtCursor(v: MapVideo, cum: number[], ll: L.LatLng): number | null {
|
||||
if (v.chainage_start_m == null || v.chainage_end_m == null || !v.track) return null;
|
||||
const len = cum[cum.length - 1];
|
||||
if (len <= 0) return null;
|
||||
const d = projectOnTrack(v.track, cum, ll.lat, ll.lng).along;
|
||||
return v.chainage_start_m + (v.chainage_end_m - v.chainage_start_m) * (d / len);
|
||||
}
|
||||
|
||||
/** Position on the track at a given client chainage (inverse of the above). */
|
||||
function pointAtChainage(v: MapVideo, cum: number[], m: number): [number, number] | null {
|
||||
if (v.chainage_start_m == null || v.chainage_end_m == null || !v.track) return null;
|
||||
const span = v.chainage_end_m - v.chainage_start_m;
|
||||
if (span === 0) return null;
|
||||
const f = (m - v.chainage_start_m) / span;
|
||||
if (f < 0 || f > 1) return null;
|
||||
const d = f * cum[cum.length - 1];
|
||||
let i = 1;
|
||||
while (i < cum.length - 1 && cum[i] < d) i++;
|
||||
const f2 = cum[i] > cum[i - 1] ? (d - cum[i - 1]) / (cum[i] - cum[i - 1]) : 0;
|
||||
return [
|
||||
v.track[i - 1][0] + (v.track[i][0] - v.track[i - 1][0]) * f2,
|
||||
v.track[i - 1][1] + (v.track[i][1] - v.track[i - 1][1]) * f2,
|
||||
];
|
||||
}
|
||||
|
||||
/** "12+400" (km+m) or "12.4"/"12.4 km" → meters; null when the query isn't a chainage. */
|
||||
function parseChainageQuery(q: string): number | null {
|
||||
const t = q.trim();
|
||||
const plus = t.match(/^(\d+)\s*\+\s*(\d+(?:\.\d+)?)$/);
|
||||
if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]);
|
||||
if (/^\d+(?:\.\d+)?\s*(km)?$/i.test(t)) return parseFloat(t) * 1000;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "21.1055, 78.1857" → [lat, lon]; null when the query isn't a coordinate pair. */
|
||||
function parseLatLngQuery(q: string): [number, number] | null {
|
||||
const m = q.trim().match(/^(-?\d{1,3}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)$/);
|
||||
if (!m) return null;
|
||||
const lat = parseFloat(m[1]);
|
||||
const lon = parseFloat(m[2]);
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||||
return [lat, lon];
|
||||
}
|
||||
|
||||
/** One search-result entry: a video hit, or (v = null) a bare "go to location". */
|
||||
type SearchMatch = { v: MapVideo | null; ch?: number; ll?: [number, number]; away?: number };
|
||||
|
||||
/**
|
||||
* Map view of the project's videos: each GPS track is a polyline (unique color per
|
||||
* video, or status colors with filter chips), with search, a details card
|
||||
* (ignore / remarks / raise hand / re-scan / Viterbi correction), calibration-point
|
||||
* markers, and the "videos without GPS metadata" panel.
|
||||
*/
|
||||
export function MapView({
|
||||
projectId,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
projectId: number;
|
||||
isAdmin: boolean;
|
||||
onChanged: () => void; // refresh the table/stats after ignore etc.
|
||||
}) {
|
||||
const [videos, setVideos] = useState<MapVideo[]>([]);
|
||||
const [points, setPoints] = useState<ChainagePoint[]>([]);
|
||||
const [colorBy, setColorBy] = useState<"video" | "status">("video");
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [thread, setThread] = useState<Remark[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [correction, setCorrection] = useState<{ videoId: number; result: CorrectResult } | null>(null);
|
||||
|
||||
const mapDiv = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const trackLayer = useRef<L.LayerGroup | null>(null);
|
||||
const pointLayer = useRef<L.LayerGroup | null>(null);
|
||||
const correctionLayer = useRef<L.LayerGroup | null>(null);
|
||||
const searchLayer = useRef<L.LayerGroup | null>(null);
|
||||
const fitted = useRef(false);
|
||||
// Per-video cumulative track distances, computed lazily for hover/search readouts.
|
||||
const cumCache = useRef(new Map<number, number[]>());
|
||||
const cumFor = (v: MapVideo): number[] | null => {
|
||||
if (!v.track || v.track.length < 2) return null;
|
||||
let c = cumCache.current.get(v.id);
|
||||
if (!c) {
|
||||
c = trackCum(v.track);
|
||||
cumCache.current.set(v.id, c);
|
||||
}
|
||||
return c;
|
||||
};
|
||||
// Refs so Leaflet click handlers (bound at draw time) see current state.
|
||||
const correctionRef = useRef(correction);
|
||||
correctionRef.current = correction;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [v, p] = await Promise.all([
|
||||
api.map(projectId),
|
||||
api.chainagePoints(projectId).catch(() => [] as ChainagePoint[]),
|
||||
]);
|
||||
setVideos(v);
|
||||
setPoints(p);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fitted.current = false;
|
||||
setSelectedId(null);
|
||||
setCorrection(null);
|
||||
void load();
|
||||
const t = setInterval(() => {
|
||||
// Don't yank the rug while a correction preview is on screen.
|
||||
if (!correctionRef.current) void load();
|
||||
}, 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
// ---- Leaflet init (once) ----
|
||||
useEffect(() => {
|
||||
if (!mapDiv.current || mapRef.current) return;
|
||||
const map = L.map(mapDiv.current, { center: [20, 45], zoom: 5 });
|
||||
// OSM raster tiles (internet). The basemap is an optional layer — tracks render
|
||||
// fine even when tiles can't load (offline VM).
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
trackLayer.current = L.layerGroup().addTo(map);
|
||||
pointLayer.current = L.layerGroup().addTo(map);
|
||||
correctionLayer.current = L.layerGroup().addTo(map);
|
||||
searchLayer.current = L.layerGroup().addTo(map);
|
||||
mapRef.current = map;
|
||||
setTimeout(() => map.invalidateSize(), 0);
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => cumCache.current.clear(), [videos]);
|
||||
|
||||
const withTrack = useMemo(() => videos.filter((v) => v.track && v.track.length >= 2), [videos]);
|
||||
const noMeta = useMemo(() => videos.filter((v) => v.gps_status === "none" || v.gps_status === "error"), [videos]);
|
||||
const pendingScan = useMemo(() => videos.filter((v) => v.gps_status === "pending"), [videos]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const c: Record<string, number> = {};
|
||||
for (const v of withTrack) c[mapStatus(v)] = (c[mapStatus(v)] ?? 0) + 1;
|
||||
return c;
|
||||
}, [withTrack]);
|
||||
|
||||
const visible = useMemo(
|
||||
() => withTrack.filter((v) => !hidden.has(mapStatus(v))),
|
||||
[withTrack, hidden],
|
||||
);
|
||||
|
||||
// Search matches by name, by chainage ("12+400" / "12.4" — EVERY calibrated group
|
||||
// containing it: LHS, RHS and a ramp can each match the same chainage), or by
|
||||
// coordinates ("21.1055, 78.1857" — nearby tracks + a bare go-to-location entry).
|
||||
const matches = useMemo(() => {
|
||||
const q = search.trim();
|
||||
if (!q) return [] as SearchMatch[];
|
||||
const ll = parseLatLngQuery(q);
|
||||
if (ll) {
|
||||
const near: SearchMatch[] = videos
|
||||
.filter((v) => v.track && v.track.length >= 2)
|
||||
.map((v) => ({ v, ll, away: projectOnTrack(v.track!, cumFor(v)!, ll[0], ll[1]).away }))
|
||||
.filter((m) => (m.away ?? Infinity) <= 300)
|
||||
.sort((a, b) => a.away! - b.away!)
|
||||
.slice(0, 10);
|
||||
return [{ v: null, ll } as SearchMatch, ...near];
|
||||
}
|
||||
const m = parseChainageQuery(q);
|
||||
if (m != null) {
|
||||
return videos
|
||||
.filter((v) => {
|
||||
if (v.chainage_start_m == null || v.chainage_end_m == null) return false;
|
||||
const lo = Math.min(v.chainage_start_m, v.chainage_end_m);
|
||||
const hi = Math.max(v.chainage_start_m, v.chainage_end_m);
|
||||
return m >= lo && m <= hi;
|
||||
})
|
||||
.sort((a, b) => a.road_type.localeCompare(b.road_type) || a.file_name.localeCompare(b.file_name))
|
||||
.slice(0, 12)
|
||||
.map((v): SearchMatch => ({ v, ch: m }));
|
||||
}
|
||||
const lq = q.toLowerCase();
|
||||
return videos
|
||||
.filter((v) => v.file_name.toLowerCase().includes(lq) || v.rel_path.toLowerCase().includes(lq))
|
||||
.slice(0, 12)
|
||||
.map((v): SearchMatch => ({ v }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videos, search]);
|
||||
|
||||
const selected = videos.find((v) => v.id === selectedId) ?? null;
|
||||
|
||||
const select = useCallback((v: MapVideo | null) => {
|
||||
setSelectedId(v?.id ?? null);
|
||||
setThread([]);
|
||||
setCorrection(null);
|
||||
searchLayer.current?.clearLayers();
|
||||
if (v) {
|
||||
api.remarks(v.id).then(setThread).catch(() => setThread([]));
|
||||
if (v.track && v.track.length >= 2 && mapRef.current) {
|
||||
mapRef.current.fitBounds(L.latLngBounds(latlngs(v.track)), { padding: [40, 40], maxZoom: 16 });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Chainage search hit: select the video AND pin the exact spot on its track. */
|
||||
const gotoChainage = useCallback((v: MapVideo, m: number) => {
|
||||
select(v);
|
||||
const cum = cumFor(v);
|
||||
const pt = cum ? pointAtChainage(v, cum, m) : null;
|
||||
if (pt && mapRef.current && searchLayer.current) {
|
||||
L.circleMarker(pt, { radius: 9, color: "#ff3b3b", weight: 3, fillOpacity: 0.15 })
|
||||
.bindTooltip(`${(m / 1000).toFixed(3)} km`, { permanent: true, direction: "top" })
|
||||
.addTo(searchLayer.current);
|
||||
mapRef.current.setView(pt, 16);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [select]);
|
||||
|
||||
/** Coordinate search hit: pin the location (and select the nearby video, if any). */
|
||||
const gotoLatLng = useCallback((ll: [number, number], v: MapVideo | null) => {
|
||||
select(v); // also clears the previous search pin
|
||||
if (mapRef.current && searchLayer.current) {
|
||||
L.circleMarker(ll, { radius: 9, color: "#ff3b3b", weight: 3, fillOpacity: 0.15 })
|
||||
.bindTooltip(`${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}`, { permanent: true, direction: "top" })
|
||||
.addTo(searchLayer.current);
|
||||
mapRef.current.setView(ll, 16);
|
||||
}
|
||||
}, [select]);
|
||||
|
||||
// ---- draw tracks ----
|
||||
useEffect(() => {
|
||||
const layer = trackLayer.current;
|
||||
const map = mapRef.current;
|
||||
if (!layer || !map) return;
|
||||
layer.clearLayers();
|
||||
for (const v of visible) {
|
||||
if (correction && correction.videoId === v.id) continue; // overlay owns this one
|
||||
const sel = v.id === selectedId;
|
||||
const color = colorBy === "video" ? videoColor(v.id) : STATUS_COLORS[mapStatus(v)] ?? "#999";
|
||||
const line = L.polyline(latlngs(v.track!), {
|
||||
color,
|
||||
weight: sel ? 6 : 3,
|
||||
opacity: sel ? 1 : 0.8,
|
||||
dashArray: v.ignored ? "6 6" : undefined,
|
||||
});
|
||||
line.bindTooltip(`${v.file_name} · ${statusLabel(mapStatus(v))}`, { sticky: true });
|
||||
const cum = cumFor(v);
|
||||
if (cum && v.chainage_start_m != null) {
|
||||
// Live chainage readout under the cursor.
|
||||
line.on("mousemove", (e: L.LeafletMouseEvent) => {
|
||||
const ch = chainageAtCursor(v, cum, e.latlng);
|
||||
line.setTooltipContent(
|
||||
`${v.file_name} · ${statusLabel(mapStatus(v))}${ch != null ? ` · ${(ch / 1000).toFixed(3)} km` : ""}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
line.on("click", () => select(v));
|
||||
line.addTo(layer);
|
||||
if (sel) line.bringToFront();
|
||||
}
|
||||
if (!fitted.current && visible.length > 0) {
|
||||
const all = visible.flatMap((v) => latlngs(v.track!));
|
||||
map.fitBounds(L.latLngBounds(all), { padding: [30, 30] });
|
||||
fitted.current = true;
|
||||
}
|
||||
}, [visible, colorBy, selectedId, correction, select]);
|
||||
|
||||
// ---- draw calibration points ----
|
||||
useEffect(() => {
|
||||
const layer = pointLayer.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
for (const p of points) {
|
||||
L.circleMarker([p.lat, p.lon], {
|
||||
radius: 6,
|
||||
color: "#f5c838",
|
||||
fillColor: "#f5c838",
|
||||
fillOpacity: 0.9,
|
||||
})
|
||||
.bindTooltip(
|
||||
`chainage ${(p.chainage_m / 1000).toFixed(3)} km${p.road_type ? ` · ${p.road_type}` : ""}${p.note ? ` · ${p.note}` : ""}`,
|
||||
)
|
||||
.addTo(layer);
|
||||
}
|
||||
}, [points]);
|
||||
|
||||
// ---- draw correction overlay (original red vs corrected green) ----
|
||||
useEffect(() => {
|
||||
const layer = correctionLayer.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
if (!correction) return;
|
||||
L.polyline(latlngs(correction.result.original), { color: "#ff5252", weight: 3, opacity: 0.9 })
|
||||
.bindTooltip("original GPS")
|
||||
.addTo(layer);
|
||||
L.polyline(latlngs(correction.result.corrected), { color: "#5ccf75", weight: 4, opacity: 0.9 })
|
||||
.bindTooltip("corrected")
|
||||
.addTo(layer);
|
||||
if (mapRef.current) {
|
||||
mapRef.current.fitBounds(L.latLngBounds(latlngs(correction.result.original)), { padding: [40, 40] });
|
||||
}
|
||||
}, [correction]);
|
||||
|
||||
// ---- actions ----
|
||||
const act = async (fn: () => Promise<unknown>, reload = true) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
if (reload) {
|
||||
await load();
|
||||
onChanged();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIgnore = (v: MapVideo) => act(() => api.setIgnore(v.id, !v.ignored));
|
||||
const toggleHand = (v: MapVideo) => act(() => api.setHand(v.id, !v.hand_raised));
|
||||
const rescan = (v: MapVideo) => act(() => api.gpsRescan(v.id));
|
||||
const addRemark = (v: MapVideo) =>
|
||||
act(async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
setThread(await api.addRemark(v.id, body));
|
||||
setDraft("");
|
||||
}, false);
|
||||
|
||||
const previewCorrection = (v: MapVideo) =>
|
||||
act(async () => {
|
||||
const result = await api.gpsCorrect(v.id, "preview");
|
||||
setCorrection({ videoId: v.id, result });
|
||||
}, false);
|
||||
const keepCorrection = (v: MapVideo) =>
|
||||
act(async () => {
|
||||
await api.gpsCorrect(v.id, "keep");
|
||||
setCorrection(null);
|
||||
});
|
||||
const discardCorrection = () => setCorrection(null);
|
||||
const revertCorrection = (v: MapVideo) => act(() => api.gpsCorrect(v.id, "discard"));
|
||||
|
||||
const toggleChip = (s: string) =>
|
||||
setHidden((h) => {
|
||||
const n = new Set(h);
|
||||
n.has(s) ? n.delete(s) : n.add(s);
|
||||
return n;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mapview">
|
||||
<div className="map-toolbar">
|
||||
<div className="map-search">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search name · chainage 12+400 · lat, lon…"
|
||||
/>
|
||||
{matches.length > 0 && (
|
||||
<div className="map-search-results">
|
||||
{matches.map((mt, i) => (
|
||||
<button
|
||||
key={mt.v ? `v${mt.v.id}` : `ll${i}`}
|
||||
onClick={() => {
|
||||
if (mt.ll) gotoLatLng(mt.ll, mt.v);
|
||||
else if (mt.v && mt.ch != null) gotoChainage(mt.v, mt.ch);
|
||||
else if (mt.v) select(mt.v);
|
||||
setSearch("");
|
||||
}}
|
||||
>
|
||||
{mt.v === null ? (
|
||||
<>📍 <span className="mono">{mt.ll![0]}, {mt.ll![1]}</span> <span className="dim">· go to location</span></>
|
||||
) : (
|
||||
<>
|
||||
<span className="mono">{mt.v.file_name}</span>
|
||||
{mt.ch != null ? (
|
||||
// Same chainage can exist on LHS, RHS AND a ramp — one row per group.
|
||||
<span className="dim"> · <b>{mt.v.road_type || "no group"}</b> · contains {(mt.ch / 1000).toFixed(3)} km</span>
|
||||
) : mt.away != null ? (
|
||||
<span className="dim"> · {mt.away.toFixed(0)} m away{mt.v.road_type ? ` · ${mt.v.road_type}` : ""}</span>
|
||||
) : mt.v.track ? (
|
||||
<span className="dim"> · {statusLabel(mapStatus(mt.v))}</span>
|
||||
) : (
|
||||
<span className="err-tag"> · no GPS</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="map-colorby">
|
||||
<button className={colorBy === "video" ? "active" : ""} onClick={() => setColorBy("video")}>
|
||||
Color by video
|
||||
</button>
|
||||
<button className={colorBy === "status" ? "active" : ""} onClick={() => setColorBy("status")}>
|
||||
Color by status
|
||||
</button>
|
||||
</div>
|
||||
<a className="btn" href={tracksKmlUrl(projectId)} download
|
||||
title="All tracks (status colors) + calibration points — opens in Google Earth">
|
||||
⬇ KML
|
||||
</a>
|
||||
{colorBy === "status" && (
|
||||
<div className="map-chips">
|
||||
{STATUS_ORDER.filter((s) => (statusCounts[s] ?? 0) > 0).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`map-chip${hidden.has(s) ? " off" : ""}`}
|
||||
style={{ borderColor: STATUS_COLORS[s], color: hidden.has(s) ? undefined : STATUS_COLORS[s] }}
|
||||
onClick={() => toggleChip(s)}
|
||||
title={hidden.has(s) ? "show" : "hide"}
|
||||
>
|
||||
{statusLabel(s)} ({statusCounts[s]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error">⚠ {error}</div>}
|
||||
|
||||
<div className="map-body">
|
||||
<div ref={mapDiv} className="map-canvas" />
|
||||
<div className="map-side">
|
||||
{selected ? (
|
||||
<div className="map-details">
|
||||
<div className="map-details-head">
|
||||
<b className="mono">{selected.file_name}</b>
|
||||
<button className="btn" onClick={() => select(null)}>✕</button>
|
||||
</div>
|
||||
<div className="map-kv">
|
||||
<span className="dim">Folder</span>
|
||||
<span className="mono">{subfolder(selected.rel_path) || "—"}</span>
|
||||
<span className="dim">Status</span>
|
||||
<span>
|
||||
<span className={`badge ${selected.workflow_status}`}>{statusLabel(selected.workflow_status)}</span>
|
||||
{selected.ignored && <span className="badge ignored-tag">ignored</span>}
|
||||
</span>
|
||||
<span className="dim">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>
|
||||
<span className="dim">GPS</span>
|
||||
<span>
|
||||
{selected.gps_status === "ok" ? (
|
||||
<>
|
||||
{((selected.gps_len_m ?? 0) / 1000).toFixed(2)} km
|
||||
{selected.gps_sample_count != null && <span className="dim"> · {selected.gps_sample_count} fixes</span>}
|
||||
{(selected.gps_max_gap_s ?? 0) > 5 && (
|
||||
<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 ⚠
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
selected.gps_status
|
||||
)}
|
||||
{selected.gps_error && <span className="err-tag" title={selected.gps_error}> ⚠</span>}
|
||||
{selected.corrected && (
|
||||
<div className="dim corrected-note" title={selected.gps_corrected_note ?? ""}>
|
||||
{selected.gps_corrected_by === "auto" ? "auto-corrected" : `corrected by ${selected.gps_corrected_by}`}
|
||||
{selected.gps_corrected_note ? `: ${selected.gps_corrected_note}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<span className="dim">Annotations</span>
|
||||
<span>{selected.annotation_count}</span>
|
||||
</div>
|
||||
|
||||
<div className="map-actions">
|
||||
{isAdmin && (
|
||||
<button className="btn" disabled={busy} onClick={() => toggleIgnore(selected)}>
|
||||
{selected.ignored ? "Un-ignore" : "Ignore"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`hand-btn${selected.hand_raised ? " raised" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => toggleHand(selected)}
|
||||
title={selected.hand_raised ? "Lower hand" : "Raise hand"}
|
||||
>
|
||||
✋
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button className="btn" disabled={busy} onClick={() => rescan(selected)} title="Queue a fresh exiftool scan">
|
||||
Re-scan GPS
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && selected.gps_status === "ok" && !correction && (
|
||||
<button className="btn" disabled={busy} onClick={() => previewCorrection(selected)}
|
||||
title="Viterbi outlier correction — preview, then keep or discard">
|
||||
GPS correction…
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && selected.corrected && !correction && (
|
||||
<button className="btn" disabled={busy} onClick={() => revertCorrection(selected)}
|
||||
title="Drop the kept correction, back to the raw track">
|
||||
Revert to raw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{correction && correction.videoId === selected.id && (
|
||||
<div className="map-correction">
|
||||
<div>
|
||||
<b>{correction.result.replaced}</b> of {correction.result.points} points corrected ·{" "}
|
||||
{(correction.result.len_before_m / 1000).toFixed(2)} →{" "}
|
||||
{(correction.result.len_after_m / 1000).toFixed(2)} km
|
||||
</div>
|
||||
{correction.result.note && <div className="dim">{correction.result.note}</div>}
|
||||
<div className="dim">red = original · green = corrected</div>
|
||||
<div className="map-actions">
|
||||
<button className="btn primary" disabled={busy} onClick={() => keepCorrection(selected)}>
|
||||
Keep correction
|
||||
</button>
|
||||
<button className="btn" disabled={busy} onClick={discardCorrection}>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="map-remarks">
|
||||
<div className="dim">Remarks ({thread.length})</div>
|
||||
<div className="remark-thread">
|
||||
{thread.length === 0 && <div className="dim">No remarks yet.</div>}
|
||||
{thread.map((r, i) => (
|
||||
<div key={i} className="remark-line">
|
||||
<b>{r.username}:</b> {r.body}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="remark-add">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void addRemark(selected);
|
||||
}}
|
||||
placeholder="Add a remark…"
|
||||
/>
|
||||
<button className="btn primary" disabled={busy || !draft.trim()} onClick={() => addRemark(selected)}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="map-hint dim">
|
||||
Click a track (or search) to see a video's details, ignore it or add a remark.
|
||||
<br />
|
||||
{withTrack.length} of {videos.length} videos have a GPS track.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirement: videos without metadata must be listed visibly. */}
|
||||
<div className="map-nometa">
|
||||
<div className="map-nometa-head">
|
||||
Without GPS metadata <b>{noMeta.length}</b>
|
||||
{pendingScan.length > 0 && <span className="dim"> · {pendingScan.length} awaiting scan</span>}
|
||||
{isAdmin && (
|
||||
<button className="btn mini" disabled={busy} style={{ marginLeft: 8 }}
|
||||
title="Queue a fresh scan of EVERY video (background, one at a time). Needed once to enable start-stitching on videos scanned before the upgrade."
|
||||
onClick={() => {
|
||||
if (window.confirm("Re-scan GPS metadata for every video in this project? The sweep works through them in the background."))
|
||||
void act(() => api.gpsRescanAll(projectId));
|
||||
}}>
|
||||
↻ all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="map-nometa-list">
|
||||
{noMeta.map((v) => (
|
||||
<div key={v.id} className="map-nometa-row">
|
||||
<button className="map-nometa-name mono" onClick={() => select(v)} title={v.rel_path}>
|
||||
{v.file_name}
|
||||
</button>
|
||||
{v.gps_status === "error" ? (
|
||||
<span className="err-tag" title={v.gps_error ?? "scan error"}>error</span>
|
||||
) : (
|
||||
<span className="dim">none</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button className="btn mini" disabled={busy} onClick={() => rescan(v)} title="Queue a fresh scan">
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{noMeta.length === 0 && <div className="dim">every scanned video has GPS ✓</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function subfolder(rel: string): string {
|
||||
const i = rel.lastIndexOf("/");
|
||||
return i >= 0 ? rel.slice(0, i) : "";
|
||||
}
|
||||
@@ -84,6 +84,111 @@ export interface VideoRow {
|
||||
ignored_by: string | null;
|
||||
remarks: Remark[];
|
||||
passes: Pass[]; // per-pass annotation-count history (push order)
|
||||
road_type: string; // '' = unassigned
|
||||
gps_status: string; // pending | ok | none | error
|
||||
gps_len_m: number | null;
|
||||
gps_error: string | null;
|
||||
gps_sample_count: number | null; // raw fixes found (≈1/s expected)
|
||||
gps_max_gap_s: number | null; // biggest dropout between fixes
|
||||
duration_s: number | null; // real container duration from the scan
|
||||
chainage_start_m: number | null; // client-calibrated
|
||||
chainage_end_m: number | null;
|
||||
sr_chainage_start_m: number | null; // GPS-measured (immutable)
|
||||
sr_chainage_end_m: number | null;
|
||||
route_seq: number | null;
|
||||
}
|
||||
|
||||
/** One video as the map sees it. `track` = [[lat, lon, t_s], …] (corrected when a
|
||||
* correction was kept, else raw); null until the GPS sweep has scanned it. */
|
||||
export interface MapVideo {
|
||||
id: number;
|
||||
file_name: string;
|
||||
rel_path: string;
|
||||
status: string;
|
||||
workflow_status: string;
|
||||
ignored: boolean;
|
||||
hand_raised: boolean;
|
||||
assigned_to: string | null;
|
||||
claimed_by: string | null;
|
||||
annotation_count: number;
|
||||
road_type: string;
|
||||
gps_status: string;
|
||||
gps_len_m: number | null;
|
||||
gps_error: string | null;
|
||||
chainage_start_m: number | null;
|
||||
chainage_end_m: number | null;
|
||||
sr_chainage_start_m: number | null;
|
||||
sr_chainage_end_m: number | null;
|
||||
route_seq: number | null;
|
||||
remark_count: number;
|
||||
track: [number, number, number][] | null;
|
||||
corrected: boolean;
|
||||
gps_corrected_by: string | null; // 'auto' or a username
|
||||
gps_corrected_note: string | null;
|
||||
gps_sample_count: number | null; // raw fixes found by the scan
|
||||
gps_max_gap_s: number | null; // biggest dropout between fixes
|
||||
}
|
||||
|
||||
export interface RoadType {
|
||||
name: string;
|
||||
video_count: number;
|
||||
}
|
||||
|
||||
export interface ChainagePoint {
|
||||
id: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
chainage_m: number;
|
||||
note: string;
|
||||
road_type: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SpanRow {
|
||||
from_chainage_m: number;
|
||||
to_chainage_m: number;
|
||||
gps_len_m: number;
|
||||
client_len_m: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface VideoChainageRow {
|
||||
id: number;
|
||||
file_name: string;
|
||||
seq: number;
|
||||
flipped: boolean;
|
||||
sr_start_m: number;
|
||||
sr_end_m: number;
|
||||
chainage_start_m: number;
|
||||
chainage_end_m: number;
|
||||
}
|
||||
|
||||
export interface CalibrationSummary {
|
||||
route_len_m: number;
|
||||
reversed: boolean;
|
||||
points_used: number;
|
||||
points_excluded: number;
|
||||
spans: SpanRow[];
|
||||
warnings: string[];
|
||||
videos: VideoChainageRow[];
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** Result of the GPS correction preview/keep — original + corrected tracks + stats.
|
||||
* The pipeline = spike removal + (when a temporal predecessor exists) a
|
||||
* missing-start stitch that re-bases the clock to the true video start. */
|
||||
export interface CorrectResult {
|
||||
action: string;
|
||||
points: number;
|
||||
replaced: number;
|
||||
stitched_from: string | null; // predecessor file the start was borrowed from
|
||||
shift_s: number; // seconds of GPS missing at the start (clock re-based by this)
|
||||
note: string;
|
||||
len_before_m: number;
|
||||
len_after_m: number;
|
||||
original: [number, number, number][];
|
||||
corrected: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface LeaderRow {
|
||||
@@ -186,6 +291,9 @@ export const exportVideoUrl = (id: number) =>
|
||||
`${BASE}/videos/${id}/export?token=${encodeURIComponent(authToken)}`;
|
||||
export const exportProjectUrl = (id: number) =>
|
||||
`${BASE}/projects/${id}/export?token=${encodeURIComponent(authToken)}`;
|
||||
/** All video tracks (colored by status) + calibration points as a Google-Earth KML. */
|
||||
export const tracksKmlUrl = (id: number) =>
|
||||
`${BASE}/projects/${id}/tracks.kml?token=${encodeURIComponent(authToken)}`;
|
||||
|
||||
function authHeaders(token?: string): Record<string, string> {
|
||||
const t = token ?? authToken;
|
||||
@@ -288,8 +396,64 @@ export const api = {
|
||||
folders: (projectId: number) => get<Folder[]>(`/projects/${projectId}/folders`),
|
||||
setFolders: (projectId: number, folders: string[], token?: string) =>
|
||||
post<{ folders: number }>(`/projects/${projectId}/folders`, { folders }, token),
|
||||
// ---- Map view + GPS pipeline + road_type + chainage ----
|
||||
map: (projectId: number) => get<MapVideo[]>(`/projects/${projectId}/map`),
|
||||
remarks: (videoId: number) => get<Remark[]>(`/videos/${videoId}/remarks`),
|
||||
// Queue a fresh exiftool scan for a video / for every scanned video (admin).
|
||||
gpsRescan: (videoId: number) => post<{ queued: boolean }>(`/videos/${videoId}/gps/rescan`),
|
||||
gpsRescanAll: (projectId: number) => post<{ queued: number }>(`/projects/${projectId}/gps/rescan_all`),
|
||||
// "Viterbi" correction: preview (nothing saved) / keep (persist) / discard (revert).
|
||||
gpsCorrect: (videoId: number, action: "preview" | "keep" | "discard") =>
|
||||
post<CorrectResult>(`/videos/${videoId}/gps/correct`, { action }),
|
||||
// Road types: admin-defined labels + bulk assignment to selected videos.
|
||||
roadTypes: (projectId: number) => get<RoadType[]>(`/projects/${projectId}/road_types`),
|
||||
setRoadTypes: (projectId: number, names: string[]) =>
|
||||
post<{ road_types: number }>(`/projects/${projectId}/road_types`, { names }),
|
||||
assignRoadType: (projectId: number, video_ids: number[], road_type: string) =>
|
||||
post<{ assigned: number }>(`/projects/${projectId}/road_type_assign`, { video_ids, road_type }),
|
||||
// Chainage: client calibration points + the recompute/quick-assign actions.
|
||||
chainagePoints: (projectId: number) => get<ChainagePoint[]>(`/projects/${projectId}/chainage/points`),
|
||||
addChainagePoint: (
|
||||
projectId: number,
|
||||
p: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string },
|
||||
) => post<ChainagePoint>(`/projects/${projectId}/chainage/points`, p),
|
||||
// Bulk insert (KML import): one transaction, all-or-nothing.
|
||||
addChainagePointsBulk: (
|
||||
projectId: number,
|
||||
points: { lat: number; lon: number; chainage_m: number; note?: string; road_type?: string }[],
|
||||
) => post<{ added: number }>(`/projects/${projectId}/chainage/points/bulk`, { points }),
|
||||
deleteChainagePoint: (pointId: number) => del<{ deleted: boolean }>(`/chainage/points/${pointId}`),
|
||||
chainageRecompute: (projectId: number, video_ids: number[], road_type: string) =>
|
||||
post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }),
|
||||
chainageQuick: (
|
||||
projectId: number,
|
||||
body: { video_ids: number[]; start_m: number; end_m: number; road_type: string },
|
||||
) => post<CalibrationSummary>(`/projects/${projectId}/chainage/quick`, body),
|
||||
};
|
||||
|
||||
/** Display label for a workflow/status value. `annotated` really means "has
|
||||
* annotations, nobody verified them yet"; `pending` means the video has no
|
||||
* annotations at all — admins need to see that plainly. Display-only: the DB/API
|
||||
* keep the raw values. */
|
||||
export function statusLabel(s: string): string {
|
||||
if (s === "annotated") return "awaiting verification";
|
||||
if (s === "pending") return "no annotations";
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Chainage in decimal km ("12.345 km"); em-dash when not computed yet. */
|
||||
export function fmtChainage(m: number | null | undefined): string {
|
||||
if (m == null || isNaN(m)) return "—";
|
||||
return `${(m / 1000).toFixed(3)} km`;
|
||||
}
|
||||
|
||||
/** "12.400 → 13.750 km" for a start/end pair (direction preserved — a flipped video
|
||||
* legitimately runs high→low); em-dash when not computed. */
|
||||
export function fmtChainageRange(a: number | null | undefined, b: number | null | undefined): string {
|
||||
if (a == null || b == null) return "—";
|
||||
return `${(a / 1000).toFixed(3)} → ${(b / 1000).toFixed(3)} km`;
|
||||
}
|
||||
|
||||
/** Per-pass annotation deltas from the imported baseline: verify +Δ, re-verify +Δ, …
|
||||
* Skips legacy passes that have no recorded count. The first pass is "verify",
|
||||
* subsequent ones "re-verify". `total` is the running annotation count after the pass. */
|
||||
|
||||
@@ -98,10 +98,14 @@ tbody tr:hover { background: rgba(255,255,255,0.02); }
|
||||
padding: 6px 8px; font-family: ui-monospace, Menlo, monospace; font-size: 12px;
|
||||
word-break: break-all; flex: 1; }
|
||||
|
||||
/* Remark thread cell (video table) */
|
||||
.remark-col { max-width: 360px; }
|
||||
/* Remark thread cell (video table) — needs a guaranteed floor so the crowded table
|
||||
can't crush it; the table itself scrolls horizontally instead (.table-scroll). */
|
||||
.remark-col { max-width: 360px; min-width: 150px; }
|
||||
.remark-collapsed { background: none; border: none; color: var(--fg, #dce3f5); cursor: pointer;
|
||||
text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center; max-width: 340px; }
|
||||
text-align: left; padding: 2px 0; font-size: 12px; display: inline-flex; gap: 6px; align-items: center;
|
||||
max-width: 340px; white-space: nowrap; }
|
||||
/* Wide tables scroll inside their panel instead of squeezing columns off the edge. */
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.remark-collapsed:hover { filter: brightness(1.2); }
|
||||
.remark-badge { background: var(--bg0); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 1px 7px; font-size: 11px; white-space: nowrap; }
|
||||
@@ -247,6 +251,83 @@ tr.rank-top { background: rgba(245,200,56,0.08); }
|
||||
line-height: 1; padding: 0 2px; }
|
||||
.chip-x:hover { filter: brightness(1.2); }
|
||||
|
||||
/* ---- Map view ---- */
|
||||
.videos-toggle { margin-left: 12px; vertical-align: middle; }
|
||||
.mapview { display: flex; flex-direction: column; gap: 10px; }
|
||||
.map-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.map-search { position: relative; }
|
||||
.map-search input { background: var(--bg2); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 7px 10px; font-size: 13px; width: 260px; }
|
||||
.map-search input:focus { outline: none; border-color: var(--info); }
|
||||
.map-search-results { position: absolute; top: calc(100% + 4px); left: 0; z-index: 1000; width: 340px;
|
||||
background: var(--bg1); border: 1px solid var(--border); border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.45); overflow: hidden; }
|
||||
.map-search-results button { display: block; width: 100%; text-align: left; background: none; border: none;
|
||||
color: var(--text); padding: 7px 10px; cursor: pointer; font-size: 12px; }
|
||||
.map-search-results button:hover { background: var(--bg2); }
|
||||
.map-colorby { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.map-colorby button { background: var(--bg1); color: var(--dim); border: none; padding: 6px 12px;
|
||||
cursor: pointer; font-size: 12px; }
|
||||
.map-colorby button.active { background: var(--bg2); color: var(--text); font-weight: 600; }
|
||||
.map-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.map-chip { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
||||
padding: 3px 10px; font-size: 11px; font-weight: 600; cursor: pointer; color: var(--text); }
|
||||
.map-chip.off { opacity: 0.35; text-decoration: line-through; }
|
||||
.map-body { display: grid; grid-template-columns: 1fr 320px; gap: 12px; }
|
||||
@media (max-width: 900px) { .map-body { grid-template-columns: 1fr; } }
|
||||
.map-canvas { height: 560px; border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--bg2); z-index: 0; }
|
||||
.map-side { display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow: auto; }
|
||||
.map-hint { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 12px; font-size: 12px; }
|
||||
.map-details { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.map-details-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.map-details-head b { word-break: break-all; font-size: 12px; }
|
||||
.map-kv { display: grid; grid-template-columns: 92px 1fr; gap: 3px 8px; font-size: 12px; }
|
||||
.map-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.map-correction { background: rgba(92,207,117,0.08); border: 1px solid rgba(92,207,117,0.4);
|
||||
border-radius: 8px; padding: 8px 10px; font-size: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.map-remarks { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||||
.map-nometa { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||||
.map-nometa-head { font-size: 12px; margin-bottom: 6px; }
|
||||
.map-nometa-head b { color: var(--warn); }
|
||||
.map-nometa-list { display: flex; flex-direction: column; gap: 3px; max-height: 200px; overflow: auto; }
|
||||
.map-nometa-row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.map-nometa-name { background: none; border: none; color: var(--text); cursor: pointer; padding: 0;
|
||||
font-size: 11px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.map-nometa-name:hover { color: var(--info); }
|
||||
.btn.mini { padding: 1px 7px; font-size: 11px; }
|
||||
/* Leaflet dark-theme nudges */
|
||||
.leaflet-container { font: inherit; }
|
||||
.leaflet-container a.leaflet-attribution-flag { display: none !important; }
|
||||
|
||||
/* ---- Road type + chainage ---- */
|
||||
.badge.road-tag { background: rgba(245,200,56,0.16); color: var(--accent); }
|
||||
.chainage-block { margin-top: 12px; }
|
||||
.block-label { font-size: 12px; margin-bottom: 6px; }
|
||||
.chainage-block input, .chainage-block select { background: var(--bg2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font-size: 12px; }
|
||||
.chainage-block input:focus, .chainage-block select:focus { outline: none; border-color: var(--info); }
|
||||
.chip-input { width: 150px; }
|
||||
.chainage-warnings { margin: 6px 0; padding-left: 18px; color: #f3d3a0; font-size: 12px; }
|
||||
tr.span-warn { background: rgba(245,166,35,0.10); }
|
||||
.kml-preview { margin-top: 10px; border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 12px; background: var(--bg2); display: flex; flex-direction: column; gap: 8px; }
|
||||
.kml-preview-list { max-height: 240px; overflow: auto; }
|
||||
|
||||
/* GPS fix-count line + the tap-to-explain data-quality warning (video table) */
|
||||
.gps-pts { position: relative; }
|
||||
.gps-warn { background: rgba(245,166,35,0.18); border: 1px solid var(--warn); color: var(--warn);
|
||||
border-radius: 8px; cursor: pointer; font-size: 11px; line-height: 1; padding: 1px 5px; margin-left: 5px; }
|
||||
.gps-warn:hover { filter: brightness(1.2); }
|
||||
.gps-explain { position: absolute; z-index: 40; margin-top: 4px; width: 340px; white-space: normal;
|
||||
background: var(--bg1); border: 1px solid var(--warn); border-radius: 8px; padding: 9px 11px;
|
||||
font-size: 12px; line-height: 1.45; box-shadow: 0 10px 30px rgba(0,0,0,0.45); cursor: pointer; }
|
||||
.gps-explain b { color: var(--warn); }
|
||||
/* Map details: correction attribution line */
|
||||
.corrected-note { font-size: 11px; margin-top: 2px; white-space: normal; }
|
||||
|
||||
/* Storage panel */
|
||||
.storage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
||||
.storage-grid > div { display: flex; flex-direction: column; gap: 2px; background: var(--bg2);
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/FoldersPicker.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/AdminPanel.tsx","./src/App.tsx","./src/ChainagePanel.tsx","./src/FoldersPicker.tsx","./src/MapView.tsx","./src/MembersPicker.tsx","./src/RemarkCell.tsx","./src/SignIn.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"}
|
||||
87
db/init.sql
87
db/init.sql
@@ -222,3 +222,90 @@ CREATE TABLE IF NOT EXISTS project_folders (
|
||||
folder TEXT NOT NULL, -- directory part of rel_path ('' = project root)
|
||||
PRIMARY KEY (project_id, folder)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- GPS pipeline (map view + chainage). A background sweep extracts each video's
|
||||
-- GPS track with exiftool (dashcam Doc{N} samples), one video at a time:
|
||||
-- gps_status: 'pending' → 'ok' (track stored) | 'none' (no GPS metadata)
|
||||
-- | 'error' (exiftool/mount failure, see gps_error)
|
||||
-- The RAW track is immutable once scanned; a kept "Viterbi" correction goes to
|
||||
-- gps_track_corrected (map/chainage prefer it, raw is never modified).
|
||||
-- Track format: [[lat, lon, t_s], ...] (t_s = seconds from first sample).
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_status TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_track JSONB;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_len_m DOUBLE PRECISION;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_error TEXT;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_scanned_at TIMESTAMPTZ;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_track_corrected JSONB;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_by TEXT;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_at TIMESTAMPTZ;
|
||||
-- Human-readable summary of what the correction did ("2 spikes · start stitched …").
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_corrected_note TEXT;
|
||||
-- Data-quality + continuity metadata from the raw scan: absolute epoch of the
|
||||
-- first/last GPS fix (lets consecutive files stitch across a missing start),
|
||||
-- raw sample count and the largest inter-fix gap (dropout visibility).
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_start_epoch DOUBLE PRECISION;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_end_epoch DOUBLE PRECISION;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_sample_count INTEGER;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS gps_max_gap_s DOUBLE PRECISION;
|
||||
-- Real media duration read from the container by the exiftool scan. Load-bearing for
|
||||
-- the stitch guard: an inter-file gap is only "missing start" when the track covers
|
||||
-- LESS than the file's duration — otherwise the camera simply wasn't recording.
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS duration_s DOUBLE PRECISION;
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_gps ON videos(gps_status) WHERE gps_status = 'pending';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Road types: admin-defined per-project labels (LHS/RHS/MCW/Servicelane, …)
|
||||
-- assigned to videos. Injected into the export/claim JSON as `road_type` and
|
||||
-- re-stamped on push (server-authoritative).
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS project_road_types (
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, name)
|
||||
);
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS road_type TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Chainage (linear referencing with piecewise-linear calibration).
|
||||
-- chainage_points = admin-entered client calibration pairs (lat/lon ↔ chainage).
|
||||
-- Optional road_type tag scopes a point to one calibration group (LHS vs RHS are
|
||||
-- separate routes); '' = usable by any group.
|
||||
-- Per video:
|
||||
-- sr_chainage_* = ACTUAL measured position along the GPS route (immutable,
|
||||
-- regenerated only from GPS — never admin-editable)
|
||||
-- chainage_* = client-calibrated values (error distributed between
|
||||
-- consecutive control points)
|
||||
-- route_seq = position of the video along the calibrated route
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chainage_points (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lon DOUBLE PRECISION NOT NULL,
|
||||
chainage_m DOUBLE PRECISION NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
road_type TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(project_id);
|
||||
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 sr_chainage_start_m DOUBLE PRECISION;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_end_m DOUBLE PRECISION;
|
||||
ALTER TABLE videos ADD COLUMN IF NOT EXISTS route_seq INTEGER;
|
||||
|
||||
-- The piecewise-linear calibration itself (per project + group), persisted at
|
||||
-- recompute time as sorted [s_m, chainage_m] anchors. Needed to evaluate chainage at
|
||||
-- ANY route position later — e.g. frame-exact per-annotation chainage in exports.
|
||||
CREATE TABLE IF NOT EXISTS chainage_calibrations (
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
road_type TEXT NOT NULL DEFAULT '',
|
||||
anchors JSONB NOT NULL,
|
||||
route_len_m DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
computed_by TEXT NOT NULL DEFAULT '',
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (project_id, road_type)
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
db:
|
||||
restart: unless-stopped
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: central
|
||||
@@ -17,6 +18,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
server:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
@@ -51,6 +53,7 @@ services:
|
||||
- "8080:8080"
|
||||
|
||||
dashboard:
|
||||
restart: unless-stopped
|
||||
build: ./dashboard
|
||||
environment:
|
||||
# The dashboard calls the API through nginx's /api proxy, so no host needed.
|
||||
|
||||
@@ -10,7 +10,9 @@ RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
# nfs-common provides mount.nfs for `nfs`-kind projects (mounted on demand at sync/download time).
|
||||
# libimage-exiftool-perl provides exiftool for the GPS-track extraction sweep (map/chainage).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates nfs-common \
|
||||
libimage-exiftool-perl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /build/server/target/release/central-server /usr/local/bin/central-server
|
||||
ENV BIND_ADDR=0.0.0.0:8080
|
||||
|
||||
1057
server/src/chainage.rs
Normal file
1057
server/src/chainage.rs
Normal file
File diff suppressed because it is too large
Load Diff
888
server/src/gps.rs
Normal file
888
server/src/gps.rs
Normal file
@@ -0,0 +1,888 @@
|
||||
//! GPS pipeline: exiftool track extraction (background sweep), shared track math,
|
||||
//! and the Stage-1 "Viterbi" smoother with its preview/keep/discard endpoint.
|
||||
//!
|
||||
//! Track representation everywhere: `[[lat, lon, t_s], …]` (t_s = seconds since the
|
||||
//! first GPS sample). The RAW extracted track (`videos.gps_track`) is immutable once
|
||||
//! scanned; a kept correction lives in `gps_track_corrected` and is preferred by the
|
||||
//! map + chainage. Raw is never modified — discarding a correction restores raw.
|
||||
//!
|
||||
//! Lifecycle (`videos.gps_status`): 'pending' → 'ok' | 'none' (no GPS metadata) |
|
||||
//! 'error' (exiftool/mount failure, message in `gps_error`). The sweep processes ONE
|
||||
//! video at a time (exiftool -ee reads the whole file over NFS — never hammer the NAS).
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::{err, ApiResult, AppState};
|
||||
use axum::{
|
||||
extract::{Path as AxPath, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// One GPS sample: [lat, lon, t_s].
|
||||
pub type Pt = [f64; 3];
|
||||
|
||||
/// Max samples stored per track (1 Hz ⇒ ~83 min of video at full fidelity).
|
||||
const MAX_TRACK_PTS: usize = 5000;
|
||||
/// Hard deadline for one exiftool scan (-ee reads the whole file over NFS).
|
||||
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
/// Sweep pacing: idle poll when nothing is pending / pause between videos / back-off
|
||||
/// after a failure (so an unreachable NAS isn't probed in a hot loop).
|
||||
const SWEEP_IDLE: Duration = Duration::from_secs(60);
|
||||
const SWEEP_BETWEEN: Duration = Duration::from_millis(500);
|
||||
const SWEEP_AFTER_ERROR: Duration = Duration::from_secs(5);
|
||||
|
||||
// ---- track math (shared with chainage) ----
|
||||
|
||||
/// WGS84 ellipsoid: semi-major axis + first eccentricity squared.
|
||||
const WGS84_A: f64 = 6_378_137.0;
|
||||
const WGS84_E2: f64 = 0.006_694_379_990_14;
|
||||
|
||||
/// Meters per degree of latitude / longitude at a given latitude, from the WGS84
|
||||
/// meridian (M) and prime-vertical (N) radii. Distance and snapping both use this,
|
||||
/// so every meter in the system is measured the same way.
|
||||
pub fn wgs84_m_per_deg(lat_deg: f64) -> (f64, f64) {
|
||||
let phi = lat_deg.to_radians();
|
||||
let w = (1.0 - WGS84_E2 * phi.sin().powi(2)).sqrt();
|
||||
let m = WGS84_A * (1.0 - WGS84_E2) / (w * w * w);
|
||||
let n = WGS84_A / w;
|
||||
let rad = std::f64::consts::PI / 180.0;
|
||||
(m * rad, n * phi.cos() * rad)
|
||||
}
|
||||
|
||||
/// WGS84 local-ellipsoid distance between two nearby points. Essentially exact at
|
||||
/// GPS sample spacing (meters–tens of meters) and <2e-5 relative for spans up to
|
||||
/// ~100 km — unlike spherical haversine, whose 0.1–0.3% systematic bias would land
|
||||
/// directly in sr_chainage. (Not valid across the ±180° meridian — irrelevant here.)
|
||||
pub fn dist_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
|
||||
let (mlat, mlon) = wgs84_m_per_deg((lat1 + lat2) * 0.5);
|
||||
let dy = (lat2 - lat1) * mlat;
|
||||
let dx = (lon2 - lon1) * mlon;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
|
||||
pub fn polyline_len_m(track: &[Pt]) -> f64 {
|
||||
track
|
||||
.windows(2)
|
||||
.map(|w| dist_m(w[0][0], w[0][1], w[1][0], w[1][1]))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Parse a stored JSONB track (`[[lat,lon,t_s],…]`) back into points. Tolerant:
|
||||
/// skips malformed entries.
|
||||
pub fn parse_track(v: &Value) -> Vec<Pt> {
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|p| {
|
||||
let p = p.as_array()?;
|
||||
Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?, p.get(2).and_then(|t| t.as_f64()).unwrap_or(0.0)])
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Serialize a track for storage/transport, rounding to keep the JSON small
|
||||
/// (1e-6 deg ≈ 0.1 m; 0.1 s time resolution).
|
||||
pub fn track_value(track: &[Pt]) -> Value {
|
||||
Value::Array(
|
||||
track
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!([
|
||||
(p[0] * 1e6).round() / 1e6,
|
||||
(p[1] * 1e6).round() / 1e6,
|
||||
(p[2] * 10.0).round() / 10.0
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Cap a track at `max` points (uniform stride, always keeping the last point).
|
||||
pub fn downsample(track: Vec<Pt>, max: usize) -> Vec<Pt> {
|
||||
if track.len() <= max || max < 2 {
|
||||
return track;
|
||||
}
|
||||
let stride = track.len().div_ceil(max);
|
||||
let last = *track.last().unwrap();
|
||||
let mut out: Vec<Pt> = track.into_iter().step_by(stride).collect();
|
||||
if out.last() != Some(&last) {
|
||||
out.push(last);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- exiftool extraction ----
|
||||
|
||||
/// What one exiftool scan yields: the GPS samples (t_s relative to the first fix),
|
||||
/// the first fix's absolute epoch (None when the camera wrote no GPSDateTime), the
|
||||
/// container's media duration in seconds, and the video frame rate. Empty samples =
|
||||
/// no GPS metadata. Duration + fps let us do frame→position math on raw NAS videos
|
||||
/// that were never annotated (no sibling JSON ⇒ no fps in our DB otherwise).
|
||||
struct ScanResult {
|
||||
samples: Vec<Pt>,
|
||||
t0_abs: Option<f64>,
|
||||
duration_s: Option<f64>,
|
||||
fps: Option<f64>,
|
||||
}
|
||||
|
||||
/// Run exiftool over a video and collect its embedded GPS time-series (Doc{N}
|
||||
/// groups, the format dashcams/DJI write at ~1 Hz). Same flags as the proven
|
||||
/// desktop extractor, plus container Duration + VideoFrameRate.
|
||||
async fn run_exiftool(path: &std::path::Path) -> Result<ScanResult, String> {
|
||||
let child = tokio::process::Command::new("exiftool")
|
||||
.args([
|
||||
"-ee", "-api", "LargeFileSupport=1", "-G3", "-json", "-n",
|
||||
"-GPSLatitude", "-GPSLongitude", "-GPSDateTime", "-Duration", "-VideoFrameRate",
|
||||
])
|
||||
.arg(path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to run exiftool (is it installed?): {e}"))?;
|
||||
|
||||
let output = match tokio::time::timeout(EXIFTOOL_TIMEOUT, child.wait_with_output()).await {
|
||||
Ok(r) => r.map_err(|e| format!("exiftool failed: {e}"))?,
|
||||
// kill_on_drop terminates the scan when the timeout drops the child.
|
||||
Err(_) => return Err("exiftool timed out (file too slow to read?)".into()),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).map_err(|_| {
|
||||
let se = String::from_utf8_lossy(&output.stderr);
|
||||
let msg: String = se.trim().chars().take(300).collect();
|
||||
if msg.is_empty() {
|
||||
"exiftool produced no output".to_string()
|
||||
} else {
|
||||
format!("exiftool: {msg}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let obj = parsed
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| "exiftool output was not a non-empty array".to_string())?;
|
||||
|
||||
// exiftool reports per-file problems inline (e.g. "Error: File is empty").
|
||||
if let Some((_, e)) = obj.iter().find(|(k, _)| *k == "Error" || k.ends_with(":Error")) {
|
||||
if let Some(msg) = e.as_str() {
|
||||
return Err(format!("exiftool: {msg}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric field across any -G3 group (container + per-track may each report one);
|
||||
// `reduce` picks the max, which is the sensible choice for both Duration and fps.
|
||||
let numeric = |name: &str| -> Option<f64> {
|
||||
obj.iter()
|
||||
.filter(|(k, _)| k.as_str() == name || k.ends_with(&format!(":{name}")))
|
||||
.filter_map(|(_, v)| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))
|
||||
.reduce(f64::max)
|
||||
};
|
||||
let duration = numeric("Duration");
|
||||
let fps = numeric("VideoFrameRate").filter(|f| *f > 0.0);
|
||||
|
||||
let (samples, t0_abs) = collect_samples(obj);
|
||||
Ok(ScanResult { samples, t0_abs, duration_s: duration, fps })
|
||||
}
|
||||
|
||||
/// Group `Doc{N}:GPSLatitude/GPSLongitude/GPSDateTime` keys into ordered samples
|
||||
/// with relative timestamps (falls back to 1 Hz when GPSDateTime is missing).
|
||||
/// Also returns the first fix's ABSOLUTE epoch — GPS time is UTC on every camera,
|
||||
/// so consecutive files can be matched by timestamp (start-of-video stitching).
|
||||
fn collect_samples(obj: &serde_json::Map<String, Value>) -> (Vec<Pt>, Option<f64>) {
|
||||
#[derive(Default)]
|
||||
struct Partial {
|
||||
lat: Option<f64>,
|
||||
lon: Option<f64>,
|
||||
t: Option<f64>,
|
||||
}
|
||||
|
||||
let mut by_doc: BTreeMap<u32, Partial> = BTreeMap::new();
|
||||
for (k, v) in obj {
|
||||
let Some((doc_idx, field)) = split_doc_key(k) else { continue };
|
||||
let entry = by_doc.entry(doc_idx).or_default();
|
||||
match field {
|
||||
"GPSLatitude" => entry.lat = v.as_f64(),
|
||||
"GPSLongitude" => entry.lon = v.as_f64(),
|
||||
"GPSDateTime" => entry.t = v.as_str().and_then(parse_gps_datetime),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut samples: Vec<Pt> = Vec::with_capacity(by_doc.len());
|
||||
let mut t0: Option<f64> = None;
|
||||
let mut t0_abs: Option<f64> = None;
|
||||
for (idx, p) in by_doc {
|
||||
let (Some(lat), Some(lon)) = (p.lat, p.lon) else { continue };
|
||||
if t0.is_none() {
|
||||
t0_abs = p.t; // real epoch only when the camera stamped it
|
||||
}
|
||||
let t_abs = p.t.unwrap_or(idx as f64);
|
||||
let t_rel = t_abs - *t0.get_or_insert(t_abs);
|
||||
samples.push([lat, lon, t_rel]);
|
||||
}
|
||||
(samples, t0_abs)
|
||||
}
|
||||
|
||||
fn split_doc_key(k: &str) -> Option<(u32, &str)> {
|
||||
let (group, field) = k.split_once(':')?;
|
||||
let idx: u32 = group.strip_prefix("Doc")?.parse().ok()?;
|
||||
Some((idx, field))
|
||||
}
|
||||
|
||||
/// "2025:08:02 09:33:31Z" → seconds since epoch (approx; only relative deltas are
|
||||
/// used). Civil-to-days via Howard Hinnant's algorithm — no chrono parse needed for
|
||||
/// exiftool's colon-separated dates.
|
||||
fn parse_gps_datetime(s: &str) -> Option<f64> {
|
||||
let s = s.trim_end_matches('Z').trim();
|
||||
let (date, time) = s.split_once(' ')?;
|
||||
let mut dp = date.split(':');
|
||||
let y: i64 = dp.next()?.parse().ok()?;
|
||||
let mo: i64 = dp.next()?.parse().ok()?;
|
||||
let d: i64 = dp.next()?.parse().ok()?;
|
||||
let mut tp = time.split(':');
|
||||
let h: i64 = tp.next()?.parse().ok()?;
|
||||
let mi: i64 = tp.next()?.parse().ok()?;
|
||||
let sec: f64 = tp.next()?.parse().ok()?;
|
||||
let y_adj = if mo <= 2 { y - 1 } else { y };
|
||||
let era = y_adj.div_euclid(400);
|
||||
let yoe = y_adj - era * 400;
|
||||
let m = if mo > 2 { mo - 3 } else { mo + 9 };
|
||||
let doy = (153 * m + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146097 + doe - 719468;
|
||||
Some(days as f64 * 86400.0 + (h * 3600 + mi * 60) as f64 + sec)
|
||||
}
|
||||
|
||||
// ---- background sweep ----
|
||||
|
||||
/// Forever-loop: pick ONE pending video, scan it, store the result, repeat.
|
||||
/// Serial by design (requirement: throttle — never scan the NAS in parallel).
|
||||
pub async fn sweep(state: AppState) {
|
||||
loop {
|
||||
let next: Option<(i32, String, String, String)> = sqlx::query_as(
|
||||
r#"SELECT v.id, v.rel_path, p.source_path, p.source_kind
|
||||
FROM videos v JOIN projects p ON v.project_id = p.id
|
||||
WHERE v.gps_status = 'pending'
|
||||
ORDER BY v.id LIMIT 1"#,
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let Some((id, rel_path, source_path, source_kind)) = next else {
|
||||
tokio::time::sleep(SWEEP_IDLE).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
let ok = scan_one(&state, id, &rel_path, &source_path, &source_kind).await;
|
||||
tokio::time::sleep(if ok { SWEEP_BETWEEN } else { SWEEP_AFTER_ERROR }).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a single video and persist the outcome. Returns false on error (the video
|
||||
/// is marked 'error' either way, so the sweep never re-picks it until a re-scan).
|
||||
async fn scan_one(state: &AppState, id: i32, rel_path: &str, source_path: &str, source_kind: &str) -> bool {
|
||||
let (sp, kind, host, opts) =
|
||||
(source_path.to_string(), source_kind.to_string(), state.nfs_host.clone(), state.nfs_opts.clone());
|
||||
let dir = match tokio::task::spawn_blocking(move || crate::nfs::resolve_dir(&sp, &kind, &host, &opts)).await {
|
||||
Ok(Ok(d)) => d,
|
||||
Ok(Err(e)) => return mark_error(&state.db, id, &e).await,
|
||||
Err(e) => return mark_error(&state.db, id, &format!("mount task failed: {e}")).await,
|
||||
};
|
||||
|
||||
match run_exiftool(&dir.join(rel_path)).await {
|
||||
Ok(ScanResult { samples, t0_abs, duration_s, fps }) if samples.len() >= 2 => {
|
||||
// Data-quality stats are measured BEFORE the storage cap: the raw fix
|
||||
// count and the largest inter-fix gap (dropouts) must reflect reality.
|
||||
let sample_count = samples.len() as i32;
|
||||
let max_gap = samples.windows(2).map(|w| w[1][2] - w[0][2]).fold(0.0_f64, f64::max);
|
||||
let end_epoch = t0_abs.map(|t0| t0 + samples.last().unwrap()[2]);
|
||||
let samples = downsample(samples, MAX_TRACK_PTS);
|
||||
let len = polyline_len_m(&samples);
|
||||
let res = sqlx::query(
|
||||
r#"UPDATE videos SET gps_status='ok', gps_track=$2, gps_len_m=$3, gps_error=NULL,
|
||||
gps_scanned_at=now(),
|
||||
gps_start_epoch=$4, gps_end_epoch=$5, gps_sample_count=$6, gps_max_gap_s=$7,
|
||||
duration_s=$8,
|
||||
-- fill fps for raw NAS videos (no sibling JSON) so frame→position
|
||||
-- math works; never clobber a JSON/desktop-derived fps.
|
||||
fps=COALESCE(fps, $9),
|
||||
-- a fresh raw track invalidates any correction derived from the old one
|
||||
gps_track_corrected=NULL, gps_corrected_by=NULL, gps_corrected_at=NULL,
|
||||
gps_corrected_note=NULL
|
||||
WHERE id=$1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(track_value(&samples))
|
||||
.bind(len)
|
||||
.bind(t0_abs)
|
||||
.bind(end_epoch)
|
||||
.bind(sample_count)
|
||||
.bind(max_gap)
|
||||
.bind(duration_s)
|
||||
.bind(fps)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
tracing::info!("gps: video {id} ok — {sample_count} samples, {len:.0} m, max gap {max_gap:.0}s");
|
||||
// Auto-correct this video (spikes + start stitch), then its
|
||||
// temporal successor — its start stitch may only now be possible.
|
||||
auto_correct(&state.db, id).await;
|
||||
if let Some(end) = end_epoch {
|
||||
let succ: Option<i32> = sqlx::query_scalar(
|
||||
r#"SELECT v.id FROM videos v, videos me
|
||||
WHERE me.id=$1 AND v.id<>$1 AND v.project_id=me.project_id
|
||||
AND v.gps_status='ok' AND v.gps_start_epoch IS NOT NULL
|
||||
AND v.gps_start_epoch > $2 AND v.gps_start_epoch - $2 <= $3
|
||||
AND (CASE WHEN v.rel_path LIKE '%/%' THEN regexp_replace(v.rel_path,'/[^/]*$','') ELSE '' END)
|
||||
= (CASE WHEN me.rel_path LIKE '%/%' THEN regexp_replace(me.rel_path,'/[^/]*$','') ELSE '' END)
|
||||
ORDER BY v.gps_start_epoch LIMIT 1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(end)
|
||||
.bind(STITCH_MAX_GAP_S)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
if let Some(succ) = succ {
|
||||
auto_correct(&state.db, succ).await;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(e) => mark_error(&state.db, id, &format!("db write failed: {e}")).await,
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE videos SET gps_status='none', gps_error=NULL, gps_scanned_at=now() WHERE id=$1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
tracing::info!("gps: video {id} has no GPS metadata");
|
||||
true
|
||||
}
|
||||
Err(e) => mark_error(&state.db, id, &e).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_error(db: &sqlx::PgPool, id: i32, msg: &str) -> bool {
|
||||
tracing::warn!("gps: video {id} scan failed: {msg}");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE videos SET gps_status='error', gps_error=$2, gps_scanned_at=now() WHERE id=$1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(msg)
|
||||
.execute(db)
|
||||
.await;
|
||||
false
|
||||
}
|
||||
|
||||
// ---- Stage-1 "Viterbi" smoother ----
|
||||
|
||||
/// Speed gate: hops implying more than this are implausible (≈160 km/h).
|
||||
const MAX_SPEED_MPS: f64 = 45.0;
|
||||
/// Cost of dropping (re-interpolating) one sample. An outlier is dropped when
|
||||
/// keeping it costs more than this.
|
||||
const DROP_PENALTY: f64 = 30.0;
|
||||
/// Max consecutive dropped samples + 1 (DP transition window).
|
||||
const VITERBI_WINDOW: usize = 12;
|
||||
|
||||
/// Min-cost keep/drop path over the samples (Viterbi over a keep-lattice):
|
||||
/// transition cost between consecutive KEPT samples grows quadratically with the
|
||||
/// implied speed above `MAX_SPEED_MPS`; every dropped sample costs `DROP_PENALTY`.
|
||||
/// Dropped samples are re-interpolated (by time) between their kept neighbors —
|
||||
/// principled outlier rejection with no road network. Returns the corrected track
|
||||
/// (same length + timestamps as the input) and how many samples were replaced.
|
||||
pub fn viterbi_smooth(track: &[Pt]) -> (Vec<Pt>, usize) {
|
||||
let n = track.len();
|
||||
if n < 3 {
|
||||
return (track.to_vec(), 0);
|
||||
}
|
||||
|
||||
let trans_cost = |a: &Pt, b: &Pt| -> f64 {
|
||||
let dt = (b[2] - a[2]).max(0.5);
|
||||
let v = dist_m(a[0], a[1], b[0], b[1]) / dt;
|
||||
let excess = (v - MAX_SPEED_MPS).max(0.0);
|
||||
(excess / 5.0).powi(2)
|
||||
};
|
||||
|
||||
// cost[i] = min cost of a path whose last kept sample is i.
|
||||
let mut cost = vec![f64::INFINITY; n];
|
||||
let mut prev = vec![usize::MAX; n];
|
||||
for i in 0..n {
|
||||
if i <= VITERBI_WINDOW {
|
||||
// Start the path at i, paying for the dropped leading samples.
|
||||
cost[i] = DROP_PENALTY * i as f64;
|
||||
}
|
||||
for j in i.saturating_sub(VITERBI_WINDOW)..i {
|
||||
if cost[j].is_finite() {
|
||||
let c = cost[j] + DROP_PENALTY * (i - j - 1) as f64 + trans_cost(&track[j], &track[i]);
|
||||
if c < cost[i] {
|
||||
cost[i] = c;
|
||||
prev[i] = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End the path at the sample minimizing total cost incl. dropped trailing ones.
|
||||
let mut end = n - 1;
|
||||
let mut best = f64::INFINITY;
|
||||
for i in n.saturating_sub(VITERBI_WINDOW + 1)..n {
|
||||
let c = cost[i] + DROP_PENALTY * (n - 1 - i) as f64;
|
||||
if c < best {
|
||||
best = c;
|
||||
end = i;
|
||||
}
|
||||
}
|
||||
|
||||
let mut kept = vec![false; n];
|
||||
let mut i = end;
|
||||
loop {
|
||||
kept[i] = true;
|
||||
if prev[i] == usize::MAX {
|
||||
break;
|
||||
}
|
||||
i = prev[i];
|
||||
}
|
||||
|
||||
// Rebuild: kept samples stay; dropped ones are re-interpolated between the
|
||||
// nearest kept neighbors (clamped to the first/last kept at the edges).
|
||||
let kept_idx: Vec<usize> = (0..n).filter(|&k| kept[k]).collect();
|
||||
let mut out = track.to_vec();
|
||||
let mut replaced = 0usize;
|
||||
for k in 0..n {
|
||||
if kept[k] {
|
||||
continue;
|
||||
}
|
||||
let after = kept_idx.partition_point(|&ki| ki < k);
|
||||
let (lo, hi) = match (after.checked_sub(1).map(|a| kept_idx[a]), kept_idx.get(after)) {
|
||||
(Some(lo), Some(&hi)) => (lo, hi),
|
||||
(None, Some(&hi)) => (hi, hi),
|
||||
(Some(lo), None) => (lo, lo),
|
||||
(None, None) => continue, // unreachable: at least one sample is kept
|
||||
};
|
||||
let (a, b) = (&track[lo], &track[hi]);
|
||||
let f = if b[2] > a[2] { ((track[k][2] - a[2]) / (b[2] - a[2])).clamp(0.0, 1.0) } else { 0.0 };
|
||||
out[k] = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, track[k][2]];
|
||||
replaced += 1;
|
||||
}
|
||||
(out, replaced)
|
||||
}
|
||||
|
||||
// ---- auto-correction: spikes + start-of-video stitching ----
|
||||
|
||||
/// Two files are one continuous recording only when the GPS gap between them is at
|
||||
/// most this many seconds.
|
||||
const STITCH_MAX_GAP_S: f64 = 180.0;
|
||||
/// Gaps at or below this are the normal ~1 Hz cadence across a file boundary —
|
||||
/// nothing is missing.
|
||||
const STITCH_MIN_GAP_S: f64 = 2.0;
|
||||
|
||||
/// Decide the time-shift for a missing-start stitch. `gap_s` = seconds between the
|
||||
/// predecessor's last fix and this video's first fix; `span_s` = this track's own
|
||||
/// first→last duration; `duration_s` = the video's real length when known
|
||||
/// (frame_count / fps); `jump_m` = distance from the predecessor's last fix to this
|
||||
/// video's first fix. Returns the seconds this track starts late (its samples are
|
||||
/// shifted by this), or None when nothing should be stitched.
|
||||
fn plan_stitch(gap_s: f64, span_s: f64, duration_s: Option<f64>, jump_m: f64) -> Option<f64> {
|
||||
if gap_s <= STITCH_MIN_GAP_S || gap_s > STITCH_MAX_GAP_S {
|
||||
return None; // healthy rollover, or not a continuous recording
|
||||
}
|
||||
if jump_m / gap_s > MAX_SPEED_MPS {
|
||||
return None; // the vehicle can't have covered the jump — different session
|
||||
}
|
||||
let mut shift = gap_s - 1.0; // one normal sample period isn't missing data
|
||||
if let Some(dur) = duration_s {
|
||||
// The track already covers the whole file ⇒ the gap was the PREDECESSOR's
|
||||
// problem (missing end), not ours — shifting would misalign every frame.
|
||||
let missing = dur - span_s;
|
||||
if missing < STITCH_MIN_GAP_S {
|
||||
return None;
|
||||
}
|
||||
shift = shift.min(missing);
|
||||
}
|
||||
(shift > 0.5).then_some(shift)
|
||||
}
|
||||
|
||||
/// The unified correction result: Viterbi spike removal + (when a temporal
|
||||
/// predecessor exists in the same folder) a missing-start stitch that re-bases the
|
||||
/// clock so t = 0 is the actual video start. Raw is never modified.
|
||||
pub struct CorrectionOutcome {
|
||||
pub raw: Vec<Pt>,
|
||||
pub corrected: Vec<Pt>,
|
||||
pub replaced: usize,
|
||||
pub stitched_from: Option<String>,
|
||||
pub shift_s: f64,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
impl CorrectionOutcome {
|
||||
pub fn changed(&self) -> bool {
|
||||
self.replaced > 0 || self.stitched_from.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute (but do not store) the correction for a video. Errors when the video has
|
||||
/// no usable raw track.
|
||||
pub async fn compute_correction(db: &sqlx::PgPool, video_id: i32) -> Result<CorrectionOutcome, String> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(Option<Value>, Option<f64>, Option<f64>, Option<i32>, Option<f64>, i32, String)> = sqlx::query_as(
|
||||
r#"SELECT gps_track, gps_start_epoch, fps, frame_count, duration_s, project_id, rel_path
|
||||
FROM videos WHERE id=$1 AND gps_status='ok'"#,
|
||||
)
|
||||
.bind(video_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let Some((track, start_epoch, fps, frame_count, duration_s, project_id, rel_path)) = row else {
|
||||
return Err("video has no GPS track".into());
|
||||
};
|
||||
let raw = track.as_ref().map(parse_track).unwrap_or_default();
|
||||
if raw.len() < 2 {
|
||||
return Err("video has no GPS track".into());
|
||||
}
|
||||
|
||||
let (mut corrected, replaced) = viterbi_smooth(&raw);
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
if replaced > 0 {
|
||||
notes.push(format!("{replaced} spike(s) re-interpolated"));
|
||||
}
|
||||
|
||||
// Missing-start stitch: the nearest earlier-ending video in the same folder,
|
||||
// matched by absolute GPS time (UTC on every camera — no timezone games).
|
||||
let mut stitched_from = None;
|
||||
let mut shift_s = 0.0;
|
||||
if let Some(start_epoch) = start_epoch {
|
||||
let folder = match rel_path.rfind('/') {
|
||||
Some(i) => rel_path[..i].to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
let prev: Option<(String, f64, Value)> = sqlx::query_as(
|
||||
r#"SELECT file_name, gps_end_epoch, gps_track FROM videos
|
||||
WHERE project_id=$1 AND id<>$2 AND gps_track IS NOT NULL AND gps_end_epoch IS NOT NULL
|
||||
AND gps_end_epoch < $3 AND $3 - gps_end_epoch <= $4
|
||||
AND (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path,'/[^/]*$','') ELSE '' END) = $5
|
||||
ORDER BY gps_end_epoch DESC LIMIT 1"#,
|
||||
)
|
||||
.bind(project_id)
|
||||
.bind(video_id)
|
||||
.bind(start_epoch)
|
||||
.bind(STITCH_MAX_GAP_S)
|
||||
.bind(&folder)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some((prev_name, prev_end, prev_track)) = prev {
|
||||
// Borrow from the predecessor's RAW track (clean provenance — its own
|
||||
// correction may already contain stitched points).
|
||||
let prev_pts = parse_track(&prev_track);
|
||||
if let Some(anchor) = prev_pts.last() {
|
||||
let gap = start_epoch - prev_end;
|
||||
let span = corrected.last().unwrap()[2] - corrected[0][2];
|
||||
// Container duration (from the scan) first; the annotation-doc
|
||||
// fps/frame_count only as a fallback for pre-upgrade rows.
|
||||
let duration = duration_s.or(match (fps, frame_count) {
|
||||
(Some(f), Some(n)) if f > 0.0 => Some(n as f64 / f),
|
||||
_ => None,
|
||||
});
|
||||
let jump = dist_m(anchor[0], anchor[1], corrected[0][0], corrected[0][1]);
|
||||
if let Some(shift) = plan_stitch(gap, span, duration, jump) {
|
||||
for p in corrected.iter_mut() {
|
||||
p[2] += shift;
|
||||
}
|
||||
corrected.insert(0, [anchor[0], anchor[1], 0.0]);
|
||||
shift_s = shift;
|
||||
notes.push(format!(
|
||||
"start stitched from {prev_name} (first {shift:.1}s of GPS were missing — clock re-based)"
|
||||
));
|
||||
stitched_from = Some(prev_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CorrectionOutcome {
|
||||
raw,
|
||||
corrected,
|
||||
replaced,
|
||||
stitched_from,
|
||||
shift_s,
|
||||
note: notes.join(" · "),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute + store the correction as 'auto' — but never over a human decision:
|
||||
/// only when the video is untouched or its current correction is itself 'auto'.
|
||||
/// A no-op correction clears a stale auto one. Best-effort (sweep path).
|
||||
pub async fn auto_correct(db: &sqlx::PgPool, video_id: i32) {
|
||||
let eligible: Option<bool> = sqlx::query_scalar(
|
||||
"SELECT (gps_corrected_by IS NULL OR gps_corrected_by='auto') FROM videos WHERE id=$1 AND gps_status='ok'",
|
||||
)
|
||||
.bind(video_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if eligible != Some(true) {
|
||||
return;
|
||||
}
|
||||
match compute_correction(db, video_id).await {
|
||||
Ok(o) if o.changed() => {
|
||||
let len = polyline_len_m(&o.corrected);
|
||||
let res = sqlx::query(
|
||||
r#"UPDATE videos SET gps_track_corrected=$2, gps_corrected_by='auto',
|
||||
gps_corrected_at=now(), gps_corrected_note=$3, gps_len_m=$4 WHERE id=$1"#,
|
||||
)
|
||||
.bind(video_id)
|
||||
.bind(track_value(&o.corrected))
|
||||
.bind(&o.note)
|
||||
.bind(len)
|
||||
.execute(db)
|
||||
.await;
|
||||
match res {
|
||||
Ok(_) => tracing::info!("gps: video {video_id} auto-corrected — {}", o.note),
|
||||
Err(e) => tracing::warn!("gps: video {video_id} auto-correct store failed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(o) => {
|
||||
// Nothing to fix (anymore) — drop a stale auto correction if present.
|
||||
let _ = sqlx::query(
|
||||
r#"UPDATE videos SET gps_track_corrected=NULL, gps_corrected_by=NULL,
|
||||
gps_corrected_at=NULL, gps_corrected_note=NULL, gps_len_m=$2
|
||||
WHERE id=$1 AND gps_corrected_by='auto'"#,
|
||||
)
|
||||
.bind(video_id)
|
||||
.bind(polyline_len_m(&o.raw))
|
||||
.execute(db)
|
||||
.await;
|
||||
}
|
||||
Err(e) => tracing::debug!("gps: video {video_id} auto-correct skipped: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- endpoints ----
|
||||
|
||||
/// `POST /api/videos/:id/gps/rescan` — queue a video for a fresh metadata scan
|
||||
/// (admin). The sweep picks it up; any previous error is cleared.
|
||||
pub async fn rescan(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Value> {
|
||||
user.require_admin()?;
|
||||
let updated: Option<i32> =
|
||||
sqlx::query_scalar("UPDATE videos SET gps_status='pending', gps_error=NULL WHERE id=$1 RETURNING id")
|
||||
.bind(id)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if updated.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "video not found".into()));
|
||||
}
|
||||
Ok(Json(json!({ "queued": true })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CorrectRequest {
|
||||
/// 'preview' (compute, don't save) | 'keep' (save corrected) | 'discard' (revert to raw).
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
/// `POST /api/videos/:id/gps/correct {action}` — the keep-or-discard correction flow
|
||||
/// (admin), running the SAME pipeline the scan sweep auto-applies (spike removal +
|
||||
/// missing-start stitch). Preview returns original + corrected tracks so the map can
|
||||
/// overlay them; keep persists (attributed to the admin); discard reverts to raw AND
|
||||
/// tombstones the video so the sweep won't re-auto-correct it (a re-scan resets that).
|
||||
pub async fn correct(
|
||||
State(s): State<AppState>,
|
||||
user: AuthUser,
|
||||
AxPath(id): AxPath<i32>,
|
||||
Json(req): Json<CorrectRequest>,
|
||||
) -> ApiResult<Value> {
|
||||
user.require_admin()?;
|
||||
let exists: Option<String> = sqlx::query_scalar("SELECT gps_status FROM videos WHERE id=$1")
|
||||
.bind(id)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let Some(gps_status) = exists else {
|
||||
return Err((StatusCode::NOT_FOUND, "video not found".into()));
|
||||
};
|
||||
if gps_status != "ok" {
|
||||
return Err((StatusCode::BAD_REQUEST, "video has no GPS track".into()));
|
||||
}
|
||||
|
||||
match req.action.as_str() {
|
||||
"discard" => {
|
||||
let raw_track: Option<Value> = sqlx::query_scalar("SELECT gps_track FROM videos WHERE id=$1")
|
||||
.bind(id)
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let len = raw_track.as_ref().map(|t| polyline_len_m(&parse_track(t))).unwrap_or_default();
|
||||
// Tombstone: gps_corrected_by keeps the admin's name with a NULL track,
|
||||
// so auto_correct (which only touches NULL/'auto') leaves this video raw.
|
||||
sqlx::query(
|
||||
r#"UPDATE videos SET gps_track_corrected=NULL,
|
||||
gps_corrected_by=$2, gps_corrected_at=now(),
|
||||
gps_corrected_note='reverted to raw — auto-correction off (re-scan to reset)',
|
||||
gps_len_m=$3
|
||||
WHERE id=$1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&user.username)
|
||||
.bind(len)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
Ok(Json(json!({ "action": "discard", "len_m": len })))
|
||||
}
|
||||
"preview" | "keep" => {
|
||||
let o = compute_correction(&s.db, id).await.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
|
||||
let len_before = polyline_len_m(&o.raw);
|
||||
let len_after = polyline_len_m(&o.corrected);
|
||||
if req.action == "keep" {
|
||||
sqlx::query(
|
||||
r#"UPDATE videos SET gps_track_corrected=$2, gps_corrected_by=$3,
|
||||
gps_corrected_at=now(), gps_corrected_note=$4, gps_len_m=$5 WHERE id=$1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(track_value(&o.corrected))
|
||||
.bind(&user.username)
|
||||
.bind(&o.note)
|
||||
.bind(len_after)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
crate::worker::log_event(&s.db, id, &user.username, "gps_corrected",
|
||||
json!({ "replaced": o.replaced, "stitched": o.stitched_from.is_some() })).await;
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"action": req.action,
|
||||
"points": o.raw.len(),
|
||||
"replaced": o.replaced,
|
||||
"stitched_from": o.stitched_from,
|
||||
"shift_s": o.shift_s,
|
||||
"note": o.note,
|
||||
"len_before_m": len_before,
|
||||
"len_after_m": len_after,
|
||||
"original": track_value(&o.raw),
|
||||
"corrected": track_value(&o.corrected),
|
||||
})))
|
||||
}
|
||||
_ => Err((StatusCode::BAD_REQUEST, "action must be preview, keep or discard".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/projects/:id/gps/rescan_all` — queue EVERY already-scanned video in the
|
||||
/// project for a fresh scan (admin). Needed once after upgrading: pre-upgrade scans
|
||||
/// have no absolute timestamps, so stitching/data-quality stats only exist after a
|
||||
/// re-scan. The serial sweep works through them in the background.
|
||||
pub async fn rescan_all(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Value> {
|
||||
user.require_admin()?;
|
||||
let n = sqlx::query(
|
||||
"UPDATE videos SET gps_status='pending', gps_error=NULL WHERE project_id=$1 AND gps_status <> 'pending'",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.map_err(err)?
|
||||
.rows_affected();
|
||||
crate::admin::audit(&s.db, &user.username, "gps_rescan_all", json!({ "project_id": id, "queued": n })).await;
|
||||
Ok(Json(json!({ "queued": n })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A straight east-west track at the equator: 1 pt/s, ~11.1 m apart (40 km/h).
|
||||
fn straight(n: usize) -> Vec<Pt> {
|
||||
(0..n).map(|i| [0.0, i as f64 * 0.0001, i as f64]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_removes_a_jump() {
|
||||
let mut t = straight(60);
|
||||
// one wild point 0.01° (~1.1 km) off the line
|
||||
t[30][0] = 0.01;
|
||||
let (fixed, replaced) = viterbi_smooth(&t);
|
||||
assert_eq!(replaced, 1);
|
||||
assert!(fixed[30][0].abs() < 1e-6, "outlier not re-interpolated: {}", fixed[30][0]);
|
||||
// everything else untouched
|
||||
assert_eq!(fixed[29], t[29]);
|
||||
assert_eq!(fixed[31], t[31]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_keeps_a_clean_track() {
|
||||
let t = straight(60);
|
||||
let (fixed, replaced) = viterbi_smooth(&t);
|
||||
assert_eq!(replaced, 0);
|
||||
assert_eq!(fixed, t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_removes_a_burst() {
|
||||
let mut t = straight(80);
|
||||
for k in 40..44 {
|
||||
t[k][0] = 0.02; // 2.2 km off for 4 consecutive samples
|
||||
}
|
||||
let (fixed, replaced) = viterbi_smooth(&t);
|
||||
assert_eq!(replaced, 4);
|
||||
for k in 40..44 {
|
||||
assert!(fixed[k][0].abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_caps_and_keeps_last() {
|
||||
let t = straight(12000);
|
||||
let d = downsample(t.clone(), 5000);
|
||||
assert!(d.len() <= 5001);
|
||||
assert_eq!(d.last(), t.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitch_planning_rules() {
|
||||
// Healthy 1 Hz rollover (1 s gap) → nothing missing.
|
||||
assert_eq!(plan_stitch(1.0, 299.0, Some(300.0), 20.0), None);
|
||||
// 9 s gap, duration unknown → shift 8 s.
|
||||
assert_eq!(plan_stitch(9.0, 291.0, None, 100.0), Some(8.0));
|
||||
// 9 s gap but the track already covers the whole file → the PREDECESSOR was
|
||||
// missing its end; shifting us would misalign every frame.
|
||||
assert_eq!(plan_stitch(9.0, 299.0, Some(300.0), 100.0), None);
|
||||
// Duration caps the shift: 20 s gap but only 5 s missing from this file.
|
||||
assert_eq!(plan_stitch(20.0, 295.0, Some(300.0), 100.0), Some(5.0));
|
||||
// Teleport guard: 5 km jump in 9 s can't be the same vehicle.
|
||||
assert_eq!(plan_stitch(9.0, 291.0, None, 5000.0), None);
|
||||
// Different session: 10 minutes apart.
|
||||
assert_eq!(plan_stitch(600.0, 291.0, None, 100.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgs84_distance_matches_reference_arcs() {
|
||||
// Textbook WGS84 arc lengths: 1° latitude ≈ 110.574 km (equator) /
|
||||
// 111.132 km (45°); 1° longitude at the equator ≈ 111.320 km.
|
||||
assert!((dist_m(0.0, 0.0, 1.0, 0.0) - 110_574.0).abs() < 200.0);
|
||||
assert!((dist_m(0.0, 0.0, 0.0, 1.0) - 111_320.0).abs() < 30.0);
|
||||
assert!((dist_m(44.5, 0.0, 45.5, 0.0) - 111_132.0).abs() < 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_roundtrip() {
|
||||
let t = straight(10);
|
||||
let v = track_value(&t);
|
||||
let back = parse_track(&v);
|
||||
assert_eq!(back.len(), 10);
|
||||
assert!((back[9][1] - t[9][1]).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod admin;
|
||||
mod auth;
|
||||
mod chainage;
|
||||
mod gps;
|
||||
mod ingest;
|
||||
mod models;
|
||||
mod nfs;
|
||||
@@ -41,6 +43,17 @@ const FOLDER_FILTER: &str = " AND (NOT EXISTS (SELECT 1 FROM project_folders pf
|
||||
OR (CASE WHEN rel_path LIKE '%/%' THEN regexp_replace(rel_path, '/[^/]*$', '') ELSE '' END) \
|
||||
IN (SELECT folder FROM project_folders WHERE project_id = $1)) ";
|
||||
|
||||
/// SQL fragment: the derived lifecycle label for the UI badge. References the
|
||||
/// unaliased `videos` table; shared by the video list and the map endpoint.
|
||||
const WORKFLOW_STATUS_SQL: &str = "CASE
|
||||
WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress'
|
||||
WHEN videos.status = 'verified' THEN
|
||||
CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2
|
||||
THEN 're-verified' ELSE 'verified' END
|
||||
WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned'
|
||||
ELSE videos.status
|
||||
END";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
@@ -117,6 +130,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let state = AppState { db, ingest_dir, admin_token, lease_secs, nfs_host, nfs_opts };
|
||||
|
||||
// GPS pipeline: serial background sweep extracting each pending video's track
|
||||
// with exiftool (one at a time — never hammers the NAS).
|
||||
tokio::spawn(gps::sweep(state.clone()));
|
||||
|
||||
// Phase 3: background task that auto-releases expired claims back to the pool.
|
||||
{
|
||||
let db = state.db.clone();
|
||||
@@ -180,6 +197,19 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/api/projects/:id/members/:username", delete(admin::remove_member))
|
||||
.route("/api/projects/:id/timer", post(project_timer))
|
||||
.route("/api/projects/:id/folders", get(list_folders).post(set_folders))
|
||||
// Map view + GPS pipeline + road_type + chainage.
|
||||
.route("/api/projects/:id/map", get(map_data))
|
||||
.route("/api/projects/:id/tracks.kml", get(export_tracks_kml))
|
||||
.route("/api/projects/:id/road_types", get(list_road_types).post(set_road_types))
|
||||
.route("/api/projects/:id/road_type_assign", post(assign_road_type))
|
||||
.route("/api/videos/:id/gps/rescan", post(gps::rescan))
|
||||
.route("/api/projects/:id/gps/rescan_all", post(gps::rescan_all))
|
||||
.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/bulk", post(chainage::add_points_bulk))
|
||||
.route("/api/chainage/points/:id", delete(chainage::delete_point))
|
||||
.route("/api/projects/:id/chainage/recompute", post(chainage::recompute))
|
||||
.route("/api/projects/:id/chainage/quick", post(chainage::quick_assign))
|
||||
// Phase 3 admin (token-gated).
|
||||
.route("/api/admin/storage", get(storage_info))
|
||||
.route("/api/users", get(admin::list_users).post(admin::create_user))
|
||||
@@ -387,6 +417,247 @@ async fn set_folders(
|
||||
Ok(Json(json!({ "folders": req.folders.len() })))
|
||||
}
|
||||
|
||||
/// Max polyline points per video in the map payload. Full-resolution tracks stay in
|
||||
/// the DB for chainage math; the map only needs the shape.
|
||||
const MAP_MAX_PTS: usize = 400;
|
||||
|
||||
/// `GET /api/projects/:id/map` — everything the map needs per video: GPS track
|
||||
/// (corrected preferred), status/workflow labels, road_type + chainage. Videos with
|
||||
/// `gps_status` none/error feed the "videos without metadata" panel. Folder-aware.
|
||||
async fn map_data(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<MapVideo>> {
|
||||
require_project_access(&s, &user, id).await?;
|
||||
let q = format!(
|
||||
r#"SELECT id, file_name, rel_path, status, {WORKFLOW_STATUS_SQL} AS workflow_status,
|
||||
ignored, hand_raised, assigned_to, claimed_by, annotation_count,
|
||||
road_type, gps_status, gps_len_m, gps_error,
|
||||
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m, route_seq,
|
||||
COALESCE((SELECT count(*) FROM video_remarks r WHERE r.video_id = videos.id), 0) AS remark_count,
|
||||
COALESCE(gps_track_corrected, gps_track) AS track,
|
||||
(gps_track_corrected IS NOT NULL) AS corrected,
|
||||
gps_corrected_by, gps_corrected_note, gps_sample_count, gps_max_gap_s
|
||||
FROM videos WHERE project_id=$1 {FOLDER_FILTER} ORDER BY file_name"#
|
||||
);
|
||||
let mut rows = sqlx::query_as::<_, MapVideo>(&q)
|
||||
.bind(id)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
// Downsample tracks for transport — hundreds of videos × 5k points would be tens
|
||||
// of MB; ~400 points per video keeps the payload in the low single-digit MB.
|
||||
for r in rows.iter_mut() {
|
||||
if let Some(t) = r.track.take() {
|
||||
let pts = gps::parse_track(&t);
|
||||
r.track = Some(gps::track_value(&gps::downsample(pts, MAP_MAX_PTS)));
|
||||
}
|
||||
}
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// `GET /api/projects/:id/road_types` — the project's road-type labels + how many
|
||||
/// videos carry each (admin or member).
|
||||
async fn list_road_types(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPath<i32>) -> ApiResult<Vec<RoadTypeRow>> {
|
||||
require_project_access(&s, &user, id).await?;
|
||||
let rows = sqlx::query_as::<_, RoadTypeRow>(
|
||||
r#"SELECT rt.name, COALESCE(c.cnt, 0) AS video_count
|
||||
FROM project_road_types rt
|
||||
LEFT JOIN (SELECT road_type, count(*)::bigint AS cnt
|
||||
FROM videos WHERE project_id=$1 GROUP BY road_type) c
|
||||
ON c.road_type = rt.name
|
||||
WHERE rt.project_id=$1 ORDER BY rt.name"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// `POST /api/projects/:id/road_types {names}` — replace the label set (admin).
|
||||
/// Videos keep whatever label they already carry; removing a name never rewrites videos.
|
||||
async fn set_road_types(
|
||||
State(s): State<AppState>,
|
||||
user: AuthUser,
|
||||
AxPath(id): AxPath<i32>,
|
||||
Json(req): Json<RoadTypesRequest>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
user.require_admin()?;
|
||||
let names: Vec<String> = req
|
||||
.names
|
||||
.iter()
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
let mut tx = s.db.begin().await.map_err(err)?;
|
||||
sqlx::query("DELETE FROM project_road_types WHERE project_id=$1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
for n in &names {
|
||||
sqlx::query("INSERT INTO project_road_types (project_id, name) VALUES ($1,$2) ON CONFLICT DO NOTHING")
|
||||
.bind(id)
|
||||
.bind(n)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
}
|
||||
tx.commit().await.map_err(err)?;
|
||||
admin::audit(&s.db, &user.username, "road_types_updated", json!({ "project_id": id, "count": names.len() })).await;
|
||||
Ok(Json(json!({ "road_types": names.len() })))
|
||||
}
|
||||
|
||||
/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign a
|
||||
/// road type to the selected videos (admin; empty road_type clears it).
|
||||
async fn assign_road_type(
|
||||
State(s): State<AppState>,
|
||||
user: AuthUser,
|
||||
AxPath(id): AxPath<i32>,
|
||||
Json(req): Json<RoadTypeAssignRequest>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
user.require_admin()?;
|
||||
if req.video_ids.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "no videos selected".into()));
|
||||
}
|
||||
let rt = req.road_type.trim().to_string();
|
||||
if !rt.is_empty() {
|
||||
let known: Option<i32> =
|
||||
sqlx::query_scalar("SELECT 1 FROM project_road_types WHERE project_id=$1 AND name=$2")
|
||||
.bind(id)
|
||||
.bind(&rt)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
if known.is_none() {
|
||||
return Err((StatusCode::BAD_REQUEST, format!("'{rt}' is not a road type of this project")));
|
||||
}
|
||||
}
|
||||
let n = sqlx::query("UPDATE videos SET road_type=$3 WHERE project_id=$1 AND id = ANY($2)")
|
||||
.bind(id)
|
||||
.bind(&req.video_ids)
|
||||
.bind(&rt)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.map_err(err)?
|
||||
.rows_affected();
|
||||
admin::audit(&s.db, &user.username, "road_type_assigned",
|
||||
json!({ "project_id": id, "road_type": rt, "count": n })).await;
|
||||
Ok(Json(json!({ "assigned": n, "road_type": rt })))
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// KML line color (aabbggrr) per workflow status — same buckets as the map view.
|
||||
fn kml_status_color(status: &str) -> &'static str {
|
||||
match status {
|
||||
"pending" => "ff5252ff",
|
||||
"annotated" => "ff23a6f5",
|
||||
"assigned" => "ffff8cb4",
|
||||
"in-progress" => "ffffa86e",
|
||||
"verified" => "ff75cf5c",
|
||||
"re-verified" => "ffa8bf2b",
|
||||
_ => "ffae958f", // ignored / unknown → gray
|
||||
}
|
||||
}
|
||||
|
||||
const KML_STATUSES: &[&str] =
|
||||
&["pending", "annotated", "assigned", "in-progress", "verified", "re-verified", "ignored"];
|
||||
|
||||
/// `GET /api/projects/:id/tracks.kml?token=` — every scanned video's track as a KML
|
||||
/// LineString colored by status (+ the calibration points), for Google Earth /
|
||||
/// client hand-off. Auth via `?token=` (file download).
|
||||
async fn export_tracks_kml(
|
||||
State(s): State<AppState>,
|
||||
Query(q): Query<TokenQuery>,
|
||||
AxPath(id): AxPath<i32>,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
|
||||
}
|
||||
let vq = format!(
|
||||
r#"SELECT file_name, {WORKFLOW_STATUS_SQL} AS workflow_status, ignored, road_type,
|
||||
chainage_start_m, chainage_end_m, COALESCE(gps_track_corrected, gps_track) AS track
|
||||
FROM videos WHERE project_id=$1 AND COALESCE(gps_track_corrected, gps_track) IS NOT NULL
|
||||
{FOLDER_FILTER} ORDER BY file_name"#
|
||||
);
|
||||
#[allow(clippy::type_complexity)]
|
||||
let rows: Vec<(String, String, bool, String, Option<f64>, Option<f64>, serde_json::Value)> =
|
||||
sqlx::query_as(&vq).bind(id).fetch_all(&s.db).await.map_err(err)?;
|
||||
let points: Vec<(f64, f64, f64, String, String)> = sqlx::query_as(
|
||||
"SELECT lat, lon, chainage_m, note, road_type FROM chainage_points WHERE project_id=$1 ORDER BY chainage_m",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
|
||||
let mut kml = String::with_capacity(1 << 20);
|
||||
kml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>\n");
|
||||
kml.push_str(&format!("<name>project {id} — video tracks</name>\n"));
|
||||
for st in KML_STATUSES {
|
||||
kml.push_str(&format!(
|
||||
"<Style id=\"st-{st}\"><LineStyle><color>{}</color><width>4</width></LineStyle></Style>\n",
|
||||
kml_status_color(st)
|
||||
));
|
||||
}
|
||||
kml.push_str("<Folder><name>Videos</name>\n");
|
||||
for (file_name, workflow_status, ignored, road_type, ch_s, ch_e, track) in rows {
|
||||
let status = if ignored { "ignored".to_string() } else { workflow_status };
|
||||
let pts = gps::downsample(gps::parse_track(&track), 1000);
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mut desc = format!("status: {status}");
|
||||
if !road_type.is_empty() {
|
||||
desc.push_str(&format!(" · road: {road_type}"));
|
||||
}
|
||||
if let (Some(a), Some(b)) = (ch_s, ch_e) {
|
||||
desc.push_str(&format!(" · chainage {:.3} → {:.3} km", a / 1000.0, b / 1000.0));
|
||||
}
|
||||
kml.push_str(&format!(
|
||||
"<Placemark><name>{}</name><description>{}</description><styleUrl>#st-{}</styleUrl><LineString><tessellate>1</tessellate><coordinates>",
|
||||
xml_escape(&file_name), xml_escape(&desc), status
|
||||
));
|
||||
for p in &pts {
|
||||
kml.push_str(&format!("{},{},0 ", p[1], p[0]));
|
||||
}
|
||||
kml.push_str("</coordinates></LineString></Placemark>\n");
|
||||
}
|
||||
kml.push_str("</Folder>\n<Folder><name>Calibration points</name>\n");
|
||||
for (lat, lon, chainage_m, note, road_type) in points {
|
||||
let name = format!("{:.3} km{}", chainage_m / 1000.0, if road_type.is_empty() { String::new() } else { format!(" ({road_type})") });
|
||||
kml.push_str(&format!(
|
||||
"<Placemark><name>{}</name><description>{}</description><Point><coordinates>{lon},{lat},0</coordinates></Point></Placemark>\n",
|
||||
xml_escape(&name), xml_escape(¬e)
|
||||
));
|
||||
}
|
||||
kml.push_str("</Folder>\n</Document></kml>\n");
|
||||
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "application/vnd.google-earth.kml+xml")
|
||||
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"project_{id}_tracks.kml\""))
|
||||
.body(Body::from(kml))
|
||||
.map_err(err)
|
||||
}
|
||||
|
||||
/// Server-authoritative wire-format metadata: inject/overwrite `road_type` + chainage
|
||||
/// keys at the top level of an export doc. Applied on claim (pull), push (store) and
|
||||
/// both export endpoints, so a worker's old client can never drop them.
|
||||
pub fn stamp_road_meta(
|
||||
doc: &mut serde_json::Value,
|
||||
road_type: &str,
|
||||
chainage: (Option<f64>, Option<f64>, Option<f64>, Option<f64>),
|
||||
) {
|
||||
if let Some(obj) = doc.as_object_mut() {
|
||||
obj.insert("road_type".into(), json!(road_type));
|
||||
obj.insert("chainage_start_m".into(), json!(chainage.0));
|
||||
obj.insert("chainage_end_m".into(), json!(chainage.1));
|
||||
obj.insert("sr_chainage_start_m".into(), json!(chainage.2));
|
||||
obj.insert("sr_chainage_end_m".into(), json!(chainage.3));
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_with_retry(url: &str, attempts: u32) -> anyhow::Result<PgPool> {
|
||||
let mut last = None;
|
||||
for i in 1..=attempts {
|
||||
@@ -538,14 +809,9 @@ async fn list_videos(State(s): State<AppState>, user: AuthUser, AxPath(id): AxPa
|
||||
WHERE video_id = videos.id AND event='push' GROUP BY username) s), '') AS verifiers,
|
||||
assigned_to,
|
||||
-- derived lifecycle label for the UI badge
|
||||
CASE
|
||||
WHEN videos.claimed_by IS NOT NULL AND videos.lease_expires_at > now() THEN 'in-progress'
|
||||
WHEN videos.status = 'verified' THEN
|
||||
CASE WHEN (SELECT count(*) FROM video_events e WHERE e.video_id = videos.id AND e.event='push') >= 2
|
||||
THEN 're-verified' ELSE 'verified' END
|
||||
WHEN videos.assigned_to IS NOT NULL AND videos.assigned_to <> '' THEN 'assigned'
|
||||
ELSE videos.status
|
||||
END AS workflow_status,
|
||||
{WORKFLOW_STATUS_SQL} AS workflow_status,
|
||||
road_type, gps_status, gps_len_m, gps_error, gps_sample_count, gps_max_gap_s, duration_s,
|
||||
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m, route_seq,
|
||||
hand_raised, hand_raised_by, hand_raised_at, ignored, ignored_by,
|
||||
COALESCE((SELECT json_agg(json_build_object('username', r.username, 'body', r.body, 'created_at', r.created_at)
|
||||
ORDER BY r.created_at)
|
||||
@@ -598,19 +864,29 @@ async fn export_video(State(s): State<AppState>, Query(q): Query<TokenQuery>, Ax
|
||||
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
|
||||
}
|
||||
let row: Option<(String, String, Option<serde_json::Value>)> =
|
||||
sqlx::query_as("SELECT file_name, rel_path, raw_json FROM videos WHERE id=$1")
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(String, String, Option<serde_json::Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
|
||||
sqlx::query_as(
|
||||
r#"SELECT file_name, rel_path, raw_json, road_type,
|
||||
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
|
||||
FROM videos WHERE id=$1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let (file_name, rel_path, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
|
||||
let (file_name, rel_path, raw_json, road_type, ch_s, ch_e, sr_s, sr_e) =
|
||||
row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
|
||||
// Embed the video identity inside the doc so the file's name/path travel with it.
|
||||
let doc = ensure_video_block(
|
||||
let mut doc = ensure_video_block(
|
||||
raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })),
|
||||
&file_name,
|
||||
&rel_path,
|
||||
);
|
||||
stamp_road_meta(&mut doc, &road_type, (ch_s, ch_e, sr_s, sr_e));
|
||||
if let Some(geo) = chainage::video_geo(&s.db, id).await {
|
||||
chainage::stamp_annotation_geo(&mut doc, &geo);
|
||||
}
|
||||
let body = serde_json::to_vec_pretty(&doc).map_err(err)?;
|
||||
let stem = file_name.rsplit_once('.').map(|(a, _)| a).unwrap_or(&file_name);
|
||||
let fname = format!("{stem}_annotations.json");
|
||||
@@ -627,26 +903,31 @@ async fn export_project(State(s): State<AppState>, Query(q): Query<TokenQuery>,
|
||||
if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() {
|
||||
return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into()));
|
||||
}
|
||||
let rows: Vec<(i32, String, String, Option<serde_json::Value>)> = sqlx::query_as(
|
||||
"SELECT id, file_name, rel_path, raw_json FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path",
|
||||
#[allow(clippy::type_complexity)]
|
||||
let rows: Vec<(i32, String, String, Option<serde_json::Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> = sqlx::query_as(
|
||||
r#"SELECT id, file_name, rel_path, raw_json, road_type,
|
||||
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
|
||||
FROM videos WHERE project_id=$1 AND raw_json IS NOT NULL ORDER BY rel_path"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let videos: Vec<ExportedVideo> = rows
|
||||
.into_iter()
|
||||
.map(|(vid, fname, rel, raw)| {
|
||||
let mut videos: Vec<ExportedVideo> = Vec::with_capacity(rows.len());
|
||||
for (vid, fname, rel, raw, road_type, ch_s, ch_e, sr_s, sr_e) in rows {
|
||||
// file_name first (struct order) AND embedded in the annotations doc, so the
|
||||
// name is both immediately visible per entry and self-contained with the data.
|
||||
let annotations = ensure_video_block(
|
||||
let mut annotations = ensure_video_block(
|
||||
raw.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })),
|
||||
&fname,
|
||||
&rel,
|
||||
);
|
||||
ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations }
|
||||
})
|
||||
.collect();
|
||||
stamp_road_meta(&mut annotations, &road_type, (ch_s, ch_e, sr_s, sr_e));
|
||||
if let Some(geo) = chainage::video_geo(&s.db, vid).await {
|
||||
chainage::stamp_annotation_geo(&mut annotations, &geo);
|
||||
}
|
||||
videos.push(ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations });
|
||||
}
|
||||
let doc = json!({ "project_id": id, "video_count": videos.len(), "videos": videos });
|
||||
let body = serde_json::to_vec_pretty(&doc).map_err(err)?;
|
||||
Response::builder()
|
||||
|
||||
@@ -148,6 +148,24 @@ pub struct VideoRow {
|
||||
// pending | annotated | assigned | in-progress | verified | re-verified.
|
||||
pub assigned_to: Option<String>,
|
||||
pub workflow_status: String,
|
||||
// Road type label ('' = unassigned) — injected into the export/claim JSON.
|
||||
pub road_type: String,
|
||||
// GPS pipeline: pending | ok | none | error (+ measured track length / last error).
|
||||
pub gps_status: String,
|
||||
pub gps_len_m: Option<f64>,
|
||||
pub gps_error: Option<String>,
|
||||
// Data quality: raw fix count + biggest dropout — the table compares the count
|
||||
// against the video duration (≈1 fix/s) and flags mismatches.
|
||||
pub gps_sample_count: Option<i32>,
|
||||
pub gps_max_gap_s: Option<f64>,
|
||||
// Real container duration from the scan (raw NAS videos have no fps/frame_count).
|
||||
pub duration_s: Option<f64>,
|
||||
// Client-calibrated chainage + the immutable GPS-measured (sr) chainage, meters.
|
||||
pub chainage_start_m: Option<f64>,
|
||||
pub chainage_end_m: Option<f64>,
|
||||
pub sr_chainage_start_m: Option<f64>,
|
||||
pub sr_chainage_end_m: Option<f64>,
|
||||
pub route_seq: Option<i32>,
|
||||
// "Raise hand" signal — anyone may raise (to start a conversation) or lower it.
|
||||
pub hand_raised: bool,
|
||||
pub hand_raised_by: Option<String>,
|
||||
@@ -163,6 +181,61 @@ pub struct VideoRow {
|
||||
pub passes: serde_json::Value,
|
||||
}
|
||||
|
||||
/// One row of `GET /api/projects/:id/map` — a video as the map sees it. `track` is
|
||||
/// the (transport-downsampled) effective polyline: corrected when kept, else raw;
|
||||
/// None until the GPS sweep has scanned the video (or when it has no metadata).
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct MapVideo {
|
||||
pub id: i32,
|
||||
pub file_name: String,
|
||||
pub rel_path: String,
|
||||
pub status: String,
|
||||
pub workflow_status: String,
|
||||
pub ignored: bool,
|
||||
pub hand_raised: bool,
|
||||
pub assigned_to: Option<String>,
|
||||
pub claimed_by: Option<String>,
|
||||
pub annotation_count: i32,
|
||||
pub road_type: String,
|
||||
pub gps_status: String,
|
||||
pub gps_len_m: Option<f64>,
|
||||
pub gps_error: Option<String>,
|
||||
pub chainage_start_m: Option<f64>,
|
||||
pub chainage_end_m: Option<f64>,
|
||||
pub sr_chainage_start_m: Option<f64>,
|
||||
pub sr_chainage_end_m: Option<f64>,
|
||||
pub route_seq: Option<i32>,
|
||||
pub remark_count: i64,
|
||||
pub track: Option<Value>,
|
||||
pub corrected: bool,
|
||||
// Who/what produced the correction ('auto' or a username) + its summary note.
|
||||
pub gps_corrected_by: Option<String>,
|
||||
pub gps_corrected_note: Option<String>,
|
||||
// Data quality from the raw scan: fix count + biggest dropout between fixes.
|
||||
pub gps_sample_count: Option<i32>,
|
||||
pub gps_max_gap_s: Option<f64>,
|
||||
}
|
||||
|
||||
/// `GET /api/projects/:id/road_types` — a label + how many videos carry it.
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct RoadTypeRow {
|
||||
pub name: String,
|
||||
pub video_count: i64,
|
||||
}
|
||||
|
||||
/// `POST /api/projects/:id/road_types {names}` — replace the label set (admin).
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoadTypesRequest {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// `POST /api/projects/:id/road_type_assign {video_ids, road_type}` — bulk-assign.
|
||||
#[derive(Deserialize)]
|
||||
pub struct RoadTypeAssignRequest {
|
||||
pub video_ids: Vec<i32>,
|
||||
pub road_type: String, // '' = clear
|
||||
}
|
||||
|
||||
/// `POST /api/videos/:id/hand {raised}` — raise/lower the hand on a video.
|
||||
#[derive(Deserialize)]
|
||||
pub struct HandRequest {
|
||||
|
||||
@@ -52,14 +52,16 @@ pub async fn claim(
|
||||
user: AuthUser,
|
||||
AxPath(id): AxPath<i32>,
|
||||
) -> ApiResult<ClaimResponse> {
|
||||
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>)> =
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(i32, String, String, Option<i32>, Option<i32>, Option<f64>, Option<i32>, chrono::DateTime<chrono::Utc>, Option<Value>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
UPDATE videos
|
||||
SET claimed_by=$2, claimed_at=now(), lease_expires_at=now() + ($3::int * interval '1 second')
|
||||
WHERE id=$1
|
||||
AND (claimed_by IS NULL OR claimed_by=$2 OR lease_expires_at < now())
|
||||
RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json
|
||||
RETURNING id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json,
|
||||
road_type, chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -69,7 +71,8 @@ pub async fn claim(
|
||||
.await
|
||||
.map_err(err)?;
|
||||
|
||||
let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json)) = row
|
||||
let Some((video_id, file_name, rel_path, width, height, fps, frame_count, lease_expires_at, raw_json,
|
||||
road_type, ch_s, ch_e, sr_s, sr_e)) = row
|
||||
else {
|
||||
// Distinguish "doesn't exist" from "not claimable".
|
||||
let exists: Option<i32> = sqlx::query_scalar("SELECT id FROM videos WHERE id=$1")
|
||||
@@ -88,6 +91,21 @@ pub async fn claim(
|
||||
|
||||
log_event(&s.db, video_id, &user.username, "claim", json!({})).await;
|
||||
|
||||
// Inject the server-authoritative road_type + chainage into the doc the worker
|
||||
// pulls (only when a doc exists — a pending video legitimately has none; push
|
||||
// re-stamps regardless, so the metadata always persists). Each annotation also
|
||||
// gets its frame-exact lat/lon + chainage.
|
||||
let annotations = match raw_json {
|
||||
Some(mut doc) => {
|
||||
crate::stamp_road_meta(&mut doc, &road_type, (ch_s, ch_e, sr_s, sr_e));
|
||||
if let Some(geo) = crate::chainage::video_geo(&s.db, video_id).await {
|
||||
crate::chainage::stamp_annotation_geo(&mut doc, &geo);
|
||||
}
|
||||
doc
|
||||
}
|
||||
None => Value::Null,
|
||||
};
|
||||
|
||||
Ok(Json(ClaimResponse {
|
||||
video_id,
|
||||
file_name,
|
||||
@@ -98,7 +116,7 @@ pub async fn claim(
|
||||
frame_count,
|
||||
lease_expires_at,
|
||||
download_url: format!("/api/videos/{video_id}/download"),
|
||||
annotations: raw_json.unwrap_or(Value::Null),
|
||||
annotations,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -223,13 +241,18 @@ pub async fn push(
|
||||
// lease lapsed (auto-released, or re-claimed by someone else) the worker must
|
||||
// re-claim before pushing — their local work is never lost, it just can't clobber a
|
||||
// claim they no longer hold. Admins may always push.
|
||||
let row: Option<(Option<String>, Option<chrono::DateTime<chrono::Utc>>)> =
|
||||
sqlx::query_as("SELECT claimed_by, lease_expires_at FROM videos WHERE id=$1")
|
||||
#[allow(clippy::type_complexity)]
|
||||
let row: Option<(Option<String>, Option<chrono::DateTime<chrono::Utc>>, String, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
|
||||
sqlx::query_as(
|
||||
r#"SELECT claimed_by, lease_expires_at, road_type,
|
||||
chainage_start_m, chainage_end_m, sr_chainage_start_m, sr_chainage_end_m
|
||||
FROM videos WHERE id=$1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.map_err(err)?;
|
||||
let (claimed_by, lease_expires_at) =
|
||||
let (claimed_by, lease_expires_at, road_type, ch_s, ch_e, sr_s, sr_e) =
|
||||
row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?;
|
||||
if !user.is_admin() {
|
||||
let holds_active_claim = claimed_by.as_deref() == Some(user.username.as_str())
|
||||
@@ -250,6 +273,34 @@ pub async fn push(
|
||||
let doc: ExportDoc = serde_json::from_value(body.clone())
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid export doc: {e}")))?;
|
||||
|
||||
// Adopt the doc's fps/frame_count/resolution BEFORE stamping. A raw (from-scratch)
|
||||
// NAS video has no fps in our DB — but the desktop decoded the real file, so its
|
||||
// fps is authoritative. Without this, per-annotation lat/lon/chainage can't be
|
||||
// computed (frame→time needs fps) and the geo silently wouldn't be written.
|
||||
// COALESCE(doc, existing) so a doc that omits a field never wipes it.
|
||||
if let Some(v) = doc.video.as_ref() {
|
||||
let _ = sqlx::query(
|
||||
r#"UPDATE videos SET fps=COALESCE($2, fps), frame_count=COALESCE($3, frame_count),
|
||||
width=COALESCE($4, width), height=COALESCE($5, height) WHERE id=$1"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(v.fps)
|
||||
.bind(v.frame_count.map(|x| x as i32))
|
||||
.bind(v.width.map(|x| x as i32))
|
||||
.bind(v.height.map(|x| x as i32))
|
||||
.execute(&s.db)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Re-stamp the server-authoritative road_type + chainage onto the stored doc —
|
||||
// whatever the worker's client sent (or dropped), the server's values win. Every
|
||||
// annotation gets its frame-exact lat/lon + chainage too (from the fps just set).
|
||||
let mut body = body;
|
||||
crate::stamp_road_meta(&mut body, &road_type, (ch_s, ch_e, sr_s, sr_e));
|
||||
if let Some(geo) = crate::chainage::video_geo(&s.db, id).await {
|
||||
crate::chainage::stamp_annotation_geo(&mut body, &geo);
|
||||
}
|
||||
|
||||
let ann_count = (doc.fixed_annotations.len() + doc.range_annotations.len()) as i32;
|
||||
|
||||
// Recompute the video-level annotator fields from the pushed doc (same idea as
|
||||
|
||||
Reference in New Issue
Block a user