diff --git a/backend/services/navi_offroute/app.py b/backend/services/navi_offroute/app.py index ad30e8c..639a99c 100644 --- a/backend/services/navi_offroute/app.py +++ b/backend/services/navi_offroute/app.py @@ -11,6 +11,7 @@ from shared.git_sha import git_short_sha from . import offroute_route, admin from .mvum import MVUMSpatialIndex +from .mvum_transitions import load_trailheads # Process-wide singleton: build the MVUM spatial index once per process (per gunicorn # worker in prod; once across create_app() calls in tests), not once per app instance. @@ -54,6 +55,13 @@ def create_app(): app.logger.warning("MVUM spatial index failed to load: %s", e) app.config['MVUM_SPATIAL_INDEX'] = None + # Layer 3a: trailhead transition index (process-wide singleton, logs its own line). + try: + app.config['MVUM_TRAILHEAD_INDEX'] = load_trailheads() + except Exception as e: + app.logger.warning("MVUM trailhead index failed to load: %s", e) + app.config['MVUM_TRAILHEAD_INDEX'] = None + app.register_blueprint(offroute_route.bp) app.register_blueprint(admin.bp) return app diff --git a/backend/services/navi_offroute/mvum_transitions.py b/backend/services/navi_offroute/mvum_transitions.py new file mode 100644 index 0000000..f1544bf --- /dev/null +++ b/backend/services/navi_offroute/mvum_transitions.py @@ -0,0 +1,98 @@ +""" +MVUM Layer 3a: trailhead transition index for multi-modal Auto routing. + +Loads the ``trail_entry_points`` table from navi.db into a shapely STRtree of +trailhead points and supports finding the trailheads near a route polyline. This +is pure spatial lookup — no routing logic — mirroring the MVUMSpatialIndex +(Layer 0) pattern: built once per process as a singleton via load_trailheads(). + +The router (``_route_auto``) uses these points as drive->offroad transition +candidates: a hybrid "drive to a trailhead, switch vehicles, continue offroad" +plan is considered when it beats the single-mode winner by a comfortable margin. +""" +import logging +import sqlite3 +import time as _time +from pathlib import Path + +from shapely.geometry import Point, LineString +from shapely.strtree import STRtree + +from .mvum import navi_db_path, _buffer_degrees_for_meters + +logger = logging.getLogger("navi_offroute.mvum_transitions") + + +class TrailheadIndex: + """In-memory STRtree over ``trail_entry_points`` (trailhead/road access points). + + Keeps the STRtree of point geometries plus a parallel ``records`` list of + ``{lat, lon, name, road_class}`` dicts aligned with the tree's geometries. + (The DB column is ``highway_class``; it is surfaced here as ``road_class`` for + consistency with the entry-point records the router already emits.) + """ + + def __init__(self, db_path=None): + t0 = _time.perf_counter() + self.db_path = Path(db_path) if db_path else navi_db_path() + self.records = [] # aligned with self._points + self._points = [] + + conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + try: + cur = conn.execute( + "SELECT lat, lon, name, highway_class FROM trail_entry_points " + "WHERE lat IS NOT NULL AND lon IS NOT NULL" + ) + for row in cur: + lat = float(row["lat"]) + lon = float(row["lon"]) + self.records.append({ + "lat": lat, + "lon": lon, + "name": row["name"] or "", + "road_class": row["highway_class"] or "", + }) + self._points.append(Point(lon, lat)) + finally: + conn.close() + + self._tree = STRtree(self._points) if self._points else None + self.count = len(self.records) + self.build_time_seconds = _time.perf_counter() - t0 + logger.info( + "Trailhead index loaded: %d entry points in %.2f seconds", + self.count, self.build_time_seconds, + ) + + def query_trailheads_near_line(self, coords, buffer_m=2000): + """Trailhead records within ~``buffer_m`` of a (lat, lon) polyline. + + Coarse STRtree bbox prefilter followed by a precise degree-distance check + so only points genuinely close to the line are returned (the bbox alone + would admit corner points up to ~1.4x buffer away). + """ + if not coords or self._tree is None: + return [] + pts = [(lon, lat) for (lat, lon) in coords] + geom = LineString(pts) if len(pts) >= 2 else Point(pts[0]) + avg_lat = sum(lat for (lat, lon) in coords) / len(coords) + buffer_deg = _buffer_degrees_for_meters(buffer_m, avg_lat) + out = [] + for i in self._tree.query(geom.buffer(buffer_deg)): + if geom.distance(self._points[i]) <= buffer_deg: + out.append(self.records[i]) + return out + + +# Process-wide singleton, mirroring app.py's _MVUM_INDEX handling. +_TRAILHEAD_INDEX = None + + +def load_trailheads(db_path=None): + """Return the process-wide TrailheadIndex singleton, building it on first call.""" + global _TRAILHEAD_INDEX + if _TRAILHEAD_INDEX is None: + _TRAILHEAD_INDEX = TrailheadIndex(db_path) + return _TRAILHEAD_INDEX diff --git a/backend/services/navi_offroute/offroute_route.py b/backend/services/navi_offroute/offroute_route.py index 3a26252..7029b92 100644 --- a/backend/services/navi_offroute/offroute_route.py +++ b/backend/services/navi_offroute/offroute_route.py @@ -73,6 +73,8 @@ def api_offroute(): router = OffrouteRouter() # Inject the Layer-0 MVUM spatial index singleton for Layer-1 annotation. router.spatial_index = current_app.config.get('MVUM_SPATIAL_INDEX') + # Inject the Layer-3a trailhead index for multi-modal Auto transitions. + router.trailhead_index = current_app.config.get('MVUM_TRAILHEAD_INDEX') try: result = router.route( start_lat=start_lat, start_lon=start_lon, diff --git a/backend/services/navi_offroute/router.py b/backend/services/navi_offroute/router.py index 45e5b52..79d7a9b 100755 --- a/backend/services/navi_offroute/router.py +++ b/backend/services/navi_offroute/router.py @@ -32,7 +32,7 @@ import psutil import requests import psycopg2 import psycopg2.extras -from shapely.geometry import LineString +from shapely.geometry import LineString, Point from .astar import astar_multigoal, inflate_cost_multiplier from shared.dem import DEMReader, dem_path @@ -98,6 +98,18 @@ MODE_TO_COSTING = { # demanding terrain) and uses the first that yields a usable route. AUTO_MODE_PRIORITY = ["vehicle", "4w", "2w", "foot"] +# MVUM Layer 3a: implicit multi-modal Auto. On long trips, "drive in to a trailhead, +# switch vehicles, continue offroad" can beat the single-mode winner; Auto picks that +# hybrid plan when it does. Leg times are summed with NO transition penalty, and the +# minimums below keep the suggestions sensible (no short trips / trivial detours). +MIN_HYBRID_DISTANCE_KM = 8.0 # ~5 mi: shorter single-mode wins stay as-is +HYBRID_MIN_TIME_SAVINGS_MIN = 15.0 # a hybrid must beat the winner by at least this +HYBRID_MIN_OFFROAD_KM = 0.8 # ~0.5 mi: reject trivial offroad detours +HYBRID_MAX_TRAILHEADS = 20 # cap candidates (closest to the route first) +HYBRID_TRAILHEAD_BUFFER_M = 2000 # candidate trailheads within 2 km of the route +# (drive_mode, offroad_mode) transition pairs, tried at each candidate trailhead. +HYBRID_PAIRS = [("vehicle", "4w"), ("vehicle", "2w"), ("vehicle", "foot"), ("4w", "foot")] + # Per-endpoint travel-mode eligibility from an OSM-style "key:value" category hint. # Looked up exact first, then "key:*" wildcard (see _eligible_modes_from_category). _MODES_ALL = frozenset({"vehicle", "4w", "2w", "foot"}) @@ -536,6 +548,7 @@ class OffrouteRouter: self.spatial_index = None # MVUMSpatialIndex (Layer 0), injected by the handler self.mvum_on_date = None # optional datetime for seasonal MVUM checks self._exclude_polygons = None # MVUM Layer 2c, set per route() call + self.trailhead_index = None # TrailheadIndex (Layer 3a), injected by the handler def _init_readers(self): """Lazy init readers.""" @@ -863,6 +876,14 @@ class OffrouteRouter: last_error = result if best_result is not None: + # MVUM Layer 3a: a "drive to a trailhead, switch, continue offroad" plan may + # beat the single-mode winner on long trips. If so, return it instead. + hybrid = self._try_hybrid_auto( + start_lat, start_lon, end_lat, end_lon, boundary_mode, + best_result, best_minutes, intersection) + if hybrid is not None: + hybrid["selected_mode_set"] = mode_set + return hybrid best_result["selected_mode_set"] = mode_set self._annotate_network_segments(best_result, best_result["selected_mode"]) return best_result @@ -876,6 +897,176 @@ class OffrouteRouter: "selected_mode_set": mode_set, } + def _route_coords_latlon(self, result): + """Flatten a route response's polyline to [(lat, lon), ...]. Prefers the + single "combined" full-path feature; otherwise concatenates LineStrings.""" + feats = (result.get("route") or {}).get("features", []) + for f in feats: + if (f.get("properties") or {}).get("segment_type") == "combined": + cs = (f.get("geometry") or {}).get("coordinates") or [] + return [(c[1], c[0]) for c in cs] + out = [] + for f in feats: + if (f.get("geometry") or {}).get("type") != "LineString": + continue + out.extend((c[1], c[0]) for c in (f["geometry"].get("coordinates") or [])) + return out + + def _try_hybrid_auto(self, start_lat, start_lon, end_lat, end_lon, + boundary_mode, best_result, best_minutes, intersection): + """MVUM Layer 3a: consider drive->trailhead->offroad hybrid plans. + + Returns a combined "multi" response if some trailhead transition beats the + single-mode winner by HYBRID_MIN_TIME_SAVINGS_MIN, else None (caller keeps + the single-mode winner). Leg times are summed with no transition penalty. + """ + idx = getattr(self, "trailhead_index", None) + if idx is None: + return None + best_summary = best_result.get("summary") or {} + if best_summary.get("total_distance_km", 0.0) < MIN_HYBRID_DISTANCE_KM: + return None + + coords = self._route_coords_latlon(best_result) + if len(coords) < 2: + return None + candidates = idx.query_trailheads_near_line( + coords, buffer_m=HYBRID_TRAILHEAD_BUFFER_M) + if not candidates: + return None + # Closest-to-route first, then cap. + line = LineString([(lon, lat) for (lat, lon) in coords]) + candidates.sort(key=lambda th: line.distance(Point(th["lon"], th["lat"]))) + candidates = candidates[:HYBRID_MAX_TRAILHEADS] + + # Per trailhead, leg1 depends only on drive_mode and leg2 only on offroad_mode, + # so route each distinct mode once and recombine across pairs. + drive_modes = sorted({d for d, _ in HYBRID_PAIRS}) + offroad_modes = sorted({o for _, o in HYBRID_PAIRS}) + + threshold = best_minutes - HYBRID_MIN_TIME_SAVINGS_MIN + winner = None + winner_minutes = None + for th in candidates: + leg1_by_mode = {} + for dm in drive_modes: + r = self.route(start_lat, start_lon, th["lat"], th["lon"], + mode=dm, boundary_mode=boundary_mode, annotate_mvum=False) + if r.get("status") == "ok": + leg1_by_mode[dm] = r + leg2_by_mode = {} + for om in offroad_modes: + r = self.route(th["lat"], th["lon"], end_lat, end_lon, + mode=om, boundary_mode=boundary_mode, annotate_mvum=False) + if r.get("status") != "ok": + continue + if (r.get("summary") or {}).get("total_distance_km", 0.0) < HYBRID_MIN_OFFROAD_KM: + continue # no trivial offroad detours + leg2_by_mode[om] = r + + for dm, om in HYBRID_PAIRS: + leg1 = leg1_by_mode.get(dm) + leg2 = leg2_by_mode.get(om) + if leg1 is None or leg2 is None: + continue + total = ((leg1.get("summary") or {}).get("total_effort_minutes", float("inf")) + + (leg2.get("summary") or {}).get("total_effort_minutes", float("inf"))) + if total < threshold and (winner_minutes is None or total < winner_minutes): + winner_minutes = total + winner = (leg1, leg2, dm, om, th) + + if winner is None: + return None + leg1, leg2, drive_mode, offroad_mode, th = winner + return self._build_hybrid_response(leg1, leg2, drive_mode, offroad_mode, th) + + def _build_hybrid_response(self, leg1, leg2, drive_mode, offroad_mode, trailhead): + """Combine two route legs into one "multi" scenario response with a transition + marker at the trailhead. Each leg is annotated separately (probing ran with + annotate_mvum=False); summary fields are summed across legs.""" + self._annotate_network_segments(leg1, drive_mode) + self._annotate_network_segments(leg2, offroad_mode) + + def leg_features(leg, leg_no, mode): + out = [] + for f in (leg.get("route") or {}).get("features", []): + props = dict(f.get("properties") or {}) + if props.get("segment_type") == "combined": + continue # drop per-leg full-path lines; we keep network/wilderness + # network_mode drives the map's per-mode polyline color; wilderness=foot + if "network_mode" not in props: + props["network_mode"] = ( + "foot" if props.get("segment_type") == "wilderness" else mode) + props["leg"] = leg_no + out.append({"type": "Feature", "properties": props, + "geometry": f.get("geometry")}) + return out + + features = leg_features(leg1, 1, drive_mode) + features.append({ + "type": "Feature", + "properties": { + "segment_type": "transition", + "kind": "transition", + "lat": trailhead["lat"], + "lon": trailhead["lon"], + "name": trailhead.get("name", ""), + "from_mode": drive_mode, + "to_mode": offroad_mode, + }, + "geometry": {"type": "Point", + "coordinates": [trailhead["lon"], trailhead["lat"]]}, + }) + features.extend(leg_features(leg2, 2, offroad_mode)) + + s1 = leg1.get("summary") or {} + s2 = leg2.get("summary") or {} + + def leg_summary(s, mode): + return { + "mode": mode, + "distance_km": float(s.get("total_distance_km", 0.0)), + "minutes": float(s.get("total_effort_minutes", 0.0)), + "segments_summary": { + "scenario": s.get("scenario"), + "network_km": float(s.get("network_distance_km", 0.0)), + "wilderness_km": float(s.get("wilderness_distance_km", 0.0)), + }, + } + + total_distance = (float(s1.get("total_distance_km", 0.0)) + + float(s2.get("total_distance_km", 0.0))) + total_minutes = (float(s1.get("total_effort_minutes", 0.0)) + + float(s2.get("total_effort_minutes", 0.0))) + summary = { + "total_distance_km": total_distance, + "total_effort_minutes": total_minutes, + "wilderness_minutes": (float(s1.get("wilderness_effort_minutes", 0.0)) + + float(s2.get("wilderness_effort_minutes", 0.0))), + "network_minutes": (float(s1.get("network_duration_minutes", 0.0)) + + float(s2.get("network_duration_minutes", 0.0))), + "mvum_closed_crossings": (int(s1.get("mvum_closed_crossings", 0) or 0) + + int(s2.get("mvum_closed_crossings", 0) or 0)), + "mvum_segments_annotated": (int(s1.get("mvum_segments_annotated", 0) or 0) + + int(s2.get("mvum_segments_annotated", 0) or 0)), + "scenario": "multi", + "network_mode": offroad_mode, + "wilderness_mode": "foot", + "legs": [leg_summary(s1, drive_mode), leg_summary(s2, offroad_mode)], + "transition": { + "lat": trailhead["lat"], "lon": trailhead["lon"], + "name": trailhead.get("name", ""), + "from_mode": drive_mode, "to_mode": offroad_mode, + }, + } + return { + "status": "ok", + "route": {"type": "FeatureCollection", "features": features}, + "summary": summary, + "selected_mode": "hybrid", + "scenario": "multi", + } + def _route_D_network_only( self, start_lat: float, start_lon: float, diff --git a/backend/services/navi_offroute/tests/test_mvum_transitions.py b/backend/services/navi_offroute/tests/test_mvum_transitions.py new file mode 100644 index 0000000..abc8809 --- /dev/null +++ b/backend/services/navi_offroute/tests/test_mvum_transitions.py @@ -0,0 +1,201 @@ +"""MVUM Layer 3a tests: trailhead transition index + multi-modal Auto hybrids. + +The index tests build a TrailheadIndex from a synthetic trail_entry_points table. +The hybrid tests drive OffrouteRouter._try_hybrid_auto on a bare instance with a +stubbed self.route, so no Valhalla/DEM dependencies are exercised. +""" +import sqlite3 + +import pytest + +from services.navi_offroute.mvum_transitions import TrailheadIndex +from services.navi_offroute.router import OffrouteRouter + + +def _trailhead_db(tmp_path, points): + """points: list of (lat, lon, highway_class, name).""" + db = tmp_path / "navi.db" + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE trail_entry_points " + "(id INTEGER PRIMARY KEY, lat REAL, lon REAL, highway_class TEXT, name TEXT)" + ) + conn.executemany( + "INSERT INTO trail_entry_points (lat, lon, highway_class, name) VALUES (?,?,?,?)", + points, + ) + conn.commit() + conn.close() + return db + + +# ── index ──────────────────────────────────────────────────────────────── + +def test_trailhead_index_loads(tmp_path): + db = _trailhead_db(tmp_path, [ + (44.00, -114.00, "track", "Trailhead A"), + (44.01, -114.02, "residential", "Road B"), + ]) + idx = TrailheadIndex(db_path=db) + assert idx.count == 2 + assert len(idx.records) == len(idx._points) == 2 + rec = idx.records[0] + assert rec["name"] == "Trailhead A" + assert rec["road_class"] == "track" # highway_class surfaced as road_class + assert rec["lat"] == 44.00 and rec["lon"] == -114.00 + + +def test_query_trailheads_near_line_returns_close_only(tmp_path): + # One point sits right on the line; one is ~30 km away (well outside 2 km). + db = _trailhead_db(tmp_path, [ + (44.000, -114.000, "track", "On Line"), + (44.300, -114.000, "track", "Far Away"), + ]) + idx = TrailheadIndex(db_path=db) + line = [(44.000, -114.010), (44.000, 113.990 * -1)] # ~horizontal segment at lat 44 + near = idx.query_trailheads_near_line(line, buffer_m=2000) + names = {r["name"] for r in near} + assert "On Line" in names + assert "Far Away" not in names + + +# ── hybrid selection (stubbed self.route) ────────────────────────────────── + +def _ok_leg(distance_km, minutes, scenario="D"): + return { + "status": "ok", + "route": {"type": "FeatureCollection", "features": [ + {"type": "Feature", + "properties": {"segment_type": "network", "network_mode": "x"}, + "geometry": {"type": "LineString", + "coordinates": [[-114.0, 44.0], [-114.1, 44.1]]}}, + ]}, + "summary": { + "total_distance_km": distance_km, + "total_effort_minutes": minutes, + "network_distance_km": distance_km, + "network_duration_minutes": minutes, + "wilderness_distance_km": 0.0, + "wilderness_effort_minutes": 0.0, + "scenario": scenario, + }, + } + + +class _FakeTrailheads: + def __init__(self, records): + self._records = records + + def query_trailheads_near_line(self, coords, buffer_m=2000): + return list(self._records) + + +def _bare_router(trailheads=None): + r = object.__new__(OffrouteRouter) + r.spatial_index = None + r.trailhead_index = trailheads + return r + + +def _winning_single_mode(distance_km, minutes): + """A single-mode best_result with a combined polyline of the given distance.""" + res = _ok_leg(distance_km, minutes) + res["route"]["features"].append({ + "type": "Feature", + "properties": {"segment_type": "combined"}, + "geometry": {"type": "LineString", + "coordinates": [[-114.0, 44.0], [-114.5, 44.0]]}, + }) + res["selected_mode"] = "vehicle" + return res + + +def test_hybrid_meets_mitigations(monkeypatch): + # Short trip (< MIN_HYBRID_DISTANCE_KM) -> never goes hybrid. + th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}]) + r = _bare_router(th) + monkeypatch.setattr(OffrouteRouter, "route", + lambda self, *a, **k: _ok_leg(1.0, 5.0)) + best = _winning_single_mode(distance_km=5.0, minutes=60.0) # 5 km < 8 km + out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic", + best, 60.0, frozenset({"vehicle", "4w", "2w", "foot"})) + assert out is None + + +def test_hybrid_wins_with_big_savings(monkeypatch): + th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "Sawtooth TH", + "road_class": "track"}]) + r = _bare_router(th) + + # Drive legs are fast; offroad legs are short-but-meaningful and fast. Any leg + # combo sums to ~50 min vs the 120 min single-mode winner -> saves > 15 min. + def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot", + boundary_mode="pragmatic", annotate_mvum=True, **k): + if mode == "vehicle": + return _ok_leg(12.0, 20.0) + return _ok_leg(4.0, 30.0) # 4w/2w/foot offroad legs (>= 0.8 km) + monkeypatch.setattr(OffrouteRouter, "route", fake_route) + + best = _winning_single_mode(distance_km=20.0, minutes=120.0) + out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic", + best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"})) + assert out is not None + assert out["selected_mode"] == "hybrid" + assert out["summary"]["scenario"] == "multi" + assert len(out["summary"]["legs"]) == 2 + assert out["summary"]["total_effort_minutes"] == pytest.approx(50.0) + # one transition marker present in the combined feature collection + kinds = [f["properties"].get("kind") for f in out["route"]["features"]] + assert kinds.count("transition") == 1 + trans = next(f for f in out["route"]["features"] + if f["properties"].get("kind") == "transition") + assert trans["properties"]["name"] == "Sawtooth TH" + assert trans["geometry"]["type"] == "Point" + + +def test_hybrid_skips_trivial_offroad_detour(monkeypatch): + th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}]) + r = _bare_router(th) + + # Offroad legs are below HYBRID_MIN_OFFROAD_KM (0.8 km) -> rejected, so even + # though the time math would otherwise win, no hybrid is produced. + def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot", + boundary_mode="pragmatic", annotate_mvum=True, **k): + if mode == "vehicle": + return _ok_leg(12.0, 20.0) + return _ok_leg(0.3, 5.0) # < 0.8 km offroad + monkeypatch.setattr(OffrouteRouter, "route", fake_route) + + best = _winning_single_mode(distance_km=20.0, minutes=120.0) + out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic", + best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"})) + assert out is None + + +def test_no_trailheads_falls_back_to_single_mode(monkeypatch): + r = _bare_router(_FakeTrailheads([])) # no candidates near the line + monkeypatch.setattr(OffrouteRouter, "route", + lambda self, *a, **k: _ok_leg(5.0, 10.0)) + best = _winning_single_mode(distance_km=20.0, minutes=120.0) + out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic", + best, 120.0, frozenset({"vehicle", "4w", "2w", "foot"})) + assert out is None + + +def test_hybrid_not_taken_when_savings_below_threshold(monkeypatch): + # Hybrid total (40 min) is faster than the winner (50 min) but only by 10 min + # (< HYBRID_MIN_TIME_SAVINGS_MIN = 15) -> single-mode winner is kept. + th = _FakeTrailheads([{"lat": 44.0, "lon": -114.25, "name": "TH", "road_class": "track"}]) + r = _bare_router(th) + + def fake_route(self, s_lat, s_lon, e_lat, e_lon, mode="foot", + boundary_mode="pragmatic", annotate_mvum=True, **k): + if mode == "vehicle": + return _ok_leg(12.0, 20.0) + return _ok_leg(4.0, 20.0) + monkeypatch.setattr(OffrouteRouter, "route", fake_route) + + best = _winning_single_mode(distance_km=20.0, minutes=50.0) + out = r._try_hybrid_auto(44.0, -114.0, 44.0, -114.5, "pragmatic", + best, 50.0, frozenset({"vehicle", "4w", "2w", "foot"})) + assert out is None diff --git a/frontend/src/components/DirectionsPanel.jsx b/frontend/src/components/DirectionsPanel.jsx index f6bb70b..5fe0f79 100644 --- a/frontend/src/components/DirectionsPanel.jsx +++ b/frontend/src/components/DirectionsPanel.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical, Repeat } from "lucide-react" import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" @@ -16,7 +16,9 @@ const TRAVEL_MODES = [ ] // Maps the backend's selected_mode to the chip label shown in the "Auto chose X" badge. -const SELECTED_MODE_LABEL = { vehicle: "Drive", "4w": "4W", "2w": "2W", foot: "Foot" } +const SELECTED_MODE_LABEL = { vehicle: "Drive", "4w": "4W", "2w": "2W", foot: "Foot", hybrid: "Multi-modal", multi: "Multi-modal" } + +const KM_TO_MI = 0.621371 const BOUNDARY_MODES = [ { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, @@ -331,6 +333,23 @@ export default function DirectionsPanel({ onClose }) { )} + {/* MVUM Layer 3a: per-leg breakdown for a multi-modal (transition) trip */} + {(routeResult?.selected_mode === "hybrid" || routeResult?.selected_mode === "multi") + && Array.isArray(routeResult?.summary?.legs) && ( +
+ {routeResult.summary.legs.map((leg, i) => ( + + {i > 0 && } + {`${SELECTED_MODE_LABEL[leg.mode] || leg.mode} ${(leg.distance_km * KM_TO_MI).toFixed(1)} mi (${Math.round(leg.minutes)}min)`} + + ))} + {`— total ${Math.round(routeResult.summary.total_effort_minutes)}min`} +
+ )} + {/* MVUM Layer 1: network-leg closures crossed for the selected mode */} {routeResult?.summary?.mvum_closed_crossings > 0 && (
m.remove()) + map._offrouteTransitionMarkers = [] + } } /** Update offroute display with route GeoJSON */ @@ -1406,16 +1414,43 @@ function updateRouteDisplay(map, routeGeojson) { filter: ["==", ["get", "segment_type"], "network"], layout: { "line-join": "round", "line-cap": "round" }, paint: { - "line-color": "#3b82f6", // blue-500 + // Layer 3a: color each network leg by its travel mode (hybrid trips mix modes). + "line-color": [ + "match", ["get", "network_mode"], + "vehicle", MODE_COLORS.vehicle, "auto", MODE_COLORS.auto, + "4w", MODE_COLORS["4w"], "2w", MODE_COLORS["2w"], "foot", MODE_COLORS.foot, + "#3b82f6", // default (single-mode legacy blue) + ], "line-width": 5, "line-opacity": 0.85, }, }, beforeId) - // Fit bounds to route const features = routeGeojson.features || [] + + // Layer 3a: transition markers (drive -> offroad vehicle switch) at trailheads. + map._offrouteTransitionMarkers = map._offrouteTransitionMarkers || [] + for (const f of features) { + if (f.properties?.kind !== "transition" || !f.geometry?.coordinates) continue + const el = document.createElement("div") + el.style.cssText = "width:26px;height:26px;border-radius:50%;background:#fff;" + + "border:2px solid #333;display:flex;align-items:center;justify-content:center;" + + "cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,0.4)" + createRoot(el).render() + const toLabel = MODE_LABELS[f.properties.to_mode] || f.properties.to_mode + const marker = new maplibregl.Marker({ element: el }) + .setLngLat(f.geometry.coordinates) + .setPopup(new maplibregl.Popup({ offset: 14, closeButton: false }) + .setText(`Switch to ${toLabel}`)) + .addTo(map) + el.addEventListener("mouseenter", () => marker.togglePopup()) + el.addEventListener("mouseleave", () => marker.togglePopup()) + map._offrouteTransitionMarkers.push(marker) + } + + // Fit bounds to route (LineString segments only; transition points are Points) const allCoords = features - .filter(f => f.geometry?.coordinates) + .filter(f => f.geometry?.type === "LineString") .flatMap(f => f.geometry.coordinates) if (allCoords.length > 0) {