bbox mode
This commit is contained in:
@@ -355,6 +355,99 @@ async fn insert_range_row(
|
|||||||
Ok(())
|
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> {
|
async fn stamp_orig_positions(pool: &SqlitePool) -> Result<(), String> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE assets
|
"UPDATE assets
|
||||||
@@ -377,13 +470,20 @@ pub async fn import_fixed_assets(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_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())?;
|
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 tx = state.pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
let mut count = 0usize;
|
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?;
|
insert_fixed_row(&mut tx, &input).await?;
|
||||||
|
ingest_bboxes_for_row(&mut tx, &row_id, &raw, false).await?;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
tx.commit().await.map_err(|e| e.to_string())?;
|
tx.commit().await.map_err(|e| e.to_string())?;
|
||||||
@@ -398,13 +498,17 @@ pub async fn import_range_assets(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_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())?;
|
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 tx = state.pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
let mut count = 0usize;
|
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?;
|
insert_range_row(&mut tx, &input).await?;
|
||||||
|
ingest_bboxes_for_row(&mut tx, &row_id, &raw, true).await?;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
tx.commit().await.map_err(|e| e.to_string())?;
|
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"));
|
&& coord_field_valid(row.get("end_coord_lng"));
|
||||||
if is_range {
|
if is_range {
|
||||||
let input: RangeAssetInput =
|
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?;
|
insert_range_row(&mut tx, &input).await?;
|
||||||
|
ingest_bboxes_for_row(&mut tx, &row_id, &row, true).await?;
|
||||||
range_count += 1;
|
range_count += 1;
|
||||||
} else {
|
} else {
|
||||||
let input: FixedAssetInput =
|
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?;
|
insert_fixed_row(&mut tx, &input).await?;
|
||||||
|
ingest_bboxes_for_row(&mut tx, &row_id, &row, false).await?;
|
||||||
fixed_count += 1;
|
fixed_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2924,6 +3032,128 @@ pub async fn list_video_names(
|
|||||||
Ok(rows.into_iter().map(|(n,)| n).collect())
|
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]
|
#[tauri::command]
|
||||||
pub async fn restore_assets(
|
pub async fn restore_assets(
|
||||||
ids: Vec<i64>,
|
ids: Vec<i64>,
|
||||||
@@ -6739,3 +6969,137 @@ pub async fn quality_report(
|
|||||||
missing_side_total,
|
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)
|
.execute(pool)
|
||||||
.await?;
|
.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(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS action_history (
|
"CREATE TABLE IF NOT EXISTS action_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use commands::{
|
|||||||
delete_roads_in_lasso, set_link, simplify_road,
|
delete_roads_in_lasso, set_link, simplify_road,
|
||||||
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
|
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
|
||||||
save_video_metadata, get_video_metadata, clear_video_metadata,
|
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,
|
flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets,
|
||||||
set_road_snap_ignored, osm_road_snapshot, restore_road_state,
|
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,
|
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,
|
save_video_metadata,
|
||||||
get_video_metadata,
|
get_video_metadata,
|
||||||
clear_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,
|
set_link,
|
||||||
clear_link
|
clear_link
|
||||||
])
|
])
|
||||||
|
|||||||
500
src/App.tsx
500
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 IMAGE_FOLDER_KEY = "kml_map_tool.image_folder";
|
||||||
const POPUP_SIZE_KEY = "kml_map_tool.popup_size";
|
const POPUP_SIZE_KEY = "kml_map_tool.popup_size";
|
||||||
const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled";
|
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 POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect";
|
||||||
const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m";
|
const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m";
|
||||||
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
|
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
|
||||||
@@ -303,6 +304,13 @@ function AppShell({
|
|||||||
const v = window.localStorage.getItem(POPUP_ENABLED_KEY);
|
const v = window.localStorage.getItem(POPUP_ENABLED_KEY);
|
||||||
return v === null ? true : v === "1";
|
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>(() => {
|
const [popupAspect, setPopupAspect] = useState<PopupAspect>(() => {
|
||||||
if (typeof window === "undefined") return "4:3";
|
if (typeof window === "undefined") return "4:3";
|
||||||
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
|
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
|
||||||
@@ -1040,12 +1048,12 @@ function AppShell({
|
|||||||
.catch((err) => setStatus(`Vertex delete failed: ${err}`));
|
.catch((err) => setStatus(`Vertex delete failed: ${err}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Bulk hide: in focused-video OSM mode and not in vertex-edit submode,
|
// Bulk action on selected roads in OSM mode (not in vertex-edit submode):
|
||||||
// Del moves the multi-select set into the hidden bucket.
|
// focusedVideo != null → hide (non-destructive, video-scoped)
|
||||||
|
// focusedVideo == null → real DB delete with snapshot-based undo
|
||||||
if (
|
if (
|
||||||
(e.key === "Delete" || e.key === "Backspace") &&
|
(e.key === "Delete" || e.key === "Backspace") &&
|
||||||
osmEditMode &&
|
osmEditMode &&
|
||||||
focusedVideo !== null &&
|
|
||||||
editingRoadId === null &&
|
editingRoadId === null &&
|
||||||
selectedRoadIds.size > 0
|
selectedRoadIds.size > 0
|
||||||
) {
|
) {
|
||||||
@@ -1056,6 +1064,7 @@ function AppShell({
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const ids = Array.from(selectedRoadIds);
|
const ids = Array.from(selectedRoadIds);
|
||||||
|
if (focusedVideo !== null) {
|
||||||
setHiddenRoadIds((prev) => {
|
setHiddenRoadIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
for (const id of ids) next.add(id);
|
for (const id of ids) next.add(id);
|
||||||
@@ -1066,6 +1075,27 @@ function AppShell({
|
|||||||
setStatus(`${ids.length} road(s) hidden.`);
|
setStatus(`${ids.length} road(s) hidden.`);
|
||||||
return;
|
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) {
|
if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
const tag = target?.tagName;
|
const tag = target?.tagName;
|
||||||
@@ -1093,6 +1123,46 @@ function AppShell({
|
|||||||
toggleOsmEditModeRef.current();
|
toggleOsmEditModeRef.current();
|
||||||
return;
|
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 (e.key !== "Escape") return;
|
||||||
if (lensActive) {
|
if (lensActive) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1332,6 +1402,7 @@ function AppShell({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshDataCounts().catch(() => {}),
|
refreshDataCounts().catch(() => {}),
|
||||||
refreshVideoNames().catch(() => {}),
|
refreshVideoNames().catch(() => {}),
|
||||||
|
refreshVideoEntries().catch(() => {}),
|
||||||
refreshAssetNames().catch(() => {}),
|
refreshAssetNames().catch(() => {}),
|
||||||
]);
|
]);
|
||||||
setStatus("Database reset.");
|
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() {
|
async function refreshVideoNames() {
|
||||||
try {
|
try {
|
||||||
const raw = await invoke<string[]>("list_video_names");
|
const raw = await invoke<string[]>("list_video_names");
|
||||||
@@ -1550,6 +1642,7 @@ function AppShell({
|
|||||||
refreshActions(),
|
refreshActions(),
|
||||||
refreshAssetNames(),
|
refreshAssetNames(),
|
||||||
refreshVideoNames(),
|
refreshVideoNames(),
|
||||||
|
refreshVideoEntries(),
|
||||||
refreshClasses(),
|
refreshClasses(),
|
||||||
refreshDataCounts(),
|
refreshDataCounts(),
|
||||||
];
|
];
|
||||||
@@ -1559,6 +1652,56 @@ function AppShell({
|
|||||||
|
|
||||||
// Fly the map to a specific video (uses any combination of metadata polyline
|
// Fly the map to a specific video (uses any combination of metadata polyline
|
||||||
// + asset bboxes, whichever exists).
|
// + 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) {
|
function flyToVideo(name: string) {
|
||||||
let mnLat = Infinity,
|
let mnLat = Infinity,
|
||||||
mxLat = -Infinity,
|
mxLat = -Infinity,
|
||||||
@@ -1674,6 +1817,7 @@ function AppShell({
|
|||||||
refreshActions().catch(() => {});
|
refreshActions().catch(() => {});
|
||||||
refreshAssetNames().catch(() => {});
|
refreshAssetNames().catch(() => {});
|
||||||
refreshVideoNames().catch(() => {});
|
refreshVideoNames().catch(() => {});
|
||||||
|
refreshVideoEntries().catch(() => {});
|
||||||
refreshClasses().catch(() => {});
|
refreshClasses().catch(() => {});
|
||||||
refreshDataCounts().catch(() => {});
|
refreshDataCounts().catch(() => {});
|
||||||
// Per-video metadata persists in DB; load it so users don't have to
|
// 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)`);
|
setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshVideoNames(),
|
refreshVideoNames(),
|
||||||
|
refreshVideoEntries(),
|
||||||
refreshAssetNames(),
|
refreshAssetNames(),
|
||||||
refreshClasses(),
|
refreshClasses(),
|
||||||
]);
|
]);
|
||||||
@@ -3048,6 +3193,7 @@ function AppShell({
|
|||||||
// Pull in the new video / class names so the filter auto-includes them.
|
// Pull in the new video / class names so the filter auto-includes them.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshVideoNames(),
|
refreshVideoNames(),
|
||||||
|
refreshVideoEntries(),
|
||||||
refreshAssetNames(),
|
refreshAssetNames(),
|
||||||
refreshClasses(),
|
refreshClasses(),
|
||||||
]);
|
]);
|
||||||
@@ -3281,6 +3427,7 @@ function AppShell({
|
|||||||
onPlaceAt={handlePlaceAt}
|
onPlaceAt={handlePlaceAt}
|
||||||
imageFolder={imageFolder}
|
imageFolder={imageFolder}
|
||||||
popupEnabled={popupEnabled}
|
popupEnabled={popupEnabled}
|
||||||
|
bboxMode={bboxMode}
|
||||||
perfMode={perfMode}
|
perfMode={perfMode}
|
||||||
anchorIds={Array.from(anchors.keys())}
|
anchorIds={Array.from(anchors.keys())}
|
||||||
colorBy={colorBy}
|
colorBy={colorBy}
|
||||||
@@ -3305,11 +3452,28 @@ function AppShell({
|
|||||||
setStatus(`Editing road #${id}. Drag the gold vertex dots.`);
|
setStatus(`Editing road #${id}. Drag the gold vertex dots.`);
|
||||||
}}
|
}}
|
||||||
onRoadRightClick={(id) => {
|
onRoadRightClick={(id) => {
|
||||||
// Right-click deletes the road from the DB. Snapshot first so the
|
// Behavior depends on whether a video is focused:
|
||||||
// OSM undo ring can resurrect it via restore_road_state's upsert
|
// focusedVideo != null → hide for this video (non-destructive,
|
||||||
// path (re-inserts the row with its original id + modified flag).
|
// frontend-only, undoable via the unhide entry).
|
||||||
// Hide-by-Del in focused-video mode is a separate, non-destructive
|
// focusedVideo == null → real DB delete with undo via the
|
||||||
// path.
|
// 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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await snapshotRoadsForUndo([id], "delete road (right-click)");
|
await snapshotRoadsForUndo([id], "delete road (right-click)");
|
||||||
@@ -3386,6 +3550,7 @@ function AppShell({
|
|||||||
onPositionChange={handlePositionChange}
|
onPositionChange={handlePositionChange}
|
||||||
imageFolder={imageFolder}
|
imageFolder={imageFolder}
|
||||||
popupEnabled={popupEnabled}
|
popupEnabled={popupEnabled}
|
||||||
|
bboxMode={bboxMode}
|
||||||
perfMode={perfMode}
|
perfMode={perfMode}
|
||||||
anchorIds={Array.from(anchors.keys())}
|
anchorIds={Array.from(anchors.keys())}
|
||||||
colorBy={
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -3597,47 +3852,118 @@ function AppShell({
|
|||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ maxHeight: 240, overflowY: "auto" }}>
|
{(() => {
|
||||||
{Array.from(
|
// Merge: videoEntries is the authoritative ordered list from
|
||||||
new Set(
|
// the backend (number + completion). Names present only in
|
||||||
allVideoNames.concat(Object.keys(metadataByVideo)),
|
// 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()
|
.sort()
|
||||||
.filter((v) =>
|
.map(
|
||||||
videoSearch.trim() === ""
|
(v, i): VideoEntry => ({
|
||||||
? true
|
video_name: v,
|
||||||
: v
|
video_number: videoEntries.length + i + 1,
|
||||||
.toLowerCase()
|
completed: false,
|
||||||
.includes(videoSearch.trim().toLowerCase()),
|
completed_at: null,
|
||||||
)
|
completed_by: null,
|
||||||
.map((v) => {
|
}),
|
||||||
const n = counts.byVideo.get(v) ?? 0;
|
);
|
||||||
return [v, n] as [string, number];
|
const all = videoEntries.concat(extras);
|
||||||
})
|
const term = videoSearch.trim().toLowerCase();
|
||||||
.map(([v, n]) => (
|
const matchSearch = (e: VideoEntry) => {
|
||||||
<label key={v} className="filter-row">
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={filters.videos.has(v)}
|
checked={filters.videos.has(label)}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
setFilters((f) => ({
|
setFilters((f) => ({
|
||||||
...f,
|
...f,
|
||||||
videos: toggleSet(f.videos, v),
|
videos: toggleSet(f.videos, label),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="label" title={v}>
|
<span
|
||||||
{v}
|
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>
|
||||||
<span className="count">{n}</span>
|
<span className="count">{n}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(ev) => {
|
||||||
e.preventDefault();
|
ev.preventDefault();
|
||||||
flyToVideo(v);
|
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={{
|
style={{
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
padding: "0 6px",
|
padding: "0 6px",
|
||||||
@@ -3651,8 +3977,60 @@ function AppShell({
|
|||||||
⤢
|
⤢
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</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
|
Show image popup when an asset is selected
|
||||||
</label>
|
</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>
|
<h4 style={{ marginTop: 12 }}>Quality thresholds</h4>
|
||||||
<label
|
<label
|
||||||
|
|||||||
696
src/MapView.tsx
696
src/MapView.tsx
@@ -12,6 +12,7 @@ import { PathStyleExtension } from "@deck.gl/extensions";
|
|||||||
import { basemapById } from "./basemaps";
|
import { basemapById } from "./basemaps";
|
||||||
import { videoColorRGB } from "./colors";
|
import { videoColorRGB } from "./colors";
|
||||||
import { resolveImageSrc } from "./imagePath";
|
import { resolveImageSrc } from "./imagePath";
|
||||||
|
import { invoke } from "./tauriShim";
|
||||||
import type { Asset, ScopePolygon } from "./types";
|
import type { Asset, ScopePolygon } from "./types";
|
||||||
|
|
||||||
export type LayerVisibility = {
|
export type LayerVisibility = {
|
||||||
@@ -72,6 +73,7 @@ type Props = {
|
|||||||
onLinkClick?: (idA: number, idB: number) => void;
|
onLinkClick?: (idA: number, idB: number) => void;
|
||||||
onMetadataLineClick?: (videoName: string) => void;
|
onMetadataLineClick?: (videoName: string) => void;
|
||||||
popupEnabled?: boolean;
|
popupEnabled?: boolean;
|
||||||
|
bboxMode?: boolean;
|
||||||
perfMode?: boolean;
|
perfMode?: boolean;
|
||||||
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
|
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
|
||||||
placeMode?: boolean;
|
placeMode?: boolean;
|
||||||
@@ -144,6 +146,7 @@ export function MapView({
|
|||||||
onLinkClick,
|
onLinkClick,
|
||||||
onMetadataLineClick,
|
onMetadataLineClick,
|
||||||
popupEnabled = true,
|
popupEnabled = true,
|
||||||
|
bboxMode = false,
|
||||||
perfMode = false,
|
perfMode = false,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
placeMode = false,
|
placeMode = false,
|
||||||
@@ -1626,11 +1629,630 @@ export function MapView({
|
|||||||
? "Mid"
|
? "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 = (
|
const showVertexPopup = (
|
||||||
lng: number,
|
lng: number,
|
||||||
lat: number,
|
lat: number,
|
||||||
url: string | null | undefined,
|
url: string | null | undefined,
|
||||||
kindLabel: string,
|
kindLabel: string,
|
||||||
|
slot: "fixed" | "start" | "mid" | "end",
|
||||||
) => {
|
) => {
|
||||||
if (activePopup) activePopup.remove();
|
if (activePopup) activePopup.remove();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -1639,6 +2261,17 @@ export function MapView({
|
|||||||
root.className = "asset-popup";
|
root.className = "asset-popup";
|
||||||
const grid = document.createElement("div");
|
const grid = document.createElement("div");
|
||||||
grid.className = "popup-imgs";
|
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");
|
const img = document.createElement("img");
|
||||||
img.src = resolveImageSrc(url, imageFolder);
|
img.src = resolveImageSrc(url, imageFolder);
|
||||||
img.alt = "asset preview";
|
img.alt = "asset preview";
|
||||||
@@ -1647,6 +2280,12 @@ export function MapView({
|
|||||||
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
||||||
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
||||||
"high";
|
"high";
|
||||||
|
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.
|
// 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
|
// Bias the visible crop toward the side the asset is on so the user
|
||||||
// sees the relevant edge instead of the centre being cropped.
|
// sees the relevant edge instead of the centre being cropped.
|
||||||
@@ -1695,7 +2334,14 @@ export function MapView({
|
|||||||
};
|
};
|
||||||
img.addEventListener("pointerup", endDrag);
|
img.addEventListener("pointerup", endDrag);
|
||||||
img.addEventListener("pointercancel", endDrag);
|
img.addEventListener("pointercancel", endDrag);
|
||||||
|
}
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.appendChild(img);
|
||||||
|
installBboxOverlay(wrapper, img, selectedAsset, slot);
|
||||||
|
grid.appendChild(wrapper);
|
||||||
|
} else {
|
||||||
grid.appendChild(img);
|
grid.appendChild(img);
|
||||||
|
}
|
||||||
root.appendChild(grid);
|
root.appendChild(grid);
|
||||||
|
|
||||||
const cap = document.createElement("div");
|
const cap = document.createElement("div");
|
||||||
@@ -1731,6 +2377,44 @@ export function MapView({
|
|||||||
small.appendChild(vidSpan);
|
small.appendChild(vidSpan);
|
||||||
}
|
}
|
||||||
cap.appendChild(small);
|
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);
|
root.appendChild(cap);
|
||||||
|
|
||||||
// Anchor the popup so it extends toward the side the asset is on:
|
// Anchor the popup so it extends toward the side the asset is on:
|
||||||
@@ -1811,7 +2495,13 @@ export function MapView({
|
|||||||
el.appendChild(pin);
|
el.appendChild(pin);
|
||||||
el.addEventListener("click", (ev) => {
|
el.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
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 })
|
const m = new maplibregl.Marker({ element: el, draggable: true })
|
||||||
.setLngLat([v.lng, v.lat])
|
.setLngLat([v.lng, v.lat])
|
||||||
@@ -1827,7 +2517,7 @@ export function MapView({
|
|||||||
if (selectedAsset.asset_type === "fixed") {
|
if (selectedAsset.asset_type === "fixed") {
|
||||||
const v = verts[0];
|
const v = verts[0];
|
||||||
if (v.lat !== null && v.lng !== null) {
|
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());
|
markers.forEach((m) => m.remove());
|
||||||
if (activePopup) activePopup.remove();
|
if (activePopup) activePopup.remove();
|
||||||
};
|
};
|
||||||
}, [selectedAsset, imageFolder, popupEnabled]);
|
}, [selectedAsset, imageFolder, popupEnabled, bboxMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|||||||
@@ -138,6 +138,23 @@ const STUBS: Record<string, Stub> = {
|
|||||||
}),
|
}),
|
||||||
list_video_names: () =>
|
list_video_names: () =>
|
||||||
Array.from(new Set(FAKE_ASSETS.map((a) => a.video_name))),
|
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: () =>
|
list_asset_names: () =>
|
||||||
Array.from(new Set(FAKE_ASSETS.map((a) => a.asset_name))),
|
Array.from(new Set(FAKE_ASSETS.map((a) => a.asset_name))),
|
||||||
list_classes: () => ["Streetlight", "Traffic_Sign", "Hydrant", "Pole", "Tree"],
|
list_classes: () => ["Streetlight", "Traffic_Sign", "Hydrant", "Pole", "Tree"],
|
||||||
|
|||||||
Reference in New Issue
Block a user