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/edits.rs b/backend/src/edits.rs index 618beed..e22cdbe 100644 --- a/backend/src/edits.rs +++ b/backend/src/edits.rs @@ -9,6 +9,51 @@ use utils::Tags; use crate::{Kind, Node, Speedwalk, Way}; +/// 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, + 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.is_snap_target_for_crossing()) + .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())?; + // 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))) +} + #[derive(Default)] pub struct Edits { pub user_commands: Vec, @@ -26,7 +71,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), @@ -142,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 fa2312e..c7a4503 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? @@ -133,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/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/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 @@ /> ; } -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" }); + } }; }); } -/** 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" ); } -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) : [] + Array.isArray(list) ? list.filter(isValidSegment) : [] ).map((seg) => (seg.id ? seg : { ...seg, id: crypto.randomUUID() })); const data = raw ? { @@ -73,15 +83,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,13 +104,14 @@ 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). 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 ( @@ -112,17 +122,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/common/mapillaryLayers.ts b/web/src/common/mapillaryLayers.ts new file mode 100644 index 0000000..89b1ab2 --- /dev/null +++ b/web/src/common/mapillaryLayers.ts @@ -0,0 +1,14 @@ +/** + * Stable layer IDs for Mapillary pin layers (image points). Used so other modes + * (e.g. Overwrites) can detect clicks on Mapillary pins and avoid conflicting behavior. + */ +export const MAPILLARY_PIN_LAYER_IDS = { + symbol: "mapillary-image-symbol", + circleInteractive: "mapillary-image-circle-interactive", + circle: "mapillary-image-circle", +} as const; + +/** All pin layer IDs as array, for queryRenderedFeatures(layers: [...]). */ +export const MAPILLARY_PIN_LAYER_IDS_LIST = Object.values( + MAPILLARY_PIN_LAYER_IDS, +); diff --git a/web/src/generator/GeneratorBulkOperations.svelte b/web/src/generator/GeneratorBulkOperations.svelte index 6aeea7a..a97e4dc 100644 --- a/web/src/generator/GeneratorBulkOperations.svelte +++ b/web/src/generator/GeneratorBulkOperations.svelte @@ -6,6 +6,8 @@ refreshLoadingScreen, onlyMajorRoadsBulk, includeCrossingNoBulk, + crossingScopeBulk, + type CrossingScopeBulk, } from "../"; const defaultCrossingOptions = { @@ -18,47 +20,29 @@ max_distance: 40, }; + const crossingScopeOptions: { value: CrossingScopeBulk; label: string }[] = [ + { value: "major", label: "Major roads only" }, + { value: "minor", label: "Major + minor, excl. service/track" }, + { + value: "all", + label: "All roads (excl. cycleways, footways, motorways, roundabouts)", + }, + ]; + let loading = $state(""); let driveOnLeft = $state(true); - async function generateCrossingsMajor() { - loading = "Generating crossings on major roads"; - await refreshLoadingScreen(); - try { - $backend!.editGenerateMissingCrossings({ - ...defaultCrossingOptions, - only_major_roads: true, - }); - $mutationCounter++; - } catch (err) { - window.alert(`Error: ${err}`); - } finally { - loading = ""; - } - } - - async function generateCrossingsMinor() { - loading = "Generating crossings on minor roads"; - await refreshLoadingScreen(); - try { - $backend!.editGenerateMissingCrossings({ - ...defaultCrossingOptions, - only_major_roads: false, - ignore_utility_roads: true, - }); - $mutationCounter++; - } catch (err) { - window.alert(`Error: ${err}`); - } finally { - loading = ""; - } - } - - async function generateCrossingsAll() { + async function generateCrossings() { + const scope = $crossingScopeBulk; + const options = { + ...defaultCrossingOptions, + only_major_roads: scope === "major", + ignore_utility_roads: scope !== "all", + }; loading = "Generating missing crossings"; await refreshLoadingScreen(); try { - $backend!.editGenerateMissingCrossings(defaultCrossingOptions); + $backend!.editGenerateMissingCrossings(options); $mutationCounter++; } catch (err) { window.alert(`Error: ${err}`); @@ -110,22 +94,31 @@
-
Generate crossings
+
+ + Generate crossings + +
- - -
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); diff --git a/web/src/overwrites/OverwritesMode.svelte b/web/src/overwrites/OverwritesMode.svelte index 1d4a43a..325faf3 100644 --- a/web/src/overwrites/OverwritesMode.svelte +++ b/web/src/overwrites/OverwritesMode.svelte @@ -2,8 +2,9 @@ import { getOverrides, saveOverrides, - filterSegmentsInRegion, - type RegionOverrides, + filterSegmentsInBoundary, + isValidSegment, + type ManualOverrides, type AddedCrossingSegment, } from "../common/localOverrides"; import { @@ -20,6 +21,7 @@ CircleLayer, MapEvents, Control, + Marker, } from "svelte-maplibre"; import type { MapMouseEvent } from "maplibre-gl"; import { SplitComponent } from "svelte-utils/top_bar_layout"; @@ -28,9 +30,10 @@ 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"; const overwritesLegendItems = [ { label: "Base data", color: "black", swatchClass: "rectangle" as const }, @@ -45,7 +48,8 @@ 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 applyError = $state(""); + let overrides: ManualOverrides = $state({ version: 1, addedCrossings: [] }); let overwritesApplied = $state(true); let appliedCount = $state(0); let nodes: FeatureCollection = $state.raw({ @@ -71,18 +75,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 []; } @@ -131,10 +135,12 @@ async function applyAll(segments: AddedCrossingSegment[]) { if (!$backend) return; + applyError = ""; loading = "Applying overwrites"; await refreshLoadingScreen(); try { for (const seg of segments) { + if (!isValidSegment(seg)) continue; $backend.editAddCrossingSegment( seg.start.lng, seg.start.lat, @@ -145,6 +151,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 = ""; } @@ -171,12 +182,47 @@ await unapplyAll(); overwritesApplied = false; } else { - await applyAll(inRegionSegments); + await applyAll(segmentsInLoadedArea); overwritesApplied = true; } } + /** 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) { + const style = $map.getStyle(); + const existingIds = style?.layers + ? new Set(style.layers.map((l) => 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) { @@ -190,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) { @@ -212,24 +264,82 @@ } } + /** 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 { - $backend.editAddCrossingSegment( + 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 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, + 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 = { @@ -240,6 +350,11 @@ appliedCount++; pointA = null; 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 { loading = ""; } @@ -260,24 +375,27 @@ if (!$backend) return; const id = segment.id; const list = overrides.addedCrossings.filter((s) => s.id !== id); - const wasApplied = - inRegionSegments.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 stillInRegion = filterSegmentsInRegion( - list, - JSON.parse($backend.getBoundary()), - ); - for (const seg of stillInRegion) { + // 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, seg.start.lat, @@ -287,7 +405,7 @@ ); mutationCounter.update((n) => n + 1); } - appliedCount = stillInRegion.length; + appliedCount = deletedIndex + toReapply.length; } finally { loading = ""; } @@ -295,6 +413,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; @@ -322,41 +441,34 @@ const file = input.files?.[0]; if (!file) return; const text = await file.text(); - const data = JSON.parse(text) as RegionOverrides & { regionKey?: string }; - const toMerge = data.addedCrossings ?? []; + const data = JSON.parse(text) as ManualOverrides & { regionKey?: string }; + const raw = data.addedCrossings ?? []; + const toMerge = raw + .filter(isValidSegment) + .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(inRegionSegments.slice(0, appliedCount)); - const notAppliedList = $derived(inRegionSegments.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 appliedList = $derived(segmentsInLoadedArea.slice(0, appliedCount)); + const notAppliedList = $derived(segmentsInLoadedArea.slice(appliedCount)); const draftLineGeoJSON = $derived.by(() => { const a = pointA; @@ -399,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.

+
+ + +
@@ -424,24 +562,38 @@ {/if} - + {#if applyError} + + {/if} {#snippet header()} - In your current region: {inRegionSegments.length} in storage, {appliedCount} + In loaded area: {segmentsInLoadedArea.length} in storage, {appliedCount} applied {/snippet} {#snippet body()} + {#if notAppliedList.length > 0}
Not applied
    @@ -496,12 +648,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}

@@ -574,26 +726,33 @@ /> {/if} - {#if pointA || pointB} - - - + {#if pointA} + + {#snippet children()} +
+ {/snippet} +
+ {/if} + {#if pointB} + + {#snippet children()} +
+ {/snippet} +
{/if} @@ -604,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} + +