-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
1458 lines (1262 loc) · 61.9 KB
/
Copy pathapp.py
File metadata and controls
1458 lines (1262 loc) · 61.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Dash Navigation — a single-file Dash app.
uv run app.py # then open http://localhost:8051 (PORT= overrides)
Everything is self-computed from free OpenStreetMap data: the street basemap
is rendered to raster tiles on the fly (Pillow), and driving directions come
from our own A* over the OSM road graph. No Google/Mapbox, no API keys.
Scoped to a radius around a center point, both configurable via env vars
(defaults to a 15-mile radius around Madison, WI):
LAT=43.0747 LON=-89.3844 RADIUS_MILES=15 uv run app.py
On first run it looks for a saved street graph next to this file; if none
exists it downloads one from OSM for the configured area (one time, ~1 min).
If you change LAT/LON/RADIUS_MILES, delete the saved graph file first so it
re-downloads for the new area -- otherwise the old area's graph gets reused.
"""
from __future__ import annotations
import heapq
import io
import math
import os
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor
import mapbox_vector_tile
import mercantile
import numpy as np
import osmnx as ox
from flask import Response
from PIL import Image, ImageDraw
from shapely.geometry import LineString, MultiLineString, box
from shapely.strtree import STRtree
import dash_leaflet as dl
from dash import Dash, Input, Output, State, ctx, dcc, html, no_update
# --------------------------------------------------------------------------
# Configuration
# --------------------------------------------------------------------------
HERE = os.path.dirname(os.path.abspath(__file__))
def _load_dotenv(path):
"""Minimal .env loader (KEY=VALUE per line) so config/secrets can live in
a .env file instead of real shell env vars. Doesn't override a value
that's already set in the environment."""
if not os.path.exists(path):
return
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip("'\""))
_load_dotenv(os.path.join(HERE, ".env"))
# Center point + coverage radius, overridable via env vars so this app isn't
# tied to any one city. Defaults to Madison, WI (State Capitol), 15 miles.
CENTER = (
float(os.environ.get("LAT", 43.0747)),
float(os.environ.get("LON", -89.3844)),
)
RADIUS_MILES = float(os.environ.get("RADIUS_MILES", 15))
RADIUS_METERS = int(RADIUS_MILES * 1609.34)
GRAPH_CANDIDATES = [
os.path.join(HERE, "drive_network.graphml"),
os.path.join(HERE, "data", "drive_network.graphml"),
]
# Optional live traffic: set TOMTOM_API_KEY (env var or .env) to enable.
# Without it, every traffic feature quietly disables itself -- see the
# "Live traffic" section below for the full story.
TOMTOM_API_KEY = os.environ.get("TOMTOM_API_KEY", "").strip()
TRAFFIC_ENABLED = bool(TOMTOM_API_KEY)
# Road classes big enough to plausibly have live-traffic coverage: these are
# the edges we match against TomTom's segments, color in the traffic
# overlay, and track chain topology for (Router's gap-fill interpolation).
MAJOR_HIGHWAYS = {
"motorway", "motorway_link", "trunk", "trunk_link",
"primary", "primary_link", "secondary", "secondary_link",
}
TILE_OUT = 256 # served tile size (px)
SUPER = 2 # supersample factor for anti-aliasing
IMG = TILE_OUT * SUPER
MAX_NATIVE_ZOOM = 15 # tiles carry full detail by here; Leaflet overzooms past
BG_RGB = (15, 20, 28) # deep slate basemap (#0f141c)
# Bump when the tile palette/rendering changes so browsers re-fetch (tiles are
# cached for a day and the {z}/{x}/{y} URL alone wouldn't change).
TILE_STYLE_VERSION = 2
# Per road class: (min zoom to draw, base width px @ z12, RGB color, draw order).
# Higher draw order is painted last (on top). Dark theme: cool blue-grays that
# brighten with road importance, kept desaturated so the cyan route is the only
# saturated thing on the map.
ROAD_STYLE = {
"motorway": (0, 4.2, (128, 139, 160), 6),
"motorway_link": (0, 2.6, (112, 123, 144), 6),
"trunk": (0, 3.6, (120, 131, 152), 5),
"trunk_link": (0, 2.3, (106, 117, 137), 5),
"primary": (0, 3.0, (96, 108, 129), 4),
"primary_link": (0, 2.0, (89, 100, 120), 4),
"secondary": (10, 2.4, (75, 86, 105), 3),
"secondary_link": (10, 1.6, (71, 82, 100), 3),
"tertiary": (11, 2.0, (61, 71, 88), 2),
"tertiary_link": (11, 1.4, (59, 69, 85), 2),
"residential": (12, 1.6, (49, 58, 73), 1),
"unclassified": (12, 1.6, (49, 58, 73), 1),
"living_street": (12, 1.4, (47, 56, 70), 1),
"service": (13, 1.1, (43, 51, 64), 0),
"road": (13, 1.4, (49, 58, 73), 1),
}
CACHE_MAX_TILES = 8192
def _first(v):
return v[0] if isinstance(v, list) else v
def _rgb_css(rgb):
return f"rgb({rgb[0]},{rgb[1]},{rgb[2]})"
# Maneuver -> (glyph, rotation degrees). A single up-arrow rotated keeps the
# turn iconography perfectly consistent; depart/arrive get their own marks.
MANEUVER_ICON = {
"depart": ("●", 0),
"straight": ("↑", 0),
"slight-right": ("↑", 45),
"right": ("↑", 90),
"slight-left": ("↑", -45),
"left": ("↑", -90),
"uturn": ("↩", 0),
"arrive": ("◉", 0),
}
# Inline gradient pin logo (data URI -> fully self-contained, crisp at any DPI).
_LOGO_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">'
'<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
'<stop offset="0" stop-color="#38bdf8"/><stop offset="1" stop-color="#34d399"/>'
'</linearGradient></defs>'
'<rect width="30" height="30" rx="9" fill="url(#g)"/>'
'<path d="M15 6.6c-3.1 0-5.6 2.5-5.6 5.6 0 4.2 5.6 9.8 5.6 9.8s5.6-5.6 5.6-9.8'
'c0-3.1-2.5-5.6-5.6-5.6zm0 7.6a2 2 0 110-4.1 2 2 0 010 4.1z" fill="#0f141c"/>'
'</svg>'
)
LOGO_URI = "data:image/svg+xml," + urllib.parse.quote(_LOGO_SVG)
# --------------------------------------------------------------------------
# Data: load the street graph, downloading from OSM if we have none.
# --------------------------------------------------------------------------
def load_graph():
for path in GRAPH_CANDIDATES:
if os.path.exists(path):
print(f"Loading street graph: {path}")
return ox.load_graphml(path)
dest = GRAPH_CANDIDATES[0]
print(f"No saved graph found. Downloading {RADIUS_MILES}-mi network around "
f"{CENTER} from OpenStreetMap (one time)…")
G = ox.graph_from_point(CENTER, dist=RADIUS_METERS, network_type="drive", simplify=True)
G = ox.add_edge_speeds(G)
G = ox.add_edge_travel_times(G)
ox.save_graphml(G, dest)
print(f"Saved graph to {dest}")
return G
# --------------------------------------------------------------------------
# Routing: vectorized nearest-node, our own A*, and turn-by-turn.
# --------------------------------------------------------------------------
def haversine(lat1, lon1, lat2, lon2):
R = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlmb = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def bearing(lat1, lon1, lat2, lon2):
p1, p2 = math.radians(lat1), math.radians(lat2)
dl_ = math.radians(lon2 - lon1)
y = math.sin(dl_) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl_)
return (math.degrees(math.atan2(y, x)) + 360) % 360
class Router:
def __init__(self, G):
self.G = G
self._x = {n: d["x"] for n, d in G.nodes(data=True)}
self._y = {n: d["y"] for n, d in G.nodes(data=True)}
self._ids = np.fromiter(self._x.keys(), dtype=np.int64)
self._lon = np.fromiter((self._x[i] for i in self._ids), dtype=np.float64)
self._lat = np.fromiter((self._y[i] for i in self._ids), dtype=np.float64)
# Major-road edges, precomputed once so a route request only has to
# filter this list by bounding box (not scan the graph) before
# asking for fresh live speeds nearby. Keeps each edge's full
# geometry, not just a midpoint, so traffic matching can measure
# distance to the whole edge (see TileTraffic.ratio_for for why).
self._major_edges = []
# Chain topology among major edges only, used to fill small gaps in
# live data (see get_effective_flow): for each major edge, the
# single successor/predecessor edge (if any) continuing the same
# road. Interchanges routinely put more than one major-edge
# continuation at a node (the mainline plus an exit ramp) --
# pick_continuation resolves that by preferring whichever candidate
# shares this edge's road name (then its road class), and only
# gives up (None) if it's still genuinely tied, rather than
# guessing which branch is "really" the same corridor.
self._major_geom = {}
self._major_len_m = {} # edge_id -> length in meters (from OSM)
self._major_attrs = {} # edge_id -> (name, highway)
out_by_node = {}
in_by_node = {}
top_kph = 120.0
for u, v, k, data in G.edges(keys=True, data=True):
kph = _first(data.get("speed_kph"))
if kph is not None:
try:
top_kph = max(top_kph, float(kph))
except (TypeError, ValueError):
pass
hw = _first(data.get("highway"))
if hw not in MAJOR_HIGHWAYS:
continue
geom = data.get("geometry")
if geom is None:
geom = LineString([(self._x[u], self._y[u]), (self._x[v], self._y[v])])
edge_id = (u, v, k)
name = _first(data.get("name"))
mid = geom.interpolate(0.5, normalized=True)
self._major_edges.append((edge_id, mid.y, mid.x, geom))
self._major_geom[edge_id] = geom
self._major_len_m[edge_id] = float(data["length"])
self._major_attrs[edge_id] = (name, hw)
out_by_node.setdefault(u, []).append(edge_id)
in_by_node.setdefault(v, []).append(edge_id)
# Straight-line speed for the A* heuristic: the fastest road the
# graph itself contains (floored at 120 km/h in case speed data is
# missing entirely). h() divides crow-flies distance by this, so it
# can never overestimate real travel time -- the heuristic stays
# admissible and A* stays exact. Doubles as the fallback speed for
# any edge with neither live traffic nor an OSM-inferred time.
self.max_mps = top_kph * 1000 / 3600
def pick_continuation(edge_id, candidates):
if len(candidates) <= 1:
return candidates[0] if candidates else None
name, hw = self._major_attrs[edge_id]
if name:
same_name = [c for c in candidates if self._major_attrs[c][0] == name]
if len(same_name) == 1:
return same_name[0]
if same_name:
candidates = same_name
same_class = [c for c in candidates if self._major_attrs[c][1] == hw]
return same_class[0] if len(same_class) == 1 else None
self._succ = {}
self._pred = {}
for edge_id in self._major_geom:
u, v, k = edge_id
nxt = [e for e in out_by_node.get(v, []) if e != edge_id]
prv = [e for e in in_by_node.get(u, []) if e != edge_id]
self._succ[edge_id] = pick_continuation(edge_id, nxt)
self._pred[edge_id] = pick_continuation(edge_id, prv)
# Only fill gaps in live data up to this total length -- big enough to
# bridge a short stretch with no matching TomTom segment (a common real
# gap right around complex interchanges), small enough that we're not
# inventing conditions for a stretch we genuinely have no signal about.
EXTRAPOLATE_MAX_GAP_M = 400
def _walk_for_ratio(self, edge_id, forward, max_dist_m):
"""Follow the unambiguous chain of major edges from edge_id looking
for one with a fresh direct TomTom match, up to max_dist_m total.
Returns (ratio, distance_m) for the first one found, else None."""
chain = self._succ if forward else self._pred
seen = {edge_id}
dist_m = 0.0
current = edge_id
while True:
nxt = chain.get(current)
if nxt is None or nxt in seen:
return None
seen.add(nxt)
dist_m += self._major_len_m[nxt]
if dist_m > max_dist_m:
return None
flow = get_cached_flow(nxt)
if flow is not None and flow.get("ratio") is not None:
return flow["ratio"], dist_m
current = nxt
def get_effective_flow(self, edge_id):
"""A live-traffic ratio for this edge: a direct TomTom match if we
have one, else one interpolated from the nearest matched edge(s)
along an unambiguous chain of major edges, as long as the gap being
bridged is small (EXTRAPOLATE_MAX_GAP_M). Returns None if neither is
available -- callers fall back to the static OSM speed / no color."""
if not TRAFFIC_ENABLED:
return None
flow = get_cached_flow(edge_id)
if flow is not None and flow.get("ratio") is not None:
return flow["ratio"]
if edge_id not in self._major_geom:
return None
back = self._walk_for_ratio(edge_id, forward=False, max_dist_m=self.EXTRAPOLATE_MAX_GAP_M)
fwd = self._walk_for_ratio(edge_id, forward=True, max_dist_m=self.EXTRAPOLATE_MAX_GAP_M)
if back and fwd:
(r_back, d_back), (r_fwd, d_fwd) = back, fwd
total = d_back + d_fwd
return r_back + (r_fwd - r_back) * (d_back / total) if total > 0 else (r_back + r_fwd) / 2
if back:
return back[0]
if fwd:
return fwd[0]
return None
def refresh_traffic_near(self, lat1, lon1, lat2, lon2):
"""Ask for fresh live speeds on major roads between two points
(padded a bit), so routing/ETA can use real conditions. No-ops
entirely if traffic isn't configured."""
if not TRAFFIC_ENABLED:
return
south, north = sorted((lat1, lat2))
west, east = sorted((lon1, lon2))
pad_lat = max((north - south) * 0.25, 0.01)
pad_lon = max((east - west) * 0.25, 0.01)
south, north = south - pad_lat, north + pad_lat
west, east = west - pad_lon, east + pad_lon
tile_coords = bbox_tile_coords(south, west, north, east)
near = [
(edge_id, lat, lon, geom)
for edge_id, lat, lon, geom in self._major_edges
if south <= lat <= north and west <= lon <= east
]
if len(tile_coords) > TRAFFIC_ROUTE_TILE_BUDGET:
# A cross-area trip's bounding box can cover many hundreds of
# flow tiles at BBOX_FETCH_ZOOM -- a multi-second fetch, mostly
# for conditions nowhere near the trip. Keep the budget's worth
# of tiles nearest the straight start->end line (real routes
# overwhelmingly stay near that corridor), and shrink the edge
# list to the kept tiles so edges we fetched no data for aren't
# recorded as "confirmed no match" (see store_edge_matches).
line = LineString([(lon1, lat1), (lon2, lat2)])
tile_coords.sort(key=lambda tc: box(*tile_bounds(*tc)).distance(line))
tile_coords = tile_coords[:TRAFFIC_ROUTE_TILE_BUDGET]
kept = {(x, y) for _, x, y in tile_coords}
def in_kept(lat, lon):
t = mercantile.tile(lon, lat, BBOX_FETCH_ZOOM)
return (t.x, t.y) in kept
near = [e for e in near if in_kept(e[1], e[2])]
refresh_traffic_edges([(edge_id, geom) for edge_id, _, _, geom in near],
tile_coords)
def _edge_time(self, u, v, k, edata):
"""Travel time for one edge: live traffic speed if we have a fresh
direct or gap-filled reading for it, else the static OSM-inferred
speed."""
static_time = edata.get("travel_time", edata["length"] / self.max_mps)
ratio = self.get_effective_flow((u, v, k))
if ratio is not None:
# traffic_level is current speed as a fraction of free-flow
# speed, so this road is currently taking 1/ratio times as long
# as the OSM-inferred (free-flow) time. Clamped on both ends: a
# near-zero reading (standstill) would otherwise produce an
# absurd multiplier (and 0.0 exactly would divide by zero), and
# a reading above 1.0 (TomTom sometimes reports traffic moving
# a bit faster than free-flow) would make the edge faster than
# the static time the A* heuristic was sized against -- quietly
# breaking the heuristic's no-overestimate guarantee.
return static_time / min(max(ratio, 0.05), 1.0)
return static_time
def nearest_node(self, lat, lon):
clat = math.cos(math.radians(lat))
dx = (self._lon - lon) * clat
dy = self._lat - lat
return int(self._ids[int(np.argmin(dx * dx + dy * dy))])
def _astar(self, source, target):
G, x, y = self.G, self._x, self._y
tlat, tlon = y[target], x[target]
def h(n):
return haversine(y[n], x[n], tlat, tlon) / self.max_mps
open_heap = [(h(source), source)]
g_score = {source: 0.0}
came_from = {}
closed = set()
while open_heap:
_, u = heapq.heappop(open_heap)
if u == target:
path = [u]
while u in came_from:
u = came_from[u]
path.append(u)
return path[::-1]
if u in closed:
continue
closed.add(u)
for v, parallel in G[u].items():
if v in closed:
continue
# Parallel edges between u and v -> take the fastest, using
# live traffic speed where we have a fresh reading for it.
best = min(self._edge_time(u, v, k, ed) for k, ed in parallel.items())
tentative = g_score[u] + best
if tentative < g_score.get(v, math.inf):
came_from[v] = u
g_score[v] = tentative
heapq.heappush(open_heap, (tentative + h(v), v))
return None
def _edge_between(self, u, v):
"""(key, edge data dict) for the fastest edge between two adjacent
nodes, judged with live traffic where available -- the same
criterion A* used, so when parallel edges exist the reconstructed
route reports the edge A* actually traveled."""
edges = self.G[u][v]
k = min(edges, key=lambda k: self._edge_time(u, v, k, edges[k]))
return k, edges[k]
@staticmethod
def _edge_coords(u_pt, v_pt, edata):
geom = edata.get("geometry")
if geom is not None:
return [[x, y] for x, y in geom.coords]
return [list(u_pt), list(v_pt)]
@staticmethod
def _street_name(edata):
return _first(edata.get("name")) or "unnamed road"
@staticmethod
def _turn(angle):
if 30 < angle <= 150:
return "Turn right", "right"
if -150 <= angle < -30:
return "Turn left", "left"
if abs(angle) > 150:
return "Make a U-turn", "uturn"
if 10 < angle <= 30:
return "Slight right", "slight-right"
if -30 <= angle < -10:
return "Slight left", "slight-left"
return "Continue straight", "straight"
def route(self, from_lat, from_lon, to_lat, to_lon):
# Pull fresh live speeds for major roads near this trip so both the
# path A* picks and the ETA it reports reflect real conditions.
self.refresh_traffic_near(from_lat, from_lon, to_lat, to_lon)
src = self.nearest_node(from_lat, from_lon)
dst = self.nearest_node(to_lat, to_lon)
node_path = self._astar(src, dst)
if not node_path or len(node_path) < 2:
return None
legs = []
total_dist = total_time = 0.0
for u, v in zip(node_path[:-1], node_path[1:]):
k, edata = self._edge_between(u, v)
leg_time = self._edge_time(u, v, k, edata)
ec = self._edge_coords((self._x[u], self._y[u]), (self._x[v], self._y[v]), edata)
legs.append({
"coords": ec,
"name": self._street_name(edata),
"length": float(edata["length"]),
"time": leg_time,
})
total_dist += float(edata["length"])
total_time += leg_time
# Full path as [lat, lon] for the Leaflet polyline.
latlon = []
for i, leg in enumerate(legs):
pts = leg["coords"] if i == 0 else leg["coords"][1:]
latlon.extend([[c[1], c[0]] for c in pts])
# Group consecutive legs by street name into human steps.
steps = []
i = 0
while i < len(legs):
name = legs[i]["name"]
j = i
seg_dist = 0.0
while j < len(legs) and legs[j]["name"] == name:
seg_dist += legs[j]["length"]
j += 1
if not steps:
instruction, maneuver = f"Head out on {name}", "depart"
else:
prev = legs[i - 1]["coords"]
cur = legs[i]["coords"]
b_in = bearing(prev[-2][1], prev[-2][0], prev[-1][1], prev[-1][0])
b_out = bearing(cur[0][1], cur[0][0], cur[1][1], cur[1][0])
delta = ((b_out - b_in + 180) % 360) - 180
verb, maneuver = self._turn(delta)
instruction = f"{verb} onto {name}"
steps.append({"instruction": instruction, "maneuver": maneuver,
"name": name, "distance_m": round(seg_dist, 1)})
i = j
steps.append({"instruction": "Arrive at your destination",
"maneuver": "arrive", "name": "", "distance_m": 0.0})
return {
"coordinates": latlon,
"distance_mi": round(total_dist / 1609.34, 2),
"duration_min": round(total_time / 60, 1),
"steps": steps,
}
# --------------------------------------------------------------------------
# Raster tiles: draw the street network into 256px PNGs on demand.
# --------------------------------------------------------------------------
def lonlat_to_tile(lon, lat, z):
"""Fractional slippy-map tile coordinates at zoom z (standard web
mercator: x grows east, y grows south). The one place this projection
is written out -- everything else projects through here."""
n = 2 ** z
xt = (lon + 180.0) / 360.0 * n
lat_rad = math.radians(lat)
yt = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
return xt, yt
def tile_bounds(z, x, y):
"""(west, south, east, north) degrees covered by slippy-map tile z/x/y."""
n = 2 ** z
west = x / n * 360.0 - 180.0
east = (x + 1) / n * 360.0 - 180.0
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return west, south, east, north
def tile_projector(z, x, y):
"""lon/lat -> pixel coordinates within tile z/x/y's supersampled image."""
def project(lon, lat):
xt, yt = lonlat_to_tile(lon, lat, z)
return ((xt - x) * IMG, (yt - y) * IMG)
return project
def stroke_scale(z):
"""Zoom-dependent multiplier for road stroke widths: thinner zoomed out,
up to 3x the base width by deep zooms."""
return max(0.6, min(3.0, 0.6 + (z - 11) * 0.5))
class TileMaker:
# Extra margin (as a fraction of tile span) when querying edges for a
# tile, so a road just outside the tile still paints the part of its
# stroke width that spills in.
QUERY_BUFFER = 0.08
def __init__(self, G, router):
# The router is consulted when drawing the traffic overlay -- its
# get_effective_flow owns the gap-fill interpolation logic.
self.router = router
gdf = ox.graph_to_gdfs(G, nodes=False).reset_index()
# Keep u/v/key (the edge's identity in the graph) alongside geometry
# -- needed to look up live traffic for major-road features.
cols = [c for c in ("u", "v", "key", "geometry", "highway", "name") if c in gdf.columns]
self.edges = gdf[cols]
self.sindex = self.edges.sindex
self._cache: OrderedDict[tuple, bytes] = OrderedDict()
self._lock = threading.Lock()
self._traffic_cache: OrderedDict[tuple, bytes] = OrderedDict()
self._traffic_lock = threading.Lock()
@staticmethod
def _cached(cache, lock, key, render):
"""Tiny thread-safe LRU both tile pyramids share."""
with lock:
if key in cache:
cache.move_to_end(key)
return cache[key]
data = render()
with lock:
cache[key] = data
if len(cache) > CACHE_MAX_TILES:
cache.popitem(last=False)
return data
def tile(self, z, x, y):
return self._cached(self._cache, self._lock, (z, x, y),
lambda: self._render(z, x, y))
def _tile_edges(self, z, x, y):
"""Edges intersecting tile z/x/y grown by QUERY_BUFFER, plus the
buffered (west, south, east, north) box that was queried."""
west, south, east, north = tile_bounds(z, x, y)
bufx = (east - west) * self.QUERY_BUFFER
bufy = (north - south) * self.QUERY_BUFFER
bounds = (west - bufx, south - bufy, east + bufx, north + bufy)
hits = self.sindex.query(box(*bounds), predicate="intersects")
return self.edges.iloc[hits], bounds
def _render(self, z, x, y):
img = Image.new("RGBA", (IMG, IMG), (0, 0, 0, 0))
subset, _ = self._tile_edges(z, x, y)
if subset.empty:
return self._encode(img)
project = tile_projector(z, x, y)
scale = stroke_scale(z)
highways = subset["highway"] if "highway" in subset else [None] * len(subset)
# Collect drawable segments, then paint in draw-order so majors sit on top.
segments = []
for geom, highway in zip(subset.geometry, highways):
style = ROAD_STYLE.get(_first(highway))
if style is None:
continue
minz, base_w, color, order = style
if minz > z:
continue
width = max(1, round(base_w * scale * SUPER))
lines = geom.geoms if isinstance(geom, MultiLineString) else [geom]
for line in lines:
if not isinstance(line, LineString):
continue
pts = [project(px, py) for px, py in line.coords]
segments.append((order, width, color, pts))
draw = ImageDraw.Draw(img)
for order, width, color, pts in sorted(segments, key=lambda s: s[0]):
draw.line(pts, fill=color + (255,), width=width, joint="curve")
return self._encode(img)
@staticmethod
def _encode(img):
if IMG != TILE_OUT:
img = img.resize((TILE_OUT, TILE_OUT), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def traffic_tile(self, z, x, y):
"""Transparent-background PNG with our own colored lines for
major-road segments that have a fresh live-traffic match -- drawn
entirely from TomTom's Flow Vector Tiles (see the Live traffic
section below), never from a TomTom tile image. The cache key
includes the current minute, so a tile being viewed re-renders
with fresh data every ~60s."""
key = (z, x, y, int(time.time() // 60)) if TRAFFIC_ENABLED else (z, x, y)
return self._cached(self._traffic_cache, self._traffic_lock, key,
lambda: self._render_traffic(z, x, y))
def _render_traffic(self, z, x, y):
img = Image.new("RGBA", (IMG, IMG), (0, 0, 0, 0))
if not TRAFFIC_ENABLED:
return self._encode(img)
subset, (west, south, east, north) = self._tile_edges(z, x, y)
major = [
((row.u, row.v, row.key), row.geometry)
for row in subset.itertuples()
if _first(getattr(row, "highway", None)) in MAJOR_HIGHWAYS
]
if not major:
return self._encode(img)
# TomTom data covering this tile -- always fetched at the one fixed
# BBOX_FETCH_ZOOM rather than our render zoom z (see that constant
# for why), so an edge's match result is stable across zooms.
tiles_by_xy = get_traffic_for_bbox(south, west, north, east)
if not tiles_by_xy:
return self._encode(img)
# Pass 1: direct spatial match for every major-road edge in this
# tile, written to the shared edge cache -- so pass 2's gap-fill
# interpolation can see a matched neighbor even if this is the
# first time it's been looked at (e.g. from a route request, not a
# tile render).
store_edge_matches(match_edges_to_traffic(major, tiles_by_xy))
# Pass 2: draw. Direct match if we have one, else interpolated from
# the nearest matched edge(s) along the same road (see
# Router.get_effective_flow) for small gaps in coverage.
project = tile_projector(z, x, y)
draw = ImageDraw.Draw(img)
width = max(2, round(3.2 * stroke_scale(z) * SUPER))
for edge_id, geom in major:
ratio = self.router.get_effective_flow(edge_id)
if ratio is None:
continue
color = _traffic_color(ratio)
lines = geom.geoms if isinstance(geom, MultiLineString) else [geom]
for line in lines:
if not isinstance(line, LineString):
continue
pts = [project(px, py) for px, py in line.coords]
draw.line(pts, fill=color + (235,), width=width, joint="curve")
return self._encode(img)
def prewarm(self, min_zoom=11, max_zoom=13):
# z14 (the bulk of the pyramid) is left to render on demand and cache;
# a single tile is only ~30-80 ms cold. Pre-warming z11-13 covers the
# default z12 view and one zoom step each way.
west, south, east, north = self.edges.total_bounds
count = 0
for z in range(min_zoom, max_zoom + 1):
x0, y0 = (int(t) for t in lonlat_to_tile(west, north, z))
x1, y1 = (int(t) for t in lonlat_to_tile(east, south, z))
for xt in range(x0, x1 + 1):
for yt in range(y0, y1 + 1):
self.tile(z, xt, yt)
count += 1
return count
# --------------------------------------------------------------------------
# Live traffic (optional): pulled directly from TomTom's free-tier Traffic
# Flow VECTOR Tiles API (https://developer.tomtom.com/traffic-api/
# documentation/traffic-flow/vector-flow-tiles) -- real MVT vector tiles,
# decoded with the same mapbox_vector_tile library our own tiles use,
# carrying a `traffic_level` property per road segment (current speed as a
# fraction of that road's own free-flow speed). We never touch TomTom's
# pre-rendered tile IMAGES: every pixel of the traffic overlay
# (TileMaker._render_traffic above) and every routing-time adjustment
# (Router._edge_time above) is computed by our own code from this raw
# vector data, which we also spatially match to our own OSM graph edges
# ourselves (TomTom's segment geometry doesn't share IDs with OSM's).
#
# One tile call covers hundreds of road segments at once (unlike a
# per-point API), so this comfortably fits a free-tier monthly quota even
# under regular use.
#
# Entirely optional and fails soft: with no TOMTOM_API_KEY set, or if TomTom
# is unreachable/rate-limited, affected tiles/edges are just skipped
# (routing falls back to static OSM speeds, tiles just don't color that
# road), so the map and every other feature work exactly as before.
# --------------------------------------------------------------------------
# (TOMTOM_API_KEY, TRAFFIC_ENABLED, and MAJOR_HIGHWAYS live in the
# Configuration section up top, since routing and tiles use them too.)
# "relative" gives traffic_level as current speed / that road's own
# free-flow speed (0..1), which reads as congestion regardless of road type.
FLOW_TILE_URL = "https://api.tomtom.com/traffic/map/4/tile/flow/relative/{z}/{x}/{y}.pbf?key={key}"
# TomTom's segment geometry is independently digitized from OSM's, so a
# "matching" edge is never exactly coincident -- this is how far apart (any
# point along) an edge and the nearest TomTom segment can be and still count
# as a match. Empirically tuned against a real stretch of the Beltline: most
# short "close call" gaps (real TomTom data, just outside a tighter radius)
# resolve by ~150m, while genuine TomTom coverage gaps (e.g. through complex
# interchanges) sit noticeably farther out (~166m+ in that sample) -- so
# this stays under that to avoid matching onto an unrelated nearby road.
MATCH_MAX_DIST_DEG = 0.0017 # roughly 150m at a mid-latitude location like Madison, WI
# Fixed zoom used for every TomTom fetch, whether it's covering a route's
# bounding box or a single rendered map tile -- deliberately NOT whatever
# zoom is being rendered. TomTom re-tiles (and re-clips/re-simplifies) its
# data per zoom, so matching at the render zoom meant the same OSM edge
# could match at one zoom and miss at another -- visible as roads
# flickering between colored and gray while panning/zooming; one fixed
# fetch zoom keeps every edge's match result stable. Why 14: TomTom's
# coverage is much richer at finer zooms (roughly 3x more of our major
# roads got a real match at z14 vs z12 in testing), and z14 keeps the tile
# count for a typical route's bounding box small enough (~15) to fetch in
# well under a second from a warm cache, a few seconds cold.
BBOX_FETCH_ZOOM = 14
# Cap on how many flow tiles one route request will fetch. A typical trip
# needs well under this; only a cross-area trip's bounding box blows past it
# (a box spanning a 15-mile-radius area is ~1,000 z14 tiles -- tens of
# seconds of fetching, mostly for roads nowhere near the trip). Past the
# cap, Router.refresh_traffic_near keeps the tiles nearest the start->end
# corridor: ~150 tiles is a few seconds cold, instant warm.
TRAFFIC_ROUTE_TILE_BUDGET = 150
TRAFFIC_REQUEST_TIMEOUT_S = 3.0
TRAFFIC_TILE_TTL_S = 90 # how long a cached tile's data stays fresh
TRAFFIC_EDGE_TTL_S = 90 # how long a cached per-edge match stays fresh
TRAFFIC_COOLDOWN_S = 30 # after a failure, stop hitting TomTom for a bit
TRAFFIC_CACHE_MAX_TILES = 2048
TRAFFIC_CACHE_MAX_EDGES = 20000
TRAFFIC_FETCH_CONCURRENCY = 4
_tile_traffic_cache: "dict[tuple, tuple[float, object]]" = {}
_tile_traffic_lock = threading.Lock()
_edge_traffic_cache: "dict[tuple, tuple[float, dict | None]]" = {}
_edge_traffic_lock = threading.Lock()
_traffic_cooldown_until = 0.0
def _traffic_color(ratio):
"""Congestion ratio (current speed / free-flow speed) -> RGB along a
red -> amber -> yellow -> green scale, interpolated linearly between
the stops below."""
stops = [
(0.0, (220, 38, 38)),
(0.5, (245, 158, 11)),
(0.75, (250, 204, 21)),
(0.9, (34, 197, 94)),
]
if ratio <= stops[0][0]:
return stops[0][1]
if ratio >= stops[-1][0]:
return stops[-1][1]
for (r0, c0), (r1, c1) in zip(stops, stops[1:]):
if r0 <= ratio <= r1:
t = (ratio - r0) / (r1 - r0)
return tuple(round(c0[i] + (c1[i] - c0[i]) * t) for i in range(3))
return stops[-1][1]
class TileTraffic:
"""Decoded, reprojected traffic segments for one TomTom flow tile, with
a spatial index for fast nearest-segment lookups."""
__slots__ = ("geoms", "ratios", "tree")
def __init__(self, geoms, ratios):
self.geoms = geoms
self.ratios = ratios
self.tree = STRtree(geoms) if geoms else None
def ratio_for(self, geom, max_dist_deg):
"""Ratio of the nearest TomTom segment to ANY point along `geom` (a
Point or LineString), within max_dist_deg, or None.
TomTom's own segments are often short (a probe-covered stretch
between two intersections can be well under 100m), while our OSM
edges are frequently longer. Matching on distance-to-the-whole-edge
rather than distance-to-just-its-midpoint avoids "gaps" where a
long edge's midpoint happens to fall between two short TomTom
segments even though the edge clearly overlaps real data."""
if self.tree is None:
return None
candidates = self.tree.query(geom.buffer(max_dist_deg))
if len(candidates) == 0:
return None
best_idx = min(candidates, key=lambda i: geom.distance(self.geoms[i]))
if geom.distance(self.geoms[best_idx]) > max_dist_deg:
return None
return self.ratios[best_idx]
def _fetch_traffic_tile(z, x, y):
url = FLOW_TILE_URL.format(z=z, x=x, y=y, key=urllib.parse.quote(TOMTOM_API_KEY))
try:
with urllib.request.urlopen(url, timeout=TRAFFIC_REQUEST_TIMEOUT_S) as resp:
if resp.status != 200:
return None
data = resp.read()
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
return None
try:
decoded = mapbox_vector_tile.decode(data)
except Exception:
return None
layer = decoded.get("Traffic flow")
if not layer:
return TileTraffic([], [])
extent = layer.get("extent", 4096)
west, south, east, north = tile_bounds(z, x, y)
def to_lonlat(px, py):
# mapbox_vector_tile.decode defaults to y_coord_down=False: it hands
# back tile-local coordinates with a BOTTOM-left origin (y grows
# north), so py counts up from the tile's south edge -- the opposite
# of the y-down convention raw MVT data and our own tiles use.
# Getting this wrong doesn't fail loudly; it silently mismatches
# most segments against nearby-but-wrong roads. (Linear
# interpolation in latitude is fine here: across one z14 tile the
# difference from true mercator is far below MATCH_MAX_DIST_DEG.)
lon = west + (px / extent) * (east - west)
lat = south + (py / extent) * (north - south)
return lon, lat
geoms, ratios = [], []
for feature in layer["features"]:
props = feature["properties"]
ratio = props.get("traffic_level")
if ratio is None or props.get("road_closure"):
continue
geom = feature["geometry"]
lines = geom["coordinates"] if geom["type"] == "MultiLineString" else [geom["coordinates"]]
for line in lines:
if len(line) < 2:
continue
geoms.append(LineString([to_lonlat(px, py) for px, py in line]))
ratios.append(float(ratio))
return TileTraffic(geoms, ratios)
def get_tile_traffic(z, x, y):
"""Decoded traffic segments for one tile (TomTom's own z/x/y, same
slippy-map scheme we use), or None if traffic isn't configured/reachable
right now. Cached for TRAFFIC_TILE_TTL_S; never raises."""
global _traffic_cooldown_until
if not TRAFFIC_ENABLED:
return None
key = (z, x, y)
now = time.time()
with _tile_traffic_lock:
hit = _tile_traffic_cache.get(key)
if hit is not None and now - hit[0] < TRAFFIC_TILE_TTL_S:
return hit[1]
if now < _traffic_cooldown_until:
# A fetch failed recently: don't hit TomTom again yet, and
# don't cache this None -- the first request after the
# cooldown should retry rather than see a poisoned entry.
return None
result = _fetch_traffic_tile(z, x, y)
with _tile_traffic_lock:
if result is None:
_traffic_cooldown_until = time.time() + TRAFFIC_COOLDOWN_S
else:
_tile_traffic_cache[key] = (time.time(), result)
if len(_tile_traffic_cache) > TRAFFIC_CACHE_MAX_TILES:
_tile_traffic_cache.pop(next(iter(_tile_traffic_cache)))
return result
def get_cached_flow(edge_id):
"""Cached traffic match for one graph edge (see store_edge_matches for
how it's populated), or None if we don't have a fresh one -- callers
fall back to static OSM speeds / no color.
Deliberately reads without the lock: a single dict lookup is atomic
under the GIL, this sits on A*'s hot path, and a stale hit just means
the same fallback the caller was prepared for anyway."""
hit = _edge_traffic_cache.get(edge_id)
if hit is None:
return None
ts, data = hit
if time.time() - ts > TRAFFIC_EDGE_TTL_S:
return None
return data
def store_edge_matches(matches):
"""Record direct spatial match results -- (edge_id, ratio) pairs, with
ratio=None meaning "looked, and confirmed no TomTom segment near this
edge" -- from whichever code path computed them. Map-tile rendering and
route requests both write here, so get_cached_flow (and Router.
get_effective_flow's gap-fill interpolation) sees the same data
regardless of which one triggered the match."""
now = time.time()
with _edge_traffic_lock:
for edge_id, ratio in matches:
_edge_traffic_cache[edge_id] = (now, {"ratio": ratio} if ratio is not None else None)
while len(_edge_traffic_cache) > TRAFFIC_CACHE_MAX_EDGES:
_edge_traffic_cache.pop(next(iter(_edge_traffic_cache)))
def bbox_tile_coords(south, west, north, east):
"""(z, x, y) of every flow tile covering the given bbox, at the one
fixed BBOX_FETCH_ZOOM (see that constant for why the zoom is fixed)."""
return [(t.z, t.x, t.y)
for t in mercantile.tiles(west, south, east, north, [BBOX_FETCH_ZOOM])]
def get_traffic_for_tiles(tile_coords):
"""Fetch + decode the given flow tiles ((z, x, y) each), in parallel and
through the per-tile cache, as {(x, y): TileTraffic} for the ones that