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
@@ -889,6 +889,65 @@ describe('MapViewGL', () => {
return calls[calls.length - 1][0] as { features: GeoFeature[] }
}
it('moves a passive semantic profile focus pin without changing the map camera', async () => {
loadOnAttach()
const focus = { distanceMeters: 120, elevationMeters: 340, lat: 48.123, lng: 11.456, sampleIndex: 3 }
const focusPoints: [number, number][] = [[48.1, 11.4], [48.2, 11.5]]
const route: [number, number][][] = [[[48.1, 11.4], [48.2, 11.5]]]
const { rerender } = render(<MapViewGL places={[]} route={route} fitKey={1} focusPoints={focusPoints} glProvider="maplibre-gl" routeProfileFocus={focus} />)
await act(async () => {})
const marker = glCanvasContainer.querySelector<HTMLElement>('[data-tour-profile-focus="true"]')
expect(marker).not.toBeNull()
const projected = glMap.project([focus.lng, focus.lat])
expect(marker?.style.transform).toContain(`translate(${projected.x}px, ${projected.y}px)`)
expect(marker?.style.pointerEvents).toBe('none')
expect(marker?.style.background).toBe('var(--text-muted)')
expect(marker?.style.border).toBe('3px solid var(--bg-card)')
expect(glCanvasContainer.contains(marker)).toBe(true)
glMap.fitBounds.mockClear()
glMap.flyTo.mockClear()
glMap.jumpTo.mockClear()
glMap.easeTo.mockClear()
const movedFocus = { ...focus, lat: 48.16, lng: 11.47 }
rerender(<MapViewGL places={[]} route={route} fitKey={1} focusPoints={focusPoints} glProvider="maplibre-gl" routeProfileFocus={movedFocus} />)
await act(async () => {})
const movedMarker = glCanvasContainer.querySelector<HTMLElement>('[data-tour-profile-focus="true"]')
expect(movedMarker).toBe(marker)
expect(movedMarker?.style.transform).toContain(`translate(${glMap.project([movedFocus.lng, movedFocus.lat]).x}px, ${glMap.project([movedFocus.lng, movedFocus.lat]).y}px)`)
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
expect(glMap.easeTo).not.toHaveBeenCalled()
rerender(<MapViewGL places={[]} route={route} fitKey={1} focusPoints={focusPoints} glProvider="maplibre-gl" routeProfileFocus={null} />)
await act(async () => {})
expect(glCanvasContainer.contains(marker)).toBe(false)
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
expect(glMap.easeTo).not.toHaveBeenCalled()
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
loadOnAttach()
const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 })
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
const { rerender } = render(<MapViewGL places={[]} route={null} fitKey={0} glProvider="maplibre-gl" />)
await act(async () => {})
glMap.fitBounds.mockClear()
rerender(<MapViewGL places={[selectedPlace]} route={null} fitKey={1} glProvider="maplibre-gl" />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalled()
glMap.fitBounds.mockClear()
rerender(<MapViewGL places={[selectedPlace]} route={route} fitKey={2} glProvider="maplibre-gl" />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalled()
})
// jsdom drives requestAnimationFrame off a timer, so the marker reconcile and
// the batched photo updates only land after a short tick.
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
@@ -1782,6 +1841,33 @@ describe('MapViewGL', () => {
expect(features[1].properties.color).toBeNull()
})
it('uses view-local Topo tiles and controls without writing settings or moving the camera', async () => {
glMap.on.mockImplementation((event, handlerOrLayer) => {
if (event === 'load' && typeof handlerOrLayer === 'function') handlerOrLayer()
return glMap
})
const onChange = vi.fn()
const settings = useSettingsStore.getState().settings.map_base_layer
const { container, rerender } = render(<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" viewBaseLayer="topo" onViewBaseLayerChange={onChange} />)
await act(async () => {})
expect(glMap.addSource).toHaveBeenCalledWith('trip-topo', expect.objectContaining({
type: 'raster', tiles: ['a', 'b', 'c'].map(subdomain => `https://${subdomain}.tile.opentopomap.org/{z}/{x}/{y}.png`),
}))
const button = Array.from(container.querySelectorAll('button')).find(element => element.textContent === 'Topo')!
expect(button).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(button)
expect(onChange).toHaveBeenCalledWith('topo')
expect(useSettingsStore.getState().settings.map_base_layer).toBe(settings)
glMap.fitBounds.mockClear()
glMap.flyTo.mockClear()
glMap.jumpTo.mockClear()
rerender(<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" viewBaseLayer="default" onViewBaseLayerChange={onChange} />)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
expect(glMap.flyTo).not.toHaveBeenCalled()
expect(glMap.jumpTo).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
const gpxSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
@@ -1800,6 +1886,47 @@ describe('MapViewGL', () => {
expect(features[0].properties.place_id).toBe(74)
})
it.each([
['two-point', [[48, 2], [49, 3]]],
['elevation', [[48, 2, 12.5], [49, 3, 22.75]]],
['long track', Array.from({ length: 1200 }, (_, index) => [48 + index / 100000, 2 + index / 100000])],
['loop', [[48, 2], [48.1, 2.1], [48, 2]]],
['point-to-point Tour', [[48, 2], [48.5, 2.5], [49, 3]]],
])('renders valid %s geometry with MapLibre coordinate order', async (_label, geometry) => {
const features = gpxFeatures()
render(<MapViewGL places={[buildMapPlace({ route_geometry: JSON.stringify(geometry) })]} fitKey={1} />)
await act(async () => {})
expect(features()).toHaveLength(1)
expect(features()[0].geometry.coordinates).toEqual(geometry.map(([lat, lng]) => [lng, lat]))
})
it.each([
['malformed JSON', 'not json'],
['non-array JSON', '{"coordinates":[]}'],
['one point', '[[48,2]]'],
['missing coordinate dimension', '[[48],[49,3]]'],
['string coordinates', '[[48,"2"],[49,3]]'],
['NaN token', '[[48,2],[NaN,3]]'],
['Infinity token', '[[48,2],[49,Infinity]]'],
['out-of-range latitude', '[[48,2],[91,3]]'],
['out-of-range longitude', '[[48,2],[49,181]]'],
['all-invalid coordinates', '[[91,2],[49,181]]'],
])('omits %s geometry from the MapLibre source', async (_label, geometry) => {
const features = gpxFeatures()
expect(() => render(<MapViewGL places={[buildMapPlace({ route_geometry: geometry })]} fitKey={1} />)).not.toThrow()
await act(async () => {})
expect(features()).toHaveLength(0)
})
it('filters invalid points and preserves valid order in the MapLibre source', async () => {
const features = gpxFeatures()
const geometry = '[[48,2],null,[91,4],[48.5,2.5],[49,3]]'
render(<MapViewGL places={[buildMapPlace({ route_geometry: geometry })]} fitKey={1} />)
await act(async () => {})
expect(features()).toHaveLength(1)
expect(features()[0].geometry.coordinates).toEqual([[2, 48], [2.5, 48.5], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-050: without a Mapbox token it shows the settings hint and touches no camera', async () => {
const mapboxgl = (await import('mapbox-gl')).default
useSettingsStore.setState({