bbox mode

This commit is contained in:
2026-05-19 18:31:44 +05:30
parent ac5deafbbf
commit 717d4986b6
6 changed files with 1648 additions and 102 deletions

View File

@@ -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<f64> {
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::<f64>().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<i64> =
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<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let parsed: Vec<FixedAssetInput> =
// 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::Value> =
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<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let parsed: Vec<RangeAssetInput> =
let raws: Vec<serde_json::Value> =
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<String>,
pub completed_by: Option<String>,
}
/// 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<Vec<VideoEntry>, 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<String> = 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<String>, Option<String>)> = 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<String>, Option<String>),
> = std::collections::HashMap::new();
for (n, c, at, by) in status_rows {
status.insert(n, (c, at, by));
}
let mut out: Vec<VideoEntry> = 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<i64>,
@@ -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<String>,
}
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<Vec<AssetBbox>, 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<usize, String> {
// 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<String>)> =
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<serde_json::Value> = 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)
}

View File

@@ -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,

View File

@@ -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
])

View File

@@ -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<boolean>(() => {
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<PopupAspect>(() => {
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<VideoEntry[]>([]);
const [completedVideosExpanded, setCompletedVideosExpanded] =
useState<boolean>(false);
async function refreshVideoEntries() {
try {
const list = await invoke<VideoEntry[]>("list_videos");
setVideoEntries(list);
} catch {
/* ignore */
}
}
async function refreshVideoNames() {
try {
const raw = await invoke<string[]>("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.
*/}
<button
type="button"
onClick={() => fitToFocusOrAll()}
title={
focusedVideo
? `Fit map to "${focusedVideo}" (C)`
: "Fit map to all loaded data (C)"
}
aria-label={
focusedVideo
? `Fit map to ${focusedVideo}`
: "Fit map to all loaded data"
}
style={{
position: "absolute",
bottom: 16,
right: 16,
zIndex: 5,
width: 40,
height: 40,
padding: 0,
background: "rgba(44, 62, 80, 0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
borderRadius: "50%",
fontSize: 20,
lineHeight: 1,
cursor: "pointer",
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
🗺
</button>
{/*
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.
*/}
<button
type="button"
onClick={() => {
if (!popupEnabled) {
setStatus("Enable image popup first to use BBox mode.");
return;
}
setBboxMode((v) => !v);
}}
title={
!popupEnabled
? "Enable image popup first to use BBox mode"
: bboxMode
? "BBox mode ON — drag to draw/move/resize bounding boxes. Click to turn off (B)"
: "Click to turn on BBox mode (B)"
}
aria-label="Toggle BBox mode"
style={{
position: "absolute",
bottom: 16,
right: 66,
zIndex: 5,
width: 40,
height: 40,
padding: 0,
background: bboxMode
? "rgba(39, 174, 96, 0.95)"
: "rgba(44, 62, 80, 0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
borderRadius: "50%",
fontSize: 13,
fontWeight: 700,
lineHeight: 1,
cursor: popupEnabled ? "pointer" : "not-allowed",
opacity: popupEnabled ? 1 : 0.5,
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
</button>
</div>
<button
@@ -3597,47 +3852,118 @@ function AppShell({
marginBottom: 4,
}}
/>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{Array.from(
new Set(
allVideoNames.concat(Object.keys(metadataByVideo)),
),
{(() => {
// Merge: videoEntries is the authoritative ordered list from
// the backend (number + completion). Names present only in
// metadataByVideo (track loaded but no assets imported yet)
// get pushed to the end with a synthetic number so the user
// can still pick them.
const known = new Set(
videoEntries.map((e) => displayVideo(e.video_name)),
);
const extras = Array.from(
new Set(Object.keys(metadataByVideo)),
)
.filter((v) => !known.has(displayVideo(v)))
.sort()
.filter((v) =>
videoSearch.trim() === ""
? true
: v
.toLowerCase()
.includes(videoSearch.trim().toLowerCase()),
)
.map((v) => {
const n = counts.byVideo.get(v) ?? 0;
return [v, n] as [string, number];
})
.map(([v, n]) => (
<label key={v} className="filter-row">
.map(
(v, i): VideoEntry => ({
video_name: v,
video_number: videoEntries.length + i + 1,
completed: false,
completed_at: null,
completed_by: null,
}),
);
const all = videoEntries.concat(extras);
const term = videoSearch.trim().toLowerCase();
const matchSearch = (e: VideoEntry) => {
if (term === "") return true;
const label = displayVideo(e.video_name).toLowerCase();
return (
label.includes(term) ||
String(e.video_number).includes(term)
);
};
const active = all.filter((e) => !e.completed && matchSearch(e));
const completed = all.filter(
(e) => e.completed && matchSearch(e),
);
const renderRow = (e: VideoEntry) => {
const label = displayVideo(e.video_name);
const n = counts.byVideo.get(label) ?? 0;
return (
<label key={e.video_name} className="filter-row">
<input
type="checkbox"
checked={filters.videos.has(v)}
checked={filters.videos.has(label)}
onChange={() =>
setFilters((f) => ({
...f,
videos: toggleSet(f.videos, v),
videos: toggleSet(f.videos, label),
}))
}
/>
<span className="label" title={v}>
{v}
<span
className="label"
title={
e.completed && e.completed_by
? `${e.video_name}\nCompleted by ${e.completed_by}${
e.completed_at ? ` on ${e.completed_at}` : ""
}`
: e.video_name
}
style={
e.completed
? { textDecoration: "line-through", opacity: 0.7 }
: undefined
}
>
<span style={{ color: "#7f8c8d", marginRight: 4 }}>
#{e.video_number}
</span>
{label}
</span>
<span className="count">{n}</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
flyToVideo(v);
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
invoke("set_video_completed", {
videoName: e.video_name,
completed: !e.completed,
})
.then(() => refreshVideoEntries())
.catch((err) =>
setStatus(`Mark complete failed: ${err}`),
);
}}
title={`Fly to ${v}`}
title={
e.completed
? "Click to mark as not complete"
: "Mark this video as reviewed"
}
style={{
marginLeft: 4,
padding: "0 6px",
fontSize: 12,
border: "1px solid #ccc",
borderRadius: 3,
background: e.completed ? "#27ae60" : "#fff",
color: e.completed ? "#fff" : "#27ae60",
cursor: "pointer",
}}
>
</button>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
flyToVideo(label);
}}
title={`Fly to ${label}`}
style={{
marginLeft: 4,
padding: "0 6px",
@@ -3651,8 +3977,60 @@ function AppShell({
</button>
</label>
))}
</div>
);
};
return (
<>
<div style={{ maxHeight: 240, overflowY: "auto" }}>
{active.map(renderRow)}
{active.length === 0 && (
<div
style={{
fontSize: 11,
color: "#7f8c8d",
padding: 4,
}}
>
(no active videos)
</div>
)}
</div>
{completed.length > 0 && (
<div style={{ marginTop: 6 }}>
<div
style={{
cursor: "pointer",
userSelect: "none",
fontSize: 12,
fontWeight: 600,
color: "#27ae60",
padding: "4px 0",
}}
onClick={() =>
setCompletedVideosExpanded((v) => !v)
}
title="Toggle the completed-videos section"
>
{completedVideosExpanded ? "▼" : "▶"} Completed (
{completed.length})
</div>
{completedVideosExpanded && (
<div
style={{
maxHeight: 200,
overflowY: "auto",
borderTop: "1px dashed #ccc",
paddingTop: 4,
}}
>
{completed.map(renderRow)}
</div>
)}
</div>
)}
</>
);
})()}
</>
)}
@@ -4045,6 +4423,58 @@ function AppShell({
/>
Show image popup when an asset is selected
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
}}
title="When ON, the popup shows the full image and overlays an editable bounding box. Drag a corner/edge to resize, drag inside to move, drag empty space to draw new."
>
<input
type="checkbox"
checked={bboxMode}
onChange={(e) => setBboxMode(e.target.checked)}
disabled={!popupEnabled}
/>
BBox mode (edit bounding boxes on images)
</label>
<button
type="button"
onClick={async () => {
try {
const { save } = await import(
"@tauri-apps/plugin-dialog"
);
const p = await save({
title: "Export bboxes to JSON",
defaultPath: "bboxes.json",
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (!p) return;
const n = await invoke<number>("export_bboxes_to_json", {
path: p,
});
setStatus(`Exported ${n} bbox(es) to ${p}`);
} catch (e) {
setStatus(`Export failed: ${e}`);
}
}}
style={{
marginTop: 6,
fontSize: 12,
padding: "4px 8px",
border: "1px solid #16a085",
background: "#fff",
color: "#16a085",
borderRadius: 3,
cursor: "pointer",
}}
title="Write every bbox row to a sidecar JSON file. Source JSONs are never modified."
>
Export bboxes to JSON
</button>
<h4 style={{ marginTop: 12 }}>Quality thresholds</h4>
<label

View File

@@ -12,6 +12,7 @@ import { PathStyleExtension } from "@deck.gl/extensions";
import { basemapById } from "./basemaps";
import { videoColorRGB } from "./colors";
import { resolveImageSrc } from "./imagePath";
import { invoke } from "./tauriShim";
import type { Asset, ScopePolygon } from "./types";
export type LayerVisibility = {
@@ -72,6 +73,7 @@ type Props = {
onLinkClick?: (idA: number, idB: number) => void;
onMetadataLineClick?: (videoName: string) => void;
popupEnabled?: boolean;
bboxMode?: boolean;
perfMode?: boolean;
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
placeMode?: boolean;
@@ -144,6 +146,7 @@ export function MapView({
onLinkClick,
onMetadataLineClick,
popupEnabled = true,
bboxMode = false,
perfMode = false,
onPositionChange,
placeMode = false,
@@ -1626,11 +1629,630 @@ export function MapView({
? "Mid"
: "";
type BboxSlot = "fixed" | "start" | "mid" | "end";
type ServerBbox = {
asset_id: number;
slot: string;
x1: number;
y1: number;
x2: number;
y2: number;
class: string | null;
};
// Render and wire up a bbox-editing SVG overlay aligned to the natural
// image. Coords are kept in source-image pixel space via SVG viewBox;
// pointer math converts client pixels back through getScreenCTM().
function installBboxOverlay(
wrapper: HTMLDivElement,
img: HTMLImageElement,
asset: Asset,
slot: BboxSlot,
) {
const SVG_NS = "http://www.w3.org/2000/svg";
let svg: SVGSVGElement | null = null;
let rect: SVGRectElement | null = null;
let labelG: SVGGElement | null = null;
let labelRect: SVGRectElement | null = null;
let labelText: SVGTextElement | null = null;
let handles: SVGCircleElement[] = [];
let bbox: { x1: number; y1: number; x2: number; y2: number } | null = null;
let className: string = asset.asset_name ?? "";
// When false, the bbox renders as just a rect + label (no resize
// handles). Clicking the rect toggles to true so handles appear;
// clicking outside flips back. Newly drawn bboxes start unselected so
// the user sees a locked-in result instead of "still editing".
let selected = false;
const setup = async () => {
const natW = img.naturalWidth;
const natH = img.naturalHeight;
if (!natW || !natH) return;
// Pull the popup's fixed image size off the CSS variable so the
// wrapper becomes a fixed-size scrollable viewport. The image is
// resized below to its "contain" fit at zoom = 1; wheel zoom grows it
// beyond the wrapper, triggering scrollbars.
const css = getComputedStyle(document.documentElement);
const baseW =
parseInt(css.getPropertyValue("--popup-img-w") || "440", 10) || 440;
const baseH =
parseInt(css.getPropertyValue("--popup-img-h") || "320", 10) || 320;
wrapper.style.width = `${baseW}px`;
wrapper.style.height = `${baseH}px`;
wrapper.style.maxWidth = `${baseW}px`;
wrapper.style.maxHeight = `${baseH}px`;
wrapper.style.overflow = "auto";
// Compute the contain-fit size at zoom = 1.
const aspect = natW / natH;
const fitW =
baseW / baseH > aspect ? baseH * aspect : baseW;
const fitH =
baseW / baseH > aspect ? baseH : baseW / aspect;
// The image and overlay need to scale together; both are sized in CSS
// pixels off the same zoom factor.
img.style.objectFit = "fill";
img.style.width = `${fitW}px`;
img.style.height = `${fitH}px`;
img.style.display = "block";
svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svg.setAttribute("viewBox", `0 0 ${natW} ${natH}`);
svg.setAttribute("preserveAspectRatio", "none");
svg.style.position = "absolute";
svg.style.left = "0";
svg.style.top = "0";
svg.style.width = `${fitW}px`;
svg.style.height = `${fitH}px`;
svg.style.cursor = "crosshair";
svg.style.touchAction = "none";
wrapper.appendChild(svg);
// Zoom state — wheel anchored on the cursor. Bounded so the user
// can't accidentally zoom to a useless extreme.
let zoom = 1;
const applyZoom = () => {
const w = fitW * zoom;
const h = fitH * zoom;
img.style.width = `${w}px`;
img.style.height = `${h}px`;
if (svg) {
svg.style.width = `${w}px`;
svg.style.height = `${h}px`;
}
};
wrapper.addEventListener(
"wheel",
(ev) => {
ev.preventDefault();
ev.stopPropagation();
const rect = wrapper.getBoundingClientRect();
const cx = ev.clientX - rect.left + wrapper.scrollLeft;
const cy = ev.clientY - rect.top + wrapper.scrollTop;
const factor = ev.deltaY < 0 ? 1.2 : 1 / 1.2;
const oldZoom = zoom;
zoom = Math.max(1, Math.min(8, zoom * factor));
if (zoom === oldZoom) return;
const ratio = zoom / oldZoom;
applyZoom();
// Re-anchor the scroll so the pixel under the cursor stays put.
wrapper.scrollLeft = cx * ratio - (ev.clientX - rect.left);
wrapper.scrollTop = cy * ratio - (ev.clientY - rect.top);
},
{ passive: false },
);
// Double-click anywhere on the image (but not on the rect/handles)
// resets zoom to fit. Caught at wrapper level; rect's own pointer
// logic doesn't fire on dblclick.
wrapper.addEventListener("dblclick", (ev) => {
const t = ev.target as SVGElement;
if (t === rect) return;
if ((t.dataset || {}).dir) return;
zoom = 1;
applyZoom();
wrapper.scrollLeft = 0;
wrapper.scrollTop = 0;
});
// Load existing bbox for this slot.
try {
const bs = await invoke<ServerBbox[]>("get_asset_bboxes", {
assetId: asset.id,
});
const matched = bs.find((b) => b.slot === slot);
if (matched) {
bbox = {
x1: matched.x1,
y1: matched.y1,
x2: matched.x2,
y2: matched.y2,
};
if (matched.class) className = matched.class;
}
} catch (e) {
console.warn("get_asset_bboxes failed", e);
}
render();
};
const ptToImg = (clientX: number, clientY: number): [number, number] => {
if (!svg) return [0, 0];
const ctm = svg.getScreenCTM();
if (!ctm) return [0, 0];
const p = svg.createSVGPoint();
p.x = clientX;
p.y = clientY;
const t = p.matrixTransform(ctm.inverse());
return [t.x, t.y];
};
const render = () => {
if (!svg) return;
// Tear down previous shapes.
while (svg.firstChild) svg.removeChild(svg.firstChild);
rect = null;
labelG = null;
labelRect = null;
labelText = null;
handles = [];
if (!bbox) {
// Hint that the user can drag to draw a bbox.
const hint = document.createElementNS(SVG_NS, "text");
hint.setAttribute("x", "10");
hint.setAttribute("y", "30");
hint.setAttribute("fill", "rgba(255,255,255,0.7)");
hint.setAttribute("stroke", "rgba(0,0,0,0.6)");
hint.setAttribute("stroke-width", "0.5");
hint.setAttribute("font-size", "22");
hint.textContent = "drag to draw a bbox";
svg.appendChild(hint);
return;
}
const natW = img.naturalWidth;
const { x1, y1, x2, y2 } = bbox;
rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", String(x1));
rect.setAttribute("y", String(y1));
rect.setAttribute("width", String(x2 - x1));
rect.setAttribute("height", String(y2 - y1));
// No mask/fill — just an outlined rectangle. pointer-events:all keeps
// the rect clickable even though it has no fill (otherwise SVG hit
// testing skips fill="none" shapes). Stroke scales with image so it
// stays visible across very different source resolutions.
const strokeW = Math.max(4, Math.round(natW / 240));
rect.setAttribute("fill", "none");
rect.setAttribute("stroke", "#27ae60");
rect.setAttribute("stroke-width", String(strokeW));
rect.style.pointerEvents = "all";
rect.style.cursor = "move";
svg.appendChild(rect);
// Class label: place above the bbox if there's room; otherwise inside
// the bbox at top. Right-align when the label would overflow right
// edge of the image.
const fontPx = Math.max(14, Math.round(natW / 80));
labelG = document.createElementNS(SVG_NS, "g") as SVGGElement;
labelRect = document.createElementNS(SVG_NS, "rect");
labelText = document.createElementNS(SVG_NS, "text");
labelText.setAttribute("font-size", String(fontPx));
labelText.setAttribute("fill", "#fff");
labelText.setAttribute("font-family", "sans-serif");
labelText.textContent = className;
// Approximate label width: assume ~0.55em per char. Good enough for
// positioning a background rect without measuring DOM.
const approxW = Math.max(
40,
Math.round(fontPx * 0.6 * className.length),
);
const labelH = Math.round(fontPx * 1.4);
const padX = Math.round(fontPx * 0.4);
let lx = x1;
let ly = y1 - labelH - 4;
if (ly < 0) ly = y1 + 4; // place inside if no room above
// Right-align if it overflows
if (lx + approxW > natW) lx = Math.max(0, natW - approxW);
labelRect.setAttribute("x", String(lx));
labelRect.setAttribute("y", String(ly));
labelRect.setAttribute("width", String(approxW));
labelRect.setAttribute("height", String(labelH));
labelRect.setAttribute("fill", "#27ae60");
labelRect.setAttribute("rx", "3");
labelText.setAttribute("x", String(lx + padX));
labelText.setAttribute("y", String(ly + labelH - Math.round(fontPx * 0.35)));
labelG.appendChild(labelRect);
labelG.appendChild(labelText);
svg.appendChild(labelG);
// X delete badge at the top-right corner of the bbox. Always visible
// while a bbox exists so the user can drop it in one click without
// hunting for the caption button. Drops inside the bbox if there's
// no room above; pulls left if it would overflow the image's right
// edge.
{
const badgeR = Math.max(10, Math.round(natW / 90));
let bx = x2;
let by = y1;
if (by - badgeR < 0) by = y1 + badgeR + 2;
if (bx + badgeR > natW) bx = Math.max(badgeR + 2, natW - badgeR - 2);
const badgeG = document.createElementNS(SVG_NS, "g") as SVGGElement;
badgeG.style.cursor = "pointer";
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", String(bx));
circle.setAttribute("cy", String(by));
circle.setAttribute("r", String(badgeR));
circle.setAttribute("fill", "#c0392b");
circle.setAttribute("stroke", "#fff");
circle.setAttribute("stroke-width", "2");
// "X" stroke
const x1Stroke = document.createElementNS(SVG_NS, "line");
const x2Stroke = document.createElementNS(SVG_NS, "line");
const armLen = badgeR * 0.55;
x1Stroke.setAttribute("x1", String(bx - armLen));
x1Stroke.setAttribute("y1", String(by - armLen));
x1Stroke.setAttribute("x2", String(bx + armLen));
x1Stroke.setAttribute("y2", String(by + armLen));
x1Stroke.setAttribute("stroke", "#fff");
x1Stroke.setAttribute("stroke-width", "3");
x1Stroke.setAttribute("stroke-linecap", "round");
x2Stroke.setAttribute("x1", String(bx + armLen));
x2Stroke.setAttribute("y1", String(by - armLen));
x2Stroke.setAttribute("x2", String(bx - armLen));
x2Stroke.setAttribute("y2", String(by + armLen));
x2Stroke.setAttribute("stroke", "#fff");
x2Stroke.setAttribute("stroke-width", "3");
x2Stroke.setAttribute("stroke-linecap", "round");
badgeG.appendChild(circle);
badgeG.appendChild(x1Stroke);
badgeG.appendChild(x2Stroke);
// Mark elements so onDown can route the pointer event to delete
// instead of starting a drag.
(badgeG as SVGGElement & { dataset: DOMStringMap }).dataset.role =
"delete";
(circle as SVGCircleElement & { dataset: DOMStringMap }).dataset.role =
"delete";
(x1Stroke as SVGLineElement & { dataset: DOMStringMap }).dataset.role =
"delete";
(x2Stroke as SVGLineElement & { dataset: DOMStringMap }).dataset.role =
"delete";
svg.appendChild(badgeG);
}
// Handles only appear when the bbox is "selected". Drawing or
// clicking outside leaves it unselected (just rect + label) so the
// user can tell it's saved. Click on the rect to bring handles back.
if (selected) {
const handleDefs: Array<{
x: number;
y: number;
dir: string;
cursor: string;
}> = [
{ x: x1, y: y1, dir: "nw", cursor: "nwse-resize" },
{ x: x2, y: y1, dir: "ne", cursor: "nesw-resize" },
{ x: x1, y: y2, dir: "sw", cursor: "nesw-resize" },
{ x: x2, y: y2, dir: "se", cursor: "nwse-resize" },
{ x: (x1 + x2) / 2, y: y1, dir: "n", cursor: "ns-resize" },
{ x: (x1 + x2) / 2, y: y2, dir: "s", cursor: "ns-resize" },
{ x: x1, y: (y1 + y2) / 2, dir: "w", cursor: "ew-resize" },
{ x: x2, y: (y1 + y2) / 2, dir: "e", cursor: "ew-resize" },
];
const handleR = Math.max(6, Math.round(natW / 120));
for (const h of handleDefs) {
const c = document.createElementNS(
SVG_NS,
"circle",
) as SVGCircleElement;
c.setAttribute("cx", String(h.x));
c.setAttribute("cy", String(h.y));
c.setAttribute("r", String(handleR));
c.setAttribute("fill", "#fff");
c.setAttribute("stroke", "#27ae60");
c.setAttribute("stroke-width", "2");
c.style.cursor = h.cursor;
c.dataset.dir = h.dir;
svg.appendChild(c);
handles.push(c);
}
}
};
// Brief toast inside the SVG so the user can see save success/failure
// without scanning the global status bar.
let toastTimer: number | null = null;
const showToast = (text: string, ok: boolean) => {
if (!svg) return;
if (toastTimer !== null) {
window.clearTimeout(toastTimer);
toastTimer = null;
}
const existing = svg.querySelector("g[data-toast]");
if (existing) existing.remove();
const natW = img.naturalWidth;
const fontPx = Math.max(14, Math.round(natW / 80));
const g = document.createElementNS(SVG_NS, "g") as SVGGElement;
g.setAttribute("data-toast", "1");
const r = document.createElementNS(SVG_NS, "rect");
const approxW = Math.max(80, Math.round(fontPx * 0.6 * text.length));
const labelH = Math.round(fontPx * 1.4);
const x = Math.max(0, natW - approxW - 8);
const y = 8;
r.setAttribute("x", String(x));
r.setAttribute("y", String(y));
r.setAttribute("width", String(approxW));
r.setAttribute("height", String(labelH));
r.setAttribute("fill", ok ? "#27ae60" : "#c0392b");
r.setAttribute("rx", "3");
const t = document.createElementNS(SVG_NS, "text");
t.setAttribute("x", String(x + Math.round(fontPx * 0.4)));
t.setAttribute("y", String(y + labelH - Math.round(fontPx * 0.35)));
t.setAttribute("fill", "#fff");
t.setAttribute("font-size", String(fontPx));
t.setAttribute("font-family", "sans-serif");
t.textContent = text;
g.appendChild(r);
g.appendChild(t);
svg.appendChild(g);
toastTimer = window.setTimeout(() => {
g.remove();
toastTimer = null;
}, 1400);
};
// Persist current bbox to the DB. Skipped if too small (covered by
// backend validation but checked here too so we don't spam errors).
// Surface success/failure via the SVG toast so the user has a clear
// "saved" signal — the export sidecar reads the same DB rows.
const persist = async (): Promise<boolean> => {
if (!bbox) return false;
const { x1, y1, x2, y2 } = bbox;
if (Math.abs(x2 - x1) < 1 || Math.abs(y2 - y1) < 1) return false;
try {
await invoke("set_asset_bbox", {
assetId: asset.id,
slot,
x1,
y1,
x2,
y2,
});
showToast("✓ Saved", true);
return true;
} catch (e) {
console.warn("set_asset_bbox failed", e);
showToast("Save failed", false);
return false;
}
};
// Drag state: kind tells what interaction is in flight. `started` flips
// to true once the pointer moves past a few image-pixels of slop —
// before that, we treat a release as a CLICK (toggles selected) rather
// than a save (so the user can click the rect to select without
// accidentally re-saving it). For "draw" we additionally need the bbox
// not to render until a real drag, so tiny click-on-empty doesn't
// pollute the canvas with a 1px-wide bbox.
type DragMode =
| { kind: "move"; ox: number; oy: number; started: boolean; ix0: number; iy0: number }
| { kind: "resize"; dir: string; started: boolean; ix0: number; iy0: number }
| { kind: "draw"; x0: number; y0: number; started: boolean; ix0: number; iy0: number }
| null;
let dragMode: DragMode = null;
// Pixel slop in IMAGE coordinates. Scaled by the visible-to-natural
// ratio so 4 screen pixels is the threshold regardless of zoom.
const slop = () => {
const scale = img.naturalWidth / Math.max(1, img.clientWidth);
return 4 * scale;
};
const onDown = (ev: PointerEvent) => {
if (!svg) return;
const [ix, iy] = ptToImg(ev.clientX, ev.clientY);
const target = ev.target as SVGElement;
// Click on the X delete badge → clear the bbox and bail before any
// drag state is set up.
const role = target?.dataset?.role;
if (role === "delete" && bbox) {
ev.preventDefault();
(
wrapper as HTMLDivElement & {
_deleteBbox?: () => Promise<void>;
}
)._deleteBbox?.();
return;
}
const dir = target?.dataset?.dir;
ev.preventDefault();
svg.setPointerCapture(ev.pointerId);
if (dir && bbox && selected) {
dragMode = {
kind: "resize",
dir,
started: false,
ix0: ix,
iy0: iy,
};
return;
}
if (target === rect && bbox) {
dragMode = {
kind: "move",
ox: ix - bbox.x1,
oy: iy - bbox.y1,
started: false,
ix0: ix,
iy0: iy,
};
return;
}
// Pointer started in empty SVG space. We don't create a bbox here —
// wait for the first real movement so a stray click doesn't replace
// an existing bbox with a 1px stub.
dragMode = {
kind: "draw",
x0: ix,
y0: iy,
started: false,
ix0: ix,
iy0: iy,
};
};
const onMove = (ev: PointerEvent) => {
if (!dragMode || !svg) return;
const [ix, iy] = ptToImg(ev.clientX, ev.clientY);
const natW = img.naturalWidth;
const natH = img.naturalHeight;
const clamp = (v: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, v));
// Promote the drag once the pointer has moved past the click slop.
if (!dragMode.started) {
if (
Math.abs(ix - dragMode.ix0) < slop() &&
Math.abs(iy - dragMode.iy0) < slop()
) {
return;
}
dragMode.started = true;
// First real movement on "draw" creates the bbox seed and selects
// it so the user can keep dragging to size it.
if (dragMode.kind === "draw") {
bbox = {
x1: dragMode.x0,
y1: dragMode.y0,
x2: dragMode.x0,
y2: dragMode.y0,
};
selected = true;
}
}
if (!bbox) return;
if (dragMode.kind === "draw") {
bbox.x1 = Math.min(dragMode.x0, ix);
bbox.y1 = Math.min(dragMode.y0, iy);
bbox.x2 = Math.max(dragMode.x0, ix);
bbox.y2 = Math.max(dragMode.y0, iy);
} else if (dragMode.kind === "move") {
const w = bbox.x2 - bbox.x1;
const h = bbox.y2 - bbox.y1;
let nx1 = ix - dragMode.ox;
let ny1 = iy - dragMode.oy;
nx1 = clamp(nx1, 0, natW - w);
ny1 = clamp(ny1, 0, natH - h);
bbox.x1 = nx1;
bbox.y1 = ny1;
bbox.x2 = nx1 + w;
bbox.y2 = ny1 + h;
} else {
const d = dragMode.dir;
if (d.includes("w")) bbox.x1 = clamp(ix, 0, bbox.x2 - 1);
if (d.includes("e")) bbox.x2 = clamp(ix, bbox.x1 + 1, natW);
if (d.includes("n")) bbox.y1 = clamp(iy, 0, bbox.y2 - 1);
if (d.includes("s")) bbox.y2 = clamp(iy, bbox.y1 + 1, natH);
}
// Clamp drawn bbox to image
bbox.x1 = clamp(bbox.x1, 0, natW);
bbox.x2 = clamp(bbox.x2, 0, natW);
bbox.y1 = clamp(bbox.y1, 0, natH);
bbox.y2 = clamp(bbox.y2, 0, natH);
render();
};
const onUp = (ev: PointerEvent) => {
if (!dragMode) return;
const mode = dragMode;
dragMode = null;
if (svg?.hasPointerCapture(ev.pointerId)) {
svg.releasePointerCapture(ev.pointerId);
}
if (!mode.started) {
// No real drag — it was a click.
if (mode.kind === "move") {
// Click on the rect: toggle selection.
selected = !selected;
render();
return;
}
if (mode.kind === "draw") {
// Click on empty space: deselect if anything was selected.
if (selected) {
selected = false;
render();
}
return;
}
// resize-click without movement → ignore.
return;
}
// Real drag completed. Normalize, validate, persist, and exit edit
// mode so the saved bbox renders as a clean rectangle.
if (bbox) {
if (bbox.x2 < bbox.x1) [bbox.x1, bbox.x2] = [bbox.x2, bbox.x1];
if (bbox.y2 < bbox.y1) [bbox.y1, bbox.y2] = [bbox.y2, bbox.y1];
}
if (bbox && Math.abs(bbox.x2 - bbox.x1) < 2) {
bbox = null;
selected = false;
render();
return;
}
selected = false;
render();
// Fire-and-forget but the toast inside persist() gives a clear
// success/failure signal; export reads from the same DB rows.
void persist();
};
// Wait for image to be loaded before computing natural dims.
if (img.complete && img.naturalWidth > 0) {
setup();
} else {
img.addEventListener("load", () => setup(), { once: true });
}
// Attach pointer listeners on wrapper after svg exists.
// We attach them right away on wrapper-level so the events still fire
// even before setup() runs (no-ops while svg is null).
wrapper.addEventListener("pointerdown", (ev) => {
if (svg) onDown(ev as PointerEvent);
});
wrapper.addEventListener("pointermove", (ev) => {
if (svg) onMove(ev as PointerEvent);
});
wrapper.addEventListener("pointerup", (ev) => {
if (svg) onUp(ev as PointerEvent);
});
wrapper.addEventListener("pointercancel", (ev) => {
if (svg) onUp(ev as PointerEvent);
});
// Expose a delete handle by attaching a property on the wrapper so the
// caller (showVertexPopup) can render a "Delete bbox" button that
// invokes it.
(
wrapper as HTMLDivElement & {
_deleteBbox?: () => Promise<void>;
_hasBbox?: () => boolean;
}
)._deleteBbox = async () => {
try {
await invoke("clear_asset_bbox", { assetId: asset.id, slot });
} catch (e) {
console.warn("clear_asset_bbox failed", e);
}
bbox = null;
render();
};
(
wrapper as HTMLDivElement & { _hasBbox?: () => boolean }
)._hasBbox = () => bbox !== null;
}
const showVertexPopup = (
lng: number,
lat: number,
url: string | null | undefined,
kindLabel: string,
slot: "fixed" | "start" | "mid" | "end",
) => {
if (activePopup) activePopup.remove();
if (!url) return;
@@ -1639,6 +2261,17 @@ export function MapView({
root.className = "asset-popup";
const grid = document.createElement("div");
grid.className = "popup-imgs";
// BBox mode wraps the image so the SVG overlay can sit on top using
// absolute positioning. Without bbox mode, image lives directly in the
// grid (original layout).
const wrapper = bboxMode
? (() => {
const w = document.createElement("div");
w.style.position = "relative";
w.style.display = "inline-block";
return w;
})()
: null;
const img = document.createElement("img");
img.src = resolveImageSrc(url, imageFolder);
img.alt = "asset preview";
@@ -1647,55 +2280,68 @@ export function MapView({
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
"high";
// The image uses object-fit:cover so the container's aspect is fixed.
// Bias the visible crop toward the side the asset is on so the user
// sees the relevant edge instead of the centre being cropped.
const sideKeyImg = (selectedAsset.side ?? "").toLowerCase();
img.style.objectPosition =
sideKeyImg === "left" || sideKeyImg === "lhs"
? "left center"
: sideKeyImg === "right" || sideKeyImg === "rhs"
? "right center"
: "center center";
img.style.cursor = "ew-resize";
img.title = "Drag horizontally to pan the cropped window";
// Click-and-drag updates object-position-x so the user can pan to any
// part of the source image — useful when the side-aware default still
// hides what they want to see. Pointer capture keeps the gesture
// confined to this element, so popup teardown removes the handlers
// automatically.
const sideDefaultPct =
sideKeyImg === "left" || sideKeyImg === "lhs"
? 0
: sideKeyImg === "right" || sideKeyImg === "rhs"
? 100
: 50;
let curPct = sideDefaultPct;
let dragX0: number | null = null;
let dragPos0 = sideDefaultPct;
img.addEventListener("pointerdown", (ev) => {
ev.preventDefault();
dragX0 = ev.clientX;
dragPos0 = curPct;
img.setPointerCapture(ev.pointerId);
});
img.addEventListener("pointermove", (ev) => {
if (dragX0 === null) return;
const w = img.clientWidth || 1;
const dx = ev.clientX - dragX0;
const next = Math.max(0, Math.min(100, dragPos0 - (dx / w) * 100));
curPct = next;
img.style.objectPosition = `${next}% center`;
});
const endDrag = (ev: PointerEvent) => {
dragX0 = null;
if (img.hasPointerCapture(ev.pointerId)) {
img.releasePointerCapture(ev.pointerId);
}
};
img.addEventListener("pointerup", endDrag);
img.addEventListener("pointercancel", endDrag);
grid.appendChild(img);
if (bboxMode) {
// BBox editing needs the FULL image visible; switch to contain so the
// SVG overlay maps to known pixel space, and skip the pan-drag UX.
img.style.objectFit = "contain";
img.style.objectPosition = "center center";
} else {
// The image uses object-fit:cover so the container's aspect is fixed.
// Bias the visible crop toward the side the asset is on so the user
// sees the relevant edge instead of the centre being cropped.
const sideKeyImg = (selectedAsset.side ?? "").toLowerCase();
img.style.objectPosition =
sideKeyImg === "left" || sideKeyImg === "lhs"
? "left center"
: sideKeyImg === "right" || sideKeyImg === "rhs"
? "right center"
: "center center";
img.style.cursor = "ew-resize";
img.title = "Drag horizontally to pan the cropped window";
// Click-and-drag updates object-position-x so the user can pan to any
// part of the source image — useful when the side-aware default still
// hides what they want to see. Pointer capture keeps the gesture
// confined to this element, so popup teardown removes the handlers
// automatically.
const sideDefaultPct =
sideKeyImg === "left" || sideKeyImg === "lhs"
? 0
: sideKeyImg === "right" || sideKeyImg === "rhs"
? 100
: 50;
let curPct = sideDefaultPct;
let dragX0: number | null = null;
let dragPos0 = sideDefaultPct;
img.addEventListener("pointerdown", (ev) => {
ev.preventDefault();
dragX0 = ev.clientX;
dragPos0 = curPct;
img.setPointerCapture(ev.pointerId);
});
img.addEventListener("pointermove", (ev) => {
if (dragX0 === null) return;
const w = img.clientWidth || 1;
const dx = ev.clientX - dragX0;
const next = Math.max(0, Math.min(100, dragPos0 - (dx / w) * 100));
curPct = next;
img.style.objectPosition = `${next}% center`;
});
const endDrag = (ev: PointerEvent) => {
dragX0 = null;
if (img.hasPointerCapture(ev.pointerId)) {
img.releasePointerCapture(ev.pointerId);
}
};
img.addEventListener("pointerup", endDrag);
img.addEventListener("pointercancel", endDrag);
}
if (wrapper) {
wrapper.appendChild(img);
installBboxOverlay(wrapper, img, selectedAsset, slot);
grid.appendChild(wrapper);
} else {
grid.appendChild(img);
}
root.appendChild(grid);
const cap = document.createElement("div");
@@ -1731,6 +2377,44 @@ export function MapView({
small.appendChild(vidSpan);
}
cap.appendChild(small);
if (bboxMode && wrapper) {
cap.appendChild(document.createElement("br"));
const badge = document.createElement("span");
badge.textContent = "BBox mode";
badge.style.fontSize = "10px";
badge.style.fontWeight = "700";
badge.style.background = "#27ae60";
badge.style.color = "#fff";
badge.style.padding = "1px 6px";
badge.style.borderRadius = "10px";
badge.style.marginRight = "6px";
cap.appendChild(badge);
const hint = document.createElement("span");
hint.textContent =
"drag to draw / move / resize · click corner to resize";
hint.style.fontSize = "10px";
hint.style.color = "#888";
cap.appendChild(hint);
cap.appendChild(document.createElement("br"));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.textContent = "Delete bbox";
delBtn.style.marginTop = "4px";
delBtn.style.fontSize = "11px";
delBtn.style.padding = "2px 8px";
delBtn.style.border = "1px solid #c0392b";
delBtn.style.background = "#fff";
delBtn.style.color = "#c0392b";
delBtn.style.borderRadius = "3px";
delBtn.style.cursor = "pointer";
delBtn.addEventListener("click", () => {
const w = wrapper as HTMLDivElement & {
_deleteBbox?: () => Promise<void>;
};
w._deleteBbox?.();
});
cap.appendChild(delBtn);
}
root.appendChild(cap);
// Anchor the popup so it extends toward the side the asset is on:
@@ -1811,7 +2495,13 @@ export function MapView({
el.appendChild(pin);
el.addEventListener("click", (ev) => {
ev.stopPropagation();
showVertexPopup(v.lng as number, v.lat as number, v.img, verboseFor(v.kind));
showVertexPopup(
v.lng as number,
v.lat as number,
v.img,
verboseFor(v.kind),
v.kind as BboxSlot,
);
});
const m = new maplibregl.Marker({ element: el, draggable: true })
.setLngLat([v.lng, v.lat])
@@ -1827,7 +2517,7 @@ export function MapView({
if (selectedAsset.asset_type === "fixed") {
const v = verts[0];
if (v.lat !== null && v.lng !== null) {
showVertexPopup(v.lng, v.lat, v.img, "");
showVertexPopup(v.lng, v.lat, v.img, "", "fixed");
}
}
@@ -1844,7 +2534,7 @@ export function MapView({
markers.forEach((m) => m.remove());
if (activePopup) activePopup.remove();
};
}, [selectedAsset, imageFolder, popupEnabled]);
}, [selectedAsset, imageFolder, popupEnabled, bboxMode]);
useEffect(() => {
const container = containerRef.current;

View File

@@ -138,6 +138,23 @@ const STUBS: Record<string, Stub> = {
}),
list_video_names: () =>
Array.from(new Set(FAKE_ASSETS.map((a) => a.video_name))),
list_videos: () => {
const names = Array.from(
new Set(FAKE_ASSETS.map((a) => a.video_name)),
).sort();
return names.map((video_name, i) => ({
video_name,
video_number: i + 1,
completed: false,
completed_at: null,
completed_by: null,
}));
},
set_video_completed: () => null,
get_asset_bboxes: () => [],
set_asset_bbox: () => null,
clear_asset_bbox: () => null,
export_bboxes_to_json: () => 0,
list_asset_names: () =>
Array.from(new Set(FAKE_ASSETS.map((a) => a.asset_name))),
list_classes: () => ["Streetlight", "Traffic_Sign", "Hydrant", "Pole", "Tree"],