Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b86b972
feat(nav): add turf deps and optional maneuver/speedLimit/lanes on Ro…
Medformatik Jun 2, 2026
4a4f562
feat(nav): pure navigation core (snap, progress, reroute, voice, proc…
Medformatik Jun 2, 2026
1fcd576
feat(nav): navigation store and extracted fetchDirections
Medformatik Jun 2, 2026
48f4e10
feat(nav): browser capability hooks (watch position, wake lock, headi…
Medformatik Jun 2, 2026
298a343
feat(nav): navigation engine, follow camera, voice hooks, and i18n na…
Medformatik Jun 2, 2026
bbe33b8
feat(nav): navigation route layer and heading puck marker
Medformatik Jun 2, 2026
f55698c
feat(nav): maneuver banner, lane guidance, speed limit badge, bottom …
Medformatik Jun 2, 2026
659a462
feat(nav): navigation view, app wiring, start button, dev simulator
Medformatik Jun 2, 2026
5a2b3fb
feat(nav): surface maneuver, lanes, and speed limit from OSRM and Val…
Medformatik Jun 2, 2026
e04a4f5
fix(nav): unlock camera on user gesture, show ETA, guard reroute afte…
Medformatik Jun 2, 2026
a11b465
fix(nav): show route maneuver/ETA immediately on Start and collapse t…
Medformatik Jun 2, 2026
387936b
fix(nav): hide map chrome (search, category chips, weather, account) …
Medformatik Jun 2, 2026
cc40142
fix(nav): hide layer selector and lift bottom bar/map controls clear …
Medformatik Jun 2, 2026
623572f
feat(settings): global metric/imperial units preference in settings p…
Medformatik Jun 2, 2026
0223351
fix(settings): hydrate persisted units on client and stop deep links …
Medformatik Jun 2, 2026
7ad963c
feat(nav): live speed-limit lookup via Valhalla map-matching during d…
Medformatik Jun 2, 2026
18e376b
feat(nav): transit trip-following — leg/stop progress, next-stop, ali…
Medformatik Jun 2, 2026
7ac8c25
test(nav): mock useNavigationStore in TransitItineraryCard test for t…
Medformatik Jun 2, 2026
2d2eb11
fix(api): correct ioredis defineCommand call for provider-health (dro…
Medformatik Jun 2, 2026
ca59563
fix(nav): gate ground NavigationView to kind=ground so it doesn't ove…
Medformatik Jun 2, 2026
fa084b2
fix(api): make provider-health recording best-effort so a health-stor…
Medformatik Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions apps/api/src/services/provider-health/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ class FakeRedis {
}

async runCommand(_name: string, args: unknown[]): Promise<string> {
// 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,
Expand Down Expand Up @@ -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<string, unknown>).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) => {
Expand All @@ -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);
Expand Down
22 changes: 18 additions & 4 deletions apps/api/src/services/provider-health/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ function annotate(state: ProviderHealthState): ProviderHealthState {

interface RedisWithCommand extends Redis {
providerHealthApply(
keyCount: number,
key: string,
op: string,
latencyMs: string,
Expand Down Expand Up @@ -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))),
Expand Down Expand Up @@ -301,11 +299,27 @@ export class ProviderHealth implements ProviderHealthHandle {
}

async recordSuccess(providerId: string, latencyMs: number): Promise<void> {
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<void> {
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
Expand Down
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 20 additions & 7 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -130,6 +134,9 @@ export default function HomePage() {
{/* Core layers (not integration-managed) */}
<PlaceBoundaryLayer />
<RouteLayer />
<NavigationRouteLayer />
<NavigationView />
<TransitNavigationView />
<FlightArcLayer />
<TransitRouteLayer />
<VehicleLiveLayer />
Expand All @@ -150,22 +157,28 @@ export default function HomePage() {
<SelectedStopInfrastructureLayer />
<WaypointMarkers />
<ElevationHoverMarker />
<HamburgerMenu />
<SearchBar />
<WeatherWidget />
<CategoryChips />
<CategoryFilterBar />
<HideDuringNavigation>
<HamburgerMenu />
<SearchBar />
<WeatherWidget />
<CategoryChips />
<CategoryFilterBar />
</HideDuringNavigation>
<SearchInAreaChip />
<ImportedGeometryBanner />
<PanelHost />
<MapClickFloatingCard />
<TopRightControls />
<HideDuringNavigation>
<TopRightControls />
</HideDuringNavigation>
<StreetViewViewer />
<div className="absolute bottom-[calc(1rem+var(--omx-safe-bottom))] left-1/2 -translate-x-1/2 z-10 flex flex-col-reverse items-center gap-2 pointer-events-none [&>*]:pointer-events-auto">
{/* All legends/toolbars loaded dynamically by LegendHost */}
<LegendHost />
</div>
<LayerSelector />
<HideDuringNavigation>
<LayerSelector />
</HideDuringNavigation>
<MapControls />
<MapFooter />
<Suspense>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = (
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/map/DeepLinkManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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" : "",
Expand Down
21 changes: 13 additions & 8 deletions apps/web/src/components/map/MapControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -45,13 +48,15 @@ export function MapControls() {
<Box
sx={{
position: "absolute",
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))`,
},
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",
Expand Down
96 changes: 96 additions & 0 deletions apps/web/src/components/map/layers/NavigationRouteLayer.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 2 additions & 2 deletions apps/web/src/components/map/layers/RouteLayer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,8 +25,8 @@ export function RouteLayer() {
avoidHighways,
avoidTolls,
avoidFerries,
units,
} = useDirectionsStore();
const units = useSettingsStore((s) => s.units);

const routeWaypoints = useMemo(
() =>
Expand Down
Loading
Loading