lasso bugs
This commit is contained in:
@@ -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<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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
556
src/App.tsx
556
src/App.tsx
@@ -584,6 +584,10 @@ function AppShell({
|
||||
}, [autoLinkMaxM]);
|
||||
const [lassoListOpen, setLassoListOpen] = 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
|
||||
// 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<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") {
|
||||
if (!lassoPolygonPoints || lassoPolygonPoints.length < 3) {
|
||||
setStatus("Use the polygon lasso to define a region first.");
|
||||
@@ -3957,6 +4194,15 @@ function AppShell({
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<button
|
||||
className="gear-btn"
|
||||
onClick={() => setShowShortcutsHelp(true)}
|
||||
aria-label="Keyboard shortcuts"
|
||||
title="Keyboard shortcuts (?)"
|
||||
style={{ right: 96 }}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<div
|
||||
className="user-chip"
|
||||
title={`Logged in as ${currentUser.displayName} (@${currentUser.username}). Click to logout.`}
|
||||
@@ -3976,7 +4222,26 @@ function AppShell({
|
||||
>
|
||||
✕
|
||||
</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">
|
||||
Showing {filteredAssets.length} / {assets.length} loaded
|
||||
<button
|
||||
@@ -4451,6 +4716,261 @@ function AppShell({
|
||||
</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 && (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
@@ -5910,6 +6430,22 @@ function AppShell({
|
||||
Clear anchors
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
@@ -6206,7 +6742,7 @@ function AppShell({
|
||||
style={{ background: "#16a085", flex: 1, fontSize: 11 }}
|
||||
onClick={() => handleAutoLink("side")}
|
||||
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 L↔R
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -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
|
||||
</button>
|
||||
@@ -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"}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user