diff --git a/apps/api/src/services/provider-health/__tests__/index.test.ts b/apps/api/src/services/provider-health/__tests__/index.test.ts index 5b2c8df5..54e8989e 100644 --- a/apps/api/src/services/provider-health/__tests__/index.test.ts +++ b/apps/api/src/services/provider-health/__tests__/index.test.ts @@ -27,12 +27,12 @@ class FakeRedis { } async runCommand(_name: string, args: unknown[]): Promise { - // Signature mirrors the script's KEYS/ARGV order: - // [keyCount, key, op, latencyMs, nowIso, reason, + // Mirrors ioredis `defineCommand({ numberOfKeys: 1 })`: the method is called + // as (key, ...ARGV) — ioredis injects the key count, callers never pass it. + // [key, op, latencyMs, nowIso, reason, // windowSize, cooldownMs, cooldownUntilIso, // threshold, minSampleSize, emaAlpha, ttlSeconds] const [ - , key, op, latencyMs, @@ -134,6 +134,14 @@ function createRedis(): Redis { return new FakeRedis() as unknown as Redis; } +/** A redis whose health-store command always rejects, to test best-effort recording. */ +function createThrowingRedis(): Redis { + const base = new FakeRedis(); + (base as unknown as Record).providerHealthApply = () => + Promise.reject(new Error("redis down")); + return base as unknown as Redis; +} + describe("ProviderHealth", () => { let nowMs = 1_700_000_000_000; const advance = (ms: number) => { @@ -154,6 +162,12 @@ describe("ProviderHealth", () => { expect(await ph.getState("acme")).toBeNull(); }); + it("never throws when the health store is unavailable (best-effort recording)", async () => { + const ph = await ProviderHealth.init({ redis: createThrowingRedis(), now: () => nowMs }); + await expect(ph.recordSuccess("acme", 100)).resolves.toBeUndefined(); + await expect(ph.recordFailure("acme", 50, "boom")).resolves.toBeUndefined(); + }); + it("records successes and failures with EMA latency", async () => { const ph = await ProviderHealth.init({ redis: createRedis(), now: () => nowMs }); await ph.recordSuccess("acme", 100); diff --git a/apps/api/src/services/provider-health/index.ts b/apps/api/src/services/provider-health/index.ts index b72b7c03..f86efbdb 100644 --- a/apps/api/src/services/provider-health/index.ts +++ b/apps/api/src/services/provider-health/index.ts @@ -175,7 +175,6 @@ function annotate(state: ProviderHealthState): ProviderHealthState { interface RedisWithCommand extends Redis { providerHealthApply( - keyCount: number, key: string, op: string, latencyMs: string, @@ -250,7 +249,6 @@ export class ProviderHealth implements ProviderHealthHandle { const nowIso = new Date(nowMs).toISOString(); const cooldownUntilIso = new Date(nowMs + this.cooldownMs).toISOString(); const raw = await this.redis.providerHealthApply( - 1, keyFor(providerId), op, String(Math.max(0, Math.round(latencyMs))), @@ -301,11 +299,27 @@ export class ProviderHealth implements ProviderHealthHandle { } async recordSuccess(providerId: string, latencyMs: number): Promise { - await this.applyOp(providerId, "ok", latencyMs, ""); + // Health tracking is observability, not core function — never let a store + // failure (Redis down, script error) propagate and fail the user's request. + try { + await this.applyOp(providerId, "ok", latencyMs, ""); + } catch (err) { + this.log?.warn( + `[provider-health] failed to record success for ${providerId}: ${(err as Error).message}`, + ); + } } async recordFailure(providerId: string, latencyMs: number, reason: string): Promise { - const state = await this.applyOp(providerId, "err", latencyMs, truncateReason(reason)); + let state: ProviderHealthState; + try { + state = await this.applyOp(providerId, "err", latencyMs, truncateReason(reason)); + } catch (err) { + this.log?.warn( + `[provider-health] failed to record failure for ${providerId}: ${(err as Error).message}`, + ); + return; + } if (state.disabledUntil) { // Was the disable set in this call? Heuristic: lastFailureAt matches // disabledUntil's window-start. We don't need exactness, just stop the diff --git a/apps/web/package.json b/apps/web/package.json index 9fcfe31a..2489f301 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,9 +42,12 @@ "@tanstack/react-query": "^5.100.14", "@tanstack/react-query-persist-client": "^5.100.14", "@tmcw/togeojson": "^7.1.2", + "@turf/along": "^7.3.5", "@turf/area": "^7.3.5", + "@turf/bearing": "^7.3.5", "@turf/helpers": "^7.3.5", "@turf/length": "^7.3.5", + "@turf/line-slice-along": "^7.3.5", "better-auth": "^1.6.11", "emoji-mart": "^5.6.0", "framer-motion": "^12.40.0", diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index a94f5aa0..781302c5 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -16,6 +16,7 @@ import { DataSourceLayer } from "@/components/map/layers/DataSourceLayer"; import { FlightArcLayer } from "@/components/map/layers/FlightArcLayer"; import { GlobeProjection } from "@/components/map/layers/GlobeProjection"; import { ImportedGeometryLayer } from "@/components/map/layers/ImportedGeometryLayer"; +import { NavigationRouteLayer } from "@/components/map/layers/NavigationRouteLayer"; import { PlaceBoundaryLayer } from "@/components/map/layers/PlaceBoundaryLayer"; import { RasterBaseLayer } from "@/components/map/layers/RasterBaseLayer"; import { RouteLayer } from "@/components/map/layers/RouteLayer"; @@ -39,6 +40,9 @@ import { TopRightControls } from "@/components/map/TopRightControls"; import { UserLocationMarker } from "@/components/map/UserLocationMarker"; import { WaypointMarkers } from "@/components/map/WaypointMarkers"; import { HamburgerMenu } from "@/components/menu/HamburgerMenu"; +import { HideDuringNavigation } from "@/components/navigation/HideDuringNavigation"; +import { NavigationView } from "@/components/navigation/NavigationView"; +import { TransitNavigationView } from "@/components/navigation/TransitNavigationView"; import { MapClickFloatingCard } from "@/components/panels/MapClickFloatingCard"; import { PanelHost } from "@/components/panels/PanelHost"; import { ShareIntentHandler } from "@/components/pwa/ShareIntentHandler"; @@ -130,6 +134,9 @@ export default function HomePage() { {/* Core layers (not integration-managed) */} + + + @@ -150,22 +157,28 @@ export default function HomePage() { - - - - - + + + + + + + - + + +
{/* All legends/toolbars loaded dynamically by LegendHost */}
- + + + diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 71d0a3ec..8eb8c182 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -2,7 +2,7 @@ import CssBaseline from "@mui/material/CssBaseline"; import { createTheme, ThemeProvider } from "@mui/material/styles"; -import { configureStorage } from "@openmapx/core"; +import { configureStorage, useSettingsStore } from "@openmapx/core"; import { registerBuiltinIdSchemeViews } from "@openmapx/place-ids"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; @@ -98,6 +98,9 @@ export function Providers({ children }: { children: React.ReactNode }) { useEffect(() => { void enforceRecentMapDataCachePreference(); + // Storage is configured at module scope above, but the settings store may + // have initialized before that ran; re-read the persisted units preference. + useSettingsStore.getState().hydrate(); }, []); const inner = ( diff --git a/apps/web/src/components/map/DeepLinkManager.tsx b/apps/web/src/components/map/DeepLinkManager.tsx index d9a656aa..5ba66734 100644 --- a/apps/web/src/components/map/DeepLinkManager.tsx +++ b/apps/web/src/components/map/DeepLinkManager.tsx @@ -356,7 +356,8 @@ function clearPanelState(): void { directions.setAvoidHighways(false); directions.setAvoidTolls(false); directions.setAvoidFerries(false); - directions.setUnits("metric"); + // Units is a persisted global preference (settings panel), not deep-link + // state — never reset it on navigation, or it would clobber the user's choice. useMeasurementStore.getState().deactivate(); useTravelTimeStore.getState().deactivate(); @@ -394,9 +395,7 @@ function applyDirections(parsed: ParsedDeepLink["directions"]): void { directions.close(); const mode = oneOf(parsed.mode, DIRECTION_MODES); - const units = oneOf(parsed.units, UNIT_SYSTEMS); if (mode) directions.setMode(mode); - if (units) directions.setUnits(units); directions.setAvoidHighways(parsed.avoid.includes("highways")); directions.setAvoidTolls(parsed.avoid.includes("tolls")); directions.setAvoidFerries(parsed.avoid.includes("ferries")); @@ -518,7 +517,6 @@ function encodeDirections(params: URLSearchParams): void { params.set("panel", PANEL.DIRECTIONS); if (directions.mode !== "driving") params.set("mode", directions.mode); - if (directions.units !== "metric") params.set("units", directions.units); const avoid = [ directions.avoidHighways ? "highways" : "", diff --git a/apps/web/src/components/map/MapControls.tsx b/apps/web/src/components/map/MapControls.tsx index 190e240f..20d26422 100644 --- a/apps/web/src/components/map/MapControls.tsx +++ b/apps/web/src/components/map/MapControls.tsx @@ -8,7 +8,7 @@ import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import Paper from "@mui/material/Paper"; import Tooltip from "@mui/material/Tooltip"; -import { useMapStore } from "@openmapx/core"; +import { useMapStore, useNavigationStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { useMyLocation } from "@/components/command-palette/useMyLocation"; @@ -19,10 +19,13 @@ import { Pegman } from "./Pegman"; const BASE_BOTTOM = 48; const PANEL_GAP = 12; +// Clearance above the navigation bottom bar so the controls don't sit under it. +const NAV_BOTTOM = 150; export function MapControls() { const t = useTranslations("map"); const { zoomIn, zoomOut, resetBearing } = useMap(); + const navigating = useNavigationStore((s) => s.status !== "idle"); const bearing = useMapStore((s) => s.bearing); const pitch = useMapStore((s) => s.pitch); const handleMyLocation = useMyLocation(); @@ -45,13 +48,15 @@ export function MapControls() { 0 - ? `calc(${followHeight + PANEL_GAP}px + var(--omx-safe-bottom))` - : `calc(${BASE_BOTTOM}px + var(--omx-safe-bottom))`, - sm: `calc(${BASE_BOTTOM}px + var(--omx-safe-bottom))`, - }, + bottom: navigating + ? `calc(${NAV_BOTTOM}px + var(--omx-safe-bottom))` + : { + xs: + followHeight > 0 + ? `calc(${followHeight + PANEL_GAP}px + var(--omx-safe-bottom))` + : `calc(${BASE_BOTTOM}px + var(--omx-safe-bottom))`, + sm: `calc(${BASE_BOTTOM}px + var(--omx-safe-bottom))`, + }, right: "calc(12px + var(--omx-safe-right))", display: "flex", flexDirection: "column", diff --git a/apps/web/src/components/map/layers/NavigationRouteLayer.tsx b/apps/web/src/components/map/layers/NavigationRouteLayer.tsx new file mode 100644 index 00000000..ed761517 --- /dev/null +++ b/apps/web/src/components/map/layers/NavigationRouteLayer.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useNavigationStore } from "@openmapx/core"; +import { lineString } from "@turf/helpers"; +import lineSliceAlong from "@turf/line-slice-along"; +import type maplibregl from "maplibre-gl"; +import { useEffect } from "react"; +import { useMap } from "@/lib/MapContext"; + +type GeoJSONSource = maplibregl.GeoJSONSource; + +const SOURCE = "nav-route-source"; +const TRAVELED = "nav-route-traveled"; +const REMAINING = "nav-route-remaining"; +const REMAINING_CASING = "nav-route-remaining-casing"; + +const REMAINING_COLOR = "#1a73e8"; +const TRAVELED_COLOR = "#9aa0a6"; + +export function NavigationRouteLayer() { + const { mapRef, mapReady, styleVersion } = useMap(); + const status = useNavigationStore((s) => s.status); + const route = useNavigationStore((s) => s.route); + const progress = useNavigationStore((s) => s.progress); + + // Create source + layers once per style. + useEffect(() => { + void styleVersion; + const map = mapRef.current; + if (!map || !mapReady) return; + if (map.getSource(SOURCE)) return; + + map.addSource(SOURCE, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + map.addLayer({ + id: REMAINING_CASING, + type: "line", + source: SOURCE, + filter: ["==", ["get", "kind"], "remaining"], + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": "#ffffff", "line-width": 11 }, + }); + map.addLayer({ + id: TRAVELED, + type: "line", + source: SOURCE, + filter: ["==", ["get", "kind"], "traveled"], + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": TRAVELED_COLOR, "line-width": 7, "line-opacity": 0.7 }, + }); + map.addLayer({ + id: REMAINING, + type: "line", + source: SOURCE, + filter: ["==", ["get", "kind"], "remaining"], + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": REMAINING_COLOR, "line-width": 8 }, + }); + }, [mapRef, mapReady, styleVersion]); + + // Update split geometry as the user moves. + useEffect(() => { + const map = mapRef.current; + const raw = map?.getSource(SOURCE); + if (!raw || raw.type !== "geojson") return; + const source = raw as GeoJSONSource; + + if (status === "idle" || !route || route.geometry.length < 2) { + source.setData({ type: "FeatureCollection", features: [] }); + return; + } + + const line = lineString(route.geometry); + const totalKm = route.distance / 1000; + const alongKm = progress ? Math.min(progress.alongMeters / 1000, totalKm) : 0; + + const features: GeoJSON.Feature[] = []; + if (alongKm > 0.001) { + features.push({ + type: "Feature", + properties: { kind: "traveled" }, + geometry: lineSliceAlong(line, 0, alongKm, { units: "kilometers" }).geometry, + }); + } + features.push({ + type: "Feature", + properties: { kind: "remaining" }, + geometry: lineSliceAlong(line, alongKm, totalKm, { units: "kilometers" }).geometry, + }); + source.setData({ type: "FeatureCollection", features }); + }, [mapRef, status, route, progress]); + + return null; +} diff --git a/apps/web/src/components/map/layers/RouteLayer.tsx b/apps/web/src/components/map/layers/RouteLayer.tsx index 2a403e68..bfbe83e7 100644 --- a/apps/web/src/components/map/layers/RouteLayer.tsx +++ b/apps/web/src/components/map/layers/RouteLayer.tsx @@ -1,7 +1,7 @@ "use client"; import type { LngLat } from "@openmapx/core"; -import { useDirections, useDirectionsStore } from "@openmapx/core"; +import { useDirections, useDirectionsStore, useSettingsStore } from "@openmapx/core"; import type maplibregl from "maplibre-gl"; import { useEffect, useMemo } from "react"; import { useMap } from "@/lib/MapContext"; @@ -25,8 +25,8 @@ export function RouteLayer() { avoidHighways, avoidTolls, avoidFerries, - units, } = useDirectionsStore(); + const units = useSettingsStore((s) => s.units); const routeWaypoints = useMemo( () => diff --git a/apps/web/src/components/map/layers/TransitItineraryLayer.tsx b/apps/web/src/components/map/layers/TransitItineraryLayer.tsx index 7559db31..39f6b563 100644 --- a/apps/web/src/components/map/layers/TransitItineraryLayer.tsx +++ b/apps/web/src/components/map/layers/TransitItineraryLayer.tsx @@ -1,7 +1,14 @@ "use client"; -import { API_ENDPOINTS, apiClient, MODE_COLORS, useDirectionsStore } from "@openmapx/core"; +import { + API_ENDPOINTS, + apiClient, + MODE_COLORS, + useDirectionsStore, + useNavigationStore, +} from "@openmapx/core"; import type { GeoJSONLineString } from "@openmapx/mobility-core/transit"; +import type { ExpressionSpecification } from "maplibre-gl"; import { useEffect, useRef, useState } from "react"; import { useMap } from "@/lib/MapContext"; import { PRIMARY_BLUE_HEX } from "@/lib/theme"; @@ -16,6 +23,14 @@ export function TransitItineraryLayer() { const { mapRef, mapReady, styleVersion, fitBounds } = useMap(); const { mode, transitItineraries, activeItineraryIndex } = useDirectionsStore(); + // During transit follow-along navigation, dim every leg except the one the + // traveller is currently on so the active segment stands out. When not + // navigating, all legs render at full opacity (normal itinerary preview). + const navActive = useNavigationStore( + (s) => s.status !== "idle" && s.status !== "arrived" && s.kind === "transit", + ); + const navLegIndex = useNavigationStore((s) => s.transitProgress?.currentLegIndex ?? 0); + // Refined per-leg geometries fetched lazily from /leg-geometry after the // itinerary is selected. Keyed by tripId; replaces stopovers geometry when available. const [legGeometries, setLegGeometries] = useState>({}); @@ -85,6 +100,11 @@ export function TransitItineraryLayer() { cleanup(); + // When following a transit trip, dim non-current legs; otherwise full opacity. + const lineOpacity: ExpressionSpecification | number = navActive + ? ["case", ["==", ["get", "index"], navLegIndex], 1, 0.3] + : 1; + // Build line features for each leg; use refined trip geometry when available const lineFeatures = itinerary.legs.map((leg, i) => { const isWalk = leg.mode === "walking"; @@ -139,6 +159,7 @@ export function TransitItineraryLayer() { "line-color": "#757575", "line-width": 4, "line-dasharray": [2, 2], + "line-opacity": lineOpacity, }, layout: { "line-cap": "round", "line-join": "round" }, }); @@ -152,6 +173,7 @@ export function TransitItineraryLayer() { paint: { "line-color": ["get", "color"], "line-width": 5, + "line-opacity": lineOpacity, }, layout: { "line-cap": "round", "line-join": "round" }, }); @@ -211,6 +233,8 @@ export function TransitItineraryLayer() { activeItineraryIndex, fitBounds, legGeometries, + navActive, + navLegIndex, ]); return null; diff --git a/apps/web/src/components/menu/HamburgerMenu.tsx b/apps/web/src/components/menu/HamburgerMenu.tsx index 7ef5d26c..642be271 100644 --- a/apps/web/src/components/menu/HamburgerMenu.tsx +++ b/apps/web/src/components/menu/HamburgerMenu.tsx @@ -12,6 +12,7 @@ import LinkIcon from "@mui/icons-material/Link"; import PrintIcon from "@mui/icons-material/Print"; import SettingsBrightnessIcon from "@mui/icons-material/SettingsBrightness"; import StorageIcon from "@mui/icons-material/Storage"; +import StraightenIcon from "@mui/icons-material/Straighten"; import TranslateIcon from "@mui/icons-material/Translate"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import Box from "@mui/material/Box"; @@ -26,7 +27,7 @@ import ListItemText from "@mui/material/ListItemText"; import Snackbar from "@mui/material/Snackbar"; import { useColorScheme } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; -import { PANEL, useMenuStore, useSession, useSidebarStore } from "@openmapx/core"; +import { PANEL, useMenuStore, useSession, useSettingsStore, useSidebarStore } from "@openmapx/core"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { type ChangeEvent, useRef, useState } from "react"; @@ -51,6 +52,9 @@ export function HamburgerMenu() { const isSignedIn = !!session?.user?.id; const [langOpen, setLangOpen] = useState(false); const [themeOpen, setThemeOpen] = useState(false); + const [unitsOpen, setUnitsOpen] = useState(false); + const units = useSettingsStore((s) => s.units); + const setUnits = useSettingsStore((s) => s.setUnits); const [snackbarOpen, setSnackbarOpen] = useState(false); const [authOpen, setAuthOpen] = useState(false); const [storageOpen, setStorageOpen] = useState(false); @@ -280,6 +284,26 @@ export function HamburgerMenu() { ))} + + setUnitsOpen((prev) => !prev)}> + + + + + + + + + {(["metric", "imperial"] as const).map((u) => ( + setUnits(u)}> + + {u === units ? : null} + + + + ))} + + diff --git a/apps/web/src/components/navigation/ArrivalCard.tsx b/apps/web/src/components/navigation/ArrivalCard.tsx new file mode 100644 index 00000000..383736ff --- /dev/null +++ b/apps/web/src/components/navigation/ArrivalCard.tsx @@ -0,0 +1,20 @@ +"use client"; + +import PlaceIcon from "@mui/icons-material/Place"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import { useTranslations } from "next-intl"; + +export function ArrivalCard({ onClose }: { onClose: () => void }) { + const t = useTranslations("navigation"); + return ( + + + {t("arrived")} + + + ); +} diff --git a/apps/web/src/components/navigation/HideDuringNavigation.tsx b/apps/web/src/components/navigation/HideDuringNavigation.tsx new file mode 100644 index 00000000..bf44b240 --- /dev/null +++ b/apps/web/src/components/navigation/HideDuringNavigation.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useNavigationStore } from "@openmapx/core"; +import type { ReactNode } from "react"; + +/** + * Hides its children while live navigation is active (status !== "idle"). + * Used to clear the map chrome (search bar, category chips, weather, account + * avatar) during turn-by-turn navigation, mirroring Google Maps' nav layout. + */ +export function HideDuringNavigation({ children }: { children: ReactNode }) { + const navigating = useNavigationStore((s) => s.status !== "idle"); + if (navigating) return null; + return <>{children}; +} diff --git a/apps/web/src/components/navigation/LaneGuidance.test.tsx b/apps/web/src/components/navigation/LaneGuidance.test.tsx new file mode 100644 index 00000000..622ebada --- /dev/null +++ b/apps/web/src/components/navigation/LaneGuidance.test.tsx @@ -0,0 +1,21 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { LaneGuidance } from "./LaneGuidance"; + +describe("LaneGuidance", () => { + it("renders nothing when there are no lanes", () => { + expect(renderToStaticMarkup()).toBe(""); + }); + it("renders one element per lane and emphasizes valid lanes", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-valid="true"'); + expect(html).toContain('data-valid="false"'); + }); +}); diff --git a/apps/web/src/components/navigation/LaneGuidance.tsx b/apps/web/src/components/navigation/LaneGuidance.tsx new file mode 100644 index 00000000..6d79cf3f --- /dev/null +++ b/apps/web/src/components/navigation/LaneGuidance.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Box from "@mui/material/Box"; +import type { ManeuverLane } from "@openmapx/core"; +import { maneuverIconFor } from "@/lib/navigation/maneuverIcon"; + +const TO_MANEUVER: Record = { + left: { type: "turn", modifier: "left" }, + right: { type: "turn", modifier: "right" }, + straight: { type: "turn", modifier: "straight" }, + "slight left": { type: "turn", modifier: "slight left" }, + "slight right": { type: "turn", modifier: "slight right" }, +}; + +export function LaneGuidance({ lanes }: { lanes?: ManeuverLane[] }) { + if (!lanes || lanes.length === 0) return null; + return ( + + {lanes.map((lane, i) => { + const ind = lane.indications[0] ?? "straight"; + const Icon = maneuverIconFor(TO_MANEUVER[ind]).component; + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: lanes have no stable id + + + + ); + })} + + ); +} diff --git a/apps/web/src/components/navigation/ManeuverBanner.test.tsx b/apps/web/src/components/navigation/ManeuverBanner.test.tsx new file mode 100644 index 00000000..fc894dce --- /dev/null +++ b/apps/web/src/components/navigation/ManeuverBanner.test.tsx @@ -0,0 +1,29 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => + key === "in" ? `In ${String(values?.distance ?? "")}` : key, +})); +vi.mock("@openmapx/core", () => ({ + formatDistance: (m: number) => `${m} m`, + formatMeasurementDistance: (m: number, sys: string) => + sys === "imperial" ? `${m} ft` : `${m} m`, +})); + +import { ManeuverBanner } from "./ManeuverBanner"; + +describe("ManeuverBanner", () => { + it("renders the instruction and distance to the maneuver", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Turn right onto Main St"); + expect(html).toContain("300 m"); + }); +}); diff --git a/apps/web/src/components/navigation/ManeuverBanner.tsx b/apps/web/src/components/navigation/ManeuverBanner.tsx new file mode 100644 index 00000000..cdd3a836 --- /dev/null +++ b/apps/web/src/components/navigation/ManeuverBanner.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { formatMeasurementDistance } from "@openmapx/core"; +import { useTranslations } from "next-intl"; +import { maneuverIconFor } from "@/lib/navigation/maneuverIcon"; + +interface Props { + instruction: string; + distanceToManeuver: number; + maneuver?: { type: string; modifier?: string }; + units: "metric" | "imperial"; +} + +export function ManeuverBanner({ instruction, distanceToManeuver, maneuver, units }: Props) { + const t = useTranslations("navigation"); + const Icon = maneuverIconFor(maneuver).component; + return ( + + + + + {t("in", { distance: formatMeasurementDistance(distanceToManeuver, units) })} + + {instruction} + + + ); +} diff --git a/apps/web/src/components/navigation/NavBottomBar.test.tsx b/apps/web/src/components/navigation/NavBottomBar.test.tsx new file mode 100644 index 00000000..d81ae397 --- /dev/null +++ b/apps/web/src/components/navigation/NavBottomBar.test.tsx @@ -0,0 +1,34 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (k: string) => k, + useLocale: () => "en", +})); +vi.mock("@openmapx/core", () => ({ + formatDistance: (m: number) => `${m} m`, + formatDuration: (s: number) => `${s}s`, + formatMeasurementDistance: (m: number, sys: string) => + sys === "imperial" ? `${m} ft` : `${m} m`, +})); + +import { NavBottomBar } from "./NavBottomBar"; + +describe("NavBottomBar", () => { + it("renders remaining distance, duration, and an end button", () => { + const html = renderToStaticMarkup( + {}} + onEnd={() => {}} + units="metric" + />, + ); + expect(html).toContain("1200 m"); + expect(html).toContain("300s"); + expect(html).toContain("end"); // i18n key passthrough + }); +}); diff --git a/apps/web/src/components/navigation/NavBottomBar.tsx b/apps/web/src/components/navigation/NavBottomBar.tsx new file mode 100644 index 00000000..f690bcb5 --- /dev/null +++ b/apps/web/src/components/navigation/NavBottomBar.tsx @@ -0,0 +1,66 @@ +"use client"; + +import CloseIcon from "@mui/icons-material/Close"; +import VolumeOffIcon from "@mui/icons-material/VolumeOff"; +import VolumeUpIcon from "@mui/icons-material/VolumeUp"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import { formatDuration, formatMeasurementDistance } from "@openmapx/core"; +import { useLocale, useTranslations } from "next-intl"; + +interface Props { + distanceRemaining: number; + durationRemaining: number; + etaEpochMs: number; + voiceEnabled: boolean; + onToggleVoice: () => void; + onEnd: () => void; + units: "metric" | "imperial"; +} + +export function NavBottomBar({ + distanceRemaining, + durationRemaining, + etaEpochMs, + voiceEnabled, + onToggleVoice, + onEnd, + units, +}: Props) { + const t = useTranslations("navigation"); + const locale = useLocale(); + const etaTime = new Date(etaEpochMs).toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + }); + return ( + + + {formatDuration(durationRemaining)} + + {formatMeasurementDistance(distanceRemaining, units)} · {t("eta", { time: etaTime })} + + + + {voiceEnabled ? : } + + + + ); +} diff --git a/apps/web/src/components/navigation/NavHeadingPuck.tsx b/apps/web/src/components/navigation/NavHeadingPuck.tsx new file mode 100644 index 00000000..0ea54aaa --- /dev/null +++ b/apps/web/src/components/navigation/NavHeadingPuck.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useNavigationStore } from "@openmapx/core"; +import type maplibregl from "maplibre-gl"; +import { useEffect, useRef } from "react"; +import { useMap } from "@/lib/MapContext"; + +export function NavHeadingPuck({ heading }: { heading: number | null }) { + const { mapRef, mapReady } = useMap(); + const progress = useNavigationStore((s) => s.progress); + const status = useNavigationStore((s) => s.status); + const markerRef = useRef(null); + const coneRef = useRef(null); + + // Create the marker element once the map is ready. + useEffect(() => { + const map = mapRef.current; + if (!map || !mapReady || markerRef.current) return; + + let destroyed = false; + + import("maplibre-gl").then(({ default: maplibregl }) => { + if (destroyed || markerRef.current) return; + + const el = document.createElement("div"); + el.style.cssText = "width:22px;height:22px;position:relative;"; + const cone = document.createElement("div"); + cone.style.cssText = + "position:absolute;left:50%;top:50%;width:0;height:0;border-left:9px solid transparent;border-right:9px solid transparent;border-bottom:18px solid #1a73e8;transform-origin:50% 70%;transform:translate(-50%,-70%);"; + const dot = document.createElement("div"); + dot.style.cssText = + "position:absolute;left:50%;top:50%;width:12px;height:12px;border-radius:50%;background:#1a73e8;border:2px solid #fff;transform:translate(-50%,-50%);box-shadow:0 1px 4px rgba(0,0,0,.4);"; + el.appendChild(cone); + el.appendChild(dot); + coneRef.current = cone; + markerRef.current = new maplibregl.Marker({ element: el, anchor: "center" }).setLngLat([ + 0, 0, + ]); + }); + + return () => { + destroyed = true; + }; + }, [mapRef, mapReady]); + + // Remove the marker on unmount. + useEffect(() => { + return () => { + markerRef.current?.remove(); + markerRef.current = null; + coneRef.current = null; + }; + }, []); + + // Reposition + rotate as the user moves. + useEffect(() => { + const map = mapRef.current; + const marker = markerRef.current; + if (!map || !marker) return; + if (status === "idle" || status === "arrived" || !progress) { + marker.remove(); + return; + } + marker.setLngLat(progress.snapped).addTo(map); + if (coneRef.current && heading !== null) { + coneRef.current.style.transform = `translate(-50%,-70%) rotate(${heading}deg)`; + } + }, [mapRef, status, progress, heading]); + + return null; +} diff --git a/apps/web/src/components/navigation/NavigationView.tsx b/apps/web/src/components/navigation/NavigationView.tsx new file mode 100644 index 00000000..38927881 --- /dev/null +++ b/apps/web/src/components/navigation/NavigationView.tsx @@ -0,0 +1,148 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useNavigationStore, useSettingsStore, useSidebarStore } from "@openmapx/core"; +import { useTranslations } from "next-intl"; +import { useEffect } from "react"; +import { useMapOptional } from "@/lib/MapContext"; +import { useFollowCamera } from "@/lib/navigation/useFollowCamera"; +import { useNavigationEngine } from "@/lib/navigation/useNavigationEngine"; +import { useHeading } from "@/lib/useHeading"; +import { useWakeLock } from "@/lib/useWakeLock"; +import { ArrivalCard } from "./ArrivalCard"; +import { LaneGuidance } from "./LaneGuidance"; +import { ManeuverBanner } from "./ManeuverBanner"; +import { NavBottomBar } from "./NavBottomBar"; +import { NavHeadingPuck } from "./NavHeadingPuck"; +import { RecenterFab } from "./RecenterFab"; +import { SpeedLimitBadge } from "./SpeedLimitBadge"; + +export function NavigationView() { + const map = useMapOptional()?.mapRef.current ?? null; + const status = useNavigationStore((s) => s.status); + const kind = useNavigationStore((s) => s.kind); + const route = useNavigationStore((s) => s.route); + const progress = useNavigationStore((s) => s.progress); + const cameraMode = useNavigationStore((s) => s.cameraMode); + const currentSpeedLimit = useNavigationStore((s) => s.currentSpeedLimit); + const voiceEnabled = useNavigationStore((s) => s.voiceEnabled); + const keepScreenOn = useNavigationStore((s) => s.keepScreenOn); + const setCameraMode = useNavigationStore((s) => s.setCameraMode); + const toggleVoice = useNavigationStore((s) => s.toggleVoice); + const stopNavigation = useNavigationStore((s) => s.stopNavigation); + + const units = useSettingsStore((s) => s.units); + const t = useTranslations("navigation"); + // Ground nav only; transit navigation is handled by TransitNavigationView. + const active = status !== "idle" && kind === "ground"; + + useNavigationEngine(); + useFollowCamera(map); + useWakeLock(active && keepScreenOn); + const heading = useHeading(active); + + // Collapse the route-planning sidebar while navigating so it doesn't sit on + // top of the map behind the nav overlay; restore the prior state on exit. + useEffect(() => { + if (!active) return; + const prevCollapsed = useSidebarStore.getState().collapsed; + useSidebarStore.getState().setCollapsed(true); + return () => useSidebarStore.getState().setCollapsed(prevCollapsed); + }, [active]); + + if (!active) return null; + + // Show the nav chrome from the static route immediately on Start; live + // position (progress) refines it once GPS fixes arrive. Without this, the + // overlay is blank until the first fix — which never comes on devices that + // deny or can't provide geolocation, so Start would appear to do nothing. + const step = route ? route.steps[progress?.currentStepIndex ?? 0] : null; + const awaitingFix = status !== "arrived" && !progress; + const distanceToManeuver = progress?.distanceToNextManeuver ?? step?.distance ?? 0; + const distanceRemaining = progress?.distanceRemaining ?? route?.distance ?? 0; + const durationRemaining = progress?.durationRemaining ?? route?.duration ?? 0; + const etaEpochMs = progress?.etaEpochMs ?? Date.now() + durationRemaining * 1000; + + return ( + <> + + + {status === "arrived" ? ( + + + + ) : ( + <> + + {step && ( + + )} + {step?.lanes && } + {awaitingFix && ( + + + {t("waitingForGps")} + + + )} + + + + + + + {cameraMode === "free" && ( + + setCameraMode("follow")} /> + + )} + + + {route && ( + + + + )} + + )} + + + ); +} diff --git a/apps/web/src/components/navigation/RecenterFab.tsx b/apps/web/src/components/navigation/RecenterFab.tsx new file mode 100644 index 00000000..1b6ef75a --- /dev/null +++ b/apps/web/src/components/navigation/RecenterFab.tsx @@ -0,0 +1,14 @@ +"use client"; + +import NavigationIcon from "@mui/icons-material/Navigation"; +import Fab from "@mui/material/Fab"; +import { useTranslations } from "next-intl"; + +export function RecenterFab({ onClick }: { onClick: () => void }) { + const t = useTranslations("navigation"); + return ( + + + + ); +} diff --git a/apps/web/src/components/navigation/SpeedLimitBadge.test.tsx b/apps/web/src/components/navigation/SpeedLimitBadge.test.tsx new file mode 100644 index 00000000..f890eb93 --- /dev/null +++ b/apps/web/src/components/navigation/SpeedLimitBadge.test.tsx @@ -0,0 +1,13 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { SpeedLimitBadge } from "./SpeedLimitBadge"; + +describe("SpeedLimitBadge", () => { + it("renders nothing when speed limit is null", () => { + expect(renderToStaticMarkup()).toBe(""); + }); + it("renders the limit", () => { + const html = renderToStaticMarkup(); + expect(html).toContain("50"); + }); +}); diff --git a/apps/web/src/components/navigation/SpeedLimitBadge.tsx b/apps/web/src/components/navigation/SpeedLimitBadge.tsx new file mode 100644 index 00000000..1b4d1a50 --- /dev/null +++ b/apps/web/src/components/navigation/SpeedLimitBadge.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; + +interface Props { + speedLimit: number | null; // km/h + units: "metric" | "imperial"; +} + +export function SpeedLimitBadge({ speedLimit, units }: Props) { + if (speedLimit === null) return null; + const value = units === "imperial" ? Math.round(speedLimit / 1.609) : speedLimit; + return ( + + {value} + + ); +} diff --git a/apps/web/src/components/navigation/TransitNavigationView.tsx b/apps/web/src/components/navigation/TransitNavigationView.tsx new file mode 100644 index 00000000..fd511618 --- /dev/null +++ b/apps/web/src/components/navigation/TransitNavigationView.tsx @@ -0,0 +1,260 @@ +"use client"; + +import CloseIcon from "@mui/icons-material/Close"; +import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk"; +import NotificationImportantIcon from "@mui/icons-material/NotificationImportant"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import { + formatDuration, + stopsUntilAlight, + type TransitProgress, + useNavigationStore, + useSidebarStore, + useVehicleJourney, +} from "@openmapx/core"; +import type { TripLeg } from "@openmapx/mobility-core/transit"; +import { useLocale, useTranslations } from "next-intl"; +import { useEffect, useRef } from "react"; +import { RouteBadge } from "@/components/panels/transit/RouteBadge"; +import { haptics } from "@/lib/haptics"; +import { useTransitNavigationEngine } from "@/lib/navigation/useTransitNavigationEngine"; +import { TEAL_HEX } from "@/lib/theme"; +import { useWakeLock } from "@/lib/useWakeLock"; +import { ArrivalCard } from "./ArrivalCard"; + +/** + * Slice the full vehicle journey down to the stops for this leg, between the + * board and alight stop ids. Mirrors the logic in TransitLegStops so the + * countdown matches what the itinerary detail view shows. + */ +function legStopsFor( + stops: { stopId: string; name: string; lat: number; lng: number }[], + leg: TripLeg, +): { lat: number; lng: number; name: string }[] { + const fromId = leg.from.stopId; + const toId = leg.to.stopId; + const fromIdx = fromId ? stops.findIndex((s) => s.stopId === fromId) : -1; + const toIdx = + fromIdx !== -1 && toId + ? stops.findIndex((s, i) => i > fromIdx && s.stopId === toId) + : toId + ? stops.findIndex((s) => s.stopId === toId) + : -1; + const sliced = + fromIdx !== -1 && toIdx !== -1 && toIdx > fromIdx ? stops.slice(fromIdx, toIdx + 1) : stops; + return sliced.map((s) => ({ lat: s.lat, lng: s.lng, name: s.name })); +} + +/** Next-stop / alight countdown for a transit leg, backed by the live journey. */ +function NextStopPanel({ + leg, + transitProgress, +}: { + leg: TripLeg; + transitProgress: TransitProgress; +}) { + const t = useTranslations("navigation"); + const { data: journey } = useVehicleJourney(leg.tripId ?? null); + const alertedRef = useRef(false); + + const legStops = journey?.stops ? legStopsFor(journey.stops, leg) : []; + const { nextStopName, stopsRemaining } = stopsUntilAlight( + leg.geometry.coordinates, + legStops, + transitProgress.snapped, + ); + + const alightSoon = legStops.length > 0 && stopsRemaining > 0 && stopsRemaining <= 1; + + // Fire the haptic pulse once per entry into the alight window; reset when we + // leave it so a re-entry can buzz again. + useEffect(() => { + if (alightSoon && !alertedRef.current) { + alertedRef.current = true; + haptics.warn(); + } else if (!alightSoon) { + alertedRef.current = false; + } + }, [alightSoon]); + + return ( + + {alightSoon ? ( + + + + + {t("alightSoon")} + + + {t("alightAt", { place: leg.to.name })} + + + + ) : ( + <> + {nextStopName && ( + + {t("nextStop", { stop: nextStopName })} + + )} + + {legStops.length > 0 && stopsRemaining > 0 + ? t("alightAtCount", { place: leg.to.name, count: stopsRemaining }) + : t("alightAt", { place: leg.to.name })} + + + )} + + ); +} + +export function TransitNavigationView() { + const status = useNavigationStore((s) => s.status); + const kind = useNavigationStore((s) => s.kind); + const itinerary = useNavigationStore((s) => s.itinerary); + const transitProgress = useNavigationStore((s) => s.transitProgress); + const keepScreenOn = useNavigationStore((s) => s.keepScreenOn); + const stopNavigation = useNavigationStore((s) => s.stopNavigation); + + const t = useTranslations("navigation"); + const locale = useLocale(); + const active = status !== "idle" && status !== "arrived" && kind === "transit"; + + // Hooks must run before any early return. + useTransitNavigationEngine(); + useWakeLock(active && keepScreenOn); + + // Collapse the route-planning sidebar while navigating; restore on exit. + useEffect(() => { + if (!active) return; + const prevCollapsed = useSidebarStore.getState().collapsed; + useSidebarStore.getState().setCollapsed(true); + return () => useSidebarStore.getState().setCollapsed(prevCollapsed); + }, [active]); + + if (status === "idle" || kind !== "transit" || !itinerary) return null; + + const legs = itinerary.legs; + const currentLegIndex = Math.min(transitProgress?.currentLegIndex ?? 0, legs.length - 1); + const currentLeg = legs[currentLegIndex] as TripLeg | undefined; + + const arrivalTime = new Date(itinerary.endTime).toLocaleTimeString(locale, { + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + {status === "arrived" ? ( + + + + ) : ( + <> + + {currentLeg && ( + + {currentLeg.mode === "walking" ? ( + + ) : currentLeg.route ? ( + + ) : ( + + )} + + + {currentLeg.mode === "walking" + ? t("walkTo", { place: currentLeg.to.name }) + : t("ride", { + line: currentLeg.route?.shortName ?? currentLeg.route?.longName ?? "", + to: currentLeg.to.name, + })} + + + {t("legCounter", { current: currentLegIndex + 1, total: legs.length })} + + + + )} + {currentLeg && currentLeg.mode !== "walking" && transitProgress && ( + + )} + + + + + {formatDuration(itinerary.duration)} + + {t("arriveAt", { time: arrivalTime })} + + + + + + )} + + ); +} diff --git a/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx b/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx index be27202f..c2cd0627 100644 --- a/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx +++ b/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx @@ -24,6 +24,7 @@ import { useMapStore, useMenuStore, useOptimizeRoute, + useSettingsStore, useSidebarStore, useTransitPlan, } from "@openmapx/core"; @@ -68,7 +69,6 @@ export function DirectionsPanelContent() { avoidHighways, avoidTolls, avoidFerries, - units, transitItineraries, activeItineraryIndex, transitDepartureTime, @@ -86,6 +86,7 @@ export function DirectionsPanelContent() { setTransitDepartureTime, setTransitArrivalTime, } = useDirectionsStore(); + const units = useSettingsStore((s) => s.units); const { userLocation } = useMapStore(); const registry = useIntegrationRegistry(); diff --git a/apps/web/src/components/panels/directions/RouteCard.tsx b/apps/web/src/components/panels/directions/RouteCard.tsx index 73f175e4..bdd2f302 100644 --- a/apps/web/src/components/panels/directions/RouteCard.tsx +++ b/apps/web/src/components/panels/directions/RouteCard.tsx @@ -3,12 +3,22 @@ import DirectionsBikeIcon from "@mui/icons-material/DirectionsBike"; import DirectionsCarIcon from "@mui/icons-material/DirectionsCar"; import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk"; +import NavigationIcon from "@mui/icons-material/Navigation"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; import type { Route } from "@openmapx/core"; -import { formatDistance, formatDuration } from "@openmapx/core"; +import { + formatDistance, + formatDuration, + useDirectionsStore, + useNavigationStore, +} from "@openmapx/core"; import { useTranslations } from "next-intl"; import { TEAL } from "@/lib/theme"; +import { requestHeadingPermission } from "@/lib/useHeading"; + +const GROUND_MODES = new Set(["driving", "walking", "cycling"]); export function RouteCard({ route, @@ -27,6 +37,17 @@ export function RouteCard({ }) { const t = useTranslations("directions"); const tc = useTranslations("common"); + const tNav = useTranslations("navigation"); + const startGroundNavigation = useNavigationStore((s) => s.startGroundNavigation); + const waypoints = useDirectionsStore((s) => s.waypoints); + + const handleStart = async () => { + const coords = waypoints.map((w) => w.coords).filter((c): c is [number, number] => c !== null); + if (coords.length < 2) return; + await requestHeadingPermission(); + startGroundNavigation(route, route.mode, coords); + }; + const dist = units === "imperial" ? `${(route.distance / 1609.34).toFixed(1)} mi` @@ -102,7 +123,7 @@ export function RouteCard({ )} {active && ( - + {tc("details")} + {GROUND_MODES.has(route.mode) && ( + + )} )} diff --git a/apps/web/src/components/panels/directions/RouteOptions.tsx b/apps/web/src/components/panels/directions/RouteOptions.tsx index da387a72..da703cb9 100644 --- a/apps/web/src/components/panels/directions/RouteOptions.tsx +++ b/apps/web/src/components/panels/directions/RouteOptions.tsx @@ -7,7 +7,7 @@ import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { useDirectionsStore } from "@openmapx/core"; +import { useDirectionsStore, useSettingsStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { TEAL } from "@/lib/theme"; @@ -79,12 +79,12 @@ export function RouteOptions() { avoidHighways, avoidTolls, avoidFerries, - units, setAvoidHighways, setAvoidTolls, setAvoidFerries, - setUnits, } = useDirectionsStore(); + const units = useSettingsStore((s) => s.units); + const setUnits = useSettingsStore((s) => s.setUnits); return ( diff --git a/apps/web/src/components/panels/directions/TransitRouteView.test.tsx b/apps/web/src/components/panels/directions/TransitRouteView.test.tsx index 8027ac13..b3805c96 100644 --- a/apps/web/src/components/panels/directions/TransitRouteView.test.tsx +++ b/apps/web/src/components/panels/directions/TransitRouteView.test.tsx @@ -22,6 +22,11 @@ vi.mock("@openmapx/core", () => ({ formatDistance: (distance: number) => `${distance} m`, formatDuration: (duration: number) => `${duration}s`, useVehicleJourney: () => ({ data: null }), + useNavigationStore: Object.assign( + (sel: (s: { startTransitNavigation: () => void }) => unknown) => + sel({ startTransitNavigation: () => {} }), + { getState: () => ({ startTransitNavigation: () => {} }) }, + ), })); vi.mock("@/components/panels/transit/RouteBadge", () => ({ diff --git a/apps/web/src/components/panels/directions/TransitRouteView.tsx b/apps/web/src/components/panels/directions/TransitRouteView.tsx index 4cb9b03b..1bdcf9f9 100644 --- a/apps/web/src/components/panels/directions/TransitRouteView.tsx +++ b/apps/web/src/components/panels/directions/TransitRouteView.tsx @@ -5,10 +5,17 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import DirectionsBusIcon from "@mui/icons-material/DirectionsBus"; import DirectionsTransitIcon from "@mui/icons-material/DirectionsTransit"; import DirectionsWalkIcon from "@mui/icons-material/DirectionsWalk"; +import NavigationIcon from "@mui/icons-material/Navigation"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { formatDistance, formatDuration, useVehicleJourney } from "@openmapx/core"; +import { + formatDistance, + formatDuration, + useNavigationStore, + useVehicleJourney, +} from "@openmapx/core"; import type { OccupancyLevel, TripItinerary, TripLeg } from "@openmapx/mobility-core/transit"; import { useLocale, useTranslations } from "next-intl"; import { RemarkChip } from "@/components/panels/transit/RemarkChip"; @@ -221,7 +228,9 @@ export function TransitItineraryCard({ const t = useTranslations("directions"); const tc = useTranslations("common"); const tt = useTranslations("transit"); + const tNav = useTranslations("navigation"); const locale = useLocale(); + const startTransitNavigation = useNavigationStore((s) => s.startTransitNavigation); const fareSummary = extractFareSummary(itinerary.fare); const occupancy = worstOccupancy(itinerary); const startTime = new Date(itinerary.startTime).toLocaleTimeString(locale, { @@ -336,7 +345,7 @@ export function TransitItineraryCard({ )} {active && ( - + {tc("details")} + )} diff --git a/apps/web/src/lib/haptics.test.ts b/apps/web/src/lib/haptics.test.ts new file mode 100644 index 00000000..10477f25 --- /dev/null +++ b/apps/web/src/lib/haptics.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from "vitest"; +import { haptics } from "./haptics"; + +describe("haptics", () => { + it("calls navigator.vibrate when available", () => { + const vibrate = vi.fn(); + vi.stubGlobal("navigator", { vibrate }); + haptics.tap(); + expect(vibrate).toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("is a no-op when vibrate is absent", () => { + vi.stubGlobal("navigator", {}); + expect(() => haptics.warn()).not.toThrow(); + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/web/src/lib/navigation/devSimulator.ts b/apps/web/src/lib/navigation/devSimulator.ts new file mode 100644 index 00000000..03b29067 --- /dev/null +++ b/apps/web/src/lib/navigation/devSimulator.ts @@ -0,0 +1,44 @@ +import { + navOptionsForMode, + processFix, + simulatePositions, + useNavigationStore, +} from "@openmapx/core"; + +/** + * Dev helper: replay the active route's geometry as fake GPS fixes through the + * store, advancing one fix per `intervalMs`. Call from the browser console: + * import("@/lib/navigation/devSimulator").then(m => m.runSimulator()) + * Returns a stop() function. + */ +export function runSimulator(intervalMs = 1000, offsetMeters = 0): () => void { + if (process.env.NODE_ENV === "production") return () => {}; + const store = useNavigationStore.getState(); + const route = store.route; + if (!route) { + console.warn("[devSimulator] start navigation first"); + return () => {}; + } + const fixes = simulatePositions(route.geometry, { stepMeters: 25, intervalMs, offsetMeters }); + const opts = navOptionsForMode(route.mode); + let tick = { + deviationHistory: [] as number[], + lastRerouteAtMs: null as number | null, + spokenCues: [] as string[], + }; + let i = 0; + const handle = setInterval(() => { + if (i >= fixes.length) { + clearInterval(handle); + return; + } + const result = processFix(useNavigationStore.getState().route ?? route, fixes[i++], tick, opts); + tick = result.nextState; + if (result.progress) { + useNavigationStore.getState().applyProgress(result.progress); + useNavigationStore.getState().setOffRoute(result.offRoute); + if (result.arrived) useNavigationStore.getState().completeArrival(); + } + }, intervalMs); + return () => clearInterval(handle); +} diff --git a/apps/web/src/lib/navigation/maneuverIcon.test.tsx b/apps/web/src/lib/navigation/maneuverIcon.test.tsx new file mode 100644 index 00000000..3d12f358 --- /dev/null +++ b/apps/web/src/lib/navigation/maneuverIcon.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { maneuverIconFor } from "./maneuverIcon"; + +describe("maneuverIconFor", () => { + it("maps left/right modifiers", () => { + expect(maneuverIconFor({ type: "turn", modifier: "left" }).name).toContain("TurnLeft"); + expect(maneuverIconFor({ type: "turn", modifier: "right" }).name).toContain("TurnRight"); + }); + it("falls back to Straight for unknown", () => { + expect(maneuverIconFor(undefined).name).toContain("Straight"); + }); + it("maps arrive to a flag", () => { + expect(maneuverIconFor({ type: "arrive" }).name).toContain("Flag"); + }); +}); diff --git a/apps/web/src/lib/navigation/maneuverIcon.tsx b/apps/web/src/lib/navigation/maneuverIcon.tsx new file mode 100644 index 00000000..d5657645 --- /dev/null +++ b/apps/web/src/lib/navigation/maneuverIcon.tsx @@ -0,0 +1,63 @@ +import type { SvgIconComponent } from "@mui/icons-material"; +import Flag from "@mui/icons-material/Flag"; +import ForkLeft from "@mui/icons-material/ForkLeft"; +import ForkRight from "@mui/icons-material/ForkRight"; +import MergeType from "@mui/icons-material/MergeType"; +import RoundaboutLeft from "@mui/icons-material/RoundaboutLeft"; +import RoundaboutRight from "@mui/icons-material/RoundaboutRight"; +import Straight from "@mui/icons-material/Straight"; +import TurnLeft from "@mui/icons-material/TurnLeft"; +import TurnRight from "@mui/icons-material/TurnRight"; +import TurnSharpLeft from "@mui/icons-material/TurnSharpLeft"; +import TurnSharpRight from "@mui/icons-material/TurnSharpRight"; +import TurnSlightLeft from "@mui/icons-material/TurnSlightLeft"; +import TurnSlightRight from "@mui/icons-material/TurnSlightRight"; +import UTurnLeft from "@mui/icons-material/UTurnLeft"; + +interface Maneuver { + type: string; + modifier?: string; +} + +interface ResolvedIcon { + component: SvgIconComponent; + name: string; +} + +/** + * Resolve a normalized maneuver to an MUI icon component. Falls back to Straight. + * + * The returned `name` is the stable icon identifier (e.g. "TurnLeft") derived from + * the mapping itself, not from MUI's reflective metadata: in the test/transpiled + * build MUI's `muiName`/`displayName`/`name` are not preserved on the icon + * component, so we label each branch explicitly to keep `name` deterministic. + */ +export function maneuverIconFor(maneuver: Maneuver | undefined): ResolvedIcon { + const pick = (component: SvgIconComponent, name: string): ResolvedIcon => ({ + component, + name, + }); + + const m = maneuver?.modifier ?? ""; + switch (maneuver?.type) { + case "arrive": + return pick(Flag, "Flag"); + case "roundabout": + case "rotary": + return m.includes("left") + ? pick(RoundaboutLeft, "RoundaboutLeft") + : pick(RoundaboutRight, "RoundaboutRight"); + case "merge": + return pick(MergeType, "MergeType"); + case "fork": + return m.includes("left") ? pick(ForkLeft, "ForkLeft") : pick(ForkRight, "ForkRight"); + } + if (m === "uturn") return pick(UTurnLeft, "UTurnLeft"); + if (m === "sharp left") return pick(TurnSharpLeft, "TurnSharpLeft"); + if (m === "sharp right") return pick(TurnSharpRight, "TurnSharpRight"); + if (m === "slight left") return pick(TurnSlightLeft, "TurnSlightLeft"); + if (m === "slight right") return pick(TurnSlightRight, "TurnSlightRight"); + if (m === "left") return pick(TurnLeft, "TurnLeft"); + if (m === "right") return pick(TurnRight, "TurnRight"); + return pick(Straight, "Straight"); +} diff --git a/apps/web/src/lib/navigation/useFollowCamera.ts b/apps/web/src/lib/navigation/useFollowCamera.ts new file mode 100644 index 00000000..bb4d78a0 --- /dev/null +++ b/apps/web/src/lib/navigation/useFollowCamera.ts @@ -0,0 +1,52 @@ +import { useNavigationStore } from "@openmapx/core"; +import type maplibregl from "maplibre-gl"; +import { useEffect } from "react"; + +const PITCH: Record = { + driving: 55, + cycling: 35, + walking: 0, + transit: 0, + flying: 0, +}; +const CAMERA_EASE_DURATION_MS = 350; + +/** + * Drive the map camera to follow the snapped position while navigating in + * "follow" mode. A user gesture (handled by the caller flipping cameraMode to + * "free") suspends this; recenter flips it back. + */ +export function useFollowCamera(map: maplibregl.Map | null): void { + const progress = useNavigationStore((s) => s.progress); + const cameraMode = useNavigationStore((s) => s.cameraMode); + const status = useNavigationStore((s) => s.status); + const mode = useNavigationStore((s) => s.mode); + + useEffect(() => { + if (!map || status === "idle" || cameraMode !== "follow" || !progress) return; + map.easeTo( + { + center: progress.snapped, + bearing: map.getBearing(), + pitch: PITCH[mode] ?? 0, + zoom: Math.max(map.getZoom(), 16), + duration: CAMERA_EASE_DURATION_MS, + }, + { programmatic: true }, + ); + }, [map, status, cameraMode, progress, mode]); + + // A user pan/rotate gesture drops follow mode; the RecenterFab restores it. + // `dragstart`/`rotatestart` only fire from user interaction (programmatic + // easeTo emits movestart, not dragstart), so no programmatic-flag check is needed. + useEffect(() => { + if (!map || status === "idle") return; + const onUserGesture = () => useNavigationStore.getState().setCameraMode("free"); + map.on("dragstart", onUserGesture); + map.on("rotatestart", onUserGesture); + return () => { + map.off("dragstart", onUserGesture); + map.off("rotatestart", onUserGesture); + }; + }, [map, status]); +} diff --git a/apps/web/src/lib/navigation/useNavigationEngine.test.tsx b/apps/web/src/lib/navigation/useNavigationEngine.test.tsx new file mode 100644 index 00000000..2c37056f --- /dev/null +++ b/apps/web/src/lib/navigation/useNavigationEngine.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import type { Route } from "@integrations/routing/types"; +import { type FixInput, useNavigationStore } from "@openmapx/core"; +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useNavigationEngine } from "./useNavigationEngine"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +let fixHandler: ((fix: FixInput) => void) | null = null; +vi.mock("../useWatchPosition", () => ({ + useWatchPosition: (_active: boolean, onFix: (f: FixInput) => void) => { + fixHandler = onFix; + }, +})); +vi.mock("./useNavigationVoice", () => ({ useNavigationVoice: () => vi.fn() })); +const fetchDirections = vi.fn(); +vi.mock("@openmapx/core", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchDirections: (...a: unknown[]) => fetchDirections(...a) }; +}); + +const geometry: [number, number][] = [ + [0, 0], + [0.002, 0], + [0.004, 0], +]; +const route = { + distance: 444, + duration: 60, + geometry, + legs: [], + mode: "driving", + steps: [ + { instruction: "Head east", distance: 222, duration: 30, coordinates: geometry.slice(0, 2) }, + { instruction: "Arrive", distance: 222, duration: 30, coordinates: geometry.slice(1, 3) }, + ], +} as unknown as Route; + +describe("useNavigationEngine", () => { + beforeEach(() => { + useNavigationStore.getState().stopNavigation(); + fixHandler = null; + fetchDirections.mockReset(); + }); + + it("writes progress to the store on each on-route fix", () => { + useNavigationStore.getState().startGroundNavigation(route, "driving", [ + [0, 0], + [0.004, 0], + ]); + renderHook(() => useNavigationEngine()); + act(() => fixHandler?.({ coords: [0.001, 0], accuracy: 5, timestampMs: 1000 })); + expect(useNavigationStore.getState().progress?.currentStepIndex).toBe(0); + }); + + it("requests a reroute and applies the new route when off-route", async () => { + useNavigationStore.getState().startGroundNavigation(route, "driving", [ + [0, 0], + [0.004, 0], + ]); + const route2 = { ...route, distance: 999 } as Route; + fetchDirections.mockResolvedValue({ routes: [route2], activeRouteIndex: 0 }); + renderHook(() => useNavigationEngine()); + // 3 consecutive far-off fixes + await act(async () => { + fixHandler?.({ coords: [0.001, 0.002], accuracy: 5, timestampMs: 1000 }); + fixHandler?.({ coords: [0.0015, 0.002], accuracy: 5, timestampMs: 2000 }); + fixHandler?.({ coords: [0.002, 0.002], accuracy: 5, timestampMs: 3000 }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(fetchDirections).toHaveBeenCalled(); + expect(useNavigationStore.getState().route?.distance).toBe(999); + }); +}); diff --git a/apps/web/src/lib/navigation/useNavigationEngine.ts b/apps/web/src/lib/navigation/useNavigationEngine.ts new file mode 100644 index 00000000..399952af --- /dev/null +++ b/apps/web/src/lib/navigation/useNavigationEngine.ts @@ -0,0 +1,149 @@ +import { + type FixInput, + fetchDirections, + fetchSpeedLimit, + formatMeasurementDistance, + type LngLat, + type NavTickState, + navOptionsForMode, + processFix, + useNavigationStore, + useSettingsStore, + type VoiceCue, +} from "@openmapx/core"; +import along from "@turf/along"; +import { lineString } from "@turf/helpers"; +import { useLocale, useTranslations } from "next-intl"; +import { useCallback, useEffect, useRef } from "react"; +import { haptics } from "../haptics"; +import { useWatchPosition } from "../useWatchPosition"; +import { useNavigationVoice } from "./useNavigationVoice"; + +const freshTick = (): NavTickState => ({ + deviationHistory: [], + lastRerouteAtMs: null, + spokenCues: [], +}); + +/** Wires GPS fixes → processFix → navigationStore + side effects (voice, reroute). */ +export function useNavigationEngine(): void { + const t = useTranslations("navigation"); + const locale = useLocale(); + const speak = useNavigationVoice(locale); + + const tickRef = useRef(freshTick()); + const reroutingRef = useRef(false); + const lastSpeedFetchRef = useRef(0); + + const speakCue = useCallback( + (cue: VoiceCue) => { + const instruction = cue.step.instruction; + const units = useSettingsStore.getState().units; + const distanceStr = formatMeasurementDistance(cue.distance, units); + const text = + cue.tier === "now" + ? instruction + : t("voiceUpcoming", { distance: distanceStr, instruction }); + speak(text); + }, + [speak, t], + ); + + const onFix = useCallback( + (fix: FixInput) => { + const store = useNavigationStore.getState(); + const { status, route, mode, destinationWaypoints, voiceEnabled } = store; + if ((status !== "navigating" && status !== "rerouting") || !route) return; + + const opts = navOptionsForMode(mode); + const result = processFix(route, fix, tickRef.current, opts); + tickRef.current = result.nextState; + if (!result.progress) return; + + store.applyProgress(result.progress); + store.setOffRoute(result.offRoute); + + // Speed limit: OSRM populates per-step speedLimit, so prefer that. When + // the route lacks it (Valhalla steps carry none), fall back to a + // throttled live map-match lookup while driving; walking/cycling clear + // the badge. + const staticLimit = route.steps[result.progress.currentStepIndex]?.speedLimit ?? null; + if (staticLimit !== null) { + store.setSpeedLimit(staticLimit); + } else if (mode === "driving") { + if (fix.timestampMs - lastSpeedFetchRef.current >= 5000) { + lastSpeedFetchRef.current = fix.timestampMs; + // Build a 2-point trace from the snapped position to a point ~25m + // ahead along the route so the matcher snaps to the road we're on. + const line = lineString(route.geometry); + const ahead = along(line, (result.progress.alongMeters + 25) / 1000, { + units: "kilometers", + }).geometry.coordinates as LngLat; + const trace: LngLat[] = [result.progress.snapped, ahead]; + fetchSpeedLimit(trace, "driving").then((limit) => { + // Ignore if navigation ended while the lookup was in flight. + const st = useNavigationStore.getState().status; + if (st === "idle" || st === "arrived") return; + useNavigationStore.getState().setSpeedLimit(limit); + }); + } + } else { + store.setSpeedLimit(null); + } + + if (result.arrived) { + haptics.success(); + store.completeArrival(); + return; + } + + if (voiceEnabled && result.voiceCue) speakCue(result.voiceCue); + + if (result.needsReroute && !reroutingRef.current) { + reroutingRef.current = true; + haptics.warn(); + store.beginReroute(); + const from = result.progress.snapped; + // NOTE: keeps all original waypoints except the origin. For multi-stop + // routes this can re-include already-passed intermediate stops on + // reroute; precise waypoint-progress tracking is a future refinement. + const waypoints = [from, ...destinationWaypoints.slice(1)]; + fetchDirections({ waypoints, mode, lang: locale }) + .then((res) => { + // Bail out if navigation ended (stopped or arrived) while the + // reroute was in flight — otherwise we'd resurrect a finished trip. + const st = useNavigationStore.getState().status; + if (st === "idle" || st === "arrived") return; + const next = res.routes?.[res.activeRouteIndex ?? 0]; + if (next) { + tickRef.current = freshTick(); + useNavigationStore.getState().applyReroute(next); + } else { + useNavigationStore.setState({ status: "navigating" }); + } + }) + .catch(() => { + const st = useNavigationStore.getState().status; + if (st === "idle" || st === "arrived") return; + useNavigationStore.setState({ status: "navigating" }); + }) + .finally(() => { + reroutingRef.current = false; + }); + } + }, + [locale, speakCue], + ); + + // Reset per-session tick state (spoken cues + deviation history) whenever the + // active route changes — a fresh start or an applied reroute — so a second + // navigation session doesn't inherit the previous one's spoken-cue keys. + const activeRoute = useNavigationStore((s) => s.route); + // biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on route identity, not tickRef. + useEffect(() => { + tickRef.current = freshTick(); + }, [activeRoute]); + + const active = useNavigationStore((s) => s.status !== "idle" && s.status !== "arrived"); + useWatchPosition(active, onFix); +} diff --git a/apps/web/src/lib/navigation/useNavigationVoice.test.ts b/apps/web/src/lib/navigation/useNavigationVoice.test.ts new file mode 100644 index 00000000..539385c6 --- /dev/null +++ b/apps/web/src/lib/navigation/useNavigationVoice.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useNavigationVoice } from "./useNavigationVoice"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("useNavigationVoice", () => { + it("speaks text via speechSynthesis", () => { + const speak = vi.fn(); + class FakeUtterance { + text: string; + lang = ""; + constructor(text: string) { + this.text = text; + } + } + vi.stubGlobal("window", { + speechSynthesis: { speak, cancel: vi.fn() }, + SpeechSynthesisUtterance: FakeUtterance, + }); + vi.stubGlobal("navigator", {}); + const { result } = renderHook(() => useNavigationVoice("de")); + result.current("Turn right"); + expect(speak).toHaveBeenCalledTimes(1); + const utterance = speak.mock.calls[0][0] as FakeUtterance; + expect(utterance.text).toBe("Turn right"); + expect(utterance.lang).toBe("de"); + }); + + it("is a no-op without speechSynthesis", () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + const { result } = renderHook(() => useNavigationVoice("en")); + expect(() => result.current("hi")).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/navigation/useNavigationVoice.ts b/apps/web/src/lib/navigation/useNavigationVoice.ts new file mode 100644 index 00000000..9eda0318 --- /dev/null +++ b/apps/web/src/lib/navigation/useNavigationVoice.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; +import { hasCapability } from "../platformCapabilities"; + +/** Returns a `speak(text)` function backed by SpeechSynthesis (locale-aware). No-op when unsupported. */ +export function useNavigationVoice(locale: string): (text: string) => void { + return useCallback( + (text: string) => { + if (!hasCapability("speech")) return; + const u = new window.SpeechSynthesisUtterance(text); + u.lang = locale; + window.speechSynthesis.speak(u); + }, + [locale], + ); +} diff --git a/apps/web/src/lib/navigation/useTransitNavigationEngine.ts b/apps/web/src/lib/navigation/useTransitNavigationEngine.ts new file mode 100644 index 00000000..77f7e0d4 --- /dev/null +++ b/apps/web/src/lib/navigation/useTransitNavigationEngine.ts @@ -0,0 +1,44 @@ +import { computeTransitProgress, type FixInput, useNavigationStore } from "@openmapx/core"; +import { useCallback } from "react"; +import { useMapOptional } from "@/lib/MapContext"; +import { haptics } from "../haptics"; +import { useWatchPosition } from "../useWatchPosition"; + +/** + * Transit follow-along engine. Wires GPS fixes → computeTransitProgress → + * navigationStore, recenters the map on the snapped position, and fires arrival + * once the traveller nears the end of the final leg. There is NO rerouting — + * the planned itinerary is fixed; this only reports where along it we are. + */ +export function useTransitNavigationEngine(): void { + const map = useMapOptional()?.mapRef.current ?? null; + + const onFix = useCallback( + (fix: FixInput) => { + const store = useNavigationStore.getState(); + const { status, kind, itinerary } = store; + if (status !== "navigating" || kind !== "transit" || !itinerary) return; + + const tp = computeTransitProgress(itinerary, fix.coords); + store.applyTransitProgress(tp); + + if (map) { + map.easeTo( + { center: tp.snapped, zoom: Math.max(map.getZoom(), 15), duration: 350 }, + { programmatic: true }, + ); + } + + if (tp.arrived) { + haptics.success(); + store.completeArrival(); + } + }, + [map], + ); + + const active = useNavigationStore( + (s) => s.status !== "idle" && s.status !== "arrived" && s.kind === "transit", + ); + useWatchPosition(active, onFix); +} diff --git a/apps/web/src/lib/platformCapabilities.test.ts b/apps/web/src/lib/platformCapabilities.test.ts new file mode 100644 index 00000000..d5ae501a --- /dev/null +++ b/apps/web/src/lib/platformCapabilities.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { hasCapability } from "./platformCapabilities"; + +describe("hasCapability", () => { + it("returns false in a non-DOM environment for hardware features", () => { + expect(hasCapability("wakeLock")).toBe( + typeof navigator !== "undefined" && "wakeLock" in navigator, + ); + }); + + it("knows the capability keys", () => { + expect(() => hasCapability("vibrate")).not.toThrow(); + expect(() => hasCapability("speech")).not.toThrow(); + expect(() => hasCapability("geolocation")).not.toThrow(); + expect(() => hasCapability("deviceOrientation")).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/platformCapabilities.ts b/apps/web/src/lib/platformCapabilities.ts new file mode 100644 index 00000000..18d0d2fb --- /dev/null +++ b/apps/web/src/lib/platformCapabilities.ts @@ -0,0 +1,20 @@ +export type Capability = "geolocation" | "wakeLock" | "deviceOrientation" | "speech" | "vibrate"; + +/** Feature-detect a browser capability; safe to call during SSR (returns false). */ +export function hasCapability(cap: Capability): boolean { + if (typeof navigator === "undefined" || typeof window === "undefined") return false; + switch (cap) { + case "geolocation": + return "geolocation" in navigator; + case "wakeLock": + return "wakeLock" in navigator; + case "deviceOrientation": + return "DeviceOrientationEvent" in window; + case "speech": + return "speechSynthesis" in window; + case "vibrate": + return typeof navigator.vibrate === "function"; + default: + return false; + } +} diff --git a/apps/web/src/lib/useHeading.test.ts b/apps/web/src/lib/useHeading.test.ts new file mode 100644 index 00000000..76a14765 --- /dev/null +++ b/apps/web/src/lib/useHeading.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useHeading } from "./useHeading"; + +afterEach(() => vi.unstubAllGlobals()); + +type OrientListener = ( + e: Partial & { webkitCompassHeading?: number }, +) => void; + +describe("useHeading", () => { + it("reads iOS webkitCompassHeading when present", () => { + const listeners: Record = {}; + vi.stubGlobal("window", { + DeviceOrientationEvent: () => {}, + addEventListener: (t: string, cb: OrientListener) => { + listeners[t] = cb; + }, + removeEventListener: vi.fn(), + }); + const { result } = renderHook(() => useHeading(true)); + act(() => listeners.deviceorientation?.({ webkitCompassHeading: 123 })); + expect(result.current).toBeCloseTo(123, 0); + }); + + it("returns null when inactive", () => { + vi.stubGlobal("window", { + DeviceOrientationEvent: () => {}, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const { result } = renderHook(() => useHeading(false)); + expect(result.current).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/useHeading.ts b/apps/web/src/lib/useHeading.ts new file mode 100644 index 00000000..580292c9 --- /dev/null +++ b/apps/web/src/lib/useHeading.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { hasCapability } from "./platformCapabilities"; + +interface CompassEvent extends DeviceOrientationEvent { + webkitCompassHeading?: number; +} + +/** + * Track compass heading (degrees, 0 = north) from device orientation while + * `active`. Uses iOS `webkitCompassHeading` when present, else `alpha`. + * Returns null when unavailable. iOS permission must be granted by the caller. + */ +export function useHeading(active: boolean): number | null { + const [heading, setHeading] = useState(null); + + useEffect(() => { + if (!active || !hasCapability("deviceOrientation")) return; + // Once the absolute event fires we ignore the relative `deviceorientation` + // fallback, so devices that support both don't double-fire setHeading. + let absoluteSeen = false; + const apply = (e: CompassEvent) => { + if (typeof e.webkitCompassHeading === "number") { + setHeading(e.webkitCompassHeading); + } else if (typeof e.alpha === "number") { + setHeading((360 - e.alpha) % 360); + } + }; + const onAbsolute = (raw: Event) => { + absoluteSeen = true; + apply(raw as CompassEvent); + }; + const onRelative = (raw: Event) => { + if (absoluteSeen) return; + apply(raw as CompassEvent); + }; + window.addEventListener("deviceorientationabsolute", onAbsolute as EventListener); + window.addEventListener("deviceorientation", onRelative as EventListener); + return () => { + window.removeEventListener("deviceorientationabsolute", onAbsolute as EventListener); + window.removeEventListener("deviceorientation", onRelative as EventListener); + }; + }, [active]); + + return active ? heading : null; +} + +/** Request iOS device-orientation permission from a user gesture. Resolves true if granted/none-needed. */ +export async function requestHeadingPermission(): Promise { + const Ctor = (typeof window !== "undefined" ? window.DeviceOrientationEvent : undefined) as + | (typeof DeviceOrientationEvent & { requestPermission?: () => Promise<"granted" | "denied"> }) + | undefined; + if (Ctor && typeof Ctor.requestPermission === "function") { + try { + return (await Ctor.requestPermission()) === "granted"; + } catch { + return false; + } + } + return true; +} diff --git a/apps/web/src/lib/useWakeLock.test.ts b/apps/web/src/lib/useWakeLock.test.ts new file mode 100644 index 00000000..118ff85c --- /dev/null +++ b/apps/web/src/lib/useWakeLock.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom + +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useWakeLock } from "./useWakeLock"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("useWakeLock", () => { + it("requests a screen wake lock when active", async () => { + const release = vi.fn().mockResolvedValue(undefined); + const request = vi.fn().mockResolvedValue({ release, addEventListener: vi.fn() }); + vi.stubGlobal("navigator", { wakeLock: { request } }); + // jsdom's real `document` defaults to visibilityState "visible"; keep it so + // testing-library can mount the hook host element. + + renderHook(() => useWakeLock(true)); + await Promise.resolve(); + expect(request).toHaveBeenCalledWith("screen"); + }); + + it("is a no-op without wakeLock support", () => { + vi.stubGlobal("navigator", {}); + // Leave jsdom's real `document` in place so testing-library can mount; the + // hook short-circuits on the missing wakeLock capability before touching it. + expect(() => renderHook(() => useWakeLock(true))).not.toThrow(); + }); +}); diff --git a/apps/web/src/lib/useWakeLock.ts b/apps/web/src/lib/useWakeLock.ts new file mode 100644 index 00000000..3e1c9930 --- /dev/null +++ b/apps/web/src/lib/useWakeLock.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { hasCapability } from "./platformCapabilities"; + +/** + * Hold a screen wake lock while `active`. Re-acquires when the tab becomes + * visible again (the lock drops on hide). No-op when unsupported. + */ +export function useWakeLock(active: boolean): void { + const lockRef = useRef(null); + + useEffect(() => { + if (!active || !hasCapability("wakeLock")) return; + let cancelled = false; + + const acquire = async () => { + try { + if (document.visibilityState !== "visible") return; + lockRef.current = await navigator.wakeLock.request("screen"); + } catch { + /* request can reject if not visible / denied — ignore */ + } + }; + + const onVisible = () => { + if (document.visibilityState === "visible" && !cancelled) void acquire(); + }; + + void acquire(); + document.addEventListener("visibilitychange", onVisible); + + return () => { + cancelled = true; + document.removeEventListener("visibilitychange", onVisible); + void lockRef.current?.release().catch(() => {}); + lockRef.current = null; + }; + }, [active]); +} diff --git a/apps/web/src/lib/useWatchPosition.test.ts b/apps/web/src/lib/useWatchPosition.test.ts new file mode 100644 index 00000000..dc79d0b5 --- /dev/null +++ b/apps/web/src/lib/useWatchPosition.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment jsdom + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useWatchPosition } from "./useWatchPosition"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("useWatchPosition", () => { + it("subscribes to watchPosition when active and emits fixes", () => { + let cb: ((p: GeolocationPosition) => void) | null = null; + const watchPosition = vi.fn((...args: unknown[]) => { + cb = args[0] as (p: GeolocationPosition) => void; + return 7; + }); + const clearWatch = vi.fn(); + vi.stubGlobal("navigator", { geolocation: { watchPosition, clearWatch } }); + + const onFix = vi.fn(); + renderHook(() => useWatchPosition(true, onFix)); + expect(watchPosition).toHaveBeenCalled(); + + act(() => { + cb?.({ + coords: { longitude: 1, latitude: 2, accuracy: 5, heading: 90, speed: 3 }, + timestamp: 1234, + } as GeolocationPosition); + }); + expect(onFix).toHaveBeenCalledWith( + expect.objectContaining({ + coords: [1, 2], + accuracy: 5, + heading: 90, + speed: 3, + timestampMs: 1234, + }), + ); + }); + + it("does not subscribe when inactive", () => { + const watchPosition = vi.fn(); + vi.stubGlobal("navigator", { geolocation: { watchPosition, clearWatch: vi.fn() } }); + renderHook(() => useWatchPosition(false, vi.fn())); + expect(watchPosition).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/useWatchPosition.ts b/apps/web/src/lib/useWatchPosition.ts new file mode 100644 index 00000000..90aa03b6 --- /dev/null +++ b/apps/web/src/lib/useWatchPosition.ts @@ -0,0 +1,32 @@ +"use client"; + +import type { FixInput } from "@openmapx/core"; +import { useEffect, useRef } from "react"; +import { hasCapability } from "./platformCapabilities"; + +/** + * Subscribe to high-accuracy geolocation while `active`. Calls `onFix` per + * update. Clears the watch on deactivate/unmount. No-op without geolocation. + */ +export function useWatchPosition(active: boolean, onFix: (fix: FixInput) => void): void { + const onFixRef = useRef(onFix); + onFixRef.current = onFix; + + useEffect(() => { + if (!active || !hasCapability("geolocation")) return; + const id = navigator.geolocation.watchPosition( + (pos) => { + onFixRef.current({ + coords: [pos.coords.longitude, pos.coords.latitude], + accuracy: pos.coords.accuracy, + heading: pos.coords.heading, + speed: pos.coords.speed, + timestampMs: pos.timestamp, + }); + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 1000, timeout: 15_000 }, + ); + return () => navigator.geolocation?.clearWatch(id); + }, [active]); +} diff --git a/apps/web/src/vitest.d.ts b/apps/web/src/vitest.d.ts index aad5c10a..e29750cd 100644 --- a/apps/web/src/vitest.d.ts +++ b/apps/web/src/vitest.d.ts @@ -4,29 +4,44 @@ type VitestMockImplementation = (...args: unknown[]) => unknown; interface VitestExpectation { not: VitestExpectation; toBe(expected: unknown): void; + toBeCloseTo(expected: number, numDigits?: number): void; toBeDefined(): void; toBeNull(): void; toContain(expected: unknown): void; toEqual(expected: unknown): void; + toHaveBeenCalled(): void; + toHaveBeenCalledTimes(times: number): void; + toHaveBeenCalledWith(...args: unknown[]): void; + toThrow(expected?: unknown): void; } interface VitestMockFunction { (...args: unknown[]): unknown; + mock: { calls: unknown[][] }; mockImplementation(implementation: VitestMockImplementation): VitestMockFunction; mockResolvedValue(value: unknown): VitestMockFunction; mockReturnValue(value: unknown): VitestMockFunction; + mockReset(): VitestMockFunction; } +type VitestMockFactory = (importOriginal: () => Promise) => unknown; + declare module "vitest" { export const describe: (name: string, callback: VitestTestCallback) => void; - export const expect: (actual: unknown) => VitestExpectation; + const expectFn: { + (actual: unknown): VitestExpectation; + objectContaining(expected: Record): unknown; + }; + export const expect: typeof expectFn; export const it: (name: string, callback: VitestTestCallback) => void; export const beforeEach: (callback: VitestTestCallback) => void; export const afterEach: (callback: VitestTestCallback) => void; export const vi: { fn(implementation?: VitestMockImplementation): VitestMockFunction; - mock(id: string, factory: () => unknown): void; + mock(id: string, factory: VitestMockFactory): void; mocked(item: T): T; importActual(id: string): Promise; + stubGlobal(name: string, value: unknown): void; + unstubAllGlobals(): void; }; } diff --git a/integrations/routing-osrm/provider.test.ts b/integrations/routing-osrm/provider.test.ts new file mode 100644 index 00000000..82468d6d --- /dev/null +++ b/integrations/routing-osrm/provider.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { transformOsrmStep } from "./provider.js"; + +const osrmStep = { + distance: 120, + duration: 30, + name: "Main St", + maneuver: { type: "turn", modifier: "right", location: [0, 0] as [number, number] }, + geometry: { + type: "LineString" as const, + coordinates: [ + [0, 0], + [0.001, 0], + ] as [number, number][], + }, + intersections: [ + { + lanes: [ + { valid: false, indications: ["left"] }, + { valid: true, indications: ["straight"] }, + ], + }, + ], + annotation: { maxspeed: [{ speed: 50, unit: "km/h" }] }, +}; + +describe("transformOsrmStep", () => { + it("carries maneuver type/modifier", () => { + const s = transformOsrmStep(osrmStep); + expect(s.maneuver).toEqual({ type: "turn", modifier: "right" }); + }); + + it("carries lanes from the first intersection with lanes", () => { + const s = transformOsrmStep(osrmStep); + expect(s.lanes).toEqual([ + { indications: ["left"], valid: false }, + { indications: ["straight"], valid: true }, + ]); + }); + + it("carries speed limit in km/h", () => { + const s = transformOsrmStep(osrmStep); + expect(s.speedLimit).toBe(50); + }); + + it("converts mph speed limits to km/h", () => { + const s = transformOsrmStep({ + ...osrmStep, + annotation: { maxspeed: [{ speed: 30, unit: "mph" }] }, + }); + expect(s.speedLimit).toBe(48); // 30 * 1.609 ≈ 48 + }); + + it("omits lanes and speed limit when not provided", () => { + const s = transformOsrmStep({ + distance: 10, + duration: 5, + name: "Side St", + maneuver: { type: "depart", location: [0, 0] as [number, number] }, + geometry: { + type: "LineString" as const, + coordinates: [[0, 0]] as [number, number][], + }, + }); + expect(s.lanes).toBeUndefined(); + expect(s.speedLimit).toBeUndefined(); + expect(s.maneuver).toEqual({ type: "depart", modifier: undefined }); + }); + + it("skips maxspeed entries flagged unknown", () => { + const s = transformOsrmStep({ + ...osrmStep, + annotation: { maxspeed: [{ unknown: true }, { speed: 70, unit: "km/h" }] }, + }); + expect(s.speedLimit).toBe(70); + }); +}); diff --git a/integrations/routing-osrm/provider.ts b/integrations/routing-osrm/provider.ts index 4477b2d1..8cffd7ea 100644 --- a/integrations/routing-osrm/provider.ts +++ b/integrations/routing-osrm/provider.ts @@ -5,7 +5,11 @@ import type { DirectionsResult, Route, RouteLeg, RouteStep, TravelMode } from "@openmapx/core"; import { USER_AGENT } from "@openmapx/core"; -import type { RoutingOptions, RoutingProvider } from "@openmapx/integration-routing/types"; +import type { + ManeuverLane, + RoutingOptions, + RoutingProvider, +} from "@openmapx/integration-routing/types"; // Populated by setup(ctx): service-registry URL → ctx.config.endpoint (which // already folds in `INTEGRATION_ROUTING_OSRM_ENDPOINT` + legacy `OSRM_URL` @@ -24,6 +28,19 @@ interface OsrmManeuver { exit?: number; } +interface OsrmLane { + valid?: boolean; + indications?: string[]; +} + +interface OsrmIntersection { + lanes?: OsrmLane[]; +} + +interface OsrmAnnotation { + maxspeed?: { speed?: number; unit?: string; unknown?: boolean }[]; +} + interface OsrmStep { distance: number; duration: number; @@ -31,6 +48,8 @@ interface OsrmStep { ref?: string; maneuver: OsrmManeuver; geometry: { type: "LineString"; coordinates: [number, number][] }; + intersections?: OsrmIntersection[]; + annotation?: OsrmAnnotation; } interface OsrmLeg { @@ -93,13 +112,42 @@ function generateInstruction(maneuver: OsrmManeuver, name: string, ref?: string) } } -function transformLeg(leg: OsrmLeg): RouteLeg { - const steps: RouteStep[] = leg.steps.map((step) => ({ +/** Lane guidance from the first intersection that carries lanes, if any. */ +function osrmLanes(step: OsrmStep): ManeuverLane[] | undefined { + const withLanes = step.intersections?.find((i) => i.lanes && i.lanes.length > 0); + if (!withLanes?.lanes) return undefined; + return withLanes.lanes.map((l) => ({ + indications: l.indications ?? [], + valid: Boolean(l.valid), + })); +} + +/** First known maxspeed annotation, normalized to km/h. */ +function osrmSpeedLimit(step: OsrmStep): number | undefined { + const entry = step.annotation?.maxspeed?.find((m) => typeof m.speed === "number" && !m.unknown); + if (!entry || typeof entry.speed !== "number") return undefined; + if (entry.unit === "mph") return Math.round(entry.speed * 1.609); + return entry.speed; // km/h +} + +/** + * Map a raw OSRM step to the unified RouteStep, carrying the normalized + * maneuver, lane guidance, and speed limit when present. Exported for testing. + */ +export function transformOsrmStep(step: OsrmStep): RouteStep { + return { instruction: generateInstruction(step.maneuver, step.name, step.ref), distance: step.distance, duration: step.duration, coordinates: step.geometry.coordinates, - })); + maneuver: { type: step.maneuver.type, modifier: step.maneuver.modifier }, + lanes: osrmLanes(step), + speedLimit: osrmSpeedLimit(step), + }; +} + +function transformLeg(leg: OsrmLeg): RouteLeg { + const steps: RouteStep[] = leg.steps.map(transformOsrmStep); const geometry: [number, number][] = leg.steps.flatMap((step) => step.geometry.coordinates); const summary = leg.summary ? `via ${leg.summary}` : undefined; @@ -152,6 +200,7 @@ export const osrmService: RoutingProvider = { url.searchParams.set("overview", "full"); url.searchParams.set("geometries", "geojson"); url.searchParams.set("steps", "true"); + url.searchParams.set("annotations", "maxspeed"); // OSRM only supports alternatives with exactly 2 waypoints if (waypoints.length === 2) { url.searchParams.set("alternatives", "3"); @@ -191,6 +240,7 @@ export const osrmService: RoutingProvider = { url.searchParams.set("overview", "full"); url.searchParams.set("geometries", "geojson"); url.searchParams.set("steps", "true"); + url.searchParams.set("annotations", "maxspeed"); url.searchParams.set("source", "first"); url.searchParams.set("destination", "last"); url.searchParams.set("roundtrip", "false"); diff --git a/integrations/routing-valhalla/provider.test.ts b/integrations/routing-valhalla/provider.test.ts new file mode 100644 index 00000000..3a8091c1 --- /dev/null +++ b/integrations/routing-valhalla/provider.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { transformTraceEdge, valhallaManeuverType } from "./provider.js"; + +describe("valhallaManeuverType", () => { + it("maps right-turn enum to normalized turn/right", () => { + // Valhalla: 10 = turn right + expect(valhallaManeuverType(10)).toEqual({ type: "turn", modifier: "right" }); + }); + + it("maps left-turn enum to normalized turn/left", () => { + // Valhalla: 14 = turn left + expect(valhallaManeuverType(14)).toEqual({ type: "turn", modifier: "left" }); + }); + + it("maps a depart enum", () => { + expect(valhallaManeuverType(1)).toEqual({ type: "depart" }); + }); + + it("maps destination enum to arrive", () => { + // Valhalla: 4/5/6 = destination + expect(valhallaManeuverType(4)).toEqual({ type: "arrive" }); + }); + + it("maps a roundabout enum", () => { + // Valhalla: 26/27 = roundabout enter/exit + expect(valhallaManeuverType(26).type).toBe("roundabout"); + }); + + it("maps a merge enum", () => { + expect(valhallaManeuverType(21)).toEqual({ type: "merge" }); + }); + + it("maps a fork enum with side modifier", () => { + expect(valhallaManeuverType(18)).toEqual({ type: "fork", modifier: "right" }); + expect(valhallaManeuverType(19)).toEqual({ type: "fork", modifier: "left" }); + }); + + it("falls back to turn/straight for unknown enums", () => { + expect(valhallaManeuverType(999)).toEqual({ type: "turn", modifier: "straight" }); + }); +}); + +describe("transformTraceEdge", () => { + it("maps speed_limit (km/h) to speedLimit and length to metres", () => { + const edge = transformTraceEdge({ + way_id: 12345, + length: 0.5, // km + speed: 48, + speed_limit: 50, + surface: "paved_smooth", + names: ["Friedrichstraße"], + begin_shape_index: 0, + end_shape_index: 3, + }); + expect(edge.speedLimit).toBe(50); + expect(edge.speed).toBe(48); + expect(edge.length).toBe(500); + expect(edge.wayId).toBe(12345); + }); + + it("passes through a missing speed_limit as undefined", () => { + const edge = transformTraceEdge({ length: 0.1 }); + expect(edge.speedLimit).toBeUndefined(); + }); +}); diff --git a/integrations/routing-valhalla/provider.ts b/integrations/routing-valhalla/provider.ts index 28c4510a..72860478 100644 --- a/integrations/routing-valhalla/provider.ts +++ b/integrations/routing-valhalla/provider.ts @@ -7,6 +7,7 @@ import type { DirectionsResult, Route, RouteLeg, RouteStep, TravelMode } from "@openmapx/core"; import { decodePolyline } from "@openmapx/core"; import type { + ManeuverLane, MatchEdge, MatchOptions, MatchPoint, @@ -48,6 +49,12 @@ const COSTING_MAP: Record = { const ELEVATION_INTERVAL = 30; // metres between elevation samples +interface ValhallaLaneRaw { + directions?: string[]; + active?: boolean; + valid?: boolean; +} + interface ValhallaManeuver { type: number; instruction: string; @@ -56,6 +63,7 @@ interface ValhallaManeuver { begin_shape_index: number; end_shape_index: number; street_names?: string[]; + lanes?: ValhallaLaneRaw[]; } interface ValhallaLeg { @@ -113,6 +121,65 @@ function buildDateTime(options: RoutingOptions): ValhallaDateTime { return { type: 0 }; } +/** + * Map a Valhalla maneuver type enum to the normalized { type, modifier } shape. + * Enum values follow Valhalla's documented `maneuver.type` table. + * Exported for testing. + */ +export function valhallaManeuverType(t: number): { type: string; modifier?: string } { + switch (t) { + case 1: + case 2: + case 3: + return { type: "depart" }; + case 4: + case 5: + case 6: + return { type: "arrive" }; + case 8: + return { type: "turn", modifier: "straight" }; + case 9: + return { type: "turn", modifier: "slight right" }; + case 10: + return { type: "turn", modifier: "right" }; + case 11: + return { type: "turn", modifier: "sharp right" }; + case 12: + return { type: "turn", modifier: "uturn" }; + case 13: + return { type: "turn", modifier: "sharp left" }; + case 14: + return { type: "turn", modifier: "left" }; + case 15: + return { type: "turn", modifier: "slight left" }; + case 16: + case 17: + return { type: "turn", modifier: "straight" }; // ramp straight / stay + case 18: + case 19: + case 20: + return { type: "fork", modifier: t === 18 ? "right" : "left" }; + case 21: + case 22: + case 23: + return { type: "merge" }; + case 26: + case 27: + return { type: "roundabout" }; + default: + return { type: "turn", modifier: "straight" }; + } +} + +/** Lane guidance from a Valhalla maneuver, when present. */ +function valhallaLanes(maneuver: ValhallaManeuver): ManeuverLane[] | undefined { + if (!maneuver.lanes || maneuver.lanes.length === 0) return undefined; + return maneuver.lanes.map((l) => ({ + indications: l.directions ?? [], + valid: Boolean(l.valid ?? l.active), + })); +} + function transformLeg(leg: ValhallaLeg): RouteLeg { const coords = decodePolyline(leg.shape, 6); const steps: RouteStep[] = leg.maneuvers.map((m) => ({ @@ -120,6 +187,8 @@ function transformLeg(leg: ValhallaLeg): RouteLeg { distance: m.length * 1000, // km -> metres duration: m.time, coordinates: coords.slice(m.begin_shape_index, m.end_shape_index + 1), + maneuver: valhallaManeuverType(m.type), + lanes: valhallaLanes(m), })); const firstNamed = leg.maneuvers.find((m) => m.street_names && m.street_names.length > 0); @@ -173,6 +242,7 @@ interface ValhallaTraceEdge { way_id?: number; length?: number; // km speed?: number; // km/h + speed_limit?: number; // km/h (posted) surface?: string; names?: string[]; begin_shape_index?: number; @@ -198,6 +268,7 @@ const TRACE_ATTRIBUTE_FILTER = [ "edge.way_id", "edge.length", "edge.speed", + "edge.speed_limit", "edge.surface", "edge.names", "edge.begin_shape_index", @@ -210,11 +281,14 @@ const TRACE_ATTRIBUTE_FILTER = [ "shape", ] as const; -function transformTraceEdge(edge: ValhallaTraceEdge): MatchEdge { +export function transformTraceEdge(edge: ValhallaTraceEdge): MatchEdge { return { wayId: edge.way_id, length: (edge.length ?? 0) * 1000, // km -> metres speed: edge.speed, + // Valhalla returns km/h; absent/0 means unknown — passed through as-is. + // The client treats <=0 as null. + speedLimit: edge.speed_limit, surface: edge.surface, names: edge.names, beginShapeIndex: edge.begin_shape_index ?? 0, diff --git a/integrations/routing/types.ts b/integrations/routing/types.ts index 9119ae09..596680f3 100644 --- a/integrations/routing/types.ts +++ b/integrations/routing/types.ts @@ -6,6 +6,7 @@ export type { IsochronePolygon, IsochroneResult, IsochroneTravelMode, + ManeuverLane, MatchEdge, MatchOptions, MatchPoint, diff --git a/packages/core/package.json b/packages/core/package.json index 684bd907..e04abc05 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,12 +50,14 @@ "@better-auth/passkey": "^1.6.11", "@openstreetmap/id-tagging-schema": "^6.17.0", "@tanstack/react-query": "^5.100.14", + "@turf/helpers": "^7.3.5", + "@turf/nearest-point-on-line": "^7.3.5", "@types/js-yaml": "^4.0.9", + "@types/tz-lookup": "^6.1.2", "better-auth": "^1.6.11", "js-yaml": "^4.1.1", "opening_hours": "^3.12.0", "tz-lookup": "^6.1.25", - "@types/tz-lookup": "^6.1.2", "zod": "^4.4.3", "zustand": "^5.0.13" } diff --git a/packages/core/src/api/directions.test.ts b/packages/core/src/api/directions.test.ts new file mode 100644 index 00000000..d6442029 --- /dev/null +++ b/packages/core/src/api/directions.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClient } from "./client"; +import { fetchDirections } from "./directions"; + +describe("fetchDirections", () => { + beforeEach(() => vi.restoreAllMocks()); + + it("serializes waypoints and forwards options to apiClient.get", async () => { + const spy = vi.spyOn(apiClient, "get").mockResolvedValue({ routes: [] } as never); + await fetchDirections({ + waypoints: [ + [1, 2], + [3, 4], + ], + mode: "driving", + avoidHighways: true, + units: "metric", + lang: "en", + }); + expect(spy).toHaveBeenCalledWith( + "/api/integrations/routing/directions", + expect.objectContaining({ + waypoints: "1,2;3,4", + mode: "driving", + avoidHighways: "true", + lang: "en", + }), + ); + }); +}); diff --git a/packages/core/src/api/directions.ts b/packages/core/src/api/directions.ts new file mode 100644 index 00000000..3f0a213c --- /dev/null +++ b/packages/core/src/api/directions.ts @@ -0,0 +1,42 @@ +import type { DirectionsResult, TravelMode } from "@integrations/routing/types"; +import type { LngLat } from "../types/geometry"; +import { apiClient } from "./client"; +import { API_ENDPOINTS } from "./endpoints"; + +export interface FetchDirectionsParams { + waypoints: LngLat[]; + mode?: TravelMode; + avoidHighways?: boolean; + avoidTolls?: boolean; + avoidFerries?: boolean; + units?: "metric" | "imperial"; + lang?: string; + departAt?: string; + arriveBy?: string; +} + +/** Plain (hook-free) directions fetch, shared by useDirections and reroute. */ +export function fetchDirections({ + waypoints, + mode = "driving", + avoidHighways = false, + avoidTolls = false, + avoidFerries = false, + units = "metric", + lang, + departAt, + arriveBy, +}: FetchDirectionsParams): Promise { + const waypointsStr = waypoints.map(([lng, lat]) => `${lng},${lat}`).join(";"); + return apiClient.get(API_ENDPOINTS.directions, { + waypoints: waypointsStr, + mode, + avoidHighways: String(avoidHighways), + avoidTolls: String(avoidTolls), + avoidFerries: String(avoidFerries), + units, + ...(lang && { lang }), + ...(departAt && { departAt }), + ...(arriveBy && { arriveBy }), + }); +} diff --git a/packages/core/src/api/speedLimit.test.ts b/packages/core/src/api/speedLimit.test.ts new file mode 100644 index 00000000..8f228be0 --- /dev/null +++ b/packages/core/src/api/speedLimit.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClient } from "./client"; +import { fetchSpeedLimit } from "./speedLimit"; + +describe("fetchSpeedLimit", () => { + beforeEach(() => vi.restoreAllMocks()); + + it("returns the edge speedLimit for the last matched point", async () => { + vi.spyOn(apiClient, "post").mockResolvedValue({ + geometry: [], + mode: "driving", + edges: [ + { length: 10, speedLimit: 30 }, + { length: 20, speedLimit: 50 }, + ], + points: [ + { lat: 0, lng: 0, type: "matched", edgeIndex: 0 }, + { lat: 1, lng: 1, type: "matched", edgeIndex: 1 }, + ], + } as never); + const limit = await fetchSpeedLimit( + [ + [13.3765, 52.5096], + [13.377, 52.511], + ], + "driving", + ); + expect(limit).toBe(50); + }); + + it("returns null when the matched edge has speedLimit <= 0", async () => { + vi.spyOn(apiClient, "post").mockResolvedValue({ + geometry: [], + mode: "driving", + edges: [{ length: 10, speedLimit: 0 }], + points: [{ lat: 0, lng: 0, type: "matched", edgeIndex: 0 }], + } as never); + const limit = await fetchSpeedLimit( + [ + [13.3765, 52.5096], + [13.377, 52.511], + ], + "driving", + ); + expect(limit).toBeNull(); + }); + + it("returns null when the matched edge has no speedLimit", async () => { + vi.spyOn(apiClient, "post").mockResolvedValue({ + geometry: [], + mode: "driving", + edges: [{ length: 10 }], + points: [{ lat: 0, lng: 0, type: "matched", edgeIndex: 0 }], + } as never); + const limit = await fetchSpeedLimit( + [ + [13.3765, 52.5096], + [13.377, 52.511], + ], + "driving", + ); + expect(limit).toBeNull(); + }); + + it("returns null when the last point has no edgeIndex", async () => { + vi.spyOn(apiClient, "post").mockResolvedValue({ + geometry: [], + mode: "driving", + edges: [{ length: 10, speedLimit: 50 }], + points: [{ lat: 0, lng: 0, type: "unmatched" }], + } as never); + const limit = await fetchSpeedLimit( + [ + [13.3765, 52.5096], + [13.377, 52.511], + ], + "driving", + ); + expect(limit).toBeNull(); + }); + + it("returns null for a trace with fewer than 2 points without calling the API", async () => { + const spy = vi.spyOn(apiClient, "post"); + const limit = await fetchSpeedLimit([[13.3765, 52.5096]], "driving"); + expect(limit).toBeNull(); + expect(spy).not.toHaveBeenCalled(); + }); + + it("returns null when the request throws", async () => { + vi.spyOn(apiClient, "post").mockRejectedValue(new Error("boom")); + const limit = await fetchSpeedLimit( + [ + [13.3765, 52.5096], + [13.377, 52.511], + ], + "driving", + ); + expect(limit).toBeNull(); + }); +}); diff --git a/packages/core/src/api/speedLimit.ts b/packages/core/src/api/speedLimit.ts new file mode 100644 index 00000000..a52aa573 --- /dev/null +++ b/packages/core/src/api/speedLimit.ts @@ -0,0 +1,25 @@ +import type { MatchResult, TravelMode } from "@integrations/routing/types"; +import type { LngLat } from "../types/geometry"; +import { apiClient } from "./client"; +import { API_ENDPOINTS } from "./endpoints"; + +/** + * Map-match a short trace and return the posted speed limit (km/h) for the + * edge under the LAST (most recent) trace point, or null when unknown. + */ +export async function fetchSpeedLimit(trace: LngLat[], mode: TravelMode): Promise { + if (trace.length < 2) return null; + try { + const res = await apiClient.post(API_ENDPOINTS.routingMatch, { + trace: trace.map(([lng, lat]) => ({ lat, lng })), + mode, + shapeMatch: "map_snap", + }); + const point = res.points?.[res.points.length - 1]; + if (!point || point.edgeIndex === undefined) return null; + const limit = res.edges?.[point.edgeIndex]?.speedLimit; + return typeof limit === "number" && limit > 0 ? limit : null; + } catch { + return null; + } +} diff --git a/packages/core/src/hooks/useDirections.ts b/packages/core/src/hooks/useDirections.ts index 70116205..3353d038 100644 --- a/packages/core/src/hooks/useDirections.ts +++ b/packages/core/src/hooks/useDirections.ts @@ -1,7 +1,6 @@ -import type { DirectionsResult, TravelMode } from "@integrations/routing/types"; +import type { TravelMode } from "@integrations/routing/types"; import { useQuery } from "@tanstack/react-query"; -import { apiClient } from "../api/client"; -import { API_ENDPOINTS } from "../api/endpoints"; +import { fetchDirections } from "../api/directions"; import type { LngLat } from "../types/geometry"; interface UseDirectionsParams { @@ -45,16 +44,16 @@ export function useDirections({ arriveBy, ], queryFn: () => - apiClient.get(API_ENDPOINTS.directions, { - waypoints: waypointsStr, + fetchDirections({ + waypoints, mode, - avoidHighways: String(avoidHighways), - avoidTolls: String(avoidTolls), - avoidFerries: String(avoidFerries), + avoidHighways, + avoidTolls, + avoidFerries, units, - ...(lang && { lang }), - ...(departAt && { departAt }), - ...(arriveBy && { arriveBy }), + lang, + departAt, + arriveBy, }), enabled: waypoints.length >= 2, staleTime: 120_000, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40abf979..0bc1059f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export type { IsochronePolygon, IsochroneResult, IsochroneTravelMode, + ManeuverLane, Route, RouteLeg, RouteStep, @@ -80,7 +81,12 @@ export { configureApiClient, proxyImageUrl, } from "./api/client"; +export { + type FetchDirectionsParams, + fetchDirections, +} from "./api/directions"; export { API_ENDPOINTS } from "./api/endpoints"; +export { fetchSpeedLimit } from "./api/speedLimit"; // Auth export type { Session, User } from "./auth/client"; export { type AuthConfig, authClient, initAuth } from "./auth/client"; @@ -220,6 +226,34 @@ export { useTides, type WaterLevelObservation, } from "./hooks/useTides"; +// Navigation +export { + computeProgress, + computeTransitProgress, + eta, + navOptionsForMode, + nextVoiceCue, + processFix, + shouldReroute, + simulatePositions, + snapToRoute, + stopsUntilAlight, +} from "./navigation"; +export type { TransitProgress } from "./navigation/transitProgress"; +export type { + CameraMode, + CueTier, + FixInput, + NavProgress, + NavStatus, + NavTickOptions, + NavTickResult, + NavTickState, + ProgressResult, + RerouteOpts, + SnapResult, + VoiceCue, +} from "./navigation/types"; export type { PanelId } from "./panels/ids"; export { PANEL } from "./panels/ids"; export { getPanel, getPanelsByLayer, PANEL_REGISTRY } from "./panels/registry"; @@ -250,6 +284,8 @@ export { useLayerStore } from "./stores/layerStore"; export { useMapClickStore } from "./stores/mapClickStore"; export { useMapStore } from "./stores/mapStore"; export { useMenuStore } from "./stores/menuStore"; +export type { NavKind } from "./stores/navigationStore"; +export { useNavigationStore } from "./stores/navigationStore"; export type { OpeningHoursFilter } from "./stores/openingHoursStore"; export { useOpeningHoursStore } from "./stores/openingHoursStore"; export type { OverlayEntry, OverlayId } from "./stores/overlayRegistry"; @@ -265,6 +301,7 @@ export { export { usePlaceStore } from "./stores/placeStore"; export { useSavedPlacesStore } from "./stores/savedPlacesStore"; export { useSearchStore } from "./stores/searchStore"; +export { useSettingsStore } from "./stores/settingsStore"; export { useSidebarStore } from "./stores/sidebarStore"; export type { DeliveryProviderInfo, DeliverySearchParams } from "./types/delivery"; export type { diff --git a/packages/core/src/navigation/__tests__/eta.test.ts b/packages/core/src/navigation/__tests__/eta.test.ts new file mode 100644 index 00000000..9ec9ab3c --- /dev/null +++ b/packages/core/src/navigation/__tests__/eta.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { eta } from "../eta"; + +describe("eta", () => { + it("adds remaining seconds (as ms) to now", () => { + expect(eta(120, 1_000_000)).toBe(1_000_000 + 120_000); + }); +}); diff --git a/packages/core/src/navigation/__tests__/options.test.ts b/packages/core/src/navigation/__tests__/options.test.ts new file mode 100644 index 00000000..058ef871 --- /dev/null +++ b/packages/core/src/navigation/__tests__/options.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { navOptionsForMode } from "../options"; + +describe("navOptionsForMode", () => { + it("uses a wider off-route threshold for driving than walking", () => { + expect(navOptionsForMode("driving").reroute.thresholdMeters).toBeGreaterThan( + navOptionsForMode("walking").reroute.thresholdMeters, + ); + }); + + it("provides voice thresholds and an arrival threshold for every ground mode", () => { + for (const m of ["driving", "walking", "cycling"] as const) { + const o = navOptionsForMode(m); + expect(o.voiceThresholds.far).toBeGreaterThan(o.voiceThresholds.near); + expect(o.arrivalThresholdMeters).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/navigation/__tests__/processFix.test.ts b/packages/core/src/navigation/__tests__/processFix.test.ts new file mode 100644 index 00000000..0cc3014d --- /dev/null +++ b/packages/core/src/navigation/__tests__/processFix.test.ts @@ -0,0 +1,80 @@ +import type { Route } from "@integrations/routing/types"; +import { describe, expect, it } from "vitest"; +import { navOptionsForMode } from "../options"; +import { processFix } from "../processFix"; +import { simulatePositions } from "../simulatePositions"; +import type { NavTickResult, NavTickState } from "../types"; + +const geometry: [number, number][] = [ + [0, 0], + [0.002, 0], + [0.004, 0], +]; + +const route = { + distance: 444, + duration: 60, + geometry, + legs: [], + mode: "driving", + steps: [ + { instruction: "Head east", distance: 222, duration: 30, coordinates: geometry.slice(0, 2) }, + { instruction: "Arrive", distance: 222, duration: 30, coordinates: geometry.slice(1, 3) }, + ], +} as unknown as Route; + +const opts = navOptionsForMode("driving"); +const emptyState: NavTickState = { deviationHistory: [], lastRerouteAtMs: null, spokenCues: [] }; + +describe("processFix", () => { + it("rejects fixes worse than the accuracy cap", () => { + const r = processFix( + route, + { coords: [0.001, 0], accuracy: 999, timestampMs: 0 }, + emptyState, + opts, + ); + expect(r.progress).toBeNull(); + }); + + it("produces progress for an on-route fix", () => { + const r = processFix( + route, + { coords: [0.001, 0], accuracy: 5, timestampMs: 1000 }, + emptyState, + opts, + ); + expect(r.progress).not.toBeNull(); + expect(r.progress?.currentStepIndex).toBe(0); + expect(r.offRoute).toBe(false); + expect(r.progress?.etaEpochMs).toBeGreaterThan(1000); + }); + + it("flags reroute after sustained off-route fixes", () => { + let state: NavTickState = emptyState; + let last: NavTickResult | undefined; + const rerouteFlags: boolean[] = []; + for (const fix of simulatePositions(geometry, { + stepMeters: 50, + offsetMeters: 200, + accuracy: 5, + })) { + last = processFix(route, fix, state, opts); + state = last.nextState; + rerouteFlags.push(last.needsReroute); + } + expect(last?.offRoute).toBe(true); + expect(state.deviationHistory.length).toBeGreaterThan(0); + expect(rerouteFlags.some(Boolean)).toBe(true); + }); + + it("arrives near the destination", () => { + const r = processFix( + route, + { coords: [0.004, 0], accuracy: 5, timestampMs: 5000 }, + emptyState, + opts, + ); + expect(r.arrived).toBe(true); + }); +}); diff --git a/packages/core/src/navigation/__tests__/progress.test.ts b/packages/core/src/navigation/__tests__/progress.test.ts new file mode 100644 index 00000000..667e1260 --- /dev/null +++ b/packages/core/src/navigation/__tests__/progress.test.ts @@ -0,0 +1,37 @@ +import type { Route } from "@integrations/routing/types"; +import { describe, expect, it } from "vitest"; +import { computeProgress } from "../progress"; + +const route = { + distance: 300, + duration: 60, + geometry: [], + legs: [], + mode: "driving", + steps: [ + { instruction: "a", distance: 100, duration: 20, coordinates: [] }, + { instruction: "b", distance: 100, duration: 20, coordinates: [] }, + { instruction: "c", distance: 100, duration: 20, coordinates: [] }, + ], +} as unknown as Route; + +describe("computeProgress", () => { + it("locates the current step from along-distance", () => { + const p = computeProgress(route, 150); + expect(p.currentStepIndex).toBe(1); + expect(p.distanceToNextManeuver).toBe(50); // end of step 1 (200) - 150 + expect(p.distanceRemaining).toBe(150); + }); + + it("computes duration remaining as partial current step + later steps", () => { + const p = computeProgress(route, 150); // half of step 1 left + step 2 + expect(p.durationRemaining).toBeCloseTo(30, 5); // 10 + 20 + }); + + it("clamps past the end", () => { + const p = computeProgress(route, 999); + expect(p.currentStepIndex).toBe(2); + expect(p.distanceRemaining).toBe(0); + expect(p.distanceToNextManeuver).toBe(0); + }); +}); diff --git a/packages/core/src/navigation/__tests__/reroute.test.ts b/packages/core/src/navigation/__tests__/reroute.test.ts new file mode 100644 index 00000000..86c2b8a0 --- /dev/null +++ b/packages/core/src/navigation/__tests__/reroute.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { shouldReroute } from "../reroute"; + +const opts = { thresholdMeters: 40, consecutiveFixes: 3, debounceMs: 10_000 }; + +describe("shouldReroute", () => { + it("returns false until enough consecutive off-route fixes", () => { + expect(shouldReroute([50, 50], null, 0, opts)).toBe(false); + }); + + it("returns true after N consecutive fixes over threshold", () => { + expect(shouldReroute([50, 60, 55], null, 0, opts)).toBe(true); + }); + + it("returns false when any of the last N is under threshold", () => { + expect(shouldReroute([50, 10, 55], null, 0, opts)).toBe(false); + }); + + it("respects the debounce window", () => { + expect(shouldReroute([50, 60, 55], 1_000, 5_000, opts)).toBe(false); // 4s < 10s + expect(shouldReroute([50, 60, 55], 1_000, 12_000, opts)).toBe(true); // 11s > 10s + }); +}); diff --git a/packages/core/src/navigation/__tests__/simulatePositions.test.ts b/packages/core/src/navigation/__tests__/simulatePositions.test.ts new file mode 100644 index 00000000..9e7c9a36 --- /dev/null +++ b/packages/core/src/navigation/__tests__/simulatePositions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { simulatePositions } from "../simulatePositions"; + +const geometry: [number, number][] = [ + [0, 0], + [0.001, 0], + [0.002, 0], +]; + +describe("simulatePositions", () => { + it("emits fixes from start to end with increasing timestamps", () => { + const fixes = simulatePositions(geometry, { stepMeters: 20, startMs: 1000, intervalMs: 1000 }); + expect(fixes.length).toBeGreaterThan(2); + expect(fixes[0].coords[0]).toBeCloseTo(0, 5); + expect(fixes[fixes.length - 1].coords[0]).toBeCloseTo(0.002, 4); + expect(fixes[1].timestampMs - fixes[0].timestampMs).toBe(1000); + }); + + it("can inject lateral offset to simulate going off-route", () => { + const fixes = simulatePositions(geometry, { stepMeters: 20, offsetMeters: 100 }); + expect(Math.abs(fixes[1].coords[1])).toBeGreaterThan(0); // pushed off the line + }); +}); diff --git a/packages/core/src/navigation/__tests__/snap.test.ts b/packages/core/src/navigation/__tests__/snap.test.ts new file mode 100644 index 00000000..295cc7dc --- /dev/null +++ b/packages/core/src/navigation/__tests__/snap.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { snapToRoute } from "../snap"; + +// A 3-point line heading due east along the equator-ish latitude. +const geometry: [number, number][] = [ + [0, 0], + [0.001, 0], + [0.002, 0], +]; + +describe("snapToRoute", () => { + it("snaps a point on the line with ~zero deviation", () => { + const r = snapToRoute(geometry, [0.0005, 0]); + expect(r.deviationMeters).toBeLessThan(1); + expect(r.alongMeters).toBeGreaterThan(50); + expect(r.alongMeters).toBeLessThan(60); // ~55.6 m at this latitude + }); + + it("reports deviation for an off-line point", () => { + const r = snapToRoute(geometry, [0.001, 0.001]); // ~111 m north of the line + expect(r.deviationMeters).toBeGreaterThan(100); + }); +}); diff --git a/packages/core/src/navigation/__tests__/voiceCue.test.ts b/packages/core/src/navigation/__tests__/voiceCue.test.ts new file mode 100644 index 00000000..9f4298b4 --- /dev/null +++ b/packages/core/src/navigation/__tests__/voiceCue.test.ts @@ -0,0 +1,32 @@ +import type { RouteStep } from "@integrations/routing/types"; +import { describe, expect, it } from "vitest"; +import { nextVoiceCue } from "../voiceCue"; + +const step = { + instruction: "Turn right", + distance: 500, + duration: 60, + coordinates: [], +} as RouteStep; +const thresholds = { far: 400, near: 200 }; + +describe("nextVoiceCue", () => { + it("returns no cue when beyond the far threshold", () => { + expect(nextVoiceCue(step, 0, 800, thresholds, [])).toBeNull(); + }); + + it("returns the 'far' tier once on first crossing", () => { + const cue = nextVoiceCue(step, 0, 350, thresholds, []); + expect(cue?.tier).toBe("far"); + expect(cue?.key).toBe("0:far"); + }); + + it("does not repeat an already-spoken tier", () => { + expect(nextVoiceCue(step, 0, 350, thresholds, ["0:far"])).toBeNull(); + }); + + it("escalates to 'now' under 30 m", () => { + const cue = nextVoiceCue(step, 0, 20, thresholds, ["0:far", "0:near"]); + expect(cue?.tier).toBe("now"); + }); +}); diff --git a/packages/core/src/navigation/eta.ts b/packages/core/src/navigation/eta.ts new file mode 100644 index 00000000..ccc5b216 --- /dev/null +++ b/packages/core/src/navigation/eta.ts @@ -0,0 +1,4 @@ +/** Estimated arrival epoch (ms) given seconds remaining and the current time. */ +export function eta(durationRemainingSec: number, nowMs: number): number { + return nowMs + durationRemainingSec * 1000; +} diff --git a/packages/core/src/navigation/index.ts b/packages/core/src/navigation/index.ts new file mode 100644 index 00000000..4dbbcadf --- /dev/null +++ b/packages/core/src/navigation/index.ts @@ -0,0 +1,13 @@ +export { eta } from "./eta"; +export { navOptionsForMode } from "./options"; +export { processFix } from "./processFix"; +export { computeProgress } from "./progress"; +export { shouldReroute } from "./reroute"; +export { simulatePositions } from "./simulatePositions"; +export { snapToRoute } from "./snap"; +export { + computeTransitProgress, + stopsUntilAlight, + type TransitProgress, +} from "./transitProgress"; +export { nextVoiceCue } from "./voiceCue"; diff --git a/packages/core/src/navigation/options.ts b/packages/core/src/navigation/options.ts new file mode 100644 index 00000000..fcba1c1a --- /dev/null +++ b/packages/core/src/navigation/options.ts @@ -0,0 +1,32 @@ +import type { TravelMode } from "@integrations/routing/types"; +import type { NavTickOptions } from "./types"; + +/** Per-mode tuning for off-route sensitivity, voice cadence, and arrival. */ +export function navOptionsForMode(mode: TravelMode): NavTickOptions { + switch (mode) { + case "walking": + return { + mode, + accuracyCapMeters: 40, + reroute: { thresholdMeters: 25, consecutiveFixes: 3, debounceMs: 8_000 }, + voiceThresholds: { far: 150, near: 50 }, + arrivalThresholdMeters: 20, + }; + case "cycling": + return { + mode, + accuracyCapMeters: 50, + reroute: { thresholdMeters: 30, consecutiveFixes: 3, debounceMs: 10_000 }, + voiceThresholds: { far: 250, near: 100 }, + arrivalThresholdMeters: 25, + }; + default: + return { + mode: "driving", + accuracyCapMeters: 60, + reroute: { thresholdMeters: 45, consecutiveFixes: 3, debounceMs: 10_000 }, + voiceThresholds: { far: 400, near: 200 }, + arrivalThresholdMeters: 35, + }; + } +} diff --git a/packages/core/src/navigation/processFix.ts b/packages/core/src/navigation/processFix.ts new file mode 100644 index 00000000..f61e3832 --- /dev/null +++ b/packages/core/src/navigation/processFix.ts @@ -0,0 +1,80 @@ +import type { Route } from "@integrations/routing/types"; +import { eta } from "./eta"; +import { computeProgress } from "./progress"; +import { shouldReroute } from "./reroute"; +import { snapToRoute } from "./snap"; +import type { FixInput, NavTickOptions, NavTickResult, NavTickState } from "./types"; +import { nextVoiceCue } from "./voiceCue"; + +const HISTORY_LIMIT = 6; + +/** + * Process one GPS fix against the active route. Pure: returns the new progress, + * off-route / reroute / arrival flags, an optional voice cue, and the next tick + * state (deviation history + spoken-cue keys). Rejected fixes return progress=null. + */ +export function processFix( + route: Route, + fix: FixInput, + state: NavTickState, + opts: NavTickOptions, +): NavTickResult { + if (fix.accuracy > opts.accuracyCapMeters) { + return { + progress: null, + offRoute: false, + needsReroute: false, + arrived: false, + voiceCue: null, + nextState: state, + }; + } + + const snap = snapToRoute(route.geometry, fix.coords); + const prog = computeProgress(route, snap.alongMeters); + + const deviationHistory = [...state.deviationHistory, snap.deviationMeters].slice(-HISTORY_LIMIT); + const offRoute = snap.deviationMeters > opts.reroute.thresholdMeters; + const needsReroute = shouldReroute( + deviationHistory, + state.lastRerouteAtMs, + fix.timestampMs, + opts.reroute, + ); + + const arrived = + prog.currentStepIndex === route.steps.length - 1 && + prog.distanceRemaining <= opts.arrivalThresholdMeters; + + const step = route.steps[prog.currentStepIndex]; + const cue = arrived + ? null + : nextVoiceCue( + step, + prog.currentStepIndex, + prog.distanceToNextManeuver, + opts.voiceThresholds, + state.spokenCues, + ); + + const spokenCues = cue ? [...state.spokenCues, cue.key] : state.spokenCues; + + return { + progress: { + ...prog, + snapped: snap.snapped, + alongMeters: snap.alongMeters, + deviationMeters: snap.deviationMeters, + etaEpochMs: eta(prog.durationRemaining, fix.timestampMs), + }, + offRoute, + needsReroute, + arrived, + voiceCue: cue, + nextState: { + deviationHistory, + lastRerouteAtMs: needsReroute ? fix.timestampMs : state.lastRerouteAtMs, + spokenCues, + }, + }; +} diff --git a/packages/core/src/navigation/progress.ts b/packages/core/src/navigation/progress.ts new file mode 100644 index 00000000..c43cf35e --- /dev/null +++ b/packages/core/src/navigation/progress.ts @@ -0,0 +1,44 @@ +import type { Route } from "@integrations/routing/types"; +import type { ProgressResult } from "./types"; + +/** + * Map a distance traveled along the route to the current step, the distance to + * the next maneuver, and the remaining distance/duration to the destination. + * Step boundaries are derived from per-step `distance` (cumulative). + */ +export function computeProgress(route: Route, alongMeters: number): ProgressResult { + const steps = route.steps; + if (steps.length === 0) { + return { + currentStepIndex: 0, + distanceToNextManeuver: 0, + distanceRemaining: 0, + durationRemaining: 0, + }; + } + const total = route.distance; + const along = Math.max(0, Math.min(alongMeters, total)); + + let cumEnd = 0; + let idx = steps.length - 1; + for (let i = 0; i < steps.length; i++) { + cumEnd += steps[i].distance; + if (along <= cumEnd) { + idx = i; + break; + } + } + + let endOfStep = 0; + for (let i = 0; i <= idx; i++) endOfStep += steps[i].distance; + + const distanceToNextManeuver = Math.max(0, endOfStep - along); + const distanceRemaining = Math.max(0, total - along); + + const current = steps[idx]; + const fractionLeft = current.distance > 0 ? distanceToNextManeuver / current.distance : 0; + let durationRemaining = current.duration * fractionLeft; + for (let i = idx + 1; i < steps.length; i++) durationRemaining += steps[i].duration; + + return { currentStepIndex: idx, distanceToNextManeuver, distanceRemaining, durationRemaining }; +} diff --git a/packages/core/src/navigation/reroute.ts b/packages/core/src/navigation/reroute.ts new file mode 100644 index 00000000..db9dc4d7 --- /dev/null +++ b/packages/core/src/navigation/reroute.ts @@ -0,0 +1,18 @@ +import type { RerouteOpts } from "./types"; + +/** + * Decide whether to trigger an online reroute: the last `consecutiveFixes` + * deviations must all exceed `thresholdMeters`, and the debounce window since + * the last reroute must have elapsed. + */ +export function shouldReroute( + deviationHistory: number[], + lastRerouteAtMs: number | null, + nowMs: number, + opts: RerouteOpts, +): boolean { + if (lastRerouteAtMs !== null && nowMs - lastRerouteAtMs < opts.debounceMs) return false; + if (deviationHistory.length < opts.consecutiveFixes) return false; + const recent = deviationHistory.slice(-opts.consecutiveFixes); + return recent.every((d) => d > opts.thresholdMeters); +} diff --git a/packages/core/src/navigation/simulatePositions.ts b/packages/core/src/navigation/simulatePositions.ts new file mode 100644 index 00000000..d882c42b --- /dev/null +++ b/packages/core/src/navigation/simulatePositions.ts @@ -0,0 +1,66 @@ +import type { LngLat } from "../types/geometry"; +import type { FixInput } from "./types"; + +interface SimulateOptions { + stepMeters?: number; + intervalMs?: number; + startMs?: number; + accuracy?: number; + /** Constant lateral offset (meters) applied north, to simulate off-route. */ + offsetMeters?: number; +} + +const EARTH = 6_378_137; +const toRad = (d: number) => (d * Math.PI) / 180; + +function haversine(a: LngLat, b: LngLat): number { + const dLat = toRad(b[1] - a[1]); + const dLng = toRad(b[0] - a[0]); + const lat1 = toRad(a[1]); + const lat2 = toRad(b[1]); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH * Math.asin(Math.sqrt(h)); +} + +function lerp(a: LngLat, b: LngLat, t: number): LngLat { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; +} + +/** Generate GPS fixes walking along a polyline at a fixed ground spacing. */ +export function simulatePositions(geometry: LngLat[], options: SimulateOptions = {}): FixInput[] { + const stepMeters = options.stepMeters ?? 25; + const intervalMs = options.intervalMs ?? 1000; + const startMs = options.startMs ?? 0; + const accuracy = options.accuracy ?? 5; + const offsetMeters = options.offsetMeters ?? 0; + const offsetDegLat = offsetMeters / (toRad(1) * EARTH); + + const fixes: FixInput[] = []; + let t = 0; + for (let i = 0; i < geometry.length - 1; i++) { + const a = geometry[i]; + const b = geometry[i + 1]; + const segLen = haversine(a, b); + if (segLen === 0) continue; + for (let d = 0; d < segLen; d += stepMeters) { + const p = lerp(a, b, d / segLen); + fixes.push({ + coords: [p[0], p[1] + offsetDegLat], + accuracy, + timestampMs: startMs + t * intervalMs, + heading: null, + speed: null, + }); + t++; + } + } + const last = geometry[geometry.length - 1]; + fixes.push({ + coords: [last[0], last[1] + offsetDegLat], + accuracy, + timestampMs: startMs + t * intervalMs, + heading: null, + speed: null, + }); + return fixes; +} diff --git a/packages/core/src/navigation/snap.ts b/packages/core/src/navigation/snap.ts new file mode 100644 index 00000000..5de7badf --- /dev/null +++ b/packages/core/src/navigation/snap.ts @@ -0,0 +1,21 @@ +import { lineString, point } from "@turf/helpers"; +import nearestPointOnLine from "@turf/nearest-point-on-line"; +import type { LngLat } from "../types/geometry"; +import type { SnapResult } from "./types"; + +/** + * Project a raw GPS fix onto a route polyline, returning the snapped point, + * the distance traveled along the line to that point, and the perpendicular + * deviation of the raw fix from the line. All distances in meters. + */ +export function snapToRoute(geometry: LngLat[], raw: LngLat): SnapResult { + const snapped = nearestPointOnLine(lineString(geometry), point(raw), { + units: "meters", + }); + return { + snapped: snapped.geometry.coordinates as LngLat, + alongMeters: snapped.properties.location ?? 0, + deviationMeters: snapped.properties.dist ?? 0, + segmentIndex: snapped.properties.index ?? 0, + }; +} diff --git a/packages/core/src/navigation/transitProgress.test.ts b/packages/core/src/navigation/transitProgress.test.ts new file mode 100644 index 00000000..65c67d85 --- /dev/null +++ b/packages/core/src/navigation/transitProgress.test.ts @@ -0,0 +1,122 @@ +import type { TripItinerary, TripLeg } from "@openmapx/mobility-core/transit"; +import { describe, expect, it } from "vitest"; +import type { LngLat } from "../types/geometry"; +import { computeTransitProgress, stopsUntilAlight } from "./transitProgress"; + +function leg(coords: LngLat[], partial: Partial = {}): TripLeg { + return { + mode: "bus", + startTime: "2026-06-01T10:00:00Z", + endTime: "2026-06-01T10:30:00Z", + from: { name: "A", lat: coords[0][1], lng: coords[0][0] }, + to: { + name: "B", + lat: coords[coords.length - 1][1], + lng: coords[coords.length - 1][0], + }, + geometry: { type: "LineString", coordinates: coords }, + ...partial, + }; +} + +function itinerary(legs: TripLeg[]): TripItinerary { + return { + duration: 1800, + startTime: "2026-06-01T10:00:00Z", + endTime: "2026-06-01T10:30:00Z", + transfers: legs.length - 1, + walkDistance: 0, + legs, + }; +} + +// Two legs running along distinct longitudes so the nearest-leg test is +// unambiguous: leg 0 is the west line (lng ~0), leg 1 the east line (lng ~1). +const legWest: LngLat[] = [ + [0, 0], + [0, 0.01], + [0, 0.02], +]; +const legEast: LngLat[] = [ + [1, 0], + [1, 0.01], + [1, 0.02], +]; + +describe("computeTransitProgress", () => { + it("selects the nearest leg across a 2-leg itinerary", () => { + const it = itinerary([leg(legWest), leg(legEast)]); + const onEast = computeTransitProgress(it, [1.0001, 0.005]); + expect(onEast.currentLegIndex).toBe(1); + const onWest = computeTransitProgress(it, [0.0001, 0.005]); + expect(onWest.currentLegIndex).toBe(0); + }); + + it("reports fractionAlongLeg roughly matching position", () => { + const it = itinerary([leg(legWest)]); + const mid = computeTransitProgress(it, [0, 0.01]); + expect(mid.fractionAlongLeg).toBeGreaterThan(0.4); + expect(mid.fractionAlongLeg).toBeLessThan(0.6); + }); + + it("flags arrived near the end of the last leg", () => { + const it = itinerary([leg(legWest), leg(legEast)]); + const nearEnd = computeTransitProgress(it, [1, 0.0199]); + expect(nearEnd.currentLegIndex).toBe(1); + expect(nearEnd.arrived).toBe(true); + }); + + it("does not flag arrived when still on an earlier leg", () => { + const it = itinerary([leg(legWest), leg(legEast)]); + const onFirst = computeTransitProgress(it, [0, 0.0199]); + expect(onFirst.currentLegIndex).toBe(0); + expect(onFirst.arrived).toBe(false); + }); + + it("handles degenerate legs gracefully", () => { + const it = itinerary([leg([[0, 0]])]); + const p = computeTransitProgress(it, [0, 0]); + expect(p.currentLegIndex).toBe(0); + expect(p.arrived).toBe(false); + }); +}); + +describe("stopsUntilAlight", () => { + const stops = [ + { lat: 0, lng: 0, name: "Origin" }, + { lat: 0.005, lng: 0, name: "Mid 1" }, + { lat: 0.012, lng: 0, name: "Mid 2" }, + { lat: 0.02, lng: 0, name: "Alight" }, + ]; + + it("returns the first stop ahead and the remaining count", () => { + // Snapped just past the origin → next is Mid 1, three stops remain. + const r = stopsUntilAlight(legWest, stops, [0, 0.001]); + expect(r.nextStopName).toBe("Mid 1"); + expect(r.stopsRemaining).toBe(3); + }); + + it("advances as the position moves along the leg", () => { + const r = stopsUntilAlight(legWest, stops, [0, 0.008]); + expect(r.nextStopName).toBe("Mid 2"); + expect(r.stopsRemaining).toBe(2); + }); + + it("reports the final stop when approaching the end", () => { + const r = stopsUntilAlight(legWest, stops, [0, 0.015]); + expect(r.nextStopName).toBe("Alight"); + expect(r.stopsRemaining).toBe(1); + }); + + it("returns nulls past the last stop", () => { + const r = stopsUntilAlight(legWest, stops, [0, 0.021]); + expect(r.nextStopName).toBeNull(); + expect(r.stopsRemaining).toBe(0); + }); + + it("returns nulls gracefully when stops are empty", () => { + const r = stopsUntilAlight(legWest, [], [0, 0.01]); + expect(r.nextStopName).toBeNull(); + expect(r.stopsRemaining).toBe(0); + }); +}); diff --git a/packages/core/src/navigation/transitProgress.ts b/packages/core/src/navigation/transitProgress.ts new file mode 100644 index 00000000..b153b88f --- /dev/null +++ b/packages/core/src/navigation/transitProgress.ts @@ -0,0 +1,109 @@ +import type { TripItinerary } from "@openmapx/mobility-core/transit"; +import type { LngLat } from "../types/geometry"; +import { haversineDistance } from "../utils/coordinates"; +import { snapToRoute } from "./snap"; + +export interface TransitProgress { + currentLegIndex: number; + snapped: LngLat; + fractionAlongLeg: number; + deviationMeters: number; + arrived: boolean; +} + +/** Total length of a polyline in metres (sum of segment haversine distances). */ +function lineLength(coords: LngLat[]): number { + let total = 0; + for (let i = 1; i < coords.length; i++) { + total += haversineDistance(coords[i - 1], coords[i]); + } + return total; +} + +/** + * Follow-along progress for a planned transit itinerary. Snaps the raw fix onto + * each leg's polyline and picks the leg with the smallest perpendicular + * deviation — i.e. the leg the traveller is most plausibly on right now. There + * is NO rerouting; this only reports where along the planned trip we are. + */ +export function computeTransitProgress(itinerary: TripItinerary, raw: LngLat): TransitProgress { + const legs = itinerary.legs ?? []; + + let bestLegIndex = -1; + let bestSnapped: LngLat = raw; + let bestAlong = 0; + let bestDeviation = Number.POSITIVE_INFINITY; + let bestLength = 0; + + for (let i = 0; i < legs.length; i++) { + const coords = legs[i].geometry?.coordinates as LngLat[] | undefined; + if (!coords || coords.length < 2) continue; + const snap = snapToRoute(coords, raw); + if (snap.deviationMeters < bestDeviation) { + bestDeviation = snap.deviationMeters; + bestLegIndex = i; + bestSnapped = snap.snapped; + bestAlong = snap.alongMeters; + bestLength = lineLength(coords); + } + } + + // No leg with usable geometry — degenerate itinerary. + if (bestLegIndex === -1) { + return { + currentLegIndex: 0, + snapped: raw, + fractionAlongLeg: 0, + deviationMeters: 0, + arrived: false, + }; + } + + const fractionAlongLeg = bestLength > 0 ? Math.max(0, Math.min(1, bestAlong / bestLength)) : 0; + const arrived = bestLegIndex === legs.length - 1 && fractionAlongLeg >= 0.9; + + return { + currentLegIndex: bestLegIndex, + snapped: bestSnapped, + fractionAlongLeg, + deviationMeters: bestDeviation, + arrived, + }; +} + +/** + * Given the current leg polyline, its ordered stop list, and the snapped + * position, work out the next stop and how many stops remain until alighting. + * Each stop and the snapped point are projected onto the leg geometry to get a + * comparable along-line distance, which is robust to GPS jitter and to stops + * that aren't exactly on the polyline. + */ +export function stopsUntilAlight( + legCoords: LngLat[], + stops: { lat: number; lng: number; name: string }[], + snapped: LngLat, +): { nextStopIndex: number; stopsRemaining: number; nextStopName: string | null } { + if (legCoords.length < 2 || stops.length === 0) { + return { nextStopIndex: -1, stopsRemaining: 0, nextStopName: null }; + } + + const snappedAlong = snapToRoute(legCoords, snapped).alongMeters; + const stopAlong = stops.map((s) => snapToRoute(legCoords, [s.lng, s.lat]).alongMeters); + + // The next stop is the first stop strictly ahead of the snapped position. + let nextStopIndex = -1; + for (let i = 0; i < stops.length; i++) { + if (stopAlong[i] > snappedAlong) { + nextStopIndex = i; + break; + } + } + + // Past the last stop (or at the alight stop) — nothing remaining. + if (nextStopIndex === -1) { + return { nextStopIndex: -1, stopsRemaining: 0, nextStopName: null }; + } + + const stopsRemaining = stops.length - nextStopIndex; + return { nextStopIndex, stopsRemaining, nextStopName: stops[nextStopIndex].name }; +} diff --git a/packages/core/src/navigation/types.ts b/packages/core/src/navigation/types.ts new file mode 100644 index 00000000..673242e6 --- /dev/null +++ b/packages/core/src/navigation/types.ts @@ -0,0 +1,73 @@ +import type { RouteStep, TravelMode } from "@integrations/routing/types"; +import type { LngLat } from "../types/geometry"; + +export type NavStatus = "idle" | "navigating" | "rerouting" | "arrived"; +export type CameraMode = "follow" | "free"; +export type CueTier = "far" | "near" | "now"; + +export interface SnapResult { + snapped: LngLat; + alongMeters: number; + deviationMeters: number; + segmentIndex: number; +} + +export interface ProgressResult { + currentStepIndex: number; + distanceToNextManeuver: number; + distanceRemaining: number; + durationRemaining: number; +} + +export interface NavProgress extends ProgressResult { + snapped: LngLat; + alongMeters: number; + deviationMeters: number; + etaEpochMs: number; +} + +export interface RerouteOpts { + thresholdMeters: number; + consecutiveFixes: number; + debounceMs: number; +} + +export interface NavTickOptions { + mode: TravelMode; + accuracyCapMeters: number; + reroute: RerouteOpts; + voiceThresholds: { far: number; near: number }; + arrivalThresholdMeters: number; +} + +export interface FixInput { + coords: LngLat; + accuracy: number; + heading?: number | null; + speed?: number | null; + timestampMs: number; +} + +export interface NavTickState { + deviationHistory: number[]; + lastRerouteAtMs: number | null; + spokenCues: string[]; +} + +export interface VoiceCue { + key: string; + tier: CueTier; + step: RouteStep; + stepIndex: number; + distance: number; +} + +export interface NavTickResult { + /** null when the fix was rejected (e.g. accuracy too poor). */ + progress: NavProgress | null; + offRoute: boolean; + needsReroute: boolean; + arrived: boolean; + voiceCue: VoiceCue | null; + nextState: NavTickState; +} diff --git a/packages/core/src/navigation/voiceCue.ts b/packages/core/src/navigation/voiceCue.ts new file mode 100644 index 00000000..b1d6421c --- /dev/null +++ b/packages/core/src/navigation/voiceCue.ts @@ -0,0 +1,29 @@ +import type { RouteStep } from "@integrations/routing/types"; +import type { CueTier, VoiceCue } from "./types"; + +const NOW_METERS = 30; + +/** + * Decide the next voice cue to speak for the current step, given how far the + * maneuver is and which cue keys have already been spoken. Pure: the caller + * adds the returned `key` to the spoken set and localizes the phrase from + * `tier` + `step` + `distance`. + */ +export function nextVoiceCue( + step: RouteStep, + stepIndex: number, + distanceToManeuver: number, + thresholds: { far: number; near: number }, + spoken: string[], +): VoiceCue | null { + let tier: CueTier | null = null; + if (distanceToManeuver <= NOW_METERS) tier = "now"; + else if (distanceToManeuver <= thresholds.near) tier = "near"; + else if (distanceToManeuver <= thresholds.far) tier = "far"; + if (tier === null) return null; + + const key = `${stepIndex}:${tier}`; + if (spoken.includes(key)) return null; + + return { key, tier, step, stepIndex, distance: distanceToManeuver }; +} diff --git a/packages/core/src/stores/directionsStore.ts b/packages/core/src/stores/directionsStore.ts index 3acfe180..c0d7fa35 100644 --- a/packages/core/src/stores/directionsStore.ts +++ b/packages/core/src/stores/directionsStore.ts @@ -40,7 +40,6 @@ export interface DirectionsState { avoidHighways: boolean; avoidTolls: boolean; avoidFerries: boolean; - units: "metric" | "imperial"; transitItineraries: TripItinerary[]; activeItineraryIndex: number; transitDepartureTime: "now" | Date; @@ -68,7 +67,6 @@ export interface DirectionsState { setAvoidHighways: (v: boolean) => void; setAvoidTolls: (v: boolean) => void; setAvoidFerries: (v: boolean) => void; - setUnits: (u: "metric" | "imperial") => void; setTransitItineraries: (items: TripItinerary[]) => void; setActiveItineraryIndex: (i: number) => void; setTransitDepartureTime: (t: "now" | Date) => void; @@ -93,7 +91,6 @@ export const useDirectionsStore = create((set, get) => { avoidHighways: false, avoidTolls: false, avoidFerries: false, - units: "metric", transitItineraries: [], activeItineraryIndex: 0, transitDepartureTime: "now" as const, @@ -174,7 +171,6 @@ export const useDirectionsStore = create((set, get) => { setAvoidHighways: (avoidHighways) => set({ avoidHighways }), setAvoidTolls: (avoidTolls) => set({ avoidTolls }), setAvoidFerries: (avoidFerries) => set({ avoidFerries }), - setUnits: (units) => set({ units }), setTransitItineraries: (transitItineraries) => set({ transitItineraries, activeItineraryIndex: 0 }), setActiveItineraryIndex: (activeItineraryIndex) => set({ activeItineraryIndex }), diff --git a/packages/core/src/stores/navigationStore.test.ts b/packages/core/src/stores/navigationStore.test.ts new file mode 100644 index 00000000..f20b091f --- /dev/null +++ b/packages/core/src/stores/navigationStore.test.ts @@ -0,0 +1,137 @@ +import type { Route } from "@integrations/routing/types"; +import type { TripItinerary } from "@openmapx/mobility-core/transit"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { TransitProgress } from "../navigation/transitProgress"; +import { useNavigationStore } from "./navigationStore"; + +const itinerary = { + duration: 1800, + startTime: "2026-06-01T10:00:00Z", + endTime: "2026-06-01T10:30:00Z", + transfers: 1, + walkDistance: 200, + legs: [], +} as TripItinerary; + +const route = { + distance: 100, + duration: 10, + geometry: [[0, 0]], + legs: [], + mode: "driving", + steps: [], +} as unknown as Route; + +describe("navigationStore", () => { + beforeEach(() => useNavigationStore.getState().stopNavigation()); + + it("starts ground navigation", () => { + useNavigationStore.getState().startGroundNavigation(route, "driving", [ + [0, 0], + [1, 1], + ]); + const s = useNavigationStore.getState(); + expect(s.status).toBe("navigating"); + expect(s.mode).toBe("driving"); + expect(s.route).toBe(route); + expect(s.cameraMode).toBe("follow"); + }); + + it("applyReroute swaps the route and returns to navigating", () => { + const store = useNavigationStore.getState(); + store.startGroundNavigation(route, "driving", [ + [0, 0], + [1, 1], + ]); + store.beginReroute(); + expect(useNavigationStore.getState().status).toBe("rerouting"); + const route2 = { ...route, distance: 200 } as Route; + store.applyReroute(route2); + expect(useNavigationStore.getState().status).toBe("navigating"); + expect(useNavigationStore.getState().route?.distance).toBe(200); + }); + + it("completeArrival then stop resets", () => { + const store = useNavigationStore.getState(); + store.startGroundNavigation(route, "driving", [ + [0, 0], + [1, 1], + ]); + store.completeArrival(); + expect(useNavigationStore.getState().status).toBe("arrived"); + store.stopNavigation(); + expect(useNavigationStore.getState().status).toBe("idle"); + expect(useNavigationStore.getState().route).toBeNull(); + }); + + it("setCameraMode toggles follow/free", () => { + const store = useNavigationStore.getState(); + store.startGroundNavigation(route, "driving", [ + [0, 0], + [1, 1], + ]); + store.setCameraMode("free"); + expect(useNavigationStore.getState().cameraMode).toBe("free"); + }); + + it("starts transit navigation and resets ground bits", () => { + const store = useNavigationStore.getState(); + store.startGroundNavigation(route, "driving", [ + [0, 0], + [1, 1], + ]); + store.startTransitNavigation(itinerary); + const s = useNavigationStore.getState(); + expect(s.status).toBe("navigating"); + expect(s.kind).toBe("transit"); + expect(s.itinerary).toBe(itinerary); + expect(s.route).toBeNull(); + expect(s.progress).toBeNull(); + expect(s.transitProgress).toBeNull(); + }); + + it("applyTransitProgress stores progress", () => { + const store = useNavigationStore.getState(); + store.startTransitNavigation(itinerary); + const tp: TransitProgress = { + currentLegIndex: 1, + snapped: [1, 2], + fractionAlongLeg: 0.5, + deviationMeters: 12, + arrived: false, + }; + store.applyTransitProgress(tp); + expect(useNavigationStore.getState().transitProgress).toBe(tp); + }); + + it("stopNavigation resets kind/itinerary/transitProgress", () => { + const store = useNavigationStore.getState(); + store.startTransitNavigation(itinerary); + store.applyTransitProgress({ + currentLegIndex: 0, + snapped: [0, 0], + fractionAlongLeg: 0, + deviationMeters: 0, + arrived: false, + }); + store.stopNavigation(); + const s = useNavigationStore.getState(); + expect(s.status).toBe("idle"); + expect(s.kind).toBe("ground"); + expect(s.itinerary).toBeNull(); + expect(s.transitProgress).toBeNull(); + }); + + it("startGroundNavigation resets transit bits", () => { + const store = useNavigationStore.getState(); + store.startTransitNavigation(itinerary); + store.startGroundNavigation(route, "driving", [ + [0, 0], + [1, 1], + ]); + const s = useNavigationStore.getState(); + expect(s.kind).toBe("ground"); + expect(s.itinerary).toBeNull(); + expect(s.transitProgress).toBeNull(); + }); +}); diff --git a/packages/core/src/stores/navigationStore.ts b/packages/core/src/stores/navigationStore.ts new file mode 100644 index 00000000..14a03888 --- /dev/null +++ b/packages/core/src/stores/navigationStore.ts @@ -0,0 +1,96 @@ +import type { Route, TravelMode } from "@integrations/routing/types"; +import type { TripItinerary } from "@openmapx/mobility-core/transit"; +import { create } from "zustand"; +import type { TransitProgress } from "../navigation/transitProgress"; +import type { CameraMode, NavProgress, NavStatus } from "../navigation/types"; +import type { LngLat } from "../types/geometry"; + +/** + * Navigation runs in one of two parallel modes. `"ground"` is full + * turn-by-turn driving/walking/cycling navigation (route + progress + reroute). + * `"transit"` is a follow-along mode for a planned public-transit itinerary — + * no rerouting; it just reports the current leg, next stop, and overall ETA. + */ +export type NavKind = "ground" | "transit"; + +interface NavigationState { + status: NavStatus; + kind: NavKind; + mode: TravelMode; + route: Route | null; + destinationWaypoints: LngLat[]; + progress: NavProgress | null; + offRoute: boolean; + cameraMode: CameraMode; + currentSpeedLimit: number | null; + voiceEnabled: boolean; + keepScreenOn: boolean; + // Transit follow-along state (only populated when kind === "transit"). + itinerary: TripItinerary | null; + transitProgress: TransitProgress | null; + + startGroundNavigation: (route: Route, mode: TravelMode, waypoints: LngLat[]) => void; + startTransitNavigation: (itinerary: TripItinerary) => void; + applyTransitProgress: (p: TransitProgress) => void; + applyProgress: (progress: NavProgress) => void; + setSpeedLimit: (v: number | null) => void; + setOffRoute: (v: boolean) => void; + beginReroute: () => void; + applyReroute: (route: Route) => void; + setCameraMode: (m: CameraMode) => void; + toggleVoice: () => void; + toggleKeepScreenOn: () => void; + completeArrival: () => void; + stopNavigation: () => void; +} + +const INITIAL = { + status: "idle" as NavStatus, + kind: "ground" as NavKind, + mode: "driving" as TravelMode, + route: null, + destinationWaypoints: [] as LngLat[], + progress: null, + offRoute: false, + cameraMode: "follow" as CameraMode, + currentSpeedLimit: null, + itinerary: null as TripItinerary | null, + transitProgress: null as TransitProgress | null, +}; + +export const useNavigationStore = create((set) => ({ + ...INITIAL, + voiceEnabled: true, + keepScreenOn: true, + + startGroundNavigation: (route, mode, waypoints) => + set({ + ...INITIAL, + status: "navigating", + kind: "ground", + mode, + route, + destinationWaypoints: waypoints, + }), + startTransitNavigation: (itinerary) => + set({ + ...INITIAL, + status: "navigating", + kind: "transit", + itinerary, + route: null, + progress: null, + transitProgress: null, + }), + applyTransitProgress: (transitProgress) => set({ transitProgress }), + applyProgress: (progress) => set({ progress }), + setSpeedLimit: (currentSpeedLimit) => set({ currentSpeedLimit }), + setOffRoute: (offRoute) => set({ offRoute }), + beginReroute: () => set({ status: "rerouting" }), + applyReroute: (route) => set({ status: "navigating", route, offRoute: false }), + setCameraMode: (cameraMode) => set({ cameraMode }), + toggleVoice: () => set((s) => ({ voiceEnabled: !s.voiceEnabled })), + toggleKeepScreenOn: () => set((s) => ({ keepScreenOn: !s.keepScreenOn })), + completeArrival: () => set({ status: "arrived" }), + stopNavigation: () => set({ ...INITIAL }), +})); diff --git a/packages/core/src/stores/settingsStore.test.ts b/packages/core/src/stores/settingsStore.test.ts new file mode 100644 index 00000000..21eb7774 --- /dev/null +++ b/packages/core/src/stores/settingsStore.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { configureStorage, type StorageAdapter } from "../platform/storage"; +import { useSettingsStore } from "./settingsStore"; + +function makeMemoryStorage(): StorageAdapter { + const map = new Map(); + return { + getString: (key) => map.get(key) ?? null, + setString: (key, value) => { + map.set(key, value); + }, + remove: (key) => { + map.delete(key); + }, + }; +} + +describe("useSettingsStore", () => { + beforeEach(() => { + configureStorage(makeMemoryStorage()); + useSettingsStore.setState({ units: "metric" }); + }); + + it("defaults to metric", () => { + expect(useSettingsStore.getState().units).toBe("metric"); + }); + + it("setUnits persists and reads back imperial", () => { + const storage = makeMemoryStorage(); + configureStorage(storage); + useSettingsStore.getState().setUnits("imperial"); + expect(useSettingsStore.getState().units).toBe("imperial"); + expect(storage.getString("openmapx:unitSystem")).toBe("imperial"); + }); + + it("setUnits switches back to metric", () => { + useSettingsStore.getState().setUnits("imperial"); + useSettingsStore.getState().setUnits("metric"); + expect(useSettingsStore.getState().units).toBe("metric"); + }); +}); diff --git a/packages/core/src/stores/settingsStore.ts b/packages/core/src/stores/settingsStore.ts new file mode 100644 index 00000000..ec94199b --- /dev/null +++ b/packages/core/src/stores/settingsStore.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import { getStorage } from "../platform/storage"; +import type { UnitSystem } from "../types/geometry"; + +const UNITS_STORAGE_KEY = "openmapx:unitSystem"; + +function readUnits(): UnitSystem { + return getStorage().getString(UNITS_STORAGE_KEY) === "imperial" ? "imperial" : "metric"; +} + +interface SettingsState { + units: UnitSystem; + setUnits: (u: UnitSystem) => void; + /** + * Re-read the persisted preference from storage. The store is created at + * module-eval time, which can run before the platform storage adapter is + * configured; calling this once on the client (after configuration) ensures + * the saved choice is applied instead of the default. + */ + hydrate: () => void; +} + +export const useSettingsStore = create((set) => ({ + units: readUnits(), + setUnits: (units) => { + getStorage().setString(UNITS_STORAGE_KEY, units); + set({ units }); + }, + hydrate: () => set({ units: readUnits() }), +})); diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 681a696e..90790ff1 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -1,4 +1,29 @@ { + "navigation": { + "start": "Starten", + "end": "Beenden", + "done": "Fertig", + "arrived": "Sie haben Ihr Ziel erreicht", + "recenter": "Zentrieren", + "eta": "Ankunft {time}", + "in": "In {distance}", + "now": "Jetzt", + "rerouting": "Route wird neu berechnet…", + "muteVoice": "Sprachansagen stummschalten", + "unmuteVoice": "Sprachansagen aktivieren", + "keepScreenOn": "Bildschirm anlassen", + "voiceUpcoming": "In {distance}, {instruction}", + "weakGps": "Schwaches GPS-Signal", + "waitingForGps": "Warte auf GPS-Signal…", + "walkTo": "Zu Fuß nach {place}", + "ride": "{line} nach {to}", + "nextStop": "Nächster Halt: {stop}", + "alightAt": "Aussteigen an {place}", + "alightAtCount": "Aussteigen an {place} · {count, plural, one {noch # Halt} other {noch # Halte}}", + "alightSoon": "Am nächsten Halt aussteigen", + "legCounter": "Abschnitt {current}/{total}", + "arriveAt": "Ankunft {time}" + }, "common": { "close": "Schließen", "cancel": "Abbrechen", @@ -1344,6 +1369,9 @@ "themeLight": "Hell", "themeDark": "Dunkel", "themeSystem": "System", + "units": "Einheiten", + "unitsMetric": "Metrisch (km)", + "unitsImperial": "Imperial (mi)", "privacy": "Datenschutz", "terms": "Nutzungsbedingungen", "imprint": "Impressum" diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 6b0ee5b4..b7f75d73 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -1,4 +1,29 @@ { + "navigation": { + "start": "Start", + "end": "End", + "done": "Done", + "arrived": "You have arrived", + "recenter": "Recenter", + "eta": "ETA {time}", + "in": "In {distance}", + "now": "Now", + "rerouting": "Rerouting…", + "muteVoice": "Mute voice guidance", + "unmuteVoice": "Unmute voice guidance", + "keepScreenOn": "Keep screen on", + "voiceUpcoming": "In {distance}, {instruction}", + "weakGps": "Weak GPS signal", + "waitingForGps": "Waiting for GPS signal…", + "walkTo": "Walk to {place}", + "ride": "{line} to {to}", + "nextStop": "Next: {stop}", + "alightAt": "Alight at {place}", + "alightAtCount": "Alight at {place} · {count, plural, one {# stop} other {# stops}}", + "alightSoon": "Get off at the next stop", + "legCounter": "Leg {current}/{total}", + "arriveAt": "Arrive {time}" + }, "common": { "close": "Close", "cancel": "Cancel", @@ -1344,6 +1369,9 @@ "themeLight": "Light", "themeDark": "Dark", "themeSystem": "System", + "units": "Units", + "unitsMetric": "Metric (km)", + "unitsImperial": "Imperial (mi)", "privacy": "Privacy", "terms": "Terms", "imprint": "Imprint" diff --git a/packages/integration-framework/src/contracts/index.ts b/packages/integration-framework/src/contracts/index.ts index 7ff83a1b..482903a8 100644 --- a/packages/integration-framework/src/contracts/index.ts +++ b/packages/integration-framework/src/contracts/index.ts @@ -62,6 +62,7 @@ export type { IsochronePolygon, IsochroneResult, IsochroneTravelMode, + ManeuverLane, MatchEdge, MatchOptions, MatchPoint, diff --git a/packages/integration-framework/src/contracts/routing-provider.ts b/packages/integration-framework/src/contracts/routing-provider.ts index 3236af18..54f28d00 100644 --- a/packages/integration-framework/src/contracts/routing-provider.ts +++ b/packages/integration-framework/src/contracts/routing-provider.ts @@ -14,11 +14,24 @@ export interface Waypoint { type: "origin" | "waypoint" | "destination"; } +export interface ManeuverLane { + /** Allowed turn indications for this lane, e.g. ["straight","slight right"]. */ + indications: string[]; + /** Whether this lane is valid for the recommended maneuver. */ + valid: boolean; +} + export interface RouteStep { instruction: string; distance: number; duration: number; coordinates: LngLat[]; + /** Normalized maneuver for icon + voice phrasing. Optional — populated when the engine provides it. */ + maneuver?: { type: string; modifier?: string }; + /** Speed limit in km/h for this step, when known. */ + speedLimit?: number; + /** Lane guidance at the maneuver, when known. */ + lanes?: ManeuverLane[]; } export interface RouteLeg { @@ -125,6 +138,8 @@ export interface MatchEdge { length: number; /** Posted or modelled speed in km/h. */ speed?: number; + /** Posted speed limit in km/h, when known. */ + speedLimit?: number; /** Surface tag (paved, gravel, dirt, …). */ surface?: string; /** Street names attached to the edge, when known. */ diff --git a/packages/integration-framework/src/index.ts b/packages/integration-framework/src/index.ts index 7fa5c740..7e081e1d 100644 --- a/packages/integration-framework/src/index.ts +++ b/packages/integration-framework/src/index.ts @@ -54,6 +54,7 @@ export type { KnowledgeContext, KnowledgeProvider, KnowledgeResult, + ManeuverLane, MatchEdge, MatchOptions, MatchPoint, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cb8e115..0d9255ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,15 +264,24 @@ importers: '@tmcw/togeojson': specifier: ^7.1.2 version: 7.1.2 + '@turf/along': + specifier: ^7.3.5 + version: 7.3.5 '@turf/area': specifier: ^7.3.5 version: 7.3.5 + '@turf/bearing': + specifier: ^7.3.5 + version: 7.3.5 '@turf/helpers': specifier: ^7.3.5 version: 7.3.5 '@turf/length': specifier: ^7.3.5 version: 7.3.5 + '@turf/line-slice-along': + specifier: ^7.3.5 + version: 7.3.5 better-auth: specifier: ^1.6.11 version: 1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.5) @@ -1517,6 +1526,12 @@ importers: '@tanstack/react-query': specifier: ^5.91.3 version: 5.100.14(react@19.2.6) + '@turf/helpers': + specifier: ^7.3.5 + version: 7.3.5 + '@turf/nearest-point-on-line': + specifier: ^7.3.5 + version: 7.3.5 '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -3850,12 +3865,21 @@ packages: cpu: [arm64] os: [win32] + '@turf/along@7.3.5': + resolution: {integrity: sha512-Ee4AHV9j2Bnlm/5nm4ChY7nkwpaaMffSez9nsJ9HcMbzG+GgTkt0F5pn9d02U7YvuWqckM5lfr3kBI+w2uTz+g==} + '@turf/area@7.3.5': resolution: {integrity: sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==} + '@turf/bearing@7.3.5': + resolution: {integrity: sha512-/qabIt/IuPsGlE6RukJ0zOirc6afNxoK7fK1WBNVnHuJjeOpSkW+7q1QW5XQGXBMv3odcD0ZgRR+MBiAHduYSA==} + '@turf/boolean-point-in-polygon@7.3.5': resolution: {integrity: sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg==} + '@turf/destination@7.3.5': + resolution: {integrity: sha512-x6ylChhOlIbucRSw7wF6z2gSEqqQl+dE0nSH5AL/ojZkqqGYahiw+2P4A8ZMuDM6rzH+FIqRYDes0o4nSArZCw==} + '@turf/distance@7.3.5': resolution: {integrity: sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA==} @@ -3868,9 +3892,15 @@ packages: '@turf/length@7.3.5': resolution: {integrity: sha512-Bi+vEP54wt1ly3BRcCOP0nd2kGTYEhGk6haQxTpkrqr3XtmqDh8c3NowSgseN2cegIZRjwCOEC8eSsZ0JemJdA==} + '@turf/line-slice-along@7.3.5': + resolution: {integrity: sha512-zLOU9mGFXJdbPvA3/zXpDsBmXY2paA7eD6/p0iYiP9OASYO0GDcarwY5K/E0uuEdLrMf8G8YA2cJDcEl9gRgFw==} + '@turf/meta@7.3.5': resolution: {integrity: sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==} + '@turf/nearest-point-on-line@7.3.5': + resolution: {integrity: sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -8908,6 +8938,16 @@ snapshots: '@turbo/windows-arm64@2.9.15': optional: true + '@turf/along@7.3.5': + dependencies: + '@turf/bearing': 7.3.5 + '@turf/destination': 7.3.5 + '@turf/distance': 7.3.5 + '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/area@7.3.5': dependencies: '@turf/helpers': 7.3.5 @@ -8915,6 +8955,13 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/bearing@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/boolean-point-in-polygon@7.3.5': dependencies: '@turf/helpers': 7.3.5 @@ -8923,6 +8970,13 @@ snapshots: point-in-polygon-hao: 1.2.4 tslib: 2.8.1 + '@turf/destination@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/distance@7.3.5': dependencies: '@turf/helpers': 7.3.5 @@ -8949,12 +9003,30 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/line-slice-along@7.3.5': + dependencies: + '@turf/bearing': 7.3.5 + '@turf/destination': 7.3.5 + '@turf/distance': 7.3.5 + '@turf/helpers': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@turf/meta@7.3.5': dependencies: '@turf/helpers': 7.3.5 '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@turf/nearest-point-on-line@7.3.5': + dependencies: + '@turf/distance': 7.3.5 + '@turf/helpers': 7.3.5 + '@turf/invariant': 7.3.5 + '@turf/meta': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1