From 58561da6f6a957f628d8b4441729c8dbe6f7b59b Mon Sep 17 00:00:00 2001 From: Dustin Carlino Date: Mon, 18 Dec 2023 11:42:56 +0900 Subject: [PATCH] Wire up the route-snapper --- backend/Cargo.lock | 20 ++ backend/Cargo.toml | 2 + backend/src/lib.rs | 29 +++ backend/src/mercator.rs | 2 + web/package-lock.json | 6 + web/package.json | 1 + web/src/App.svelte | 64 ++++- web/src/MapLoader.svelte | 2 + web/src/NeighbourhoodLayer.svelte | 15 ++ web/src/common/RouteSnapperLayer.svelte | 49 ++++ web/src/common/index.ts | 21 +- web/src/common/route_tool.ts | 307 ++++++++++++++++++++++++ web/src/common/stores.ts | 11 + web/vite.config.ts | 2 +- 14 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 web/src/NeighbourhoodLayer.svelte create mode 100644 web/src/common/RouteSnapperLayer.svelte create mode 100644 web/src/common/route_tool.ts create mode 100644 web/src/common/stores.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 93d68b4..f6ea0a4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -70,12 +70,14 @@ name = "backend" version = "0.1.0" dependencies = [ "anyhow", + "bincode", "console_error_panic_hook", "console_log", "geo", "geojson", "log", "osm-reader", + "route-snapper-graph", "rstar", "serde", "serde-wasm-bindgen", @@ -84,6 +86,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -607,6 +618,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbf4a6aa5f6d6888f39e980649f3ad6b666acdce1d78e95b8a2cb076e687ae30" +[[package]] +name = "route-snapper-graph" +version = "0.1.0" +source = "git+https://github.com/dabreegster/route_snapper?branch=no_osm2streets#935e58824f6c73385cd425de510fb0542a748860" +dependencies = [ + "geo", + "serde", +] + [[package]] name = "roxmltree" version = "0.19.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7c3f102..71ca179 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -14,9 +14,11 @@ geo = "0.27.0" geojson = { git = "https://github.com/georust/geojson", features = ["geo-types"] } log = "0.4.20" osm-reader = { git = "https://github.com/a-b-street/osm-reader" } +route-snapper-graph = { git = "https://github.com/dabreegster/route_snapper", branch = "no_osm2streets" } rstar = { version = "0.11.0" } serde = "1.0.188" serde_json = "1.0.105" serde-wasm-bindgen = "0.6.0" wasm-bindgen = "0.2.87" web-sys = { version = "0.3.64", features = ["console"] } +bincode = "1.3.3" diff --git a/backend/src/lib.rs b/backend/src/lib.rs index bcd2fcc..dcaa627 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -85,6 +85,35 @@ impl MapModel { Ok(out) } + #[wasm_bindgen(js_name = toRouteSnapper)] + pub fn to_route_snapper(&self) -> Vec { + use route_snapper_graph::{Edge, NodeID, RouteSnapperMap}; + + let mut nodes = Vec::new(); + for i in &self.intersections { + nodes.push(self.mercator.to_wgs84(i.point.into())); + } + + let mut edges = Vec::new(); + for r in &self.roads { + let mut linestring = r.linestring.clone(); + linestring.map_coords_in_place(|c| self.mercator.to_wgs84(c)); + + edges.push(Edge { + node1: NodeID(r.src_i.0 as u32), + node2: NodeID(r.dst_i.0 as u32), + geometry: linestring, + // Isn't serialized, doesn't matter + length_meters: 0.0, + name: r.tags.get("name").cloned(), + }); + } + + let graph = RouteSnapperMap { nodes, edges }; + let bytes = bincode::serialize(&graph).unwrap(); + bytes + } + fn find_edge(&self, i1: IntersectionID, i2: IntersectionID) -> &Road { // TODO Store lookup table for r in &self.intersections[i1.0].roads { diff --git a/backend/src/mercator.rs b/backend/src/mercator.rs index 26c0e67..2461e71 100644 --- a/backend/src/mercator.rs +++ b/backend/src/mercator.rs @@ -45,4 +45,6 @@ impl Mercator { + (self.wgs84_bounds.height() * (self.height - pt.y) / self.height); Coord { x, y } } + + // TODO Take anything that can do mapcoords } diff --git a/web/package-lock.json b/web/package-lock.json index f1ae35f..2fdd727 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@mapbox/mapbox-gl-draw": "^1.4.3", "@turf/bbox": "^6.5.0", "@types/geojson": "^7946.0.13", + "route-snapper": "0.2.5-alpha", "svelte-maplibre": "^0.7.3" }, "devDependencies": { @@ -2340,6 +2341,11 @@ "fsevents": "~2.3.2" } }, + "node_modules/route-snapper": { + "version": "0.2.5-alpha", + "resolved": "https://registry.npmjs.org/route-snapper/-/route-snapper-0.2.5-alpha.tgz", + "integrity": "sha512-ya74yGc3GOHEYxss7c6W94/nAwlCvRod8nKifqlSyXchwbuLmiCHWaAvw80dXT4jm3oC6UXWb1cwc2Wi30dizw==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/web/package.json b/web/package.json index e5fb12e..9141dad 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,7 @@ "@mapbox/mapbox-gl-draw": "^1.4.3", "@turf/bbox": "^6.5.0", "@types/geojson": "^7946.0.13", + "route-snapper": "0.2.5-alpha", "svelte-maplibre": "^0.7.3" } } diff --git a/web/src/App.svelte b/web/src/App.svelte index 51aa770..9086ede 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,13 +1,33 @@ @@ -34,6 +81,15 @@ {/if}
+ + {#if mode.mode == "network"} + + {:else if mode.mode == "set-boundary"} +

Draw the boundary...

+ {:else if mode.mode == "neighbourhood"} + +

Analyze and edit now

+ {/if}
{#if model} - + {#if mode.mode == "network"} + + {:else if mode.mode == "set-boundary"} + + {:else if mode.mode == "neighbourhood"} + + {/if} {/if}
diff --git a/web/src/MapLoader.svelte b/web/src/MapLoader.svelte index 6afb887..7d22261 100644 --- a/web/src/MapLoader.svelte +++ b/web/src/MapLoader.svelte @@ -1,6 +1,7 @@ + + + + diff --git a/web/src/common/RouteSnapperLayer.svelte b/web/src/common/RouteSnapperLayer.svelte new file mode 100644 index 0000000..7d264d7 --- /dev/null +++ b/web/src/common/RouteSnapperLayer.svelte @@ -0,0 +1,49 @@ + + + + + + + diff --git a/web/src/common/index.ts b/web/src/common/index.ts index 1656d11..ee482c8 100644 --- a/web/src/common/index.ts +++ b/web/src/common/index.ts @@ -1,4 +1,7 @@ -import type { DataDrivenPropertyValueSpecification } from "maplibre-gl"; +import type { + DataDrivenPropertyValueSpecification, + ExpressionSpecification, +} from "maplibre-gl"; export { default as Layout } from "./Layout.svelte"; export { default as Legend } from "./Legend.svelte"; @@ -6,6 +9,22 @@ export { default as Loading } from "./Loading.svelte"; export { default as OverpassSelector } from "./OverpassSelector.svelte"; export { default as PropertiesTable } from "./PropertiesTable.svelte"; +export const isPolygon: ExpressionSpecification = [ + "==", + ["geometry-type"], + "Polygon", +]; +export const isLine: ExpressionSpecification = [ + "==", + ["geometry-type"], + "LineString", +]; +export const isPoint: ExpressionSpecification = [ + "==", + ["geometry-type"], + "Point", +]; + export function constructMatchExpression( getter: any[], map: { [name: string]: OutputType }, diff --git a/web/src/common/route_tool.ts b/web/src/common/route_tool.ts new file mode 100644 index 0000000..18080d8 --- /dev/null +++ b/web/src/common/route_tool.ts @@ -0,0 +1,307 @@ +import type { Feature, LineString, Polygon, Position } from "geojson"; +import type { Map, MapMouseEvent } from "maplibre-gl"; +import { JsRouteSnapper } from "route-snapper"; +import { routeToolGj, snapMode, undoLength } from "./stores"; + +const snapDistancePixels = 30; + +export class RouteTool { + map: Map; + inner: JsRouteSnapper; + active: boolean; + eventListenersSuccess: ((f: Feature) => void)[]; + eventListenersUpdated: ((f: Feature) => void)[]; + eventListenersFailure: (() => void)[]; + + constructor(map: Map, graphBytes: Uint8Array) { + this.map = map; + console.time("Deserialize and setup JsRouteSnapper"); + this.inner = new JsRouteSnapper(graphBytes); + console.timeEnd("Deserialize and setup JsRouteSnapper"); + this.active = false; + this.eventListenersSuccess = []; + this.eventListenersUpdated = []; + this.eventListenersFailure = []; + + this.map.on("mousemove", this.onMouseMove); + this.map.on("click", this.onClick); + this.map.on("dblclick", this.onDoubleClick); + this.map.on("dragstart", this.onDragStart); + this.map.on("mouseup", this.onMouseUp); + document.addEventListener("keydown", this.onKeyDown); + document.addEventListener("keypress", this.onKeyPress); + } + + tearDown() { + this.map.off("mousemove", this.onMouseMove); + this.map.off("click", this.onClick); + this.map.off("dblclick", this.onDoubleClick); + this.map.off("dragstart", this.onDragStart); + this.map.off("mouseup", this.onMouseUp); + document.removeEventListener("keydown", this.onKeyDown); + document.removeEventListener("keypress", this.onKeyPress); + } + + onMouseMove = (e: MapMouseEvent) => { + if (!this.active) { + return; + } + const nearbyPoint: [number, number] = [ + e.point.x - snapDistancePixels, + e.point.y, + ]; + const circleRadiusMeters = this.map + .unproject(e.point) + .distanceTo(this.map.unproject(nearbyPoint)); + if ( + this.inner.onMouseMove(e.lngLat.lng, e.lngLat.lat, circleRadiusMeters) + ) { + this.redraw(); + // TODO We'll call this too frequently + this.dataUpdated(); + } + }; + + onClick = () => { + if (!this.active) { + return; + } + this.inner.onClick(); + this.redraw(); + this.dataUpdated(); + }; + + onDoubleClick = (e: MapMouseEvent) => { + if (!this.active) { + return; + } + // When we finish, we'll re-enable doubleClickZoom, but we don't want this to zoom in + e.preventDefault(); + // Double clicks happen as [click, click, dblclick]. The first click adds a + // point, the second immediately deletes it, and so we simulate a third + // click to add it again. + this.inner.onClick(); + this.finish(); + }; + + onDragStart = () => { + if (!this.active) { + return; + } + if (this.inner.onDragStart()) { + this.map.dragPan.disable(); + } + }; + + onMouseUp = () => { + if (!this.active) { + return; + } + if (this.inner.onMouseUp()) { + this.map.dragPan.enable(); + } + }; + + onKeyDown = (e: KeyboardEvent) => { + if (!this.active) { + return; + } + if (e.key == "Escape") { + e.stopPropagation(); + this.cancel(); + } + }; + + onKeyPress = (e: KeyboardEvent) => { + if (!this.active) { + return; + } + // Ignore keypresses if we're not focused on the map + if ((e.target as HTMLElement).tagName == "INPUT") { + return; + } + + if (e.key == "Enter") { + e.stopPropagation(); + this.finish(); + } else if (e.key == "s") { + e.stopPropagation(); + this.inner.toggleSnapMode(); + this.redraw(); + } else if (e.key == "z" && e.ctrlKey) { + this.undo(); + } + }; + + // Activate the tool with blank state. + startRoute() { + // If we were already active, don't do anything + // TODO Or... error? Why'd this happen? + if (this.active) { + return; + } + + this.active = true; + + // Otherwise, shift+click breaks + this.map.boxZoom.disable(); + // Otherwise, double clicking to finish breaks + this.map.doubleClickZoom.disable(); + } + + // Activate the tool with blank state. + startArea() { + // If we were already active, don't do anything + // TODO Or... error? Why'd this happen? + if (this.active) { + return; + } + + this.inner.setAreaMode(); + this.active = true; + this.map.boxZoom.disable(); + this.map.doubleClickZoom.disable(); + } + + // Deactivate the tool, clearing all state. No events are fired for eventListenersFailure. + stop() { + this.active = false; + this.inner.clearState(); + this.redraw(); + this.map.boxZoom.enable(); + this.map.doubleClickZoom.enable(); + } + + // This takes a GeoJSON feature previously returned. It must have all + // properties returned originally. If waypoints are missing (maybe because + // the route was produced by a different tool, or an older version of this + // tool), the edited line-string may differ from the input. + editExistingRoute(feature: Feature) { + if (this.active) { + window.alert("Bug: editExistingRoute called when tool is already active"); + } + + if (!feature.properties.waypoints) { + // Only use the first and last points as waypoints, and assume they're + // snapped. This only works for the simplest cases. + feature.properties.waypoints = [ + { + lon: feature.geometry.coordinates[0][0], + lat: feature.geometry.coordinates[0][1], + snapped: true, + }, + { + lon: feature.geometry.coordinates[ + feature.geometry.coordinates.length - 1 + ][0], + lat: feature.geometry.coordinates[ + feature.geometry.coordinates.length - 1 + ][1], + snapped: true, + }, + ]; + } + + this.startRoute(); + this.inner.editExisting(feature.properties.waypoints); + this.redraw(); + } + + // This only handles features previously returned by this tool. + editExistingArea(feature: Feature) { + if (this.active) { + window.alert("Bug: editExistingArea called when tool is already active"); + } + + if (!feature.properties.waypoints) { + window.alert( + "Bug: editExistingArea called for a polygon not produced by the route-snapper" + ); + } + + this.startArea(); + this.inner.editExisting(feature.properties.waypoints); + this.redraw(); + } + + addEventListenerSuccess( + callback: (f: Feature) => void + ) { + this.eventListenersSuccess.push(callback); + } + addEventListenerUpdated( + callback: (f: Feature) => void + ) { + this.eventListenersUpdated.push(callback); + } + addEventListenerFailure(callback: () => void) { + this.eventListenersFailure.push(callback); + } + clearEventListeners() { + this.eventListenersSuccess = []; + this.eventListenersUpdated = []; + this.eventListenersFailure = []; + } + + isActive(): boolean { + return this.active; + } + + // Either a success or failure event will happen, depending on current state + finish() { + let rawJSON = this.inner.toFinalFeature(); + if (rawJSON) { + // Pass copies to each callback + for (let cb of this.eventListenersSuccess) { + cb(JSON.parse(rawJSON) as Feature); + } + } else { + for (let cb of this.eventListenersFailure) { + cb(); + } + } + this.stop(); + } + + // This stops the tool and fires a failure event + cancel() { + this.inner.clearState(); + this.finish(); + } + + setRouteConfig(config: { + avoid_doubling_back: boolean; + extend_route: boolean; + }) { + this.inner.setRouteConfig(config); + this.redraw(); + } + + addSnappedWaypoint(pt: Position) { + this.inner.addSnappedWaypoint(pt[0], pt[1]); + this.redraw(); + } + + undo() { + this.inner.undo(); + this.redraw(); + } + + private redraw() { + let gj = JSON.parse(this.inner.renderGeojson()); + routeToolGj.set(gj); + this.map.getCanvas().style.cursor = gj.cursor; + snapMode.set(gj.snap_mode); + undoLength.set(gj.undo_length); + } + + private dataUpdated() { + let rawJSON = this.inner.toFinalFeature(); + if (rawJSON) { + // Pass copies to each callback + for (let cb of this.eventListenersUpdated) { + cb(JSON.parse(rawJSON) as Feature); + } + } + } +} diff --git a/web/src/common/stores.ts b/web/src/common/stores.ts new file mode 100644 index 0000000..6c7cc0a --- /dev/null +++ b/web/src/common/stores.ts @@ -0,0 +1,11 @@ +import type { GeoJSON } from "geojson"; +import { writable, type Writable } from "svelte/store"; + +// These are necessary to communicate between components nested under the sidebar and map + +export const routeToolGj: Writable = writable({ + type: "FeatureCollection", + features: [], +}); +export const snapMode: Writable = writable(true); +export const undoLength: Writable = writable(0); diff --git a/web/vite.config.ts b/web/vite.config.ts index 76f92dd..e871db2 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -12,5 +12,5 @@ export default defineConfig({ }, }, }, - plugins: [svelte(), wasmPack(["../backend"], [])] + plugins: [svelte(), wasmPack(["../backend"], ["route-snapper"])] })