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 @@
+
+
+
+