linear bug and osm new features
This commit is contained in:
@@ -66,8 +66,14 @@ struct RangeAssetInput {
|
|||||||
row_id: String,
|
row_id: String,
|
||||||
asset_name: String,
|
asset_name: String,
|
||||||
video_name: String,
|
video_name: String,
|
||||||
coord_lat: NumOrStr,
|
// Midpoint is optional: real data sometimes ships only start+end (no
|
||||||
coord_lng: NumOrStr,
|
// mid-frame annotated). When absent we synthesize (start+end)/2 below so
|
||||||
|
// the line still renders; the popup gates the M pin on image_path2 so a
|
||||||
|
// synthesized midpoint never shows as a third pin.
|
||||||
|
#[serde(default)]
|
||||||
|
coord_lat: Option<NumOrStr>,
|
||||||
|
#[serde(default)]
|
||||||
|
coord_lng: Option<NumOrStr>,
|
||||||
start_coord_lat: NumOrStr,
|
start_coord_lat: NumOrStr,
|
||||||
start_coord_lng: NumOrStr,
|
start_coord_lng: NumOrStr,
|
||||||
end_coord_lat: NumOrStr,
|
end_coord_lat: NumOrStr,
|
||||||
@@ -81,6 +87,19 @@ struct RangeAssetInput {
|
|||||||
side: Option<String>,
|
side: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn opt_num_or_str_to_f64(v: &Option<NumOrStr>) -> Option<f64> {
|
||||||
|
match v.as_ref()? {
|
||||||
|
NumOrStr::Num(n) => Some(*n).filter(|x| x.is_finite()),
|
||||||
|
NumOrStr::Str(s) => {
|
||||||
|
let t = s.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
t.parse::<f64>().ok().filter(|x| x.is_finite())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use std::sync::{OnceLock, RwLock};
|
use std::sync::{OnceLock, RwLock};
|
||||||
static CURRENT_USER: OnceLock<RwLock<String>> = OnceLock::new();
|
static CURRENT_USER: OnceLock<RwLock<String>> = OnceLock::new();
|
||||||
fn user_id() -> String {
|
fn user_id() -> String {
|
||||||
@@ -262,12 +281,16 @@ async fn insert_range_row(
|
|||||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
input: &RangeAssetInput,
|
input: &RangeAssetInput,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mid_lat = input.coord_lat.to_f64()?;
|
|
||||||
let mid_lng = input.coord_lng.to_f64()?;
|
|
||||||
let s_lat = input.start_coord_lat.to_f64()?;
|
let s_lat = input.start_coord_lat.to_f64()?;
|
||||||
let s_lng = input.start_coord_lng.to_f64()?;
|
let s_lng = input.start_coord_lng.to_f64()?;
|
||||||
let e_lat = input.end_coord_lat.to_f64()?;
|
let e_lat = input.end_coord_lat.to_f64()?;
|
||||||
let e_lng = input.end_coord_lng.to_f64()?;
|
let e_lng = input.end_coord_lng.to_f64()?;
|
||||||
|
// Synthesize midpoint when missing/empty so the line still renders.
|
||||||
|
// Whether the midpoint is real is communicated to the UI via image_path2:
|
||||||
|
// popup gates the M pin on that, so a synthesized mid never shows as a
|
||||||
|
// third pin.
|
||||||
|
let mid_lat = opt_num_or_str_to_f64(&input.coord_lat).unwrap_or((s_lat + e_lat) / 2.0);
|
||||||
|
let mid_lng = opt_num_or_str_to_f64(&input.coord_lng).unwrap_or((s_lng + e_lng) / 2.0);
|
||||||
let min_lat = mid_lat.min(s_lat).min(e_lat);
|
let min_lat = mid_lat.min(s_lat).min(e_lat);
|
||||||
let max_lat = mid_lat.max(s_lat).max(e_lat);
|
let max_lat = mid_lat.max(s_lat).max(e_lat);
|
||||||
let min_lng = mid_lng.min(s_lng).min(e_lng);
|
let min_lng = mid_lng.min(s_lng).min(e_lng);
|
||||||
@@ -437,13 +460,29 @@ pub async fn import_assets_auto(
|
|||||||
let mut fixed_count = 0usize;
|
let mut fixed_count = 0usize;
|
||||||
let mut range_count = 0usize;
|
let mut range_count = 0usize;
|
||||||
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
|
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
|
// Tighter range detection: presence of `start_coord_*` / `end_coord_*`
|
||||||
|
// keys isn't enough — many pipelines emit a SHARED schema where those
|
||||||
|
// fields exist on every row but are empty strings on fixed assets
|
||||||
|
// ("start_coord_lat": ""). Treat the row as range only when all four
|
||||||
|
// start/end coord fields parse to a finite number.
|
||||||
|
fn coord_field_valid(v: Option<&serde_json::Value>) -> bool {
|
||||||
|
match v {
|
||||||
|
Some(serde_json::Value::Number(n)) => {
|
||||||
|
n.as_f64().map(|x| x.is_finite()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
Some(serde_json::Value::String(s)) => {
|
||||||
|
let t = s.trim();
|
||||||
|
!t.is_empty()
|
||||||
|
&& t.parse::<f64>().map(|x| x.is_finite()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
for row in rows {
|
for row in rows {
|
||||||
// Range assets carry start_coord_* and end_coord_* fields. Use both
|
let is_range = coord_field_valid(row.get("start_coord_lat"))
|
||||||
// to disambiguate from fixed (which has only `coord`). A row that
|
&& coord_field_valid(row.get("start_coord_lng"))
|
||||||
// also carries `coord` is fine — RangeAssetInput's `coord_lat` /
|
&& coord_field_valid(row.get("end_coord_lat"))
|
||||||
// `coord_lng` capture mid; the `coord` field is just ignored.
|
&& coord_field_valid(row.get("end_coord_lng"));
|
||||||
let is_range = row.get("start_coord_lat").is_some()
|
|
||||||
&& row.get("end_coord_lat").is_some();
|
|
||||||
if is_range {
|
if is_range {
|
||||||
let input: RangeAssetInput =
|
let input: RangeAssetInput =
|
||||||
serde_json::from_value(row).map_err(|e| e.to_string())?;
|
serde_json::from_value(row).map_err(|e| e.to_string())?;
|
||||||
@@ -4382,6 +4421,428 @@ pub async fn delete_road_vertices(
|
|||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn coords_bbox(coords: &[[f64; 2]]) -> (f64, f64, f64, f64) {
|
||||||
|
let mut mn_lat = f64::INFINITY;
|
||||||
|
let mut mx_lat = f64::NEG_INFINITY;
|
||||||
|
let mut mn_lng = f64::INFINITY;
|
||||||
|
let mut mx_lng = f64::NEG_INFINITY;
|
||||||
|
for &[lng, lat] in coords {
|
||||||
|
if lat < mn_lat { mn_lat = lat; }
|
||||||
|
if lat > mx_lat { mx_lat = lat; }
|
||||||
|
if lng < mn_lng { mn_lng = lng; }
|
||||||
|
if lng > mx_lng { mx_lng = lng; }
|
||||||
|
}
|
||||||
|
(mn_lat, mx_lat, mn_lng, mx_lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the section of a road that lies between two (or more) marked
|
||||||
|
/// vertices. Behaviour:
|
||||||
|
/// - sort the indices, take min and max
|
||||||
|
/// - drop everything strictly between them; keep [0..=min] (left) and
|
||||||
|
/// [max..end] (right) as the survivors
|
||||||
|
/// - if both halves have ≥2 vertices → split into TWO road rows
|
||||||
|
/// (original updated to left, new row inserted for right with the same
|
||||||
|
/// name / oneway / highway / snap_ignored carried over)
|
||||||
|
/// - if only one half has ≥2 vertices → original is shrunk to that half
|
||||||
|
/// - if neither half has ≥2 vertices → error
|
||||||
|
/// Returns the ids of the resulting road rows (1 or 2).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_road_section(
|
||||||
|
id: i64,
|
||||||
|
indices: Vec<usize>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<i64>, String> {
|
||||||
|
if indices.len() < 2 {
|
||||||
|
return Err("need at least 2 marked vertices to define a section".into());
|
||||||
|
}
|
||||||
|
let row: (Option<String>, String, i64, Option<String>, i64) = sqlx::query_as(
|
||||||
|
"SELECT name, geojson, COALESCE(oneway, 0), highway,
|
||||||
|
COALESCE(snap_ignored, 0)
|
||||||
|
FROM roads WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (name, geojson, oneway, highway, snap_ignored) = row;
|
||||||
|
|
||||||
|
let val: serde_json::Value = serde_json::from_str(&geojson)
|
||||||
|
.map_err(|e| format!("invalid geojson: {}", e))?;
|
||||||
|
let arr = val
|
||||||
|
.get("coordinates")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.ok_or_else(|| "road has no coordinates".to_string())?;
|
||||||
|
let coords: Vec<[f64; 2]> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| p.as_array())
|
||||||
|
.filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
|
||||||
|
.collect();
|
||||||
|
if coords.len() < 2 {
|
||||||
|
return Err("road has < 2 coordinates".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted = indices;
|
||||||
|
sorted.sort_unstable();
|
||||||
|
sorted.dedup();
|
||||||
|
if sorted.len() < 2 {
|
||||||
|
return Err("need at least 2 distinct marked vertices".into());
|
||||||
|
}
|
||||||
|
let min_i = sorted[0];
|
||||||
|
let max_i = *sorted.last().unwrap();
|
||||||
|
if max_i >= coords.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"vertex index {} out of range (road has {} vertices)",
|
||||||
|
max_i, coords.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Adjacent marks (max_i == min_i + 1) sever the single edge between them:
|
||||||
|
// left keeps [..=min_i], right starts at max_i — both halves still hold both
|
||||||
|
// marked vertices, just the connecting edge is dropped. Non-adjacent marks
|
||||||
|
// additionally drop the strictly-between vertices.
|
||||||
|
let left: Vec<[f64; 2]> = coords[0..=min_i].to_vec();
|
||||||
|
let right: Vec<[f64; 2]> = coords[max_i..].to_vec();
|
||||||
|
let left_ok = left.len() >= 2;
|
||||||
|
let right_ok = right.len() >= 2;
|
||||||
|
if !left_ok && !right_ok {
|
||||||
|
return Err(
|
||||||
|
"section deletion would leave < 2 vertices on both sides".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
|
let mut result_ids: Vec<i64> = Vec::new();
|
||||||
|
|
||||||
|
// Helper: shrink the original road to the given coords, fix r-tree.
|
||||||
|
async fn shrink(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: i64,
|
||||||
|
coords: &[[f64; 2]],
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (mn_lat, mx_lat, mn_lng, mx_lng) = coords_bbox(coords);
|
||||||
|
let geom = serde_json::json!({
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": coords,
|
||||||
|
});
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE roads SET geojson = ?, min_lat = ?, max_lat = ?,
|
||||||
|
min_lng = ?, max_lng = ?
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(geom.to_string())
|
||||||
|
.bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
// R-tree has only INSERT/DELETE triggers; emulate UPDATE.
|
||||||
|
sqlx::query("DELETE FROM roads_rtree WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO roads_rtree(id, min_lat, max_lat, min_lng, max_lng)
|
||||||
|
VALUES (?, ?, ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(id).bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
if left_ok && right_ok {
|
||||||
|
// Split: original keeps the left half; right half becomes a new row
|
||||||
|
// carrying the same name/oneway/highway/snap_ignored.
|
||||||
|
shrink(&mut tx, id, &left).await?;
|
||||||
|
result_ids.push(id);
|
||||||
|
let (rn_lat, rx_lat, rn_lng, rx_lng) = coords_bbox(&right);
|
||||||
|
let geom = serde_json::json!({
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": right,
|
||||||
|
});
|
||||||
|
let inserted: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng,
|
||||||
|
oneway, highway, snap_ignored)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&name)
|
||||||
|
.bind(geom.to_string())
|
||||||
|
.bind(rn_lat).bind(rx_lat).bind(rn_lng).bind(rx_lng)
|
||||||
|
.bind(oneway)
|
||||||
|
.bind(&highway)
|
||||||
|
.bind(snap_ignored)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
result_ids.push(inserted.0);
|
||||||
|
} else if left_ok {
|
||||||
|
shrink(&mut tx, id, &left).await?;
|
||||||
|
result_ids.push(id);
|
||||||
|
} else {
|
||||||
|
shrink(&mut tx, id, &right).await?;
|
||||||
|
result_ids.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(result_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct LassoRoadDeleteResult {
|
||||||
|
pub roads_examined: usize,
|
||||||
|
pub roads_deleted: usize,
|
||||||
|
pub roads_split: usize,
|
||||||
|
pub new_road_ids: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk-delete road sections that fall inside a lasso polygon.
|
||||||
|
///
|
||||||
|
/// `polygon` is a sequence of `[lng, lat]` points in any order (closing point
|
||||||
|
/// optional — implicitly closed). For each road that overlaps the polygon's
|
||||||
|
/// bbox we walk segment-by-segment, intersecting each road edge with the
|
||||||
|
/// polygon boundary. Outside-pieces survive as new road rows (with boundary
|
||||||
|
/// crossings as fresh vertices); inside-pieces are dropped. This handles
|
||||||
|
/// concave lassos correctly — a segment that crosses the boundary multiple
|
||||||
|
/// times keeps every outside sub-segment.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_roads_in_lasso(
|
||||||
|
polygon: Vec<[f64; 2]>,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<LassoRoadDeleteResult, String> {
|
||||||
|
if polygon.len() < 3 {
|
||||||
|
return Err("lasso polygon needs at least 3 points".into());
|
||||||
|
}
|
||||||
|
// Polygon bbox (lng, lat) — coarse pre-filter via R-tree.
|
||||||
|
let mut p_mn_lat = f64::INFINITY;
|
||||||
|
let mut p_mx_lat = f64::NEG_INFINITY;
|
||||||
|
let mut p_mn_lng = f64::INFINITY;
|
||||||
|
let mut p_mx_lng = f64::NEG_INFINITY;
|
||||||
|
for &[lng, lat] in &polygon {
|
||||||
|
if lat < p_mn_lat { p_mn_lat = lat; }
|
||||||
|
if lat > p_mx_lat { p_mx_lat = lat; }
|
||||||
|
if lng < p_mn_lng { p_mn_lng = lng; }
|
||||||
|
if lng > p_mx_lng { p_mx_lng = lng; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate_rows: Vec<(i64, Option<String>, String, i64, Option<String>, i64)> =
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT r.id, r.name, r.geojson, COALESCE(r.oneway, 0), r.highway,
|
||||||
|
COALESCE(r.snap_ignored, 0)
|
||||||
|
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 <= ?",
|
||||||
|
)
|
||||||
|
.bind(p_mn_lat)
|
||||||
|
.bind(p_mx_lat)
|
||||||
|
.bind(p_mn_lng)
|
||||||
|
.bind(p_mx_lng)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
fn point_in_polygon(lng: f64, lat: f64, poly: &[[f64; 2]]) -> bool {
|
||||||
|
// Standard ray-casting. Boundary cases default to "inside" — fine for
|
||||||
|
// lasso UX since users expect a generous interpretation.
|
||||||
|
let mut inside = false;
|
||||||
|
let n = poly.len();
|
||||||
|
let mut j = n - 1;
|
||||||
|
for i in 0..n {
|
||||||
|
let (xi, yi) = (poly[i][0], poly[i][1]);
|
||||||
|
let (xj, yj) = (poly[j][0], poly[j][1]);
|
||||||
|
let cond = (yi > lat) != (yj > lat)
|
||||||
|
&& lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-15) + xi;
|
||||||
|
if cond { inside = !inside; }
|
||||||
|
j = i;
|
||||||
|
}
|
||||||
|
inside
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment vs. polygon-edge intersection. Returns the parameter t∈(0,1) at
|
||||||
|
// which segment a→b crosses edge p→q, or None if no proper crossing.
|
||||||
|
// Endpoints touching (t=0 / t=1) are treated as non-crossings to avoid
|
||||||
|
// double-counting at shared vertices.
|
||||||
|
fn segment_intersect_t(
|
||||||
|
a: [f64; 2],
|
||||||
|
b: [f64; 2],
|
||||||
|
p: [f64; 2],
|
||||||
|
q: [f64; 2],
|
||||||
|
) -> Option<f64> {
|
||||||
|
let rx = b[0] - a[0];
|
||||||
|
let ry = b[1] - a[1];
|
||||||
|
let sx = q[0] - p[0];
|
||||||
|
let sy = q[1] - p[1];
|
||||||
|
let denom = rx * sy - ry * sx;
|
||||||
|
if denom.abs() < 1e-15 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dx = p[0] - a[0];
|
||||||
|
let dy = p[1] - a[1];
|
||||||
|
let t = (dx * sy - dy * sx) / denom;
|
||||||
|
let u = (dx * ry - dy * rx) / denom;
|
||||||
|
if t > 1e-12 && t < 1.0 - 1e-12 && u >= -1e-12 && u <= 1.0 + 1e-12 {
|
||||||
|
Some(t)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] {
|
||||||
|
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
|
||||||
|
let mut roads_deleted = 0usize;
|
||||||
|
let mut roads_split = 0usize;
|
||||||
|
let mut new_road_ids: Vec<i64> = Vec::new();
|
||||||
|
let examined = candidate_rows.len();
|
||||||
|
|
||||||
|
for (id, name, geojson, oneway, highway, snap_ignored) in candidate_rows {
|
||||||
|
let val: serde_json::Value = match serde_json::from_str(&geojson) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let arr = match val.get("coordinates").and_then(|c| c.as_array()) {
|
||||||
|
Some(a) => a,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let coords: Vec<[f64; 2]> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| p.as_array())
|
||||||
|
.filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
|
||||||
|
.collect();
|
||||||
|
if coords.len() < 2 { continue; }
|
||||||
|
|
||||||
|
let inside_flags: Vec<bool> = coords
|
||||||
|
.iter()
|
||||||
|
.map(|c| point_in_polygon(c[0], c[1], &polygon))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if inside_flags.iter().all(|&b| b) {
|
||||||
|
// Every vertex inside; assume edges are too. Delete outright.
|
||||||
|
sqlx::query("DELETE FROM roads WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
roads_deleted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk each edge, splitting on polygon-boundary crossings. Each
|
||||||
|
// sub-edge is wholly inside or wholly outside; outside sub-edges are
|
||||||
|
// appended to the current "kept" run and inside sub-edges close it.
|
||||||
|
let mut kept_runs: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||||
|
let mut cur: Vec<[f64; 2]> = Vec::new();
|
||||||
|
|
||||||
|
let push_outside = |cur: &mut Vec<[f64; 2]>, p: [f64; 2]| {
|
||||||
|
// Avoid duplicating the previous endpoint (consecutive segments
|
||||||
|
// share a vertex).
|
||||||
|
if cur.last().map(|q| (q[0] - p[0]).abs() < 1e-12 && (q[1] - p[1]).abs() < 1e-12)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cur.push(p);
|
||||||
|
};
|
||||||
|
let close_run = |kept_runs: &mut Vec<Vec<[f64; 2]>>, cur: &mut Vec<[f64; 2]>| {
|
||||||
|
if cur.len() >= 2 {
|
||||||
|
kept_runs.push(std::mem::take(cur));
|
||||||
|
} else {
|
||||||
|
cur.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for i in 0..coords.len() - 1 {
|
||||||
|
let a = coords[i];
|
||||||
|
let b = coords[i + 1];
|
||||||
|
// Collect all boundary crossings within this segment.
|
||||||
|
let mut ts: Vec<f64> = Vec::new();
|
||||||
|
let n = polygon.len();
|
||||||
|
for k in 0..n {
|
||||||
|
let p = polygon[k];
|
||||||
|
let q = polygon[(k + 1) % n];
|
||||||
|
if let Some(t) = segment_intersect_t(a, b, p, q) {
|
||||||
|
ts.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
|
||||||
|
|
||||||
|
// Sub-segment endpoints along a→b: 0, t1, t2, …, 1
|
||||||
|
let mut breakpoints: Vec<f64> = vec![0.0];
|
||||||
|
breakpoints.extend(ts);
|
||||||
|
breakpoints.push(1.0);
|
||||||
|
|
||||||
|
// Inside/outside alternates, starting from inside_flags[i].
|
||||||
|
let mut currently_inside = inside_flags[i];
|
||||||
|
for w in breakpoints.windows(2) {
|
||||||
|
let (t0, t1) = (w[0], w[1]);
|
||||||
|
if (t1 - t0).abs() < 1e-12 {
|
||||||
|
currently_inside = !currently_inside;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p0 = lerp(a, b, t0);
|
||||||
|
let p1 = lerp(a, b, t1);
|
||||||
|
if currently_inside {
|
||||||
|
// Closes any open kept-run; the inside piece is dropped.
|
||||||
|
close_run(&mut kept_runs, &mut cur);
|
||||||
|
} else {
|
||||||
|
// Outside piece: append both endpoints (dedup against last).
|
||||||
|
push_outside(&mut cur, p0);
|
||||||
|
push_outside(&mut cur, p1);
|
||||||
|
}
|
||||||
|
currently_inside = !currently_inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close_run(&mut kept_runs, &mut cur);
|
||||||
|
|
||||||
|
// The road had at least one inside vertex (we early-returned the
|
||||||
|
// wholly-inside case above), so SOMETHING changed: delete the original
|
||||||
|
// and re-insert the surviving outside runs.
|
||||||
|
sqlx::query("DELETE FROM roads WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if kept_runs.is_empty() {
|
||||||
|
roads_deleted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for run in kept_runs {
|
||||||
|
let (mn_lat, mx_lat, mn_lng, mx_lng) = coords_bbox(&run);
|
||||||
|
let geom = serde_json::json!({
|
||||||
|
"type": "LineString",
|
||||||
|
"coordinates": run,
|
||||||
|
});
|
||||||
|
let inserted: (i64,) = sqlx::query_as(
|
||||||
|
"INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng,
|
||||||
|
oneway, highway, snap_ignored)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
|
||||||
|
)
|
||||||
|
.bind(&name)
|
||||||
|
.bind(geom.to_string())
|
||||||
|
.bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng)
|
||||||
|
.bind(oneway)
|
||||||
|
.bind(&highway)
|
||||||
|
.bind(snap_ignored)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
new_road_ids.push(inserted.0);
|
||||||
|
}
|
||||||
|
roads_split += 1;
|
||||||
|
}
|
||||||
|
tx.commit().await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(LassoRoadDeleteResult {
|
||||||
|
roads_examined: examined,
|
||||||
|
roads_deleted,
|
||||||
|
roads_split,
|
||||||
|
new_road_ids,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Douglas-Peucker simplification of a road's polyline. Tolerance is in metres.
|
/// Douglas-Peucker simplification of a road's polyline. Tolerance is in metres.
|
||||||
/// Converted to a degrees epsilon at the road's centroid latitude — accurate
|
/// Converted to a degrees epsilon at the road's centroid latitude — accurate
|
||||||
/// enough for cleanup tolerances of 0.5–10 m.
|
/// enough for cleanup tolerances of 0.5–10 m.
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ use commands::{
|
|||||||
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,
|
||||||
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_vertices, set_link, simplify_road,
|
create_osm_road, create_user, delete_road_section, delete_road_vertices,
|
||||||
|
delete_roads_in_lasso, set_link, simplify_road,
|
||||||
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
|
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
|
||||||
save_video_metadata, get_video_metadata, clear_video_metadata,
|
save_video_metadata, get_video_metadata, clear_video_metadata,
|
||||||
flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets,
|
flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets,
|
||||||
@@ -70,6 +71,8 @@ pub fn run() {
|
|||||||
update_osm_road,
|
update_osm_road,
|
||||||
create_osm_road,
|
create_osm_road,
|
||||||
delete_road_vertices,
|
delete_road_vertices,
|
||||||
|
delete_road_section,
|
||||||
|
delete_roads_in_lasso,
|
||||||
simplify_road,
|
simplify_road,
|
||||||
delete_osm_road,
|
delete_osm_road,
|
||||||
list_asset_names,
|
list_asset_names,
|
||||||
|
|||||||
178
src/App.tsx
178
src/App.tsx
@@ -400,7 +400,15 @@ function AppShell({
|
|||||||
snap_ignored: number;
|
snap_ignored: number;
|
||||||
};
|
};
|
||||||
type OsmUndoEntry =
|
type OsmUndoEntry =
|
||||||
| { kind: "restore-roads"; snapshots: OsmRoadSnap[]; label: string }
|
| {
|
||||||
|
kind: "restore-roads";
|
||||||
|
snapshots: OsmRoadSnap[];
|
||||||
|
// Rows that were *created* by the operation (e.g. the right-half row
|
||||||
|
// from a section split). Undo deletes them before restoring snapshots,
|
||||||
|
// so the original geometry is the only survivor.
|
||||||
|
createdIds?: number[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
| { kind: "unhide"; ids: number[]; label: string }
|
| { kind: "unhide"; ids: number[]; label: string }
|
||||||
| { kind: "rehide"; ids: number[]; label: string };
|
| { kind: "rehide"; ids: number[]; label: string };
|
||||||
const [osmUndoStack, setOsmUndoStack] = useState<OsmUndoEntry[]>([]);
|
const [osmUndoStack, setOsmUndoStack] = useState<OsmUndoEntry[]>([]);
|
||||||
@@ -423,12 +431,41 @@ function AppShell({
|
|||||||
console.warn("osm_road_snapshot failed", e);
|
console.warn("osm_road_snapshot failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// For ops that mutate AND create new rows (delete_road_section split), the
|
||||||
|
// caller should snapshot first, run the op, then call this with the ids
|
||||||
|
// that were freshly inserted so undo can drop them on the way back.
|
||||||
|
function attachCreatedIdsToLastUndo(createdIds: number[]) {
|
||||||
|
if (createdIds.length === 0) return;
|
||||||
|
setOsmUndoStack((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last.kind !== "restore-roads") return prev;
|
||||||
|
const next = prev.slice();
|
||||||
|
next[next.length - 1] = {
|
||||||
|
...last,
|
||||||
|
createdIds: [...(last.createdIds ?? []), ...createdIds],
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
async function performOsmUndo() {
|
async function performOsmUndo() {
|
||||||
if (osmUndoStack.length === 0) return;
|
if (osmUndoStack.length === 0) return;
|
||||||
const last = osmUndoStack[osmUndoStack.length - 1];
|
const last = osmUndoStack[osmUndoStack.length - 1];
|
||||||
setOsmUndoStack((prev) => prev.slice(0, -1));
|
setOsmUndoStack((prev) => prev.slice(0, -1));
|
||||||
try {
|
try {
|
||||||
if (last.kind === "restore-roads") {
|
if (last.kind === "restore-roads") {
|
||||||
|
// Delete any rows that the operation created before restoring the
|
||||||
|
// original geometry — otherwise a split-section undo leaves a stale
|
||||||
|
// duplicate alongside the resurrected original.
|
||||||
|
if (last.createdIds && last.createdIds.length > 0) {
|
||||||
|
for (const cid of last.createdIds) {
|
||||||
|
try {
|
||||||
|
await invoke("delete_osm_road", { id: cid });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`undo: delete_osm_road(${cid}) failed`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const n = await invoke<number>("restore_road_state", {
|
const n = await invoke<number>("restore_road_state", {
|
||||||
snapshots: last.snapshots,
|
snapshots: last.snapshots,
|
||||||
});
|
});
|
||||||
@@ -2069,6 +2106,82 @@ function AppShell({
|
|||||||
Math.sin(dLng / 2) ** 2;
|
Math.sin(dLng / 2) ** 2;
|
||||||
return 2 * R * Math.asin(Math.sqrt(s));
|
return 2 * R * Math.asin(Math.sqrt(s));
|
||||||
};
|
};
|
||||||
|
// OSM-edit-mode lasso: convert shape → polygon and bulk-delete the road
|
||||||
|
// sections that fall inside it. Skips the asset-bulk-action flow below.
|
||||||
|
if (osmEditMode) {
|
||||||
|
const m_per_lat = 111_320;
|
||||||
|
let polygon: Array<[number, number]> = [];
|
||||||
|
if (shape === "circle") {
|
||||||
|
const c = points[0];
|
||||||
|
const edge = points[1];
|
||||||
|
const radius_m = haversine(c, edge);
|
||||||
|
if (radius_m < 1) {
|
||||||
|
setStatus("Lasso too small.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m_per_lng = 111_320 * Math.cos(toRad(c[1]));
|
||||||
|
const N = 64;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const a = (i / N) * Math.PI * 2;
|
||||||
|
polygon.push([
|
||||||
|
c[0] + (radius_m * Math.cos(a)) / m_per_lng,
|
||||||
|
c[1] + (radius_m * Math.sin(a)) / m_per_lat,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if (shape === "rect") {
|
||||||
|
const [a, b] = points;
|
||||||
|
const minLng = Math.min(a[0], b[0]);
|
||||||
|
const maxLng = Math.max(a[0], b[0]);
|
||||||
|
const minLat = Math.min(a[1], b[1]);
|
||||||
|
const maxLat = Math.max(a[1], b[1]);
|
||||||
|
polygon = [
|
||||||
|
[minLng, minLat],
|
||||||
|
[maxLng, minLat],
|
||||||
|
[maxLng, maxLat],
|
||||||
|
[minLng, maxLat],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
if (points.length < 3) {
|
||||||
|
setStatus("Need at least 3 polygon vertices.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
polygon = points.map((p) => [p[0], p[1]] as [number, number]);
|
||||||
|
}
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Bulk-delete OSM road sections inside this lasso?\n\n` +
|
||||||
|
`Roads fully inside are removed; roads partially inside are clipped at the lasso boundary — the inside portion is dropped, the outside portion(s) survive as separate roads. ` +
|
||||||
|
`This is NOT covered by the OSM Undo (the ring buffer doesn't recreate deleted rows).`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
setLassoMode(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
invoke<{
|
||||||
|
roads_examined: number;
|
||||||
|
roads_deleted: number;
|
||||||
|
roads_split: number;
|
||||||
|
new_road_ids: number[];
|
||||||
|
}>("delete_roads_in_lasso", { polygon })
|
||||||
|
.then(async (r) => {
|
||||||
|
setStatus(
|
||||||
|
`Lasso roads: examined ${r.roads_examined}, fully deleted ${r.roads_deleted}, split ${r.roads_split} (→ ${r.new_road_ids.length} new sub-road(s)).`,
|
||||||
|
);
|
||||||
|
// The currently-edited / selected / marked road(s) may have been
|
||||||
|
// deleted or split — drop those UI references so the next click
|
||||||
|
// doesn't act on a stale id.
|
||||||
|
setEditingRoadId(null);
|
||||||
|
setMarkedVertices(new Set());
|
||||||
|
setSelectedRoadIds(new Set());
|
||||||
|
await refreshOsmRoads();
|
||||||
|
})
|
||||||
|
.catch((e) => setStatus(`Bulk road delete failed: ${e}`))
|
||||||
|
.finally(() => {
|
||||||
|
setBusy(false);
|
||||||
|
setLassoMode(false);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ids: number[] = [];
|
const ids: number[] = [];
|
||||||
let label = "";
|
let label = "";
|
||||||
if (shape === "circle") {
|
if (shape === "circle") {
|
||||||
@@ -4667,6 +4780,56 @@ function AppShell({
|
|||||||
>
|
>
|
||||||
Simplify this road
|
Simplify this road
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="primary-btn"
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
width: "100%",
|
||||||
|
background: "#c0392b",
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
disabled={busy || markedVertices.size < 2}
|
||||||
|
onClick={async () => {
|
||||||
|
if (editingRoadId === null) return;
|
||||||
|
const idx = Array.from(markedVertices).sort((a, b) => a - b);
|
||||||
|
if (idx.length < 2) return;
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Delete the road section between vertex #${idx[0]} and vertex #${idx[idx.length - 1]}?\n\n` +
|
||||||
|
"If both halves keep ≥2 vertices the road splits in two; otherwise the original road is shrunk.",
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await snapshotRoadsForUndo([editingRoadId], "delete section");
|
||||||
|
const origId = editingRoadId;
|
||||||
|
const ids = await invoke<number[]>("delete_road_section", {
|
||||||
|
id: origId,
|
||||||
|
indices: idx,
|
||||||
|
});
|
||||||
|
// Anything other than the original id was freshly inserted
|
||||||
|
// by the split — attach those to the snapshot so undo
|
||||||
|
// deletes them before restoring the original geometry.
|
||||||
|
const createdIds = ids.filter((i) => i !== origId);
|
||||||
|
if (createdIds.length > 0) {
|
||||||
|
attachCreatedIdsToLastUndo(createdIds);
|
||||||
|
}
|
||||||
|
setMarkedVertices(new Set());
|
||||||
|
setStatus(
|
||||||
|
ids.length === 1
|
||||||
|
? `Section deleted; road #${ids[0]} kept.`
|
||||||
|
: `Section deleted; road split into #${ids[0]} and #${ids[1]}.`,
|
||||||
|
);
|
||||||
|
await refreshOsmRoads();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Delete section failed: ${e}`);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Drop the marked vertex range from this road. With 2 marks: deletes everything strictly between them. Splits the road into two if both halves still have ≥2 vertices."
|
||||||
|
>
|
||||||
|
Delete section between marked
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{focusedVideo === null && (
|
{focusedVideo === null && (
|
||||||
<button
|
<button
|
||||||
@@ -4852,12 +5015,17 @@ function AppShell({
|
|||||||
setLassoSelected(null);
|
setLassoSelected(null);
|
||||||
setLassoPolygonPoints(null);
|
setLassoPolygonPoints(null);
|
||||||
setLassoMode((v) => !v);
|
setLassoMode((v) => !v);
|
||||||
|
const turningOn = !lassoMode;
|
||||||
|
const targetHint = osmEditMode
|
||||||
|
? "OSM edit mode is on — lasso will bulk-delete road sections, NOT assets."
|
||||||
|
: "";
|
||||||
setStatus(
|
setStatus(
|
||||||
lassoMode
|
!turningOn
|
||||||
? "Lasso off."
|
? "Lasso off."
|
||||||
: lassoShape === "polygon"
|
: (lassoShape === "polygon"
|
||||||
? "Lasso on: click to add polygon vertices, double-click to finish."
|
? "Lasso on: click to add polygon vertices, double-click to finish."
|
||||||
: "Lasso on: drag to select.",
|
: "Lasso on: drag to select.") +
|
||||||
|
(targetHint ? ` ${targetHint}` : ""),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
140
src/MapView.tsx
140
src/MapView.tsx
@@ -192,6 +192,8 @@ export function MapView({
|
|||||||
osmEditModeRef.current = osmEditMode;
|
osmEditModeRef.current = osmEditMode;
|
||||||
const onPickRoadRef = useRef(onPickRoad);
|
const onPickRoadRef = useRef(onPickRoad);
|
||||||
onPickRoadRef.current = onPickRoad;
|
onPickRoadRef.current = onPickRoad;
|
||||||
|
const onUpdateRoadRef = useRef(onUpdateRoad);
|
||||||
|
onUpdateRoadRef.current = onUpdateRoad;
|
||||||
const onRoadRightClickRef = useRef(onRoadRightClick);
|
const onRoadRightClickRef = useRef(onRoadRightClick);
|
||||||
onRoadRightClickRef.current = onRoadRightClick;
|
onRoadRightClickRef.current = onRoadRightClick;
|
||||||
const onCursorWorldRef = useRef(onCursorWorld);
|
const onCursorWorldRef = useRef(onCursorWorld);
|
||||||
@@ -285,11 +287,28 @@ export function MapView({
|
|||||||
const overlay = new MapboxOverlay({
|
const overlay = new MapboxOverlay({
|
||||||
layers: [],
|
layers: [],
|
||||||
interleaved: false,
|
interleaved: false,
|
||||||
onClick: (info: {
|
// Show a pointer when hovering a road in OSM edit mode so it's clear
|
||||||
object?: unknown;
|
// they're clickable. The default canvas cursor (set in the cursor
|
||||||
coordinate?: number[];
|
// effect below) wins when no pickable object is under the mouse.
|
||||||
layer?: { id: string };
|
getCursor: ({
|
||||||
|
isHovering,
|
||||||
|
isDragging,
|
||||||
|
}: {
|
||||||
|
isHovering: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (isDragging) return "grabbing";
|
||||||
|
if (isHovering && osmEditModeRef.current) return "pointer";
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
onClick: (
|
||||||
|
info: {
|
||||||
|
object?: unknown;
|
||||||
|
coordinate?: number[];
|
||||||
|
layer?: { id: string };
|
||||||
|
},
|
||||||
|
event?: { srcEvent?: { shiftKey?: boolean } },
|
||||||
|
) => {
|
||||||
if (drawRoadModeRef.current && info.coordinate) {
|
if (drawRoadModeRef.current && info.coordinate) {
|
||||||
const [lng, lat] = info.coordinate;
|
const [lng, lat] = info.coordinate;
|
||||||
onDrawRoadAddPointRef.current?.(lat, lng);
|
onDrawRoadAddPointRef.current?.(lat, lng);
|
||||||
@@ -302,12 +321,62 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
if (osmEditModeRef.current && info.layer?.id === "osm-roads") {
|
if (osmEditModeRef.current && info.layer?.id === "osm-roads") {
|
||||||
const f = info.object as
|
const f = info.object as
|
||||||
| { properties?: { id?: number } }
|
| {
|
||||||
|
properties?: { id?: number };
|
||||||
|
geometry?: {
|
||||||
|
type?: string;
|
||||||
|
coordinates?: Array<[number, number]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
const rid = f?.properties?.id;
|
const rid = f?.properties?.id;
|
||||||
if (typeof rid === "number") {
|
if (typeof rid !== "number") return;
|
||||||
onPickRoadRef.current?.(rid);
|
// Shift+click on a road inserts a vertex at the click location,
|
||||||
|
// snapped to the nearest edge. Mirrors the ghost-midpoint UX but
|
||||||
|
// works anywhere along the polyline — saves the user from zooming
|
||||||
|
// in to find the right midpoint.
|
||||||
|
const shift = event?.srcEvent?.shiftKey === true;
|
||||||
|
if (shift && info.coordinate && f?.geometry?.coordinates) {
|
||||||
|
const [clng, clat] = info.coordinate;
|
||||||
|
const coords = f.geometry.coordinates;
|
||||||
|
// Find nearest edge (project click onto each segment, clamped).
|
||||||
|
let bestI = -1;
|
||||||
|
let bestD2 = Infinity;
|
||||||
|
let bestPt: [number, number] = [clng, clat];
|
||||||
|
for (let i = 0; i < coords.length - 1; i++) {
|
||||||
|
const a = coords[i];
|
||||||
|
const b = coords[i + 1];
|
||||||
|
const dx = b[0] - a[0];
|
||||||
|
const dy = b[1] - a[1];
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
const t =
|
||||||
|
len2 > 0
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
((clng - a[0]) * dx + (clat - a[1]) * dy) / len2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
const px = a[0] + t * dx;
|
||||||
|
const py = a[1] + t * dy;
|
||||||
|
const d2 =
|
||||||
|
(clng - px) * (clng - px) + (clat - py) * (clat - py);
|
||||||
|
if (d2 < bestD2) {
|
||||||
|
bestD2 = d2;
|
||||||
|
bestI = i;
|
||||||
|
bestPt = [px, py];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestI >= 0) {
|
||||||
|
const next = coords.slice() as Array<[number, number]>;
|
||||||
|
next.splice(bestI + 1, 0, bestPt);
|
||||||
|
onUpdateRoadRef.current?.(rid, next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
onPickRoadRef.current?.(rid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (osmEditModeRef.current) {
|
if (osmEditModeRef.current) {
|
||||||
@@ -1526,6 +1595,54 @@ export function MapView({
|
|||||||
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
||||||
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
||||||
"high";
|
"high";
|
||||||
|
// The image uses object-fit:cover so the container's aspect is fixed.
|
||||||
|
// Bias the visible crop toward the side the asset is on so the user
|
||||||
|
// sees the relevant edge instead of the centre being cropped.
|
||||||
|
const sideKeyImg = (selectedAsset.side ?? "").toLowerCase();
|
||||||
|
img.style.objectPosition =
|
||||||
|
sideKeyImg === "left" || sideKeyImg === "lhs"
|
||||||
|
? "left center"
|
||||||
|
: sideKeyImg === "right" || sideKeyImg === "rhs"
|
||||||
|
? "right center"
|
||||||
|
: "center center";
|
||||||
|
img.style.cursor = "ew-resize";
|
||||||
|
img.title = "Drag horizontally to pan the cropped window";
|
||||||
|
// Click-and-drag updates object-position-x so the user can pan to any
|
||||||
|
// part of the source image — useful when the side-aware default still
|
||||||
|
// hides what they want to see. Pointer capture keeps the gesture
|
||||||
|
// confined to this element, so popup teardown removes the handlers
|
||||||
|
// automatically.
|
||||||
|
const sideDefaultPct =
|
||||||
|
sideKeyImg === "left" || sideKeyImg === "lhs"
|
||||||
|
? 0
|
||||||
|
: sideKeyImg === "right" || sideKeyImg === "rhs"
|
||||||
|
? 100
|
||||||
|
: 50;
|
||||||
|
let curPct = sideDefaultPct;
|
||||||
|
let dragX0: number | null = null;
|
||||||
|
let dragPos0 = sideDefaultPct;
|
||||||
|
img.addEventListener("pointerdown", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
dragX0 = ev.clientX;
|
||||||
|
dragPos0 = curPct;
|
||||||
|
img.setPointerCapture(ev.pointerId);
|
||||||
|
});
|
||||||
|
img.addEventListener("pointermove", (ev) => {
|
||||||
|
if (dragX0 === null) return;
|
||||||
|
const w = img.clientWidth || 1;
|
||||||
|
const dx = ev.clientX - dragX0;
|
||||||
|
const next = Math.max(0, Math.min(100, dragPos0 - (dx / w) * 100));
|
||||||
|
curPct = next;
|
||||||
|
img.style.objectPosition = `${next}% center`;
|
||||||
|
});
|
||||||
|
const endDrag = (ev: PointerEvent) => {
|
||||||
|
dragX0 = null;
|
||||||
|
if (img.hasPointerCapture(ev.pointerId)) {
|
||||||
|
img.releasePointerCapture(ev.pointerId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.addEventListener("pointerup", endDrag);
|
||||||
|
img.addEventListener("pointercancel", endDrag);
|
||||||
grid.appendChild(img);
|
grid.appendChild(img);
|
||||||
root.appendChild(grid);
|
root.appendChild(grid);
|
||||||
|
|
||||||
@@ -1626,6 +1743,15 @@ export function MapView({
|
|||||||
const id = selectedAsset.id;
|
const id = selectedAsset.id;
|
||||||
for (const v of verts) {
|
for (const v of verts) {
|
||||||
if (v.lat === null || v.lng === null) continue;
|
if (v.lat === null || v.lng === null) continue;
|
||||||
|
// Range assets without a real middle frame have a synthesized midpoint
|
||||||
|
// (start+end)/2 stored in lat/lng so the line still renders, but no
|
||||||
|
// image_path2. Hide the M pin in that case so users see only S and E.
|
||||||
|
if (
|
||||||
|
selectedAsset.asset_type === "range" &&
|
||||||
|
v.kind === "mid" &&
|
||||||
|
!selectedAsset.image_path2
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
const pin = document.createElement("div");
|
const pin = document.createElement("div");
|
||||||
pin.className = `vertex-pin v-${v.kind}`;
|
pin.className = `vertex-pin v-${v.kind}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user