chainage panel update

This commit is contained in:
2026-07-08 18:02:01 +05:30
parent 8238bcf981
commit cf4ae6e146
6 changed files with 120 additions and 30 deletions

View File

@@ -212,8 +212,13 @@ Distances are WGS84-ellipsoidal (no spherical bias); calibration anchors persist
the column, so renaming the note is safe) the column, so renaming the note is safe)
- `GET /api/projects/:id/chainage/calibrations` — the saved calibrations, one per road-type group - `GET /api/projects/:id/chainage/calibrations` — the saved calibrations, one per road-type group
(`road_type, route_len_m, anchor_count, computed_by, (`road_type, route_len_m, anchor_count, computed_by,
computed_at`) — every recompute persists one, so any user computed_at, has_summary`) — every recompute persists one,
sees what's already calibrated (member/admin) 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 - `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 route, snap the points, distribute the client-vs-GPS error
piecewise-linearly; writes `chainage_*` (client) + piecewise-linearly; writes `chainage_*` (client) +

View File

@@ -105,6 +105,8 @@ export function ChainagePanel({
const [calibs, setCalibs] = useState<CalibrationRow[]>([]); const [calibs, setCalibs] = useState<CalibrationRow[]>([]);
const [calibsErr, setCalibsErr] = useState<string | null>(null); const [calibsErr, setCalibsErr] = useState<string | null>(null);
const [summary, setSummary] = useState<CalibrationSummary | null>(null); const [summary, setSummary] = useState<CalibrationSummary | null>(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 [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// add-point form // add-point form
@@ -144,7 +146,27 @@ export function ChainagePanel({
useEffect(() => { useEffect(() => {
if (open) void loadPoints(); if (open) void loadPoints();
}, [open, 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. */ /** The route's videos = current group, ignored ones excluded. */
const groupVideos = useMemo( const groupVideos = useMemo(
@@ -180,6 +202,7 @@ export function ChainagePanel({
setError(null); setError(null);
try { try {
setSummary(await fn()); setSummary(await fn());
setSummaryIsSaved(false);
await loadPoints(); await loadPoints();
onChanged(); onChanged();
} catch (e) { } catch (e) {
@@ -429,6 +452,11 @@ export function ChainagePanel({
<div key={c.road_type || "(any)"} className="dim" style={{ fontSize: 12 }}> <div key={c.road_type || "(any)"} className="dim" style={{ fontSize: 12 }}>
<b>{c.road_type || "(ungrouped)"}</b> · route {fmtChainage(c.route_len_m)} km <b>{c.road_type || "(ungrouped)"}</b> · route {fmtChainage(c.route_len_m)} km
· {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)} · {c.anchor_count} anchor(s) · computed by {c.computed_by} · {fmtWhen(c.computed_at)}
{!c.has_summary && (
<span className="err-tag" title="This calibration predates saved results — select its group and hit 'Recompute from points' once (same points ⇒ same values); after that every admin sees the full result here.">
{" "}· result not saved yet recompute once
</span>
)}
</div> </div>
))} ))}
</div> </div>
@@ -582,9 +610,19 @@ export function ChainagePanel({
</div> </div>
</div> </div>
{/* Result summary */} {/* Result summary — freshly run, or the saved result of the last recompute */}
{summary && ( {summary && (
<div className="chainage-block"> <div className="chainage-block">
{summaryIsSaved && (() => {
const c = calibs.find((x) => x.road_type === group);
return (
<div className="dim" style={{ fontSize: 12, marginBottom: 4 }}>
Saved result of the last compute
{c ? <> by <b>{c.computed_by}</b> · {fmtWhen(c.computed_at)}</> : null}
{" "}(recompute only if points or videos changed)
</div>
);
})()}
<div className="block-label"> <div className="block-label">
Route <b>{(summary.route_len_m / 1000).toFixed(3)} km</b> (GPS-measured) Route <b>{(summary.route_len_m / 1000).toFixed(3)} km</b> (GPS-measured)
· {summary.points_used} point(s) used · {summary.points_used} point(s) used

View File

@@ -194,6 +194,8 @@ export interface CalibrationRow {
anchor_count: number; anchor_count: number;
computed_by: string; computed_by: string;
computed_at: 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. /** 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. // Saved calibrations per group (member or admin) — proof the chainage is computed.
chainageCalibrations: (projectId: number) => chainageCalibrations: (projectId: number) =>
get<CalibrationRow[]>(`/projects/${projectId}/chainage/calibrations`), get<CalibrationRow[]>(`/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<CalibrationSummary | null>(
`/projects/${projectId}/chainage/summary?road_type=${encodeURIComponent(road_type)}`,
),
chainageRecompute: (projectId: number, video_ids: number[], road_type: string) => chainageRecompute: (projectId: number, video_ids: number[], road_type: string) =>
post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }), post<CalibrationSummary>(`/projects/${projectId}/chainage/recompute`, { video_ids, road_type }),
chainageQuick: ( chainageQuick: (

View File

@@ -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. -- 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 ''; 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:%'; 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_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 chainage_end_m DOUBLE PRECISION;
ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION; ALTER TABLE videos ADD COLUMN IF NOT EXISTS sr_chainage_start_m DOUBLE PRECISION;

View File

@@ -676,6 +676,9 @@ pub struct CalibrationRow {
pub anchor_count: i32, pub anchor_count: i32,
pub computed_by: String, pub computed_by: String,
pub computed_at: chrono::DateTime<chrono::Utc>, pub computed_at: chrono::DateTime<chrono::Utc>,
/// 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 /// `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?; crate::require_project_access(&s, &user, id).await?;
let rows = sqlx::query_as::<_, CalibrationRow>( let rows = sqlx::query_as::<_, CalibrationRow>(
r#"SELECT road_type, route_len_m, jsonb_array_length(anchors)::int AS anchor_count, 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"#, FROM chainage_calibrations WHERE project_id=$1 ORDER BY road_type"#,
) )
.bind(id) .bind(id)
@@ -700,6 +703,34 @@ pub async fn list_calibrations(
Ok(Json(rows)) 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<AppState>,
user: AuthUser,
AxPath(id): AxPath<i32>,
axum::extract::Query(q): axum::extract::Query<SummaryQuery>,
) -> ApiResult<Value> {
crate::require_project_access(&s, &user, id).await?;
let v: Option<Option<Value>> = 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). /// `DELETE /api/chainage/points/:id` — remove a calibration point (admin).
pub async fn delete_point( pub async fn delete_point(
State(s): State<AppState>, State(s): State<AppState>,
@@ -1022,30 +1053,7 @@ async fn run_recompute(
.map_err(err)?; .map_err(err)?;
warnings.push(format!("{name} has no GPS track — excluded from the route, chainage cleared")); 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 let summary = CalibrationSummary {
// 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 {
route_len_m: route.len_m(), route_len_m: route.len_m(),
reversed, reversed,
majority_flipped, majority_flipped,
@@ -1055,7 +1063,34 @@ async fn run_recompute(
warnings, warnings,
videos: out_videos, videos: out_videos,
skipped: skipped.into_iter().map(|(_, n)| n).collect(), 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)] #[cfg(test)]

View File

@@ -211,6 +211,7 @@ async fn main() -> anyhow::Result<()> {
.route("/api/projects/:id/chainage/recompute", post(chainage::recompute)) .route("/api/projects/:id/chainage/recompute", post(chainage::recompute))
.route("/api/projects/:id/chainage/quick", post(chainage::quick_assign)) .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/calibrations", get(chainage::list_calibrations))
.route("/api/projects/:id/chainage/summary", get(chainage::saved_summary))
// Phase 3 admin (token-gated). // Phase 3 admin (token-gated).
.route("/api/admin/storage", get(storage_info)) .route("/api/admin/storage", get(storage_info))
.route("/api/users", get(admin::list_users).post(admin::create_user)) .route("/api/users", get(admin::list_users).post(admin::create_user))