From cf4ae6e1464a9a158911a7cfeef180db7fe8b31a Mon Sep 17 00:00:00 2001 From: ravi Date: Wed, 8 Jul 2026 18:02:01 +0530 Subject: [PATCH] chainage panel update --- README.md | 9 +++- dashboard/src/ChainagePanel.tsx | 42 +++++++++++++++- dashboard/src/api.ts | 8 +++ db/init.sql | 3 ++ server/src/chainage.rs | 87 +++++++++++++++++++++++---------- server/src/main.rs | 1 + 6 files changed, 120 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 04e127f..0ea5357 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,13 @@ Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist the column, so renaming the note is safe) - `GET /api/projects/:id/chainage/calibrations` — the saved calibrations, one per road-type group (`road_type, route_len_m, anchor_count, computed_by, - computed_at`) — every recompute persists one, so any user - sees what's already calibrated (member/admin) + computed_at, has_summary`) — every recompute persists one, + so any user sees what's already calibrated (member/admin) +- `GET /api/projects/:id/chainage/summary?road_type=` — the SAVED full result of the group's last + recompute (route, spans, per-video chainage, warnings), + null if never computed; the dashboard auto-loads it so a + newly signed-in admin sees the existing compute + (member/admin) - `POST /api/projects/:id/chainage/recompute` `{video_ids, road_type}` — order the videos into a route, snap the points, distribute the client-vs-GPS error piecewise-linearly; writes `chainage_*` (client) + diff --git a/dashboard/src/ChainagePanel.tsx b/dashboard/src/ChainagePanel.tsx index f86ed62..ac92454 100644 --- a/dashboard/src/ChainagePanel.tsx +++ b/dashboard/src/ChainagePanel.tsx @@ -105,6 +105,8 @@ export function ChainagePanel({ const [calibs, setCalibs] = useState([]); const [calibsErr, setCalibsErr] = useState(null); const [summary, setSummary] = useState(null); + // true = loaded from the server (someone's earlier compute); false = just run here. + const [summaryIsSaved, setSummaryIsSaved] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // add-point form @@ -144,7 +146,27 @@ export function ChainagePanel({ useEffect(() => { if (open) void loadPoints(); }, [open, loadPoints]); - useEffect(() => setSummary(null), [projectId, group]); + + // On open / group change, show the SAVED result of the last recompute (persisted + // server-side) so a freshly signed-in admin sees the existing compute for LHS/RHS + // instead of an empty panel asking to recompute. + useEffect(() => { + setSummary(null); + setSummaryIsSaved(false); + if (!open) return; + let alive = true; + api.chainageSummary(projectId, group) + .then((s) => { + if (alive && s && typeof s === "object") { + setSummary(s); + setSummaryIsSaved(true); + } + }) + .catch(() => {}); // calibrations block surfaces endpoint failures already + return () => { + alive = false; + }; + }, [projectId, group, open]); /** The route's videos = current group, ignored ones excluded. */ const groupVideos = useMemo( @@ -180,6 +202,7 @@ export function ChainagePanel({ setError(null); try { setSummary(await fn()); + setSummaryIsSaved(false); await loadPoints(); onChanged(); } catch (e) { @@ -429,6 +452,11 @@ export function ChainagePanel({
{c.road_type || "(ungrouped)"} · route {fmtChainage(c.route_len_m)} km · {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)} + {!c.has_summary && ( + + {" "}· result not saved yet — recompute once + + )}
))} @@ -582,9 +610,19 @@ export function ChainagePanel({ - {/* Result summary */} + {/* Result summary — freshly run, or the saved result of the last recompute */} {summary && (
+ {summaryIsSaved && (() => { + const c = calibs.find((x) => x.road_type === group); + return ( +
+ Saved result of the last compute + {c ? <> — by {c.computed_by} · {fmtWhen(c.computed_at)} : null} + {" "}(recompute only if points or videos changed) +
+ ); + })()}
Route {(summary.route_len_m / 1000).toFixed(3)} km (GPS-measured) · {summary.points_used} point(s) used diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 0e9025f..58e44ed 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -194,6 +194,8 @@ export interface CalibrationRow { anchor_count: number; computed_by: string; computed_at: string; + /** False only for pre-summary calibrations — one recompute seeds the saved result. */ + has_summary: boolean; } /** Result of the GPS correction preview/keep — original + corrected tracks + stats. @@ -450,6 +452,12 @@ export const api = { // Saved calibrations per group (member or admin) — proof the chainage is computed. chainageCalibrations: (projectId: number) => get(`/projects/${projectId}/chainage/calibrations`), + // The saved result of the last recompute for a group (null if never computed) — + // any admin who signs in later sees the existing compute without re-running it. + chainageSummary: (projectId: number, road_type: string) => + get( + `/projects/${projectId}/chainage/summary?road_type=${encodeURIComponent(road_type)}`, + ), chainageRecompute: (projectId: number, video_ids: number[], road_type: string) => post(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }), chainageQuick: ( diff --git a/db/init.sql b/db/init.sql index b5013dd..d7a7d15 100644 --- a/db/init.sql +++ b/db/init.sql @@ -295,6 +295,9 @@ CREATE INDEX IF NOT EXISTS idx_chainage_points_project ON chainage_points(projec -- be replaced on the next quick run regardless of the (freely editable) note text. ALTER TABLE chainage_points ADD COLUMN IF NOT EXISTS origin TEXT NOT NULL DEFAULT ''; UPDATE chainage_points SET origin='quick' WHERE origin='' AND note LIKE 'quick:%'; +-- The full result of the last recompute (route/spans/per-video/warnings JSON) so +-- any admin who signs in later sees the existing computed data, not a recompute ask. +ALTER TABLE chainage_calibrations ADD COLUMN IF NOT EXISTS summary JSONB; ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_start_m DOUBLE PRECISION; ALTER TABLE videos ADD COLUMN IF NOT EXISTS chainage_end_m DOUBLE PRECISION; ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION; diff --git a/server/src/chainage.rs b/server/src/chainage.rs index ef020f0..4302fa8 100644 --- a/server/src/chainage.rs +++ b/server/src/chainage.rs @@ -676,6 +676,9 @@ pub struct CalibrationRow { pub anchor_count: i32, pub computed_by: String, pub computed_at: chrono::DateTime, + /// False only for calibrations saved before summaries were stored — one + /// recompute (same points, same result) seeds it. + pub has_summary: bool, } /// `GET /api/projects/:id/chainage/calibrations` — the saved calibrations (one per @@ -690,7 +693,7 @@ pub async fn list_calibrations( crate::require_project_access(&s, &user, id).await?; let rows = sqlx::query_as::<_, CalibrationRow>( r#"SELECT road_type, route_len_m, jsonb_array_length(anchors)::int AS anchor_count, - computed_by, computed_at + computed_by, computed_at, (summary IS NOT NULL) AS has_summary FROM chainage_calibrations WHERE project_id=$1 ORDER BY road_type"#, ) .bind(id) @@ -700,6 +703,34 @@ pub async fn list_calibrations( Ok(Json(rows)) } +#[derive(Deserialize)] +pub struct SummaryQuery { + #[serde(default)] + pub road_type: String, +} + +/// `GET /api/projects/:id/chainage/summary?road_type=` — the SAVED result of the +/// last recompute for a group (route, spans, per-video chainage, warnings), exactly +/// as the admin who ran it saw it. Null when the group was never computed (or was +/// computed before summaries were stored — one recompute seeds it). Member or admin. +pub async fn saved_summary( + State(s): State, + user: AuthUser, + AxPath(id): AxPath, + axum::extract::Query(q): axum::extract::Query, +) -> ApiResult { + crate::require_project_access(&s, &user, id).await?; + let v: Option> = sqlx::query_scalar( + "SELECT summary FROM chainage_calibrations WHERE project_id=$1 AND road_type=$2", + ) + .bind(id) + .bind(q.road_type.trim()) + .fetch_optional(&s.db) + .await + .map_err(err)?; + Ok(Json(v.flatten().unwrap_or(Value::Null))) +} + /// `DELETE /api/chainage/points/:id` — remove a calibration point (admin). pub async fn delete_point( State(s): State, @@ -1022,30 +1053,7 @@ async fn run_recompute( .map_err(err)?; warnings.push(format!("{name} has no GPS track — excluded from the route, chainage cleared")); } - // Persist the calibration itself so chainage(s) can be evaluated later at ANY - // route position (frame-exact per-annotation stamping, exports). - sqlx::query( - r#"INSERT INTO chainage_calibrations (project_id, road_type, anchors, route_len_m, computed_by) - VALUES ($1,$2,$3,$4,$5) - ON CONFLICT (project_id, road_type) DO UPDATE SET - anchors=EXCLUDED.anchors, route_len_m=EXCLUDED.route_len_m, - computed_by=EXCLUDED.computed_by, computed_at=now()"#, - ) - .bind(project_id) - .bind(road_type) - .bind(calib.to_value()) - .bind(route.len_m()) - .bind(&user.username) - .execute(&mut *tx) - .await - .map_err(err)?; - tx.commit().await.map_err(err)?; - - crate::admin::audit(&s.db, &user.username, "chainage_recomputed", - json!({ "project_id": project_id, "road_type": road_type, - "videos": out_videos.len(), "points_used": calib.anchors.len() })).await; - - Ok(CalibrationSummary { + let summary = CalibrationSummary { route_len_m: route.len_m(), reversed, majority_flipped, @@ -1055,7 +1063,34 @@ async fn run_recompute( warnings, videos: out_videos, skipped: skipped.into_iter().map(|(_, n)| n).collect(), - }) + }; + + // Persist the calibration itself so chainage(s) can be evaluated later at ANY + // route position (frame-exact per-annotation stamping, exports) — plus the full + // result summary, so every admin sees the existing compute, not a recompute ask. + sqlx::query( + r#"INSERT INTO chainage_calibrations (project_id, road_type, anchors, route_len_m, computed_by, summary) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (project_id, road_type) DO UPDATE SET + anchors=EXCLUDED.anchors, route_len_m=EXCLUDED.route_len_m, + computed_by=EXCLUDED.computed_by, computed_at=now(), summary=EXCLUDED.summary"#, + ) + .bind(project_id) + .bind(road_type) + .bind(calib.to_value()) + .bind(route.len_m()) + .bind(&user.username) + .bind(serde_json::to_value(&summary).unwrap_or(Value::Null)) + .execute(&mut *tx) + .await + .map_err(err)?; + tx.commit().await.map_err(err)?; + + crate::admin::audit(&s.db, &user.username, "chainage_recomputed", + json!({ "project_id": project_id, "road_type": road_type, + "videos": summary.videos.len(), "points_used": calib.anchors.len() })).await; + + Ok(summary) } #[cfg(test)] diff --git a/server/src/main.rs b/server/src/main.rs index 53621cb..82b5382 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -211,6 +211,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/projects/:id/chainage/recompute", post(chainage::recompute)) .route("/api/projects/:id/chainage/quick", post(chainage::quick_assign)) .route("/api/projects/:id/chainage/calibrations", get(chainage::list_calibrations)) + .route("/api/projects/:id/chainage/summary", get(chainage::saved_summary)) // Phase 3 admin (token-gated). .route("/api/admin/storage", get(storage_info)) .route("/api/users", get(admin::list_users).post(admin::create_user))