From 717d4986b6cd536a0b6114b69dc8c946c2638fe6 Mon Sep 17 00:00:00 2001 From: ravi Date: Tue, 19 May 2026 18:31:44 +0530 Subject: [PATCH] bbox mode --- src-tauri/src/commands.rs | 376 +++++++++++++++++- src-tauri/src/db.rs | 37 ++ src-tauri/src/lib.rs | 8 + src/App.tsx | 518 ++++++++++++++++++++++--- src/MapView.tsx | 794 +++++++++++++++++++++++++++++++++++--- src/tauriShim.ts | 17 + 6 files changed, 1648 insertions(+), 102 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7cd609c..eeea7f3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -355,6 +355,99 @@ async fn insert_range_row( Ok(()) } +/// Parse a single bbox prefix from a row and write it into asset_bboxes for +/// the given slot. Accepts numeric or string values and silently skips slots +/// where any of the four coordinate fields are missing / unparseable. Class +/// is asset-level (assets.bbox_class) and handled separately by the caller. +async fn maybe_insert_bbox_from_row( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + asset_id: i64, + row: &serde_json::Value, + prefix: &str, + slot: &str, +) -> Result<(), String> { + fn parse_f(v: Option<&serde_json::Value>) -> Option { + match v? { + serde_json::Value::Number(n) => n.as_f64().filter(|x| x.is_finite()), + serde_json::Value::String(s) => { + let t = s.trim(); + if t.is_empty() { + None + } else { + t.parse::().ok().filter(|x| x.is_finite()) + } + } + _ => None, + } + } + let x1 = parse_f(row.get(&format!("{prefix}bbox_x1"))); + let y1 = parse_f(row.get(&format!("{prefix}bbox_y1"))); + let x2 = parse_f(row.get(&format!("{prefix}bbox_x2"))); + let y2 = parse_f(row.get(&format!("{prefix}bbox_y2"))); + let (Some(x1), Some(y1), Some(x2), Some(y2)) = (x1, y1, x2, y2) else { + return Ok(()); + }; + let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) }; + let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) }; + if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 { + return Ok(()); + } + sqlx::query( + "INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(asset_id, slot) DO UPDATE SET + x1 = excluded.x1, y1 = excluded.y1, + x2 = excluded.x2, y2 = excluded.y2", + ) + .bind(asset_id) + .bind(slot) + .bind(nx1) + .bind(ny1) + .bind(nx2) + .bind(ny2) + .execute(&mut **tx) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Resolve the asset id for a row_id we just inserted, then attempt to ingest +/// any bbox fields. For fixed rows we look for the flat `bbox_*` keys; for +/// range rows we additionally check `start_bbox_*` / `mid_bbox_*` / +/// `end_bbox_*` so each image-slot can carry its own bbox. The (asset-level) +/// `bbox_class` field, if present anywhere in the row, is stored on assets. +async fn ingest_bboxes_for_row( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + row_id: &str, + raw: &serde_json::Value, + is_range: bool, +) -> Result<(), String> { + let asset_id: Option = + sqlx::query_scalar("SELECT id FROM assets WHERE row_id = ?") + .bind(row_id) + .fetch_optional(&mut **tx) + .await + .map_err(|e| e.to_string())?; + let Some(asset_id) = asset_id else { + return Ok(()); + }; + if is_range { + // Flat bbox_* maps to mid by convention; explicit per-slot prefixes + // override that when present. + maybe_insert_bbox_from_row(tx, asset_id, raw, "", "mid").await?; + maybe_insert_bbox_from_row(tx, asset_id, raw, "start_", "start").await?; + maybe_insert_bbox_from_row(tx, asset_id, raw, "mid_", "mid").await?; + maybe_insert_bbox_from_row(tx, asset_id, raw, "end_", "end").await?; + } else { + maybe_insert_bbox_from_row(tx, asset_id, raw, "", "fixed").await?; + } + // Class lives on assets.asset_name, populated by the regular import path. + // No bbox_class write here — `set_asset_bbox` API stays purely geometric + // and class renames flow through the existing rename_assets command. + let _ = asset_id; + Ok(()) +} + async fn stamp_orig_positions(pool: &SqlitePool) -> Result<(), String> { sqlx::query( "UPDATE assets @@ -377,13 +470,20 @@ pub async fn import_fixed_assets( state: State<'_, AppState>, ) -> Result { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; - let parsed: Vec = + // Deserialize as raw JSON first so we can still see bbox_* keys that + // aren't on FixedAssetInput. Each row is then converted to the typed + // shape for INSERT and re-used for bbox ingest. + let raws: Vec = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut count = 0usize; - for input in parsed { + for raw in raws { + let input: FixedAssetInput = + serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?; + let row_id = input.row_id.clone(); insert_fixed_row(&mut tx, &input).await?; + ingest_bboxes_for_row(&mut tx, &row_id, &raw, false).await?; count += 1; } tx.commit().await.map_err(|e| e.to_string())?; @@ -398,13 +498,17 @@ pub async fn import_range_assets( state: State<'_, AppState>, ) -> Result { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; - let parsed: Vec = + let raws: Vec = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut count = 0usize; - for input in parsed { + for raw in raws { + let input: RangeAssetInput = + serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?; + let row_id = input.row_id.clone(); insert_range_row(&mut tx, &input).await?; + ingest_bboxes_for_row(&mut tx, &row_id, &raw, true).await?; count += 1; } tx.commit().await.map_err(|e| e.to_string())?; @@ -485,13 +589,17 @@ pub async fn import_assets_auto( && coord_field_valid(row.get("end_coord_lng")); if is_range { let input: RangeAssetInput = - serde_json::from_value(row).map_err(|e| e.to_string())?; + serde_json::from_value(row.clone()).map_err(|e| e.to_string())?; + let row_id = input.row_id.clone(); insert_range_row(&mut tx, &input).await?; + ingest_bboxes_for_row(&mut tx, &row_id, &row, true).await?; range_count += 1; } else { let input: FixedAssetInput = - serde_json::from_value(row).map_err(|e| e.to_string())?; + serde_json::from_value(row.clone()).map_err(|e| e.to_string())?; + let row_id = input.row_id.clone(); insert_fixed_row(&mut tx, &input).await?; + ingest_bboxes_for_row(&mut tx, &row_id, &row, false).await?; fixed_count += 1; } } @@ -2924,6 +3032,128 @@ pub async fn list_video_names( Ok(rows.into_iter().map(|(n,)| n).collect()) } +#[derive(Debug, Serialize)] +pub struct VideoEntry { + pub video_name: String, + pub video_number: i64, + pub completed: bool, + pub completed_at: Option, + pub completed_by: Option, +} + +/// Sort key for stable numbering: parse the first `YYYY_MMDD_HHMMSS` pattern +/// out of the video name (timestamps captured by the on-vehicle recorder). +/// Names without a timestamp sort after timestamped ones, lexically. +fn video_sort_key(name: &str) -> String { + let bytes = name.as_bytes(); + // Window-scan for the pattern: 4 digits, _, 4 digits, _, 6 digits. + let mut i = 0; + while i + 16 <= bytes.len() { + let w = &bytes[i..i + 16]; + let is_digits = |b: &[u8]| b.iter().all(|c| c.is_ascii_digit()); + if w[4] == b'_' + && w[9] == b'_' + && is_digits(&w[0..4]) + && is_digits(&w[5..9]) + && is_digits(&w[10..16]) + { + // 14-char numeric key sorts chronologically. + let mut key = String::with_capacity(14 + 1 + name.len()); + key.push_str(std::str::from_utf8(&w[0..4]).unwrap_or("")); + key.push_str(std::str::from_utf8(&w[5..9]).unwrap_or("")); + key.push_str(std::str::from_utf8(&w[10..16]).unwrap_or("")); + key.push('_'); + key.push_str(name); + return key; + } + i += 1; + } + // No timestamp: prefix with sentinel that sorts after any real timestamp. + let mut key = String::with_capacity(15 + name.len()); + key.push_str("99999999999999_"); + key.push_str(name); + key +} + +/// Return every distinct video name (from assets + video_metadata + the +/// status table) annotated with a deterministic 1-based number and its +/// completion state. Sort: parsed timestamp asc, then video_name asc. The +/// numbering is recomputed on every call, so adding a new video shifts later +/// numbers consistently for every client sharing the DB. +#[tauri::command] +pub async fn list_videos( + state: State<'_, AppState>, +) -> Result, String> { + let names: Vec<(String,)> = sqlx::query_as( + "SELECT DISTINCT video_name FROM ( + SELECT video_name FROM assets WHERE deleted = 0 + UNION + SELECT video_name FROM video_metadata + UNION + SELECT video_name FROM video_status + ) WHERE TRIM(COALESCE(video_name, '')) <> ''", + ) + .fetch_all(&state.pool) + .await + .map_err(|e| e.to_string())?; + + let mut all: Vec = names.into_iter().map(|(n,)| n).collect(); + all.sort_by(|a, b| video_sort_key(a).cmp(&video_sort_key(b))); + + let status_rows: Vec<(String, i64, Option, Option)> = sqlx::query_as( + "SELECT video_name, completed, completed_at, completed_by FROM video_status", + ) + .fetch_all(&state.pool) + .await + .map_err(|e| e.to_string())?; + let mut status: std::collections::HashMap< + String, + (i64, Option, Option), + > = std::collections::HashMap::new(); + for (n, c, at, by) in status_rows { + status.insert(n, (c, at, by)); + } + + let mut out: Vec = Vec::with_capacity(all.len()); + for (idx, n) in all.into_iter().enumerate() { + let (completed, completed_at, completed_by) = + status.remove(&n).unwrap_or((0, None, None)); + out.push(VideoEntry { + video_number: (idx + 1) as i64, + video_name: n, + completed: completed != 0, + completed_at, + completed_by, + }); + } + Ok(out) +} + +#[tauri::command] +pub async fn set_video_completed( + video_name: String, + completed: bool, + state: State<'_, AppState>, +) -> Result<(), String> { + let user = user_id(); + sqlx::query( + "INSERT INTO video_status(video_name, completed, completed_at, completed_by) + VALUES (?, ?, CASE WHEN ? THEN datetime('now') ELSE NULL END, ?) + ON CONFLICT(video_name) DO UPDATE SET + completed = excluded.completed, + completed_at = CASE WHEN excluded.completed = 1 THEN datetime('now') ELSE NULL END, + completed_by = CASE WHEN excluded.completed = 1 THEN excluded.completed_by ELSE NULL END", + ) + .bind(&video_name) + .bind(if completed { 1i64 } else { 0 }) + .bind(if completed { 1i64 } else { 0 }) + .bind(&user) + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub async fn restore_assets( ids: Vec, @@ -6739,3 +6969,137 @@ pub async fn quality_report( missing_side_total, }) } + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct AssetBbox { + pub asset_id: i64, + pub slot: String, + pub x1: f64, + pub y1: f64, + pub x2: f64, + pub y2: f64, + // The class label is the asset's name. Reported here so the frontend + // doesn't have to plumb assets[asset_id].asset_name separately, but the + // source of truth is `assets.asset_name` — rename via rename_assets. + pub class: Option, +} + +fn normalize_slot(slot: &str) -> Result<&'static str, String> { + match slot { + "fixed" => Ok("fixed"), + "start" => Ok("start"), + "mid" => Ok("mid"), + "end" => Ok("end"), + other => Err(format!("invalid bbox slot: {other}")), + } +} + +#[tauri::command] +pub async fn get_asset_bboxes( + asset_id: i64, + state: State<'_, AppState>, +) -> Result, String> { + sqlx::query_as::<_, AssetBbox>( + "SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2, + a.asset_name AS class + FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id + WHERE b.asset_id = ? ORDER BY b.slot", + ) + .bind(asset_id) + .fetch_all(&state.pool) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_asset_bbox( + asset_id: i64, + slot: String, + x1: f64, + y1: f64, + x2: f64, + y2: f64, + state: State<'_, AppState>, +) -> Result<(), String> { + let slot = normalize_slot(&slot)?; + // Normalize so x1 <= x2 and y1 <= y2 so render math doesn't have to. + let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) }; + let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) }; + if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 { + return Err("bbox too small (need ≥ 1 px in each axis)".into()); + } + sqlx::query( + "INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(asset_id, slot) DO UPDATE SET + x1 = excluded.x1, y1 = excluded.y1, + x2 = excluded.x2, y2 = excluded.y2", + ) + .bind(asset_id) + .bind(slot) + .bind(nx1) + .bind(ny1) + .bind(nx2) + .bind(ny2) + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + + +#[tauri::command] +pub async fn clear_asset_bbox( + asset_id: i64, + slot: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let slot = normalize_slot(&slot)?; + sqlx::query("DELETE FROM asset_bboxes WHERE asset_id = ? AND slot = ?") + .bind(asset_id) + .bind(slot) + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn export_bboxes_to_json( + path: String, + state: State<'_, AppState>, +) -> Result { + // Sidecar shape: one entry per (row_id, slot). The original JSON's bbox_* + // keys stay shallow — we emit a flat `slot` field so consumers don't have + // to know the asset_type ahead of time. Class is asset-level so every + // entry for the same row_id carries the same value. + let rows: Vec<(String, String, String, f64, f64, f64, f64, Option)> = + sqlx::query_as( + "SELECT a.row_id, a.video_name, b.slot, b.x1, b.y1, b.x2, b.y2, a.asset_name + FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id + ORDER BY a.row_id, b.slot", + ) + .fetch_all(&state.pool) + .await + .map_err(|e| e.to_string())?; + let entries: Vec = rows + .into_iter() + .map(|(row_id, video_name, slot, x1, y1, x2, y2, class)| { + serde_json::json!({ + "row_id": row_id, + "video_name": video_name, + "slot": slot, + "bbox_x1": x1, + "bbox_y1": y1, + "bbox_x2": x2, + "bbox_y2": y2, + "class": class, + }) + }) + .collect(); + let n = entries.len(); + let out = serde_json::json!({ "bboxes": entries }); + let body = serde_json::to_vec_pretty(&out).map_err(|e| e.to_string())?; + tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?; + Ok(n) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 104c32c..47fbf0c 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -241,6 +241,43 @@ async fn apply_schema(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + // Per-video completion status. video_number is recomputed on demand from + // the sorted distinct video_name set (parsed timestamp + name); stored + // here only so other clients sharing the DB see the same flag/value + // without recomputing. Wiped only by reset_database. + sqlx::query( + "CREATE TABLE IF NOT EXISTS video_status ( + video_name TEXT PRIMARY KEY, + completed INTEGER NOT NULL DEFAULT 0, + completed_at TEXT, + completed_by TEXT + )", + ) + .execute(pool) + .await?; + + // BBox annotations on asset images. Slot identifies which image the bbox + // belongs to: 'fixed' (the single image_path for fixed assets), or one of + // 'start'/'mid'/'end' (the image_path1/2/3 for range assets). Coords are + // in source-image pixel space — render code scales to the display frame. + // The "class" label rendered on each bbox is sourced from assets.asset_name + // — renaming the asset renames the class on every image at once, which is + // exactly what the user wants for range (three images, one shared class). + sqlx::query( + "CREATE TABLE IF NOT EXISTS asset_bboxes ( + asset_id INTEGER NOT NULL, + slot TEXT NOT NULL, + x1 REAL NOT NULL, + y1 REAL NOT NULL, + x2 REAL NOT NULL, + y2 REAL NOT NULL, + PRIMARY KEY (asset_id, slot), + FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE + )", + ) + .execute(pool) + .await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS action_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c9a30e..06df414 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,8 @@ use commands::{ delete_roads_in_lasso, set_link, simplify_road, delete_roads_by_highway, export_osm_roads, find_duplicate_clusters, save_video_metadata, get_video_metadata, clear_video_metadata, + list_videos, set_video_completed, + get_asset_bboxes, set_asset_bbox, clear_asset_bbox, export_bboxes_to_json, flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets, set_road_snap_ignored, osm_road_snapshot, restore_road_state, list_road_classes, merge_roads_by_name, merge_roads_by_ids, quality_report, assets_coords_for_ids, @@ -115,6 +117,12 @@ pub fn run() { save_video_metadata, get_video_metadata, clear_video_metadata, + list_videos, + set_video_completed, + get_asset_bboxes, + set_asset_bbox, + clear_asset_bbox, + export_bboxes_to_json, set_link, clear_link ]) diff --git a/src/App.tsx b/src/App.tsx index 94ea364..9e7a5e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { open, save } from "@tauri-apps/plugin-dialog"; const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder"; const POPUP_SIZE_KEY = "kml_map_tool.popup_size"; const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled"; +const BBOX_MODE_KEY = "kml_map_tool.bbox_mode"; const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect"; const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m"; const PERF_MODE_KEY = "kml_map_tool.perf_mode"; @@ -303,6 +304,13 @@ function AppShell({ const v = window.localStorage.getItem(POPUP_ENABLED_KEY); return v === null ? true : v === "1"; }); + const [bboxMode, setBboxMode] = useState(() => { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(BBOX_MODE_KEY) === "1"; + }); + useEffect(() => { + window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0"); + }, [bboxMode]); const [popupAspect, setPopupAspect] = useState(() => { if (typeof window === "undefined") return "4:3"; const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null; @@ -1040,12 +1048,12 @@ function AppShell({ .catch((err) => setStatus(`Vertex delete failed: ${err}`)); return; } - // Bulk hide: in focused-video OSM mode and not in vertex-edit submode, - // Del moves the multi-select set into the hidden bucket. + // Bulk action on selected roads in OSM mode (not in vertex-edit submode): + // focusedVideo != null → hide (non-destructive, video-scoped) + // focusedVideo == null → real DB delete with snapshot-based undo if ( (e.key === "Delete" || e.key === "Backspace") && osmEditMode && - focusedVideo !== null && editingRoadId === null && selectedRoadIds.size > 0 ) { @@ -1056,14 +1064,36 @@ function AppShell({ } e.preventDefault(); const ids = Array.from(selectedRoadIds); - setHiddenRoadIds((prev) => { - const next = new Set(prev); - for (const id of ids) next.add(id); - return next; - }); - setSelectedRoadIds(new Set()); - pushOsmUndo({ kind: "unhide", ids, label: "hide selected" }); - setStatus(`${ids.length} road(s) hidden.`); + if (focusedVideo !== null) { + setHiddenRoadIds((prev) => { + const next = new Set(prev); + for (const id of ids) next.add(id); + return next; + }); + setSelectedRoadIds(new Set()); + pushOsmUndo({ kind: "unhide", ids, label: "hide selected" }); + setStatus(`${ids.length} road(s) hidden.`); + return; + } + // Global OSM mode: snapshot, delete each id, then refresh. Undo via + // the existing restore-roads upsert path resurrects them. + (async () => { + try { + await snapshotRoadsForUndo(ids, "delete selected roads"); + for (const id of ids) { + await invoke("delete_osm_road", { id }); + } + setSelectedRoadIds(new Set()); + if (editingRoadId !== null && ids.includes(editingRoadId)) { + setEditingRoadId(null); + } + setMarkedVertices(new Set()); + await refreshOsmRoads(); + setStatus(`${ids.length} road(s) deleted. Use Undo to bring them back.`); + } catch (err) { + setStatus(`Bulk delete failed: ${err}`); + } + })(); return; } if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) { @@ -1093,6 +1123,46 @@ function AppShell({ toggleOsmEditModeRef.current(); return; } + // 'B' / 'b' toggles bbox editing. No-op when popups are disabled. + if (e.key === "b" || e.key === "B") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.metaKey || e.ctrlKey || e.altKey) return; + e.preventDefault(); + if (!popupEnabled) { + setStatus("Enable image popup first to use BBox mode."); + return; + } + setBboxMode((v) => !v); + return; + } + // 'H' / 'h' toggles the asset image popup. Same input-guard rules as 'O'. + if (e.key === "h" || e.key === "H") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.metaKey || e.ctrlKey || e.altKey) return; + e.preventDefault(); + setPopupEnabled((v) => !v); + return; + } + // 'C' / 'c': fit to focused video (focus mode) or all data (global). + if (e.key === "c" || e.key === "C") { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.metaKey || e.ctrlKey || e.altKey) return; + e.preventDefault(); + fitToFocusOrAllRef.current(); + return; + } if (e.key !== "Escape") return; if (lensActive) { e.preventDefault(); @@ -1332,6 +1402,7 @@ function AppShell({ await Promise.all([ refreshDataCounts().catch(() => {}), refreshVideoNames().catch(() => {}), + refreshVideoEntries().catch(() => {}), refreshAssetNames().catch(() => {}), ]); setStatus("Database reset."); @@ -1504,6 +1575,27 @@ function AppShell({ } } + // Annotated video list from the backend: raw names, deterministic numbers + // (timestamp + name sort), and completion flags shared across clients. + type VideoEntry = { + video_name: string; + video_number: number; + completed: boolean; + completed_at: string | null; + completed_by: string | null; + }; + const [videoEntries, setVideoEntries] = useState([]); + const [completedVideosExpanded, setCompletedVideosExpanded] = + useState(false); + async function refreshVideoEntries() { + try { + const list = await invoke("list_videos"); + setVideoEntries(list); + } catch { + /* ignore */ + } + } + async function refreshVideoNames() { try { const raw = await invoke("list_video_names"); @@ -1550,6 +1642,7 @@ function AppShell({ refreshActions(), refreshAssetNames(), refreshVideoNames(), + refreshVideoEntries(), refreshClasses(), refreshDataCounts(), ]; @@ -1559,6 +1652,56 @@ function AppShell({ // Fly the map to a specific video (uses any combination of metadata polyline // + asset bboxes, whichever exists). + // Fit the map to either the focused video or every loaded asset+track. The + // C shortcut and the on-map "Fit" button both call this. Held in a ref so + // the keydown handler always sees the current closure. + function fitToFocusOrAll() { + if (focusedVideo !== null) { + flyToVideo(focusedVideo); + return; + } + let mnLat = Infinity, + mxLat = -Infinity, + mnLng = Infinity, + mxLng = -Infinity; + for (const a of assets) { + if (a.deleted !== 0) continue; + const lats = [a.lat, a.start_lat, a.end_lat].filter( + (v): v is number => v !== null, + ); + const lngs = [a.lng, a.start_lng, a.end_lng].filter( + (v): v is number => v !== null, + ); + for (const v of lats) { + if (v < mnLat) mnLat = v; + if (v > mxLat) mxLat = v; + } + for (const v of lngs) { + if (v < mnLng) mnLng = v; + if (v > mxLng) mxLng = v; + } + } + for (const track of Object.values(metadataByVideo)) { + for (const [lng, lat] of track) { + if (lng < mnLng) mnLng = lng; + if (lng > mxLng) mxLng = lng; + if (lat < mnLat) mnLat = lat; + if (lat > mxLat) mxLat = lat; + } + } + if (!Number.isFinite(mnLat)) { + setStatus("No data loaded to fit."); + return; + } + setViewport({ + center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2], + zoom: 13, + }); + setStatus("Fit to all data."); + } + const fitToFocusOrAllRef = useRef(fitToFocusOrAll); + fitToFocusOrAllRef.current = fitToFocusOrAll; + function flyToVideo(name: string) { let mnLat = Infinity, mxLat = -Infinity, @@ -1674,6 +1817,7 @@ function AppShell({ refreshActions().catch(() => {}); refreshAssetNames().catch(() => {}); refreshVideoNames().catch(() => {}); + refreshVideoEntries().catch(() => {}); refreshClasses().catch(() => {}); refreshDataCounts().catch(() => {}); // Per-video metadata persists in DB; load it so users don't have to @@ -3004,6 +3148,7 @@ function AppShell({ setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`); await Promise.all([ refreshVideoNames(), + refreshVideoEntries(), refreshAssetNames(), refreshClasses(), ]); @@ -3048,6 +3193,7 @@ function AppShell({ // Pull in the new video / class names so the filter auto-includes them. await Promise.all([ refreshVideoNames(), + refreshVideoEntries(), refreshAssetNames(), refreshClasses(), ]); @@ -3281,6 +3427,7 @@ function AppShell({ onPlaceAt={handlePlaceAt} imageFolder={imageFolder} popupEnabled={popupEnabled} + bboxMode={bboxMode} perfMode={perfMode} anchorIds={Array.from(anchors.keys())} colorBy={colorBy} @@ -3305,11 +3452,28 @@ function AppShell({ setStatus(`Editing road #${id}. Drag the gold vertex dots.`); }} onRoadRightClick={(id) => { - // Right-click deletes the road from the DB. Snapshot first so the - // OSM undo ring can resurrect it via restore_road_state's upsert - // path (re-inserts the row with its original id + modified flag). - // Hide-by-Del in focused-video mode is a separate, non-destructive - // path. + // Behavior depends on whether a video is focused: + // focusedVideo != null → hide for this video (non-destructive, + // frontend-only, undoable via the unhide entry). + // focusedVideo == null → real DB delete with undo via the + // restore-roads upsert path. The explicit "Delete road" + // button + Del-on-selected stay aligned with this. + if (focusedVideo !== null) { + setHiddenRoadIds((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + setSelectedRoadIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + pushOsmUndo({ kind: "unhide", ids: [id], label: "hide road" }); + setStatus(`Road #${id} hidden for "${focusedVideo}".`); + return; + } (async () => { try { await snapshotRoadsForUndo([id], "delete road (right-click)"); @@ -3386,6 +3550,7 @@ function AppShell({ onPositionChange={handlePositionChange} imageFolder={imageFolder} popupEnabled={popupEnabled} + bboxMode={bboxMode} perfMode={perfMode} anchorIds={Array.from(anchors.keys())} colorBy={ @@ -3396,6 +3561,96 @@ function AppShell({ } /> )} + {/* + Floating "Fit" button — same action as the C shortcut. Tooltip still + carries the wordy description; the button itself is just a map icon + so it stays compact at the bottom of the map. + */} + + {/* + BBox mode quick-toggle — green when on, dark when off. Same as the + Settings checkbox + 'B' shortcut. Disabled when popups themselves + are off because there's nothing to render onto. + */} + + - ))} - + ); + }; + return ( + <> +
+ {active.map(renderRow)} + {active.length === 0 && ( +
+ (no active videos) +
+ )} +
+ {completed.length > 0 && ( +
+
+ setCompletedVideosExpanded((v) => !v) + } + title="Toggle the completed-videos section" + > + {completedVideosExpanded ? "▼" : "▶"} Completed ( + {completed.length}) +
+ {completedVideosExpanded && ( +
+ {completed.map(renderRow)} +
+ )} +
+ )} + + ); + })()} )} @@ -4045,6 +4423,58 @@ function AppShell({ /> Show image popup when an asset is selected + +

Quality thresholds