Skip to content
Draft
Show file tree
Hide file tree
Changes from 84 commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
163e0e7
Add heading to route segments and ensure they're in order
Aug 21, 2022
61fa26b
JS sketch of turn by turn directions (not yet using backend calculate…
Aug 21, 2022
48dbb3a
Make sure line segments along a route are all oriented correctly
Aug 24, 2022
2439727
Finish draft of turn by turn directions
Aug 25, 2022
c1914f3
Store start and end addresses in URL
wrangtangle Oct 15, 2025
50450f3
Merge branch 'master' into turn-by-turn-contd
wrangtangle Oct 15, 2025
5c10e2a
Show turn-by-turn directions on Home
wrangtangle Oct 17, 2025
44ce3a9
sketch of labeling alleys
wrangtangle Oct 17, 2025
09df30c
preserve query params when url is rewritten
wrangtangle Oct 17, 2025
f11d8e3
lowercase cardinal directions
wrangtangle Oct 17, 2025
7d82457
add route colors and ?debug=true mode
wrangtangle Oct 17, 2025
68e1475
break up segments in debug info, use miles/feet
wrangtangle Oct 17, 2025
0a4e223
collapse slight turns
wrangtangle Oct 17, 2025
b193295
click instructions to highlight ways
wrangtangle Oct 17, 2025
f801616
settings menu
wrangtangle Oct 24, 2025
7589e25
Move handler
wrangtangle Oct 24, 2025
9c21ddc
Describe unnamed streets
wrangtangle Nov 10, 2025
dea9e05
Logic for combining items with same names should respect new "effecti…
wrangtangle Nov 10, 2025
03260d1
Specify when unnamed streets are inside parks
wrangtangle Nov 10, 2025
077ecd8
Don't collapse slight turns for unnamed streets
wrangtangle Nov 10, 2025
a10b02d
Allow saved locations in URLs
wrangtangle Nov 11, 2025
0222e6a
Merge branch 'settings-menu' into turn-by-turn-contd
wrangtangle Nov 12, 2025
6fe92a1
Position turn-by-turn directions in sidebar on desktop
wrangtangle Nov 12, 2025
4861325
Remove L/R padding for directions in sidebar
wrangtangle Nov 12, 2025
424589d
Revert "sketch of labeling alleys"
wrangtangle Nov 12, 2025
a1d09b3
Allow unselecting a focused direction
wrangtangle Nov 13, 2025
cb7b5f8
Fix bug: can click focused segments to see tooltip
wrangtangle Nov 13, 2025
2a7b062
Don't add "Park" to park names that already contain it
wrangtangle Nov 13, 2025
8741d33
Just humanize park names once
wrangtangle Nov 13, 2025
cf37ad9
Describe access roads
wrangtangle Nov 13, 2025
b9c693e
Convert directions code from js to python
wrangtangle Nov 13, 2025
ef1958f
Remove verbose LLM comments and extract method
wrangtangle Nov 13, 2025
644e800
Extract functions and clean up
wrangtangle Nov 13, 2025
3690c32
Add tests for merging segments into single direction
wrangtangle Nov 13, 2025
bcc2099
Test merging directions
wrangtangle Nov 13, 2025
cf26418
Simplify logic
wrangtangle Nov 13, 2025
e4f5e90
Test heading_to_english_maneuver
wrangtangle Nov 13, 2025
d29b365
Remove arg
wrangtangle Nov 13, 2025
c566fd4
Variable naming
wrangtangle Nov 13, 2025
27b1e56
Refactor
wrangtangle Nov 13, 2025
b904088
Refactor
wrangtangle Nov 13, 2025
f4f3554
Remove unnecessary command
wrangtangle Nov 14, 2025
5c32195
Fuzz test turn by turn directions
wrangtangle Nov 14, 2025
6af1670
Couple updates to describing unnameds based on fuzz testing
wrangtangle Nov 14, 2025
0be802d
More fuzzing
wrangtangle Nov 14, 2025
269c89d
Comment
wrangtangle Nov 14, 2025
a1bdb50
CSS naming
wrangtangle Nov 18, 2025
8cf16d0
CSS naming
wrangtangle Nov 18, 2025
6c159a7
Merge branch 'settings-menu' of github.com-wrangtangle:wrangtangle/me…
wrangtangle Nov 18, 2025
c178a2c
Stray comment
wrangtangle Nov 18, 2025
d39623e
Restore search/legend toggle buttons on mobile only
wrangtangle Nov 18, 2025
f6f0bd4
Merge branch 'master' into turn-by-turn-contd
wrangtangle Dec 2, 2025
73da066
Merge branch 'settings-menu' into turn-by-turn-contd
wrangtangle Dec 3, 2025
83cd3ff
Merge branch 'master' into turn-by-turn-contd
wrangtangle Dec 3, 2025
1faeb05
Fix merge issue with addresses in URLs
wrangtangle Dec 3, 2025
e07f8f5
Show TBT directions in half-height panel on mobile
wrangtangle Jan 1, 2026
ff5f151
Buttons to hide/show TBT directions
wrangtangle Jan 1, 2026
bf4b835
Update app.js
wrangtangle Jan 1, 2026
f37ca4d
Update style of button to show tbt directions
wrangtangle Jan 1, 2026
7653fbb
Tweak directions container on mobile
wrangtangle Jan 1, 2026
12b0688
More compact directions list on mobile
wrangtangle Jan 1, 2026
d730b24
Improve X button to close mobile tbt directions
wrangtangle Jan 2, 2026
4bd0705
Button to toggle tbt directions window size
wrangtangle Jan 2, 2026
720d9ee
Match styling with header buttons
wrangtangle Jan 2, 2026
2bed813
Padding
wrangtangle Jan 3, 2026
3b6b69d
Move turn by turn directions button to top right
wrangtangle Jan 27, 2026
4f9365d
Merge branch 'master' into turn-by-turn-contd
wrangtangle Jan 27, 2026
b90151a
Alias List[GeoJSONFeature] as Route
wrangtangle Jan 27, 2026
5c7da73
Clean up comments
wrangtangle Jan 27, 2026
40bb90d
Extract module
wrangtangle Jan 27, 2026
ce5e7e0
Remove debug mode
wrangtangle Jan 27, 2026
979136e
Generate direction text on backend
wrangtangle Jan 27, 2026
0d518d7
management command to generate directions list
wrangtangle Jan 28, 2026
a584440
Move functions
wrangtangle Jan 28, 2026
e6fe0e4
Add tests
wrangtangle Jan 28, 2026
b05977b
Remove unused API endpoint
wrangtangle Feb 1, 2026
5613eff
Update naming
wrangtangle Feb 2, 2026
d734b8e
Move and test get_nearest_vertex_id
wrangtangle Feb 2, 2026
3fd2b60
Error handling
wrangtangle Feb 2, 2026
6ee9430
Update generate_directions.py
wrangtangle Feb 2, 2026
4464485
Un-expand TBT modal when clicking a direction
wrangtangle Feb 3, 2026
c41244d
Update centering of clicked directions for mobile, and remove highlig…
wrangtangle Feb 3, 2026
a0df75f
Switch displayed site name to "MBM" on small viewports
wrangtangle Feb 3, 2026
98f9c7a
Fix bumpiness in highlighted segments
wrangtangle Feb 3, 2026
296faf3
Move chicago_parks.geojson
wrangtangle Mar 4, 2026
87da3c0
Add note on source of park boundaries geojson
wrangtangle Mar 4, 2026
328230a
Remove unused variable
wrangtangle Mar 7, 2026
dd8861c
Preserve parentheticals in park names
wrangtangle Mar 18, 2026
70e8e10
Merge branch 'master' into turn-by-turn-contd
wrangtangle Mar 18, 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
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.PHONY: all
all: db/import/mellowroute.fixture db/import/chicago.table
all: db/import/mellowroute.fixture db/import/chicago.table db/import/chicago_parks.table

db/import/%.fixture: app/mbm/fixtures/%.json
(cd app && python manage.py loaddata $*) && touch $@
Expand All @@ -14,6 +14,12 @@ db/import/chicago.table: db/raw/chicago-filtered.osm
AND osm_ways.tags @> 'oneway:bicycle => no'" && \
touch $@

db/import/chicago_parks.table: db/chicago_parks.geojson
ogr2ogr -f "PostgreSQL" PG:"dbname=mbm user=postgres password=postgres host=postgres" \
$< -nln chicago_parks -overwrite -lco GEOMETRY_NAME=wkb_geometry && \
PGPASSWORD=postgres psql -U postgres -h postgres -d mbm -f /app/db/label-parks.sql && \
touch $@


db/raw/chicago-filtered.osm: db/raw/chicago.osm
osmconvert $< --drop-author --drop-version --out-osm -o="$@"
Expand Down
17 changes: 16 additions & 1 deletion README.md

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Nitpick, optional] I think we may need to add import instructions for loading parks, right?

Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ The app will be available on http://localhost:8000.

