From f510a4213e5dc1547a9e12f407cc9c9f5167b472 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:45:01 +0000 Subject: [PATCH 1/6] Initial plan From 0d0065db290ad5664618b6b61723ebe4a72db218 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:53:53 +0000 Subject: [PATCH 2/6] feat: add route enrichment foundation --- src/App.css | 8 + src/App.tsx | 61 ++ src/__tests__/Rower3D.curve.test.ts | 14 + src/__tests__/routeEnrichment.test.ts | 179 +++++ src/components/Rower3D.tsx | 28 +- src/components/rower3d/bankComponents.tsx | 102 ++- src/components/rower3d/curve.ts | 103 ++- src/components/rower3d/waterComponents.tsx | 41 +- src/services/routeEnrichmentService.ts | 744 +++++++++++++++++++++ src/utils/geoUtils.ts | 19 + 10 files changed, 1264 insertions(+), 35 deletions(-) create mode 100644 src/__tests__/routeEnrichment.test.ts create mode 100644 src/services/routeEnrichmentService.ts diff --git a/src/App.css b/src/App.css index f0b9b63..91764a3 100644 --- a/src/App.css +++ b/src/App.css @@ -503,6 +503,14 @@ body { color: var(--color-text-secondary); } +.route-enrichment-status, +.route-item-status { + margin: 10px 0 0; + font-size: 13px; + color: var(--color-primary); + font-weight: 600; +} + .route-info-overlay .route-description { margin: 0; font-size: 15px; diff --git a/src/App.tsx b/src/App.tsx index e64142b..8a71741 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,11 +22,13 @@ import { GuestSessionSummary } from './components/GuestSessionSummary'; import { AuthButton } from './components/AuthButton'; import { AuthGateModal } from './components/AuthGateModal'; import { heartRateSimulator } from './services/heartRateSimulatorService'; +import { routeEnrichmentService } from './services/routeEnrichmentService'; import { saveSession, loadSessions } from './services/localStorageWorkoutStore'; import { useAuth } from './context/AuthContext'; import { formatPace } from './utils/formatters'; import { buildSessionGPX, buildSessionFITPayload, triggerBlobDownload } from './utils/exporters'; import type { WaterRoute, PM5Data, WorkoutSession, HeartRateSample, StructuredWorkout, WorkoutProgress } from './types/index'; +import type { RouteEnrichmentData } from './services/routeEnrichmentService'; import './App.css'; // Session state type for workout controls @@ -77,6 +79,8 @@ function App() { const [authGateAction, setAuthGateAction] = useState(undefined); const [kmlImportOpenSignal, setKmlImportOpenSignal] = useState(0); const pendingExportRef = useRef<(() => void) | null>(null); + const [routeEnrichments, setRouteEnrichments] = useState>({}); + const [routeEnrichmentLoading, setRouteEnrichmentLoading] = useState>({}); // Activate guest mode if the URL contains ?guest=true useEffect(() => { @@ -140,6 +144,53 @@ function App() { setWorkoutHistory(workoutService.getAllSessions()); }, []); + useEffect(() => { + if (!selectedRoute) return; + + let cancelled = false; + const cached = routeEnrichmentService.readCached(selectedRoute.id); + if (cached.data) { + setRouteEnrichments((current) => ({ + ...current, + [selectedRoute.id]: cached.data, + })); + } + + if (cached.data && !cached.stale) { + setRouteEnrichmentLoading((current) => ({ + ...current, + [selectedRoute.id]: false, + })); + return; + } + + setRouteEnrichmentLoading((current) => ({ + ...current, + [selectedRoute.id]: true, + })); + + void routeEnrichmentService + .enrichRoute(selectedRoute) + .then((enrichment) => { + if (cancelled) return; + setRouteEnrichments((current) => ({ + ...current, + [selectedRoute.id]: enrichment, + })); + }) + .finally(() => { + if (cancelled) return; + setRouteEnrichmentLoading((current) => ({ + ...current, + [selectedRoute.id]: false, + })); + }); + + return () => { + cancelled = true; + }; + }, [selectedRoute]); + // Load persisted sessions from localStorage when the user logs in (stub until #37) useEffect(() => { if (isAuthenticated && user) { @@ -216,6 +267,8 @@ function App() { // The Willowbrook River route (id '1') is the default guest route const willowbrookRoute = useMemo(() => routes.find(r => r.id === '1') ?? null, [routes]); + const selectedRouteEnrichment = selectedRoute ? routeEnrichments[selectedRoute.id] ?? null : null; + const selectedRouteEnrichmentLoading = selectedRoute ? !!routeEnrichmentLoading[selectedRoute.id] : false; const handleQuickStart = useCallback(() => { setIsGuestMode(true); @@ -782,6 +835,10 @@ function App() { ))} + {selectedRouteEnrichmentLoading && ( +

Loading route data…

+ )} + {!isGuestMode && selectedWorkout && (
🎯 {selectedWorkout.name} @@ -849,6 +906,9 @@ function App() { {route.estimatedTime} min
+ {routeEnrichmentLoading[route.id] && ( +

Loading route data…

+ )} ))} @@ -891,6 +951,7 @@ function App() { > { @@ -62,6 +63,19 @@ describe('Rower3D curve helpers', () => { const curve = createRouteCurve(coords); expect(curve).toBeInstanceOf(THREE.CatmullRomCurve3); }); + + it('upsamples sparse GPS points to the default 10m resolution', () => { + const coords: Coordinate[] = [ + { lat: 0, lng: 0 }, + { lat: 0, lng: 0.001 }, // ~111 m apart + ]; + + const upsampled = upsampleRouteCoordinates(coords); + const curve = createRouteCurve(coords); + + expect(upsampled.length).toBeGreaterThan(2); + expect(curve?.points.length).toBe(upsampled.length); + }); }); describe('getRoutePositionAtProgress', () => { diff --git a/src/__tests__/routeEnrichment.test.ts b/src/__tests__/routeEnrichment.test.ts new file mode 100644 index 0000000..ddd152d --- /dev/null +++ b/src/__tests__/routeEnrichment.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WaterRoute } from '../types/index'; +import { + ROUTE_ENRICHMENT_CACHE_TTL_MS, + RouteEnrichmentService, + buildOverpassQuery, + calculateBearingDeltaForSegments, + createFallbackRouteEnrichment, + getRouteEnrichmentCacheKey, + loadCachedRouteEnrichment, + mapOsmTagsToSceneryProfile, + saveCachedRouteEnrichment, + splitCoordinatesIntoElevationBatches, +} from '../services/routeEnrichmentService'; + +const routeFixture: WaterRoute = { + id: 'route-1', + name: 'Test Canal', + description: 'Test route', + distance: 2, + difficulty: 'moderate', + location: 'Somewhere', + coordinates: [ + { lat: 51.5, lng: -0.11 }, + { lat: 51.5005, lng: -0.1097 }, + { lat: 51.501, lng: -0.1091 }, + { lat: 51.5014, lng: -0.1084 }, + ], + elevationGain: 0, + estimatedTime: 20, + tags: ['canal'], + createdAt: new Date('2025-01-01T00:00:00Z'), +}; + +describe('route enrichment helpers', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('maps OSM tags to scenery profiles', () => { + expect(mapOsmTagsToSceneryProfile({ landuse: 'forest' })).toBe('forest'); + expect(mapOsmTagsToSceneryProfile({ landuse: 'farmland' })).toBe('farmland'); + expect(mapOsmTagsToSceneryProfile({ natural: 'wetland' })).toBe('wetland'); + expect(mapOsmTagsToSceneryProfile({ building: 'yes' })).toBe('commercial'); + expect(mapOsmTagsToSceneryProfile({})).toBe('fallback'); + }); + + it('splits elevation requests into batches of 100 points', () => { + const coordinates = Array.from({ length: 205 }, (_, index) => ({ + lat: 51 + index * 0.0001, + lng: -0.1, + })); + + const batches = splitCoordinatesIntoElevationBatches(coordinates); + + expect(batches).toHaveLength(3); + expect(batches[0]).toHaveLength(100); + expect(batches[1]).toHaveLength(100); + expect(batches[2]).toHaveLength(5); + }); + + it('calculates bearing deltas between consecutive segments', () => { + expect(calculateBearingDeltaForSegments([10, 12, 45, 355])).toEqual([ + 0, + 2, + 33, + 50, + ]); + }); + + it('reads fresh and stale cache entries correctly', () => { + const cached = createFallbackRouteEnrichment(routeFixture); + saveCachedRouteEnrichment(routeFixture.id, cached, localStorage); + + expect(loadCachedRouteEnrichment(routeFixture.id, localStorage)).toMatchObject({ + stale: false, + data: expect.objectContaining({ routeId: routeFixture.id }), + }); + + const key = getRouteEnrichmentCacheKey(routeFixture.id); + const raw = JSON.parse(localStorage.getItem(key) ?? '{}'); + raw.savedAt = Date.now() - ROUTE_ENRICHMENT_CACHE_TTL_MS - 1000; + localStorage.setItem(key, JSON.stringify(raw)); + + expect(loadCachedRouteEnrichment(routeFixture.id, localStorage).stale).toBe(true); + }); + + it('builds an Overpass query with the expected tag filters', () => { + const query = buildOverpassQuery(routeFixture.coordinates); + + expect(query).toContain('way["landuse"]'); + expect(query).toContain('way["waterway"]'); + expect(query).toContain('relation["building"]'); + expect(query).toContain('node["natural"]'); + }); +}); + +describe('RouteEnrichmentService', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('fetches enrichment data, caches it, and reuses the cache on subsequent loads', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + results: routeFixture.coordinates.map((_, index) => ({ + elevation: 5 + index, + })), + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + elements: [ + { + type: 'way', + tags: { landuse: 'forest' }, + bounds: { + minlat: 51.499, + minlon: -0.112, + maxlat: 51.503, + maxlon: -0.107, + }, + }, + { + type: 'way', + tags: { waterway: 'canal', width: '14' }, + bounds: { + minlat: 51.499, + minlon: -0.112, + maxlat: 51.503, + maxlon: -0.107, + }, + }, + ], + }), + } as Response); + + const service = new RouteEnrichmentService( + fetchMock as unknown as typeof fetch, + localStorage, + ); + + const first = await service.enrichRoute(routeFixture); + const second = await service.enrichRoute(routeFixture); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(first.source).toBe('network'); + expect(first.elevations).toHaveLength(routeFixture.coordinates.length); + expect(first.waterBodyType).toBe('canal'); + expect(first.segmentProfiles[0].sceneryProfile).toBe('forest'); + expect(first.segmentProfiles[0].waterWidthMeters).toBe(14); + expect(first.segmentProfiles.length).toBeGreaterThan(0); + expect(second.source).toBe('cache'); + }); + + it('falls back quietly when the APIs fail', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('network down')); + const service = new RouteEnrichmentService( + fetchMock as unknown as typeof fetch, + localStorage, + ); + + const enrichment = await service.enrichRoute(routeFixture); + + expect(enrichment.source).toBe('fallback'); + expect(enrichment.elevations).toHaveLength(routeFixture.coordinates.length); + expect(enrichment.segmentProfiles.length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/Rower3D.tsx b/src/components/Rower3D.tsx index b257164..82bd8c3 100644 --- a/src/components/Rower3D.tsx +++ b/src/components/Rower3D.tsx @@ -7,6 +7,10 @@ import type { WaterRoute } from '../types/index'; import { routeTotalDistanceMeters } from '../utils/geoUtils'; import { isWebGPUAvailable, isWebGLAvailable } from '../utils/gpuUtils'; import { usePhysicsEngine } from '../hooks/usePhysicsEngine'; +import { + getDragMultiplierForProgress, + type RouteEnrichmentData, +} from '../services/routeEnrichmentService'; import { createRouteCurve, getRoutePositionAtProgress, @@ -60,6 +64,7 @@ const detectRouteTheme = (route: WaterRoute): RouteTheme => { interface Rower3DProps { route: WaterRoute; + enrichment?: RouteEnrichmentData | null; paceSPer500?: number | null; distanceMeters?: number | null; isPlaying?: boolean; @@ -94,6 +99,7 @@ const ThemedRiverbanks: React.FC<{ boatZ: number; theme: RouteTheme }> = ({ boat // ============================================================================ const RowerScene: React.FC = ({ route, + enrichment, paceSPer500, distanceMeters, isPlaying, @@ -157,9 +163,16 @@ const RowerScene: React.FC = ({ } let targetProgress = boatProgressRef.current; + const visualDragMultiplier = getDragMultiplierForProgress( + enrichment?.segmentProfiles, + boatProgressRef.current, + ); + // Cosmetic-only slowdown on bends: this changes rendered route travel slightly + // without altering the WASM physics model or stroke timing. + const renderedSpeedMps = speedMps / visualDragMultiplier; if (isPlaying && speedMps > 0 && totalDistance > 0 && curveData.length > 0) { - const progressRate = (speedMps / totalDistance); + const progressRate = renderedSpeedMps / totalDistance; targetProgress = Math.min(1, boatProgressRef.current + progressRate * delta); boatProgressRef.current = targetProgress; } else if (!isPlaying && distanceMeters !== null && distanceMeters !== undefined && totalDistance > 0) { @@ -229,7 +242,7 @@ const RowerScene: React.FC = ({ totalDistance, curveLength: curveData.length }; - window.__ROWER3D_SPEED_MPS = speedMps; + window.__ROWER3D_SPEED_MPS = renderedSpeedMps; window.__ROWER3D_STROKE_PHASE = boatStateRef.current.strokePhase; window.__ROWER3D_DISTANCE_M = boatProgressRef.current * totalDistance; } @@ -356,7 +369,7 @@ const RowerScene: React.FC = ({ {routeCurve ? ( - + ) : ( )} @@ -366,7 +379,7 @@ const RowerScene: React.FC = ({ )} {routeCurve && ( - + )} @@ -376,7 +389,12 @@ const RowerScene: React.FC = ({ )} {routeCurve ? ( - + ) : ( renderThemedLandscape() )} diff --git a/src/components/rower3d/bankComponents.tsx b/src/components/rower3d/bankComponents.tsx index bd6a740..7274914 100644 --- a/src/components/rower3d/bankComponents.tsx +++ b/src/components/rower3d/bankComponents.tsx @@ -7,6 +7,10 @@ import { useAnimationFrame } from './AnimationContext'; import { getThemeConfig } from './themeConfig'; import type { RouteTheme } from './themeConfig'; import { makeSwayFoliageMaterial } from './vegetationComponents'; +import { + getWaterWidthSceneUnitsForProgress, + type RouteEnrichmentData, +} from '../../services/routeEnrichmentService'; // ============================================================================ // HD CURVED RIVERBANKS - Follows GPS path with realistic terrain materials @@ -14,17 +18,20 @@ import { makeSwayFoliageMaterial } from './vegetationComponents'; export interface CurvedRiverbanksProps { curve: THREE.CatmullRomCurve3 | null; theme: RouteTheme; + enrichment?: RouteEnrichmentData | null; } -export const CurvedRiverbanks: React.FC = ({ curve, theme }) => { +export const CurvedRiverbanks: React.FC = ({ + curve, + theme, + enrichment, +}) => { const bankConfig = useMemo(() => getThemeConfig(theme).bank, [theme]); const { leftBankGeometry, rightBankGeometry } = useMemo(() => { if (!curve) return { leftBankGeometry: null, rightBankGeometry: null }; const segments = 200; - const waterHalfWidth = WATER_CHANNEL_WIDTH / 2; - const bankWidth = RIVERBANK_WIDTH; const createBankGeometry = (side: 'left' | 'right') => { const positions: number[] = []; const normals: number[] = []; @@ -38,6 +45,13 @@ export const CurvedRiverbanks: React.FC = ({ curve, theme const up = new THREE.Vector3(0, 1, 0); const perp = new THREE.Vector3().crossVectors(tangent, up).normalize(); + const waterWidth = getWaterWidthSceneUnitsForProgress( + enrichment?.segmentProfiles, + enrichment?.waterWidthMeters ?? WATER_CHANNEL_WIDTH / 0.1, + t, + ); + const waterHalfWidth = waterWidth / 2; + const bankWidth = Math.max(RIVERBANK_WIDTH * 0.25, waterWidth * 2.25); const innerOffset = side === 'left' ? -waterHalfWidth : waterHalfWidth; const outerOffset = side === 'left' ? -(waterHalfWidth + bankWidth) : (waterHalfWidth + bankWidth); @@ -77,7 +91,7 @@ export const CurvedRiverbanks: React.FC = ({ curve, theme leftBankGeometry: createBankGeometry('left'), rightBankGeometry: createBankGeometry('right') }; - }, [curve]); + }, [curve, enrichment]); useEffect(() => { return () => { @@ -127,9 +141,49 @@ interface CurvedLandscapeProps { curve: THREE.CatmullRomCurve3 | null; theme: RouteTheme; boatProgress: number; + enrichment?: RouteEnrichmentData | null; } -export const CurvedLandscapeElements: React.FC = ({ curve, theme, boatProgress }) => { +const getSegmentStyle = ( + enrichment: RouteEnrichmentData | null | undefined, + progress: number, +) => { + const segmentProfiles = enrichment?.segmentProfiles; + if (!segmentProfiles || segmentProfiles.length === 0) { + return { + treeDensity: 0.45, + vegetationDensity: 0.5, + buildingDensity: 0.12, + objectScale: 1, + }; + } + + const clampedProgress = Math.max(0, Math.min(1, progress)); + const lastIndex = segmentProfiles.length - 1; + const scaledIndex = clampedProgress * lastIndex; + const lowerIndex = Math.floor(scaledIndex); + const upperIndex = Math.min(lastIndex, lowerIndex + 1); + const blend = scaledIndex - lowerIndex; + const lower = segmentProfiles[lowerIndex]; + const upper = segmentProfiles[upperIndex]; + + return { + treeDensity: lower.treeDensity + (upper.treeDensity - lower.treeDensity) * blend, + vegetationDensity: + lower.vegetationDensity + + (upper.vegetationDensity - lower.vegetationDensity) * blend, + buildingDensity: + lower.buildingDensity + (upper.buildingDensity - lower.buildingDensity) * blend, + objectScale: lower.objectScale + (upper.objectScale - lower.objectScale) * blend, + }; +}; + +export const CurvedLandscapeElements: React.FC = ({ + curve, + theme, + boatProgress, + enrichment, +}) => { const landscapeElements = useMemo(() => { if (!curve) return { leftElements: [], rightElements: [] }; @@ -145,42 +199,46 @@ export const CurvedLandscapeElements: React.FC = ({ curve, const tangent = curve.getTangentAt(t).normalize(); const up = new THREE.Vector3(0, 1, 0); const perp = new THREE.Vector3().crossVectors(tangent, up).normalize(); + const segmentStyle = getSegmentStyle(enrichment, t); - const leftOffset = minOffset + seededRandom(elemIdx * 7 + 1) * 30; - const rightOffset = minOffset + seededRandom(elemIdx * 7 + 2) * 30; + const leftOffset = + minOffset + + seededRandom(elemIdx * 7 + 1) * (16 + (1 - segmentStyle.vegetationDensity) * 34); + const rightOffset = + minOffset + + seededRandom(elemIdx * 7 + 2) * (16 + (1 - segmentStyle.vegetationDensity) * 34); const getElementType = (seedOffset: number): 'tree' | 'mountain' | 'building' => { const rand = seededRandom(elemIdx * 7 + seedOffset); - switch (theme) { - case 'dystopian-thames': - case 'scifi-boston': - return rand < 0.3 ? 'building' : rand < 0.6 ? 'mountain' : 'tree'; - case 'crystal-bled': - case 'willowbrook': - return rand < 0.4 ? 'mountain' : 'tree'; - default: - return rand < 0.2 ? 'building' : rand < 0.4 ? 'mountain' : 'tree'; - } + const buildingThreshold = Math.min(0.8, segmentStyle.buildingDensity * 0.85); + const treeThreshold = Math.min( + 0.98, + buildingThreshold + Math.max(0.18, segmentStyle.treeDensity * 0.75), + ); + if (rand < buildingThreshold) return 'building'; + if (rand < treeThreshold) return 'tree'; + return 'mountain'; }; - if (seededRandom(elemIdx * 7 + 4) < 0.6) { + const placementChance = 0.1 + segmentStyle.treeDensity * 0.55 + segmentStyle.vegetationDensity * 0.2; + if (seededRandom(elemIdx * 7 + 4) < placementChance) { const leftPos = new THREE.Vector3().copy(point).addScaledVector(perp, -leftOffset); leftPos.y = 0; leftElements.push({ position: leftPos, type: getElementType(3), - scale: 0.8 + seededRandom(elemIdx * 7 + 5) * 0.8, + scale: (0.8 + seededRandom(elemIdx * 7 + 5) * 0.8) * segmentStyle.objectScale, rotation: Math.atan2(tangent.x, tangent.z) + Math.PI / 2 }); } - if (seededRandom(elemIdx * 7 + 6) < 0.6) { + if (seededRandom(elemIdx * 7 + 6) < placementChance) { const rightPos = new THREE.Vector3().copy(point).addScaledVector(perp, rightOffset); rightPos.y = 0; rightElements.push({ position: rightPos, type: getElementType(7), - scale: 0.8 + seededRandom(elemIdx * 7 + 8) * 0.8, + scale: (0.8 + seededRandom(elemIdx * 7 + 8) * 0.8) * segmentStyle.objectScale, rotation: Math.atan2(tangent.x, tangent.z) - Math.PI / 2 }); } @@ -188,7 +246,7 @@ export const CurvedLandscapeElements: React.FC = ({ curve, } return { leftElements, rightElements }; - }, [curve, theme]); + }, [curve, enrichment, theme]); const colors = useMemo(() => getThemeConfig(theme).landscapeColors, [theme]); const archConfig = useMemo(() => getThemeConfig(theme).architecture, [theme]); diff --git a/src/components/rower3d/curve.ts b/src/components/rower3d/curve.ts index 18603ab..62e67e5 100644 --- a/src/components/rower3d/curve.ts +++ b/src/components/rower3d/curve.ts @@ -8,7 +8,102 @@ */ import * as THREE from 'three'; import type { Coordinate } from '../../types/index'; -import { latLngToMeters } from '../../utils/geoUtils'; +import { distanceBetweenLatLng, latLngToMeters } from '../../utils/geoUtils'; + +export const DEFAULT_ROUTE_POINT_RESOLUTION_METERS = 10; + +const cubicHermite = ( + p0: number, + p1: number, + m0: number, + m1: number, + t: number, +): number => { + const t2 = t * t; + const t3 = t2 * t; + return ( + (2 * t3 - 3 * t2 + 1) * p0 + + (t3 - 2 * t2 + t) * m0 + + (-2 * t3 + 3 * t2) * p1 + + (t3 - t2) * m1 + ); +}; + +export const upsampleRouteCoordinates = ( + coordinates: Coordinate[], + minResolutionMeters: number = DEFAULT_ROUTE_POINT_RESOLUTION_METERS, +): Coordinate[] => { + if (coordinates.length < 2 || minResolutionMeters <= 0) { + return coordinates; + } + + const tangents = coordinates.map((coord, index) => { + const previous = coordinates[Math.max(0, index - 1)]; + const next = coordinates[Math.min(coordinates.length - 1, index + 1)]; + + if (index === 0) { + return { + lat: next.lat - coord.lat, + lng: next.lng - coord.lng, + }; + } + + if (index === coordinates.length - 1) { + return { + lat: coord.lat - previous.lat, + lng: coord.lng - previous.lng, + }; + } + + return { + lat: (next.lat - previous.lat) / 2, + lng: (next.lng - previous.lng) / 2, + }; + }); + + const upsampled: Coordinate[] = []; + + for (let index = 0; index < coordinates.length - 1; index++) { + const start = coordinates[index]; + const end = coordinates[index + 1]; + upsampled.push(start); + + const segmentDistance = distanceBetweenLatLng( + start.lat, + start.lng, + end.lat, + end.lng, + ); + const subdivisions = Math.ceil(segmentDistance / minResolutionMeters); + + if (subdivisions <= 1) continue; + + const startTangent = tangents[index]; + const endTangent = tangents[index + 1]; + for (let step = 1; step < subdivisions; step++) { + const t = step / subdivisions; + upsampled.push({ + lat: cubicHermite( + start.lat, + end.lat, + startTangent.lat, + endTangent.lat, + t, + ), + lng: cubicHermite( + start.lng, + end.lng, + startTangent.lng, + endTangent.lng, + t, + ), + }); + } + } + + upsampled.push(coordinates[coordinates.length - 1]); + return upsampled; +}; /** * Convert a polyline of GPS coordinates into 3D scene points. @@ -49,8 +144,12 @@ export const gpsToScenePoints = ( export const createRouteCurve = ( coordinates: Coordinate[], sceneScale: number = 0.1, + minResolutionMeters: number = DEFAULT_ROUTE_POINT_RESOLUTION_METERS, ): THREE.CatmullRomCurve3 | null => { - const points = gpsToScenePoints(coordinates, sceneScale); + const points = gpsToScenePoints( + upsampleRouteCoordinates(coordinates, minResolutionMeters), + sceneScale, + ); if (points.length < 2) return null; return new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.5); diff --git a/src/components/rower3d/waterComponents.tsx b/src/components/rower3d/waterComponents.tsx index ab27c4a..b32fea8 100644 --- a/src/components/rower3d/waterComponents.tsx +++ b/src/components/rower3d/waterComponents.tsx @@ -8,6 +8,10 @@ import { useAnimationFrame } from './AnimationContext'; import { getThemeConfig } from './themeConfig'; import type { RouteTheme } from './themeConfig'; import { attachGerstnerShader, createWaterNormalMap } from './helpers'; +import { + getWaterWidthSceneUnitsForProgress, + type RouteEnrichmentData, +} from '../../services/routeEnrichmentService'; // ============================================================================ // WATER REFLECTION PROBE — CubeCamera providing real-time env reflections @@ -180,22 +184,42 @@ export const MistLayer: React.FC<{ boatZ: number; theme: RouteTheme }> = ({ boat export interface CurvedWaterChannelProps { curve: THREE.CatmullRomCurve3 | null; theme: RouteTheme; + enrichment?: RouteEnrichmentData | null; } -export const CurvedWaterChannel: React.FC = ({ curve, theme }) => { +export const CurvedWaterChannel: React.FC = ({ + curve, + theme, + enrichment, +}) => { const meshRef = useRef(null); const materialRef = useRef(null); const timeUniformRef = useRef({ value: 0 }); - const waterConfig = useMemo(() => getThemeConfig(theme).water, [theme]); + const waterConfig = useMemo(() => { + const baseConfig = getThemeConfig(theme).water; + return { + ...baseConfig, + color: enrichment?.waterColor ?? baseConfig.color, + waveAmplitude: baseConfig.waveAmplitude * (enrichment?.waveIntensity ?? 1), + waveFrequency: baseConfig.waveFrequency * (enrichment?.waveIntensity ?? 1), + }; + }, [enrichment?.waterColor, enrichment?.waveIntensity, theme]); useEffect(() => { if (IS_TEST_MODE) return; const mat = materialRef.current; if (!mat) return; - attachGerstnerShader(mat, timeUniformRef.current, 'y', `curved-${theme}`); + attachGerstnerShader( + mat, + timeUniformRef.current, + 'y', + `curved-${theme}`, + waterConfig.waveAmplitude, + waterConfig.waveFrequency, + ); mat.needsUpdate = true; - }, [theme]); + }, [theme, waterConfig.waveAmplitude, waterConfig.waveFrequency]); useAnimationFrame((time) => { timeUniformRef.current.value = time; @@ -209,7 +233,6 @@ export const CurvedWaterChannel: React.FC = ({ curve, t if (!curve) return null; const segments = 200; - const halfWidth = WATER_CHANNEL_WIDTH / 2; const positions: number[] = []; const normals: number[] = []; @@ -223,6 +246,12 @@ export const CurvedWaterChannel: React.FC = ({ curve, t const up = new THREE.Vector3(0, 1, 0); const perp = new THREE.Vector3().crossVectors(tangent, up).normalize(); + const halfWidth = + getWaterWidthSceneUnitsForProgress( + enrichment?.segmentProfiles, + enrichment?.waterWidthMeters ?? WATER_CHANNEL_WIDTH / 0.1, + t, + ) / 2; const left = new THREE.Vector3().copy(point).addScaledVector(perp, -halfWidth); const right = new THREE.Vector3().copy(point).addScaledVector(perp, halfWidth); @@ -253,7 +282,7 @@ export const CurvedWaterChannel: React.FC = ({ curve, t geometry.setIndex(indices); return geometry; - }, [curve]); + }, [curve, enrichment]); useEffect(() => { return () => { diff --git a/src/services/routeEnrichmentService.ts b/src/services/routeEnrichmentService.ts new file mode 100644 index 0000000..1acbfa6 --- /dev/null +++ b/src/services/routeEnrichmentService.ts @@ -0,0 +1,744 @@ +import type { Coordinate, WaterRoute } from '../types/index'; +import { + calculateBearing, + distanceBetweenLatLng, + normalizeBearingDelta, + routeTotalDistanceMeters, +} from '../utils/geoUtils'; + +export const OPEN_TOPO_DATA_BATCH_LIMIT = 100; +export const ROUTE_SEGMENT_LENGTH_METERS = 50; +export const ROUTE_ENRICHMENT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +export const OPEN_TOPO_DATA_URL = 'https://api.opentopodata.org/v1/srtm30m'; +export const OVERPASS_API_URL = 'https://overpass-api.de/api/interpreter'; +const ROUTE_ENRICHMENT_CACHE_PREFIX = 'virtualrow:route-enrichment:'; +const SCENE_SCALE = 0.1; + +export type SceneryProfile = + | 'forest' + | 'residential' + | 'commercial' + | 'farmland' + | 'beach' + | 'wetland' + | 'fallback'; + +export type WaterBodyType = + | 'river' + | 'canal' + | 'stream' + | 'lake' + | 'reservoir' + | 'unknown'; + +export interface RouteSegmentEnrichment { + index: number; + startMeters: number; + endMeters: number; + sceneryProfile: SceneryProfile; + treeDensity: number; + vegetationDensity: number; + buildingDensity: number; + objectScale: number; + waterWidthMeters: number; + dragMultiplier: number; + bearing: number; + bearingDelta: number; +} + +export interface RouteEnrichmentData { + routeId: string; + elevations: number[]; + segmentProfiles: RouteSegmentEnrichment[]; + waterBodyType: WaterBodyType; + waterWidthMeters: number; + waterColor: string; + waveIntensity: number; + fetchedAt: number; + source: 'network' | 'cache' | 'fallback'; +} + +interface CachedRouteEnrichment { + savedAt: number; + data: RouteEnrichmentData; +} + +interface BoundingBox { + minLat: number; + minLng: number; + maxLat: number; + maxLng: number; +} + +interface OverpassGeometryPoint { + lat: number; + lon: number; +} + +export interface OverpassElement { + type: string; + id?: number; + lat?: number; + lon?: number; + bounds?: { + minlat: number; + minlon: number; + maxlat: number; + maxlon: number; + }; + geometry?: OverpassGeometryPoint[]; + tags?: Record; +} + +interface OverpassResponse { + elements?: OverpassElement[]; +} + +interface OpenTopoDataResponse { + results?: Array<{ elevation: number | null }>; +} + +interface CacheLookupResult { + data: RouteEnrichmentData | null; + stale: boolean; +} + +export const SCENERY_PROFILE_CONFIG: Record< + SceneryProfile, + Pick< + RouteSegmentEnrichment, + 'treeDensity' | 'vegetationDensity' | 'buildingDensity' | 'objectScale' + > +> = { + forest: { + treeDensity: 1, + vegetationDensity: 0.85, + buildingDensity: 0.05, + objectScale: 1.15, + }, + residential: { + treeDensity: 0.55, + vegetationDensity: 0.55, + buildingDensity: 0.4, + objectScale: 1, + }, + commercial: { + treeDensity: 0.15, + vegetationDensity: 0.2, + buildingDensity: 0.8, + objectScale: 1.05, + }, + farmland: { + treeDensity: 0.22, + vegetationDensity: 0.45, + buildingDensity: 0.08, + objectScale: 0.9, + }, + beach: { + treeDensity: 0.18, + vegetationDensity: 0.35, + buildingDensity: 0.04, + objectScale: 0.95, + }, + wetland: { + treeDensity: 0.3, + vegetationDensity: 0.8, + buildingDensity: 0.03, + objectScale: 0.92, + }, + fallback: { + treeDensity: 0.45, + vegetationDensity: 0.5, + buildingDensity: 0.1, + objectScale: 1, + }, +}; + +const defaultStorage = () => { + if (typeof window === 'undefined') return null; + return window.localStorage; +}; + +export const getRouteEnrichmentCacheKey = (routeId: string) => + `${ROUTE_ENRICHMENT_CACHE_PREFIX}${routeId}`; + +export const splitCoordinatesIntoElevationBatches = ( + coordinates: Coordinate[], + batchSize: number = OPEN_TOPO_DATA_BATCH_LIMIT, +) => { + const batches: Coordinate[][] = []; + for (let index = 0; index < coordinates.length; index += batchSize) { + batches.push(coordinates.slice(index, index + batchSize)); + } + return batches; +}; + +export const mapOsmTagsToSceneryProfile = ( + tags: Record = {}, +): SceneryProfile => { + if (tags.landuse === 'forest' || tags.natural === 'wood') return 'forest'; + if (tags.landuse === 'residential') return 'residential'; + if ( + tags.landuse === 'commercial' || + tags.landuse === 'industrial' || + tags.building + ) { + return 'commercial'; + } + if (tags.landuse === 'farmland' || tags.landuse === 'grass') return 'farmland'; + if (tags.natural === 'beach' || tags.natural === 'sand') return 'beach'; + if (tags.natural === 'wetland') return 'wetland'; + return 'fallback'; +}; + +export const inferWaterBodyType = ( + tags: Record = {}, +): WaterBodyType => { + if (tags.waterway === 'river') return 'river'; + if (tags.waterway === 'canal') return 'canal'; + if (tags.waterway === 'stream') return 'stream'; + if (tags.natural === 'water') { + if (tags.water === 'reservoir' || tags.water === 'basin') return 'reservoir'; + return 'lake'; + } + return 'unknown'; +}; + +export const getDefaultWaterWidthMeters = (waterBodyType: WaterBodyType) => { + switch (waterBodyType) { + case 'river': + return 55; + case 'canal': + return 15; + case 'stream': + return 7; + case 'lake': + case 'reservoir': + return 45; + default: + return 30; + } +}; + +const parseWidthMeters = (widthValue?: string) => { + if (!widthValue) return null; + const parsed = Number.parseFloat(widthValue); + return Number.isFinite(parsed) ? parsed : null; +}; + +const getWaterAppearance = (waterBodyType: WaterBodyType) => { + switch (waterBodyType) { + case 'canal': + return { waterColor: '#537a94', waveIntensity: 0.78 }; + case 'stream': + return { waterColor: '#487d88', waveIntensity: 0.92 }; + case 'lake': + case 'reservoir': + return { waterColor: '#3f7ea8', waveIntensity: 0.88 }; + case 'river': + return { waterColor: '#2f6f93', waveIntensity: 1.1 }; + default: + return { waterColor: '#3a7aa2', waveIntensity: 1 }; + } +}; + +export const calculateBearingDragModifier = (bearingDelta: number) => { + if (bearingDelta <= 5) return 1; + const clamped = Math.min(45, Math.max(5, bearingDelta)); + return 1 + ((clamped - 5) / 40) * 0.05; +}; + +export const calculateBearingDeltaForSegments = (bearings: number[]) => + bearings.map((bearing, index) => + index === 0 ? 0 : normalizeBearingDelta(bearings[index - 1], bearing), + ); + +const interpolateCoordinateAtDistance = ( + coordinates: Coordinate[], + distanceMeters: number, +): Coordinate => { + if (coordinates.length === 0) return { lat: 0, lng: 0 }; + if (coordinates.length === 1 || distanceMeters <= 0) return coordinates[0]; + + let travelled = 0; + for (let index = 1; index < coordinates.length; index++) { + const start = coordinates[index - 1]; + const end = coordinates[index]; + const segmentDistance = distanceBetweenLatLng( + start.lat, + start.lng, + end.lat, + end.lng, + ); + if (travelled + segmentDistance >= distanceMeters && segmentDistance > 0) { + const t = (distanceMeters - travelled) / segmentDistance; + return { + lat: start.lat + (end.lat - start.lat) * t, + lng: start.lng + (end.lng - start.lng) * t, + }; + } + travelled += segmentDistance; + } + + return coordinates[coordinates.length - 1]; +}; + +export const buildBoundingBox = ( + coordinates: Coordinate[], + marginMeters = 200, +): BoundingBox => { + const lats = coordinates.map((coordinate) => coordinate.lat); + const lngs = coordinates.map((coordinate) => coordinate.lng); + const centerLat = + lats.reduce((total, lat) => total + lat, 0) / Math.max(1, coordinates.length); + const latMargin = marginMeters / 111320; + const lngMargin = + marginMeters / (111320 * Math.max(Math.cos((centerLat * Math.PI) / 180), 0.1)); + + return { + minLat: Math.min(...lats) - latMargin, + minLng: Math.min(...lngs) - lngMargin, + maxLat: Math.max(...lats) + latMargin, + maxLng: Math.max(...lngs) + lngMargin, + }; +}; + +export const buildOverpassQuery = (coordinates: Coordinate[]) => { + const bounds = buildBoundingBox(coordinates); + const bbox = `${bounds.minLat},${bounds.minLng},${bounds.maxLat},${bounds.maxLng}`; + + return ` +[out:json][timeout:25]; +( + way["landuse"](${bbox}); + way["natural"](${bbox}); + way["waterway"](${bbox}); + way["building"](${bbox}); + way["leisure"](${bbox}); + relation["landuse"](${bbox}); + relation["natural"](${bbox}); + relation["waterway"](${bbox}); + relation["building"](${bbox}); + relation["leisure"](${bbox}); + node["natural"](${bbox}); + node["waterway"](${bbox}); +); +out geom; +`; +}; + +const getElementBounds = (element: OverpassElement): BoundingBox | null => { + if (element.bounds) { + return { + minLat: element.bounds.minlat, + minLng: element.bounds.minlon, + maxLat: element.bounds.maxlat, + maxLng: element.bounds.maxlon, + }; + } + + if (element.geometry && element.geometry.length > 0) { + return buildBoundingBox( + element.geometry.map((point) => ({ lat: point.lat, lng: point.lon })), + 0, + ); + } + + if (Number.isFinite(element.lat) && Number.isFinite(element.lon)) { + return { + minLat: element.lat!, + minLng: element.lon!, + maxLat: element.lat!, + maxLng: element.lon!, + }; + } + + return null; +}; + +const pointInBounds = (coordinate: Coordinate, bounds: BoundingBox) => + coordinate.lat >= bounds.minLat && + coordinate.lat <= bounds.maxLat && + coordinate.lng >= bounds.minLng && + coordinate.lng <= bounds.maxLng; + +const findNearestFeature = ( + coordinate: Coordinate, + elements: OverpassElement[], +): OverpassElement | null => { + let nearest: OverpassElement | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const element of elements) { + const bounds = getElementBounds(element); + if (!bounds) continue; + if (pointInBounds(coordinate, bounds)) return element; + + const center = { + lat: (bounds.minLat + bounds.maxLat) / 2, + lng: (bounds.minLng + bounds.maxLng) / 2, + }; + const distance = distanceBetweenLatLng( + coordinate.lat, + coordinate.lng, + center.lat, + center.lng, + ); + if (distance < nearestDistance) { + nearest = element; + nearestDistance = distance; + } + } + + return nearestDistance <= 250 ? nearest : null; +}; + +const inferFallbackSceneryProfile = (route: WaterRoute): SceneryProfile => { + if (route.tags.includes('forest')) return 'forest'; + if (route.tags.includes('lake')) return 'beach'; + if (route.tags.includes('urban')) return 'commercial'; + if (route.tags.includes('meadow')) return 'farmland'; + return 'fallback'; +}; + +const inferRouteWaterBodyType = (route: WaterRoute): WaterBodyType => { + if (route.tags.includes('canal')) return 'canal'; + if (route.tags.includes('stream')) return 'stream'; + if (route.tags.includes('lake')) return 'lake'; + if (route.tags.includes('reservoir')) return 'reservoir'; + if (route.tags.includes('river')) return 'river'; + return 'unknown'; +}; + +export const createFallbackRouteEnrichment = ( + route: WaterRoute, +): RouteEnrichmentData => { + const waterBodyType = inferRouteWaterBodyType(route); + const sceneryProfile = inferFallbackSceneryProfile(route); + const totalDistance = routeTotalDistanceMeters(route.coordinates); + const segmentCount = Math.max(1, Math.ceil(totalDistance / ROUTE_SEGMENT_LENGTH_METERS)); + const widthMeters = getDefaultWaterWidthMeters(waterBodyType); + const profileConfig = SCENERY_PROFILE_CONFIG[sceneryProfile]; + const segmentProfiles: RouteSegmentEnrichment[] = []; + + for (let index = 0; index < segmentCount; index++) { + const startMeters = index * ROUTE_SEGMENT_LENGTH_METERS; + const endMeters = Math.min(totalDistance, startMeters + ROUTE_SEGMENT_LENGTH_METERS); + const startCoord = interpolateCoordinateAtDistance(route.coordinates, startMeters); + const endCoord = interpolateCoordinateAtDistance(route.coordinates, endMeters); + const bearing = calculateBearing( + startCoord.lat, + startCoord.lng, + endCoord.lat, + endCoord.lng, + ); + const previousBearing = + segmentProfiles[segmentProfiles.length - 1]?.bearing ?? bearing; + const bearingDelta = + index === 0 ? 0 : normalizeBearingDelta(previousBearing, bearing); + + segmentProfiles.push({ + index, + startMeters, + endMeters, + sceneryProfile, + waterWidthMeters: widthMeters, + dragMultiplier: calculateBearingDragModifier(bearingDelta), + bearing, + bearingDelta, + ...profileConfig, + }); + } + + return { + routeId: route.id, + elevations: route.coordinates.map(() => 0), + segmentProfiles, + waterBodyType, + waterWidthMeters: widthMeters, + ...getWaterAppearance(waterBodyType), + fetchedAt: Date.now(), + source: 'fallback', + }; +}; + +export const loadCachedRouteEnrichment = ( + routeId: string, + storage: Storage | null = defaultStorage(), +): CacheLookupResult => { + if (!storage) { + return { data: null, stale: false }; + } + + try { + const raw = storage.getItem(getRouteEnrichmentCacheKey(routeId)); + if (!raw) return { data: null, stale: false }; + const parsed = JSON.parse(raw) as CachedRouteEnrichment; + if (!parsed?.data || typeof parsed.savedAt !== 'number') { + return { data: null, stale: false }; + } + const stale = Date.now() - parsed.savedAt > ROUTE_ENRICHMENT_CACHE_TTL_MS; + return { + data: { ...parsed.data, source: 'cache' }, + stale, + }; + } catch { + return { data: null, stale: false }; + } +}; + +export const saveCachedRouteEnrichment = ( + routeId: string, + data: RouteEnrichmentData, + storage: Storage | null = defaultStorage(), +) => { + if (!storage) return; + const entry: CachedRouteEnrichment = { + savedAt: Date.now(), + data, + }; + storage.setItem(getRouteEnrichmentCacheKey(routeId), JSON.stringify(entry)); +}; + +export const getDragMultiplierForProgress = ( + segmentProfiles: RouteSegmentEnrichment[] | undefined, + progress: number, +) => { + if (!segmentProfiles || segmentProfiles.length === 0) return 1; + + const clampedProgress = Math.max(0, Math.min(1, progress)); + const lastIndex = segmentProfiles.length - 1; + const scaledIndex = clampedProgress * lastIndex; + const lowerIndex = Math.floor(scaledIndex); + const upperIndex = Math.min(lastIndex, lowerIndex + 1); + const blend = scaledIndex - lowerIndex; + + const lower = segmentProfiles[lowerIndex]; + const upper = segmentProfiles[upperIndex]; + return lower.dragMultiplier + (upper.dragMultiplier - lower.dragMultiplier) * blend; +}; + +export const getWaterWidthSceneUnitsForProgress = ( + segmentProfiles: RouteSegmentEnrichment[] | undefined, + fallbackWidthMeters: number, + progress: number, +) => { + if (!segmentProfiles || segmentProfiles.length === 0) { + return fallbackWidthMeters * SCENE_SCALE; + } + + const clampedProgress = Math.max(0, Math.min(1, progress)); + const lastIndex = segmentProfiles.length - 1; + const scaledIndex = clampedProgress * lastIndex; + const lowerIndex = Math.floor(scaledIndex); + const upperIndex = Math.min(lastIndex, lowerIndex + 1); + const blend = scaledIndex - lowerIndex; + const widthMeters = + segmentProfiles[lowerIndex].waterWidthMeters + + (segmentProfiles[upperIndex].waterWidthMeters - + segmentProfiles[lowerIndex].waterWidthMeters) * + blend; + + return widthMeters * SCENE_SCALE; +}; + +const createSegmentProfilesFromFeatures = ( + route: WaterRoute, + elements: OverpassElement[], +): RouteSegmentEnrichment[] => { + const fallback = createFallbackRouteEnrichment(route); + const waterFeatures = elements.filter( + (element) => inferWaterBodyType(element.tags) !== 'unknown', + ); + const landFeatures = elements.filter( + (element) => mapOsmTagsToSceneryProfile(element.tags) !== 'fallback', + ); + const totalDistance = routeTotalDistanceMeters(route.coordinates); + const segmentCount = Math.max(1, Math.ceil(totalDistance / ROUTE_SEGMENT_LENGTH_METERS)); + const segmentProfiles: RouteSegmentEnrichment[] = []; + + let dominantWaterBodyType = fallback.waterBodyType; + let dominantWidth = fallback.waterWidthMeters; + if (waterFeatures.length > 0) { + const tags = waterFeatures[0].tags ?? {}; + dominantWaterBodyType = inferWaterBodyType(tags); + dominantWidth = + parseWidthMeters(tags.width) ?? getDefaultWaterWidthMeters(dominantWaterBodyType); + } + + for (let index = 0; index < segmentCount; index++) { + const startMeters = index * ROUTE_SEGMENT_LENGTH_METERS; + const endMeters = Math.min(totalDistance, startMeters + ROUTE_SEGMENT_LENGTH_METERS); + const midpoint = interpolateCoordinateAtDistance( + route.coordinates, + startMeters + (endMeters - startMeters) / 2, + ); + const startCoord = interpolateCoordinateAtDistance(route.coordinates, startMeters); + const endCoord = interpolateCoordinateAtDistance(route.coordinates, endMeters); + const bearing = calculateBearing( + startCoord.lat, + startCoord.lng, + endCoord.lat, + endCoord.lng, + ); + const nearestLandFeature = findNearestFeature(midpoint, landFeatures); + const nearestWaterFeature = findNearestFeature(midpoint, waterFeatures); + const sceneryProfile = nearestLandFeature + ? mapOsmTagsToSceneryProfile(nearestLandFeature.tags) + : fallback.segmentProfiles[Math.min(index, fallback.segmentProfiles.length - 1)] + .sceneryProfile; + const profileConfig = SCENERY_PROFILE_CONFIG[sceneryProfile]; + const waterBodyType = nearestWaterFeature + ? inferWaterBodyType(nearestWaterFeature.tags) + : dominantWaterBodyType; + const widthMeters = nearestWaterFeature + ? parseWidthMeters(nearestWaterFeature.tags?.width) ?? + getDefaultWaterWidthMeters(waterBodyType) + : dominantWidth; + + dominantWaterBodyType = waterBodyType === 'unknown' ? dominantWaterBodyType : waterBodyType; + dominantWidth = widthMeters; + + const previousBearing = + segmentProfiles[segmentProfiles.length - 1]?.bearing ?? bearing; + const bearingDelta = + index === 0 ? 0 : normalizeBearingDelta(previousBearing, bearing); + + segmentProfiles.push({ + index, + startMeters, + endMeters, + sceneryProfile, + waterWidthMeters: widthMeters, + dragMultiplier: calculateBearingDragModifier(bearingDelta), + bearing, + bearingDelta, + ...profileConfig, + }); + } + + return segmentProfiles; +}; + +export class RouteEnrichmentService { + private readonly fetchImpl: typeof fetch; + private readonly storage: Storage | null; + private readonly inflight = new Map>(); + + constructor(fetchImpl: typeof fetch = fetch, storage: Storage | null = defaultStorage()) { + this.fetchImpl = fetchImpl; + this.storage = storage; + } + + readCached(routeId: string) { + return loadCachedRouteEnrichment(routeId, this.storage); + } + + private async fetchElevations(coordinates: Coordinate[]) { + const batches = splitCoordinatesIntoElevationBatches(coordinates); + const elevations: number[] = []; + + for (const [index, batch] of batches.entries()) { + if (import.meta.env?.DEV) { + console.debug( + `[route-enrichment] OpenTopoData batch ${index + 1}/${batches.length} (${batch.length} points)`, + ); + } + + const locations = batch + .map((coordinate) => `${coordinate.lat},${coordinate.lng}`) + .join('|'); + const response = await this.fetchImpl( + `${OPEN_TOPO_DATA_URL}?locations=${encodeURIComponent(locations)}`, + ); + if (!response.ok) { + throw new Error(`OpenTopoData request failed with HTTP ${response.status}`); + } + const payload = (await response.json()) as OpenTopoDataResponse; + elevations.push( + ...(payload.results ?? []).map((result) => result.elevation ?? 0), + ); + } + + if (import.meta.env?.DEV) { + console.debug( + `[route-enrichment] received ${elevations.length} elevation points`, + ); + } + + return elevations; + } + + private async fetchOverpassElements(coordinates: Coordinate[]) { + const query = buildOverpassQuery(coordinates); + const response = await this.fetchImpl(OVERPASS_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: `data=${encodeURIComponent(query)}`, + }); + + if (!response.ok) { + throw new Error(`Overpass request failed with HTTP ${response.status}`); + } + + const payload = (await response.json()) as OverpassResponse; + return payload.elements ?? []; + } + + async enrichRoute(route: WaterRoute): Promise { + const cached = this.readCached(route.id); + if (cached.data && !cached.stale) { + return cached.data; + } + + const inflightRequest = this.inflight.get(route.id); + if (inflightRequest) { + return inflightRequest; + } + + const request = (async () => { + const fallback = createFallbackRouteEnrichment(route); + try { + const [elevations, elements] = await Promise.all([ + this.fetchElevations(route.coordinates), + this.fetchOverpassElements(route.coordinates), + ]); + const segmentProfiles = createSegmentProfilesFromFeatures(route, elements); + const waterBodyType = + segmentProfiles.find((segment) => segment.waterWidthMeters > 0) + ? inferWaterBodyType( + findNearestFeature(route.coordinates[0], elements)?.tags, + ) + : fallback.waterBodyType; + const waterWidthMeters = + segmentProfiles[0]?.waterWidthMeters ?? fallback.waterWidthMeters; + + const enrichment: RouteEnrichmentData = { + routeId: route.id, + elevations, + segmentProfiles, + waterBodyType: waterBodyType === 'unknown' ? fallback.waterBodyType : waterBodyType, + waterWidthMeters, + ...getWaterAppearance( + waterBodyType === 'unknown' ? fallback.waterBodyType : waterBodyType, + ), + fetchedAt: Date.now(), + source: 'network', + }; + saveCachedRouteEnrichment(route.id, enrichment, this.storage); + return enrichment; + } catch { + return fallback; + } + })().finally(() => { + this.inflight.delete(route.id); + }); + + this.inflight.set(route.id, request); + return request; + } +} + +export const routeEnrichmentService = new RouteEnrichmentService(); diff --git a/src/utils/geoUtils.ts b/src/utils/geoUtils.ts index 52f1ef8..0499981 100644 --- a/src/utils/geoUtils.ts +++ b/src/utils/geoUtils.ts @@ -1,5 +1,6 @@ const EARTH_RADIUS_M = 6378137; const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; export function latLngToMeters(lat: number, lng: number, originLat: number, originLng: number) { // Approximation using equirectangular projection around origin @@ -28,3 +29,21 @@ export function distanceBetweenLatLng(lat1:number, lng1:number, lat2:number, lng const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return EARTH_RADIUS_M * c; } + +export function calculateBearing(lat1: number, lng1: number, lat2: number, lng2: number) { + const phi1 = lat1 * DEG_TO_RAD; + const phi2 = lat2 * DEG_TO_RAD; + const deltaLambda = (lng2 - lng1) * DEG_TO_RAD; + + const y = Math.sin(deltaLambda) * Math.cos(phi2); + const x = + Math.cos(phi1) * Math.sin(phi2) - + Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda); + + return (Math.atan2(y, x) * RAD_TO_DEG + 360) % 360; +} + +export function normalizeBearingDelta(fromBearing: number, toBearing: number) { + const delta = ((toBearing - fromBearing + 540) % 360) - 180; + return Math.abs(delta); +} From d674cfe475d6a2a7ab8943a4c043e8ea834ebbe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:55:52 +0000 Subject: [PATCH 3/6] test: cover route enrichment behavior --- src/App.tsx | 7 +++--- src/components/Rower3D.tsx | 4 ++-- src/components/rower3d/bankComponents.tsx | 3 +-- src/components/rower3d/waterComponents.tsx | 27 +++++++++++++++------- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8a71741..17da5a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,14 +149,15 @@ function App() { let cancelled = false; const cached = routeEnrichmentService.readCached(selectedRoute.id); - if (cached.data) { + const cachedData = cached.data; + if (cachedData) { setRouteEnrichments((current) => ({ ...current, - [selectedRoute.id]: cached.data, + [selectedRoute.id]: cachedData, })); } - if (cached.data && !cached.stale) { + if (cachedData && !cached.stale) { setRouteEnrichmentLoading((current) => ({ ...current, [selectedRoute.id]: false, diff --git a/src/components/Rower3D.tsx b/src/components/Rower3D.tsx index 82bd8c3..8d81189 100644 --- a/src/components/Rower3D.tsx +++ b/src/components/Rower3D.tsx @@ -112,8 +112,8 @@ const RowerScene: React.FC = ({ const routeTheme = useMemo(() => detectRouteTheme(route), [route]); const themeConfig = useMemo(() => getThemeConfig(routeTheme), [routeTheme]); const landmarkConfig = useMemo( - () => getRouteLandmarkConfig(route?.name, route?.tags), - [route?.name, route?.tags], + () => getRouteLandmarkConfig(route.name, route.tags), + [route.name, route.tags], ); const routeCurve = useMemo(() => { diff --git a/src/components/rower3d/bankComponents.tsx b/src/components/rower3d/bankComponents.tsx index 7274914..1113b04 100644 --- a/src/components/rower3d/bankComponents.tsx +++ b/src/components/rower3d/bankComponents.tsx @@ -246,7 +246,7 @@ export const CurvedLandscapeElements: React.FC = ({ } return { leftElements, rightElements }; - }, [curve, enrichment, theme]); + }, [curve, enrichment]); const colors = useMemo(() => getThemeConfig(theme).landscapeColors, [theme]); const archConfig = useMemo(() => getThemeConfig(theme).architecture, [theme]); @@ -258,7 +258,6 @@ export const CurvedLandscapeElements: React.FC = ({ makeSwayFoliageMaterial({ color: colors.tree, roughness: 0.74, metalness: 0.0, transmission: 0.10, thickness: 0.5, sheen: 0.52, sheenColor: new THREE.Color(colors.treeHighlight), sheenRoughness: 0.65 }, swayTime), makeSwayFoliageMaterial({ color: colors.tree, roughness: 0.70, metalness: 0.0, transmission: 0.12, thickness: 0.4, sheen: 0.58, sheenColor: new THREE.Color(colors.treeHighlight), sheenRoughness: 0.6 }, swayTime), makeSwayFoliageMaterial({ color: colors.tree, roughness: 0.68, metalness: 0.0, transmission: 0.14, thickness: 0.3, sheen: 0.65, sheenColor: new THREE.Color(colors.treeHighlight) }, swayTime), - // eslint-disable-next-line react-hooks/exhaustive-deps ], [colors, swayTime]); useEffect(() => () => { curveFoliageMats.forEach(m => m.dispose()); }, [curveFoliageMats]); useAnimationFrame((time) => { swayTime.value = time; }); diff --git a/src/components/rower3d/waterComponents.tsx b/src/components/rower3d/waterComponents.tsx index b32fea8..dc0f8ce 100644 --- a/src/components/rower3d/waterComponents.tsx +++ b/src/components/rower3d/waterComponents.tsx @@ -38,10 +38,11 @@ export const WaterReflectionProbe: React.FC<{ }); useEffect(() => { + const material = materialRef.current; return () => { - if (materialRef.current) { - materialRef.current.envMap = null; - materialRef.current.needsUpdate = true; + if (material) { + material.envMap = null; + material.needsUpdate = true; } }; }, [materialRef]); @@ -56,11 +57,18 @@ export const PhotorealisticWater: React.FC<{ boatZ: number; theme: RouteTheme; p const materialRef = useRef(null); const meshRef = useRef(null); const timeUniformRef = useRef({ value: 0 }); + const waterNormalMapRef = useRef(null); const waterConfig = useMemo(() => getThemeConfig(theme).water, [theme]); const waterNormalMap = useMemo(() => createWaterNormalMap(3.0), []); - useEffect(() => () => { waterNormalMap.dispose(); }, [waterNormalMap]); + useEffect(() => { + waterNormalMapRef.current = waterNormalMap; + return () => { + waterNormalMapRef.current = null; + waterNormalMap.dispose(); + }; + }, [waterNormalMap]); useEffect(() => { if (IS_TEST_MODE) return; @@ -68,7 +76,7 @@ export const PhotorealisticWater: React.FC<{ boatZ: number; theme: RouteTheme; p if (!mat) return; attachGerstnerShader(mat, timeUniformRef.current, 'z', theme, waterConfig.waveAmplitude, waterConfig.waveFrequency); mat.needsUpdate = true; - }, [theme]); + }, [theme, waterConfig.waveAmplitude, waterConfig.waveFrequency]); useAnimationFrame((time) => { timeUniformRef.current.value = time; @@ -81,9 +89,12 @@ export const PhotorealisticWater: React.FC<{ boatZ: number; theme: RouteTheme; p materialRef.current.emissiveIntensity = waterConfig.emissiveIntensity + causticPulse; } - waterNormalMap.offset.x = (time * 0.02) % 1; - waterNormalMap.offset.y = (time * 0.01) % 1; - waterNormalMap.needsUpdate = true; + const normalMap = waterNormalMapRef.current; + if (normalMap) { + normalMap.offset.x = (time * 0.02) % 1; + normalMap.offset.y = (time * 0.01) % 1; + normalMap.needsUpdate = true; + } }); return ( From cc62951b032905b889cd1dba4b8b036d90137da0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:28:32 +0000 Subject: [PATCH 4/6] Fix route enrichment review feedback --- src/__tests__/routeEnrichment.test.ts | 126 +++++++++++++++++++++++++ src/services/routeEnrichmentService.ts | 18 +++- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/src/__tests__/routeEnrichment.test.ts b/src/__tests__/routeEnrichment.test.ts index ddd152d..0b5496d 100644 --- a/src/__tests__/routeEnrichment.test.ts +++ b/src/__tests__/routeEnrichment.test.ts @@ -32,6 +32,12 @@ const routeFixture: WaterRoute = { createdAt: new Date('2025-01-01T00:00:00Z'), }; +const routeWithoutWaterTags: WaterRoute = { + ...routeFixture, + id: 'route-2', + tags: [], +}; + describe('route enrichment helpers', () => { beforeEach(() => { localStorage.clear(); @@ -176,4 +182,124 @@ describe('RouteEnrichmentService', () => { expect(enrichment.elevations).toHaveLength(routeFixture.coordinates.length); expect(enrichment.segmentProfiles.length).toBeGreaterThan(0); }); + + it('returns fallback immediately for routes without coordinates', async () => { + const fetchMock = vi.fn(); + const service = new RouteEnrichmentService( + fetchMock as unknown as typeof fetch, + localStorage, + ); + + const enrichment = await service.enrichRoute({ + ...routeFixture, + id: 'route-empty', + coordinates: [], + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(enrichment.source).toBe('fallback'); + expect(enrichment.elevations).toEqual([]); + }); + + it('returns network enrichment when cache writes fail', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + results: routeFixture.coordinates.map((_, index) => ({ + elevation: 5 + index, + })), + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + elements: [ + { + type: 'way', + tags: { landuse: 'forest' }, + bounds: { + minlat: 51.499, + minlon: -0.112, + maxlat: 51.503, + maxlon: -0.107, + }, + }, + { + type: 'way', + tags: { waterway: 'canal', width: '14' }, + bounds: { + minlat: 51.499, + minlon: -0.112, + maxlat: 51.503, + maxlon: -0.107, + }, + }, + ], + }), + } as Response); + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded'); + }); + const service = new RouteEnrichmentService( + fetchMock as unknown as typeof fetch, + localStorage, + ); + + const enrichment = await service.enrichRoute(routeFixture); + + expect(setItem).toHaveBeenCalled(); + expect(enrichment.source).toBe('network'); + expect(enrichment.waterBodyType).toBe('canal'); + }); + + it('derives the water body type from nearby water features even when land features appear first', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + results: routeWithoutWaterTags.coordinates.map((_, index) => ({ + elevation: 5 + index, + })), + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + elements: [ + { + type: 'way', + tags: { landuse: 'forest' }, + bounds: { + minlat: 51.499, + minlon: -0.112, + maxlat: 51.503, + maxlon: -0.107, + }, + }, + { + type: 'way', + tags: { waterway: 'canal', width: '14' }, + bounds: { + minlat: 51.499, + minlon: -0.112, + maxlat: 51.503, + maxlon: -0.107, + }, + }, + ], + }), + } as Response); + const service = new RouteEnrichmentService( + fetchMock as unknown as typeof fetch, + localStorage, + ); + + const enrichment = await service.enrichRoute(routeWithoutWaterTags); + + expect(enrichment.source).toBe('network'); + expect(enrichment.waterBodyType).toBe('canal'); + }); }); diff --git a/src/services/routeEnrichmentService.ts b/src/services/routeEnrichmentService.ts index 1acbfa6..cd1d5f4 100644 --- a/src/services/routeEnrichmentService.ts +++ b/src/services/routeEnrichmentService.ts @@ -497,7 +497,11 @@ export const saveCachedRouteEnrichment = ( savedAt: Date.now(), data, }; - storage.setItem(getRouteEnrichmentCacheKey(routeId), JSON.stringify(entry)); + try { + storage.setItem(getRouteEnrichmentCacheKey(routeId), JSON.stringify(entry)); + } catch { + return; + } }; export const getDragMultiplierForProgress = ( @@ -698,6 +702,10 @@ export class RouteEnrichmentService { return inflightRequest; } + if (route.coordinates.length === 0) { + return createFallbackRouteEnrichment(route); + } + const request = (async () => { const fallback = createFallbackRouteEnrichment(route); try { @@ -706,11 +714,13 @@ export class RouteEnrichmentService { this.fetchOverpassElements(route.coordinates), ]); const segmentProfiles = createSegmentProfilesFromFeatures(route, elements); + const waterFeatures = elements.filter( + (element) => inferWaterBodyType(element.tags) !== 'unknown', + ); + const nearestWaterFeature = findNearestFeature(route.coordinates[0], waterFeatures); const waterBodyType = segmentProfiles.find((segment) => segment.waterWidthMeters > 0) - ? inferWaterBodyType( - findNearestFeature(route.coordinates[0], elements)?.tags, - ) + ? inferWaterBodyType(nearestWaterFeature?.tags) : fallback.waterBodyType; const waterWidthMeters = segmentProfiles[0]?.waterWidthMeters ?? fallback.waterWidthMeters; From 59ed4ba9933ec42d55558f32a558d69298334260 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:30:36 +0000 Subject: [PATCH 5/6] Refine route enrichment fallback handling --- src/services/routeEnrichmentService.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/services/routeEnrichmentService.ts b/src/services/routeEnrichmentService.ts index cd1d5f4..920ecb9 100644 --- a/src/services/routeEnrichmentService.ts +++ b/src/services/routeEnrichmentService.ts @@ -707,7 +707,11 @@ export class RouteEnrichmentService { } const request = (async () => { - const fallback = createFallbackRouteEnrichment(route); + let fallback: RouteEnrichmentData | null = null; + const getFallback = () => { + fallback ??= createFallbackRouteEnrichment(route); + return fallback; + }; try { const [elevations, elements] = await Promise.all([ this.fetchElevations(route.coordinates), @@ -721,26 +725,26 @@ export class RouteEnrichmentService { const waterBodyType = segmentProfiles.find((segment) => segment.waterWidthMeters > 0) ? inferWaterBodyType(nearestWaterFeature?.tags) - : fallback.waterBodyType; + : getFallback().waterBodyType; + const resolvedWaterBodyType = + waterBodyType === 'unknown' ? getFallback().waterBodyType : waterBodyType; const waterWidthMeters = - segmentProfiles[0]?.waterWidthMeters ?? fallback.waterWidthMeters; + segmentProfiles[0]?.waterWidthMeters ?? getFallback().waterWidthMeters; const enrichment: RouteEnrichmentData = { routeId: route.id, elevations, segmentProfiles, - waterBodyType: waterBodyType === 'unknown' ? fallback.waterBodyType : waterBodyType, + waterBodyType: resolvedWaterBodyType, waterWidthMeters, - ...getWaterAppearance( - waterBodyType === 'unknown' ? fallback.waterBodyType : waterBodyType, - ), + ...getWaterAppearance(resolvedWaterBodyType), fetchedAt: Date.now(), source: 'network', }; saveCachedRouteEnrichment(route.id, enrichment, this.storage); return enrichment; } catch { - return fallback; + return getFallback(); } })().finally(() => { this.inflight.delete(route.id); From 11bf351817d9544daa4f19ad55468ec7b1dce3af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:31:55 +0000 Subject: [PATCH 6/6] Tighten route water type inference --- src/services/routeEnrichmentService.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/services/routeEnrichmentService.ts b/src/services/routeEnrichmentService.ts index 920ecb9..3f4113b 100644 --- a/src/services/routeEnrichmentService.ts +++ b/src/services/routeEnrichmentService.ts @@ -722,10 +722,9 @@ export class RouteEnrichmentService { (element) => inferWaterBodyType(element.tags) !== 'unknown', ); const nearestWaterFeature = findNearestFeature(route.coordinates[0], waterFeatures); - const waterBodyType = - segmentProfiles.find((segment) => segment.waterWidthMeters > 0) - ? inferWaterBodyType(nearestWaterFeature?.tags) - : getFallback().waterBodyType; + const waterBodyType = nearestWaterFeature + ? inferWaterBodyType(nearestWaterFeature.tags) + : getFallback().waterBodyType; const resolvedWaterBodyType = waterBodyType === 'unknown' ? getFallback().waterBodyType : waterBodyType; const waterWidthMeters =