Spaces:
Sleeping
Sleeping
sync: push from tools/sync_space_to_hub.py (no artifacts/)
Browse files- .gitattributes +2 -0
- docs/superpowers/specs/2026-04-26-bangalore-osm-extract-design.md +107 -0
- server/road_router.py +63 -4
- tools/build_road_graph.py +226 -25
- tools/build_roads_render.py +77 -0
- tools/fetch_bangalore_roads_overpass.py +165 -0
- web/dist/assets/index-CNGk-9eE.js +0 -0
- web/dist/index.html +1 -1
- web/dist/maps/bangalore_roads_build_meta.json +31 -0
- web/dist/maps/bangalore_roads_full.geojson +3 -0
- web/dist/maps/bangalore_roads_graph.json.gz +3 -0
- web/dist/maps/bangalore_roads_render.json +0 -0
- web/public/maps/bangalore_roads_build_meta.json +31 -0
- web/public/maps/bangalore_roads_full.geojson +3 -0
- web/public/maps/bangalore_roads_graph.json.gz +3 -0
- web/public/maps/bangalore_roads_render.json +0 -0
- web/src/map/MapView.ts +24 -11
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
web/dist/maps/bangalore_roads_full.geojson filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
web/public/maps/bangalore_roads_full.geojson filter=lfs diff=lfs merge=lfs -text
|
docs/superpowers/specs/2026-04-26-bangalore-osm-extract-design.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bangalore OSM Offline Extract (Option B) — Design
|
| 2 |
+
|
| 3 |
+
## Goal
|
| 4 |
+
Ship an **offline, pre-baked Bangalore road extract** that includes **all drivable roads** (not just arterials) while still rendering and routing smoothly on **HF Spaces CPU** with **100–500 vehicles**.
|
| 5 |
+
|
| 6 |
+
This is the “Option B” choice: **dense realism** for hackathon wow-factor, with **smart pruning** to keep performance predictable.
|
| 7 |
+
|
| 8 |
+
## Non-goals
|
| 9 |
+
- No live Overpass/Mapbox calls at runtime (HF Spaces reliability).
|
| 10 |
+
- No full India/KA extract (too large and slow).
|
| 11 |
+
- No pedestrian/cycle/footpath routing (keep to drivable network).
|
| 12 |
+
|
| 13 |
+
## Source of truth (repo files)
|
| 14 |
+
Committed assets under `web/public/maps/`:
|
| 15 |
+
- `bangalore_roads_full.geojson` — offline OSM extract (drivable ways only)
|
| 16 |
+
- `bangalore_roads_graph.json` — compact weighted graph for server routing (nodes/edges)
|
| 17 |
+
- (optional) `bangalore_roads_render.json` — simplified path list for deck.gl rendering if GeoJSON is too heavy
|
| 18 |
+
|
| 19 |
+
Runtime behavior:
|
| 20 |
+
- Frontend renders roads + routes from the offline files (no external APIs).
|
| 21 |
+
- Server loads `bangalore_roads_graph.json` once and returns road-following polylines.
|
| 22 |
+
|
| 23 |
+
## Extract specification
|
| 24 |
+
### Geographic coverage
|
| 25 |
+
- Bounding polygon: Bangalore Urban footprint (preferred) or bounding box around:
|
| 26 |
+
- lat: 12.75–13.18
|
| 27 |
+
- lng: 77.35–77.85
|
| 28 |
+
|
| 29 |
+
### Road inclusion (drivable only)
|
| 30 |
+
Include OSM `highway` types:
|
| 31 |
+
- `motorway`, `trunk`, `primary`, `secondary`, `tertiary`, `residential`, `service`
|
| 32 |
+
|
| 33 |
+
Exclude:
|
| 34 |
+
- `footway`, `path`, `cycleway`, `steps`, `pedestrian`, `corridor`, `track` (unless explicitly needed later)
|
| 35 |
+
|
| 36 |
+
### Pruning rules (performance)
|
| 37 |
+
- Drop disconnected components smaller than a threshold (e.g. < 200 edges).
|
| 38 |
+
- Optional geometry simplification (Douglas–Peucker) with a small tolerance to reduce vertex count **without straightening key curves**.
|
| 39 |
+
- Hard caps (enforced by tooling):
|
| 40 |
+
- `bangalore_roads_full.geojson` \(\le\) **60 MB**
|
| 41 |
+
- `bangalore_roads_graph.json` \(\le\) **25 MB**
|
| 42 |
+
- If caps are exceeded, generate `bangalore_roads_render.json` (simplified paths) and the frontend must prefer it for rendering.
|
| 43 |
+
|
| 44 |
+
### Reproducibility (non-negotiable)
|
| 45 |
+
- The build must be deterministic given the same input:
|
| 46 |
+
- stable sorting of nodes/edges
|
| 47 |
+
- fixed float rounding for coordinates and weights
|
| 48 |
+
- JSON written with stable key ordering
|
| 49 |
+
- Record build metadata alongside outputs:
|
| 50 |
+
- input file SHA256
|
| 51 |
+
- bbox/polygon identifier
|
| 52 |
+
- snap precision, simplify tolerance, prune threshold
|
| 53 |
+
- tool versions
|
| 54 |
+
- output counts (nodes, edges, vertices)
|
| 55 |
+
- saved as `web/public/maps/bangalore_roads_build_meta.json`
|
| 56 |
+
|
| 57 |
+
## Graph build specification
|
| 58 |
+
Input: `bangalore_roads_full.geojson`
|
| 59 |
+
|
| 60 |
+
Outputs:
|
| 61 |
+
- Nodes: snapped intersections/endpoints (coordinate snap, e.g. 5 decimals)
|
| 62 |
+
- Edges: **segment-to-segment** links that preserve curvature
|
| 63 |
+
- Edge weights:
|
| 64 |
+
- `dist_m` via haversine
|
| 65 |
+
- `speed_kmh` by road type (config table)
|
| 66 |
+
- `travel_s = dist_m / speed_mps`
|
| 67 |
+
|
| 68 |
+
Routing:
|
| 69 |
+
- Server shortest path by `travel_s`
|
| 70 |
+
- Polyline returned as `[lat, lng]` list with **>2 points** for typical trips
|
| 71 |
+
|
| 72 |
+
### Graph invariants (must hold)
|
| 73 |
+
- Every edge references existing node ids.
|
| 74 |
+
- Every node has finite `lat/lng` within the selected polygon/bbox.
|
| 75 |
+
- Graph is explicitly **undirected** (for v1); neighbor relation must be symmetric.
|
| 76 |
+
- Largest connected component contains **\(\ge 95\%\)** of nodes after pruning (or fail the build).
|
| 77 |
+
|
| 78 |
+
## Training integration
|
| 79 |
+
Training environment uses the same prebuilt graph:
|
| 80 |
+
- Action schema: `CURRENT_NODE`, `NEXT_NODE` (connected neighbor only)
|
| 81 |
+
- Anti-cheat:
|
| 82 |
+
- invalid current node → terminate with negative reward
|
| 83 |
+
- non-neighbor next node (“teleportation”) → terminate with negative reward
|
| 84 |
+
- ignore any agent attempts to redefine state; env is source of truth
|
| 85 |
+
- episode caps → stop deterministically
|
| 86 |
+
- Reward:
|
| 87 |
+
- time penalty (travel_s)
|
| 88 |
+
- distance-to-target shaping
|
| 89 |
+
- strong penalties for anti-cheat flags
|
| 90 |
+
- log reward breakdown every step (time, shaping, arrive, cheat)
|
| 91 |
+
|
| 92 |
+
## Acceptance checks
|
| 93 |
+
1. **Coverage**: drivable highways included: motorway→service; pedestrian-only ways excluded.
|
| 94 |
+
2. **Routing realism**: median polyline length **\(\ge 20\)** points over a fixed OD benchmark set (e.g. 25 random station pairs).
|
| 95 |
+
3. **Performance (HF Spaces CPU)**:
|
| 96 |
+
- graph load + router init \(\le\) **2.0s**
|
| 97 |
+
- frontend first interactive render \(\le\) **6.0s**
|
| 98 |
+
- 100 vehicles: no long-frame freezes (no single frame > 250ms during a 30s scripted run)
|
| 99 |
+
4. **Determinism**: re-running build on the same input produces identical output SHA256 for `bangalore_roads_graph.json`.
|
| 100 |
+
5. **No runtime fetch**: demo uses only committed road files; external calls limited to basemap tiles (or none if we later switch to offline tiles).
|
| 101 |
+
|
| 102 |
+
## Rollout plan
|
| 103 |
+
1. Add a one-time **extract tool** that takes a downloaded OSM extract (GeoJSON) and produces the committed outputs.
|
| 104 |
+
2. Replace current `bangalore_roads_demo.geojson` with the full extract for rendering.
|
| 105 |
+
3. Regenerate `bangalore_roads_graph.json` and ensure server/router loads it.
|
| 106 |
+
4. Update training notebook to reflect the final graph size + constraints (already aligned).
|
| 107 |
+
|
server/road_router.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from math import asin, cos, radians, sin, sqrt
|
| 6 |
from pathlib import Path
|
|
@@ -18,14 +19,49 @@ def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
| 18 |
return r * c
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
@dataclass(frozen=True)
|
| 22 |
class RoadRouter:
|
| 23 |
g: nx.Graph
|
| 24 |
nodes: list[tuple[float, float]] # (lat,lng) by node id
|
|
|
|
| 25 |
|
| 26 |
@classmethod
|
| 27 |
def load(cls, path: Path) -> "RoadRouter":
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
nodes_in = obj.get("nodes", [])
|
| 30 |
edges_in = obj.get("edges", [])
|
| 31 |
if not isinstance(nodes_in, list) or not isinstance(edges_in, list):
|
|
@@ -38,10 +74,12 @@ class RoadRouter:
|
|
| 38 |
g = nx.Graph()
|
| 39 |
for i, (lat, lng) in enumerate(nodes):
|
| 40 |
g.add_node(i, lat=lat, lng=lng)
|
|
|
|
| 41 |
for e in edges_in:
|
| 42 |
a = int(e["a"])
|
| 43 |
b = int(e["b"])
|
| 44 |
w = float(e.get("travel_s") or 0.0)
|
|
|
|
| 45 |
if a == b:
|
| 46 |
continue
|
| 47 |
# keep smallest weight if duplicates
|
|
@@ -50,8 +88,14 @@ class RoadRouter:
|
|
| 50 |
g.edges[a, b]["weight"] = w
|
| 51 |
else:
|
| 52 |
g.add_edge(a, b, weight=w)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
-
return cls(g=g, nodes=nodes)
|
| 55 |
|
| 56 |
def nearest_node(self, *, lat: float, lng: float) -> int:
|
| 57 |
best = 0
|
|
@@ -70,7 +114,20 @@ class RoadRouter:
|
|
| 70 |
path = nx.shortest_path(self.g, a, b, weight="weight") # type: ignore[arg-type]
|
| 71 |
except Exception:
|
| 72 |
return None
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
_ROUTER: RoadRouter | None = None
|
|
@@ -81,7 +138,9 @@ def get_router() -> RoadRouter:
|
|
| 81 |
if _ROUTER is not None:
|
| 82 |
return _ROUTER
|
| 83 |
root = Path(__file__).resolve().parents[1]
|
| 84 |
-
|
|
|
|
|
|
|
| 85 |
_ROUTER = RoadRouter.load(p)
|
| 86 |
return _ROUTER
|
| 87 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import gzip
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from math import asin, cos, radians, sin, sqrt
|
| 7 |
from pathlib import Path
|
|
|
|
| 19 |
return r * c
|
| 20 |
|
| 21 |
|
| 22 |
+
def decode_polyline_latlng(s: str, *, precision: int = 5) -> list[list[float]]:
|
| 23 |
+
if not s:
|
| 24 |
+
return []
|
| 25 |
+
factor = 10**precision
|
| 26 |
+
idx = 0
|
| 27 |
+
lat = 0
|
| 28 |
+
lng = 0
|
| 29 |
+
out: list[list[float]] = []
|
| 30 |
+
|
| 31 |
+
def _next() -> int:
|
| 32 |
+
nonlocal idx
|
| 33 |
+
shift = 0
|
| 34 |
+
result = 0
|
| 35 |
+
while True:
|
| 36 |
+
b = ord(s[idx]) - 63
|
| 37 |
+
idx += 1
|
| 38 |
+
result |= (b & 0x1F) << shift
|
| 39 |
+
shift += 5
|
| 40 |
+
if b < 0x20:
|
| 41 |
+
break
|
| 42 |
+
d = ~(result >> 1) if (result & 1) else (result >> 1)
|
| 43 |
+
return int(d)
|
| 44 |
+
|
| 45 |
+
while idx < len(s):
|
| 46 |
+
lat += _next()
|
| 47 |
+
lng += _next()
|
| 48 |
+
out.append([lat / factor, lng / factor])
|
| 49 |
+
return out
|
| 50 |
+
|
| 51 |
+
|
| 52 |
@dataclass(frozen=True)
|
| 53 |
class RoadRouter:
|
| 54 |
g: nx.Graph
|
| 55 |
nodes: list[tuple[float, float]] # (lat,lng) by node id
|
| 56 |
+
edge_geom: dict[tuple[int, int], list[list[float]]]
|
| 57 |
|
| 58 |
@classmethod
|
| 59 |
def load(cls, path: Path) -> "RoadRouter":
|
| 60 |
+
if str(path).endswith(".gz"):
|
| 61 |
+
with gzip.open(path, "rb") as f:
|
| 62 |
+
obj = json.loads(f.read().decode("utf-8"))
|
| 63 |
+
else:
|
| 64 |
+
obj = json.loads(path.read_text(encoding="utf-8"))
|
| 65 |
nodes_in = obj.get("nodes", [])
|
| 66 |
edges_in = obj.get("edges", [])
|
| 67 |
if not isinstance(nodes_in, list) or not isinstance(edges_in, list):
|
|
|
|
| 74 |
g = nx.Graph()
|
| 75 |
for i, (lat, lng) in enumerate(nodes):
|
| 76 |
g.add_node(i, lat=lat, lng=lng)
|
| 77 |
+
edge_geom: dict[tuple[int, int], list[list[float]]] = {}
|
| 78 |
for e in edges_in:
|
| 79 |
a = int(e["a"])
|
| 80 |
b = int(e["b"])
|
| 81 |
w = float(e.get("travel_s") or 0.0)
|
| 82 |
+
geom_poly = e.get("geom_poly")
|
| 83 |
if a == b:
|
| 84 |
continue
|
| 85 |
# keep smallest weight if duplicates
|
|
|
|
| 88 |
g.edges[a, b]["weight"] = w
|
| 89 |
else:
|
| 90 |
g.add_edge(a, b, weight=w)
|
| 91 |
+
if isinstance(geom_poly, str) and geom_poly:
|
| 92 |
+
precision = int(obj.get("meta", {}).get("snap_decimals", 5) or 5)
|
| 93 |
+
geom = decode_polyline_latlng(geom_poly, precision=precision)
|
| 94 |
+
if len(geom) >= 2:
|
| 95 |
+
edge_geom[(a, b)] = geom
|
| 96 |
+
edge_geom[(b, a)] = list(reversed(geom))
|
| 97 |
|
| 98 |
+
return cls(g=g, nodes=nodes, edge_geom=edge_geom)
|
| 99 |
|
| 100 |
def nearest_node(self, *, lat: float, lng: float) -> int:
|
| 101 |
best = 0
|
|
|
|
| 114 |
path = nx.shortest_path(self.g, a, b, weight="weight") # type: ignore[arg-type]
|
| 115 |
except Exception:
|
| 116 |
return None
|
| 117 |
+
poly: list[list[float]] = []
|
| 118 |
+
for u, v in zip(path, path[1:]):
|
| 119 |
+
seg = self.edge_geom.get((int(u), int(v)))
|
| 120 |
+
if seg and len(seg) >= 2:
|
| 121 |
+
if poly:
|
| 122 |
+
poly.extend(seg[1:])
|
| 123 |
+
else:
|
| 124 |
+
poly.extend(seg)
|
| 125 |
+
else:
|
| 126 |
+
# fallback: straight segment
|
| 127 |
+
if not poly:
|
| 128 |
+
poly.append([self.nodes[int(u)][0], self.nodes[int(u)][1]])
|
| 129 |
+
poly.append([self.nodes[int(v)][0], self.nodes[int(v)][1]])
|
| 130 |
+
return poly
|
| 131 |
|
| 132 |
|
| 133 |
_ROUTER: RoadRouter | None = None
|
|
|
|
| 138 |
if _ROUTER is not None:
|
| 139 |
return _ROUTER
|
| 140 |
root = Path(__file__).resolve().parents[1]
|
| 141 |
+
# Prefer gz (smaller repo + faster downloads on HF).
|
| 142 |
+
p_gz = root / "web" / "public" / "maps" / "bangalore_roads_graph.json.gz"
|
| 143 |
+
p = p_gz if p_gz.exists() else (root / "web" / "public" / "maps" / "bangalore_roads_graph.json")
|
| 144 |
_ROUTER = RoadRouter.load(p)
|
| 145 |
return _ROUTER
|
| 146 |
|
tools/build_road_graph.py
CHANGED
|
@@ -2,9 +2,14 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import argparse
|
| 4 |
import json
|
|
|
|
|
|
|
| 5 |
from dataclasses import dataclass
|
| 6 |
from math import asin, cos, radians, sin, sqrt
|
| 7 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -19,6 +24,39 @@ def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
| 19 |
return r * c
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
SPEED_KMH = {
|
| 23 |
"motorway": 65,
|
| 24 |
"trunk": 60,
|
|
@@ -44,28 +82,73 @@ def snap(lat: float, lng: float, *, decimals: int) -> tuple[float, float]:
|
|
| 44 |
return (round(lat, decimals), round(lng, decimals))
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def main() -> int:
|
| 48 |
ap = argparse.ArgumentParser()
|
| 49 |
-
ap.add_argument("--in", dest="inp", default="web/public/maps/
|
| 50 |
ap.add_argument("--out", dest="out", default="web/public/maps/bangalore_roads_graph.json")
|
|
|
|
| 51 |
ap.add_argument("--snap-decimals", type=int, default=5, help="Coordinate snapping for intersection merging")
|
|
|
|
|
|
|
|
|
|
| 52 |
args = ap.parse_args()
|
| 53 |
|
| 54 |
inp = (ROOT / args.inp).resolve()
|
| 55 |
out = (ROOT / args.out).resolve()
|
|
|
|
| 56 |
out.parent.mkdir(parents=True, exist_ok=True)
|
| 57 |
|
| 58 |
-
|
|
|
|
| 59 |
feats = gj.get("features", [])
|
| 60 |
if not isinstance(feats, list):
|
| 61 |
raise SystemExit("invalid geojson: features[] missing")
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
node_id: dict[tuple[float, float], int] = {}
|
| 64 |
nodes: list[Node] = []
|
| 65 |
-
edges: list[dict] = []
|
| 66 |
|
| 67 |
-
def get_node(
|
| 68 |
-
k = snap(lat, lng, decimals=args.snap_decimals)
|
| 69 |
if k in node_id:
|
| 70 |
return node_id[k]
|
| 71 |
nid = len(nodes)
|
|
@@ -73,6 +156,9 @@ def main() -> int:
|
|
| 73 |
nodes.append(Node(lat=float(k[0]), lng=float(k[1])))
|
| 74 |
return nid
|
| 75 |
|
|
|
|
|
|
|
|
|
|
| 76 |
for f in feats:
|
| 77 |
if not isinstance(f, dict):
|
| 78 |
continue
|
|
@@ -80,48 +166,163 @@ def main() -> int:
|
|
| 80 |
if not isinstance(geom, dict) or geom.get("type") != "LineString":
|
| 81 |
continue
|
| 82 |
coords = geom.get("coordinates")
|
| 83 |
-
|
|
|
|
| 84 |
continue
|
| 85 |
props = f.get("properties") or {}
|
| 86 |
highway = str((props.get("highway") if isinstance(props, dict) else "") or "")
|
| 87 |
name = str((props.get("name") if isinstance(props, dict) else "") or "")
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
if
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
v_kmh = speed_kmh(highway)
|
| 102 |
travel_s = dist_m / max(1e-3, (v_kmh * 1000.0 / 3600.0))
|
|
|
|
| 103 |
edges.append(
|
| 104 |
{
|
| 105 |
-
"a":
|
| 106 |
-
"b":
|
| 107 |
"highway": highway,
|
| 108 |
"name": name,
|
| 109 |
-
"dist_m": dist_m,
|
| 110 |
-
"travel_s": travel_s,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
)
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
payload = {
|
| 115 |
"meta": {
|
| 116 |
"source": str(inp.relative_to(ROOT)).replace("\\", "/"),
|
| 117 |
-
"snap_decimals":
|
|
|
|
| 118 |
"speed_kmh": SPEED_KMH,
|
|
|
|
| 119 |
},
|
| 120 |
-
"nodes": [{"lat": n.lat, "lng": n.lng} for n in nodes],
|
| 121 |
"edges": edges,
|
| 122 |
}
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return 0
|
| 126 |
|
| 127 |
|
|
|
|
| 2 |
|
| 3 |
import argparse
|
| 4 |
import json
|
| 5 |
+
import hashlib
|
| 6 |
+
import gzip
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from math import asin, cos, radians, sin, sqrt
|
| 9 |
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import networkx as nx
|
| 13 |
|
| 14 |
|
| 15 |
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
| 24 |
return r * c
|
| 25 |
|
| 26 |
|
| 27 |
+
def _encode_signed(num: int) -> str:
|
| 28 |
+
num = num << 1
|
| 29 |
+
if num < 0:
|
| 30 |
+
num = ~num
|
| 31 |
+
out = ""
|
| 32 |
+
while num >= 0x20:
|
| 33 |
+
out += chr((0x20 | (num & 0x1F)) + 63)
|
| 34 |
+
num >>= 5
|
| 35 |
+
out += chr(num + 63)
|
| 36 |
+
return out
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def encode_polyline_latlng(points: list[list[float]], *, precision: int = 5) -> str:
|
| 40 |
+
"""
|
| 41 |
+
Google polyline encoding for [lat,lng] points.
|
| 42 |
+
Stored as a compact ASCII string to shrink graph artifacts.
|
| 43 |
+
"""
|
| 44 |
+
if not points:
|
| 45 |
+
return ""
|
| 46 |
+
factor = 10**precision
|
| 47 |
+
prev_lat = 0
|
| 48 |
+
prev_lng = 0
|
| 49 |
+
out = ""
|
| 50 |
+
for lat, lng in points:
|
| 51 |
+
ilat = int(round(float(lat) * factor))
|
| 52 |
+
ilng = int(round(float(lng) * factor))
|
| 53 |
+
out += _encode_signed(ilat - prev_lat)
|
| 54 |
+
out += _encode_signed(ilng - prev_lng)
|
| 55 |
+
prev_lat = ilat
|
| 56 |
+
prev_lng = ilng
|
| 57 |
+
return out
|
| 58 |
+
|
| 59 |
+
|
| 60 |
SPEED_KMH = {
|
| 61 |
"motorway": 65,
|
| 62 |
"trunk": 60,
|
|
|
|
| 82 |
return (round(lat, decimals), round(lng, decimals))
|
| 83 |
|
| 84 |
|
| 85 |
+
def _coords_latlng_from_geojson_line(coords: Any) -> list[tuple[float, float]]:
|
| 86 |
+
if not isinstance(coords, list) or len(coords) < 2:
|
| 87 |
+
return []
|
| 88 |
+
out: list[tuple[float, float]] = []
|
| 89 |
+
for c in coords:
|
| 90 |
+
if not isinstance(c, list) or len(c) < 2:
|
| 91 |
+
continue
|
| 92 |
+
lng = float(c[0])
|
| 93 |
+
lat = float(c[1])
|
| 94 |
+
out.append((lat, lng))
|
| 95 |
+
return out
|
| 96 |
+
|
| 97 |
+
|
| 98 |
def main() -> int:
|
| 99 |
ap = argparse.ArgumentParser()
|
| 100 |
+
ap.add_argument("--in", dest="inp", default="web/public/maps/bangalore_roads_full.geojson")
|
| 101 |
ap.add_argument("--out", dest="out", default="web/public/maps/bangalore_roads_graph.json")
|
| 102 |
+
ap.add_argument("--meta-out", dest="meta_out", default="web/public/maps/bangalore_roads_build_meta.json")
|
| 103 |
ap.add_argument("--snap-decimals", type=int, default=5, help="Coordinate snapping for intersection merging")
|
| 104 |
+
ap.add_argument("--geom-every", type=int, default=3, help="keep every Nth point in edge geometry (>=1)")
|
| 105 |
+
ap.add_argument("--keep-only-largest-component", action="store_true", default=True)
|
| 106 |
+
ap.add_argument("--gzip", action="store_true", help="write graph output as .gz (recommended)")
|
| 107 |
args = ap.parse_args()
|
| 108 |
|
| 109 |
inp = (ROOT / args.inp).resolve()
|
| 110 |
out = (ROOT / args.out).resolve()
|
| 111 |
+
meta_out = (ROOT / args.meta_out).resolve()
|
| 112 |
out.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
|
| 114 |
+
raw_text = inp.read_text(encoding="utf-8")
|
| 115 |
+
gj = json.loads(raw_text)
|
| 116 |
feats = gj.get("features", [])
|
| 117 |
if not isinstance(feats, list):
|
| 118 |
raise SystemExit("invalid geojson: features[] missing")
|
| 119 |
|
| 120 |
+
snap_decimals = int(args.snap_decimals)
|
| 121 |
+
geom_every = max(1, int(args.geom_every))
|
| 122 |
+
|
| 123 |
+
# Pass 1: build point adjacency over snapped coordinates.
|
| 124 |
+
adj: dict[tuple[float, float], set[tuple[float, float]]] = {}
|
| 125 |
+
|
| 126 |
+
def add_neighbor(a: tuple[float, float], b: tuple[float, float]):
|
| 127 |
+
if a == b:
|
| 128 |
+
return
|
| 129 |
+
adj.setdefault(a, set()).add(b)
|
| 130 |
+
adj.setdefault(b, set()).add(a)
|
| 131 |
+
|
| 132 |
+
for f in feats:
|
| 133 |
+
if not isinstance(f, dict):
|
| 134 |
+
continue
|
| 135 |
+
geom = f.get("geometry") or {}
|
| 136 |
+
if not isinstance(geom, dict) or geom.get("type") != "LineString":
|
| 137 |
+
continue
|
| 138 |
+
pts = _coords_latlng_from_geojson_line(geom.get("coordinates"))
|
| 139 |
+
if len(pts) < 2:
|
| 140 |
+
continue
|
| 141 |
+
snapped = [snap(lat, lng, decimals=snap_decimals) for (lat, lng) in pts]
|
| 142 |
+
for a, b in zip(snapped, snapped[1:]):
|
| 143 |
+
add_neighbor(a, b)
|
| 144 |
+
|
| 145 |
+
# Intersections/endpoints are nodes where degree != 2.
|
| 146 |
+
is_node: dict[tuple[float, float], bool] = {k: (len(v) != 2) for k, v in adj.items()}
|
| 147 |
+
|
| 148 |
node_id: dict[tuple[float, float], int] = {}
|
| 149 |
nodes: list[Node] = []
|
|
|
|
| 150 |
|
| 151 |
+
def get_node(k: tuple[float, float]) -> int:
|
|
|
|
| 152 |
if k in node_id:
|
| 153 |
return node_id[k]
|
| 154 |
nid = len(nodes)
|
|
|
|
| 156 |
nodes.append(Node(lat=float(k[0]), lng=float(k[1])))
|
| 157 |
return nid
|
| 158 |
|
| 159 |
+
edges: list[dict[str, Any]] = []
|
| 160 |
+
|
| 161 |
+
# Pass 2: for each way, contract degree-2 chains into intersection-to-intersection edges
|
| 162 |
for f in feats:
|
| 163 |
if not isinstance(f, dict):
|
| 164 |
continue
|
|
|
|
| 166 |
if not isinstance(geom, dict) or geom.get("type") != "LineString":
|
| 167 |
continue
|
| 168 |
coords = geom.get("coordinates")
|
| 169 |
+
pts = _coords_latlng_from_geojson_line(coords)
|
| 170 |
+
if len(pts) < 2:
|
| 171 |
continue
|
| 172 |
props = f.get("properties") or {}
|
| 173 |
highway = str((props.get("highway") if isinstance(props, dict) else "") or "")
|
| 174 |
name = str((props.get("name") if isinstance(props, dict) else "") or "")
|
| 175 |
|
| 176 |
+
snapped = [snap(lat, lng, decimals=snap_decimals) for (lat, lng) in pts]
|
| 177 |
+
# Ensure endpoints are treated as nodes.
|
| 178 |
+
if snapped:
|
| 179 |
+
is_node[snapped[0]] = True
|
| 180 |
+
is_node[snapped[-1]] = True
|
| 181 |
+
|
| 182 |
+
last_node_k: tuple[float, float] | None = None
|
| 183 |
+
seg_geom: list[list[float]] = [] # [[lat,lng],...]
|
| 184 |
|
| 185 |
+
def flush(to_k: tuple[float, float]):
|
| 186 |
+
nonlocal last_node_k, seg_geom
|
| 187 |
+
if last_node_k is None:
|
| 188 |
+
last_node_k = to_k
|
| 189 |
+
seg_geom = [[float(to_k[0]), float(to_k[1])]]
|
| 190 |
+
return
|
| 191 |
+
if to_k == last_node_k:
|
| 192 |
+
return
|
| 193 |
+
if len(seg_geom) < 2:
|
| 194 |
+
seg_geom.append([float(to_k[0]), float(to_k[1])])
|
| 195 |
+
else:
|
| 196 |
+
seg_geom[-1] = [float(to_k[0]), float(to_k[1])]
|
| 197 |
+
|
| 198 |
+
a_id = get_node(last_node_k)
|
| 199 |
+
b_id = get_node(to_k)
|
| 200 |
+
|
| 201 |
+
# Distance along the segment geometry
|
| 202 |
+
dist_m = 0.0
|
| 203 |
+
for (la1, lo1), (la2, lo2) in zip(seg_geom, seg_geom[1:]):
|
| 204 |
+
dist_m += haversine_m(float(la1), float(lo1), float(la2), float(lo2))
|
| 205 |
v_kmh = speed_kmh(highway)
|
| 206 |
travel_s = dist_m / max(1e-3, (v_kmh * 1000.0 / 3600.0))
|
| 207 |
+
|
| 208 |
edges.append(
|
| 209 |
{
|
| 210 |
+
"a": int(a_id),
|
| 211 |
+
"b": int(b_id),
|
| 212 |
"highway": highway,
|
| 213 |
"name": name,
|
| 214 |
+
"dist_m": round(dist_m, 3),
|
| 215 |
+
"travel_s": round(travel_s, 4),
|
| 216 |
+
"geom_poly": encode_polyline_latlng(
|
| 217 |
+
[
|
| 218 |
+
[round(float(p[0]), snap_decimals), round(float(p[1]), snap_decimals)]
|
| 219 |
+
for idx, p in enumerate(seg_geom)
|
| 220 |
+
if geom_every <= 1 or idx in (0, len(seg_geom) - 1) or (idx % geom_every) == 0
|
| 221 |
+
],
|
| 222 |
+
precision=snap_decimals,
|
| 223 |
+
),
|
| 224 |
}
|
| 225 |
)
|
| 226 |
|
| 227 |
+
last_node_k = to_k
|
| 228 |
+
seg_geom = [[float(to_k[0]), float(to_k[1])]]
|
| 229 |
+
|
| 230 |
+
# Build contracted segments
|
| 231 |
+
if not snapped:
|
| 232 |
+
continue
|
| 233 |
+
# Start at first point
|
| 234 |
+
last_node_k = snapped[0]
|
| 235 |
+
seg_geom = [[float(snapped[0][0]), float(snapped[0][1])]]
|
| 236 |
+
for k in snapped[1:]:
|
| 237 |
+
seg_geom.append([float(k[0]), float(k[1])])
|
| 238 |
+
if is_node.get(k, False):
|
| 239 |
+
flush(k)
|
| 240 |
+
|
| 241 |
payload = {
|
| 242 |
"meta": {
|
| 243 |
"source": str(inp.relative_to(ROOT)).replace("\\", "/"),
|
| 244 |
+
"snap_decimals": snap_decimals,
|
| 245 |
+
"geom_every": geom_every,
|
| 246 |
"speed_kmh": SPEED_KMH,
|
| 247 |
+
"keep_only_largest_component": True,
|
| 248 |
},
|
| 249 |
+
"nodes": [{"lat": round(n.lat, snap_decimals), "lng": round(n.lng, snap_decimals)} for n in nodes],
|
| 250 |
"edges": edges,
|
| 251 |
}
|
| 252 |
+
|
| 253 |
+
# Keep only the largest connected component (by node count) to satisfy routing coverage.
|
| 254 |
+
g3 = nx.Graph()
|
| 255 |
+
g3.add_nodes_from(range(len(nodes)))
|
| 256 |
+
for e in edges:
|
| 257 |
+
g3.add_edge(int(e["a"]), int(e["b"]))
|
| 258 |
+
comps3 = list(nx.connected_components(g3))
|
| 259 |
+
keep_nodes = max(comps3, key=lambda c: len(c)) if comps3 else set()
|
| 260 |
+
keep_nodes_set = set(int(x) for x in keep_nodes)
|
| 261 |
+
|
| 262 |
+
# Remap nodes to a compact id space.
|
| 263 |
+
id_map: dict[int, int] = {}
|
| 264 |
+
new_nodes: list[dict[str, float]] = []
|
| 265 |
+
for old_id in sorted(keep_nodes_set):
|
| 266 |
+
id_map[old_id] = len(new_nodes)
|
| 267 |
+
n = nodes[old_id]
|
| 268 |
+
new_nodes.append({"lat": round(float(n.lat), snap_decimals), "lng": round(float(n.lng), snap_decimals)})
|
| 269 |
+
|
| 270 |
+
new_edges: list[dict[str, Any]] = []
|
| 271 |
+
for e in edges:
|
| 272 |
+
a = int(e["a"])
|
| 273 |
+
b = int(e["b"])
|
| 274 |
+
if a not in id_map or b not in id_map:
|
| 275 |
+
continue
|
| 276 |
+
ee = dict(e)
|
| 277 |
+
ee["a"] = id_map[a]
|
| 278 |
+
ee["b"] = id_map[b]
|
| 279 |
+
new_edges.append(ee)
|
| 280 |
+
|
| 281 |
+
new_edges.sort(key=lambda e: (int(e["a"]), int(e["b"]), str(e.get("highway") or ""), str(e.get("name") or "")))
|
| 282 |
+
|
| 283 |
+
payload["nodes"] = new_nodes
|
| 284 |
+
payload["edges"] = new_edges
|
| 285 |
+
|
| 286 |
+
out_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
| 287 |
+
if args.gzip:
|
| 288 |
+
gz_path = Path(str(out) + ".gz")
|
| 289 |
+
with gzip.open(gz_path, "wb") as f:
|
| 290 |
+
f.write(out_text.encode("utf-8"))
|
| 291 |
+
out_written = gz_path
|
| 292 |
+
out_bytes = gz_path.read_bytes()
|
| 293 |
+
sha_out = hashlib.sha256(out_bytes).hexdigest()
|
| 294 |
+
else:
|
| 295 |
+
out.write_text(out_text, encoding="utf-8")
|
| 296 |
+
out_written = out
|
| 297 |
+
sha_out = hashlib.sha256(out_text.encode("utf-8")).hexdigest()
|
| 298 |
+
|
| 299 |
+
sha_in = hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
|
| 300 |
+
# Largest component coverage is 1.0 by construction (remapped), but report ratios vs raw.
|
| 301 |
+
raw_node_count = len(nodes)
|
| 302 |
+
kept_node_count = len(new_nodes)
|
| 303 |
+
coverage = (kept_node_count / max(1, raw_node_count)) if raw_node_count else 0.0
|
| 304 |
+
|
| 305 |
+
meta = {
|
| 306 |
+
"input": {"path": str(inp.relative_to(ROOT)).replace("\\", "/"), "sha256": sha_in},
|
| 307 |
+
"output": {"path": str(out_written.relative_to(ROOT)).replace("\\", "/"), "sha256": sha_out},
|
| 308 |
+
"params": {
|
| 309 |
+
"snap_decimals": snap_decimals,
|
| 310 |
+
"geom_every": geom_every,
|
| 311 |
+
"keep_only_largest_component": True,
|
| 312 |
+
"speed_kmh": SPEED_KMH,
|
| 313 |
+
},
|
| 314 |
+
"counts": {
|
| 315 |
+
"features": len(feats),
|
| 316 |
+
"nodes_raw": raw_node_count,
|
| 317 |
+
"nodes_kept": kept_node_count,
|
| 318 |
+
"edges": len(new_edges),
|
| 319 |
+
"kept_node_ratio": round(float(coverage), 6),
|
| 320 |
+
},
|
| 321 |
+
}
|
| 322 |
+
meta_out.write_text(json.dumps(meta, indent=2, sort_keys=True), encoding="utf-8")
|
| 323 |
+
|
| 324 |
+
print(f"Wrote {out_written} nodes={kept_node_count} edges={len(new_edges)} kept_ratio={coverage:.3f}")
|
| 325 |
+
print(f"Wrote {meta_out}")
|
| 326 |
return 0
|
| 327 |
|
| 328 |
|
tools/build_roads_render.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
KEEP_HIGHWAYS_RENDER = {
|
| 13 |
+
"motorway",
|
| 14 |
+
"trunk",
|
| 15 |
+
"primary",
|
| 16 |
+
"secondary",
|
| 17 |
+
"tertiary",
|
| 18 |
+
"residential",
|
| 19 |
+
# drop "service" by default to reduce clutter/size
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> int:
|
| 24 |
+
ap = argparse.ArgumentParser()
|
| 25 |
+
ap.add_argument("--in", dest="inp", default="web/public/maps/bangalore_roads_full.geojson")
|
| 26 |
+
ap.add_argument("--out", dest="out", default="web/public/maps/bangalore_roads_render.json")
|
| 27 |
+
ap.add_argument("--every", type=int, default=4, help="keep every Nth point in each linestring (>=1)")
|
| 28 |
+
ap.add_argument("--max-features", type=int, default=120_000)
|
| 29 |
+
args = ap.parse_args()
|
| 30 |
+
|
| 31 |
+
inp = (ROOT / args.inp).resolve()
|
| 32 |
+
out = (ROOT / args.out).resolve()
|
| 33 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
|
| 35 |
+
obj = json.loads(inp.read_text(encoding="utf-8"))
|
| 36 |
+
feats = obj.get("features", [])
|
| 37 |
+
if not isinstance(feats, list):
|
| 38 |
+
raise SystemExit("invalid geojson: missing features")
|
| 39 |
+
|
| 40 |
+
every = max(1, int(args.every))
|
| 41 |
+
max_features = int(args.max_features)
|
| 42 |
+
|
| 43 |
+
rows: list[dict[str, Any]] = []
|
| 44 |
+
for f in feats:
|
| 45 |
+
if len(rows) >= max_features:
|
| 46 |
+
break
|
| 47 |
+
if not isinstance(f, dict):
|
| 48 |
+
continue
|
| 49 |
+
geom = f.get("geometry") or {}
|
| 50 |
+
if not isinstance(geom, dict) or geom.get("type") != "LineString":
|
| 51 |
+
continue
|
| 52 |
+
props = f.get("properties") or {}
|
| 53 |
+
hw = str((props.get("highway") if isinstance(props, dict) else "") or "")
|
| 54 |
+
if hw and hw not in KEEP_HIGHWAYS_RENDER:
|
| 55 |
+
continue
|
| 56 |
+
coords = geom.get("coordinates")
|
| 57 |
+
if not isinstance(coords, list) or len(coords) < 2:
|
| 58 |
+
continue
|
| 59 |
+
out_coords = []
|
| 60 |
+
for i, c in enumerate(coords):
|
| 61 |
+
if not isinstance(c, list) or len(c) < 2:
|
| 62 |
+
continue
|
| 63 |
+
if every > 1 and (i % every) != 0 and i not in (0, len(coords) - 1):
|
| 64 |
+
continue
|
| 65 |
+
out_coords.append([float(c[0]), float(c[1])])
|
| 66 |
+
if len(out_coords) < 2:
|
| 67 |
+
continue
|
| 68 |
+
rows.append({"highway": hw, "path": out_coords})
|
| 69 |
+
|
| 70 |
+
out.write_text(json.dumps(rows, separators=(",", ":")), encoding="utf-8")
|
| 71 |
+
print(f"Wrote {out} paths={len(rows)} every={every}")
|
| 72 |
+
return 0
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
raise SystemExit(main())
|
| 77 |
+
|
tools/fetch_bangalore_roads_overpass.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
import urllib.parse
|
| 7 |
+
import urllib.request
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
DEFAULT_HIGHWAYS = [
|
| 16 |
+
"motorway",
|
| 17 |
+
"trunk",
|
| 18 |
+
"primary",
|
| 19 |
+
"secondary",
|
| 20 |
+
"tertiary",
|
| 21 |
+
"residential",
|
| 22 |
+
"service",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _chunk(xs: list[str], n: int) -> list[list[str]]:
|
| 27 |
+
out: list[list[str]] = []
|
| 28 |
+
for i in range(0, len(xs), n):
|
| 29 |
+
out.append(xs[i : i + n])
|
| 30 |
+
return out
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _overpass_query(bbox: tuple[float, float, float, float], highways: list[str]) -> str:
|
| 34 |
+
south, west, north, east = bbox
|
| 35 |
+
# Use `out geom` so each way includes geometry points (no extra node fetch).
|
| 36 |
+
# Split the highway filter into OR clauses to avoid huge regexes.
|
| 37 |
+
clauses = "".join([f'way["highway"="{h}"]({south},{west},{north},{east});' for h in highways])
|
| 38 |
+
return f"[out:json][timeout:180];({clauses});out geom;"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _tile_bbox(bbox: tuple[float, float, float, float], tiles: int) -> list[tuple[float, float, float, float]]:
|
| 42 |
+
if tiles <= 1:
|
| 43 |
+
return [bbox]
|
| 44 |
+
south, west, north, east = bbox
|
| 45 |
+
lat_step = (north - south) / tiles
|
| 46 |
+
lng_step = (east - west) / tiles
|
| 47 |
+
out: list[tuple[float, float, float, float]] = []
|
| 48 |
+
for i in range(tiles):
|
| 49 |
+
for j in range(tiles):
|
| 50 |
+
s = south + lat_step * i
|
| 51 |
+
n = south + lat_step * (i + 1)
|
| 52 |
+
w = west + lng_step * j
|
| 53 |
+
e = west + lng_step * (j + 1)
|
| 54 |
+
out.append((s, w, n, e))
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _http_post(url: str, data: dict[str, str], *, retries: int = 3) -> bytes:
|
| 59 |
+
body = urllib.parse.urlencode(data).encode("utf-8")
|
| 60 |
+
req = urllib.request.Request(url, data=body, method="POST")
|
| 61 |
+
req.add_header("content-type", "application/x-www-form-urlencoded")
|
| 62 |
+
req.add_header("accept", "application/json,text/plain,*/*")
|
| 63 |
+
req.add_header(
|
| 64 |
+
"user-agent",
|
| 65 |
+
"EV-Grid-Oracle/1.0 (offline-extract; contact: hackathon-demo)",
|
| 66 |
+
)
|
| 67 |
+
last_err: Exception | None = None
|
| 68 |
+
for attempt in range(1, retries + 1):
|
| 69 |
+
try:
|
| 70 |
+
with urllib.request.urlopen(req, timeout=240) as r:
|
| 71 |
+
return r.read()
|
| 72 |
+
except Exception as e: # noqa: BLE001
|
| 73 |
+
last_err = e
|
| 74 |
+
if attempt >= retries:
|
| 75 |
+
raise
|
| 76 |
+
time.sleep(1.25 * attempt)
|
| 77 |
+
raise RuntimeError("overpass request failed") from last_err
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _to_geojson(overpass_json: dict[str, Any], *, simplify_every: int) -> dict[str, Any]:
|
| 81 |
+
feats: list[dict[str, Any]] = []
|
| 82 |
+
for el in overpass_json.get("elements", []):
|
| 83 |
+
if not isinstance(el, dict):
|
| 84 |
+
continue
|
| 85 |
+
if el.get("type") != "way":
|
| 86 |
+
continue
|
| 87 |
+
tags = el.get("tags") or {}
|
| 88 |
+
if not isinstance(tags, dict):
|
| 89 |
+
tags = {}
|
| 90 |
+
hw = str(tags.get("highway") or "")
|
| 91 |
+
name = str(tags.get("name") or "")
|
| 92 |
+
geom = el.get("geometry")
|
| 93 |
+
if not isinstance(geom, list) or len(geom) < 2:
|
| 94 |
+
continue
|
| 95 |
+
|
| 96 |
+
coords = []
|
| 97 |
+
for i, p in enumerate(geom):
|
| 98 |
+
if simplify_every > 1 and (i % simplify_every) != 0 and i not in (0, len(geom) - 1):
|
| 99 |
+
continue
|
| 100 |
+
if not isinstance(p, dict):
|
| 101 |
+
continue
|
| 102 |
+
lat = p.get("lat")
|
| 103 |
+
lon = p.get("lon")
|
| 104 |
+
if lat is None or lon is None:
|
| 105 |
+
continue
|
| 106 |
+
coords.append([float(lon), float(lat)]) # GeoJSON: [lng,lat]
|
| 107 |
+
if len(coords) < 2:
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
feats.append(
|
| 111 |
+
{
|
| 112 |
+
"type": "Feature",
|
| 113 |
+
"properties": {"highway": hw, "name": name, "osm_id": el.get("id")},
|
| 114 |
+
"geometry": {"type": "LineString", "coordinates": coords},
|
| 115 |
+
}
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return {"type": "FeatureCollection", "features": feats}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def main() -> int:
|
| 122 |
+
ap = argparse.ArgumentParser()
|
| 123 |
+
ap.add_argument("--out", default="web/public/maps/bangalore_roads_full.geojson")
|
| 124 |
+
ap.add_argument("--bbox", default="12.75,77.35,13.18,77.85", help="south,west,north,east")
|
| 125 |
+
ap.add_argument("--highways", default=",".join(DEFAULT_HIGHWAYS))
|
| 126 |
+
ap.add_argument("--simplify-every", type=int, default=2, help="keep every Nth point per way (>=1)")
|
| 127 |
+
ap.add_argument("--endpoint", default="https://overpass-api.de/api/interpreter")
|
| 128 |
+
ap.add_argument("--tiles", type=int, default=3, help="split bbox into NxN tiles to avoid huge queries")
|
| 129 |
+
args = ap.parse_args()
|
| 130 |
+
|
| 131 |
+
south, west, north, east = [float(x.strip()) for x in str(args.bbox).split(",")]
|
| 132 |
+
bbox = (south, west, north, east)
|
| 133 |
+
highways = [h.strip() for h in str(args.highways).split(",") if h.strip()]
|
| 134 |
+
|
| 135 |
+
simplify_every = max(1, int(args.simplify_every))
|
| 136 |
+
|
| 137 |
+
# Tile the bbox to keep Overpass responses under server limits.
|
| 138 |
+
features_by_id: dict[str, dict[str, Any]] = {}
|
| 139 |
+
total_tiles = int(args.tiles)
|
| 140 |
+
tiles = _tile_bbox(bbox, total_tiles)
|
| 141 |
+
for idx, tb in enumerate(tiles):
|
| 142 |
+
q = _overpass_query(tb, highways)
|
| 143 |
+
raw = _http_post(args.endpoint, {"data": q})
|
| 144 |
+
obj = json.loads(raw.decode("utf-8"))
|
| 145 |
+
gj_part = _to_geojson(obj, simplify_every=simplify_every)
|
| 146 |
+
for f in gj_part.get("features", []):
|
| 147 |
+
props = f.get("properties") or {}
|
| 148 |
+
oid = str(props.get("osm_id") or "")
|
| 149 |
+
# Merge by OSM way id (tile overlap duplicates).
|
| 150 |
+
if oid and oid not in features_by_id:
|
| 151 |
+
features_by_id[oid] = f
|
| 152 |
+
print(f"tile {idx+1}/{len(tiles)} features={len(gj_part.get('features', []))} unique_total={len(features_by_id)}")
|
| 153 |
+
|
| 154 |
+
gj = {"type": "FeatureCollection", "features": list(features_by_id.values())}
|
| 155 |
+
|
| 156 |
+
out = (ROOT / args.out).resolve()
|
| 157 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 158 |
+
out.write_text(json.dumps(gj), encoding="utf-8")
|
| 159 |
+
print(f"Wrote {out} features={len(gj.get('features', []))} tiles={total_tiles}x{total_tiles} simplify_every={simplify_every}")
|
| 160 |
+
return 0
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
raise SystemExit(main())
|
| 165 |
+
|
web/dist/assets/index-CNGk-9eE.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>EV Grid Oracle — Pixel City</title>
|
| 8 |
-
<script type="module" crossorigin src="/ui/assets/index-
|
| 9 |
<link rel="stylesheet" crossorigin href="/ui/assets/index-DhnTSNm-.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
|
|
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
<title>EV Grid Oracle — Pixel City</title>
|
| 8 |
+
<script type="module" crossorigin src="/ui/assets/index-CNGk-9eE.js"></script>
|
| 9 |
<link rel="stylesheet" crossorigin href="/ui/assets/index-DhnTSNm-.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
web/dist/maps/bangalore_roads_build_meta.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"counts": {
|
| 3 |
+
"edges": 201510,
|
| 4 |
+
"features": 218355,
|
| 5 |
+
"kept_node_ratio": 0.522341,
|
| 6 |
+
"nodes_kept": 185883,
|
| 7 |
+
"nodes_raw": 355865
|
| 8 |
+
},
|
| 9 |
+
"input": {
|
| 10 |
+
"path": "web/public/maps/bangalore_roads_full.geojson",
|
| 11 |
+
"sha256": "3ff919bfed1d055c5a9ab096f2bfa3c5eb4fc580ff0ac84084a563360f26edc4"
|
| 12 |
+
},
|
| 13 |
+
"output": {
|
| 14 |
+
"path": "web/public/maps/bangalore_roads_graph.json.gz",
|
| 15 |
+
"sha256": "f91aa66d5eecfe6c10bfbc974d38b18d547e145967a0a3ef3b5ce5dc038f2f9b"
|
| 16 |
+
},
|
| 17 |
+
"params": {
|
| 18 |
+
"geom_every": 4,
|
| 19 |
+
"keep_only_largest_component": true,
|
| 20 |
+
"snap_decimals": 5,
|
| 21 |
+
"speed_kmh": {
|
| 22 |
+
"motorway": 65,
|
| 23 |
+
"primary": 45,
|
| 24 |
+
"residential": 22,
|
| 25 |
+
"secondary": 35,
|
| 26 |
+
"service": 16,
|
| 27 |
+
"tertiary": 28,
|
| 28 |
+
"trunk": 60
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
}
|
web/dist/maps/bangalore_roads_full.geojson
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3ff919bfed1d055c5a9ab096f2bfa3c5eb4fc580ff0ac84084a563360f26edc4
|
| 3 |
+
size 51801935
|
web/dist/maps/bangalore_roads_graph.json.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f91aa66d5eecfe6c10bfbc974d38b18d547e145967a0a3ef3b5ce5dc038f2f9b
|
| 3 |
+
size 6175675
|
web/dist/maps/bangalore_roads_render.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/public/maps/bangalore_roads_build_meta.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"counts": {
|
| 3 |
+
"edges": 201510,
|
| 4 |
+
"features": 218355,
|
| 5 |
+
"kept_node_ratio": 0.522341,
|
| 6 |
+
"nodes_kept": 185883,
|
| 7 |
+
"nodes_raw": 355865
|
| 8 |
+
},
|
| 9 |
+
"input": {
|
| 10 |
+
"path": "web/public/maps/bangalore_roads_full.geojson",
|
| 11 |
+
"sha256": "3ff919bfed1d055c5a9ab096f2bfa3c5eb4fc580ff0ac84084a563360f26edc4"
|
| 12 |
+
},
|
| 13 |
+
"output": {
|
| 14 |
+
"path": "web/public/maps/bangalore_roads_graph.json.gz",
|
| 15 |
+
"sha256": "f91aa66d5eecfe6c10bfbc974d38b18d547e145967a0a3ef3b5ce5dc038f2f9b"
|
| 16 |
+
},
|
| 17 |
+
"params": {
|
| 18 |
+
"geom_every": 4,
|
| 19 |
+
"keep_only_largest_component": true,
|
| 20 |
+
"snap_decimals": 5,
|
| 21 |
+
"speed_kmh": {
|
| 22 |
+
"motorway": 65,
|
| 23 |
+
"primary": 45,
|
| 24 |
+
"residential": 22,
|
| 25 |
+
"secondary": 35,
|
| 26 |
+
"service": 16,
|
| 27 |
+
"tertiary": 28,
|
| 28 |
+
"trunk": 60
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
}
|
web/public/maps/bangalore_roads_full.geojson
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3ff919bfed1d055c5a9ab096f2bfa3c5eb4fc580ff0ac84084a563360f26edc4
|
| 3 |
+
size 51801935
|
web/public/maps/bangalore_roads_graph.json.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f91aa66d5eecfe6c10bfbc974d38b18d547e145967a0a3ef3b5ce5dc038f2f9b
|
| 3 |
+
size 6175675
|
web/public/maps/bangalore_roads_render.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/map/MapView.ts
CHANGED
|
@@ -109,18 +109,31 @@ export class MapView {
|
|
| 109 |
];
|
| 110 |
this.map.fitBounds(bounds as any, { padding: 60, duration: 600, maxZoom: 13.8 });
|
| 111 |
|
| 112 |
-
// Load
|
| 113 |
-
|
| 114 |
-
const gj = await fetch(roadsUrl).then((r) => r.json());
|
| 115 |
-
const feats = Array.isArray(gj?.features) ? gj.features : [];
|
| 116 |
const paths: { path: [number, number][]; highway: string }[] = [];
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
const
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
(this as any)._roads = paths;
|
|
|
|
| 109 |
];
|
| 110 |
this.map.fitBounds(bounds as any, { padding: 60, duration: 600, maxZoom: 13.8 });
|
| 111 |
|
| 112 |
+
// Load simplified render paths (much smaller than GeoJSON).
|
| 113 |
+
// Fallback to GeoJSON only if render file is missing.
|
|
|
|
|
|
|
| 114 |
const paths: { path: [number, number][]; highway: string }[] = [];
|
| 115 |
+
try {
|
| 116 |
+
const renderUrl = staticAssetUrl("maps/bangalore_roads_render.json");
|
| 117 |
+
const rows = (await fetch(renderUrl).then((r) => r.json())) as any[];
|
| 118 |
+
if (Array.isArray(rows)) {
|
| 119 |
+
for (const row of rows) {
|
| 120 |
+
const hw = String(row?.highway || "");
|
| 121 |
+
const coords = row?.path;
|
| 122 |
+
if (!Array.isArray(coords) || coords.length < 2) continue;
|
| 123 |
+
paths.push({ path: coords as [number, number][], highway: hw });
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
} catch {
|
| 127 |
+
const roadsUrl = staticAssetUrl("maps/bangalore_roads_full.geojson");
|
| 128 |
+
const gj = await fetch(roadsUrl).then((r) => r.json());
|
| 129 |
+
const feats = Array.isArray(gj?.features) ? gj.features : [];
|
| 130 |
+
for (const f of feats) {
|
| 131 |
+
if (f?.geometry?.type !== "LineString") continue;
|
| 132 |
+
const hw = String(f?.properties?.highway || "");
|
| 133 |
+
const coords = f?.geometry?.coordinates;
|
| 134 |
+
if (!Array.isArray(coords) || coords.length < 2) continue;
|
| 135 |
+
paths.push({ path: coords as [number, number][], highway: hw });
|
| 136 |
+
}
|
| 137 |
}
|
| 138 |
|
| 139 |
(this as any)._roads = paths;
|