bbox mode
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
])
|
||||
|
||||
518
src/App.tsx
518
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<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
|
||||
|
||||
794
src/MapView.tsx
794
src/MapView.tsx
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user