Files
Centralised_VideoVerificati…/dashboard/src/App.tsx
2026-07-07 22:42:47 +05:30

710 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import {
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;
const IDLE_MS = 10 * 60_000; // auto-logout after 10 minutes of inactivity
type Auth = { username: string; role: string };
export function App() {
// ---- auth gate ----
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) ?? "");
const [auth, setAuth] = useState<Auth | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [expiredReason, setExpiredReason] = useState<string | null>(null);
const isAdmin = auth?.role === "admin";
const signOut = useCallback((reason?: string) => {
setAuth(null);
setToken("");
setAuthToken("");
localStorage.removeItem(TOKEN_KEY);
setExpiredReason(reason ?? null);
}, []);
const onSignedIn = useCallback((t: string, me: Auth) => {
setAuthToken(t);
localStorage.setItem(TOKEN_KEY, t);
setToken(t);
setAuth(me);
setExpiredReason(null);
}, []);
// Restore a persisted session on load (silent whoami — no audit spam).
useEffect(() => {
let cancel = false;
(async () => {
if (token) {
setAuthToken(token);
try {
const me = await api.whoami(token);
if (!cancel) setAuth(me);
} catch {
if (!cancel) { setAuthToken(""); localStorage.removeItem(TOKEN_KEY); }
}
}
if (!cancel) setAuthChecked(true);
})();
return () => { cancel = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Idle auto-logout: any activity resets a 10-minute timer.
useEffect(() => {
if (!auth) return;
let timer = 0;
const reset = () => {
clearTimeout(timer);
timer = window.setTimeout(
() => signOut("Your session expired because of inactivity. Please sign in again."),
IDLE_MS,
);
};
const events = ["mousemove", "mousedown", "keydown", "scroll", "touchstart", "click"];
events.forEach((e) => window.addEventListener(e, reset, { passive: true }));
reset();
return () => { clearTimeout(timer); events.forEach((e) => window.removeEventListener(e, reset)); };
}, [auth, signOut]);
// ---- dashboard data ----
const [view, setView] = useState<"dashboard" | "admin">("dashboard");
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [projectId, setProjectId] = useState<number | null>(null);
const [stats, setStats] = useState<Stats | null>(null);
const [activity, setActivity] = useState<Activity | null>(null);
const [videos, setVideos] = useState<VideoRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Assignment (admin): selected video ids; the dropdown lists the project's members.
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");
useEffect(() => {
if (!isAdmin) { setUsers([]); return; }
api.listUsers().then(setUsers).catch(() => setUsers([]));
}, [isAdmin]);
const toggleSel = (id: number) =>
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
const loadProject = useCallback(async (id: number) => {
try {
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));
}
}, []);
const loadProjects = useCallback(async () => {
try {
const p = await api.projects();
setProjects(p);
setProjectId((cur) => cur ?? (p[0]?.id ?? null));
setError(null);
} catch (e) {
setError(String(e));
}
}, []);
const assignSelected = useCallback(async () => {
if (!isAdmin || selected.size === 0 || projectId == null) return;
setBusy(true);
try {
await api.assign(projectId, [...selected], assignee);
setSelected(new Set());
await loadProject(projectId);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}, [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(() => {
if (!auth || projectId == null) return;
setSelected(new Set()); // clear selection when switching projects
void loadProject(projectId);
const t = setInterval(() => { void loadProject(projectId); void loadProjects(); }, POLL_MS);
return () => clearInterval(t);
}, [auth, projectId, loadProject, loadProjects]);
const timer = useCallback(async (action: "start" | "stop" | "reset") => {
if (projectId == null) return;
if (action === "reset" && !window.confirm("Reset the project timer back to zero?")) return;
try {
await api.setTimer(projectId, action);
await loadProject(projectId);
} catch (e) {
setError(String(e));
}
}, [projectId, loadProject]);
const toggleIgnore = useCallback(async (v: VideoRow) => {
if (!isAdmin || projectId == null) return;
try {
await api.setIgnore(v.id, !v.ignored);
await loadProject(projectId);
} catch (e) {
setError(String(e));
}
}, [isAdmin, projectId, loadProject]);
const reingest = useCallback(async () => {
if (projectId == null) return;
setBusy(true);
try {
await api.ingest(projectId);
await loadProject(projectId);
await loadProjects();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}, [projectId, loadProject, loadProjects]);
if (!authChecked) {
return <div className="signin-screen"><div className="dim">Loading</div></div>;
}
if (!auth) {
return <SignIn onSignedIn={onSignedIn} reason={expiredReason} />;
}
const project = projects.find((p) => p.id === projectId) ?? null;
const ov = stats?.overview;
const groups = groupByClient(projects);
return (
<div className="page">
<header className="topbar">
<div className="brand">Verification Dashboard</div>
<div className="viewtabs">
<button className={view === "dashboard" ? "active" : ""} onClick={() => setView("dashboard")}>Dashboard</button>
<button className={view === "admin" ? "active" : ""} onClick={() => setView("admin")}>Admin</button>
</div>
{view === "dashboard" && (
<>
<select
value={projectId ?? ""}
onChange={(e) => setProjectId(Number(e.target.value))}
disabled={projects.length === 0}
>
{groups.map(([client, ps]) => (
<optgroup key={client} label={client}>
{ps.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</optgroup>
))}
</select>
<button onClick={reingest} disabled={busy || projectId == null}>
{busy ? "Ingesting…" : "Re-ingest"}
</button>
</>
)}
<span className="spacer" />
{view === "dashboard" && project && (
<span className="dim path" title={project.source_path}>
{project.client_name ? `${project.client_name} · ` : ""}{project.source_path}
</span>
)}
<span className="whoami" title={`role: ${auth.role}`}>{auth.username} · {auth.role}</span>
<button className="signout-btn" onClick={() => signOut()}>Sign out</button>
</header>
{error && <div className="error"> {error}</div>}
{view === "admin" && <AdminPanel token={token} role={auth.role} onChanged={loadProjects} />}
{view === "dashboard" && (
<>
{/* Overview */}
{ov && (
<section className="cards">
<Card label="Total videos" value={ov.total} />
<Card label="Pending" value={ov.pending} tone="warn" />
<Card label="Annotated" value={ov.annotated} tone="info" />
<Card label="Verified" value={ov.verified} tone="ok" />
<div className="card progress-card">
<div className="card-label">Verified progress</div>
<div className="bar">
<div className="bar-fill" style={{ width: `${ov.pct_done}%` }} />
</div>
<div className="card-value sm">{ov.pct_done.toFixed(1)}%
{stats?.timeline.eta_ms != null && (
<span className="dim"> · ETA ~{fmtDuration(stats.timeline.eta_ms)}</span>
)}
</div>
</div>
</section>
)}
{/* Project time & estimate (driven by the assigned users) */}
{stats && ov && (
<section className="panel time-panel">
<div className="panel-head">
<h3>Project time &amp; estimate</h3>
{isAdmin && projectId != null && (
<div className="timer-controls">
<span className={`timer-state ${stats.timeline.time_running ? "on" : "off"}`}>
{stats.timeline.time_running ? "● running" : "⏸ stopped"}
</span>
{stats.timeline.time_running
? <button className="btn" onClick={() => timer("stop")}>Stop</button>
: <button className="btn" onClick={() => timer("start")}>Start</button>}
<button className="btn danger" onClick={() => timer("reset")}>Reset</button>
</div>
)}
</div>
<div className="time-grid">
<div className="tcell big">
<span className="dim">Total project time</span>
<b>{fmtDuration(stats.timeline.elapsed_ms)} {stats.timeline.all_verified ? "✓" : ""}</b>
<span className="dim sub">inception {stats.timeline.all_verified ? "all verified" : "now (ongoing)"}</span>
</div>
<div className="tcell">
<span className="dim">Total hours all users</span>
<b>{fmtDuration(stats.timeline.total_spent_ms)}</b>
<span className="dim sub">annotation + verify, summed</span>
</div>
<div className="tcell">
<span className="dim">Assigned users</span>
<b>{stats.timeline.active_workers}</b>
<span className="dim sub">{stats.timeline.active_workers === 0 ? "assign users to estimate" : "drives the estimate"}</span>
</div>
<div className="tcell">
<span className="dim">Avg per video</span>
<b>{fmtDuration(stats.timeline.avg_video_ms)}</b>
<span className="dim sub">over verified videos</span>
</div>
<div className="tcell big">
<span className="dim">Estimated time to finish</span>
<b>{stats.timeline.eta_ms != null ? fmtDuration(stats.timeline.eta_ms) : "—"}</b>
<span className="dim sub">
{ov.total - ov.verified} left ÷ {Math.max(1, stats.timeline.active_workers)} user(s) @ {fmtDuration(stats.timeline.avg_video_ms)}/video
</span>
</div>
</div>
</section>
)}
<div className="grid2">
{/* Leaderboard + project members picker */}
<section className="panel">
<div className="panel-head">
<h3>Per-user leaderboard</h3>
{isAdmin && projectId != null && (
<MembersPicker
projectId={projectId}
users={mlUsers}
members={members}
onChange={() => loadProject(projectId)}
/>
)}
</div>
{isAdmin && members.length === 0 && (
<div className="members-cta">No users assigned to this project yet use <b>+ Assign users</b> to pick ml_support, then assign them videos.</div>
)}
<table>
<thead>
<tr><th>User</th><th>Videos</th><th>Annotations</th><th>Total time</th><th>Avg / video</th></tr>
</thead>
<tbody>
{stats?.leaderboard.map((r) => (
<tr key={r.user}>
<td>{r.user}</td>
<td>{r.videos}</td>
<td>{r.annotations}</td>
<td>{fmtDuration(r.total_time_ms)}</td>
<td>{fmtDuration(r.avg_time_ms)}</td>
</tr>
))}
{(!stats || stats.leaderboard.length === 0) && (
<tr><td colSpan={5} className="dim">no annotators yet</td></tr>
)}
</tbody>
</table>
</section>
{/* Verification pace (rate-based, replaces the old per-day bars) */}
<section className="panel">
<h3>Verification pace</h3>
{stats && ov ? (() => {
const t = stats.timeline;
const perDay = t.recent_window_days > 0 ? t.recent_verified / t.recent_window_days : 0;
const remaining = ov.total - ov.verified;
const paceDays = perDay > 0 ? Math.ceil(remaining / perDay) : null;
return (
<div className="pace-grid">
<div className="pcell">
<span className="dim">Verified / day</span>
<b>{perDay.toFixed(1)}</b>
<span className="dim sub">last {t.recent_window_days} days ({t.recent_verified} done)</span>
</div>
<div className="pcell">
<span className="dim">Avg per video</span>
<b>{fmtDuration(t.avg_video_ms)}</b>
<span className="dim sub">over verified videos</span>
</div>
<div className="pcell big">
<span className="dim">Finish at recent pace</span>
<b>{paceDays != null ? `~${paceDays} day${paceDays === 1 ? "" : "s"}` : "—"}</b>
<span className="dim sub">{perDay > 0 ? `${remaining} left ÷ ${perDay.toFixed(1)}/day (all users' actual output)` : "no recent activity"}</span>
</div>
<div className="pcell big">
<span className="dim">Est. to finish · {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"}</span>
<b>{t.eta_ms != null ? fmtDuration(t.eta_ms) : "—"}</b>
<span className="dim sub">{remaining} left × {fmtDuration(t.avg_video_ms)} ÷ {Math.max(1, t.active_workers)} user{t.active_workers === 1 ? "" : "s"}</span>
</div>
</div>
);
})() : <div className="dim">no data yet</div>}
</section>
</div>
<div className="grid2">
{/* Verification leaderboard — assigned users ranked by annotation throughput
(most annotations in the least time), not by video count. */}
<section className="panel">
<h3>Verification leaderboard <span className="dim">· most annotations / min</span></h3>
<table>
<thead>
<tr><th>Rank</th><th>User</th><th>Annotations</th><th>Anno/min</th><th>Verified</th><th>Total time</th></tr>
</thead>
<tbody>
{stats?.verifiers.map((r) => (
<tr key={r.user} className={r.rank === 1 ? "rank-top" : undefined}>
<td>{r.rank > 0 ? (r.rank === 1 ? "🥇 1" : `#${r.rank}`) : <span className="dim"></span>}</td>
<td>{r.user}</td>
<td>{r.annotations > 0 ? r.annotations : <span className="dim">0</span>}</td>
<td>{r.annotations_per_min > 0 ? r.annotations_per_min.toFixed(1) : <span className="dim"></span>}</td>
<td>{r.videos > 0 ? r.videos : <span className="dim">0</span>}</td>
<td>{r.total_time_ms > 0 ? fmtDuration(r.total_time_ms) : <span className="dim"></span>}</td>
</tr>
))}
{(!stats || stats.verifiers.length === 0) && (
<tr><td colSpan={6} className="dim">no assigned users yet</td></tr>
)}
</tbody>
</table>
</section>
{/* Recent activity (live claims + event feed) */}
<section className="panel">
<h3>Recent activity</h3>
{activity && activity.active_claims.length > 0 && (
<div className="active-claims">
{activity.active_claims.map((c) => (
<div className="active-claim" key={c.video_id} title={`lease expires ${fmtWhen(c.lease_expires_at)}`}>
🔒 <b>{c.claimed_by}</b> has <span className="mono">{c.file_name}</span> <span className="dim">· since {fmtWhen(c.claimed_at)}</span>
</div>
))}
</div>
)}
<div className="activity-feed">
{activity?.recent_events.map((e, i) => (
<div className="activity-row" key={i}>
<span className={`evt evt-${e.event}`}>{e.event}</span>
<span className="mono">{e.file_name}</span>
<span className="dim">{e.username} · {fmtWhen(e.at)}</span>
</div>
))}
{(!activity || activity.recent_events.length === 0) && <div className="dim">no activity yet</div>}
</div>
</section>
</div>
{/* Per-video table / map */}
<section className="panel">
<div className="panel-head">
<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 && 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)">
<option value="">(unassign)</option>
{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>
<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) && (
<a className="btn" href={exportProjectUrl(projectId)} download title="Download all annotations in this project as one JSON">
Export all
</a>
)}
</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>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>
<tbody>
{videos.map((v, idx) => (
<tr key={v.id} className={`${selected.has(v.id) ? "row-sel " : ""}${v.ignored ? "row-ignored" : ""}`}>
{isAdmin && <td><input type="checkbox" checked={selected.has(v.id)} onChange={() => toggleSel(v.id)} /></td>}
<td className="dim">{idx + 1}</td>
<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}`}>{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)}
title={v.ignored ? "Restore this video" : "Ignore this video (cross it out)"}>
{v.ignored ? "undo ignore" : "ignore"}
</button>
)}
{v.claimed_by ? (
<div className="claim-line" title={v.lease_expires_at ? `lease expires ${fmtWhen(v.lease_expires_at)}` : undefined}>
🔒 claimed by <b>{v.claimed_by}</b>{v.claimed_at ? ` · ${fmtWhen(v.claimed_at)}` : ""}
</div>
) : v.workflow_status === "assigned" && v.assigned_to ? (
<div className="claim-line"> assigned to <b>{v.assigned_to}</b></div>
) : v.completed_at ? (
<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
? <a href={exportVideoUrl(v.id)} download title="Download this video's annotations (JSON)">{v.annotation_count} </a>
: 0}
{(v.imported_count > 0 || (v.passes?.length ?? 0) > 0) && (() => {
const deltas = passDeltas(v.imported_count, v.passes ?? []);
return (
<div className="imported-line" title="imported baseline → per-pass changes (verify / re-verify)">
<span className="dim">imported {v.imported_count}</span>
{deltas.length > 0
? deltas.map((d, i) => (
<span key={i} className={d.delta >= 0 ? "ann-add" : "ann-del"}
title={`${d.label} by ${d.username}${d.total} total`}>
{" · "}{d.label} {d.delta >= 0 ? "+" : ""}{d.delta}
</span>
))
: <>
{v.annotation_count < v.imported_count &&
<span className="ann-del" title={`${v.imported_count - v.annotation_count} deleted by the user`}> · {v.imported_count - v.annotation_count}</span>}
{v.annotation_count > v.imported_count &&
<span className="ann-add" title={`${v.annotation_count - v.imported_count} added by the user`}> · +{v.annotation_count - v.imported_count}</span>}
</>}
</div>
);
})()}
</td>
<td>{v.review_count > 0
? <span className="review-flag" title={`${v.review_count} annotation(s) flagged for review`}>🚩 {v.review_count}</span>
: <span className="dim"></span>}</td>
<td title="total across all passes">{fmtDuration(v.verify_time_ms)}</td>
<td className="dim">{v.width && v.height ? `${v.width}×${v.height}` : "—"}</td>
<td className="dim">{v.annotated_at ? v.annotated_at.slice(0, 10) : "—"}</td>
<td className="remark-col">
<RemarkCell
videoId={v.id}
initial={v.remarks}
handRaised={v.hand_raised}
handBy={v.hand_raised_by}
/>
</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>
);
}
/** The subfolder portion of a rel_path (everything before the last '/'), or "" at the root. */
function subfolder(rel: string): string {
const i = rel.lastIndexOf("/");
return i >= 0 ? rel.slice(0, i) : "";
}
/** Group projects under their client name, preserving the server's ordering. */
function groupByClient(projects: ProjectSummary[]): [string, ProjectSummary[]][] {
const order: string[] = [];
const map = new Map<string, ProjectSummary[]>();
for (const p of projects) {
const key = p.client_name || "—";
if (!map.has(key)) { map.set(key, []); order.push(key); }
map.get(key)!.push(p);
}
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 ?? ""}`}>
<div className="card-label">{label}</div>
<div className="card-value">{value}</div>
</div>
);
}