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())?;
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
556
src/App.tsx
556
src/App.tsx
@@ -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 L↔R
|
Auto-link L↔R
|
||||||
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user