Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
22bae34
Refactor WayDetails by extracting sub components
tordans Feb 2, 2026
cd9dee0
WayDetails: Actions for sidepath like ways
tordans Feb 2, 2026
406c776
Improve CurrentTagsTable
tordans Feb 2, 2026
9f2372e
Improve SidepathTagActions
tordans Feb 4, 2026
c9545a4
Highlight added tags in CurrentTagsTable
tordans Feb 4, 2026
b9042df
Split adding and removing tags; Rework centerline vs sidepath components
tordans Feb 5, 2026
853a715
Rename refresh button to signal "refresh"
tordans Mar 5, 2026
9571ed9
Rename "Disconnections" button
tordans Mar 5, 2026
6214b41
Jumbotron for each Page
tordans Mar 5, 2026
9702dc2
Change default for Disconnected page to Routable Network
tordans Mar 5, 2026
ac037aa
Introduce "Generator" page to consolidate actions
tordans Mar 5, 2026
fb87a90
Jumbotron: Update wording
tordans Mar 5, 2026
8eaff68
Sidewalk actions: Split "show major roads" from "minor"
tordans Mar 5, 2026
05e6690
Share crossing legend on bulk page and update legend style/component
tordans Mar 5, 2026
b002533
Rework Legend helper components and usage
tordans Mar 5, 2026
ba890fa
Fix legend styles
tordans Mar 5, 2026
1e56a37
Sidewalk page: Fix map style
tordans Mar 5, 2026
2d5b3f2
Share labels between export and sidewalk
tordans Mar 5, 2026
2b86381
Improve styles and legend
tordans Mar 5, 2026
5cd2b58
Generator page: Add "other" legend item
tordans Mar 6, 2026
68e1be6
Change basemap to maptiler data viz light
tordans Mar 6, 2026
28fd804
Handle hw=trunk|trunk_link as "RoadWithoutSidewalksImplicit"
tordans Mar 6, 2026
2fee40c
Handle more high level highway classes as "RoadWithoutSidewalksImplicit"
tordans Mar 6, 2026
c0625a3
Make sidewalk: Fix(?) crash
tordans Mar 6, 2026
7061144
Make sidewalk: Rework to use tangent method only
tordans Mar 6, 2026
51c9341
Sidewalks: Close edits collapsible by default until edits present
tordans Mar 6, 2026
2b2133f
Sidewalk: Make trash btn gray by default
tordans Mar 6, 2026
c19e980
Editing: Show warning when crossing > 30 m
tordans Mar 6, 2026
c402787
Editing: Show left|right in Map only for centerline ways
tordans Mar 6, 2026
040415a
Editing: Disable button when applied already
tordans Mar 6, 2026
88f4b8e
Introduce manual overwrite page and allow adding manual connections
tordans Mar 6, 2026
e722823
Editing: Expand the "removes" tags
tordans Mar 6, 2026
a207df0
Share network filter between export and overwrite pages
tordans Mar 6, 2026
86415df
Mapillary: Improve Button and Box
tordans Mar 6, 2026
ecd8d23
ActionBar: Rework background color
tordans Mar 6, 2026
42af603
Overwrites: Add loading state
tordans Mar 6, 2026
301142f
More Checkboxes in Localstorage
tordans Mar 6, 2026
4e04bc5
Introduce app modes to hide menu items
tordans Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion backend/src/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor with is_severance, except the service road case?

"motorway",
"motorway_link",
"trunk",
"trunk_link",
"primary",
"primary_link",
"secondary",
"secondary_link",
"tertiary",
"tertiary_link",
"service",
],
) {
return Self::RoadWithoutSidewalksImplicit;
}

Expand Down Expand Up @@ -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 {
Expand Down
65 changes: 55 additions & 10 deletions backend/src/edits.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,11 +26,13 @@ pub struct Edits {

#[derive(Clone, Serialize)]
pub enum UserCmd {
SetTags(WayID, Vec<(String, String)>),
SetTags(WayID, Vec<String>, Vec<(String, String)>),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be useful to add a `/// Doc comment saying what these are; the Vec<(String, String)> was key/value pairs, and I'm guessing the new thing is keys to delete. Or switch to a struct for this case with named fields

MakeAllSidewalks(bool),
ConnectAllCrossings(bool),
AssumeTags(bool),
AddCrossings(Vec<Point>, 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 {
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good, the responsibility for this moves to the frontend then

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));
}
}
Expand Down Expand Up @@ -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<WayID, Vec<(Coord, Tags)>> = 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(())
}
Expand Down
5 changes: 5 additions & 0 deletions backend/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,11 @@ impl Speedwalk {
edge: &Edge,
dead_end_edges: Option<&HashSet<EdgeID>>,
) -> 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;
Expand Down
32 changes: 15 additions & 17 deletions backend/src/make_sidewalks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<LineString, WayID>) -> 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<f64> {
let fraction = ls.line_locate_point(&pt)?;
let snapped = ls.point_at_ratio_from_start(&Euclidean, fraction)?;
Some(Euclidean.distance(pt, snapped))
}
71 changes: 67 additions & 4 deletions backend/src/wasm.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::sync::Once;

use anyhow::Result;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Vec<String>> = 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<String> = serde_wasm_bindgen::from_value(remove_keys)?;
let add_tags: Vec<Vec<String>> = 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(),
),
Expand All @@ -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<String, String> = 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<String, String> = 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;
Expand Down
7 changes: 3 additions & 4 deletions web/assets/Mapillary_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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)!;
Expand All @@ -56,6 +58,7 @@
"heatmap-",
"symbol-",
"speedwalk-",
"overwrites-",
"edit-polygon-",
].some((prefix) => l.id.startsWith(prefix)),
);
Expand Down Expand Up @@ -134,6 +137,10 @@
<AuditCrossingsMode />
{:else if $mode.kind == "disconnections"}
<DisconnectionsMode />
{:else if $mode.kind == "generator"}
<GeneratorMode />
{:else if $mode.kind == "overwrites"}
<OverwritesMode />
{:else if $mode.kind == "export"}
<ExportMode />
{/if}
Expand Down
Loading