map and chainage calculation
This commit is contained in:
@@ -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 ?? ""}`}>
|
||||
|
||||
Reference in New Issue
Block a user