diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 598d3b2..ff5022d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7232,3 +7232,358 @@ pub async fn export_bboxes_to_json( tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?; Ok(n) } + +// ---------- equalize_along_road ------------------------------------------- +// +// Redistribute a set of "uniformly-spaced" assets (e.g. streetlights) so the +// arc-length spacing along the nearest OSM road is constant between two +// anchors. Returns a proposal (no DB writes) so the frontend can show the +// gap-anomaly report and let the user confirm before applying. +// +// Why arc-length not Euclidean: on curves, equal Euclidean spacing pushes +// markers off the road. Arc-length spacing keeps them on the curve. +// Lateral offset uses the avg perpendicular distance of the two anchors from +// the road centerline, so left-side stays left and right-side stays right +// even through bends. + +#[derive(Debug, Serialize)] +pub struct EqualizationProposal { + pub asset_id: i64, + pub orig_lat: f64, + pub orig_lng: f64, + pub new_lat: f64, + pub new_lng: f64, + pub displacement_m: f64, +} + +#[derive(Debug, Serialize)] +pub struct GapAnomaly { + pub before_id: i64, + pub after_id: i64, + pub gap_m: f64, + pub expected_missing: i64, +} + +#[derive(Debug, Serialize)] +pub struct EqualizationResult { + pub proposals: Vec, + pub anomalies: Vec, + pub median_gap_m: f64, + pub arc_length_m: f64, + pub road_id: i64, + pub lateral_offset_m: f64, +} + +/// Project (lng, lat) onto every segment of `line`, return the closest +/// (arc_position_m, snap_lng, snap_lat, signed_perpendicular_m). Sign of +/// perpendicular: cross product (segment × asset-from-snap) — positive ⇒ +/// asset is to the left of the segment heading. +fn project_onto_polyline( + line: &LineString, + lng: f64, + lat: f64, +) -> (f64, f64, f64, f64) { + let pt = Point::new(lng, lat); + let mut best_dist = f64::INFINITY; + let mut best_arc = 0.0; + let mut best_x = lng; + let mut best_y = lat; + let mut best_dx = 0.0_f64; + let mut best_dy = 0.0_f64; + let mut cumulative = 0.0; + for seg in line.lines() { + let snapped = match LineString(vec![seg.start, seg.end]).closest_point(&pt) { + Closest::SinglePoint(p) | Closest::Intersection(p) => p, + Closest::Indeterminate => continue, + }; + let d = Geodesic.distance(pt, snapped); + let seg_len = Geodesic.distance( + Point::new(seg.start.x, seg.start.y), + Point::new(seg.end.x, seg.end.y), + ); + if d < best_dist { + best_dist = d; + best_x = snapped.x(); + best_y = snapped.y(); + best_dx = seg.end.x - seg.start.x; + best_dy = seg.end.y - seg.start.y; + // Arc from seg start to snapped point. Use straight-line ratio in + // lat/lng space — accurate for the short segments OSM produces. + let t_dx = snapped.x() - seg.start.x; + let t_dy = snapped.y() - seg.start.y; + let raw_dx = seg.end.x - seg.start.x; + let raw_dy = seg.end.y - seg.start.y; + let raw_len_sq = raw_dx * raw_dx + raw_dy * raw_dy; + let t = if raw_len_sq > 1e-18 { + ((t_dx * raw_dx + t_dy * raw_dy) / raw_len_sq).clamp(0.0, 1.0) + } else { + 0.0 + }; + best_arc = cumulative + t * seg_len; + } + cumulative += seg_len; + } + // Signed perpendicular distance in metres. + let m_per_lat = 111_320.0_f64; + let m_per_lng = 111_320.0_f64 + * (best_y * std::f64::consts::PI / 180.0).cos().abs().max(1e-6); + let dx_m = best_dx * m_per_lng; + let dy_m = best_dy * m_per_lat; + let ax_m = (lng - best_x) * m_per_lng; + let ay_m = (lat - best_y) * m_per_lat; + let len = (dx_m * dx_m + dy_m * dy_m).sqrt().max(1e-9); + let cross = dx_m * ay_m - dy_m * ax_m; + let perp_signed = cross / len; + (best_arc, best_x, best_y, perp_signed) +} + +/// Return (lng, lat, tangent_dx_lng, tangent_dy_lat) of the point at +/// `target_arc` metres along `line`. None if `target_arc` is past the end. +fn point_at_arc( + line: &LineString, + target_arc: f64, +) -> Option<(f64, f64, f64, f64)> { + let mut cumulative = 0.0; + for seg in line.lines() { + let seg_len = Geodesic.distance( + Point::new(seg.start.x, seg.start.y), + Point::new(seg.end.x, seg.end.y), + ); + if cumulative + seg_len + 1e-6 >= target_arc { + let t = if seg_len > 1e-6 { + (target_arc - cumulative) / seg_len + } else { + 0.0 + }; + let lng = seg.start.x + t * (seg.end.x - seg.start.x); + let lat = seg.start.y + t * (seg.end.y - seg.start.y); + return Some((lng, lat, seg.end.x - seg.start.x, seg.end.y - seg.start.y)); + } + cumulative += seg_len; + } + None +} + +async fn find_nearest_road_id( + pool: &sqlx::SqlitePool, + lat: f64, + lng: f64, + max_d: f64, +) -> Result<(i64, LineString), String> { + let pad_lat = max_d / 111_320.0; + let pad_lng = max_d + / (111_320.0 + * (lat * std::f64::consts::PI / 180.0).cos().abs().max(1e-6)); + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT r.id, r.geojson + FROM roads r JOIN roads_rtree x ON x.id = r.id + WHERE x.max_lat >= ? AND x.min_lat <= ? + AND x.max_lng >= ? AND x.min_lng <= ? + AND COALESCE(r.snap_ignored, 0) = 0", + ) + .bind(lat - pad_lat) + .bind(lat + pad_lat) + .bind(lng - pad_lng) + .bind(lng + pad_lng) + .fetch_all(pool) + .await + .map_err(|e| e.to_string())?; + let pt = Point::new(lng, lat); + let mut best: Option<(f64, i64, LineString)> = None; + for (id, gj) in rows { + let Some(line) = parse_linestring(&gj) else { continue }; + let mut min_d = f64::INFINITY; + for seg in line.lines() { + let snapped = match LineString(vec![seg.start, seg.end]).closest_point(&pt) { + Closest::SinglePoint(p) | Closest::Intersection(p) => p, + Closest::Indeterminate => continue, + }; + let d = Geodesic.distance(pt, snapped); + if d < min_d { + min_d = d; + } + } + if min_d.is_finite() + && min_d <= max_d + && best.as_ref().map(|b| min_d < b.0).unwrap_or(true) + { + best = Some((min_d, id, line)); + } + } + let (_, id, line) = + best.ok_or_else(|| format!("no road within {:.0} m of anchor", max_d))?; + Ok((id, line)) +} + +#[tauri::command] +pub async fn equalize_along_road( + anchor_a_id: i64, + anchor_b_id: i64, + asset_ids: Vec, + max_road_distance_m: Option, + state: State<'_, AppState>, +) -> Result { + let max_d = max_road_distance_m.unwrap_or(50.0); + if anchor_a_id == anchor_b_id { + return Err("two distinct anchors required".into()); + } + if asset_ids.is_empty() { + return Err("no assets selected to redistribute".into()); + } + + // Fetch positions. + let mut all_ids: Vec = Vec::with_capacity(2 + asset_ids.len()); + all_ids.push(anchor_a_id); + all_ids.push(anchor_b_id); + all_ids.extend(&asset_ids); + let placeholders = vec!["?"; all_ids.len()].join(","); + let sql = format!( + "SELECT id, lat, lng FROM assets WHERE id IN ({}) AND deleted = 0", + placeholders + ); + let mut q = sqlx::query_as::<_, (i64, Option, Option)>(&sql); + for id in &all_ids { + q = q.bind(id); + } + let rows = q.fetch_all(&state.pool).await.map_err(|e| e.to_string())?; + let mut pos: std::collections::HashMap = + std::collections::HashMap::new(); + for (id, lat, lng) in rows { + if let (Some(la), Some(lo)) = (lat, lng) { + pos.insert(id, (la, lo)); + } + } + let (a_lat, a_lng) = *pos + .get(&anchor_a_id) + .ok_or("anchor A has no position")?; + let (b_lat, b_lng) = *pos + .get(&anchor_b_id) + .ok_or("anchor B has no position")?; + + // Locate the road for each anchor; require both on the same OSM way. + let (road_a_id, line_a) = find_nearest_road_id(&state.pool, a_lat, a_lng, max_d).await?; + let (road_b_id, _) = find_nearest_road_id(&state.pool, b_lat, b_lng, max_d).await?; + if road_a_id != road_b_id { + return Err(format!( + "anchors are on different OSM roads ({} vs {}). Multi-way stitching isn't supported yet — drop a road that covers both anchors.", + road_a_id, road_b_id + )); + } + let line = line_a; + + // Project anchors + assets. + let proj_a = project_onto_polyline(&line, a_lng, a_lat); + let proj_b = project_onto_polyline(&line, b_lng, b_lat); + let (mut s_lo, mut s_hi) = (proj_a.0, proj_b.0); + let lo_id = if s_lo <= s_hi { + anchor_a_id + } else { + std::mem::swap(&mut s_lo, &mut s_hi); + anchor_b_id + }; + let hi_id = if lo_id == anchor_a_id { anchor_b_id } else { anchor_a_id }; + let arc_length = s_hi - s_lo; + if arc_length < 1.0 { + return Err("anchors are within 1 m on the road — nothing to redistribute".into()); + } + + let mut asset_projs: Vec<(i64, f64, f64, f64)> = Vec::new(); + for id in &asset_ids { + if *id == anchor_a_id || *id == anchor_b_id { + continue; + } + let Some((la, lo)) = pos.get(id).copied() else { continue }; + let p = project_onto_polyline(&line, lo, la); + // Allow a 1 m slop so an asset sitting exactly on an anchor counts. + if p.0 >= s_lo - 1.0 && p.0 <= s_hi + 1.0 { + asset_projs.push((*id, p.0, la, lo)); + } + } + asset_projs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + if asset_projs.is_empty() { + return Err( + "none of the selected assets project onto the road segment between the two anchors" + .into(), + ); + } + + // Build the full ordered sequence with anchors at both ends for gap stats. + let mut sequence: Vec<(i64, f64)> = Vec::with_capacity(asset_projs.len() + 2); + sequence.push((lo_id, s_lo)); + for ap in &asset_projs { + sequence.push((ap.0, ap.1)); + } + sequence.push((hi_id, s_hi)); + + let mut gaps: Vec = Vec::with_capacity(sequence.len() - 1); + for i in 0..sequence.len() - 1 { + gaps.push((sequence[i + 1].1 - sequence[i].1).max(0.0)); + } + let mut sorted_gaps = gaps.clone(); + sorted_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median_gap = sorted_gaps[sorted_gaps.len() / 2]; + + // Anomaly detection: a gap is flagged when it's much larger than the + // median AND larger than a minimum absolute floor (so a 0.5 m → 1 m gap + // doesn't spam warnings). + const ANOMALY_RATIO: f64 = 1.8; + const MIN_GAP_FOR_ANOMALY_M: f64 = 5.0; + let mut anomalies: Vec = Vec::new(); + for (i, &g) in gaps.iter().enumerate() { + if g > median_gap * ANOMALY_RATIO && g > MIN_GAP_FOR_ANOMALY_M && median_gap > 1e-3 { + let expected_missing = ((g / median_gap).round() as i64 - 1).max(1); + anomalies.push(GapAnomaly { + before_id: sequence[i].0, + after_id: sequence[i + 1].0, + gap_m: g, + expected_missing, + }); + } + } + + // Average signed perpendicular offset of the anchors — used as the + // lateral offset for every redistributed point. Curvature is handled by + // computing the perpendicular direction PER point at its new tangent. + let lateral_offset = (proj_a.3 + proj_b.3) / 2.0; + + // Equalize: N intermediate points, equally spaced between s_lo and s_hi. + let n = asset_projs.len(); + let step = arc_length / (n as f64 + 1.0); + let mut proposals: Vec = Vec::with_capacity(n); + for (i, ap) in asset_projs.iter().enumerate() { + let new_arc = s_lo + step * (i as f64 + 1.0); + let Some((cx, cy, dx_lng, dy_lat)) = point_at_arc(&line, new_arc) else { + continue; + }; + let m_per_lat = 111_320.0_f64; + let m_per_lng = 111_320.0_f64 + * (cy * std::f64::consts::PI / 180.0).cos().abs().max(1e-6); + let dx_m = dx_lng * m_per_lng; + let dy_m = dy_lat * m_per_lat; + let len = (dx_m * dx_m + dy_m * dy_m).sqrt().max(1e-9); + // Left-normal of the tangent (matches the sign convention of + // project_onto_polyline so positive perp keeps the same side). + let nx = -dy_m / len; + let ny = dx_m / len; + let new_lng = cx + nx * lateral_offset / m_per_lng; + let new_lat = cy + ny * lateral_offset / m_per_lat; + let displacement_m = haversine_m((ap.2, ap.3), (new_lat, new_lng)); + proposals.push(EqualizationProposal { + asset_id: ap.0, + orig_lat: ap.2, + orig_lng: ap.3, + new_lat, + new_lng, + displacement_m, + }); + } + + Ok(EqualizationResult { + proposals, + anomalies, + median_gap_m: median_gap, + arc_length_m: arc_length, + road_id: road_a_id, + lateral_offset_m: lateral_offset, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06df414..19ad316 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,7 +8,7 @@ use commands::{ import_osm_geojson, 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, clear_assets_by_type, + add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type, equalize_along_road, clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope, create_osm_road, create_user, delete_road_section, delete_road_vertices, delete_roads_in_lasso, set_link, simplify_road, @@ -124,7 +124,8 @@ pub fn run() { clear_asset_bbox, export_bboxes_to_json, set_link, - clear_link + clear_link, + equalize_along_road ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 482d2d6..d2606a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -584,6 +584,10 @@ function AppShell({ }, [autoLinkMaxM]); const [lassoListOpen, setLassoListOpen] = useState(false); const [showDeleted, setShowDeleted] = useState(false); + // '?' opens a cheat-sheet modal listing every keyboard shortcut. We track + // it as plain boolean state so the same handler can toggle on key press + // and close on backdrop click. + const [showShortcutsHelp, setShowShortcutsHelp] = useState(false); // Pulse animation for "Blink modified points". A simple bool toggle drives // a setInterval in an effect; the tick is plumbed through to MapView so its // dedicated overlay layer redraws every ~600 ms with alternating opacity. @@ -1209,6 +1213,112 @@ function AppShell({ toggleOsmEditModeRef.current(); return; } + // '?' (or Shift+/ on US layouts) opens the keyboard cheat-sheet. + // Toggles, so pressing again closes it. Always allowed except when + // typing into a text field. + if (e.key === "?" || (e.key === "/" && e.shiftKey)) { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + e.preventDefault(); + setShowShortcutsHelp((v) => !v); + return; + } + // 'A' toggles the selected asset as a GPS-correction anchor. Same + // toggle the "Mark as anchor" button does, just one-tap: pick the + // asset, press A, drag it to the right spot, press Distribute. + if ( + (e.key === "a" || e.key === "A") && + selectedAsset && + !osmEditMode + ) { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.ctrlKey || e.metaKey || e.altKey) return; + e.preventDefault(); + setAnchors((prev) => { + const next = new Map(prev); + if (next.has(selectedAsset.id)) { + next.delete(selectedAsset.id); + showToast("Anchor cleared"); + } else if ( + selectedAsset.lat !== null && + selectedAsset.lng !== null + ) { + next.set(selectedAsset.id, { + origLat: selectedAsset.lat, + origLng: selectedAsset.lng, + rowId: selectedAsset.row_id, + }); + showToast("Marked as anchor"); + } + return next; + }); + return; + } + // Auto-link shortcuts. Require an active lasso selection (and its + // synthesized polygon — circle/bbox/polygon all set lassoPolygonPoints + // now) so we know the region to link inside. Modifiers pick the + // splitter: plain L = side (Left↔Right), Shift+L = by video, + // Alt+L = nearest (no split). + if ( + (e.key === "l" || e.key === "L") && + lassoSelected && + lassoPolygonPoints && + lassoPolygonPoints.length >= 3 + ) { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.ctrlKey || e.metaKey) return; + e.preventDefault(); + const mode: "side" | "video" | "none" = e.altKey + ? "none" + : e.shiftKey + ? "video" + : "side"; + void handleAutoLink(mode); + return; + } + // 'K' clears links on every asset in the active lasso selection. + // Mirrors the "Clear links" button so it's a 1-tap undo after a + // mis-linked auto-link run. + if ( + (e.key === "k" || e.key === "K") && + lassoSelected && + lassoSelected.ids.length > 0 + ) { + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) { + return; + } + if (e.ctrlKey || e.metaKey || e.altKey) return; + e.preventDefault(); + const ids = lassoSelected.ids.slice(); + (async () => { + let n = 0; + for (const id of ids) { + try { + await invoke("clear_link", { id }); + n++; + } catch { + /* asset wasn't linked; ignore */ + } + } + setStatus(`Cleared links on ${n} asset(s)`); + showToast(`Cleared ${n} link(s)`); + await refreshAfterMutation(); + })(); + return; + } // 'R' / 'r' opens the rename modal for the currently selected asset. // Input-guard pattern matches the other shortcuts. if (e.key === "r" || e.key === "R") { @@ -1339,6 +1449,11 @@ function AppShell({ return; } if (e.key !== "Escape") return; + if (showShortcutsHelp) { + e.preventDefault(); + setShowShortcutsHelp(false); + return; + } if (lensActive) { e.preventDefault(); setLensActive(false); @@ -1384,7 +1499,7 @@ function AppShell({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, selectedAssetId, popupEnabled, multiSelectedIds]); + }, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, selectedAssetId, popupEnabled, multiSelectedIds, lassoSelected, lassoPolygonPoints, showShortcutsHelp]); // Image prefetch: when an asset is selected, warm the browser cache for its // immediate neighbours so [/] navigation feels instant. @@ -2610,6 +2725,21 @@ function AppShell({ if (haversine(center, [a.lng, a.lat]) <= radius) ids.push(a.id); } label = `circle r=${radius.toFixed(0)} m`; + // Stash a polygon approximation of the circle so polygon-only actions + // (Auto-link, anchor-Distribute) light up for circle lasso too. Same + // 64-vertex approximation we already use in the OSM-edit-mode lasso. + const m_per_lat = 111_320; + const m_per_lng = 111_320 * Math.cos(toRad(center[1])); + const N = 64; + const poly: Array<[number, number]> = []; + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2; + poly.push([ + center[0] + (radius * Math.cos(a)) / m_per_lng, + center[1] + (radius * Math.sin(a)) / m_per_lat, + ]); + } + setLassoPolygonPoints(poly); } else if (shape === "rect") { const [a, b] = points; const minLng = Math.min(a[0], b[0]); @@ -2632,6 +2762,13 @@ function AppShell({ ids.push(x.id); } label = "bbox"; + // Bbox as a 4-vertex polygon for Auto-link + region-scoped actions. + setLassoPolygonPoints([ + [minLng, minLat], + [maxLng, minLat], + [maxLng, maxLat], + [minLng, maxLat], + ]); } else { // polygon: ray-casting test const verts = points; @@ -2821,15 +2958,23 @@ function AppShell({ anchorList.sort((a, b) => naturalCompare(a.rowId, b.rowId)); const anchorIds = new Set(anchorList.map((a) => a.id)); - // Build the corrections payload for every visible filtered asset that - // isn't itself an anchor (anchors already moved, no need to re-translate). + // Build the corrections payload. When a lasso selection is active we + // scope to those ids — anchor moves are meant to correct the assets the + // user explicitly bracketed, NOT every other point that happens to be + // visible. Without an active lasso the legacy behavior (all live filtered + // assets) still applies for one-anchor uniform shifts. + const lassoIdSet = + lassoSelected && lassoSelected.ids.length > 0 + ? new Set(lassoSelected.ids) + : null; const targets = filteredAssets .filter( (a) => a.deleted === 0 && a.lat !== null && a.lng !== null && - !anchorIds.has(a.id), + !anchorIds.has(a.id) && + (lassoIdSet === null || lassoIdSet.has(a.id)), ) .slice() .sort((a, b) => naturalCompare(a.row_id, b.row_id)); @@ -2938,6 +3083,98 @@ function AppShell({ } } + type EqualizationProposal = { + asset_id: number; + orig_lat: number; + orig_lng: number; + new_lat: number; + new_lng: number; + displacement_m: number; + }; + type GapAnomaly = { + before_id: number; + after_id: number; + gap_m: number; + expected_missing: number; + }; + type EqualizationResult = { + proposals: EqualizationProposal[]; + anomalies: GapAnomaly[]; + median_gap_m: number; + arc_length_m: number; + road_id: number; + lateral_offset_m: number; + }; + const [equalizeReport, setEqualizeReport] = + useState(null); + + // Convert an EqualizationResult into bulk_translate corrections (id, dlat, + // dlng). Shared between the auto-apply path (no anomalies) and the + // user-confirms path inside the modal. + async function applyEqualization(result: EqualizationResult) { + const corrections = result.proposals.map((p) => ({ + id: p.asset_id, + dlat: p.new_lat - p.orig_lat, + dlng: p.new_lng - p.orig_lng, + })); + if (corrections.length === 0) { + showToast("Nothing to apply"); + return; + } + const n = await invoke("bulk_translate", { corrections }); + setStatus( + `Equalized ${n} asset(s) along road #${result.road_id} — arc ${result.arc_length_m.toFixed(0)} m, spacing ${(result.arc_length_m / (corrections.length + 1)).toFixed(1)} m`, + ); + showToast(`Equalized ${n} asset(s)`); + await refreshAfterMutation(); + } + + async function handleEqualizeAlongRoad() { + if (anchors.size !== 2) { + setStatus("Mark exactly two anchors (press A on each) before equalizing."); + return; + } + if (!lassoSelected || lassoSelected.ids.length === 0) { + setStatus("Lasso the assets you want to redistribute between the anchors."); + return; + } + const anchorIds = Array.from(anchors.keys()); + const [anchorAId, anchorBId] = anchorIds; + // Drop anchor ids from the target list — anchors stay where the user + // moved them; equalize redistributes the others. + const assetIds = lassoSelected.ids.filter( + (id) => id !== anchorAId && id !== anchorBId, + ); + if (assetIds.length === 0) { + setStatus("Lasso must include at least one non-anchor asset."); + return; + } + setBusy(true); + try { + const result = await invoke( + "equalize_along_road", + { + anchorAId, + anchorBId, + assetIds, + maxRoadDistanceM: 50, + }, + ); + if (result.anomalies.length === 0) { + // No discrepancies → apply immediately. + await applyEqualization(result); + } else { + // Show the gap report so the user can decide. + setEqualizeReport(result); + } + } catch (e) { + setStatus(`Equalize failed: ${e}`); + showToast(`Equalize failed: ${e}`); + } finally { + setBusy(false); + } + } + async function handleAutoLink(splitBy: "side" | "video" | "none") { if (!lassoPolygonPoints || lassoPolygonPoints.length < 3) { setStatus("Use the polygon lasso to define a region first."); @@ -3957,6 +4194,15 @@ function AppShell({ > ⚙ +
✕ -