### Testing

To run backend tests:
#### Automated

To run the Python test suite:

```
docker compose run --rm app sh -c "python -m pytest /app/tests"
Expand All @@ -51,6 +53,19 @@ To run frontend tests:
docker compose run --rm app sh -c "npm test"
```

#### Manual

To generate directions between two coordinate pairs on the command line:

docker compose run --rm app sh -c "python manage.py generate_directions --sourceCoordinates 'x, y' --targetCoordinates 'x, y'"

To fuzz turn-by-turn directions (to find routes that don't work, tag sets that look iffy for bikes, and streets that show up as unnamed in directions):

```
docker compose run --rm app sh -c "python /app/scripts/fuzz_directions.py --runs 5"
```


## Mapping

To begin mapping, make sure you've created an admin user with
Expand Down
245 changes: 245 additions & 0 deletions app/mbm/directions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
from typing import TypedDict, Dict, List, Optional, Any
from mbm.types import Route, RouteProperties

class DirectionSegment(TypedDict):
gid: Optional[int]
osmData: Dict[str, Any]
maneuver: str
cardinal: str
distance: float
name: Optional[str]
effectiveName: str
featureIndex: int

class Direction(TypedDict):
directionSegments: List[DirectionSegment]
name: Optional[str]
effectiveName: str
distance: float
maneuver: str
heading: float
cardinal: str
type: Optional[str]
osmData: Dict[str, Any]
featureIndices: List[int]
directionText: str

def directions_list(features: Route) -> List[Direction]:
directions: List[Direction] = []
previous_heading = None
previous_effective_name = None

for i, feature in enumerate(features):
props: RouteProperties = feature['properties']
name = props.get('name')
heading: float = props.get('heading', 0.0) or 0.0
distance: float = props.get('distance', 0)
route_type: Optional[str] = props.get('type')
Comment thread
wrangtangle marked this conversation as resolved.
Outdated
osm_tags: Optional[Dict[str, str]] = props.get('osm_tags')
park_name: Optional[str] = props.get('park_name')

osm_data = {
'gid': props.get('gid'),
'osm_id': props.get('osm_id'),
'tag_id': props.get('tag_id'),
'oneway': props.get('oneway'),
'rule': props.get('rule'),
'priority': props.get('priority'),
'maxspeed_forward': props.get('maxspeed_forward'),
'maxspeed_backward': props.get('maxspeed_backward'),
'length_m': distance,
'osm_tags': osm_tags,
'park_name': park_name,
}

maneuver_info = _heading_to_english_maneuver(heading, previous_heading)
maneuver = maneuver_info['maneuver']
cardinal = maneuver_info['cardinal']

effective_name = name or _describe_unnamed_street(osm_tags, park_name)

direction_segment: DirectionSegment = {
'gid': props.get('gid'),
'osmData': osm_data,
'maneuver': maneuver,
'cardinal': cardinal,
'distance': distance,
'name': name,
'effectiveName': effective_name,
'featureIndex': i,
}

direction: Direction = {
'directionSegments': [direction_segment],
'name': name,
'effectiveName': effective_name,
'distance': distance,
'maneuver': maneuver,
'heading': heading,
'cardinal': cardinal,
'type': props.get('type'),
'osmData': osm_data,
'featureIndices': [i],
'directionText': '',
}

if _should_merge_segments(maneuver, effective_name, previous_effective_name, name):
_merge_with_previous_direction(directions, direction_segment)
else:
directions.append(direction)

previous_heading = heading
previous_effective_name = effective_name

for index, direction in enumerate(directions):
street_name = direction.get('effectiveName') or direction.get('name') or 'an unknown street'
distance_text = _format_distance(direction['distance'])
if index == 0:
direction_text = f"Head {direction['cardinal']} on {street_name} for {distance_text}"
else:
direction_text = (
f"{direction['maneuver']} onto {street_name} and head "
f"{direction['cardinal']} for {distance_text}"
)

if index == len(directions) - 1:
direction_text += ' until you reach your destination'

direction['directionText'] = direction_text

return directions

def _nearest_45(x: float) -> int:
"""Round an angle to the nearest 45-degree increment (0-360)."""
return (round(x / 45) * 45) % 360

def _format_distance(meters: float) -> str:
meters_per_mile = 1609.344
meters_per_foot = 0.3048
miles = meters / meters_per_mile
def round_half_up(value: float) -> int:
return int(value + 0.5)

if miles < 0.09:
feet = round_half_up(meters / meters_per_foot)
unit = 'foot' if feet == 1 else 'feet'
return f"{feet} {unit}"

rounded_miles = round_half_up(miles * 10) / 10
unit = 'mile' if rounded_miles == 1 else 'miles'
return f"{rounded_miles} {unit}"
Comment on lines +115 to +129

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Question, non-blocking] This seems like a dupe of mbm.routing._format_distance(), albeit with slightly different logic. Do they really need to be two different functions? If so, is there a way we can distinguish their function names to make the difference clearer?


