From 2d743eb63597b6ad643272ff0e152c755d3c54ec Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Wed, 11 Mar 2026 14:20:52 +0100 Subject: [PATCH 1/8] Document SetTags variant args (PR #103) Keep SetTags(WayID, Vec, Vec<(String, String)>) with explicit fields. Add doc comment and inline comments (remove_keys, add_tags) so the two vecs are clearly defined. --- backend/src/edits.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/edits.rs b/backend/src/edits.rs index 618beed..26960ff 100644 --- a/backend/src/edits.rs +++ b/backend/src/edits.rs @@ -26,7 +26,12 @@ pub struct Edits { #[derive(Clone, Serialize)] pub enum UserCmd { - SetTags(WayID, Vec, Vec<(String, String)>), + /// Set tags on a way. First: tag keys to remove. Second: key-value pairs to add or set (applied after removals). + SetTags( + WayID, + Vec, // remove_keys + Vec<(String, String)>, // add_tags + ), MakeAllSidewalks(bool), ConnectAllCrossings(bool), AssumeTags(bool), From 1d3234e28c7282f7c870a8ad030242d4f09084e6 Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Wed, 11 Mar 2026 14:12:07 +0100 Subject: [PATCH 2/8] Refactor classify to use shared severance highway list (PR #103) - Add SEVERANCE_HIGHWAY_TYPES in lib.rs as single source of truth for motorway through tertiary_link (no service). - Add is_severance_highway(tags) and is_road_without_sidewalks_implicit(tags); the latter is severance types OR highway=service for RoadWithoutSidewalksImplicit. - Way::is_severance() now uses is_severance_highway(); classify uses is_road_without_sidewalks_implicit() so service stays explicit and the rest reuse the same definition. --- backend/src/classify.rs | 22 +++------------------ backend/src/lib.rs | 42 ++++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/backend/src/classify.rs b/backend/src/classify.rs index e0720f7..ced9005 100644 --- a/backend/src/classify.rs +++ b/backend/src/classify.rs @@ -83,25 +83,9 @@ impl Kind { return Self::RoadWithTags; } - // Only applied when there are no sidewalk tags above. Any of these highway types with - // explicit sidewalk tags (e.g. sidewalk:right=yes) are already RoadWithTags and stay in - // the sidewalk generator. - if tags.is_any( - "highway", - vec![ - "motorway", - "motorway_link", - "trunk", - "trunk_link", - "primary", - "primary_link", - "secondary", - "secondary_link", - "tertiary", - "tertiary_link", - "service", - ], - ) { + // Only applied when there are no sidewalk tags above. Severance highway types (see + // is_severance_highway) plus service; explicit sidewalk tags are already RoadWithTags. + if crate::is_road_without_sidewalks_implicit(tags) { return Self::RoadWithoutSidewalksImplicit; } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index fa2312e..a222b1c 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -28,6 +28,32 @@ use utils::{Mercator, Tags}; use wasm_bindgen::prelude::*; pub use crate::classify::Kind; + +/// Highway types treated as severance (major roads that cut through). Used for both +/// Way::is_severance() and RoadWithoutSidewalksImplicit; the latter also includes "service". +const SEVERANCE_HIGHWAY_TYPES: &[&str] = &[ + "motorway", + "motorway_link", + "trunk", + "trunk_link", + "primary", + "primary_link", + "secondary", + "secondary_link", + "tertiary", + "tertiary_link", +]; + +/// True if the way's highway type is in the severance list (used for assume-tags, etc.). +pub(crate) fn is_severance_highway(tags: &Tags) -> bool { + tags.is_any("highway", SEVERANCE_HIGHWAY_TYPES.to_vec()) +} + +/// True if the way should be classified as RoadWithoutSidewalksImplicit: severance types +/// or highway=service (no sidewalk tags). Service is excluded from is_severance. +pub(crate) fn is_road_without_sidewalks_implicit(tags: &Tags) -> bool { + is_severance_highway(tags) || tags.is("highway", "service") +} pub use crate::edits::{Edits, UserCmd}; #[wasm_bindgen] @@ -104,21 +130,7 @@ impl Node { impl Way { pub fn is_severance(&self) -> bool { - self.tags.is_any( - "highway", - vec![ - "motorway", - "motorway_link", - "trunk", - "trunk_link", - "primary", - "primary_link", - "secondary", - "secondary_link", - "tertiary", - "tertiary_link", - ], - ) + is_severance_highway(&self.tags) } /// For Kind::Other cases (often cycleways or paths), is the way usable for walking? From af1a42c627c22cb9776973cf198ab7c0f3af3667 Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Wed, 11 Mar 2026 14:49:00 +0100 Subject: [PATCH 3/8] Overrides: clean IndexedDB schema, drop migration and unused API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store "overrides" with keyPath "id"; single record id "default" (no region). - DB version 2; create store when oldVersion < 2 so existing v1 DBs don’t break (no data migration). - Remove legacy migration and deleteOverrides(); simplify file comment. --- web/src/common/localOverrides.ts | 55 +++++++++++------------- web/src/overwrites/OverwritesMode.svelte | 34 +++++++-------- 2 files changed, 41 insertions(+), 48 deletions(-) diff --git a/web/src/common/localOverrides.ts b/web/src/common/localOverrides.ts index 5a6a24d..8ebb3ed 100644 --- a/web/src/common/localOverrides.ts +++ b/web/src/common/localOverrides.ts @@ -1,9 +1,15 @@ +/** + * Manual overwrites (e.g. added crossing segments) stored in IndexedDB. + * We store a single overrides blob (one record). Boundary filtering is separate: + * filterSegmentsInBoundary() filters segments by the loaded map boundary (bbox). + */ import type { GeoJSON } from "geojson"; import { bbox } from "svelte-utils/map"; const DB_NAME = "speedwalk-overrides"; -const STORE_NAME = "regionOverrides"; -const GLOBAL_KEY = "global"; +const DB_VERSION = 2; +const STORE_NAME = "overrides"; +const OVERRIDES_RECORD_ID = "default"; export interface AddedCrossingSegment { id?: string; @@ -12,23 +18,27 @@ export interface AddedCrossingSegment { tags: Record; } -export interface RegionOverrides { +export interface ManualOverrides { version: number; addedCrossings: AddedCrossingSegment[]; } -const DEFAULT_OVERRIDES: RegionOverrides = { +const DEFAULT_OVERRIDES: ManualOverrides = { version: 1, addedCrossings: [], }; function openDb(): Promise { return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 1); + const req = indexedDB.open(DB_NAME, DB_VERSION); req.onerror = () => reject(req.error); req.onsuccess = () => resolve(req.result); - req.onupgradeneeded = () => { - req.result.createObjectStore(STORE_NAME, { keyPath: "regionKey" }); + req.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + // Create store for new DB (oldVersion 0) or when upgrading to v2 (e.g. from v1). No data migration. + if (event.oldVersion < 2) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } }; }); } @@ -46,18 +56,16 @@ function isSegment( ); } -export async function getOverrides(): Promise { +export async function getOverrides(): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const store = tx.objectStore(STORE_NAME); - const req = store.get(GLOBAL_KEY); + const req = store.get(OVERRIDES_RECORD_ID); req.onerror = () => reject(req.error); req.onsuccess = () => { + const raw = req.result as (ManualOverrides & { id?: string }) | undefined; db.close(); - const raw = req.result as - | (RegionOverrides & { regionKey?: string }) - | undefined; const list = raw?.addedCrossings ?? DEFAULT_OVERRIDES.addedCrossings; const addedCrossings = ( Array.isArray(list) ? list.filter(isSegment) : [] @@ -73,15 +81,14 @@ export async function getOverrides(): Promise { }); } -export async function saveOverrides(data: RegionOverrides): Promise { +export async function saveOverrides(data: ManualOverrides): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); const store = tx.objectStore(STORE_NAME); - // Plain object so IndexedDB structured-clone succeeds (no Svelte proxies) const record = JSON.parse( JSON.stringify({ - regionKey: GLOBAL_KEY, + id: OVERRIDES_RECORD_ID, version: data.version, addedCrossings: data.addedCrossings, }), @@ -95,8 +102,8 @@ export async function saveOverrides(data: RegionOverrides): Promise { }); } -/** Returns segments whose midpoint is inside the boundary's bbox. */ -export function filterSegmentsInRegion( +/** Returns segments whose midpoint is inside the boundary's bbox (e.g. loaded map area). */ +export function filterSegmentsInBoundary( segments: AddedCrossingSegment[], boundaryGeoJson: GeoJSON, ): AddedCrossingSegment[] { @@ -112,17 +119,3 @@ export function filterSegmentsInRegion( ); }); } - -export async function deleteOverrides(): Promise { - const db = await openDb(); - return new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - const req = store.delete(GLOBAL_KEY); - req.onerror = () => reject(req.error); - req.onsuccess = () => { - db.close(); - resolve(); - }; - }); -} diff --git a/web/src/overwrites/OverwritesMode.svelte b/web/src/overwrites/OverwritesMode.svelte index 1d4a43a..6f39f3a 100644 --- a/web/src/overwrites/OverwritesMode.svelte +++ b/web/src/overwrites/OverwritesMode.svelte @@ -2,8 +2,8 @@ import { getOverrides, saveOverrides, - filterSegmentsInRegion, - type RegionOverrides, + filterSegmentsInBoundary, + type ManualOverrides, type AddedCrossingSegment, } from "../common/localOverrides"; import { @@ -45,7 +45,7 @@ let pointA: { lng: number; lat: number } | null = $state(null); let pointB: { lng: number; lat: number } | null = $state(null); let loading = $state(""); - let overrides: RegionOverrides = $state({ version: 1, addedCrossings: [] }); + let overrides: ManualOverrides = $state({ version: 1, addedCrossings: [] }); let overwritesApplied = $state(true); let appliedCount = $state(0); let nodes: FeatureCollection = $state.raw({ @@ -71,18 +71,18 @@ getOverrides().then((data) => { overrides = data; const boundary = JSON.parse(b.getBoundary()); - const list = filterSegmentsInRegion(data.addedCrossings, boundary); + const list = filterSegmentsInBoundary(data.addedCrossings, boundary); if (overwritesApplied && list.length > 0 && appliedCount === 0) { applyAll(list); } }); }); - const inRegionSegments = $derived.by(() => { + const segmentsInLoadedArea = $derived.by(() => { if (!$backend) return []; try { const boundary = JSON.parse($backend.getBoundary()); - return filterSegmentsInRegion(overrides.addedCrossings, boundary); + return filterSegmentsInBoundary(overrides.addedCrossings, boundary); } catch { return []; } @@ -171,7 +171,7 @@ await unapplyAll(); overwritesApplied = false; } else { - await applyAll(inRegionSegments); + await applyAll(segmentsInLoadedArea); overwritesApplied = true; } } @@ -261,7 +261,7 @@ const id = segment.id; const list = overrides.addedCrossings.filter((s) => s.id !== id); const wasApplied = - inRegionSegments.findIndex((s) => s.id === id) < appliedCount; + segmentsInLoadedArea.findIndex((s) => s.id === id) < appliedCount; overrides = { ...overrides, addedCrossings: list }; await saveOverrides(overrides); if (wasApplied && $backend) { @@ -273,11 +273,11 @@ mutationCounter.update((n) => n + 1); } appliedCount = 0; - const stillInRegion = filterSegmentsInRegion( + const stillInLoadedArea = filterSegmentsInBoundary( list, JSON.parse($backend.getBoundary()), ); - for (const seg of stillInRegion) { + for (const seg of stillInLoadedArea) { $backend.editAddCrossingSegment( seg.start.lng, seg.start.lat, @@ -287,7 +287,7 @@ ); mutationCounter.update((n) => n + 1); } - appliedCount = stillInRegion.length; + appliedCount = stillInLoadedArea.length; } finally { loading = ""; } @@ -322,7 +322,7 @@ const file = input.files?.[0]; if (!file) return; const text = await file.text(); - const data = JSON.parse(text) as RegionOverrides & { regionKey?: string }; + const data = JSON.parse(text) as ManualOverrides & { regionKey?: string }; const toMerge = data.addedCrossings ?? []; overrides = { version: 1, @@ -332,8 +332,8 @@ input.value = ""; } - const appliedList = $derived(inRegionSegments.slice(0, appliedCount)); - const notAppliedList = $derived(inRegionSegments.slice(appliedCount)); + const appliedList = $derived(segmentsInLoadedArea.slice(0, appliedCount)); + const notAppliedList = $derived(segmentsInLoadedArea.slice(appliedCount)); const draftPointsGeoJSON = $derived.by(() => { const features: Array<{ @@ -438,7 +438,7 @@ {#snippet header()} - In your current region: {inRegionSegments.length} in storage, {appliedCount} + In loaded area: {segmentsInLoadedArea.length} in storage, {appliedCount} applied {/snippet} {#snippet body()} @@ -496,12 +496,12 @@ {/each} {/if} - {#if inRegionSegments.length === 0} + {#if segmentsInLoadedArea.length === 0}

{#if overrides.addedCrossings.length === 0} No manual crossings yet. {:else} - No overwrites in this region ({overrides.addedCrossings.length} total + No overwrites in loaded area ({overrides.addedCrossings.length} total in storage). {/if}

From bbe935c0854fdd255f78603d77ef336dddd54080 Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Wed, 11 Mar 2026 14:56:07 +0100 Subject: [PATCH 4/8] Overwrites: do not set marker when clicking Mapillary pins - Add mapillaryLayers.ts with stable layer IDs; use them in Mapillary.svelte and OverwritesMode. - Skip setting pointA/pointB when the click hits a Mapillary pin. - Only query layers that exist in the current style so it works when Mapillary is off. --- web/src/App.svelte | 1 + web/src/common/Mapillary.svelte | 4 ++++ web/src/common/mapillaryLayers.ts | 14 ++++++++++++++ web/src/overwrites/OverwritesMode.svelte | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 web/src/common/mapillaryLayers.ts diff --git a/web/src/App.svelte b/web/src/App.svelte index 4194fb5..87887e3 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -60,6 +60,7 @@ "speedwalk-", "overwrites-", "edit-polygon-", + "mapillary-", ].some((prefix) => l.id.startsWith(prefix)), ); let layers = nextStyle.layers.concat(customLayers); diff --git a/web/src/common/Mapillary.svelte b/web/src/common/Mapillary.svelte index 009ca22..d05c980 100644 --- a/web/src/common/Mapillary.svelte +++ b/web/src/common/Mapillary.svelte @@ -15,6 +15,7 @@ import logo from "../../assets/Mapillary_logo.svg"; import lineArc from "@turf/line-arc"; import { point } from "@turf/helpers"; + import { MAPILLARY_PIN_LAYER_IDS } from "./mapillaryLayers"; let show = $state(false); @@ -108,6 +109,7 @@ /> l.id)) + : new Set(); + const layersToQuery = MAPILLARY_PIN_LAYER_IDS_LIST.filter((id) => + existingIds.has(id), + ); + const mapillaryFeatures = + layersToQuery.length > 0 + ? $map.queryRenderedFeatures(e.point, { layers: layersToQuery }) + : []; + if (mapillaryFeatures.length > 0) { + return; + } + } let lng: number; let lat: number; if (e.lngLat) { From 17baa0f4fe3286fa78dc33818552b7590eba292e Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Thu, 12 Mar 2026 09:09:48 +0100 Subject: [PATCH 5/8] Overwrites: apply on import, store snapped geometry, surface errors - Apply overwrites immediately after import: when importing a file, segments in the current boundary are applied right away (no need to switch modes). - Store snapped geometry: new crossings use snapCrossingSegment() before apply; start/end are saved as snapped coords so re-apply and import snap correctly. - Backend: add snap_crossing_segment() and snapCrossingSegment WASM; reuse same RTree/snap logic as AddCrossingSegment, return snapped WGS84 coords. - Surface backend snap/apply errors in the UI via applyError state and a dismissible danger alert (e.g. "Couldn't find a line to snap to"). - Normalize imported segments (ids, valid start/end) and fix appliedCount when applying only newly imported segments. - Remove unused LineString/WayProps imports in OverwritesMode. --- backend/src/edits.rs | 34 ++++++++++++ backend/src/wasm.rs | 21 ++++++++ web/src/overwrites/OverwritesMode.svelte | 69 +++++++++++++++++++++--- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/backend/src/edits.rs b/backend/src/edits.rs index 26960ff..1806442 100644 --- a/backend/src/edits.rs +++ b/backend/src/edits.rs @@ -9,6 +9,40 @@ use utils::Tags; use crate::{Kind, Node, Speedwalk, Way}; +/// Snaps two WGS84 points to the nearest road/sidewalk; returns snapped points in WGS84. +/// Used by the UI to store snapped geometry so re-apply and import snap correctly. +pub fn snap_crossing_segment( + model: &Speedwalk, + start_wgs84: Point, + end_wgs84: Point, +) -> Result<(Point, Point)> { + let start_pt = model.mercator.to_mercator(&start_wgs84); + let end_pt = model.mercator.to_mercator(&end_wgs84); + let closest_line = RTree::bulk_load( + model + .derived_ways + .iter() + .filter(|(_, way)| way.kind.is_road() || way.kind == Kind::Sidewalk) + .map(|(id, way)| GeomWithData::new(way.linestring.clone(), *id)) + .collect(), + ); + let snap_to_line = |pt: Coord| -> Result { + let Some(obj) = closest_line.nearest_neighbor(&Point::from(pt)) else { + bail!("Couldn't find a line to snap to"); + }; + let snapped = match obj.geom().closest_point(&Point::from(pt)) { + Closest::Intersection(c) | Closest::SinglePoint(c) => c.into(), + Closest::Indeterminate => bail!("Couldn't snap point to line"), + }; + Ok(snapped) + }; + let snapped_start = snap_to_line(start_pt.into())?; + let snapped_end = snap_to_line(end_pt.into())?; + let start_out = model.mercator.pt_to_wgs84(snapped_start); + let end_out = model.mercator.pt_to_wgs84(snapped_end); + Ok((Point::from(start_out), Point::from(end_out))) +} + #[derive(Default)] pub struct Edits { pub user_commands: Vec, diff --git a/backend/src/wasm.rs b/backend/src/wasm.rs index 42d5aab..efb8095 100644 --- a/backend/src/wasm.rs +++ b/backend/src/wasm.rs @@ -350,6 +350,27 @@ impl Speedwalk { Ok(()) } + /// Snap two WGS84 points to the nearest road/sidewalk; returns snapped coords as JSON. + /// Does not apply any edit. Used so the UI can store snapped geometry for re-apply/import. + #[wasm_bindgen(js_name = snapCrossingSegment)] + pub fn snap_crossing_segment_wasm( + &self, + start_lng: f64, + start_lat: f64, + end_lng: f64, + end_lat: f64, + ) -> Result { + let start = Point::new(start_lng, start_lat); + let end = Point::new(end_lng, end_lat); + let (s_start, s_end) = + crate::edits::snap_crossing_segment(self, start, end).map_err(err_to_js)?; + let out = serde_json::json!({ + "start": { "lng": s_start.x(), "lat": s_start.y() }, + "end": { "lng": s_end.x(), "lat": s_end.y() } + }); + Ok(serde_wasm_bindgen::to_value(&out)?) + } + #[wasm_bindgen(js_name = editUndo)] pub fn edit_undo(&mut self) -> Result<(), JsValue> { let mut cmds = self.edits.take().unwrap().user_commands; diff --git a/web/src/overwrites/OverwritesMode.svelte b/web/src/overwrites/OverwritesMode.svelte index ca804af..87d3ee6 100644 --- a/web/src/overwrites/OverwritesMode.svelte +++ b/web/src/overwrites/OverwritesMode.svelte @@ -28,8 +28,8 @@ import FilterNetworkCard from "../common/FilterNetworkCard.svelte"; import LegendList from "../common/LegendList.svelte"; import { downloadGeneratedFile, Loading } from "svelte-utils"; - import type { FeatureCollection, LineString, Point } from "geojson"; - import { type WayProps, type NodeProps } from "../sidewalks"; + import type { FeatureCollection, Point } from "geojson"; + import { type NodeProps } from "../sidewalks"; import { roadLineWidth } from "../sidewalks"; import { MAPILLARY_PIN_LAYER_IDS_LIST } from "../common/mapillaryLayers"; @@ -46,6 +46,7 @@ let pointA: { lng: number; lat: number } | null = $state(null); let pointB: { lng: number; lat: number } | null = $state(null); let loading = $state(""); + let applyError = $state(""); let overrides: ManualOverrides = $state({ version: 1, addedCrossings: [] }); let overwritesApplied = $state(true); let appliedCount = $state(0); @@ -132,6 +133,7 @@ async function applyAll(segments: AddedCrossingSegment[]) { if (!$backend) return; + applyError = ""; loading = "Applying overwrites"; await refreshLoadingScreen(); try { @@ -146,6 +148,11 @@ mutationCounter.update((n) => n + 1); } appliedCount = segments.length; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + applyError = + msg || + "Failed to apply overwrite (e.g. could not snap to road or sidewalk)"; } finally { loading = ""; } @@ -237,18 +244,26 @@ loading = "Adding manual crossing"; await refreshLoadingScreen(); try { - $backend.editAddCrossingSegment( + const snapped = $backend.snapCrossingSegment( pointA.lng, pointA.lat, pointB.lng, pointB.lat, + ); + const start = snapped.start as { lng: number; lat: number }; + const end = snapped.end as { lng: number; lat: number }; + $backend.editAddCrossingSegment( + start.lng, + start.lat, + end.lng, + end.lat, tags, ); mutationCounter.update((n) => n + 1); const newEntry: AddedCrossingSegment = { id: crypto.randomUUID(), - start: { lat: pointA.lat, lng: pointA.lng }, - end: { lat: pointB.lat, lng: pointB.lng }, + start: { lat: start.lat, lng: start.lng }, + end: { lat: end.lat, lng: end.lng }, tags, }; overrides = { @@ -259,6 +274,10 @@ appliedCount++; pointA = null; pointB = null; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + applyError = msg || "Could not snap to road or sidewalk"; + return; } finally { loading = ""; } @@ -342,13 +361,36 @@ if (!file) return; const text = await file.text(); const data = JSON.parse(text) as ManualOverrides & { regionKey?: string }; - const toMerge = data.addedCrossings ?? []; + const raw = data.addedCrossings ?? []; + const toMerge = raw + .filter( + (s): s is AddedCrossingSegment => + s != null && + "start" in s && + "end" in s && + typeof (s as AddedCrossingSegment).start?.lat === "number" && + typeof (s as AddedCrossingSegment).end?.lat === "number", + ) + .map((seg) => (seg.id ? seg : { ...seg, id: crypto.randomUUID() })); overrides = { version: 1, addedCrossings: [...overrides.addedCrossings, ...toMerge], }; await saveOverrides(overrides); input.value = ""; + + if (!$backend || toMerge.length === 0) return; + if (!overwritesApplied) return; + applyError = ""; + try { + const boundary = JSON.parse($backend.getBoundary()); + const inBoundary = filterSegmentsInBoundary(toMerge, boundary); + if (inBoundary.length > 0) { + const prevApplied = appliedCount; + await applyAll(inBoundary); + appliedCount = prevApplied + inBoundary.length; + } + } catch (_) {} } const appliedList = $derived(segmentsInLoadedArea.slice(0, appliedCount)); @@ -443,6 +485,21 @@ {/if} + {#if applyError} + + {/if} + - - diff --git a/web/src/index.ts b/web/src/index.ts index 515a2b0..ac0b7e8 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -58,6 +58,12 @@ export let includeCrossingNoBulk = localStorageStore( false, ); +export type CrossingScopeBulk = "major" | "minor" | "all"; +export let crossingScopeBulk = localStorageStore( + "speedwalk-crossingScopeBulk", + "major", +); + // TODO Upstream several of these export function sum(list: number[]): number { return list.reduce((total, x) => total + x, 0); From 604e8b942f45d5181f63834562473221ffecb16c Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Sat, 14 Mar 2026 12:52:03 +0100 Subject: [PATCH 7/8] Fix manual crossing: snap to footways, handle WASM Map result, validate segments --- backend/src/edits.rs | 19 ++++++-- backend/src/lib.rs | 9 ++++ web/src/common/localOverrides.ts | 19 ++++---- web/src/overwrites/OverwritesMode.svelte | 58 ++++++++++++++++++++---- 4 files changed, 83 insertions(+), 22 deletions(-) diff --git a/backend/src/edits.rs b/backend/src/edits.rs index 1806442..e22cdbe 100644 --- a/backend/src/edits.rs +++ b/backend/src/edits.rs @@ -9,8 +9,8 @@ use utils::Tags; use crate::{Kind, Node, Speedwalk, Way}; -/// Snaps two WGS84 points to the nearest road/sidewalk; returns snapped points in WGS84. -/// Used by the UI to store snapped geometry so re-apply and import snap correctly. +/// Snaps two WGS84 points to the nearest road, sidewalk, or walkable path (e.g. footway); returns snapped points in WGS84. +/// Includes walkable "Other" ways (e.g. highway=footway) so manual crossings can connect e.g. a footway to a road. pub fn snap_crossing_segment( model: &Speedwalk, start_wgs84: Point, @@ -22,7 +22,7 @@ pub fn snap_crossing_segment( model .derived_ways .iter() - .filter(|(_, way)| way.kind.is_road() || way.kind == Kind::Sidewalk) + .filter(|(_, way)| way.is_snap_target_for_crossing()) .map(|(id, way)| GeomWithData::new(way.linestring.clone(), *id)) .collect(), ); @@ -38,6 +38,17 @@ pub fn snap_crossing_segment( }; let snapped_start = snap_to_line(start_pt.into())?; let snapped_end = snap_to_line(end_pt.into())?; + // Reject degenerate case: both points snapped to the same location (e.g. same road segment) + let dist_m = Euclidean.distance( + Point::from(snapped_start), + Point::from(snapped_end), + ); + if dist_m < 0.5 { + bail!( + "Both points snapped to the same location (distance {:.1}m). Try clicking on the two crossing ways you want to connect.", + dist_m + ); + } let start_out = model.mercator.pt_to_wgs84(snapped_start); let end_out = model.mercator.pt_to_wgs84(snapped_end); Ok((Point::from(start_out), Point::from(end_out))) @@ -181,7 +192,7 @@ impl Edits { model .derived_ways .iter() - .filter(|(_, way)| way.kind.is_road() || way.kind == Kind::Sidewalk) + .filter(|(_, way)| way.is_snap_target_for_crossing()) .map(|(id, way)| GeomWithData::new(way.linestring.clone(), *id)) .collect(), ); diff --git a/backend/src/lib.rs b/backend/src/lib.rs index a222b1c..c7a4503 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -145,6 +145,15 @@ impl Way { true } } + + /// True if this way is a valid snap target for crossing segment endpoints (roads, sidewalks, + /// and walkable Other e.g. footway/path). Must stay in sync with snap_crossing_segment and + /// AddCrossingSegment so snap and apply use the same candidate set. + pub fn is_snap_target_for_crossing(&self) -> bool { + self.kind.is_road() + || self.kind == Kind::Sidewalk + || (self.kind == Kind::Other && self.is_walkable_other()) + } } #[derive(Clone, Serialize)] diff --git a/web/src/common/localOverrides.ts b/web/src/common/localOverrides.ts index 8ebb3ed..ac192a8 100644 --- a/web/src/common/localOverrides.ts +++ b/web/src/common/localOverrides.ts @@ -43,16 +43,18 @@ function openDb(): Promise { }); } -/** True if the item is a segment (has start and end). Drops legacy single-point entries. */ -function isSegment( - x: AddedCrossingSegment | { lat?: number; lng?: number }, -): x is AddedCrossingSegment { +/** True if the segment has valid start/end with numeric lng/lat. Reused for DB reads, imports, and runtime guards. */ +export function isValidSegment(x: unknown): x is AddedCrossingSegment { + const seg = x as AddedCrossingSegment; return ( x != null && + typeof x === "object" && "start" in x && "end" in x && - typeof (x as AddedCrossingSegment).start?.lat === "number" && - typeof (x as AddedCrossingSegment).end?.lat === "number" + typeof seg.start?.lat === "number" && + typeof seg.start?.lng === "number" && + typeof seg.end?.lat === "number" && + typeof seg.end?.lng === "number" ); } @@ -68,7 +70,7 @@ export async function getOverrides(): Promise { db.close(); const list = raw?.addedCrossings ?? DEFAULT_OVERRIDES.addedCrossings; const addedCrossings = ( - Array.isArray(list) ? list.filter(isSegment) : [] + Array.isArray(list) ? list.filter(isValidSegment) : [] ).map((seg) => (seg.id ? seg : { ...seg, id: crypto.randomUUID() })); const data = raw ? { @@ -102,13 +104,14 @@ export async function saveOverrides(data: ManualOverrides): Promise { }); } -/** Returns segments whose midpoint is inside the boundary's bbox (e.g. loaded map area). */ +/** Returns segments whose midpoint is inside the boundary's bbox (e.g. loaded map area). Skips invalid segments. */ export function filterSegmentsInBoundary( segments: AddedCrossingSegment[], boundaryGeoJson: GeoJSON, ): AddedCrossingSegment[] { const [minLng, minLat, maxLng, maxLat] = bbox(boundaryGeoJson); return segments.filter((seg) => { + if (!isValidSegment(seg)) return false; const midLng = (seg.start.lng + seg.end.lng) / 2; const midLat = (seg.start.lat + seg.end.lat) / 2; return ( diff --git a/web/src/overwrites/OverwritesMode.svelte b/web/src/overwrites/OverwritesMode.svelte index 87d3ee6..676b638 100644 --- a/web/src/overwrites/OverwritesMode.svelte +++ b/web/src/overwrites/OverwritesMode.svelte @@ -3,6 +3,7 @@ getOverrides, saveOverrides, filterSegmentsInBoundary, + isValidSegment, type ManualOverrides, type AddedCrossingSegment, } from "../common/localOverrides"; @@ -138,6 +139,7 @@ await refreshLoadingScreen(); try { for (const seg of segments) { + if (!isValidSegment(seg)) continue; $backend.editAddCrossingSegment( seg.start.lng, seg.start.lat, @@ -238,20 +240,60 @@ } } + /** Backend may return a plain object or a Map (from WASM/serde). Normalize to { start: { lng, lat }, end: { lng, lat } }. */ + function normalizeSnappedResult(snapped: unknown): { + start: { lng: number; lat: number }; + end: { lng: number; lat: number }; + } | null { + if (snapped == null || typeof snapped !== "object") return null; + const get = (obj: unknown, key: string): unknown => + obj instanceof Map ? obj.get(key) : (obj as Record)?.[key]; + const getNum = (o: unknown, key: string): number | undefined => { + const v = get(o, key); + return typeof v === "number" ? v : undefined; + }; + const startRaw = get(snapped, "start"); + const endRaw = get(snapped, "end"); + if (startRaw == null || endRaw == null) return null; + const startLng = getNum(startRaw, "lng"); + const startLat = getNum(startRaw, "lat"); + const endLng = getNum(endRaw, "lng"); + const endLat = getNum(endRaw, "lat"); + if ( + startLng == null || + startLat == null || + endLng == null || + endLat == null + ) + return null; + return { + start: { lng: startLng, lat: startLat }, + end: { lng: endLng, lat: endLat }, + }; + } + async function addCrossingSegmentFromDraft() { if (!pointA || !pointB || !$backend) return; const tags = { ...crossingWayTags }; loading = "Adding manual crossing"; await refreshLoadingScreen(); try { + const a = { lng: pointA.lng, lat: pointA.lat }; + const b = { lng: pointB.lng, lat: pointB.lat }; + console.debug("[Overwrites] Adding manual crossing: pointA =", a, "pointB =", b); const snapped = $backend.snapCrossingSegment( pointA.lng, pointA.lat, pointB.lng, pointB.lat, ); - const start = snapped.start as { lng: number; lat: number }; - const end = snapped.end as { lng: number; lat: number }; + const normalized = normalizeSnappedResult(snapped); + if (normalized == null) { + console.error("[Overwrites] snapCrossingSegment returned invalid value:", snapped); + applyError = "Snap failed: no result from backend"; + return; + } + const { start, end } = normalized; $backend.editAddCrossingSegment( start.lng, start.lat, @@ -276,6 +318,7 @@ pointB = null; } catch (e) { const msg = e instanceof Error ? e.message : String(e); + console.error("[Overwrites] addCrossingSegmentFromDraft error:", e); applyError = msg || "Could not snap to road or sidewalk"; return; } finally { @@ -316,6 +359,7 @@ JSON.parse($backend.getBoundary()), ); for (const seg of stillInLoadedArea) { + if (!isValidSegment(seg)) continue; $backend.editAddCrossingSegment( seg.start.lng, seg.start.lat, @@ -333,6 +377,7 @@ } function zoomToSegment(seg: AddedCrossingSegment) { + if (!isValidSegment(seg)) return; const lngs = [seg.start.lng, seg.end.lng]; const lats = [seg.start.lat, seg.end.lat]; const pad = 0.0001; @@ -363,14 +408,7 @@ const data = JSON.parse(text) as ManualOverrides & { regionKey?: string }; const raw = data.addedCrossings ?? []; const toMerge = raw - .filter( - (s): s is AddedCrossingSegment => - s != null && - "start" in s && - "end" in s && - typeof (s as AddedCrossingSegment).start?.lat === "number" && - typeof (s as AddedCrossingSegment).end?.lat === "number", - ) + .filter(isValidSegment) .map((seg) => (seg.id ? seg : { ...seg, id: crypto.randomUUID() })); overrides = { version: 1, From e0ce9883e1140c946075d7bc00f47c7607196c03 Mon Sep 17 00:00:00 2001 From: "Tobias (FMC)" Date: Sat, 14 Mar 2026 13:12:50 +0100 Subject: [PATCH 8/8] Manual crossing: Rework interaction Use marker, add bbox guard, add buttons --- web/src/overwrites/OverwritesMode.svelte | 233 ++++++++++++++--------- 1 file changed, 142 insertions(+), 91 deletions(-) diff --git a/web/src/overwrites/OverwritesMode.svelte b/web/src/overwrites/OverwritesMode.svelte index 676b638..325faf3 100644 --- a/web/src/overwrites/OverwritesMode.svelte +++ b/web/src/overwrites/OverwritesMode.svelte @@ -21,6 +21,7 @@ CircleLayer, MapEvents, Control, + Marker, } from "svelte-maplibre"; import type { MapMouseEvent } from "maplibre-gl"; import { SplitComponent } from "svelte-utils/top_bar_layout"; @@ -186,7 +187,24 @@ } } + /** True when any current draft point is outside the viewport (next click acts as first click). */ + function anyDraftPointOutsideViewport(): boolean { + if (!$map || (!pointA && !pointB)) return false; + const b = $map.getBounds(); + if (pointA && !b.contains([pointA.lng, pointA.lat])) return true; + if (pointB && !b.contains([pointB.lng, pointB.lat])) return true; + return false; + } + function onMapClick(e: MapMouseEvent) { + // Ignore clicks on draft markers (they are draggable; map click would otherwise move points). + if ( + (e.originalEvent?.target as Element)?.closest?.( + ".overwrites-draft-marker", + ) + ) { + return; + } // Do not set overwrite marker when clicking a Mapillary pin (only when clicking the map). // Mapillary layers are conditional; only query layers that exist in the current style. if ($map && e.point) { @@ -218,6 +236,12 @@ return; } const pt = { lng, lat }; + // If any draft point is outside the viewport, start fresh (e.g. one accidental click then zoom away). + if ((pointA || pointB) && anyDraftPointOutsideViewport()) { + pointA = pt; + pointB = null; + return; + } if (pointA === null) { pointA = pt; } else if (pointB === null) { @@ -247,7 +271,9 @@ } | null { if (snapped == null || typeof snapped !== "object") return null; const get = (obj: unknown, key: string): unknown => - obj instanceof Map ? obj.get(key) : (obj as Record)?.[key]; + obj instanceof Map + ? obj.get(key) + : (obj as Record)?.[key]; const getNum = (o: unknown, key: string): number | undefined => { const v = get(o, key); return typeof v === "number" ? v : undefined; @@ -280,7 +306,12 @@ try { const a = { lng: pointA.lng, lat: pointA.lat }; const b = { lng: pointB.lng, lat: pointB.lat }; - console.debug("[Overwrites] Adding manual crossing: pointA =", a, "pointB =", b); + console.debug( + "[Overwrites] Adding manual crossing: pointA =", + a, + "pointB =", + b, + ); const snapped = $backend.snapCrossingSegment( pointA.lng, pointA.lat, @@ -289,7 +320,10 @@ ); const normalized = normalizeSnappedResult(snapped); if (normalized == null) { - console.error("[Overwrites] snapCrossingSegment returned invalid value:", snapped); + console.error( + "[Overwrites] snapCrossingSegment returned invalid value:", + snapped, + ); applyError = "Snap failed: no result from backend"; return; } @@ -341,24 +375,26 @@ if (!$backend) return; const id = segment.id; const list = overrides.addedCrossings.filter((s) => s.id !== id); - const wasApplied = - segmentsInLoadedArea.findIndex((s) => s.id === id) < appliedCount; + const appliedOrder = segmentsInLoadedArea; + const deletedIndex = appliedOrder.findIndex((s) => s.id === id); + const wasApplied = deletedIndex >= 0 && deletedIndex < appliedCount; overrides = { ...overrides, addedCrossings: list }; await saveOverrides(overrides); if (wasApplied && $backend) { loading = "Removing crossing"; await refreshLoadingScreen(); try { - for (let i = 0; i < appliedCount; i++) { + // Undo only until we've removed the command for this segment (backend stack order + // matches appliedOrder). Each undo replays the whole stack, so we do the minimum + // number of undos to avoid repeated ConnectAllCrossings etc. + const undosNeeded = appliedCount - deletedIndex; + for (let i = 0; i < undosNeeded; i++) { $backend.editUndo(); mutationCounter.update((n) => n + 1); } - appliedCount = 0; - const stillInLoadedArea = filterSegmentsInBoundary( - list, - JSON.parse($backend.getBoundary()), - ); - for (const seg of stillInLoadedArea) { + // Re-apply segments that were after the deleted one (we popped them when we undid). + const toReapply = appliedOrder.slice(deletedIndex + 1, appliedCount); + for (const seg of toReapply) { if (!isValidSegment(seg)) continue; $backend.editAddCrossingSegment( seg.start.lng, @@ -369,7 +405,7 @@ ); mutationCounter.update((n) => n + 1); } - appliedCount = stillInLoadedArea.length; + appliedCount = deletedIndex + toReapply.length; } finally { loading = ""; } @@ -434,29 +470,6 @@ const appliedList = $derived(segmentsInLoadedArea.slice(0, appliedCount)); const notAppliedList = $derived(segmentsInLoadedArea.slice(appliedCount)); - const draftPointsGeoJSON = $derived.by(() => { - const features: Array<{ - type: "Feature"; - geometry: { type: "Point"; coordinates: [number, number] }; - properties: { dot: string }; - }> = []; - if (pointA) { - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: [pointA.lng, pointA.lat] }, - properties: { dot: "red" }, - }); - } - if (pointB) { - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: [pointB.lng, pointB.lat] }, - properties: { dot: "blue" }, - }); - } - return { type: "FeatureCollection" as const, features }; - }); - const draftLineGeoJSON = $derived.by(() => { const a = pointA; const b = pointB; @@ -498,17 +511,43 @@ title="Manual overwrites" lead="Modify the network by manually removing geometries and adding junctions. Changes are stored in your browser." > -

+

Add crossing: - First click = red (left), second = blue (right). Click again to move either - point. Press + Place two points on the map (first = left/red, second = right/blue). + Drag markers to adjust, or click the map to set or move them. If both + points are off the map, the next click starts a new draft. Use + Add crossing + or press a - to add; both points are snapped to the nearest road or sidewalk. + to snap to the network and save. Use + Reset draft + to clear both points.

-

+

The new crossing is a routable segment between the two snapped points on the network.

+
+ + +
@@ -538,24 +577,23 @@ {/if} - - {#snippet header()} In loaded area: {segmentsInLoadedArea.length} in storage, {appliedCount} applied {/snippet} {#snippet body()} + {#if notAppliedList.length > 0}
Not applied
    @@ -688,26 +726,33 @@ /> {/if} - {#if pointA || pointB} - - - + {#if pointA} + + {#snippet children()} +
    + {/snippet} +
    + {/if} + {#if pointB} + + {#snippet children()} +
    + {/snippet} +
    {/if} @@ -718,23 +763,29 @@ {/snippet} - - {#if pointA && pointB} -
    - Red = left, blue = right. Click again to move left or right point. Press - a - - to add crossing. -
    - {:else if pointA} -
    - First point set (red, left). Click for second point (blue, right). -
    - {/if} {/snippet} + +