Filters

+

+ Filters + +

Showing {filteredAssets.length} / {assets.length} loaded +

Spacing anomalies detected

+
+ Median spacing along the road:{" "} + {equalizeReport.median_gap_m.toFixed(1)} m · + arc length{" "} + {equalizeReport.arc_length_m.toFixed(0)} m · + lateral offset{" "} + + {Math.abs(equalizeReport.lateral_offset_m).toFixed(1)} m{" "} + {equalizeReport.lateral_offset_m >= 0 ? "left" : "right"} + +
+

+ {equalizeReport.anomalies.length} gap(s) are much larger than the + median — likely missed marker(s) in the original labelling. You + can still apply the equalization, but the known assets will be + spread evenly across the gap. +

+
+ + + + + + + + + + {equalizeReport.anomalies.map((g, i) => { + const beforeRow = assets.find((a) => a.id === g.before_id); + const afterRow = assets.find((a) => a.id === g.after_id); + const label = (a: typeof beforeRow) => + a + ? `${a.asset_name} · ${a.row_id.slice(-6)}` + : "(unknown)"; + return ( + + + + + + ); + })} + +
BetweenGapLikely missing
+ {label(beforeRow)} → {label(afterRow)} + + {g.gap_m.toFixed(1)} m + + {g.expected_missing} +
+
+
+ + +
+
+
+ )} + + {showShortcutsHelp && ( +
setShowShortcutsHelp(false)} + > +
e.stopPropagation()} + style={{ maxWidth: 620 }} + > + +

