feat(tours): add non-motorized day tour planning

This commit is contained in:
elmocito
2026-10-04 12:59:55 +02:00
parent c93cf2bce9
commit 9e475f447a
202 changed files with 16129 additions and 383 deletions
+16 -1
View File
@@ -1,6 +1,13 @@
import { describe, it, expect } from 'vitest'
import { isVectorStyle, resolveBasemap, resolveTileUrl } from './tileUrl'
import { OFM_POSITRON, attributionForTile, OFM_ATTRIBUTION } from '../constants/mapDefaults'
import {
OFM_POSITRON,
OPENTOPOMAP_TILE_ATTRIBUTION,
OPENTOPOMAP_TILE_MAXZOOM,
OPENTOPOMAP_TILE_URL,
attributionForTile,
OFM_ATTRIBUTION,
} from '../constants/mapDefaults'
const CARTO = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const CUSTOM = 'https://tiles.example.test/{z}/{x}/{y}.png'
@@ -68,4 +75,12 @@ describe('attributionForTile', () => {
expect(attributionForTile(CUSTOM)).toMatch(/OpenStreetMap/)
expect(attributionForTile(null)).toMatch(/OpenStreetMap/)
})
it('FE-UTIL-BASEMAP-010: OpenTopoMap carries its required data and style credits', () => {
expect(OPENTOPOMAP_TILE_URL).toBe('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png')
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('OpenStreetMap')
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('SRTM')
expect(OPENTOPOMAP_TILE_ATTRIBUTION).toContain('OpenTopoMap')
expect(OPENTOPOMAP_TILE_MAXZOOM).toBe(17)
})
})
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { analyzeRouteGeometry, focusRouteProfileAtDistance, parseRenderableRouteGeometry } from './routeGeometry'
describe('analyzeRouteGeometry', () => {
it('shares a validated rendering parser that retains valid elevation values', () => {
expect(parseRenderableRouteGeometry('[[48,11,512.125],[48.01,11.02,520.75]]'))
.toEqual([[48, 11, 512.125], [48.01, 11.02, 520.75]])
})
it('derives distance, elevation extrema, gain, loss and cumulative-distance samples together', () => {
const analysis = analyzeRouteGeometry(JSON.stringify([
[0, 0, 100],
[0, 0.01, 130],
[0, 0.03, 110],
]))
expect(analysis).not.toBeNull()
expect(analysis!.routeCoordinates).toEqual([[0, 0], [0, 0.01], [0, 0.03]])
expect(analysis!.distanceKm).toBeCloseTo(3.3358, 3)
expect(analysis!.minEle).toBe(100)
expect(analysis!.maxEle).toBe(130)
expect(analysis!.gain).toBe(30)
expect(analysis!.loss).toBe(20)
expect(analysis!.distanceIndexedProfileSamples).toHaveLength(3)
expect(analysis!.distanceIndexedProfileSamples[1]).toMatchObject({ lat: 0, lng: 0.01 })
expect(analysis!.distanceIndexedProfileSamples[1].distanceMeters).toBeCloseTo(1111.95, 1)
expect(analysis!.distanceIndexedProfileSamples[2].distanceMeters).toBeCloseTo(3335.85, 1)
})
it('maps route endpoints and interpolates focus between prepared samples', () => {
const samples = [
{ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20 },
{ distanceMeters: 100, elevationMeters: 200, lat: 12, lng: 24 },
{ distanceMeters: 300, elevationMeters: 300, lat: 14, lng: 28 },
]
expect(focusRouteProfileAtDistance(samples, -20)).toMatchObject({ distanceMeters: 0, elevationMeters: 100, lat: 10, lng: 20, sampleIndex: 0 })
expect(focusRouteProfileAtDistance(samples, 150)).toMatchObject({ distanceMeters: 150, elevationMeters: 225, lat: 12.5, lng: 25, sampleIndex: 1 })
expect(focusRouteProfileAtDistance(samples, 500)).toMatchObject({ distanceMeters: 300, elevationMeters: 300, lat: 14, lng: 28, sampleIndex: 2 })
})
it('does not create geographic focus for samples without coordinates', () => {
expect(focusRouteProfileAtDistance([
{ distanceMeters: 0, elevationMeters: 100 },
{ distanceMeters: 10, elevationMeters: 110 },
], 5)).toBeNull()
})
it('keeps distance but omits all elevation outputs when any point lacks elevation', () => {
const analysis = analyzeRouteGeometry(JSON.stringify([
[48, 11, 500],
[48.01, 11.01],
]))
expect(analysis?.distanceKm).toBeGreaterThan(0)
expect(analysis?.minEle).toBeNull()
expect(analysis?.maxEle).toBeNull()
expect(analysis?.gain).toBeNull()
expect(analysis?.loss).toBeNull()
expect(analysis?.distanceIndexedProfileSamples).toEqual([])
})
it.each([undefined, null, '', 'not json', '[]', '[[0,0]]', '[[0,"bad"],[1,1]]'])('returns null for unusable geometry %s', geometry => {
expect(analyzeRouteGeometry(geometry)).toBeNull()
})
})
+140
View File
@@ -0,0 +1,140 @@
export interface DistanceIndexedProfileSample {
distanceMeters: number
elevationMeters: number
lat?: number
lng?: number
}
export interface RouteProfileFocus {
distanceMeters: number
elevationMeters: number | null
lat: number
lng: number
sampleIndex: number
}
export interface RouteGeometryAnalysis {
routeCoordinates: [number, number][]
distanceKm: number
minEle: number | null
maxEle: number | null
gain: number | null
loss: number | null
distanceIndexedProfileSamples: DistanceIndexedProfileSample[]
}
export type RouteGeometryPoint = [number, number] | [number, number, number]
function isRoutePoint(value: unknown): value is RouteGeometryPoint {
return Array.isArray(value) && (value.length === 2 || value.length === 3) &&
Number.isFinite(value[0]) && Number.isFinite(value[1]) &&
Math.abs(value[0]) <= 90 && Math.abs(value[1]) <= 180 &&
(value.length === 2 || Number.isFinite(value[2]))
}
/** Parses and filters route coordinates for analysis and map rendering. */
export function parseRenderableRouteGeometry(routeGeometry: string | null | undefined): RouteGeometryPoint[] | null {
if (!routeGeometry) return null
let parsed: unknown
try {
parsed = JSON.parse(routeGeometry)
} catch {
return null
}
if (!Array.isArray(parsed)) return null
const points = parsed.filter(isRoutePoint)
return points.length >= 2 ? points : null
}
function haversineMeters(a: RouteGeometryPoint, b: RouteGeometryPoint): number {
const earthRadiusMeters = 6_371_000
const toRadians = (degrees: number) => degrees * Math.PI / 180
const [lat1, lng1] = a
const [lat2, lng2] = b
const deltaLat = toRadians(lat2 - lat1)
const deltaLng = toRadians(lng2 - lng1)
const chord = Math.sin(deltaLat / 2) ** 2 +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(deltaLng / 2) ** 2
const boundedChord = Math.min(1, Math.max(0, chord))
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(boundedChord), Math.sqrt(1 - boundedChord))
}
/** Interpolates a focus point from prepared samples in O(log n). */
export function focusRouteProfileAtDistance(
samples: readonly DistanceIndexedProfileSample[],
requestedDistanceMeters: number,
): RouteProfileFocus | null {
if (samples.length < 2 || !Number.isFinite(requestedDistanceMeters)) return null
const first = samples[0]
const last = samples[samples.length - 1]
if (![first.lat, first.lng, last.lat, last.lng].every(Number.isFinite)) return null
const distanceMeters = Math.max(first.distanceMeters, Math.min(last.distanceMeters, requestedDistanceMeters))
let low = 0
let high = samples.length - 1
while (low + 1 < high) {
const middle = (low + high) >>> 1
if (samples[middle].distanceMeters < distanceMeters) low = middle
else high = middle
}
if (distanceMeters <= first.distanceMeters) low = high = 0
else if (distanceMeters >= last.distanceMeters) low = high = samples.length - 1
const start = samples[low]
const end = samples[high]
if (![start.lat, start.lng, end.lat, end.lng].every(Number.isFinite)) return null
const span = end.distanceMeters - start.distanceMeters
const ratio = low === high || span <= 0 ? 0 : (distanceMeters - start.distanceMeters) / span
const interpolate = (a: number | undefined, b: number | undefined): number | null =>
Number.isFinite(a) && Number.isFinite(b) ? a! + (b! - a!) * ratio : null
return {
distanceMeters,
elevationMeters: interpolate(start.elevationMeters, end.elevationMeters),
lat: interpolate(start.lat, end.lat)!,
lng: interpolate(start.lng, end.lng)!,
sampleIndex: ratio < 0.5 ? low : high,
}
}
/**
* Derives all route metrics and chart samples from the current place geometry.
* Persisted tour metrics are only a list/display cache; detail views use this
* live analysis so their values and profile can never drift apart.
*/
export function analyzeRouteGeometry(routeGeometry: string | null | undefined): RouteGeometryAnalysis | null {
const points = parseRenderableRouteGeometry(routeGeometry)
if (!points) return null
const hasElevation = points.every(point => point.length >= 3 && Number.isFinite(point[2]))
let distanceMeters = 0
let minEle = hasElevation ? points[0][2]! : null
let maxEle = minEle
let gain = hasElevation ? 0 : null
let loss = hasElevation ? 0 : null
const distanceIndexedProfileSamples: DistanceIndexedProfileSample[] = hasElevation
? [{ distanceMeters: 0, elevationMeters: points[0][2]!, lat: points[0][0], lng: points[0][1] }]
: []
for (let index = 1; index < points.length; index++) {
distanceMeters += haversineMeters(points[index - 1], points[index])
if (!hasElevation) continue
const elevation = points[index][2]!
minEle = Math.min(minEle!, elevation)
maxEle = Math.max(maxEle!, elevation)
const delta = elevation - points[index - 1][2]!
if (delta > 0) gain! += delta
else loss! += Math.abs(delta)
distanceIndexedProfileSamples.push({ distanceMeters, elevationMeters: elevation, lat: points[index][0], lng: points[index][1] })
}
return {
routeCoordinates: points.map(([lat, lng]) => [lat, lng]),
distanceKm: distanceMeters / 1000,
minEle,
maxEle,
gain,
loss,
distanceIndexedProfileSamples,
}
}