Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 3 additions & 19 deletions backend/src/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,9 @@ impl Kind {
return Self::RoadWithTags;
}

// 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",
],
) {
// Only applied when there are no sidewalk tags above. Severance highway types (see
// is_severance_highway) plus service; explicit sidewalk tags are already RoadWithTags.
if crate::is_road_without_sidewalks_implicit(tags) {
return Self::RoadWithoutSidewalksImplicit;
}

Expand Down
54 changes: 52 additions & 2 deletions backend/src/edits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,51 @@ use utils::Tags;

use crate::{Kind, Node, Speedwalk, Way};

/// Snaps two WGS84 points to the nearest road, sidewalk, or walkable path (e.g. footway); returns snapped points in WGS84.
/// Includes walkable "Other" ways (e.g. highway=footway) so manual crossings can connect e.g. a footway to a road.
pub fn snap_crossing_segment(
model: &Speedwalk,
start_wgs84: Point,
end_wgs84: Point,
) -> Result<(Point, Point)> {
let start_pt = model.mercator.to_mercator(&start_wgs84);
let end_pt = model.mercator.to_mercator(&end_wgs84);
let closest_line = RTree::bulk_load(

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.

I bet this is the thing slowing down undo in the override mode. We could get more clever about caching this or only calculating once + updating if we're doing multiple undos. Just an idea for later, not important now

model
.derived_ways
.iter()
.filter(|(_, way)| way.is_snap_target_for_crossing())
.map(|(id, way)| GeomWithData::new(way.linestring.clone(), *id))
.collect(),
);
let snap_to_line = |pt: Coord| -> Result<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(snapped)
};
let snapped_start = snap_to_line(start_pt.into())?;
let snapped_end = snap_to_line(end_pt.into())?;
// Reject degenerate case: both points snapped to the same location (e.g. same road segment)
let dist_m = Euclidean.distance(
Point::from(snapped_start),
Point::from(snapped_end),
);
if dist_m < 0.5 {
bail!(
"Both points snapped to the same location (distance {:.1}m). Try clicking on the two crossing ways you want to connect.",
dist_m
);
}
let start_out = model.mercator.pt_to_wgs84(snapped_start);
let end_out = model.mercator.pt_to_wgs84(snapped_end);
Ok((Point::from(start_out), Point::from(end_out)))
}

