mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
feat(tours): add non-motorized day tour planning
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user