diff --git a/FORMATS.md b/FORMATS.md new file mode 100644 index 0000000..39bca87 --- /dev/null +++ b/FORMATS.md @@ -0,0 +1,301 @@ +# Import & Export Formats + +What each Import button accepts, plus what every Export option produces. Sample snippets included so you can shape data outside the tool and feed it in. + +--- + +## Imports + +### Fixed assets (JSON) +Source-of-truth shape used by the labelling pipeline. Each row is one point asset. + +```json +[ + { + "row_id": "406_473_0.04_1", + "asset_name": "Double_Arm_Street_Light", + "video_name": "20250802150330_000000", + "image_path": "https://auditor-master-images.seekright.com/.../406_473_0.04_1.jpeg", + "coord": ["17.39869", "78.34701"], + "deleted": false, + "side": "Left" + } +] +``` + +| Field | Type | Required | Notes | +|---|---|---|---| +| `row_id` | string | yes | Unique key. Re-import upserts on this. | +| `asset_name` | string | yes | Class label, e.g. `Double_Arm_Street_Light`. | +| `video_name` | string | yes | Video the asset was captured in. `"Not available"` is treated as NA. | +| `image_path` | string \| null | no | HTTPS URL or local path. Used by the popup. | +| `coord` | `[lat, lng]` | yes | Strings or numbers. Order is **lat first**. | +| `deleted` | bool | no | Default `false`. Re-import never resurrects rows the user deleted. | +| `side` | `"Left" \| "Right" \| "LHS" \| "RHS" \| null` | no | If absent, derived from `image_path` (e.g. a `/LHS/` or `/RHS/` segment). | + +Re-import behaviour: rows the user has manually moved (`modified=1`) or deleted are **not** overwritten — only `modified=0` geometry is re-stamped from source. + +### Range assets (JSON) +Same idea but with start / mid / end coordinates and three image paths. + +```json +[ + { + "row_id": "406_473_0.50_2", + "asset_name": "Crash_Barrier", + "video_name": "20250802150330_000000", + "coord_lat": 17.4002, + "coord_lng": 78.3445, + "start_coord_lat": 17.40015, + "start_coord_lng": 78.34448, + "end_coord_lat": 17.40025, + "end_coord_lng": 78.34452, + "image_path1": "https://.../start.jpg", + "image_path2": "https://.../mid.jpg", + "image_path3": "https://.../end.jpg", + "deleted": false, + "side": "Left" + } +] +``` + +`coord_*` may be strings or numbers. Same upsert / preserve rules as fixed assets. + +### KML scope +Standard KML 2.2. `Polygon`s and `LineString`s inside `Placemark`s become scope features. Polygons gate by point-in-polygon; LineStrings are 30 m proximity buffers. + +```xml + + + + + HYDTOT corridor + + 78.34,17.39,0 78.36,17.40,0 78.35,17.41,0 78.34,17.39,0 + + + + +``` + +Importing a new KML replaces the entire scope set. + +### Per-video metadata (JSON) +Multi-video GPS polylines keyed by `video_name`. Used for direction inference (snap-to-road priority: metadata → OSM `oneway` → position fallback) and on-map display. + +```json +{ + "2026_0330_094759_F": [[ + [17.398456, 78.347281], + [17.398522, 78.347233], + [17.398581, 78.347192] + ]], + "2026_0330_095259_F": [[ + [17.410322, 78.320736], + [17.410367, 78.320547] + ]] +} +``` + +Structure: `{ video_name: [[ [lat, lng], … ]] }`. Each value is an array containing **one** polyline (the outer wrapper is for compatibility with multi-segment legacy formats — the importer reads only the first inner array). + +Coordinates are `[lat, lng]`; the importer swaps to `[lng, lat]` internally. Garbage points are filtered out automatically: +- Anything within ~100 m of `(0, 0)`. +- Non-finite or out-of-range lat/lng. +- Jumps > 5 km between consecutive points. + +Stored only in memory (cleared by the **Loaded data → Clear** button or app restart). + +### Metadata polyline (legacy single-track) +Single-video legacy format used by the old FastAPI pipeline. + +```json +{ + "track": [ + [17.398, 78.347], + [17.399, 78.346] + ] +} +``` + +`track` array of `[lat, lng]`. Used as the single source-of-truth track when no per-video map is loaded. Per-video JSON supersedes this. + +### OSM roads (GeoJSON) +A `FeatureCollection` of `LineString` features. Each feature's `properties` are stored on the `roads` table. + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [78.347281, 17.398456], + [78.347233, 17.398522] + ] + }, + "properties": { + "id": 12345, + "name": "ORR Service Road", + "highway": "service", + "oneway": 1 + } + } + ] +} +``` + +| Property | Type | Required | Notes | +|---|---|---|---| +| `id` | integer | no | If absent the importer assigns a sequential id. | +| `name` | string | no | Used by Merge-by-name and the popup. | +| `highway` | string | no | OSM class — `motorway`, `trunk`, `primary`, `service`, etc. Used by Prune. | +| `oneway` | int | no | `1` = forward, `-1` = reverse, `0` (or absent) = undirected. `motorway` and `roundabout` default to `1` if `oneway` is missing. | + +GeoJSON convention: `coordinates` is `[lng, lat]` (the *opposite* of every JSON above). The importer expects the standard order. + +You can also generate this file in-app via **OSM tools… → Generate visible bbox** (uses the Overpass API). + +--- + +## Exports + +The **Export data…** button writes every non-deleted asset in one of four formats. The Save dialog filters by extension. + +### `.json` — Source JSON (round-trippable) +Same shape `import_fixed_assets` and `import_range_assets` accept, split into two arrays so each can be fed back through its matching importer. + +```json +{ + "fixed": [ + { + "row_id": "406_473_0.04_1", + "asset_name": "Double_Arm_Street_Light", + "video_name": "20250802150330_000000", + "image_path": "https://.../406_473_0.04_1.jpeg", + "coord": [17.39869, 78.34701], + "deleted": false, + "side": "Left" + } + ], + "range": [ + { + "row_id": "406_473_0.50_2", + "asset_name": "Crash_Barrier", + "video_name": "20250802150330_000000", + "coord_lat": 17.4002, + "coord_lng": 78.3445, + "start_coord_lat": 17.40015, + "start_coord_lng": 78.34448, + "end_coord_lat": 17.40025, + "end_coord_lng": 78.34452, + "image_path1": "https://.../start.jpg", + "image_path2": "https://.../mid.jpg", + "image_path3": "https://.../end.jpg", + "deleted": false, + "side": "Left" + } + ] +} +``` + +Use this for round-trip backup / sharing edits with a colleague. + +### `.geojson` — FeatureCollection +Standard GeoJSON for any GIS tool. Fixed → `Point`, range → `LineString` over `[start, mid, end]`. + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [78.34701, 17.39869] }, + "properties": { + "row_id": "406_473_0.04_1", + "asset_type": "fixed", + "asset_name": "Double_Arm_Street_Light", + "video_name": "20250802150330_000000", + "side": "Left", + "in_scope": 1, + "modified": 1, + "image_path": "https://.../...jpeg", + "image_path1": null, + "image_path2": null, + "image_path3": null, + "link_pair_id": 882, + "link_locked": 1 + } + } + ] +} +``` + +Use for QGIS / Mapbox / shipping data downstream. + +### `.kml` — KML Placemarks +Each asset is a Placemark with name, description, and geometry. Useful for Google Earth and any KML-aware GIS. + +```xml + + + + Double_Arm_Street_Light · 406_473_0.04_1 + type: fixed +video: 20250802150330_000000 +side: Left +modified: 1 + 78.34701,17.39869,0 + + +``` + +### `.csv` — flat row dump +Spreadsheet-friendly. Header row first. + +``` +id,row_id,asset_type,asset_name,video_name,side,in_scope,deleted,modified,lat,lng,start_lat,start_lng,end_lat,end_lng,link_pair_id,link_locked,image_path,image_path1,image_path2,image_path3 +123,406_473_0.04_1,fixed,Double_Arm_Street_Light,20250802150330_000000,Left,1,0,1,17.39869,78.34701,,,,,,,,https://.../406_473_0.04_1.jpeg,,, +``` + +### `.geojson` — OSM roads (separate export) +**OSM tools… → Export current roads** writes the entire `roads` table as a GeoJSON FeatureCollection. Same shape as the OSM import — feeds straight back into any GIS tool or back into this app on another machine. + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[78.347281, 17.398456], [78.347233, 17.398522]] + }, + "properties": { + "id": 12345, + "name": "ORR Service Road", + "oneway": 1, + "highway": "service" + } + } + ] +} +``` + +--- + +## Coordinate-order cheat sheet + +| Source | Order | +|---|---| +| Fixed asset JSON `coord` | `[lat, lng]` | +| Range asset JSON `coord_*` | scalar lat / lng | +| Per-video metadata JSON | `[lat, lng]` | +| Legacy single-track metadata | `[lat, lng]` | +| KML `` | `lng,lat,alt` (KML standard) | +| GeoJSON anywhere | `[lng, lat]` (GeoJSON standard) | +| App / DB internals | `[lng, lat]` (deck.gl convention) | + +When in doubt: **JSON coords here are lat-first, GeoJSON / KML are lng-first**. The importers swap as needed; the exports always emit the format's native order. diff --git a/README.md b/README.md index 635990f..c33baad 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,47 @@ Range assets (start / mid / end) translate as a rigid body — all three vertice ## OSM road editing +### Modes - **OSM tools…** modal — generate roads for the visible bbox (Overpass), import existing GeoJSON, export current roads, prune small road classes. -- **OSM edit mode** — drag any road's vertex to refine the centerline. Markers are gated by zoom ≥ 14 and capped at 80 vertices / 40 ghost dots per viewport so the map stays responsive. -- **Flip road direction** — cycle a road's `oneway` between 0 / 1 / -1 when it's wrong. +- **Edit OSM roads** button — toggles OSM edit mode. While on, every other layer is dimmed and only OSM roads are interactive. - **Lane offset (m)** — controls the parallel offset used by snap-to-road. Range 0.1–30 m. +### Picking a road to edit +1. Turn on **Edit OSM roads** (right panel → Data section). +2. Zoom in to **z ≥ 14** — vertex handles only render at that zoom or deeper, to keep the map responsive. +3. Click any road. Its yellow `•` vertex pins appear; other roads stay un-pinned. The selected road's id is shown in the OSM-edit panel. + +### Modifying a road +- **Drag a yellow `•` pin** — moves that vertex to wherever you drop it. The change is saved immediately. +- **Click a yellow `+` ghost pin** between two vertices — inserts a new vertex at that midpoint. Useful when an OSM segment is too sparse for the curve. +- **Flip direction** — cycles `oneway` through forward (`1`) → reverse (`-1`) → undirected (`0`). Use when OSM has the heading wrong on a divided road. + +### Deleting vertices +Two tools, depending on scope: + +**A. Surgical — Shift-click + Del** +1. Hold **Shift** and click a vertex pin → it turns red with an `✕`. +2. Repeat to mark more. +3. Press **Del** (or Backspace) → all marked vertices are removed in one shot. +4. The "Currently marked: N" label in the panel shows how many you've flagged. **Clear marks** unselects without deleting. +5. Shift-click a marked vertex again to unmark it individually. +6. The road must keep ≥ 2 vertices; the backend rejects deletes that would break that. + +**B. Sweeping — Simplify by tolerance** +1. Use the **Simplify (m)** slider in the panel (0.5–20 m, default 2 m). Higher = more aggressive. +2. Click **Simplify this road**. Runs Douglas-Peucker on the polyline: any vertex within tolerance of the simplified line is dropped. +3. Status bar reports `before → after` vertex counts. +4. Try 1–2 m for cleanup, 5+ m for heavy decimation. Re-running with the same tolerance is idempotent. + +### Deleting / flipping the whole road +- **Delete this road** — removes the road outright (with confirmation). +- **Deselect road** — exits the per-road editing tool but keeps OSM edit mode on. + +### Notes +- OSM road edits are **not** undoable via the Recent actions panel — they're treated like reference-data tweaks. Be deliberate, especially with delete and simplify. +- Dragging a vertex triggers a single `update_osm_road` write per drag. Bulk delete and simplify are also single writes. +- After a vertex change, the road line refreshes in real time but the surrounding draggable pins re-render — slight visual flicker is normal. + --- ## Snap-to-road diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5981ba2..72ddf1f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -426,6 +426,78 @@ pub async fn import_kml_scope( Ok(parsed.len()) } +/// Read a multi-video metadata polyline file shaped as +/// `{ video_name: [[[lat, lng], ...]] }`. Returns a map from video_name to a +/// `[lng, lat]` track (deck.gl / GeoJSON convention). +#[tauri::command] +pub async fn read_video_polylines_json( + path: String, +) -> Result>, String> { + let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; + let parsed: serde_json::Map = + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {}", e))?; + let mut out: std::collections::HashMap> = + std::collections::HashMap::new(); + for (video_name, val) in parsed { + let outer = match val.as_array() { + Some(a) if !a.is_empty() => a, + _ => continue, + }; + let pts_json = match outer[0].as_array() { + Some(a) => a, + None => continue, + }; + let mut track: Vec<[f64; 2]> = Vec::with_capacity(pts_json.len()); + for pt in pts_json { + let arr = match pt.as_array() { + Some(a) if a.len() >= 2 => a, + _ => continue, + }; + // Source is [lat, lng]; downstream code expects [lng, lat]. + let lat = arr[0].as_f64(); + let lng = arr[1].as_f64(); + if let (Some(la), Some(lo)) = (lat, lng) { + // Drop sentinel / garbage coords. Anything within ~100 m of + // the (0,0) origin is the standard "no GPS fix" placeholder. + if la.abs() < 1e-3 && lo.abs() < 1e-3 { + continue; + } + if !la.is_finite() || !lo.is_finite() { + continue; + } + if !(-90.0..=90.0).contains(&la) || !(-180.0..=180.0).contains(&lo) { + continue; + } + // Break implausible jumps (> ~5 km between consecutive points). + // Most consumer GPS tracks sample faster than that; a long jump + // signals a missing point, so skip it rather than draw a line + // across the map. + if let Some(&[plng, plat]) = track.last() { + let dlat = la - plat; + let dlng = lo - plng; + // Cheap planar approximation in degrees → meters. + let m_per_deg_lat = 111_000.0; + let m_per_deg_lng = 111_000.0 * la.to_radians().cos().max(0.1); + let dist = ((dlat * m_per_deg_lat).powi(2) + + (dlng * m_per_deg_lng).powi(2)) + .sqrt(); + if dist > 5_000.0 { + continue; + } + } + track.push([lo, la]); + } + } + if track.len() >= 2 { + out.insert(video_name, track); + } + } + if out.is_empty() { + return Err("no usable polylines in file".to_string()); + } + Ok(out) +} + #[tauri::command] pub async fn get_scope_polygons( state: State<'_, AppState>, @@ -3315,6 +3387,113 @@ pub async fn delete_osm_road( Ok(()) } +/// Remove a list of vertex indices from a road's polyline. Indices are +/// zero-based and refer to the *current* coords array. The road must keep at +/// least 2 vertices afterwards. +#[tauri::command] +pub async fn delete_road_vertices( + id: i64, + indices: Vec, + state: State<'_, AppState>, +) -> Result { + let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + let val: serde_json::Value = + serde_json::from_str(&row.0).map_err(|e| e.to_string())?; + let arr = val + .get("coordinates") + .and_then(|c| c.as_array()) + .ok_or_else(|| "road has no coordinates".to_string())?; + let mut coords: Vec<[f64; 2]> = arr + .iter() + .filter_map(|p| p.as_array()) + .filter_map(|p| { + Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]) + }) + .collect(); + use std::collections::HashSet; + let drop: HashSet = indices.into_iter().collect(); + let new_coords: Vec<[f64; 2]> = coords + .drain(..) + .enumerate() + .filter(|(i, _)| !drop.contains(i)) + .map(|(_, c)| c) + .collect(); + if new_coords.len() < 2 { + return Err( + "removing those vertices would leave < 2 — road needs at least 2".into(), + ); + } + let removed = drop.len(); + update_osm_road(id, new_coords, state).await?; + Ok(removed) +} + +/// Douglas-Peucker simplification of a road's polyline. Tolerance is in metres. +/// Converted to a degrees epsilon at the road's centroid latitude — accurate +/// enough for cleanup tolerances of 0.5–10 m. +#[tauri::command] +pub async fn simplify_road( + id: i64, + tolerance_m: f64, + state: State<'_, AppState>, +) -> Result<(usize, usize), String> { + if tolerance_m <= 0.0 { + return Err("tolerance must be > 0".into()); + } + let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + let val: serde_json::Value = + serde_json::from_str(&row.0).map_err(|e| e.to_string())?; + let arr = val + .get("coordinates") + .and_then(|c| c.as_array()) + .ok_or_else(|| "road has no coordinates".to_string())?; + let coords: Vec<[f64; 2]> = arr + .iter() + .filter_map(|p| p.as_array()) + .filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) + .collect(); + let before = coords.len(); + if before < 3 { + return Ok((before, before)); + } + // Convert tolerance metres → degrees, with a cos-lat correction for lng. + let mid_lat = coords[before / 2][1]; + let m_per_deg_lat = 111_000.0_f64; + let m_per_deg_lng = 111_000.0_f64 * mid_lat.to_radians().cos().abs().max(0.1); + // Project to a local "metres" space so DP epsilon is uniform. + let projected: Vec<[f64; 2]> = coords + .iter() + .map(|c| [c[0] * m_per_deg_lng, c[1] * m_per_deg_lat]) + .collect(); + use geo::algorithm::Simplify; + let line = LineString::from( + projected + .iter() + .map(|c| (c[0], c[1])) + .collect::>(), + ); + let simplified = line.simplify(tolerance_m); + let new_coords: Vec<[f64; 2]> = simplified + .0 + .iter() + .map(|c| [c.x / m_per_deg_lng, c.y / m_per_deg_lat]) + .collect(); + if new_coords.len() < 2 { + return Err("simplification would leave < 2 vertices".into()); + } + let after = new_coords.len(); + update_osm_road(id, new_coords, state).await?; + Ok((before, after)) +} + #[tauri::command] pub async fn update_osm_road( id: i64, @@ -3986,6 +4165,87 @@ pub async fn reset_database(state: State<'_, AppState>) -> Result<(), String> { Ok(()) } +#[derive(Debug, Serialize)] +pub struct DataSourceCounts { + pub fixed_assets: i64, + pub range_assets: i64, + pub osm_roads: i64, + pub scope_features: i64, +} + +#[tauri::command] +pub async fn data_source_counts( + state: State<'_, AppState>, +) -> Result { + let (fixed,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'fixed' AND deleted = 0") + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + let (range,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'range' AND deleted = 0") + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + let (roads,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roads") + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + let (scope,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scope_polygons") + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(DataSourceCounts { + fixed_assets: fixed, + range_assets: range, + osm_roads: roads, + scope_features: scope, + }) +} + +#[tauri::command] +pub async fn clear_assets_by_type( + asset_type: String, + state: State<'_, AppState>, +) -> Result { + if asset_type != "fixed" && asset_type != "range" { + return Err("asset_type must be 'fixed' or 'range'".into()); + } + let res = sqlx::query("DELETE FROM assets WHERE asset_type = ?") + .bind(&asset_type) + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(res.rows_affected() as usize) +} + +#[tauri::command] +pub async fn clear_osm_roads( + state: State<'_, AppState>, +) -> Result { + let res = sqlx::query("DELETE FROM roads") + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(res.rows_affected() as usize) +} + +#[tauri::command] +pub async fn clear_scope_polygons( + state: State<'_, AppState>, +) -> Result { + let res = sqlx::query("DELETE FROM scope_polygons") + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + // Reset derived in_scope on every asset that didn't have a manual override. + sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL") + .execute(&state.pool) + .await + .map_err(|e| e.to_string())?; + Ok(res.rows_affected() as usize) +} + #[tauri::command] pub async fn list_recent_actions( limit: i64, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce75f71..a9863da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,10 +5,11 @@ mod scope; use commands::{ delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads, get_scope_polygons, import_fixed_assets, import_kml_scope, import_osm_geojson, - import_range_assets, list_assets, list_recent_actions, merge_polylines, reset_assets_to_original, + import_range_assets, list_assets, read_video_polylines_json, list_recent_actions, merge_polylines, reset_assets_to_original, read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox, - add_class, auto_link_in_polygon, bulk_translate, change_assets_side, clear_link, clear_manual_scope, - create_osm_road, create_user, set_link, + add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type, + clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope, + create_osm_road, create_user, delete_road_vertices, set_link, simplify_road, delete_roads_by_highway, export_osm_roads, find_duplicate_clusters, flip_road_direction, generate_osm_for_bbox, list_road_classes, merge_roads_by_name, delete_assets_bulk, delete_osm_road, delete_user, export_assets, @@ -47,6 +48,7 @@ pub fn run() { get_assets_in_bbox, update_asset_position, import_kml_scope, + read_video_polylines_json, get_scope_polygons, delete_asset, delete_assets_by_video, @@ -62,6 +64,8 @@ pub fn run() { snap_assets_in_bbox, update_osm_road, create_osm_road, + delete_road_vertices, + simplify_road, delete_osm_road, list_asset_names, export_assets, @@ -88,6 +92,10 @@ pub fn run() { change_assets_side, auto_link_in_polygon, bulk_translate, + clear_assets_by_type, + clear_osm_roads, + clear_scope_polygons, + data_source_counts, set_link, clear_link ]) diff --git a/src-tauri/src/scope.rs b/src-tauri/src/scope.rs index 7144819..02b5c37 100644 --- a/src-tauri/src/scope.rs +++ b/src-tauri/src/scope.rs @@ -164,7 +164,6 @@ pub async fn apply_scope_to_assets( geoms: &[ScopeGeom], ) -> Result { if geoms.is_empty() { - // Reset only assets that don't have a manual override. sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL") .execute(pool) .await?; diff --git a/src/App.css b/src/App.css index fb39c75..c4d0eed 100644 --- a/src/App.css +++ b/src/App.css @@ -408,6 +408,17 @@ body, .vertex-pin.v-end { background: #e74c3c; } .vertex-pin.v-fixed { background: #1abc9c; } .vertex-pin.v-osm { background: #ffd700; color: #222; width: 14px; height: 14px; font-size: 10px; } +.vertex-pin.v-osm-marked { + background: #e74c3c; + color: #fff; + width: 14px; + height: 14px; + font-size: 11px; + line-height: 14px; + border-radius: 50%; + border: 2px solid #fff; + box-shadow: 0 0 0 1px #c0392b; +} .vertex-pin.v-osm-ghost { background: rgba(255, 215, 0, 0.45); color: #222; diff --git a/src/App.tsx b/src/App.tsx index 7db66fe..fe3d1d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -118,6 +118,12 @@ function AppShell({ const [compare, setCompare] = useState(false); const [assets, setAssets] = useState([]); const [scopePolygons, setScopePolygons] = useState([]); + const [dataCounts, setDataCounts] = useState<{ + fixed_assets: number; + range_assets: number; + osm_roads: number; + scope_features: number; + }>({ fixed_assets: 0, range_assets: 0, osm_roads: 0, scope_features: 0 }); const [recentActions, setRecentActions] = useState([]); const [status, setStatus] = useState(""); const [busy, setBusy] = useState(false); @@ -143,6 +149,16 @@ function AppShell({ features: [], }); const [metadataTrack, setMetadataTrack] = useState>([]); + // Per-video metadata polylines loaded from a multi-video JSON. When set, + // snap-to-road and direction inference look up each asset's track by video. + // The single-track `metadataTrack` is still used as a fallback (e.g. when + // only one video is loaded via the legacy importer). + const [metadataByVideo, setMetadataByVideo] = useState< + Record> + >({}); + const [selectedMetadataVideo, setSelectedMetadataVideo] = useState< + string | null + >(null); const [layerVisibility, setLayerVisibility] = useState({ scope: true, metadata: true, @@ -176,9 +192,21 @@ function AppShell({ const [namesExpanded, setNamesExpanded] = useState(true); const [filterPanelOpen, setFilterPanelOpen] = useState(true); const [videoSearch, setVideoSearch] = useState(""); + const [gotoQuery, setGotoQuery] = useState(""); + const [gotoPin, setGotoPin] = useState<{ lat: number; lng: number } | null>( + null, + ); + const [markedVertices, setMarkedVertices] = useState>(new Set()); + const [simplifyTolM, setSimplifyTolM] = useState(2); const [allAssetNames, setAllAssetNames] = useState([]); const [osmEditMode, setOsmEditMode] = useState(false); const [editingRoadId, setEditingRoadId] = useState(null); + // Reset the marked-vertex set whenever the user switches roads or leaves + // OSM-edit mode. Indices are road-relative, so they're meaningless across + // roads. + useEffect(() => { + setMarkedVertices(new Set()); + }, [editingRoadId, osmEditMode]); const [drawRoadMode, setDrawRoadMode] = useState(false); const [drawRoadCoords, setDrawRoadCoords] = useState< Array<[number, number]> @@ -396,8 +424,12 @@ function AppShell({ names.add(a.asset_name); if (vids.size > 1 && names.size > 1) break; // early-out: result will be "video" } + // Single video visible → fall back to asset / side coloring so the user + // can tell individual assets apart instead of seeing one solid color. if (vids.size <= 1 && names.size === 1) return "side"; if (vids.size <= 1) return "asset"; + // Multiple videos visible → match each asset's color to its metadata + // polyline (when loaded) by coloring by video. return "video"; }, [filteredAssets]); @@ -499,6 +531,29 @@ function AppShell({ // ESC: deselect editing road (or cancel a draw-in-progress). useEffect(() => { const handler = (e: KeyboardEvent) => { + if ( + (e.key === "Delete" || e.key === "Backspace") && + osmEditMode && + editingRoadId !== null && + markedVertices.size > 0 + ) { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + e.preventDefault(); + const idx = Array.from(markedVertices); + const roadId = editingRoadId; + invoke("delete_road_vertices", { id: roadId, indices: idx }) + .then((n) => { + setStatus(`Removed ${n} vertex(s) from road #${roadId}`); + setMarkedVertices(new Set()); + return refreshOsmRoads(); + }) + .catch((err) => setStatus(`Vertex delete failed: ${err}`)); + return; + } if ((e.key === "Delete" || e.key === "Backspace") && selectedLink) { const target = e.target as HTMLElement | null; const tag = target?.tagName; @@ -523,6 +578,18 @@ function AppShell({ setStatus("Lasso cancelled."); return; } + if (selectedMetadataVideo !== null) { + e.preventDefault(); + setSelectedMetadataVideo(null); + setStatus(""); + return; + } + if (gotoPin !== null) { + e.preventDefault(); + setGotoPin(null); + setStatus(""); + return; + } if (linkPickMode) { e.preventDefault(); setLinkPickMode(false); @@ -541,7 +608,7 @@ function AppShell({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink]); + }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices]); // Image prefetch: when an asset is selected, warm the browser cache for its // immediate neighbours so [/] navigation feels instant. @@ -887,11 +954,116 @@ function AppShell({ refreshAssetNames(), refreshVideoNames(), refreshClasses(), + refreshDataCounts(), ]; if (boundsRef.current) tasks.push(fetchBboxAssets(boundsRef.current)); await Promise.all(tasks); } + // Fly the map to a specific video (uses any combination of metadata polyline + // + asset bboxes, whichever exists). + function flyToVideo(name: string) { + let mnLat = Infinity, + mxLat = -Infinity, + mnLng = Infinity, + mxLng = -Infinity; + const track = metadataByVideo[name]; + if (track && track.length > 0) { + 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; + } + } + for (const a of assets) { + if (a.deleted !== 0) continue; + if (displayVideo(a.video_name) !== name) 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; + } + } + if (!Number.isFinite(mnLat)) { + setStatus(`No location for "${name}" (load metadata or assets first).`); + return; + } + setViewport({ + center: [(mnLng + mxLng) / 2, (mnLat + mxLat) / 2], + zoom: 14, + }); + // If the video has a metadata track, select it so the user sees direction + // arrows + S/E markers + name badge — same effect as tapping the line. + if (metadataByVideo[name]) { + setSelectedMetadataVideo(name); + } + setStatus(name); + } + + function handleGoto() { + const q = gotoQuery.trim(); + if (q === "") return; + // Try lat,lng first. + const m = q.match(/^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/); + if (m) { + const lat = parseFloat(m[1]); + const lng = parseFloat(m[2]); + if ( + Number.isFinite(lat) && + Number.isFinite(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ) { + setViewport({ center: [lng, lat], zoom: 18 }); + setGotoPin({ lat, lng }); + setStatus(`📍 ${lat.toFixed(6)}, ${lng.toFixed(6)}`); + return; + } + setStatus(`Out-of-range lat/lng: ${q}`); + return; + } + // Else treat as a video name — exact, then prefix, then substring (case-insensitive). + const videos = Object.keys(metadataByVideo).concat(allVideoNames); + const lower = q.toLowerCase(); + const exact = videos.find((v) => v === q); + const prefix = videos.find((v) => v.toLowerCase().startsWith(lower)); + const sub = videos.find((v) => v.toLowerCase().includes(lower)); + const hit = exact ?? prefix ?? sub; + if (hit) { + flyToVideo(hit); + return; + } + setStatus( + `No match for "${q}". Use "lat,lng" (e.g. 17.398, 78.347) or a video name.`, + ); + } + + async function refreshDataCounts() { + try { + const c = await invoke<{ + fixed_assets: number; + range_assets: number; + osm_roads: number; + scope_features: number; + }>("data_source_counts"); + setDataCounts(c); + } catch { + /* ignore */ + } + } + useEffect(() => { refreshScopePolygons().catch((e) => setStatus(`Scope load failed: ${e}`), @@ -901,6 +1073,7 @@ function AppShell({ refreshAssetNames().catch(() => {}); refreshVideoNames().catch(() => {}); refreshClasses().catch(() => {}); + refreshDataCounts().catch(() => {}); invoke("list_assets") .then((list) => { if (list.length > 0) { @@ -1205,7 +1378,12 @@ function AppShell({ maxDistanceM: 50, offsetM: roadOffsetMeters, centerlineNames: Array.from(centerlineNames), - metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null, + metadataTrack: + metadataByVideo[name]?.length >= 2 + ? metadataByVideo[name] + : metadataTrack.length >= 2 + ? metadataTrack + : null, }); setStatus( `Snap by video: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`, @@ -1715,7 +1893,15 @@ function AppShell({ maxDistanceM: 50, offsetM: roadOffsetMeters, centerlineNames: Array.from(centerlineNames), - metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null, + metadataTrack: (() => { + // If only one video is selected in the filters, use that video's + // track. Otherwise fall back to the concatenated single track. + const sel = Array.from(filters.videos); + if (sel.length === 1 && metadataByVideo[sel[0]]?.length >= 2) { + return metadataByVideo[sel[0]]; + } + return metadataTrack.length >= 2 ? metadataTrack : null; + })(), }); setStatus( `Bulk snap: ${res.snapped} moved, ${res.skipped_far} too far, ${res.skipped_no_geom} skipped`, @@ -1733,11 +1919,18 @@ function AppShell({ setBusy(true); try { const useCenter = centerlineNames.has(selectedAsset.asset_name); + const perVideo = metadataByVideo[selectedAsset.video_name]; + const trackForAsset = + perVideo && perVideo.length >= 2 + ? perVideo + : metadataTrack.length >= 2 + ? metadataTrack + : null; const [lat, lng] = await invoke<[number, number]>("snap_to_road", { assetId: selectedAsset.id, maxDistanceM: 50, offsetM: useCenter ? 0 : roadOffsetMeters, - metadataTrack: metadataTrack.length >= 2 ? metadataTrack : null, + metadataTrack: trackForAsset, }); setStatus( `Snapped to road @ ${lat.toFixed(6)}, ${lng.toFixed(6)}`, @@ -1778,6 +1971,58 @@ function AppShell({ } } + async function handleImportVideoMetadata() { + if (busy) return; + setBusy(true); + setStatus("Picking per-video metadata JSON…"); + try { + const picked = await open({ + multiple: false, + directory: false, + filters: [{ name: "Per-video metadata JSON", extensions: ["json"] }], + }); + if (typeof picked !== "string") { + setStatus(""); + return; + } + setStatus("Parsing video polylines…"); + const map = await invoke>>( + "read_video_polylines_json", + { path: picked }, + ); + setMetadataByVideo(map); + const keys = Object.keys(map); + // Concatenated fallback for callers that don't yet pass a video. + const flat: Array<[number, number]> = []; + for (const k of keys) flat.push(...map[k]); + setMetadataTrack(flat); + // Recenter the map on the metadata bbox so the user can actually see + // what they just imported (otherwise the polylines may sit outside the + // current viewport and look "missing"). + if (flat.length > 0) { + let minLng = Infinity, maxLng = -Infinity; + let minLat = Infinity, maxLat = -Infinity; + for (const [lng, lat] of flat) { + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + setViewport({ + center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2], + zoom: 12, + }); + } + setStatus( + `Loaded metadata for ${keys.length} video(s). Toggle "Metadata polyline" in Layers if it's off. Snap & direction now use each asset's matching video track.`, + ); + } catch (e) { + setStatus(`Video metadata import failed: ${e}`); + } finally { + setBusy(false); + } + } + async function handleImport(kind: "fixed" | "range") { if (busy) return; setBusy(true); @@ -1838,6 +2083,25 @@ function AppShell({ scopePolygons={scopePolygons} osmRoads={osmRoads} metadataTrack={metadataTrack} + metadataTracks={metadataByVideo} + selectedMetadataVideo={selectedMetadataVideo} + gotoPin={gotoPin} + markedVertices={markedVertices} + onVertexShiftClick={(idx) => { + setMarkedVertices((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }} + onMetadataLineClick={(name) => { + setSelectedMetadataVideo((cur) => { + const next = cur === name ? null : name; + setStatus(next === null ? "" : name); + return next; + }); + }} layerVisibility={layerVisibility} selectedAsset={selectedAsset} onSelect={(a) => { @@ -1928,6 +2192,25 @@ function AppShell({ scopePolygons={scopePolygons} osmRoads={osmRoads} metadataTrack={metadataTrack} + metadataTracks={metadataByVideo} + selectedMetadataVideo={selectedMetadataVideo} + gotoPin={gotoPin} + markedVertices={markedVertices} + onVertexShiftClick={(idx) => { + setMarkedVertices((prev) => { + const next = new Set(prev); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + return next; + }); + }} + onMetadataLineClick={(name) => { + setSelectedMetadataVideo((cur) => { + const next = cur === name ? null : name; + setStatus(next === null ? "" : name); + return next; + }); + }} layerVisibility={layerVisibility} selectedAsset={selectedAsset} onSelect={(a) => { @@ -2148,7 +2431,12 @@ function AppShell({ }} />
- {allVideoNames + {Array.from( + new Set( + allVideoNames.concat(Object.keys(metadataByVideo)), + ), + ) + .sort() .filter((v) => videoSearch.trim() === "" ? true @@ -2176,6 +2464,25 @@ function AppShell({ {v} {n} + ))}
@@ -2378,9 +2685,22 @@ function AppShell({ handleImportScope(); }} disabled={busy} + title="Import a KML scope polygon or polyline (geometric only)." > KML scope… + + +

Data

+
+
Loaded data
+ {([ + { + label: "Fixed assets", + count: dataCounts.fixed_assets, + onReplace: () => { + setImportsOpen(false); + handleImport("fixed"); + }, + onClear: async () => { + if ( + !window.confirm( + `Permanently delete all ${dataCounts.fixed_assets} fixed asset(s)?`, + ) + ) + return; + try { + const n = await invoke("clear_assets_by_type", { + assetType: "fixed", + }); + setStatus(`Cleared ${n} fixed asset(s)`); + await refreshAfterMutation(); + } catch (e) { + setStatus(`Clear failed: ${e}`); + } + }, + }, + { + label: "Range assets", + count: dataCounts.range_assets, + onReplace: () => { + setImportsOpen(false); + handleImport("range"); + }, + onClear: async () => { + if ( + !window.confirm( + `Permanently delete all ${dataCounts.range_assets} range asset(s)?`, + ) + ) + return; + try { + const n = await invoke("clear_assets_by_type", { + assetType: "range", + }); + setStatus(`Cleared ${n} range asset(s)`); + await refreshAfterMutation(); + } catch (e) { + setStatus(`Clear failed: ${e}`); + } + }, + }, + { + label: "OSM roads", + count: dataCounts.osm_roads, + onReplace: () => handleImportOsm(), + onClear: async () => { + if ( + !window.confirm( + `Permanently delete all ${dataCounts.osm_roads} OSM road(s)?`, + ) + ) + return; + try { + const n = await invoke("clear_osm_roads"); + setStatus(`Cleared ${n} OSM road(s)`); + await refreshOsmRoads(); + await refreshDataCounts(); + } catch (e) { + setStatus(`Clear failed: ${e}`); + } + }, + }, + { + label: "KML scope", + count: dataCounts.scope_features, + onReplace: () => handleImportScope(), + onClear: async () => { + if ( + !window.confirm( + `Permanently delete all ${dataCounts.scope_features} scope feature(s)?`, + ) + ) + return; + try { + const n = await invoke("clear_scope_polygons"); + setStatus(`Cleared ${n} scope feature(s)`); + await refreshScopePolygons(); + await refreshAfterMutation(); + } catch (e) { + setStatus(`Clear failed: ${e}`); + } + }, + }, + { + label: "Per-video metadata", + count: Object.keys(metadataByVideo).length, + onReplace: () => handleImportVideoMetadata(), + onClear: () => { + setMetadataByVideo({}); + setMetadataTrack([]); + setSelectedMetadataVideo(null); + setStatus("Cleared per-video metadata"); + }, + }, + ] as const).map((row) => ( +
+ + {row.label}:{" "} + + {row.count > 0 ? row.count.toLocaleString() : "—"} + + + + +
+ ))} +
+
+
+ Bulk vertex tools +
+
+ Shift-click vertices to mark (red ✕). Press{" "} + Del to remove all marked. +
+
+ Currently marked:{" "} + {markedVertices.size} + {markedVertices.size > 0 && ( + + )} +
+ + +