lasso bugs

This commit is contained in:
2026-06-10 19:54:10 +05:30
parent ea7730bef9
commit 49a4b51d75
3 changed files with 904 additions and 12 deletions

View File

@@ -7232,3 +7232,358 @@ pub async fn export_bboxes_to_json(
tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?; tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?;
Ok(n) 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<EqualizationProposal>,
pub anomalies: Vec<GapAnomaly>,
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<f64>,
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<f64>,
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<f64>), 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<f64>)> = 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<i64>,
max_road_distance_m: Option<f64>,
state: State<'_, AppState>,
) -> Result<EqualizationResult, String> {
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<i64> = 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<f64>, Option<f64>)>(&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<i64, (f64, f64)> =
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<f64> = 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<GapAnomaly> = 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<EqualizationProposal> = 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,
})
}

View File

@@ -8,7 +8,7 @@ use commands::{
import_osm_geojson, import_osm_geojson,
import_range_assets, list_assets, read_video_polylines_json, 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, 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, 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, create_osm_road, create_user, delete_road_section, delete_road_vertices,
delete_roads_in_lasso, set_link, simplify_road, delete_roads_in_lasso, set_link, simplify_road,
@@ -124,7 +124,8 @@ pub fn run() {
clear_asset_bbox, clear_asset_bbox,
export_bboxes_to_json, export_bboxes_to_json,
set_link, set_link,
clear_link clear_link,
equalize_along_road
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -584,6 +584,10 @@ function AppShell({
}, [autoLinkMaxM]); }, [autoLinkMaxM]);
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false); const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
const [showDeleted, setShowDeleted] = useState<boolean>(false); const [showDeleted, setShowDeleted] = useState<boolean>(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<boolean>(false);
// Pulse animation for "Blink modified points". A simple bool toggle drives // 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 // a setInterval in an effect; the tick is plumbed through to MapView so its
// dedicated overlay layer redraws every ~600 ms with alternating opacity. // dedicated overlay layer redraws every ~600 ms with alternating opacity.
@@ -1209,6 +1213,112 @@ function AppShell({
toggleOsmEditModeRef.current(); toggleOsmEditModeRef.current();
return; 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. // 'R' / 'r' opens the rename modal for the currently selected asset.
// Input-guard pattern matches the other shortcuts. // Input-guard pattern matches the other shortcuts.
if (e.key === "r" || e.key === "R") { if (e.key === "r" || e.key === "R") {
@@ -1339,6 +1449,11 @@ function AppShell({
return; return;
} }
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
if (showShortcutsHelp) {
e.preventDefault();
setShowShortcutsHelp(false);
return;
}
if (lensActive) { if (lensActive) {
e.preventDefault(); e.preventDefault();
setLensActive(false); setLensActive(false);
@@ -1384,7 +1499,7 @@ function AppShell({
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("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 // Image prefetch: when an asset is selected, warm the browser cache for its
// immediate neighbours so [/] navigation feels instant. // immediate neighbours so [/] navigation feels instant.
@@ -2610,6 +2725,21 @@ function AppShell({
if (haversine(center, [a.lng, a.lat]) <= radius) ids.push(a.id); if (haversine(center, [a.lng, a.lat]) <= radius) ids.push(a.id);
} }
label = `circle r=${radius.toFixed(0)} m`; 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") { } else if (shape === "rect") {
const [a, b] = points; const [a, b] = points;
const minLng = Math.min(a[0], b[0]); const minLng = Math.min(a[0], b[0]);
@@ -2632,6 +2762,13 @@ function AppShell({
ids.push(x.id); ids.push(x.id);
} }
label = "bbox"; label = "bbox";
// Bbox as a 4-vertex polygon for Auto-link + region-scoped actions.
setLassoPolygonPoints([
[minLng, minLat],
[maxLng, minLat],
[maxLng, maxLat],
[minLng, maxLat],
]);
} else { } else {
// polygon: ray-casting test // polygon: ray-casting test
const verts = points; const verts = points;
@@ -2821,15 +2958,23 @@ function AppShell({
anchorList.sort((a, b) => naturalCompare(a.rowId, b.rowId)); anchorList.sort((a, b) => naturalCompare(a.rowId, b.rowId));
const anchorIds = new Set(anchorList.map((a) => a.id)); const anchorIds = new Set(anchorList.map((a) => a.id));
// Build the corrections payload for every visible filtered asset that // Build the corrections payload. When a lasso selection is active we
// isn't itself an anchor (anchors already moved, no need to re-translate). // 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 const targets = filteredAssets
.filter( .filter(
(a) => (a) =>
a.deleted === 0 && a.deleted === 0 &&
a.lat !== null && a.lat !== null &&
a.lng !== null && a.lng !== null &&
!anchorIds.has(a.id), !anchorIds.has(a.id) &&
(lassoIdSet === null || lassoIdSet.has(a.id)),
) )
.slice() .slice()
.sort((a, b) => naturalCompare(a.row_id, b.row_id)); .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<EqualizationResult | null>(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<number>("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<EqualizationResult>(
"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") { async function handleAutoLink(splitBy: "side" | "video" | "none") {
if (!lassoPolygonPoints || lassoPolygonPoints.length < 3) { if (!lassoPolygonPoints || lassoPolygonPoints.length < 3) {
setStatus("Use the polygon lasso to define a region first."); setStatus("Use the polygon lasso to define a region first.");
@@ -3957,6 +4194,15 @@ function AppShell({
> >
</button> </button>
<button
className="gear-btn"
onClick={() => setShowShortcutsHelp(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
style={{ right: 96 }}
>
?
</button>
<div <div
className="user-chip" className="user-chip"
title={`Logged in as ${currentUser.displayName} (@${currentUser.username}). Click to logout.`} title={`Logged in as ${currentUser.displayName} (@${currentUser.username}). Click to logout.`}
@@ -3976,7 +4222,26 @@ function AppShell({
> >
</button> </button>
<h3>Filters</h3> <h3 style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ flex: 1 }}>Filters</span>
<button
type="button"
onClick={() => setShowShortcutsHelp(true)}
title="Show keyboard shortcuts (?)"
style={{
fontSize: 11,
padding: "0 6px",
border: "1px solid #bdc3c7",
background: "#fff",
color: "#34495e",
borderRadius: 10,
cursor: "pointer",
lineHeight: "16px",
}}
>
? Shortcuts
</button>
</h3>
<div className="filter-summary"> <div className="filter-summary">
Showing {filteredAssets.length} / {assets.length} loaded Showing {filteredAssets.length} / {assets.length} loaded
<button <button
@@ -4451,6 +4716,261 @@ function AppShell({
</button> </button>
)} )}
{equalizeReport && (
<div
className="modal-backdrop"
onClick={() => setEqualizeReport(null)}
>
<div
className="modal-card"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 560 }}
>
<button
className="modal-close"
onClick={() => setEqualizeReport(null)}
>
</button>
<h2>Spacing anomalies detected</h2>
<div className="status" style={{ marginBottom: 8 }}>
Median spacing along the road:{" "}
<strong>{equalizeReport.median_gap_m.toFixed(1)} m</strong> ·
arc length{" "}
<strong>{equalizeReport.arc_length_m.toFixed(0)} m</strong> ·
lateral offset{" "}
<strong>
{Math.abs(equalizeReport.lateral_offset_m).toFixed(1)} m{" "}
{equalizeReport.lateral_offset_m >= 0 ? "left" : "right"}
</strong>
</div>
<p style={{ fontSize: 12, color: "#444" }}>
{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.
</p>
<div
style={{
maxHeight: 220,
overflowY: "auto",
border: "1px solid #ddd",
borderRadius: 4,
padding: 6,
fontSize: 12,
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#666" }}>
<th style={{ padding: "2px 6px" }}>Between</th>
<th style={{ padding: "2px 6px" }}>Gap</th>
<th style={{ padding: "2px 6px" }}>Likely missing</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={`${g.before_id}-${g.after_id}-${i}`}
style={{ borderTop: "1px dotted #eee" }}
>
<td style={{ padding: "3px 6px" }}>
{label(beforeRow)} {label(afterRow)}
</td>
<td
style={{
padding: "3px 6px",
color: "#c0392b",
fontWeight: 600,
}}
>
{g.gap_m.toFixed(1)} m
</td>
<td style={{ padding: "3px 6px" }}>
{g.expected_missing}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div
style={{
display: "flex",
gap: 6,
marginTop: 12,
justifyContent: "flex-end",
}}
>
<button
className="primary-btn"
style={{ background: "#bdc3c7", color: "#222" }}
onClick={() => setEqualizeReport(null)}
disabled={busy}
>
Cancel
</button>
<button
className="primary-btn"
style={{ background: "#16a085" }}
onClick={async () => {
const r = equalizeReport;
setEqualizeReport(null);
setBusy(true);
try {
await applyEqualization(r);
} catch (e) {
setStatus(`Apply failed: ${e}`);
showToast(`Apply failed: ${e}`);
} finally {
setBusy(false);
}
}}
disabled={busy}
>
Apply anyway ({equalizeReport.proposals.length})
</button>
</div>
</div>
</div>
)}
{showShortcutsHelp && (
<div
className="modal-backdrop"
onClick={() => setShowShortcutsHelp(false)}
>
<div
className="modal-card"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: 620 }}
>
<button
className="modal-close"
onClick={() => setShowShortcutsHelp(false)}
>
</button>
<h2>Keyboard shortcuts</h2>
<div className="status" style={{ marginBottom: 8 }}>
Press <kbd>?</kbd> anywhere to open this dialog. <kbd>?</kbd>{" "}
again or <kbd>Esc</kbd> closes it. Hover any button in the side
panels for tooltips.
</div>
{(
[
{
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) => (
<div key={sec.group} style={{ marginBottom: 10 }}>
<h4
style={{
margin: "8px 0 4px",
fontSize: 11,
textTransform: "uppercase",
color: "#555",
letterSpacing: 0.5,
}}
>
{sec.group}
</h4>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
}}
>
<tbody>
{sec.rows.map(([key, label]) => (
<tr key={key + label}>
<td
style={{
width: 180,
padding: "3px 6px 3px 0",
verticalAlign: "top",
}}
>
<kbd
style={{
fontFamily: "monospace",
background: "#ecf0f1",
border: "1px solid #bdc3c7",
borderRadius: 3,
padding: "1px 6px",
fontSize: 11,
}}
>
{key}
</kbd>
</td>
<td style={{ padding: "3px 0" }}>{label}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
</div>
)}
{renameOpen && ( {renameOpen && (
<div <div
className="modal-backdrop" className="modal-backdrop"
@@ -5910,6 +6430,22 @@ function AppShell({
Clear anchors Clear anchors
</button> </button>
</div> </div>
{anchors.size === 2 && lassoSelected && (
<button
className="primary-btn"
style={{
marginTop: 4,
width: "100%",
background: "#16a085",
fontSize: 11,
}}
onClick={handleEqualizeAlongRoad}
disabled={busy || lassoSelected.ids.length === 0}
title="Project the two anchors + lassoed assets onto the nearest OSM road and redistribute them at constant arc-length spacing. Runs a gap check first; if discrepancies are found you'll be prompted."
>
Equalize along road
</button>
)}
</div> </div>
)} )}
<button <button
@@ -6206,7 +6742,7 @@ function AppShell({
style={{ background: "#16a085", flex: 1, fontSize: 11 }} style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("side")} onClick={() => handleAutoLink("side")}
disabled={busy} disabled={busy}
title="Auto-pair Left↔Right within this polygon by proximity" title="Auto-pair Left↔Right within this region by proximity (L)"
> >
Auto-link LR Auto-link LR
</button> </button>
@@ -6215,7 +6751,7 @@ function AppShell({
style={{ background: "#1abc9c", flex: 1, fontSize: 11 }} style={{ background: "#1abc9c", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("video")} onClick={() => handleAutoLink("video")}
disabled={busy} 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 Auto-link by video
</button> </button>
@@ -6224,7 +6760,7 @@ function AppShell({
style={{ background: "#16a085", flex: 1, fontSize: 11 }} style={{ background: "#16a085", flex: 1, fontSize: 11 }}
onClick={() => handleAutoLink("none")} onClick={() => handleAutoLink("none")}
disabled={busy} 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 Auto-link nearest
</button> </button>
@@ -6256,7 +6792,7 @@ function AppShell({
await refreshAfterMutation(); await refreshAfterMutation();
}} }}
disabled={busy || lassoSelected.ids.length === 0} 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 Clear links
</button> </button>
@@ -6647,7 +7183,7 @@ function AppShell({
}); });
}} }}
disabled={busy} 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"} {isAnchor ? "★ Anchor (click to unmark)" : "Mark as anchor"}
</button> </button>