#[derive(Default)]
pub struct Edits {
pub user_commands: Vec<UserCmd>,
Expand All @@ -26,7 +71,12 @@ pub struct Edits {

#[derive(Clone, Serialize)]
pub enum UserCmd {
SetTags(WayID, Vec<String>, Vec<(String, String)>),
/// Set tags on a way. First: tag keys to remove. Second: key-value pairs to add or set (applied after removals).

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.

Thanks for fixing this. FYI for the future, https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html#listing-6-2 is an example of the struct-style for an enum case. Then the docs are unnecessary, the field names are clear

SetTags(
WayID,
Vec<String>, // remove_keys
Vec<(String, String)>, // add_tags
),
MakeAllSidewalks(bool),
ConnectAllCrossings(bool),
AssumeTags(bool),
Expand Down Expand Up @@ -142,7 +192,7 @@ impl Edits {
model
.derived_ways
.iter()
.filter(|(_, way)| way.kind.is_road() || way.kind == Kind::Sidewalk)
.filter(|(_, way)| way.is_snap_target_for_crossing())
.map(|(id, way)| GeomWithData::new(way.linestring.clone(), *id))
.collect(),
);
Expand Down
51 changes: 36 additions & 15 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@ use utils::{Mercator, Tags};
use wasm_bindgen::prelude::*;

pub use crate::classify::Kind;

/// Highway types treated as severance (major roads that cut through). Used for both
/// Way::is_severance() and RoadWithoutSidewalksImplicit; the latter also includes "service".
const SEVERANCE_HIGHWAY_TYPES: &[&str] = &[
"motorway",
"motorway_link",
"trunk",
"trunk_link",
"primary",
"primary_link",
"secondary",
"secondary_link",
"tertiary",
"tertiary_link",
];

/// True if the way's highway type is in the severance list (used for assume-tags, etc.).
pub(crate) fn is_severance_highway(tags: &Tags) -> bool {
tags.is_any("highway", SEVERANCE_HIGHWAY_TYPES.to_vec())
}

/// True if the way should be classified as RoadWithoutSidewalksImplicit: severance types
/// or highway=service (no sidewalk tags). Service is excluded from is_severance.
pub(crate) fn is_road_without_sidewalks_implicit(tags: &Tags) -> bool {
is_severance_highway(tags) || tags.is("highway", "service")
}
pub use crate::edits::{Edits, UserCmd};

#[wasm_bindgen]
Expand Down Expand Up @@ -104,21 +130,7 @@ impl Node {

impl Way {
pub fn is_severance(&self) -> bool {
self.tags.is_any(
"highway",
vec![
"motorway",
"motorway_link",
"trunk",
"trunk_link",
"primary",
"primary_link",
"secondary",
"secondary_link",
"tertiary",
"tertiary_link",
],
)
is_severance_highway(&self.tags)
}

/// For Kind::Other cases (often cycleways or paths), is the way usable for walking?
Expand All @@ -133,6 +145,15 @@ impl Way {
true
}
}

/// True if this way is a valid snap target for crossing segment endpoints (roads, sidewalks,
/// and walkable Other e.g. footway/path). Must stay in sync with snap_crossing_segment and
/// AddCrossingSegment so snap and apply use the same candidate set.
pub fn is_snap_target_for_crossing(&self) -> bool {
self.kind.is_road()
|| self.kind == Kind::Sidewalk
|| (self.kind == Kind::Other && self.is_walkable_other())
}
}

#[derive(Clone, Serialize)]
Expand Down
21 changes: 21 additions & 0 deletions backend/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,27 @@ impl Speedwalk {
Ok(())
}

/// Snap two WGS84 points to the nearest road/sidewalk; returns snapped coords as JSON.
/// Does not apply any edit. Used so the UI can store snapped geometry for re-apply/import.
#[wasm_bindgen(js_name = snapCrossingSegment)]
pub fn snap_crossing_segment_wasm(
&self,
start_lng: f64,
start_lat: f64,
end_lng: f64,
end_lat: f64,
) -> Result<JsValue, JsValue> {
let start = Point::new(start_lng, start_lat);
let end = Point::new(end_lng, end_lat);
let (s_start, s_end) =
crate::edits::snap_crossing_segment(self, start, end).map_err(err_to_js)?;
let out = serde_json::json!({
"start": { "lng": s_start.x(), "lat": s_start.y() },
"end": { "lng": s_end.x(), "lat": s_end.y() }
});
Ok(serde_wasm_bindgen::to_value(&out)?)
}

#[wasm_bindgen(js_name = editUndo)]
pub fn edit_undo(&mut self) -> Result<(), JsValue> {
let mut cmds = self.edits.take().unwrap().user_commands;
Expand Down
1 change: 1 addition & 0 deletions web/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"speedwalk-",
"overwrites-",
"edit-polygon-",
"mapillary-",
].some((prefix) => l.id.startsWith(prefix)),
);
let layers = nextStyle.layers.concat(customLayers);
Expand Down
4 changes: 4 additions & 0 deletions web/src/common/Mapillary.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import logo from "../../assets/Mapillary_logo.svg";
import lineArc from "@turf/line-arc";
import { point } from "@turf/helpers";
import { MAPILLARY_PIN_LAYER_IDS } from "./mapillaryLayers";

let show = $state(false);

Expand Down Expand Up @@ -108,6 +109,7 @@
/>

<SymbolLayer
id={MAPILLARY_PIN_LAYER_IDS.symbol}
minzoom={16}
sourceLayer="image"
layout={{
Expand All @@ -119,6 +121,7 @@
/>

<CircleLayer
id={MAPILLARY_PIN_LAYER_IDS.circleInteractive}
sourceLayer="image"
manageHoverState
paint={{
Expand All @@ -135,6 +138,7 @@
/>

<CircleLayer
id={MAPILLARY_PIN_LAYER_IDS.circle}
sourceLayer="image"
paint={{
"circle-radius": 4,
Expand Down
72 changes: 34 additions & 38 deletions web/src/common/localOverrides.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/**
* Manual overwrites (e.g. added crossing segments) stored in IndexedDB.
* We store a single overrides blob (one record). Boundary filtering is separate:
* filterSegmentsInBoundary() filters segments by the loaded map boundary (bbox).
*/
import type { GeoJSON } from "geojson";
import { bbox } from "svelte-utils/map";

const DB_NAME = "speedwalk-overrides";
const STORE_NAME = "regionOverrides";
const GLOBAL_KEY = "global";
const DB_VERSION = 2;
const STORE_NAME = "overrides";
const OVERRIDES_RECORD_ID = "default";

export interface AddedCrossingSegment {
id?: string;
Expand All @@ -12,55 +18,59 @@ export interface AddedCrossingSegment {
tags: Record<string, string>;
}

export interface RegionOverrides {
export interface ManualOverrides {
version: number;
addedCrossings: AddedCrossingSegment[];
}

const DEFAULT_OVERRIDES: RegionOverrides = {
const DEFAULT_OVERRIDES: ManualOverrides = {
version: 1,
addedCrossings: [],
};

function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE_NAME, { keyPath: "regionKey" });
req.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create store for new DB (oldVersion 0) or when upgrading to v2 (e.g. from v1). No data migration.
if (event.oldVersion < 2) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
});
}

/** True if the item is a segment (has start and end). Drops legacy single-point entries. */
function isSegment(
x: AddedCrossingSegment | { lat?: number; lng?: number },
): x is AddedCrossingSegment {
/** True if the segment has valid start/end with numeric lng/lat. Reused for DB reads, imports, and runtime guards. */
export function isValidSegment(x: unknown): x is AddedCrossingSegment {
const seg = x as AddedCrossingSegment;
return (
x != null &&
typeof x === "object" &&
"start" in x &&
"end" in x &&
typeof (x as AddedCrossingSegment).start?.lat === "number" &&
typeof (x as AddedCrossingSegment).end?.lat === "number"
typeof seg.start?.lat === "number" &&
typeof seg.start?.lng === "number" &&
typeof seg.end?.lat === "number" &&
typeof seg.end?.lng === "number"
);
}

export async function getOverrides(): Promise<RegionOverrides> {
export async function getOverrides(): Promise<ManualOverrides> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const req = store.get(GLOBAL_KEY);
const req = store.get(OVERRIDES_RECORD_ID);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const raw = req.result as (ManualOverrides & { id?: string }) | undefined;
db.close();
const raw = req.result as
| (RegionOverrides & { regionKey?: string })
| undefined;
const list = raw?.addedCrossings ?? DEFAULT_OVERRIDES.addedCrossings;
const addedCrossings = (
Array.isArray(list) ? list.filter(isSegment) : []
Array.isArray(list) ? list.filter(isValidSegment) : []
).map((seg) => (seg.id ? seg : { ...seg, id: crypto.randomUUID() }));
const data = raw
? {
Expand All @@ -73,15 +83,14 @@ export async function getOverrides(): Promise<RegionOverrides> {
});
}

export async function saveOverrides(data: RegionOverrides): Promise<void> {
export async function saveOverrides(data: ManualOverrides): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
// Plain object so IndexedDB structured-clone succeeds (no Svelte proxies)
const record = JSON.parse(
JSON.stringify({
regionKey: GLOBAL_KEY,
id: OVERRIDES_RECORD_ID,
version: data.version,
addedCrossings: data.addedCrossings,
}),
Expand All @@ -95,13 +104,14 @@ export async function saveOverrides(data: RegionOverrides): Promise<void> {
});
}

/** Returns segments whose midpoint is inside the boundary's bbox. */
export function filterSegmentsInRegion(
/** Returns segments whose midpoint is inside the boundary's bbox (e.g. loaded map area). Skips invalid segments. */
export function filterSegmentsInBoundary(
segments: AddedCrossingSegment[],
boundaryGeoJson: GeoJSON,
): AddedCrossingSegment[] {
const [minLng, minLat, maxLng, maxLat] = bbox(boundaryGeoJson);
return segments.filter((seg) => {
if (!isValidSegment(seg)) return false;
const midLng = (seg.start.lng + seg.end.lng) / 2;
const midLat = (seg.start.lat + seg.end.lat) / 2;
return (
Expand All @@ -112,17 +122,3 @@ export function filterSegmentsInRegion(
);
});
}

export async function deleteOverrides(): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
const req = store.delete(GLOBAL_KEY);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
db.close();
resolve();
};
});
}
Loading