# Relevant docs:
# https://wiki.openstreetmap.org/wiki/Key:highway
# https://wiki.openstreetmap.org/wiki/Tag:highway
def _describe_unnamed_street(
osm_tags: Optional[Dict[str, str]],
park_name: Optional[str]
) -> str:
if not osm_tags or not isinstance(osm_tags, dict):
return 'an unknown street'

description = ''

if osm_tags.get('highway') == 'service' and osm_tags.get('service') == 'alley':
description = 'an alley'
elif osm_tags.get('highway') == 'service':
description = 'an access road'
elif osm_tags.get('footway') == 'crossing':
description = 'a crosswalk'
elif osm_tags.get('highway') == 'cycleway':
description = 'a bike path'
elif osm_tags.get('bicycle') == 'designated':
description = 'a bike path'
elif osm_tags.get('highway') == 'pedestrian':
description = 'a pedestrian path'
elif osm_tags.get('highway') == 'footway' or osm_tags.get('footway') == 'sidewalk':
description = 'a sidewalk'
elif osm_tags.get('highway') == 'pedestrian' and osm_tags.get('bicycle') == 'yes':
description = 'a mixed-use path'
Comment on lines +157 to +158

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Nitpick, non-blocking] This conditional is a no-op since we already check highway == 'pedestrian' on line 154 above. Not a huge deal, but if we want to preserve this condition, we should move it above the check on line 154 so it takes priority.

