chainage panel update
This commit is contained in:
@@ -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) +
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user