diff --git a/osm2streets-js/src/lib.rs b/osm2streets-js/src/lib.rs index 1fc0819c..1ee5f766 100644 --- a/osm2streets-js/src/lib.rs +++ b/osm2streets-js/src/lib.rs @@ -48,8 +48,8 @@ impl JsStreetNetwork { let clip_pts = if clip_pts_geojson.is_empty() { None } else { - let mut list = LonLat::parse_geojson_polygons(clip_pts_geojson.to_string()) - .map_err(|err| JsValue::from_str(&err.to_string()))?; + let mut list = + LonLat::parse_geojson_polygons(clip_pts_geojson.to_string()).map_err(err_to_js)?; if list.len() != 1 { return Err(JsValue::from_str(&format!( "{clip_pts_geojson} doesn't contain exactly one polygon" @@ -66,7 +66,7 @@ impl JsStreetNetwork { let mut timer = Timer::throwaway(); let (mut street_network, doc) = streets_reader::osm_to_street_network(osm_input, clip_pts, cfg, &mut timer) - .map_err(|err| JsValue::from_str(&err.to_string()))?; + .map_err(err_to_js)?; let mut transformations = Transformation::standard_for_clipped_areas(); if input.dual_carriageway_experiment { // Collapsing short roads tries to touch "bridges," making debugging harder @@ -249,6 +249,20 @@ impl JsStreetNetwork { out.push_str(""); Ok(out) } + + #[wasm_bindgen(js_name = findBlock)] + pub fn find_block(&self, road: usize, left: bool) -> Result { + self.inner + .find_block(RoadID(road), left) + .map_err(err_to_js)? + .render_polygon(&self.inner) + .map_err(err_to_js) + } + + #[wasm_bindgen(js_name = findAllBlocks)] + pub fn find_all_blocks(&self) -> String { + self.inner.find_all_blocks().unwrap() + } } // Mutations @@ -332,3 +346,7 @@ impl JsDebugStreets { self.inner.to_debug_geojson() } } + +fn err_to_js(err: E) -> JsValue { + JsValue::from_str(&err.to_string()) +} diff --git a/osm2streets/src/block.rs b/osm2streets/src/block.rs new file mode 100644 index 00000000..9b91b466 --- /dev/null +++ b/osm2streets/src/block.rs @@ -0,0 +1,258 @@ +use std::collections::HashSet; + +use anyhow::Result; +use geojson::Feature; +use geom::{Polygon, Ring}; + +use crate::{IntersectionID, LaneType, RoadID, StreetNetwork}; + +/// A "tight" cycle of roads and intersections, with a polygon capturing the negative space inside. +pub struct Block { + pub kind: BlockKind, + pub steps: Vec, + pub polygon: Polygon, +} + +#[derive(Clone, Copy)] +pub enum Step { + Node(IntersectionID), + Edge(RoadID), +} + +#[derive(Debug)] +pub enum BlockKind { + /// The space between a road and sidewalk. It might be a wide sidewalk or contain grass. + RoadAndSidewalk, + /// The space between a road and cycle lane. It should contain some kind of separation. + RoadAndCycleLane, + /// The space between a cycle lane and sidewalk. It should contain some kind of separation -- + /// at least a curb. + CycleLaneAndSidewalk, + /// The space between one-way roads. Probably has some kind of physical barrier, not just + /// markings. + DualCarriageway, + Unknown, +} + +impl StreetNetwork { + // Start at road's src_i + pub fn find_block(&self, start: RoadID, left: bool) -> Result { + // TODO ?? + let clockwise = left; + let steps = walk_around(self, start, clockwise)?; + let polygon = trace_polygon(self, &steps, clockwise)?; + let kind = classify(self, &steps); + + Ok(Block { + kind, + steps, + polygon, + }) + } + + pub fn find_all_blocks(&self) -> Result { + // TODO consider a Left/Right enum instead of bool + let mut visited_roads: HashSet<(RoadID, bool)> = HashSet::new(); + let mut blocks = Vec::new(); + + for r in self.roads.keys() { + for left in [true, false] { + if visited_roads.contains(&(*r, left)) { + continue; + } + if let Ok(block) = self.find_block(*r, left) { + // TODO Put more info in Step to avoid duplicating logic with trace_polygon + for pair in block.steps.windows(2) { + match (pair[0], pair[1]) { + (Step::Edge(r), Step::Node(i)) => { + let road = &self.roads[&r]; + if road.dst_i == i { + visited_roads.insert((r, left)); + } else { + visited_roads.insert((r, !left)); + } + } + // Skip... unless for the last case? + (Step::Node(_), Step::Edge(_)) => {} + _ => unreachable!(), + } + } + blocks.push(block); + } + } + } + + let mut features = Vec::new(); + for block in blocks { + let mut f = Feature::from(block.polygon.to_geojson(Some(&self.gps_bounds))); + f.set_property("type", "block"); + f.set_property("kind", format!("{:?}", block.kind)); + features.push(f); + } + serialize_features(features) + } +} + +impl Block { + pub fn render_polygon(&self, streets: &StreetNetwork) -> Result { + let mut f = Feature::from(self.polygon.to_geojson(Some(&streets.gps_bounds))); + f.set_property("type", "block"); + f.set_property("kind", format!("{:?}", self.kind)); + serialize_features(vec![f]) + } + + pub fn render_debug(&self, streets: &StreetNetwork) -> Result { + let mut features = Vec::new(); + + for step in &self.steps { + match step { + Step::Node(i) => { + let mut f = Feature::from( + streets.intersections[&i] + .polygon + .to_geojson(Some(&streets.gps_bounds)), + ); + f.set_property("type", "intersection"); + features.push(f); + } + Step::Edge(r) => { + let road = &streets.roads[&r]; + let mut f = Feature::from( + road.center_line + .make_polygons(road.total_width()) + .to_geojson(Some(&streets.gps_bounds)), + ); + f.set_property("type", "road"); + features.push(f); + } + } + } + + serialize_features(features) + } +} + +fn walk_around(streets: &StreetNetwork, start_road: RoadID, clockwise: bool) -> Result> { + let start_i = streets.roads[&start_road].src_i; + + let mut current_i = start_i; + let mut current_r = start_road; + + let mut steps = vec![Step::Edge(current_r)]; + + while current_i != start_i || steps.len() < 2 { + // Fail for dead-ends (for now, to avoid tracing around the entire clipped map) + if streets.intersections[¤t_i].roads.len() == 1 { + bail!("Found a dead-end at {current_i}"); + } + + let next_i = &streets.intersections[&streets.roads[¤t_r].other_side(current_i)]; + let idx = next_i.roads.iter().position(|x| *x == current_r).unwrap(); + let next_idx = if clockwise { + if idx == next_i.roads.len() - 1 { + 0 + } else { + idx + 1 + } + } else { + if idx == 0 { + next_i.roads.len() - 1 + } else { + idx - 1 + } + }; + let next_r = next_i.roads[next_idx]; + steps.push(Step::Node(next_i.id)); + steps.push(Step::Edge(next_r)); + current_i = next_i.id; + current_r = next_r; + } + + Ok(steps) +} + +fn trace_polygon(streets: &StreetNetwork, steps: &Vec, clockwise: bool) -> Result { + let shift_dir = if clockwise { -1.0 } else { 1.0 }; + let mut pts = Vec::new(); + + // steps will begin and end with an edge + for pair in steps.windows(2) { + match (pair[0], pair[1]) { + (Step::Edge(r), Step::Node(i)) => { + let road = &streets.roads[&r]; + if road.dst_i == i { + pts.extend( + road.center_line + .shift_either_direction(shift_dir * road.half_width())? + .into_points(), + ); + } else { + pts.extend( + road.center_line + .reversed() + .shift_either_direction(shift_dir * road.half_width())? + .into_points(), + ); + } + } + // Skip... unless for the last case? + (Step::Node(_), Step::Edge(_)) => {} + _ => unreachable!(), + } + } + + pts.push(pts[0]); + Ok(Ring::deduping_new(pts)?.into_polygon()) +} + +fn classify(streets: &StreetNetwork, steps: &Vec) -> BlockKind { + let mut has_road = false; + let mut has_cycle_lane = false; + let mut has_sidewalk = false; + + for step in steps { + if let Step::Edge(r) = step { + let road = &streets.roads[r]; + if road.is_driveable() { + // TODO Or bus lanes? + has_road = true; + } else if road.lane_specs_ltr.len() == 1 + && road.lane_specs_ltr[0].lt == LaneType::Biking + { + has_cycle_lane = true; + } else if road.lane_specs_ltr.len() == 1 + && road.lane_specs_ltr[0].lt == LaneType::Sidewalk + { + has_sidewalk = true; + } + } + } + + if has_road && has_sidewalk { + return BlockKind::RoadAndSidewalk; + } + if has_road && has_cycle_lane { + return BlockKind::RoadAndCycleLane; + } + if has_road { + // TODO Insist on one-ways pointing the opposite direction? What about different types of + // small connector roads? + return BlockKind::DualCarriageway; + } + // TODO This one is usually missed, because of a small piece of road crossing both + if !has_road && has_cycle_lane && has_sidewalk { + return BlockKind::CycleLaneAndSidewalk; + } + + BlockKind::Unknown +} + +fn serialize_features(features: Vec) -> Result { + let gj = geojson::GeoJson::from(geojson::FeatureCollection { + bbox: None, + features, + foreign_members: None, + }); + let output = serde_json::to_string_pretty(&gj)?; + Ok(output) +} diff --git a/osm2streets/src/lib.rs b/osm2streets/src/lib.rs index 361e4980..345c728d 100644 --- a/osm2streets/src/lib.rs +++ b/osm2streets/src/lib.rs @@ -30,6 +30,7 @@ pub use osm2lanes::{ Placement, NORMAL_LANE_THICKNESS, SIDEWALK_THICKNESS, }; +mod block; mod geometry; mod ids; mod intersection; diff --git a/web/src/common/Legend.svelte b/web/src/common/Legend.svelte new file mode 100644 index 00000000..840c92de --- /dev/null +++ b/web/src/common/Legend.svelte @@ -0,0 +1,24 @@ + + +
    + {#each rows as [label, color]} +
  • + + {label} +
  • + {/each} +
+ + diff --git a/web/src/common/index.ts b/web/src/common/index.ts index 7b40d02d..5ecf453c 100644 --- a/web/src/common/index.ts +++ b/web/src/common/index.ts @@ -1,6 +1,7 @@ export { default as BasemapPicker } from "./BasemapPicker.svelte"; export { default as Geocoder } from "./Geocoder.svelte"; export { default as Layout } from "./Layout.svelte"; +export { default as Legend } from "./Legend.svelte"; export { default as Map } from "./Map.svelte"; export { default as StreetView } from "./StreetView.svelte"; export { default as ThemePicker } from "./ThemePicker.svelte"; diff --git a/web/src/common/layers/DebugIDs.svelte b/web/src/common/layers/DebugIDs.svelte new file mode 100644 index 00000000..5cb6d8d7 --- /dev/null +++ b/web/src/common/layers/DebugIDs.svelte @@ -0,0 +1,31 @@ + + + + + + + diff --git a/web/src/common/utils.ts b/web/src/common/utils.ts index 9f5530f4..2ef0927b 100644 --- a/web/src/common/utils.ts +++ b/web/src/common/utils.ts @@ -115,8 +115,11 @@ const layerZorder = [ "lane-markings", "intersection-markings", + // Street Explorer only "movements", "connected-roads", + "block", + "debug-ids", // TODO This is specific to lane-editor. Figure out how different apps can // build on top of the common common ordering. diff --git a/web/src/street-explorer/App.svelte b/web/src/street-explorer/App.svelte index b063a320..d8083e07 100644 --- a/web/src/street-explorer/App.svelte +++ b/web/src/street-explorer/App.svelte @@ -3,6 +3,7 @@ import init from "osm2streets-js"; import { onMount } from "svelte"; import AppSwitcher from "../AppSwitcher.svelte"; + import RenderBlock from "./RenderBlock.svelte"; import { StreetView, ThemePicker, @@ -20,6 +21,7 @@ import DynamicConnectedRoads from "../common/layers/DynamicConnectedRoads.svelte"; import DynamicMovementArrows from "../common/layers/DynamicMovementArrows.svelte"; import DynamicRoadOrdering from "../common/layers/DynamicRoadOrdering.svelte"; + import DebugIDs from "../common/layers/DebugIDs.svelte"; import IntersectionPopup from "./IntersectionPopup.svelte"; import LanePopup from "./LanePopup.svelte"; @@ -89,9 +91,14 @@
+ + +
+ +
diff --git a/web/src/street-explorer/IntersectionPopup.svelte b/web/src/street-explorer/IntersectionPopup.svelte index 6d2a571a..449478d1 100644 --- a/web/src/street-explorer/IntersectionPopup.svelte +++ b/web/src/street-explorer/IntersectionPopup.svelte @@ -36,4 +36,6 @@ {/each}

- +
+ +
diff --git a/web/src/street-explorer/LanePopup.svelte b/web/src/street-explorer/LanePopup.svelte index a1e1774a..a13437c9 100644 --- a/web/src/street-explorer/LanePopup.svelte +++ b/web/src/street-explorer/LanePopup.svelte @@ -2,6 +2,7 @@ import type { Polygon } from "geojson"; import type { FeatureWithProps } from "../common/utils"; import { network } from "../common"; + import { blockGj } from "./stores"; // Note the input is maplibre's GeoJSONFeature, which stringifies nested properties export let data: FeatureWithProps | undefined; @@ -22,6 +23,15 @@ $network = $network; close(); } + + function findBlock(left: boolean) { + try { + blockGj.set(JSON.parse($network!.findBlock(props.road, left))); + } catch (err) { + window.alert(err); + } + close(); + }

Lane {props.index} of Road {props.road}

@@ -62,6 +72,14 @@ +
+ + +