diff --git a/backend/src/classify.rs b/backend/src/classify.rs index 57af38b..e0720f7 100644 --- a/backend/src/classify.rs +++ b/backend/src/classify.rs @@ -83,7 +83,25 @@ impl Kind { return Self::RoadWithTags; } - if tags.is_any("highway", vec!["motorway", "motorway_link", "service"]) { + // 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", + ], + ) { return Self::RoadWithoutSidewalksImplicit; } @@ -130,6 +148,16 @@ mod tests { // TODO Not sure about some of these: https://github.com/a-b-street/speedwalk/issues/23 (vec!["highway=path", "footway=sidewalk"], Kind::Other), (vec!["highway=cycleway", "foot=yes"], Kind::Other), + (vec!["highway=trunk"], Kind::RoadWithoutSidewalksImplicit), + ( + vec!["highway=trunk", "sidewalk:both=yes"], + Kind::RoadWithTags, + ), + (vec!["highway=primary"], Kind::RoadWithoutSidewalksImplicit), + ( + vec!["highway=primary", "sidewalk:both=yes"], + Kind::RoadWithTags, + ), ] { let actual = Kind::classify(&Tags::new_from_pairs(&input)); if actual != expected { diff --git a/backend/src/edits.rs b/backend/src/edits.rs index 679c624..618beed 100644 --- a/backend/src/edits.rs +++ b/backend/src/edits.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use anyhow::Result; -use geo::{Coord, Distance, Euclidean, LineString, Point}; +use geo::{Closest, ClosestPoint, Coord, Distance, Euclidean, LineString, Point}; use osm_reader::{NodeID, WayID}; use rstar::{RTree, primitives::GeomWithData}; use serde::Serialize; @@ -26,11 +26,13 @@ pub struct Edits { #[derive(Clone, Serialize)] pub enum UserCmd { - SetTags(WayID, Vec<(String, String)>), + SetTags(WayID, Vec, Vec<(String, String)>), MakeAllSidewalks(bool), ConnectAllCrossings(bool), AssumeTags(bool), AddCrossings(Vec, Tags), + /// Add a crossing as a segment between two points; each point is snapped to the nearest road or sidewalk (closest line). + AddCrossingSegment(Point, Point, Tags), } pub enum TagCmd { @@ -52,16 +54,14 @@ impl Edits { pub fn apply_cmd(&mut self, cmd: UserCmd, model: &Speedwalk) -> Result<()> { self.user_commands.push(cmd.clone()); match cmd { - UserCmd::SetTags(way, replace) => { + UserCmd::SetTags(way, remove_keys, add_tags) => { let cmds = self.change_way_tags.entry(way).or_insert_with(Vec::new); - // Clear old sidewalk tags first - for (k, _) in &model.derived_ways[&way].tags.0 { - if k.starts_with("sidewalk") { - cmds.push(TagCmd::Remove(k.clone())); - } + // First remove all tags in the removal list + for key in remove_keys { + cmds.push(TagCmd::Remove(key)); } - - for (k, v) in replace { + // Then add/set all tags in the addition list + for (k, v) in add_tags { cmds.push(TagCmd::Set(k, v)); } } @@ -135,6 +135,51 @@ impl Edits { model, ); } + UserCmd::AddCrossingSegment(start_wgs84, end_wgs84, way_tags) => { + 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<(WayID, Coord)> { + 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((obj.data, snapped)) + }; + let (way_start, snapped_start) = snap_to_line(start_pt.into())?; + let (way_end, snapped_end) = snap_to_line(end_pt.into())?; + let node_tags = way_tags.clone(); + let mut insert_new_nodes: HashMap> = HashMap::new(); + insert_new_nodes + .entry(way_start) + .or_default() + .push((snapped_start, node_tags.clone())); + insert_new_nodes + .entry(way_end) + .or_default() + .push((snapped_end, node_tags)); + let crossing_way = LineString::new(vec![snapped_start, snapped_end]); + let new_ways = vec![(crossing_way, way_tags)]; + self.create_new_geometry( + CreateNewGeometry { + new_ways, + new_kind: Kind::Crossing, + insert_new_nodes, + modify_existing_way_tags: HashMap::new(), + }, + model, + ); + } } Ok(()) } diff --git a/backend/src/export.rs b/backend/src/export.rs index afc2b9e..726313d 100644 --- a/backend/src/export.rs +++ b/backend/src/export.rs @@ -335,6 +335,11 @@ impl Speedwalk { edge: &Edge, dead_end_edges: Option<&HashSet>, ) -> bool { + // Manual crossing segments (from overwrites) are always shown so they stay visible regardless of filter + let way = &self.derived_ways[&edge.osm_way]; + if way.tags.is("crossing", "manual") { + return true; + } // Apply filters without dead end check if !self.filter_network_without_deadends(filter, graph, edge) { return false; diff --git a/backend/src/make_sidewalks.rs b/backend/src/make_sidewalks.rs index 8eb9ebb..06830af 100644 --- a/backend/src/make_sidewalks.rs +++ b/backend/src/make_sidewalks.rs @@ -3,12 +3,12 @@ use std::collections::{HashMap, HashSet}; use geo::buffer::{BufferStyle, LineJoin}; use geo::line_intersection::{LineIntersection, line_intersection}; use geo::{ - Buffer, Coord, Distance, Euclidean, InterpolatableLine, Line, LineLocatePoint, LineString, + Buffer, Coord, Euclidean, InterpolatableLine, Line, LineLocatePoint, LineString, MultiLineString, Point, }; use osm_reader::WayID; use rstar::{RTree, primitives::GeomWithData}; -use utils::{OffsetCurve, Tags, aabb}; +use utils::{Tags, aabb}; use crate::{ Kind, Speedwalk, @@ -275,24 +275,22 @@ fn split_new_sidewalks( output } +/// Classify which side of the road a point is on using the tangent at the nearest point on the road. fn classify_side(pt: Point, road: &GeomWithData) -> Side { - // TODO There's probably something much easier with Orient, but this works - let Some(left) = road.geom().offset_curve(-BUFFER_DISTANCE) else { - panic!("offset failed for {}", road.data); - }; - let Some(right) = road.geom().offset_curve(BUFFER_DISTANCE) else { - panic!("offset failed for {}", road.data); - }; - if distance(&left, pt).expect("snap failed") <= distance(&right, pt).expect("snap failed") { + let road = road.geom(); + let Some(fraction) = road.line_locate_point(&pt) else { return Side::Left }; + let Some(closest) = road.point_at_ratio_from_start(&Euclidean, fraction) else { return Side::Left }; + let eps = 1e-6; + let before = road.point_at_ratio_from_start(&Euclidean, (fraction - eps).max(0.0)).unwrap_or(closest); + let after = road.point_at_ratio_from_start(&Euclidean, (fraction + eps).min(1.0)).unwrap_or(closest); + let dx = after.x() - before.x(); + let dy = after.y() - before.y(); + let to_pt_x = pt.x() - closest.x(); + let to_pt_y = pt.y() - closest.y(); + // Dot with left normal (-dy, dx): positive => left side + if to_pt_x * (-dy) + to_pt_y * dx >= 0.0 { Side::Left } else { Side::Right } } - -// TODO Upstream -fn distance(ls: &LineString, pt: Point) -> Option { - let fraction = ls.line_locate_point(&pt)?; - let snapped = ls.point_at_ratio_from_start(&Euclidean, fraction)?; - Some(Euclidean.distance(pt, snapped)) -} diff --git a/backend/src/wasm.rs b/backend/src/wasm.rs index 4a38a51..42d5aab 100644 --- a/backend/src/wasm.rs +++ b/backend/src/wasm.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Once; use anyhow::Result; @@ -62,6 +62,10 @@ impl Speedwalk { "is_generated_crossing", node.tags.is("crossing", "generated"), ); + f.set_property( + "is_manual_crossing", + node.tags.is("crossing", "manual"), + ); f.set_property("modified", node.modified); f.set_property( "way_ids", @@ -93,10 +97,12 @@ impl Speedwalk { ); f.set_property("is_severance", way.is_severance()); f.set_property("is_service", way.tags.is("highway", "service")); + f.set_property("is_manual_crossing", way.tags.is("crossing", "manual")); f.set_property( "problems", serde_json::to_value(&way.problems).map_err(err_to_js)?, ); + f.set_property("length_m", Euclidean.length(&way.linestring)); if way.kind == Kind::Crossing { crossings.push(f); } else { @@ -253,14 +259,22 @@ impl Speedwalk { } #[wasm_bindgen(js_name = editSetTags)] - pub fn edit_set_tags(&mut self, base: i64, tags: JsValue) -> Result<(), JsValue> { - let tags: Vec> = serde_wasm_bindgen::from_value(tags)?; + pub fn edit_set_tags( + &mut self, + base: i64, + remove_keys: JsValue, + add_tags: JsValue, + ) -> Result<(), JsValue> { + let remove_keys: Vec = serde_wasm_bindgen::from_value(remove_keys)?; + let add_tags: Vec> = serde_wasm_bindgen::from_value(add_tags)?; let mut edits = self.edits.take().unwrap(); edits .apply_cmd( UserCmd::SetTags( WayID(base), - tags.into_iter() + remove_keys, + add_tags + .into_iter() .map(|mut kv| (kv.remove(0), kv.remove(0))) .collect(), ), @@ -287,6 +301,55 @@ impl Speedwalk { Ok(()) } + /// Add a crossing at (x, y) in WGS84 lon/lat with the given tags (e.g. highway=crossing, crossing=manual). + #[wasm_bindgen(js_name = editAddNewCrossingWithTags)] + pub fn edit_add_new_crossing_with_tags( + &mut self, + x: f64, + y: f64, + tags_js: JsValue, + ) -> Result<(), JsValue> { + let tag_map: HashMap = serde_wasm_bindgen::from_value(tags_js)?; + let mut tags = Tags::empty(); + for (k, v) in tag_map { + tags.insert(&k, &v); + } + let mut edits = self.edits.take().unwrap(); + edits + .apply_cmd(UserCmd::AddCrossings(vec![Point::new(x, y)], tags), self) + .map_err(err_to_js)?; + self.edits = Some(edits); + self.after_edit(); + Ok(()) + } + + /// Add a crossing as a segment between two points (WGS84 lon/lat). Each point is snapped to the + /// nearest road or sidewalk (closest line); a new crossing way is created between the snapped points. + #[wasm_bindgen(js_name = editAddCrossingSegment)] + pub fn edit_add_crossing_segment( + &mut self, + start_lng: f64, + start_lat: f64, + end_lng: f64, + end_lat: f64, + tags_js: JsValue, + ) -> Result<(), JsValue> { + let tag_map: HashMap = serde_wasm_bindgen::from_value(tags_js)?; + let mut tags = Tags::empty(); + for (k, v) in tag_map { + tags.insert(&k, &v); + } + let start = Point::new(start_lng, start_lat); + let end = Point::new(end_lng, end_lat); + let mut edits = self.edits.take().unwrap(); + edits + .apply_cmd(UserCmd::AddCrossingSegment(start, end, tags), self) + .map_err(err_to_js)?; + self.edits = Some(edits); + self.after_edit(); + Ok(()) + } + #[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/assets/Mapillary_logo.svg b/web/assets/Mapillary_logo.svg index 76a3d0d..34671b8 100644 --- a/web/assets/Mapillary_logo.svg +++ b/web/assets/Mapillary_logo.svg @@ -1,5 +1,4 @@ - - - - + + + diff --git a/web/package-lock.json b/web/package-lock.json index f4b6598..5124140 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2715,7 +2715,7 @@ }, "node_modules/svelte-utils": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/a-b-street/svelte-utils.git#a4bf4cd46345311a084810b1cc99e3cb33575456", + "resolved": "git+ssh://git@github.com/a-b-street/svelte-utils.git#64cfea2d31850b269c2d4a30231d0f880d70b16a", "dependencies": { "@maptiler/geocoding-control": "^2.1.7", "@turf/bbox": "^7.2.0", diff --git a/web/src/App.svelte b/web/src/App.svelte index fe2ce98..4194fb5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -18,6 +18,8 @@ import AuditCrossingsMode from "./crossings/AuditCrossingsMode.svelte"; import DisconnectionsMode from "./DisconnectionsMode.svelte"; import ExportMode from "./ExportMode.svelte"; + import GeneratorMode from "./generator/GeneratorMode.svelte"; + import OverwritesMode from "./overwrites/OverwritesMode.svelte"; import StudyAreaFade from "./common/StudyAreaFade.svelte"; import NavBar from "./common/NavBar.svelte"; @@ -33,7 +35,7 @@ } }); - let basemap = $state("Maptiler OpenStreetMap"); + let basemap = $state("Maptiler Dataviz Light"); // svelte-ignore state_referenced_locally let initialStyle = basemapStyles.get(basemap)!; @@ -56,6 +58,7 @@ "heatmap-", "symbol-", "speedwalk-", + "overwrites-", "edit-polygon-", ].some((prefix) => l.id.startsWith(prefix)), ); @@ -134,6 +137,10 @@ {:else if $mode.kind == "disconnections"} + {:else if $mode.kind == "generator"} + + {:else if $mode.kind == "overwrites"} + {:else if $mode.kind == "export"} {/if} diff --git a/web/src/DisconnectionsMode.svelte b/web/src/DisconnectionsMode.svelte index 828158e..d4cc334 100644 --- a/web/src/DisconnectionsMode.svelte +++ b/web/src/DisconnectionsMode.svelte @@ -11,6 +11,7 @@ import { constructMatchExpression, emptyGeojson } from "svelte-utils/map"; import { backend, map, prettyPrintDistance, networkFilter } from "./"; import NetworkFilter from "./common/NetworkFilter.svelte"; + import Jumbotron from "./common/Jumbotron.svelte"; let gj = $derived( $backend @@ -57,13 +58,15 @@ {#snippet left()} -

Network disconnections

+ + + -

- This shows where the network is disconnected. Click a piece to see it. -

- - +

Disconnected Networks

+

Click to highlight the selected subnetwork.