Keyboard shortcuts

+
+ Press ? anywhere to open this dialog. ?{" "} + again or Esc closes it. Hover any button in the side + panels for tooltips. +
+ {( + [ + { + group: "Selection & navigation", + rows: [ + ["[", "Previous navigable asset"], + ["]", "Next navigable asset"], + ["Ctrl/⌘ + click", "Add asset to multi-select halo"], + ["C", "Fit map to focused video / all data"], + ["Esc", "Cancel current mode (lasso, link-pick, draw, OSM editing, magnifier, this dialog)"], + ], + }, + { + group: "Asset actions", + rows: [ + ["R", "Rename selected asset"], + ["A", "Toggle anchor on selected asset (then drag → Distribute)"], + ["Delete / Backspace", "Delete selected / multi-selected asset(s)"], + ], + }, + { + group: "Image popup & bbox", + rows: [ + ["H", "Toggle image popup (works while Ctrl-held)"], + ["B", "Toggle bbox edit mode (popup must be on)"], + ], + }, + { + group: "Lasso region", + rows: [ + ["L", "Auto-link L↔R inside lasso"], + ["Shift + L", "Auto-link by video inside lasso"], + ["Alt + L", "Auto-link nearest inside lasso"], + ["K", "Clear links on lasso selection"], + ], + }, + { + group: "OSM edit mode", + rows: [ + ["O", "Toggle OSM edit mode"], + ["Delete / Backspace", "Delete marked vertices, then selected roads"], + ["Shift + click on road", "Insert a vertex at click point"], + ], + }, + { + group: "Other", + rows: [ + ["?", "Show / hide this cheat-sheet"], + ["Delete (with link selected)", "Unlink the selected pair"], + ], + }, + ] as Array<{ group: string; rows: Array<[string, string]> }> + ).map((sec) => ( +
+

+ {sec.group} +

+ + + {sec.rows.map(([key, label]) => ( + + + + + ))} + +
+ + {key} + + {label}
+
+ ))} +
+
+ )} + {renameOpen && (
+ {anchors.size === 2 && lassoSelected && ( + + )} )} @@ -6215,7 +6751,7 @@ function AppShell({ style={{ background: "#1abc9c", flex: 1, fontSize: 11 }} onClick={() => handleAutoLink("video")} disabled={busy} - title="Auto-pair across the two videos in this polygon" + title="Auto-pair across the two videos in this region (Shift+L)" > Auto-link by video @@ -6224,7 +6760,7 @@ function AppShell({ style={{ background: "#16a085", flex: 1, fontSize: 11 }} onClick={() => handleAutoLink("none")} disabled={busy} - title="Pair nearest neighbours in the polygon, ignoring side and video. Use when video_name is missing." + title="Pair nearest neighbours in the region, ignoring side and video. Use when video_name is missing. (Alt+L)" > Auto-link nearest @@ -6256,7 +6792,7 @@ function AppShell({ await refreshAfterMutation(); }} disabled={busy || lassoSelected.ids.length === 0} - title="Remove auto-link / locked-link from every asset in the lasso" + title="Remove auto-link / locked-link from every asset in the lasso (K)" > Clear links @@ -6647,7 +7183,7 @@ function AppShell({ }); }} disabled={busy} - title="Mark this asset as a GPS-correction anchor. Drag it after marking, then press Distribute to propagate the offset to other visible assets." + title="Mark this asset as a GPS-correction anchor. Drag it after marking, then press Distribute to propagate the offset to other visible assets. (A)" > {isAnchor ? "★ Anchor (click to unmark)" : "Mark as anchor"}