map and chainage calculation

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

View File

@@ -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 ?? ""}`}>

View File

@@ -0,0 +1,549 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { unzipSync } from "fflate";
import {
api, fmtChainage, fmtChainageRange,
type CalibrationSummary, type ChainagePoint, type RoadType, type VideoRow,
} from "./api";
/** The import format clients should follow: one Point placemark per survey point,
* chainage in the name ("12+400" = 12 km + 400 m, or decimal km). */
const SAMPLE_KML = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<name>chainage points — sample</name>
<!-- One Placemark per survey point.
name = the chainage at that point: "12+400" (12 km + 400 m) or "12.4" (km)
coordinates = lon,lat[,alt] (KML order: longitude first!)
The dashboard reads the name (or description) and the Point coordinates.
LineStrings/paths are ignored — only Point placemarks import. -->
<Placemark><name>0+000</name><Point><coordinates>78.185718,21.105570,0</coordinates></Point></Placemark>
<Placemark><name>5+000</name><Point><coordinates>78.158000,21.092045,0</coordinates></Point></Placemark>
<Placemark><name>10+000</name><Point><coordinates>78.123885,21.091447,0</coordinates></Point></Placemark>
</Document></kml>
`;
function downloadSampleKml() {
const url = URL.createObjectURL(new Blob([SAMPLE_KML], { type: "application/vnd.google-earth.kml+xml" }));
const a = document.createElement("a");
a.href = url;
a.download = "chainage_points_sample.kml";
a.click();
URL.revokeObjectURL(url);
}
/** One placemark parsed out of an uploaded KML, editable before import. */
interface KmlRow {
name: string;
lat: number;
lon: number;
/** Editable chainage in km (prefilled from the placemark name/description). */
km: string;
include: boolean;
}
/** Chainage from free text: road notation "12+400" (km+m) always wins; otherwise the
* first number, interpreted in `unit`. Returns meters, or null when nothing parses. */
function parseChainageText(text: string, unit: "km" | "m"): number | null {
const plus = text.match(/(\d+)\s*\+\s*(\d+(?:\.\d+)?)/);
if (plus) return parseInt(plus[1], 10) * 1000 + parseFloat(plus[2]);
const num = text.match(/-?\d+(?:[.,]\d+)?/);
if (!num) return null;
const v = parseFloat(num[0].replace(",", "."));
if (isNaN(v)) return null;
return unit === "km" ? v * 1000 : v;
}
/** Extract Point placemarks (name + lat/lon) from a KML document. LineStrings and
* multi-coordinate placemarks are skipped (counted) — only survey points import. */
function parseKmlPoints(text: string): { rows: { name: string; lat: number; lon: number }[]; skipped: number } {
const doc = new DOMParser().parseFromString(text, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new Error("not a valid KML/XML file (KMZ must be unzipped first)");
}
const rows: { name: string; lat: number; lon: number }[] = [];
let skipped = 0;
for (const pm of Array.from(doc.getElementsByTagNameNS("*", "Placemark"))) {
const coords = pm.getElementsByTagNameNS("*", "coordinates")[0]?.textContent?.trim() ?? "";
const tuples = coords.split(/\s+/).filter(Boolean);
if (tuples.length !== 1) {
skipped += 1; // a LineString/Polygon (or empty) — not a survey point
continue;
}
const [lon, lat] = tuples[0].split(",").map(parseFloat);
if (isNaN(lat) || isNaN(lon)) {
skipped += 1;
continue;
}
const name = pm.getElementsByTagNameNS("*", "name")[0]?.textContent?.trim()
|| pm.getElementsByTagNameNS("*", "description")[0]?.textContent?.trim()
|| "";
rows.push({ name, lat, lon });
}
return { rows, skipped };
}
/**
* Admin chainage panel: manage road-type labels, enter the client's calibration
* points (lat/lon ↔ chainage), run the two-point quick assign ("first video starts
* at X, last ends at Y") or a full recompute over the current group, and read the
* resulting per-span scales + per-video chainage. `sr` (GPS-measured) chainage is
* shown but never editable — only the client-calibrated values come from points.
*/
export function ChainagePanel({
projectId,
videos,
roadTypes,
onChanged,
}: {
projectId: number;
videos: VideoRow[];
roadTypes: RoadType[];
onChanged: () => void;
}) {
const [open, setOpen] = useState(false);
const [group, setGroup] = useState(""); // road_type calibration group ('' = all videos)
const [points, setPoints] = useState<ChainagePoint[]>([]);
const [summary, setSummary] = useState<CalibrationSummary | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// add-point form
const [latlon, setLatlon] = useState("");
const [pointKm, setPointKm] = useState("");
const [pointNote, setPointNote] = useState("");
// quick two-point form
const [startKm, setStartKm] = useState("0");
const [endKm, setEndKm] = useState("");
// road-type editor
const [newType, setNewType] = useState("");
// KML import preview
const [kmlRows, setKmlRows] = useState<KmlRow[] | null>(null);
const [kmlUnit, setKmlUnit] = useState<"km" | "m">("km");
const [kmlSkipped, setKmlSkipped] = useState(0);
const kmlFileRef = useRef<HTMLInputElement>(null);
const loadPoints = useCallback(async () => {
try {
setPoints(await api.chainagePoints(projectId));
} catch (e) {
setError(String(e));
}
}, [projectId]);
useEffect(() => {
if (open) void loadPoints();
}, [open, loadPoints]);
useEffect(() => setSummary(null), [projectId, group]);
/** The route's videos = current group, ignored ones excluded. */
const groupVideos = useMemo(
() => videos.filter((v) => !v.ignored && (group === "" || v.road_type === group)),
[videos, group],
);
const groupPoints = useMemo(
() => points.filter((p) => p.road_type === group || p.road_type === ""),
[points, group],
);
const run = async (fn: () => Promise<CalibrationSummary>) => {
setBusy(true);
setError(null);
try {
setSummary(await fn());
await loadPoints();
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const addPoint = async () => {
const m = latlon.trim().match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*$/);
const km = parseFloat(pointKm);
if (!m || isNaN(km)) {
setError('Point needs "lat, lon" and a chainage in km.');
return;
}
setBusy(true);
setError(null);
try {
await api.addChainagePoint(projectId, {
lat: parseFloat(m[1]),
lon: parseFloat(m[2]),
chainage_m: km * 1000,
note: pointNote.trim(),
road_type: group,
});
setLatlon("");
setPointKm("");
setPointNote("");
await loadPoints();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const deletePoint = async (id: number) => {
setBusy(true);
try {
await api.deleteChainagePoint(id);
await loadPoints();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const quick = () => {
const s = parseFloat(startKm);
const e = parseFloat(endKm);
if (isNaN(s) || isNaN(e)) {
setError("Quick assign needs start and end chainage in km.");
return;
}
void run(() =>
api.chainageQuick(projectId, {
video_ids: groupVideos.map((v) => v.id),
start_m: s * 1000,
end_m: e * 1000,
road_type: group,
}),
);
};
const recompute = () =>
run(() => api.chainageRecompute(projectId, groupVideos.map((v) => v.id), group));
const addRoadType = async () => {
const name = newType.trim();
if (!name) return;
setBusy(true);
setError(null);
try {
await api.setRoadTypes(projectId, [...roadTypes.map((r) => r.name), name]);
setNewType("");
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const onKmlFile = async (file: File) => {
setError(null);
try {
let text: string;
if (/\.kmz$/i.test(file.name)) {
// KMZ = zipped KML; take the first .kml entry (usually doc.kml).
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
const key = Object.keys(entries).find((k) => k.toLowerCase() === "doc.kml")
?? Object.keys(entries).find((k) => /\.kml$/i.test(k));
if (!key) {
setError(`${file.name} contains no .kml document.`);
return;
}
text = new TextDecoder().decode(entries[key]);
} else {
text = await file.text();
}
const { rows, skipped } = parseKmlPoints(text);
if (rows.length === 0) {
setError(`No point placemarks found in ${file.name}${skipped ? ` (${skipped} non-point entries skipped)` : ""}.`);
return;
}
setKmlSkipped(skipped);
setKmlRows(rows.map((r) => {
const m = parseChainageText(r.name, kmlUnit);
return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null };
}));
} catch (e) {
setError(String(e));
} finally {
if (kmlFileRef.current) kmlFileRef.current.value = ""; // allow re-picking the same file
}
};
/** Re-read every row's chainage from its placemark name under the new unit. */
const setKmlUnitAndReparse = (u: "km" | "m") => {
setKmlUnit(u);
setKmlRows((rows) => rows?.map((r) => {
const m = parseChainageText(r.name, u);
return { ...r, km: m != null ? (m / 1000).toFixed(3) : "", include: m != null };
}) ?? null);
};
const importKml = async () => {
if (!kmlRows) return;
const points = kmlRows
.filter((r) => r.include && r.km.trim() !== "" && !isNaN(parseFloat(r.km)))
.map((r) => ({
lat: r.lat,
lon: r.lon,
chainage_m: parseFloat(r.km) * 1000,
note: r.name,
road_type: group,
}));
if (points.length === 0) {
setError("No rows selected with a valid chainage.");
return;
}
setBusy(true);
setError(null);
try {
await api.addChainagePointsBulk(projectId, points);
setKmlRows(null);
await loadPoints();
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const removeRoadType = async (name: string) => {
setBusy(true);
setError(null);
try {
await api.setRoadTypes(projectId, roadTypes.map((r) => r.name).filter((n) => n !== name));
if (group === name) setGroup("");
onChanged();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
if (!open) {
return (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; road types</h3>
<button className="btn" onClick={() => setOpen(true)}>Open </button>
</div>
</section>
);
}
return (
<section className="panel">
<div className="panel-head">
<h3>Chainage &amp; road types</h3>
<button className="btn" onClick={() => setOpen(false)}>Close </button>
</div>
{error && <div className="error"> {error}</div>}
{/* Road-type labels (project-wide) */}
<div className="chainage-block">
<div className="dim block-label">Road types (assign them to selected videos in the table below)</div>
<div className="member-chips">
{roadTypes.map((r) => (
<span key={r.name} className="member-chip">
{r.name} <span className="dim">({r.video_count})</span>
<button className="chip-x" disabled={busy} onClick={() => removeRoadType(r.name)} title="Remove label">×</button>
</span>
))}
<input
className="chip-input"
value={newType}
onChange={(e) => setNewType(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void addRoadType(); }}
placeholder="Add: LHS / RHS / MCW…"
/>
<button className="btn" disabled={busy || !newType.trim()} onClick={addRoadType}>Add</button>
</div>
</div>
{/* Calibration group + the videos it covers */}
<div className="chainage-block form-row">
<label className="dim">Calibration group</label>
<select value={group} onChange={(e) => setGroup(e.target.value)}>
<option value="">(all videos)</option>
{roadTypes.map((r) => (
<option key={r.name} value={r.name}>{r.name}</option>
))}
</select>
<span className="dim">
{groupVideos.length} video(s) in the route · {groupPoints.length} calibration point(s)
</span>
</div>
<div className="grid2">
{/* Control points */}
<div className="chainage-block">
<div className="dim block-label">Client calibration points (lat/lon chainage)</div>
<table>
<thead>
<tr><th>Chainage</th><th>Lat, Lon</th><th>Group</th><th>Note</th><th></th></tr>
</thead>
<tbody>
{groupPoints.map((p) => (
<tr key={p.id}>
<td>{fmtChainage(p.chainage_m)}</td>
<td className="mono">{p.lat.toFixed(6)}, {p.lon.toFixed(6)}</td>
<td className="dim">{p.road_type || "any"}</td>
<td className="dim">{p.note || "—"}</td>
<td><button className="chip-x" disabled={busy} onClick={() => deletePoint(p.id)} title="Delete point">×</button></td>
</tr>
))}
{groupPoints.length === 0 && (
<tr><td colSpan={5} className="dim">no points yet add them, or use the quick assign</td></tr>
)}
</tbody>
</table>
<div className="form-row">
<input value={latlon} onChange={(e) => setLatlon(e.target.value)} placeholder="lat, lon" style={{ width: 190 }} />
<input value={pointKm} onChange={(e) => setPointKm(e.target.value)} placeholder="chainage (km)" style={{ width: 110 }} />
<input value={pointNote} onChange={(e) => setPointNote(e.target.value)} placeholder="note (optional)" style={{ width: 140 }} />
<button className="btn" disabled={busy} onClick={addPoint}>Add point</button>
<input
ref={kmlFileRef}
type="file"
accept=".kml,.kmz,.xml"
style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onKmlFile(f); }}
/>
<button className="btn" disabled={busy} onClick={() => kmlFileRef.current?.click()}
title="Upload a KML/KMZ of survey points — chainage read from each placemark's name (12+400 or a number)">
Import KML/KMZ
</button>
<button className="btn" onClick={downloadSampleKml}
title="Download the template your client's points file should follow">
Sample KML
</button>
</div>
{kmlRows && (
<div className="kml-preview">
<div className="form-row">
<b>{kmlRows.filter((r) => r.include).length}</b>
<span className="dim">of {kmlRows.length} points will import into group "{group || "any"}"
{kmlSkipped > 0 && ` · ${kmlSkipped} non-point placemark(s) skipped`}</span>
<span className="dim">· values in names are</span>
<select value={kmlUnit} onChange={(e) => setKmlUnitAndReparse(e.target.value as "km" | "m")}>
<option value="km">km</option>
<option value="m">meters</option>
</select>
<span className="dim">("12+400" always = 12 km + 400 m)</span>
</div>
<div className="kml-preview-list">
<table>
<thead>
<tr><th></th><th>Placemark</th><th>Lat, Lon</th><th>Chainage (km)</th></tr>
</thead>
<tbody>
{kmlRows.map((r, i) => (
<tr key={i} className={r.include ? undefined : "row-ignored"}>
<td><input type="checkbox" checked={r.include}
onChange={() => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, include: !x.include } : x))} /></td>
<td className="mono">{r.name || <span className="dim">(unnamed)</span>}</td>
<td className="mono dim">{r.lat.toFixed(6)}, {r.lon.toFixed(6)}</td>
<td><input value={r.km} style={{ width: 90 }} placeholder="?"
onChange={(e) => setKmlRows((rows) => rows!.map((x, k) => k === i ? { ...x, km: e.target.value, include: true } : x))} /></td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-row">
<button className="btn primary" disabled={busy} onClick={importKml}>
Import {kmlRows.filter((r) => r.include).length} point(s)
</button>
<button className="btn" disabled={busy} onClick={() => setKmlRows(null)}>Cancel</button>
</div>
</div>
)}
</div>
{/* Quick assign + recompute */}
<div className="chainage-block">
<div className="dim block-label">
Two-point quick assign client gives just first &amp; last chainage; the error is
distributed over the whole route
</div>
<div className="form-row">
<label className="dim">Route starts at</label>
<input value={startKm} onChange={(e) => setStartKm(e.target.value)} style={{ width: 90 }} /> km
<label className="dim">ends at</label>
<input value={endKm} onChange={(e) => setEndKm(e.target.value)} placeholder="49.5" style={{ width: 90 }} /> km
<button className="btn primary" disabled={busy || groupVideos.length === 0} onClick={quick}>
Assign &amp; compute
</button>
</div>
<div className="form-row" style={{ marginTop: 10 }}>
<button className="btn" disabled={busy || groupVideos.length === 0} onClick={recompute}
title="Re-run the calibration with the points above">
Recompute from points
</button>
<span className="dim">orders the {groupVideos.length} video(s) into a route, snaps the points, distributes the error</span>
</div>
</div>
</div>
{/* Result summary */}
{summary && (
<div className="chainage-block">
<div className="block-label">
Route <b>{(summary.route_len_m / 1000).toFixed(3)} km</b> (GPS-measured)
· {summary.points_used} point(s) used
{summary.points_excluded > 0 && <span className="err-tag"> · {summary.points_excluded} excluded</span>}
{summary.reversed && <span className="dim"> · direction auto-reversed</span>}
</div>
{summary.warnings.length > 0 && (
<ul className="chainage-warnings">
{summary.warnings.map((w, i) => <li key={i}> {w}</li>)}
</ul>
)}
{summary.spans.length > 0 && (
<table>
<thead>
<tr><th>Span (client)</th><th>GPS length</th><th>Client length</th><th>Scale</th></tr>
</thead>
<tbody>
{summary.spans.map((sp, i) => (
<tr key={i} className={Math.abs(sp.scale - 1) > 0.05 ? "span-warn" : undefined}>
<td>{fmtChainage(sp.from_chainage_m)} {fmtChainage(sp.to_chainage_m)}</td>
<td>{(sp.gps_len_m / 1000).toFixed(3)} km</td>
<td>{(sp.client_len_m / 1000).toFixed(3)} km</td>
<td>×{sp.scale.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
)}
<table style={{ marginTop: 8 }}>
<thead>
<tr><th>#</th><th>Video</th><th>Chainage (client)</th><th>SR chainage (GPS)</th><th></th></tr>
</thead>
<tbody>
{summary.videos.map((v) => (
<tr key={v.id}>
<td className="dim">{v.seq + 1}</td>
<td className="mono">{v.file_name}</td>
<td>{fmtChainageRange(v.chainage_start_m, v.chainage_end_m)}</td>
<td className="dim">{fmtChainageRange(v.sr_start_m, v.sr_end_m)}</td>
<td className="dim">{v.flipped && (
<span title="This footage was filmed driving toward DECREASING chainage (e.g. the return carriageway), so frame 0 sits at the higher value — chainage runs high → low through the video. Handled automatically everywhere (per-annotation chainage included). If about half a group is flipped, you probably have both directions mixed in one group — split them by road type.">
flipped
</span>
)}</td>
</tr>
))}
</tbody>
</table>
{summary.skipped.length > 0 && (
<div className="dim" style={{ marginTop: 6 }}>
Skipped (no GPS): {summary.skipped.join(", ")}
</div>
)}
</div>
)}
</section>
);
}

692
dashboard/src/MapView.tsx Normal file
View 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: '&copy; <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) : "";
}

View File

@@ -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. */

View File

@@ -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);