elif osm_tags.get('highway') == 'path' and osm_tags.get('bicycle') == 'permissive':
description = 'a mixed-use path'
elif park_name:
description = 'a path'
else:
description = 'an unknown street'

if park_name:
description += f' inside {park_name}'

return description


def _heading_to_english_maneuver(
heading: float,
previous_heading: Optional[float]
) -> Dict[str, str]:
degrees = {
0: {'maneuver': 'Continue', 'cardinal': 'north'},
45: {'maneuver': 'Turn slightly to the right', 'cardinal': 'northeast'},
90: {'maneuver': 'Turn right', 'cardinal': 'east'},
135: {'maneuver': 'Take a sharp right turn', 'cardinal': 'southeast'},
180: {'maneuver': 'Turn around', 'cardinal': 'south'},
225: {'maneuver': 'Take a sharp left turn', 'cardinal': 'southwest'},
270: {'maneuver': 'Turn left', 'cardinal': 'west'},
315: {'maneuver': 'Turn slightly to the left', 'cardinal': 'northwest'},
}

if previous_heading is not None:
angle = _nearest_45(((heading - previous_heading) + 360) % 360)
maneuver = degrees.get(angle, {}).get('maneuver', 'Continue')
else:
maneuver = 'Continue'

cardinal = degrees.get(_nearest_45(heading), {}).get('cardinal', 'north')

return {'maneuver': maneuver, 'cardinal': cardinal}


def _should_merge_segments(
maneuver: str,
new_segment_effective_name: str,
previous_segment_effective_name: Optional[str],
name: Optional[str],
) -> bool:
# Merge continues and slight turns when two segments have the same street name
is_slight_turn = (maneuver == 'Turn slightly to the left' or
maneuver == 'Turn slightly to the right' or maneuver == 'Continue')
is_named_street = bool(name)
same_named_street = new_segment_effective_name == previous_segment_effective_name
if is_slight_turn and is_named_street and same_named_street:
return True

# Merge continues on unnamed streets
if maneuver == 'Continue' and not is_named_street:
return True
Comment on lines +212 to +214

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

[Question, non-blocking] This is always going to be true for the first segment whenever it is not a named street. Does that pose a problem for us? I notice the only similar test case has a named street as well (test_first_segment_no_previous_heading_should_not_merge). Might be worth testing the unnamed case to make sure the output is what we expect.


return False


def _merge_with_previous_direction(
directions: List[Direction],
direction_segment: DirectionSegment,
) -> None:
if not directions:
return

previous_direction = directions[-1]
previous_direction['distance'] += direction_segment['distance']
previous_direction['directionSegments'].append(direction_segment)
previous_direction['featureIndices'].append(direction_segment['featureIndex'])
name = direction_segment.get('name')
effective_name = direction_segment['effectiveName']
osm_data = direction_segment['osmData']

# Sometimes only some chicago_ways of a street are named, so check if the
# previous chicago_way is named and backfill the name if not
if name and not previous_direction['name']:
previous_direction['name'] = name
previous_direction['effectiveName'] = effective_name

# For unnamed streets, preserve OSM data from the current chicago_way if previous doesn't have a name
if not name and osm_data and not previous_direction['name']:
previous_direction['osmData'] = osm_data
previous_direction['effectiveName'] = effective_name

Loading