feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)

The tour type is a dropdown; a bike tour picks its profile, routes for it,
says when a point lies off every way, and shows what it runs on: surfaces,
kinds of way and, for a mountain bike, the trail grades, each pointed at on
the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour)
is offered while a tour of that type is open, with an admin switch each.

From the planned cards: a hike avoids steep climbs, steps or ferries, nothing
else; a saved tour can be shared where the platform takes GPX files; and the
tours rail adds a tour from one menu: plan new, import GPX, or take one over
from another trip.
This commit is contained in:
Maurice
2026-10-19 21:33:49 +02:00
parent adcaca3c5f
commit 73237ffedb
85 changed files with 3728 additions and 647 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1402,
"src/components/Map/MapView.tsx": 1401,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2296,
"src/components/Map/RouteCalculator.ts": 1195,
-9
View File
@@ -1,9 +0,0 @@
import type { AdminFeatureToggleResponse } from '@trek/shared';
import apiClient from './client';
/** MAP-01: the admin's switch for OpenHikingMap in the Tours planner. */
export const hikingMapAdminApi = {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-hiking-map').then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put('/admin/tours-hiking-map', { enabled }).then((r) => r.data),
};
+16
View File
@@ -0,0 +1,16 @@
import type { AdminFeatureToggleResponse } from '@trek/shared';
import apiClient from './client';
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
function tourBaseMapAdminApi(path: string) {
return {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put(path, { enabled }).then((r) => r.data),
};
}
/** MAP-01: OpenHikingMap for a hike. */
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
/** BIK-06: CyclOSM for a bike tour. */
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
+20
View File
@@ -0,0 +1,20 @@
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
import apiClient from './client';
/**
* BIK-05: the trail grades of the ways a mountain bike tour runs on. The
* server asks Overpass in parts, one after another, so a long tour may take
* longer than the shared default.
*/
const TRAIL_GRADE_TIMEOUT_MS = 60_000;
export const tourTrailGradeApi = {
grades: (
tripId: number | string,
body: TourTrailGradeRequest,
signal?: AbortSignal
): Promise<TourTrailGradeResponse> =>
apiClient
.post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS })
.then((r) => r.data),
};
+12 -10
View File
@@ -1,7 +1,7 @@
import { useId, type ComponentType } from 'react'
import { useTranslation } from '../../i18n'
import { useIsDark } from '../../hooks/useIsDark'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront, Footprints } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
import EmptyState from '../shared/EmptyState'
@@ -77,8 +77,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
toursHikingMap,
toggleToursHikingMap,
mapSwitches,
} = useAddonManager()
if (loading) {
@@ -165,13 +164,16 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
<AddonSubRow
icon={<Footprints size={14} />}
title={t('admin.toursHikingMap.title')}
description={t('admin.toursHikingMap.subtitle')}
enabled={toursHikingMap}
onToggle={() => void toggleToursHikingMap()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
)
}
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../../api/client';
import { hikingMapAdminApi } from '../../../api/hikingMap';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useAddonStore } from '../../../store/addonStore';
import type { Addon } from './addonModel';
@@ -39,6 +39,7 @@ beforeEach(() => {
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
});
afterEach(() => vi.restoreAllMocks());
@@ -50,6 +51,13 @@ async function loaded() {
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
/** One of the map switches under Tours, by its key. */
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
const found = manager.mapSwitches.find((entry) => entry.key === key);
if (!found) throw new Error(`no ${key} map switch`);
return found;
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
const { result, rerender } = await loaded();
@@ -123,14 +131,24 @@ describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(result.current.toursHikingMap).toBe(true));
await act(() => result.current.toggleToursHikingMap());
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'hiking').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(result.current.toursHikingMap).toBe(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
expect(loadAddons).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
});
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'cycle').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
const { result } = await loaded();
@@ -1,7 +1,8 @@
import { Bike, Footprints } from 'lucide-react';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { hikingMapAdminApi } from '../../../api/hikingMap';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
@@ -22,6 +23,7 @@ export function useAddonManager() {
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const loadAddons = async () => {
try {
@@ -42,6 +44,10 @@ export function useAddonManager() {
.get()
.then((flag) => setToursHikingMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
cycleMapAdminApi
.get()
.then((flag) => setToursCycleMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -69,7 +75,7 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** A switch under Tours (wanderer, OpenHikingMap): flipped at once, put back when the save fails. */
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
const flipToursSwitch = async (
current: boolean,
set: (enabled: boolean) => void,
@@ -88,7 +94,25 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
const toggleToursHikingMap = () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update);
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
const mapSwitches = [
{
key: 'hiking',
Icon: Footprints,
titleKey: 'admin.toursHikingMap.title',
subtitleKey: 'admin.toursHikingMap.subtitle',
enabled: toursHikingMap,
toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update),
},
{
key: 'cycle',
Icon: Bike,
titleKey: 'admin.toursCycleMap.title',
subtitleKey: 'admin.toursCycleMap.subtitle',
enabled: toursCycleMap,
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
},
] as const;
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
@@ -118,7 +142,6 @@ export function useAddonManager() {
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
toursHikingMap,
toggleToursHikingMap,
mapSwitches,
};
}
@@ -1,4 +1,4 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-019
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -70,8 +70,8 @@ describe('MapLayerSwitcher', () => {
})
it('FE-COMP-MAPLAYER-019: a hiking map that stopped answering says so on its segment, and only while it is shown', () => {
const hikingMap = { down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
const layers = { bases: ['default', 'hiking'] as const, hikingMap }
const baseHealth = { base: 'hiking' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
const layers = { bases: ['default', 'hiking'] as const, baseHealth }
const { rerender } = render(<TourMapLayerSwitcher active="hiking" onChange={() => {}} layers={layers} />)
const segment = screen.getByRole('button', { name: 'Hiking' })
expect(segment).toHaveAttribute('aria-pressed', 'true')
@@ -83,6 +83,14 @@ describe('MapLayerSwitcher', () => {
expect(screen.queryByRole('status')).toBeNull()
})
it('FE-COMP-MAPLAYER-020: CyclOSM sits in the switch as Cycling and says so when it stops answering (BIK-06)', () => {
const baseHealth = { base: 'cycle' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
render(<TourMapLayerSwitcher active="cycle" onChange={() => {}} layers={{ bases: ['default', 'cycle'], baseHealth }} />)
expect(screen.getByRole('button', { name: 'Cycling' })).toHaveTextContent('!')
expect(screen.getByRole('status')).toHaveTextContent('CyclOSM is not answering right now')
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
})
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
+15 -9
View File
@@ -1,4 +1,4 @@
import { Anchor, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
import type { CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
@@ -9,7 +9,7 @@ import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
import type { TileHealth, TourRasterBase } from './tourRasterBases'
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | TourRasterBase
@@ -20,8 +20,8 @@ export interface TourLayerOffer {
overlays?: readonly ToggleableRasterOverlay[]
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
paddle?: PaddleMapLayer
/** MAP-01: whether OpenHikingMap answers while it is the base map; the maps report into it. */
hikingMap?: TileHealth
/** MAP-01, BIK-06: whether the map on terms shown (OpenHikingMap, CyclOSM) answers; the maps report into it. */
baseHealth?: BaseMapHealth
}
const FROSTED_SHELL = {
@@ -87,6 +87,7 @@ const TOUR_LAYERS = [
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
{ id: 'topo', labelKey: null, Icon: Mountain },
{ id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints },
{ id: 'cycle', labelKey: 'map.baseLayer.cycle', Icon: Bike },
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
@@ -274,17 +275,18 @@ export function TourMapLayerSwitcher({
const { t } = useTranslation()
const bases = layers?.bases
const overlays = layers?.overlays
// OpenHikingMap is a best-effort service: while it is shown and stops answering, its segment says so.
const hikingDown = active === 'hiking' && !!layers?.hikingMap?.down
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
const health = layers?.baseHealth
return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
const selected = active === id
const label = labelKey ? t(labelKey) : 'Topo'
const down = id === 'hiking' && hikingDown
const down = !!health?.down && health.base === id && active === id
const downLabel = id === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')
return (
<Tooltip key={id} label={down ? t('map.baseLayer.hikingDown') : label} placement="top" disabled={!compact && !down}>
<Tooltip key={id} label={down ? downLabel : label} placement="top" disabled={!compact && !down}>
<button
type="button"
onClick={() => onChange(id)}
@@ -300,7 +302,11 @@ export function TourMapLayerSwitcher({
</Tooltip>
)
})}
{hikingDown && <span role="status" aria-live="polite" className="sr-only">{t('map.baseLayer.hikingDown')}</span>}
{health?.down && active === health.base && (
<span role="status" aria-live="polite" className="sr-only">
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
</span>
)}
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
+3 -3
View File
@@ -64,7 +64,7 @@ import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { tourRasterBaseOf } from './tourRasterBases'
import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
@@ -840,7 +840,7 @@ export const MapView = memo(function MapView({
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const isSatellite = baseLayer === 'satellite'
const tourRaster = tourRasterBaseOf(baseLayer) // topo or OpenHikingMap, the Tours planner's own
const tourRaster = tourRasterBaseOf(baseLayer) // topo, OpenHikingMap or CyclOSM, the Tours planner's own
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
@@ -1126,7 +1126,7 @@ export const MapView = memo(function MapView({
maxZoom={MAP_MAX_ZOOM}
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
eventHandlers={baseLayer === 'hiking' && tourLayers?.hikingMap ? { tileerror: tourLayers.hikingMap.onTileError, tileload: tourLayers.hikingMap.onTileLoad } : undefined}
eventHandlers={tileHealthHandlers(tourLayers?.baseHealth, baseLayer)}
/>
) : basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
@@ -5,7 +5,7 @@ import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers';
import type { TileHealth } from './tourRasterBases';
import type { BaseMapHealth, TileHealth } from './tourRasterBases';
/**
* A transparent raster layer laid over the basemap and under the route, such
@@ -135,17 +135,17 @@ export function useSourceTileHealthGL(
/**
* The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers,
* the paddle obstacles, and whether OpenHikingMap answers while it is the base map.
* the paddle obstacles, and whether the map on terms (OpenHikingMap, CyclOSM) answers while it is the base map.
*/
export function useTourMapLayersGL(
map: RasterOverlayGlMap | null,
ferrataLayer: FerrataMapLayer | undefined,
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; hikingMap?: TileHealth } | undefined
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
) {
useFerrataMapLayerGL(map, ferrataLayer);
useRasterOverlaysGL(map, layers?.overlays);
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
useSourceTileHealthGL(map, tourRasterSourceId('hiking'), layers?.hikingMap);
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
}
/**
+3 -2
View File
@@ -1,4 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
@@ -45,7 +45,7 @@ const CORE_COLOR = new Map(POI_CATEGORIES.map(c => [c.key, c.color] as const))
const coreColor = (key: string) => CORE_COLOR.get(key) ?? POI_DEFAULT_COLOR
/**
* What a hike or a paddle tour stops for (PLN-02), searched along a tour in the
* What a hike, a ride or a paddle tour stops for (PLN-02), searched along a tour in the
* planner and drawn on its map. Each takes the colour of the explore category
* it is closest to, so the map keeps one palette.
*/
@@ -61,6 +61,7 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
sights: { key: 'sights', labelKey: 'tours.poi.cat.sights', Icon: Camera, color: coreColor('sights') },
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
}
// A Map rather than an object, so a category named after an Object.prototype member
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
import { isOnTermsBase, tileHealthHandlers, TOUR_RASTER_BASES, tourRasterBaseOf } from './tourRasterBases';
// FE-MAP-TOURBASES-001 to FE-MAP-TOURBASES-003
describe('tourRasterBases (MAP-01, BIK-06)', () => {
it('FE-MAP-TOURBASES-001: knows topo, OpenHikingMap and CyclOSM, CyclOSM on its three shards', () => {
expect(tourRasterBaseOf('topo')).toBe(TOUR_RASTER_BASES.topo);
expect(tourRasterBaseOf('cycle')?.tiles).toEqual([
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
]);
expect(tourRasterBaseOf('cycle')?.maxZoom).toBe(20);
expect(tourRasterBaseOf('satellite')).toBeNull();
// Inherited names are no base map.
expect(tourRasterBaseOf('toString')).toBeNull();
});
it('FE-MAP-TOURBASES-002: the maps on terms carry their credit; topo is no map on terms', () => {
expect(isOnTermsBase('hiking')).toBe(true);
expect(isOnTermsBase('cycle')).toBe(true);
expect(isOnTermsBase('topo')).toBe(false);
expect(isOnTermsBase('default')).toBe(false);
expect(TOUR_RASTER_BASES.cycle.onTerms?.credit.map((part) => part.href)).toEqual([
'https://www.cyclosm.org',
'https://www.openstreetmap.org/copyright',
]);
expect(TOUR_RASTER_BASES.hiking.onTerms?.credit).toHaveLength(3);
});
it('FE-MAP-TOURBASES-003: a Leaflet layer reports into the health of the map watched, and only of that one', () => {
const health = { base: 'cycle' as const, down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
expect(tileHealthHandlers(health, 'cycle')).toEqual({ tileerror: health.onTileError, tileload: health.onTileLoad });
expect(tileHealthHandlers(health, 'hiking')).toBeUndefined();
expect(tileHealthHandlers(undefined, 'cycle')).toBeUndefined();
});
});
+47 -2
View File
@@ -1,4 +1,7 @@
import {
CYCLOSM_TILE_ATTRIBUTION,
CYCLOSM_TILE_MAXZOOM,
CYCLOSM_TILE_URL,
OPENHIKINGMAP_TILE_ATTRIBUTION,
OPENHIKINGMAP_TILE_MAXZOOM,
OPENHIKINGMAP_TILE_URL,
@@ -8,7 +11,7 @@ import {
} from '../../constants/mapDefaults';
/** The raster base maps only the Tours planner offers, drawn the same way by Leaflet, GL and the 3D map. */
export type TourRasterBase = 'topo' | 'hiking';
export type TourRasterBase = 'topo' | 'hiking' | 'cycle';
export interface TourRasterBaseSource {
/** The Leaflet template; `{s}` is a shard Leaflet fills in. */
@@ -17,6 +20,12 @@ export interface TourRasterBaseSource {
tiles: string[];
attribution: string;
maxZoom: number;
/**
* A map its operator offers on terms (MAP-01, BIK-06): loaded only as viewed, never
* prefetched or kept offline, its credit readable without a click, and watched for whether it
* answers. `credit` is what the 3D map shows above its controls.
*/
onTerms?: { credit: ReadonlyArray<{ label: string; href: string }> };
}
export const TOUR_RASTER_BASES: Record<TourRasterBase, TourRasterBaseSource> = {
@@ -31,6 +40,25 @@ export const TOUR_RASTER_BASES: Record<TourRasterBase, TourRasterBaseSource> = {
tiles: [OPENHIKINGMAP_TILE_URL],
attribution: OPENHIKINGMAP_TILE_ATTRIBUTION,
maxZoom: OPENHIKINGMAP_TILE_MAXZOOM,
onTerms: {
credit: [
{ label: '\u00a9 OpenHikingMap', href: 'https://wiki.openstreetmap.org/wiki/OpenHikingMap' },
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
{ label: '\u2764\ufe0f Donation', href: 'https://openmaps.fr/donate' },
],
},
},
cycle: {
url: CYCLOSM_TILE_URL,
tiles: ['a', 'b', 'c'].map((shard) => CYCLOSM_TILE_URL.replace('{s}', shard)),
attribution: CYCLOSM_TILE_ATTRIBUTION,
maxZoom: CYCLOSM_TILE_MAXZOOM,
onTerms: {
credit: [
{ label: '\u00a9 CyclOSM', href: 'https://www.cyclosm.org' },
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
],
},
},
};
@@ -38,7 +66,14 @@ export const TOUR_RASTER_BASE_IDS = Object.keys(TOUR_RASTER_BASES) as TourRaster
/** The raster source of a base layer, or null for the user's own map and the imagery. */
export function tourRasterBaseOf(layer: string): TourRasterBaseSource | null {
return layer === 'topo' || layer === 'hiking' ? TOUR_RASTER_BASES[layer] : null;
return Object.prototype.hasOwnProperty.call(TOUR_RASTER_BASES, layer)
? TOUR_RASTER_BASES[layer as TourRasterBase]
: null;
}
/** Whether the layer is a map its operator offers on terms (OpenHikingMap, CyclOSM). */
export function isOnTermsBase(layer: string): layer is TourRasterBase {
return !!tourRasterBaseOf(layer)?.onTerms;
}
/**
@@ -51,3 +86,13 @@ export interface TileHealth {
onTileError: () => void;
onTileLoad: () => void;
}
/** The health of the map on terms that is the base map now, with which one it is. */
export interface BaseMapHealth extends TileHealth {
base: TourRasterBase;
}
/** A Leaflet tile layer's events that report into the health of `layer`, while it is the map watched. */
export function tileHealthHandlers(health: BaseMapHealth | undefined, layer: string) {
return health?.base === layer ? { tileerror: health.onTileError, tileload: health.onTileLoad } : undefined;
}
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -531,3 +531,55 @@ describe('valhallaAlternates', () => {
expect(calls).toBe(2)
})
})
describe('valhallaTraceAttributes (BIK-01)', () => {
const TRACE = 'https://valhalla1.openstreetmap.de/trace_attributes'
it('VALHALLA-TRACE-001: asks for the ways under the line, matched as a mountain bike rides, and hands the answer on', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toEqual({ shape: SHAPE, edges: [] })
expect(bodies[0]).toMatchObject({
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
shape_match: 'map_snap',
filters: { action: 'include' },
})
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
})
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
let shape: unknown[] = []
server.use(http.post(TRACE, async ({ request }) => {
shape = ((await request.json()) as { shape: unknown[] }).shape
return HttpResponse.json({})
}))
const line = Array.from({ length: 9001 }, (_, i): [number, number] => [47 + i / 1e5, 11])
await valhallaTraceAttributes(line)
expect(shape.length).toBeLessThanOrEqual(4001)
expect(shape[0]).toEqual({ lat: 47, lon: 11 })
expect(shape[shape.length - 1]).toEqual({ lat: line[9000][0], lon: 11 })
})
it('VALHALLA-TRACE-003: null for a refusal, a network failure, a line of one point or a router that is not Valhalla', async () => {
server.use(http.post(TRACE, () => new HttpResponse(null, { status: 400 })))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
server.use(http.post(TRACE, () => HttpResponse.error()))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
await expect(valhallaTraceAttributes(SHAPE_POINTS.slice(0, 1))).resolves.toBeNull()
setSettings({ routing_base_url: 'https://osrm.example' })
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
})
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
let asked = false
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
const controller = new AbortController()
controller.abort()
await expect(valhallaTraceAttributes(SHAPE_POINTS, controller.signal)).resolves.toBeNull()
expect(asked).toBe(false)
})
})
+52 -4
View File
@@ -55,6 +55,9 @@ const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
cycling: 'bicycle',
}
/** What a caller tunes in the costing model: a weighting, a switch, or a bicycle type by name. */
export type CostingOptions = Record<string, number | string | boolean>
/**
* What a caller can ask to be left out, against the costing option that asks for it.
*
@@ -191,7 +194,7 @@ export async function valhallaRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<Record<string, number | boolean>> = {},
costingOptions: Readonly<CostingOptions> = {},
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
@@ -315,14 +318,14 @@ function routeBody(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
costingOptions: Readonly<Record<string, number | boolean>> = {},
costingOptions: Readonly<CostingOptions> = {},
): Record<string, unknown> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
// away together answered 656.9 km against 339.2 km for the plain route.
const options: Record<string, number | boolean> = { ...costingOptions }
const options: CostingOptions = { ...costingOptions }
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
return {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
@@ -359,6 +362,51 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
}
}
/** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */
const TRACE_MAX_POINTS = 4000
/** How long a description may take before it counts as failed, so a router that hangs does not leave its card waiting. */
const TRACE_TIMEOUT_MS = 30_000
/**
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
* of every edge, with the line as matched. The edges are matched as a mountain
* bike would ride them, the costing that refuses the fewest ways, so a road
* bike's route and a gravel one are described alike. Null for every failure:
* the description is an addition beside a route that already exists.
*/
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon }))
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS)
try {
await valhallaTurn(base, signal)
if (signal?.aborted) return null
const response = await fetch(`${base}/trace_attributes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
shape_match: 'map_snap',
filters: {
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
'edge.begin_shape_index', 'edge.end_shape_index'],
action: 'include',
},
}),
signal: bounded,
})
if (!response.ok) return null
return await response.json()
} catch {
return null
}
}
/** One request, at most ten locations. */
async function requestRun(
base: string,
@@ -366,7 +414,7 @@ async function requestRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<Record<string, number | boolean>> = {},
costingOptions: Readonly<CostingOptions> = {},
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
return data === null ? null : runFrom(data)
+5 -3
View File
@@ -1,4 +1,4 @@
import type { MouseEvent, ReactNode } from 'react'
import type { MouseEvent, ReactNode, Ref } from 'react'
import { Clock, MoreHorizontal, type LucideIcon } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
@@ -15,7 +15,7 @@ import { fs } from '../shared/DialogShell'
export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2'
/** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId }: {
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId, ref }: {
label: string
/** A longer explanation for the tooltip; the label still names the button. */
tooltip?: string
@@ -29,10 +29,12 @@ export function BarButton({ label, tooltip, onClick, active = false, disabled =
ariaExpanded?: boolean
ariaHaspopup?: 'dialog' | 'menu'
placement?: 'top' | 'bottom' | 'left' | 'right'
/** The button itself, for a list or a menu that opens beside it. */
ref?: Ref<HTMLButtonElement>
}) {
return (
<Tooltip label={tooltip ?? label} placement={placement}>
<button type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
<button ref={ref} type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted enabled:hover:bg-surface-card enabled:hover:text-content'} ${className}`}>
{children}
@@ -222,7 +222,7 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
formatPlannedTourDuration(times.walkingMinutes),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
@@ -1,4 +1,4 @@
import { Download, Loader2 } from 'lucide-react';
import { Download, Loader2, Share } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { BarButton } from '../Planner/planParts';
import { DialogButton } from '../shared/DialogShell';
@@ -6,44 +6,73 @@ import { Tooltip } from '../shared/Tooltip';
import { useTourGpxExport, type TourGpxExportTarget } from './useTourGpxExport';
/**
* PR1-06A in a footer: the saved tour as a GPX file, its own action beside
* the navigation, which only leads to the start. "GPX" says the format, the
* tooltip what the file holds.
* PR1-06A, IO-01 in a footer: the saved tour as a GPX file, its own action
* beside the navigation, which only leads to the start. "GPX" says the format,
* the tooltip what the file holds. Where the platform takes GPX files, a share
* button beside it hands the same file to the system's share sheet.
*/
export function TourGpxExportButton({ target }: { target: TourGpxExportTarget }) {
const { t } = useTranslation();
const { exportGpx, exporting } = useTourGpxExport();
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
return (
<Tooltip label={t('tours.gpxExport.hint')}>
<span className="inline-flex">
<DialogButton
onClick={() => void exportGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.action')}
data-testid="tour-gpx-export"
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">GPX</span>
</DialogButton>
</span>
</Tooltip>
<>
<Tooltip label={t('tours.gpxExport.hint')}>
<span className="inline-flex">
<DialogButton
onClick={() => void exportGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.action')}
data-testid="tour-gpx-export"
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">GPX</span>
</DialogButton>
</span>
</Tooltip>
{canShare && (
<Tooltip label={t('tours.gpxExport.share')}>
<span className="inline-flex">
<DialogButton
onClick={() => void shareGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.share')}
data-testid="tour-gpx-share"
icon={<Share size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{t('tours.gpxExport.shareShort')}</span>
</DialogButton>
</span>
</Tooltip>
)}
</>
);
}
/**
* The same export in the planner's head. While the tour has unsaved changes
* the file would not hold them, so the button waits for the save and says so.
* the file would not hold them, so the buttons wait for the save and say so.
*/
export function TourGpxExportBarButton({ target, unsaved }: { target: TourGpxExportTarget; unsaved: boolean }) {
const { t } = useTranslation();
const { exportGpx, exporting } = useTourGpxExport();
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
return (
<BarButton
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
onClick={() => void exportGpx(target)}
disabled={unsaved || exporting}
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
</BarButton>
<>
<BarButton
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
onClick={() => void exportGpx(target)}
disabled={unsaved || exporting}
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
</BarButton>
{canShare && (
<BarButton
label={t(unsaved ? 'tours.gpxExport.shareSaveFirst' : 'tours.gpxExport.share')}
onClick={() => void shareGpx(target)}
disabled={unsaved || exporting}
>
<Share size={15} strokeWidth={2} />
</BarButton>
)}
</>
);
}
@@ -1,5 +1,12 @@
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
import {
BIKE_PROFILES,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
type TourGpxImportFields,
} from '@trek/shared';
import { AlertTriangle, FileDown, Info } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
@@ -20,6 +27,7 @@ import {
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
@@ -76,10 +84,12 @@ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpx
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const { t } = useTranslation();
const labelId = useId();
const typeLabelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const [type, setType] = useState<PlannableTourType>('hike');
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -96,7 +106,9 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: { tour_type: 'hike' };
: type === 'bike'
? { tour_type: 'bike', bike_profile: bikeProfile }
: { tour_type: 'hike' };
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
@@ -168,18 +180,21 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
)}
</DialogSection>
<DialogSection label={t('tours.import.previewAs')}>
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<div className="flex flex-col gap-3">
<Segmented<PlannableTourType>
value={type}
onChange={setType}
label={t('tours.import.previewAs')}
fill
options={[
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
]}
/>
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
{type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={bikeProfile}
onChange={setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
@@ -349,7 +349,8 @@ describe('ToursSidebar', () => {
// A file without heights says so instead of showing zero.
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
fireEvent.click(screen.getByRole('button', { name: 'Paddle tour' }))
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-kayak'))
const confirm = screen.getByTestId('gpx-preview-import')
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
@@ -0,0 +1,152 @@
import type { TourTrailGrade } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { BikeWaysCard } from './BikeWaysCard';
import type { RouteWays } from './routeWays';
import type { RouteWaysState } from './useRouteWays';
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
vi.mock('./useRouteWays', () => ({
useRouteWays: (input: unknown) => {
hook.input = input;
return { ...(hook.state as object), retry: hook.retry };
},
}));
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
];
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
],
};
function planner(overrides: Partial<TourPlannerController> = {}) {
return {
tripId: 4,
mode: { type: 'new-draft' },
isBike: true,
bikeProfile: 'touring',
route: LINE,
routeCurrent: true,
...overrides,
} as unknown as TourPlannerController;
}
const grade = (id: number, mtb: string | null, uphill: string | null = null): TourTrailGrade => ({
id,
mtb_scale: mtb,
mtb_scale_uphill: uphill,
mtb_scale_imba: null,
});
function show(state: Partial<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
return render(<BikeWaysCard planner={planner(overrides)} />);
}
beforeEach(() => {
localStorage.clear();
useTourRouteHighlightStore.getState().clear();
});
afterEach(() => vi.clearAllMocks());
describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
show({});
const card = screen.getByTestId('bike-ways-card');
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
expect(card).not.toHaveTextContent('Trail grade');
});
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
show({});
const row = screen.getByTestId('bike-ways-row-surface-gravel');
fireEvent.click(row);
expect(row).toHaveAttribute('aria-pressed', 'true');
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
id: 'surface-gravel',
lines: [LINE.slice(1, 3)],
paint: 'gravel',
});
fireEvent.click(row);
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
it('FE-TOUR-BIKECARD-003: a road bike tour names what is unpaved and shows it on the map', () => {
show({}, { bikeProfile: 'road' } as Partial<TourPlannerController>);
fireEvent.click(screen.getByRole('button', { name: 'Show on the map' }));
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('unpaved');
expect(useTourRouteHighlightStore.getState().highlight?.lines).toEqual([LINE.slice(1)]);
});
it('FE-TOUR-BIKECARD-004: a mountain bike tour shows its trail grades, uphill apart, the unknown said', () => {
show({
grades: {
status: 'ready',
byWay: new Map([
[1, grade(1, '0')],
[2, grade(2, '2', '3')],
]),
unasked: new Set([3]),
},
});
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
});
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(hook.retry).toHaveBeenCalled();
});
it('FE-TOUR-BIKECARD-006: says when it is still asking, offline, or failed with a way to ask again', () => {
const { unmount } = show({ status: 'loading', ways: null });
expect(screen.getByTestId('bike-ways-card')).toHaveTextContent(/./);
unmount();
const offline = show({ status: 'offline', ways: null });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
offline.unmount();
show({ status: 'failed', ways: null });
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(hook.retry).toHaveBeenCalledTimes(1);
});
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
unmount();
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
});
it('FE-TOUR-BIKECARD-008: only for a bike tour being edited, and only the route as planned now', () => {
const { unmount } = show({ status: 'idle', ways: null });
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
unmount();
const hike = show({}, { isBike: false });
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
});
});
@@ -0,0 +1,341 @@
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, RailCard, TourNotice } from '../tourParts';
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
import {
sharesBy,
stretchesOf,
trailClassOf,
UNPAVED,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type TrailClass,
type WayClass,
} from './routeWays';
import { useRouteWays, type TrailGrades } from './useRouteWays';
const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`bike-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
{label}
</span>
{children}
</div>
);
}
function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
return (
<Section label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`surface-${row.key}`}
row={row}
label={t(`tours.bike.ways.surface.${row.key}`)}
paint={surfacePaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => edge.surface === row.key}
/>
))}
</ul>
</Section>
);
}
function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
return (
<Section label={t('tours.bike.ways.way')}>
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`way-${row.key}`}
row={row}
label={t(`tours.bike.ways.way.${row.key}`)}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => edge.way === row.key}
/>
))}
</ul>
</Section>
);
}
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
const { t } = useTranslation();
const classOf = useMemo(
() =>
(edge: RouteWayEdge): TrailClass =>
edge.wayId !== null && grades.unasked.has(edge.wayId)
? 'unasked'
: trailClassOf(edge.wayId === null ? undefined : grades.byWay.get(edge.wayId)),
[grades]
);
const uphillOf = useMemo(
() =>
(edge: RouteWayEdge): string => {
const step = edge.wayId === null ? null : grades.byWay.get(edge.wayId)?.mtb_scale_uphill;
return step ? `u${step[0]}` : 'none';
},
[grades]
);
const rows = useMemo(() => sharesBy<TrailClass>(ways, classOf), [ways, classOf]);
const uphill = useMemo(() => sharesBy<string>(ways, uphillOf).filter((row) => row.key !== 'none'), [ways, uphillOf]);
// Nothing could be asked about: said once with a way to ask again, not as a row covering the whole tour.
const noneAnswered =
grades.status === 'ready' && grades.byWay.size === 0 && rows.every((row) => row.key === 'unasked');
const unread = noneAnswered ? 'failed' : grades.status;
if (unread !== 'ready')
return (
<Section label={t('tours.bike.trail.title')}>
<GradesStatus status={unread} onRetry={onRetry} />
</Section>
);
return (
<>
<Section label={t('tours.bike.trail.title')}>
<ShareBar rows={rows} paintOf={trailPaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`trail-${row.key}`}
row={row}
label={t(`tours.bike.trail.${row.key}`)}
paint={trailPaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
</Section>
{uphill.length > 0 && (
<Section label={t('tours.bike.trail.uphillTitle')}>
<ul className="m-0 flex list-none flex-col p-0">
{uphill.map((row) => (
<ShareRow
key={row.key}
id={`uphill-${row.key}`}
row={row}
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => uphillOf(edge) === row.key}
/>
))}
</ul>
</Section>
)}
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.bike.trail.note')}
</span>
</>
);
}
function GradesStatus({ status, onRetry }: { status: Exclude<TrailGrades['status'], 'ready'>; onRetry: () => void }) {
const { t } = useTranslation();
if (status === 'loading')
return (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.trail.loading')}
</span>
);
return (
<TourNotice
tone="warning"
icon={status === 'offline' ? <WifiOff size={13} strokeWidth={2} /> : <AlertTriangle size={13} strokeWidth={2} />}
>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t(status === 'offline' ? 'tours.bike.ways.offline' : 'tours.bike.trail.failed')}</span>
{status === 'failed' && (
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
{t('common.errorRetry')}
</CardAction>
)}
</span>
</TourNotice>
);
}
/** BIK-02: a road bike tour names what of it is known to be unpaved, and points at it on the map. */
function UnpavedNotice({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const unpaved = ways.edges.filter((edge) => UNPAVED.has(edge.surface)).reduce((sum, edge) => sum + edge.lengthM, 0);
if (unpaved === 0) return null;
const show = () =>
useTourRouteHighlightStore
.getState()
.show({ id: 'unpaved', lines: stretchesOf(ways, (edge) => UNPAVED.has(edge.surface)), paint: 'gravel' });
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.unpavedNotice', { length: formatDistance(unpaved / 1000, unit) })}</span>
<CardAction icon={<Layers size={12} strokeWidth={2} />} onClick={show}>
{t('tours.bike.ways.showOnMap')}
</CardAction>
</span>
</TourNotice>
);
}
/**
* BIK-01 to BIK-05: what the bike tour being planned runs on, its surfaces and
* its kinds of way, and on a mountain bike tour the trail grades. Read off the
* router's description of the route as OpenStreetMap maps it; where the data
* says nothing the card says unknown. Nothing here changes the tour.
*/
export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation();
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
const route = planner.routeCurrent ? planner.route : null;
const state = useRouteWays({
tripId: planner.tripId,
active: planner.isBike && editing,
route,
profile: planner.bikeProfile,
});
const { ways } = state;
const unpavedShare = useMemo(
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
[ways]
);
if (!planner.isBike || !editing || state.status === 'idle') return null;
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
return (
<RailCard
icon={<Layers size={14} strokeWidth={2} />}
title={t('tours.bike.ways.title')}
foldId="bike-ways"
aside={
unpaved ? (
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
) : undefined
}
>
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
{state.status === 'loading' && (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
)}
{state.status === 'offline' && (
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
)}
{state.status === 'failed' && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
)}
{ways && (
<>
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
<SurfaceSection ways={ways} />
<WaySection ways={ways} />
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
</>
)}
</div>
</RailCard>
);
}
@@ -0,0 +1,46 @@
import { useMapPaint } from '../../Map/mapPaint';
import type { SurfaceClass, TrailClass } from './routeWays';
/**
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
* by name. The cards take the CSS variable, which follows the theme on its
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
* colour of its own: it is pointed at in the accent.
*/
export const BIKE_PAINT_TOKENS = {
paved: '--bike-surface-paved',
rough: '--bike-surface-rough',
compacted: '--bike-surface-compacted',
gravel: '--bike-surface-gravel',
ground: '--bike-surface-ground',
unknown: '--bike-surface-unknown',
s0: '--bike-trail-s0',
s1: '--bike-trail-s1',
s2: '--bike-trail-s2',
s3: '--bike-trail-s3',
s4: '--bike-trail-s4',
way: '--accent',
casing: '--bike-highlight-casing',
} as const;
export type BikePaintKey = keyof typeof BIKE_PAINT_TOKENS;
/** The paint of a trail class; a way without a grade, or not asked about, in the grey of the unknown. */
export function trailPaintKey(key: TrailClass): BikePaintKey {
return key === 'none' || key === 'unasked' ? 'unknown' : key;
}
/** The paint of a surface class. */
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
return key;
}
/** A colour as a CSS variable, for markup on the page. */
export function bikePaintCss(key: BikePaintKey): string {
return `var(${BIKE_PAINT_TOKENS[key]})`;
}
/** The colours resolved for map paint, again whenever the theme changes. */
export function useBikePaint(): Record<BikePaintKey, string> {
return useMapPaint(BIKE_PAINT_TOKENS);
}
@@ -0,0 +1,27 @@
import type { RouteHighlight } from '../../../store/tourRouteHighlightStore';
import type { BikePaintKey } from './bikePaint';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/**
* The planner map's lines with a highlight laid over them: the route's own
* lines first as they were, the highlighted stretches after, solid, in their
* colour over a rim of the map's own white, so they read over the route.
* Without a highlight the lines stay exactly as they came.
*/
export function withRouteHighlight(
highlight: RouteHighlight | null,
route: Line[] | null,
walking: boolean[] | null,
colors: LineColor[] | null,
paint: Record<BikePaintKey, string>
): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } {
if (!highlight?.lines.length || !route?.length) return { route, walking, colors };
const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing };
return {
route: [...route, ...highlight.lines],
walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null,
colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)],
};
}
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
[47.004, 11.0],
];
function answer(edges: Record<string, unknown>[], line = LINE) {
return { shape: encodePolyline(line, 6), edges };
}
const edge = (from: number, to: number, extra: Record<string, unknown> = {}) => ({
begin_shape_index: from,
end_shape_index: to,
length: 0.1,
surface: 'paved_smooth',
use: 'road',
road_class: 'residential',
way_id: 100 + from,
...extra,
});
describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
it('FE-TOUR-WAYS-001: reads each edge into its place on the line, its surface, its kind of way and its way', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { surface: 'paved', use: 'cycleway' }),
edge(1, 2, { surface: 'gravel', use: 'track', length: 0.25 }),
edge(2, 3, { surface: 'dirt', use: 'path' }),
edge(3, 4, { surface: 'compacted', use: 'road', road_class: 'primary', cycle_lane: 'dedicated', way_id: 0 }),
])
);
expect(ways?.line).toHaveLength(5);
expect(ways?.edges.map(({ surface, way, wayId, lengthM }) => [surface, way, wayId, lengthM])).toEqual([
['paved', 'cyclePath', 100, 100],
['gravel', 'track', 101, 250],
['ground', 'path', 102, 100],
['compacted', 'cycleLane', null, 100],
]);
});
it('FE-TOUR-WAYS-002: names quiet roads, roads, ferries and what it does not know', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { use: 'living_street' }),
edge(1, 2, { use: 'road', road_class: 'secondary', surface: 'paved_rough' }),
edge(2, 3, { use: 'ferry', surface: undefined }),
edge(3, 4, { use: 'something_new', surface: 'wood' }),
])
);
expect(ways?.edges.map(({ way, surface }) => [way, surface])).toEqual([
['quiet', 'paved'],
['road', 'rough'],
['ferry', 'unknown'],
['other', 'unknown'],
]);
});
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
const ways = routeWaysFrom(
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
);
expect(ways?.edges).toHaveLength(1);
expect(routeWaysFrom(null)).toBeNull();
expect(routeWaysFrom({ shape: 1, edges: [] })).toBeNull();
expect(routeWaysFrom(answer([]))).toBeNull();
expect(routeWaysFrom(answer([edge(0, 0)], [[47, 11]]))).toBeNull();
});
});
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
],
};
describe('what the line runs on (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-004: shares by class, the largest first, a class with no length left out', () => {
expect(sharesBy(WAYS, (e) => e.surface)).toEqual([
{ key: 'paved', lengthM: 300, share: 0.6 },
{ key: 'gravel', lengthM: 200, share: 0.4 },
]);
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
});
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
expect(stretchesOf(WAYS, () => false)).toEqual([]);
});
it('FE-TOUR-WAYS-006: the ways to grade, each once, in riding order, at most as many as asked', () => {
expect(wayIdsOf(WAYS, 10)).toEqual([1, 2]);
expect(wayIdsOf(WAYS, 1)).toEqual([1]);
});
it('FE-TOUR-WAYS-007: a trail grade by its step, S4 and up together, none where there is none', () => {
expect(trailClassOf({ mtb_scale: '0' })).toBe('s0');
expect(trailClassOf({ mtb_scale: '2+' })).toBe('s2');
expect(trailClassOf({ mtb_scale: '5' })).toBe('s4');
expect(trailClassOf({ mtb_scale: null })).toBe('none');
expect(trailClassOf({ mtb_scale: 'x' })).toBe('none');
expect(trailClassOf(undefined)).toBe('none');
});
});
describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-008: a class names its token; an ungraded way is grey', () => {
expect(surfacePaintKey('gravel')).toBe('gravel');
expect(trailPaintKey('s3')).toBe('s3');
expect(trailPaintKey('none')).toBe('unknown');
expect(trailPaintKey('unasked')).toBe('unknown');
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
});
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
const route = [LINE.slice(0, 3), LINE.slice(2)];
const drawn = withRouteHighlight(
{ id: 'surface:gravel', lines: [LINE.slice(1, 3)], paint: 'gravel' },
route,
[false, true],
null,
paint
);
expect(drawn.route).toEqual([...route, LINE.slice(1, 3)]);
expect(drawn.walking).toEqual([false, true, false]);
expect(drawn.colors).toEqual([undefined, undefined, { line: '#a0522d', casing: '#fff' }]);
const way = withRouteHighlight({ id: 'way:track', lines: [LINE], paint: 'track' }, route, null, null, paint);
expect(way.colors?.[2]).toEqual({ line: '#111', casing: '#fff' });
expect(way.walking).toBeNull();
});
it('FE-TOUR-WAYS-010: without a highlight, or a route to lay it on, the lines stay as they came', () => {
const route = [LINE];
const colors = [{ line: '#00f', casing: '#fff' }];
expect(withRouteHighlight(null, route, [false], colors, paint)).toEqual({ route, walking: [false], colors });
expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route);
expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull();
});
});
@@ -0,0 +1,193 @@
import type { TourTrailGrade } from '@trek/shared';
import { decodePolyline } from '../../Map/transitGeometry';
/**
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
* description of the ways along its line (Valhalla's trace attributes): the
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
* Pure, so the classes and their shares are tested apart from the request.
*/
/** Surfaces from smooth to rough; `unknown` where the data names none. */
export const SURFACE_CLASSES = ['paved', 'rough', 'compacted', 'gravel', 'ground', 'unknown'] as const;
export type SurfaceClass = (typeof SURFACE_CLASSES)[number];
/** Kinds of way, the ones a rider plans by. */
export const WAY_CLASSES = ['cyclePath', 'cycleLane', 'quiet', 'road', 'track', 'path', 'ferry', 'other'] as const;
export type WayClass = (typeof WAY_CLASSES)[number];
/**
* mtb:scale steps as a rider reads them: S0 to S3 apart, S4 and up together
* (rare, and all of them expert ground); `none` where OpenStreetMap gives the
* way no grade, `unasked` where it could not be asked this time.
*/
export const TRAIL_CLASSES = ['s0', 's1', 's2', 's3', 's4', 'none', 'unasked'] as const;
export type TrailClass = (typeof TRAIL_CLASSES)[number];
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
export interface RouteWayEdge {
/** Where on the line the stretch starts and ends, as indices into `RouteWays.line`. */
from: number;
to: number;
lengthM: number;
surface: SurfaceClass;
way: WayClass;
/** The OpenStreetMap way, where the router names it. */
wayId: number | null;
}
export interface RouteWays {
/** The line as the router matched it, which the stretches index into. */
line: [number, number][];
edges: RouteWayEdge[];
}
const SURFACE_OF: Record<string, SurfaceClass> = {
paved_smooth: 'paved',
paved: 'paved',
paved_rough: 'rough',
compacted: 'compacted',
gravel: 'gravel',
dirt: 'ground',
path: 'ground',
};
const PATH_USES = new Set([
'path',
'footway',
'pedestrian',
'bridleway',
'mountain_bike',
'steps',
'sidewalk',
'pedestrian_crossing',
'elevator',
]);
const QUIET_ROAD_CLASSES = new Set(['residential', 'service_other', 'unclassified']);
function wayOf(use: string | undefined, roadClass: string | undefined, cycleLane: string | undefined): WayClass {
if (use === 'cycleway' || use === 'cycle_street') return 'cyclePath';
if (use === 'ferry' || use === 'rail_ferry') return 'ferry';
if (use === 'track') return 'track';
if (use && PATH_USES.has(use)) return 'path';
if (
use === 'living_street' ||
use === 'service_road' ||
use === 'driveway' ||
use === 'alley' ||
use === 'parking_aisle'
)
return 'quiet';
if (use === 'road') {
if (cycleLane === 'dedicated' || cycleLane === 'separated') return 'cycleLane';
return roadClass && QUIET_ROAD_CLASSES.has(roadClass) ? 'quiet' : 'road';
}
return 'other';
}
interface TraceEdge {
surface?: unknown;
use?: unknown;
road_class?: unknown;
cycle_lane?: unknown;
length?: unknown;
way_id?: unknown;
begin_shape_index?: unknown;
end_shape_index?: unknown;
}
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
/**
* The router's answer read into stretches, or null when it is no answer of
* that kind. Edge lengths come in kilometres; an edge without its place on the
* line is dropped rather than placed by guess.
*/
export function routeWaysFrom(answer: unknown): RouteWays | null {
const data = answer as { shape?: unknown; edges?: unknown } | null;
if (!data || typeof data.shape !== 'string' || !Array.isArray(data.edges)) return null;
const line = decodePolyline(data.shape, 6);
if (line.length < 2) return null;
const edges: RouteWayEdge[] = [];
for (const raw of data.edges as TraceEdge[]) {
const from = count(raw.begin_shape_index);
const to = count(raw.end_shape_index);
const km = count(raw.length);
if (from === null || to === null || km === null || to < from || to >= line.length) continue;
const surface = SURFACE_OF[text(raw.surface) ?? ''] ?? 'unknown';
const wayId = count(raw.way_id);
edges.push({
from,
to,
lengthM: km * 1000,
surface,
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
wayId: wayId && wayId > 0 ? wayId : null,
});
}
return edges.length ? { line, edges } : null;
}
/** A trail grade's class: the step of mtb:scale, S4 and up together. */
export function trailClassOf(grade: Pick<TourTrailGrade, 'mtb_scale'> | undefined): TrailClass {
const step = grade?.mtb_scale ? Number(grade.mtb_scale[0]) : null;
if (step === null || Number.isNaN(step)) return 'none';
return step >= 4 ? 's4' : (`s${step}` as TrailClass);
}
export interface ClassShare<C extends string> {
key: C;
lengthM: number;
/** Of the whole line, 0 to 1. */
share: number;
}
/** How much of the line falls in each class, the largest first; classes it has none of are left out. */
export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: RouteWayEdge) => C): ClassShare<C>[] {
const total = ways.edges.reduce((sum, edge) => sum + edge.lengthM, 0);
const sums = new Map<C, number>();
for (const edge of ways.edges) sums.set(classOf(edge), (sums.get(classOf(edge)) ?? 0) + edge.lengthM);
return [...sums]
.filter(([, lengthM]) => lengthM > 0)
.map(([key, lengthM]) => ({ key, lengthM, share: total > 0 ? lengthM / total : 0 }))
.sort((a, b) => b.lengthM - a.lengthM);
}
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
const stretches: [number, number][][] = [];
let open: { from: number; to: number } | null = null;
const close = () => {
if (open && open.to > open.from) stretches.push(ways.line.slice(open.from, open.to + 1));
open = null;
};
for (const edge of ways.edges) {
if (!inClass(edge)) {
close();
continue;
}
if (open && edge.from <= open.to) open.to = Math.max(open.to, edge.to);
else {
close();
open = { from: edge.from, to: edge.to };
}
}
close();
return stretches;
}
/** The ways along the line OpenStreetMap could grade, each once, in the order they are ridden. */
export function wayIdsOf(ways: RouteWays, limit: number): number[] {
const ids: number[] = [];
const seen = new Set<number>();
for (const edge of ways.edges) {
if (edge.wayId === null || seen.has(edge.wayId)) continue;
seen.add(edge.wayId);
ids.push(edge.wayId);
if (ids.length === limit) break;
}
return ids;
}
@@ -0,0 +1,22 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useHighlightedRoute } from './useHighlightedRoute';
const LINE: [number, number][] = [
[47, 11],
[47.01, 11],
];
describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => {
act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' }));
const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
expect(shown.result.current.route).toHaveLength(2);
expect(shown.result.current.colors?.[1]).toMatchObject({ line: expect.any(String), casing: expect.any(String) });
const hidden = renderHook(() => useHighlightedRoute(false, [LINE], null, null));
expect(hidden.result.current.route).toEqual([LINE]);
act(() => useTourRouteHighlightStore.getState().clear());
expect(shown.result.current.route).toEqual([LINE]);
});
});
@@ -0,0 +1,18 @@
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useBikePaint } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
type Lines = Parameters<typeof withRouteHighlight>[1];
type Walking = Parameters<typeof withRouteHighlight>[2];
type Colors = Parameters<typeof withRouteHighlight>[3];
/**
* BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's
* card points at laid over it, while `shown` (the route being edited is on
* the map); otherwise the lines as they came.
*/
export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) {
const highlight = useTourRouteHighlightStore((state) => state.highlight);
const paint = useBikePaint();
return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint);
}
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useRouteWays } from './useRouteWays';
const trace = vi.hoisted(() => ({ valhallaTraceAttributes: vi.fn() }));
const gradesRepo = vi.hoisted(() => ({ grades: vi.fn() }));
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../../Map/valhallaRoute', () => trace);
vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo }));
vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) }));
// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-008
const ROUTE: [number, number][] = [
[47.0, 11.0],
[47.002, 11.0],
[47.004, 11.0],
];
const ANSWER = {
shape: encodePolyline(ROUTE, 6),
edges: [
{ begin_shape_index: 0, end_shape_index: 1, length: 0.2, surface: 'gravel', use: 'track', way_id: 11 },
{ begin_shape_index: 1, end_shape_index: 2, length: 0.2, surface: 'dirt', use: 'path', way_id: 12 },
],
};
type Input = Parameters<typeof useRouteWays>[0];
const input = (overrides: Partial<Input> = {}): Input => ({
tripId: 3,
active: true,
route: ROUTE,
profile: 'touring',
...overrides,
});
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
}
beforeEach(() => {
vi.useFakeTimers();
network.offline = false;
trace.valhallaTraceAttributes.mockReset().mockResolvedValue(ANSWER);
gradesRepo.grades.mockReset();
});
afterEach(() => vi.useRealTimers());
describe('useRouteWays (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-HOOK-001: asks nothing while inactive or without a route of two points', () => {
for (const overrides of [{ active: false }, { route: null }, { route: ROUTE.slice(0, 1) }]) {
const { result } = renderHook(() => useRouteWays(input(overrides)));
expect(result.current.status).toBe('idle');
}
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-002: waits for the route to settle, then reads its ways, without grades for a touring bike', async () => {
const { result } = renderHook(() => useRouteWays(input()));
expect(result.current.status).toBe('loading');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal));
expect(result.current.status).toBe('ready');
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
expect(result.current.grades).toBeNull();
expect(gradesRepo.grades).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => {
gradesRepo.grades.mockResolvedValue({
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
unanswered: [12],
});
const { result } = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(gradesRepo.grades).toHaveBeenCalledWith(3, { way_ids: [11, 12] }, expect.any(AbortSignal));
expect(result.current.grades?.status).toBe('ready');
expect(result.current.grades?.byWay.get(11)?.mtb_scale).toBe('2');
expect([...(result.current.grades?.unasked ?? [])]).toEqual([12]);
});
it('FE-TOUR-WAYS-HOOK-004: grades that cannot be had say why: offline, or failed', async () => {
gradesRepo.grades.mockRejectedValueOnce(new OnlineOnlyError());
const offline = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(offline.result.current.status).toBe('ready');
expect(offline.result.current.grades?.status).toBe('offline');
offline.unmount();
gradesRepo.grades.mockRejectedValueOnce(new Error('overpass'));
const failed = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(failed.result.current.grades?.status).toBe('failed');
});
it('FE-TOUR-WAYS-HOOK-005: a route the router cannot describe fails, and retry asks again', async () => {
trace.valhallaTraceAttributes.mockResolvedValueOnce(null);
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('failed');
act(() => result.current.retry());
expect(result.current.status).toBe('loading');
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledTimes(2);
expect(result.current.status).toBe('ready');
});
it('FE-TOUR-WAYS-HOOK-006: an error on the way fails rather than leaving the card waiting', async () => {
trace.valhallaTraceAttributes.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('failed');
});
it('FE-TOUR-WAYS-HOOK-007: offline it says so and asks nothing', async () => {
network.offline = true;
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('offline');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
const { result, rerender, unmount } = renderHook((props: Input) => useRouteWays(props), { initialProps: input() });
await settle();
expect(result.current.status).toBe('ready');
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
rerender(input({ route: [...ROUTE, [47.006, 11.0]] }));
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
expect(result.current.status).toBe('loading');
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
unmount();
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
});
@@ -0,0 +1,111 @@
import { TOUR_TRAIL_GRADE_MAX_WAYS, type BikeProfile, type TourTrailGrade } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { valhallaTraceAttributes } from '../../Map/valhallaRoute';
import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays';
/** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */
const SETTLE_MS = 600;
export type RouteWaysStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'offline';
export interface TrailGrades {
status: 'loading' | 'ready' | 'failed' | 'offline';
byWay: ReadonlyMap<number, TourTrailGrade>;
/** Ways the data could not be asked about: their grade is unknown, not absent. */
unasked: ReadonlySet<number>;
}
export interface RouteWaysState {
status: RouteWaysStatus;
ways: RouteWays | null;
/** BIK-05, a mountain bike tour's only: the trail grades of its ways. */
grades: TrailGrades | null;
}
interface Answer extends RouteWaysState {
attempt: number;
/** The route and the question it answers; another one makes it stale. */
route: [number, number][];
withGrades: boolean;
}
const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null };
const NO_GRADES = { byWay: new Map<number, TourTrailGrade>(), unasked: new Set<number>() };
/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
async function askRouteWays(
tripId: number | string,
route: [number, number][],
withGrades: boolean,
signal: AbortSignal,
onWays: (ways: RouteWays) => void
): Promise<RouteWaysState> {
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal));
if (!ways) return { status: 'failed', ways: null, grades: null };
if (!withGrades) return { status: 'ready', ways, grades: null };
onWays(ways);
const ids = wayIdsOf(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
if (ids.length === 0) return { status: 'ready', ways, grades: { status: 'ready', ...NO_GRADES } };
try {
const answer = await tourTrailGradeRepo.grades(tripId, { way_ids: ids }, signal);
const byWay = new Map(answer.ways.map((grade) => [grade.id, grade]));
return { status: 'ready', ways, grades: { status: 'ready', byWay, unasked: new Set(answer.unanswered) } };
} catch (failure) {
const status = failure instanceof OnlineOnlyError ? 'offline' : 'failed';
return { status: 'ready', ways, grades: { status, ...NO_GRADES } };
}
}
/**
* BIK-01 to BIK-05: what the bike tour being planned runs on, asked of the
* router once its route has settled, and for a mountain bike tour the trail
* grades of its ways. A new route makes the answer on its way stale and takes
* the map's highlight down; nothing of it changes the tour.
*/
export function useRouteWays(input: {
tripId: number | string;
active: boolean;
route: [number, number][] | null;
profile: BikeProfile;
}): RouteWaysState & { retry: () => void } {
const { tripId, active, route, profile } = input;
const { offline } = useNetworkMode();
const [answer, setAnswer] = useState<Answer | null>(null);
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const withGrades = profile === 'mtb';
const asking = active && !offline && !!route && route.length >= 2;
useEffect(() => {
useTourRouteHighlightStore.getState().clear();
if (!asking || !route) return;
const controller = new AbortController();
const settle = (state: RouteWaysState) => {
if (!controller.signal.aborted) setAnswer({ ...state, route, withGrades, attempt });
};
const timer = window.setTimeout(() => {
askRouteWays(tripId, route, withGrades, controller.signal, (ways) =>
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
)
.then(settle)
// Whatever went wrong, the card says it failed and offers to ask again rather than wait for ever.
.catch(() => settle({ status: 'failed', ways: null, grades: null }));
}, SETTLE_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [asking, attempt, route, tripId, withGrades]);
// The highlight belongs to the route it was drawn on.
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
if (!active || !route || route.length < 2) return { ...IDLE, retry };
if (offline) return { status: 'offline', ways: null, grades: null, retry };
const current = answer?.route === route && answer.withGrades === withGrades && answer.attempt === attempt;
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
}
@@ -1,7 +1,8 @@
import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { routeWalkingTour } from '../planner/tourRouting';
import { isTourRoute } from '../planner/tourRouteOutcome';
import { routeBikeTour, routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
@@ -41,6 +42,8 @@ export function useGpxConversion(planner: TourPlannerController) {
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
// A paddle import is planned as a paddle tour is: every leg drawn by hand, no router asked.
const paddle = tour?.tour_type === 'kayak';
// BIK-01: a bike import is planned for its profile.
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
useEffect(() => () => abort.current?.abort(), []);
@@ -63,9 +66,11 @@ export function useGpxConversion(planner: TourPlannerController) {
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
}))
: points;
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
const routed = bikeProfile
? await routeBikeTour(legs, bikeProfile, controller.signal)
: await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!routed) {
if (!isTourRoute(routed)) {
setState({ ...base, status: 'failed', error: 'routing' });
return;
}
@@ -76,7 +81,7 @@ export function useGpxConversion(planner: TourPlannerController) {
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [difficulty, paddle, tour, track]);
}, [bikeProfile, difficulty, paddle, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
@@ -101,11 +106,12 @@ export function useGpxConversion(planner: TourPlannerController) {
inPlace: how === 'replace',
revision: tour.revision,
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
});
if (started) setState(IDLE);
return started;
},
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
[bikeProfile, current.points, current.status, difficulty, paddle, startDerivedTour, tour]
);
return useMemo(
@@ -1,4 +1,4 @@
import type { TourLibraryItem, TourListItem, TourTypeKey } from '@trek/shared';
import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
@@ -7,7 +7,6 @@ import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { BarButton } from '../../Planner/planParts';
import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
import {
DialogButton,
@@ -19,11 +18,10 @@ import {
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourDayRowPill } from '../TourDayRowFacts';
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
import { useTourLibrary, type TourLibrary } from './useTourLibrary';
import { type TourLibrary } from './useTourLibrary';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
@@ -141,7 +139,7 @@ function ListFilters({ library }: { library: TourLibrary }) {
];
const typeOptions = [
{ value: ALL, label: t('tours.library.allTypes') },
...(['hike', 'kayak'] as const).map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
];
return (
<div className="flex flex-col gap-2">
@@ -267,7 +265,8 @@ function Preview({ library }: { library: TourLibrary }) {
* list on the left, the chosen tour on the right; the copy is made only by the
* button in its foot.
*/
function TourLibraryDialog({ library }: { library: TourLibrary }) {
/** LIB-02: the dialog that takes a tour of another trip into this one, opened from "Add tour". */
export function TourLibraryDialog({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const labelId = useId();
if (!library.open) return null;
@@ -343,46 +342,3 @@ function TourLibraryDialog({ library }: { library: TourLibrary }) {
</DialogShell>
);
}
/** The rail head's way in, beside the GPX import: a button, and the dialog it opens. */
export function TourLibraryButton({ library, disabled }: { library: TourLibrary; disabled?: boolean }) {
const { t } = useTranslation();
return (
<>
<BarButton
label={t('tours.library.open')}
testId="tour-rail-library"
disabled={disabled}
onClick={library.show}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Library size={16} strokeWidth={2} />
</BarButton>
<TourLibraryDialog library={library} />
</>
);
}
/**
* The tours rail's way in, with the dialog's state: a tour taken over comes
* into the trip and opens in the editor, unless the editor holds unsaved work.
*/
export function TourLibraryEntry({
planner,
onTakenOver,
disabled,
}: {
planner: Pick<TourPlannerController, 'tripId' | 'hasUnsavedChanges' | 'openTour'>;
/** Where the tour goes into the trip; without it there is no way in. */
onTakenOver?: (tour: TourListItem) => Promise<void>;
disabled?: boolean;
}) {
const library = useTourLibrary({
tripId: Number(planner.tripId),
onTakenOver: async (tour) => {
await onTakenOver?.(tour);
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
},
});
return onTakenOver ? <TourLibraryButton library={library} disabled={disabled} /> : null;
}
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { TourLibraryButton } from './TourLibraryDialog';
import { TourLibraryDialog } from './TourLibraryDialog';
import { useTourLibrary } from './useTourLibrary';
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
@@ -149,7 +149,15 @@ describe('useTourLibrary', () => {
describe('TourLibraryDialog', () => {
function Harness({ onTakenOver = vi.fn() }: { onTakenOver?: () => void }) {
const library = useTourLibrary({ tripId: 5, onTakenOver });
return <TourLibraryButton library={library} />;
// The way in is the rail's Add tour menu (TourAddMenu); here a plain button stands in for it.
return (
<>
<button type="button" data-testid="tour-rail-library" onClick={library.show}>
open
</button>
<TourLibraryDialog library={library} />
</>
);
}
it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => {
@@ -1,5 +1,5 @@
import { HIKING_PREFERENCE_KEYS, type HikingPreferenceKey } from '@trek/shared';
import { ChartNoAxesColumnIncreasing, Footprints, Ruler, Ship } from 'lucide-react';
import { ChartNoAxesColumnIncreasing, Mountain, Ship } from 'lucide-react';
import type { ComponentType } from 'react';
import { useTranslation } from '../../../i18n';
import { BOX } from '../../Planner/bookings/bookingParts';
@@ -9,10 +9,9 @@ import { InfoDot } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
const ICON: Record<HikingPreferenceKey, ComponentType<{ size?: number; strokeWidth?: number; className?: string }>> = {
avoid_climbs: Mountain,
avoid_steps: ChartNoAxesColumnIncreasing,
avoid_ferries: Ship,
prefer_paths: Footprints,
shortest: Ruler,
};
/**
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import type { TourGpxImport } from '../useTourGpxImport';
import { TourAddMenu } from './TourAddMenu';
const library = vi.hoisted(() => ({ show: vi.fn(), options: null as unknown }));
vi.mock('../library/useTourLibrary', () => ({
useTourLibrary: (options: unknown) => {
library.options = options;
return { show: library.show };
},
}));
vi.mock('../library/TourLibraryDialog', () => ({ TourLibraryDialog: () => <div data-testid="library-dialog" /> }));
// FE-TOUR-ADD-001 to FE-TOUR-ADD-005
function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<string, unknown> = {}) {
return {
tripId: 5,
mode: { type: mode },
hasUnsavedChanges: false,
openTour: vi.fn(),
startNewTour: vi.fn(),
...overrides,
} as unknown as Parameters<typeof TourAddMenu>[0]['planner'];
}
const gpxImport = (importing = false) => ({ pick: vi.fn(), importing }) as unknown as TourGpxImport;
const entries = () => screen.getAllByRole('button').filter((button) => button.closest('[role="group"]'));
const open = () => fireEvent.click(screen.getByTestId('tour-rail-add'));
afterEach(() => vi.clearAllMocks());
describe('TourAddMenu (LIB-02)', () => {
it('FE-TOUR-ADD-001: one button opens the three ways to add a tour, in order', () => {
render(<TourAddMenu planner={planner()} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
const button = screen.getByTestId('tour-rail-add');
expect(button).toHaveAccessibleName('Add tour');
expect(button).toHaveAttribute('aria-expanded', 'false');
open();
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(entries().map((entry) => entry.textContent)).toEqual([
'Plan new tour',
'Import GPX',
'Take over a tour from another trip',
]);
});
it('FE-TOUR-ADD-002: each entry does its own thing and closes the menu', () => {
const p = planner();
const gpx = gpxImport();
render(<TourAddMenu planner={p} gpxImport={gpx} onTakenOver={vi.fn()} disabled={false} />);
open();
fireEvent.click(screen.getByText('Plan new tour'));
expect(p.startNewTour).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('group')).toBeNull();
open();
fireEvent.click(screen.getByText('Import GPX'));
expect(gpx.pick).toHaveBeenCalledTimes(1);
open();
fireEvent.click(screen.getByText('Take over a tour from another trip'));
expect(library.show).toHaveBeenCalledTimes(1);
});
it('FE-TOUR-ADD-003: with no tour open the map is already planning one, so that entry is left out', () => {
render(<TourAddMenu planner={planner('neutral')} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
open();
expect(entries().map((entry) => entry.textContent)).toEqual(['Import GPX', 'Take over a tour from another trip']);
});
it('FE-TOUR-ADD-004: a tour taken over comes in, and opens unless the editor holds unsaved work', async () => {
const onTakenOver = vi.fn().mockResolvedValue(undefined);
const p = planner();
const { rerender } = render(<TourAddMenu planner={p} onTakenOver={onTakenOver} disabled={false} />);
const take = (library.options as { onTakenOver: (tour: unknown) => Promise<void> }).onTakenOver;
await take({ place_id: 9 });
expect(onTakenOver).toHaveBeenCalledWith({ place_id: 9 });
expect(p.openTour).toHaveBeenCalledWith({ place_id: 9 });
const busy = planner('new-draft', { hasUnsavedChanges: true });
rerender(<TourAddMenu planner={busy} onTakenOver={onTakenOver} disabled={false} />);
await (library.options as { onTakenOver: (tour: unknown) => Promise<void> }).onTakenOver({ place_id: 10 });
expect(busy.openTour).not.toHaveBeenCalled();
expect(screen.getByTestId('library-dialog')).toBeInTheDocument();
});
it('FE-TOUR-ADD-005: waits while saving or importing, and is not there with nothing to offer', () => {
const { rerender } = render(<TourAddMenu planner={planner()} gpxImport={gpxImport(true)} disabled={false} />);
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
rerender(<TourAddMenu planner={planner()} gpxImport={gpxImport()} disabled />);
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
rerender(<TourAddMenu planner={planner('neutral')} disabled={false} />);
expect(screen.queryByTestId('tour-rail-add')).toBeNull();
expect(screen.queryByTestId('library-dialog')).toBeNull();
});
});
@@ -0,0 +1,111 @@
import type { TourListItem } from '@trek/shared';
import { FileDown, Library, Loader2, PenLine, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { PopoverItem } from '../../Packing/PackingPopover';
import { BarButton } from '../../Planner/planParts';
import { PopoverListPanel } from '../../shared/dialogParts';
import { usePopoverList } from '../../shared/usePopoverList';
import { TourLibraryDialog } from '../library/TourLibraryDialog';
import { useTourLibrary } from '../library/useTourLibrary';
import type { TourGpxImport } from '../useTourGpxImport';
import type { TourPlannerController } from './useTourPlanner';
interface AddEntry {
key: string;
icon: ReactNode;
label: string;
run: () => void;
}
/**
* LIB-02: the tours rail's one way to add a tour, as a menu: plan a new one
* (while another is open; with none open the map is already planning one),
* import GPX files, or take one over from another trip, whose dialog opens
* from here. A tour taken over comes into the trip and opens in the editor,
* unless the editor holds unsaved work.
*/
export function TourAddMenu({
planner,
gpxImport,
onTakenOver,
disabled,
}: {
planner: Pick<TourPlannerController, 'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour'>;
gpxImport?: TourGpxImport;
/** Where a tour of another trip goes into this one; without it that entry is left out. */
onTakenOver?: (tour: TourListItem) => Promise<void>;
disabled: boolean;
}) {
const { t } = useTranslation();
const list = usePopoverList(0, { estimatedHeight: 140 });
const library = useTourLibrary({
tripId: Number(planner.tripId),
onTakenOver: async (tour) => {
await onTakenOver?.(tour);
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
},
});
const importing = gpxImport?.importing ?? false;
const entries: AddEntry[] = [];
if (planner.mode.type !== 'neutral') {
entries.push({
key: 'new',
icon: <PenLine size={14} strokeWidth={2} />,
label: t('tours.planner.newTour'),
run: () => planner.startNewTour(),
});
}
if (gpxImport) {
entries.push({
key: 'gpx',
icon: <FileDown size={14} strokeWidth={2} />,
label: t('tours.import.button'),
run: gpxImport.pick,
});
}
if (onTakenOver) {
entries.push({
key: 'library',
icon: <Library size={14} strokeWidth={2} />,
label: t('tours.library.open'),
run: library.show,
});
}
if (entries.length === 0) return null;
// Run inside the click that picked it: the file picker opens only from a user's own tap.
const pick = (entry: AddEntry) => {
list.close();
entry.run();
};
return (
<>
<BarButton
ref={list.triggerRef}
label={t('tours.planner.addTour')}
tooltip={t('tours.planner.addTourHint')}
testId="tour-rail-add"
disabled={disabled || importing}
onClick={list.toggle}
ariaExpanded={list.open}
active={list.open}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{importing ? (
<Loader2 size={15} strokeWidth={2} className="animate-spin" />
) : (
<Plus size={16} strokeWidth={2} />
)}
</BarButton>
<PopoverListPanel list={list} label={t('tours.planner.addTour')} className="min-w-[240px]">
{entries.map((entry) => (
<PopoverItem key={entry.key} icon={entry.icon} label={entry.label} onClick={() => pick(entry)} />
))}
</PopoverListPanel>
{onTakenOver && <TourLibraryDialog library={library} />}
</>
);
}
@@ -74,7 +74,13 @@ export function TourNameField({ planner, locked }: TourFieldProps) {
if (!locked) planner.setName(event.target.value);
}}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
placeholder={
{
hike: t('tours.planner.namePlaceholder'),
bike: t('tours.planner.namePlaceholder.bike'),
kayak: t('tours.planner.namePlaceholder.kayak'),
}[planner.tourType]
}
className={INPUT}
/>
</div>
@@ -350,7 +356,7 @@ export function TourDetailsSection({
<TourDescriptionField planner={planner} locked={locked} />
<TourWebsiteField planner={planner} locked={locked} />
{/* A paddle tour is not graded on the hiking scale. */}
{!planner.isPaddle && (
{planner.tourType === 'hike' && (
<>
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
<HikingPreferencesField planner={planner} locked={locked} />
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowLeft, Circle, FileDown, History, Loader2, Mountain, Plus, Redo2, RotateCcw, Route, Save, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Circle, History, Loader2, MapPin, Mountain, Plus, Redo2, RotateCcw, Route, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -36,6 +36,7 @@ import TourConversionSection from '../conversion/TourConversionSection'
import type { GpxConversion } from '../conversion/useGpxConversion'
import type { RecordedTrack } from '../useRecordedTrack'
import type { TourGpxImport } from '../useTourGpxImport'
import { TourAddMenu } from './TourAddMenu'
import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignment'
import WandererMark from '../wanderer/WandererMark'
import { TourGpxExportBarButton } from '../TourGpxExportButton'
@@ -43,8 +44,8 @@ import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisi
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
import { TourScopeSummary } from '../TourScopeSummary'
import { useTourListFilters } from '../useTourListFilters'
import { TourLibraryEntry } from '../library/TourLibraryDialog'
import { TourPoiSearchCard } from '../poi/TourPoiSearchCard'
import { BikeWaysCard } from '../bike/BikeWaysCard'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
dirty: 'tours.planner.status.dirty',
@@ -131,13 +132,15 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
function retryable(planner: TourPlannerController): boolean {
if (planner.error === 'paddle-route') return false
// A point no way reaches stays unreachable until it moves; asking again changes nothing.
if (planner.error === 'paddle-route' || planner.error === 'unreachable') return false
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
}
/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */
function statusText(planner: TourPlannerController, key: string, t: Translate): string {
if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 })
if (planner.unreachable) return t('tours.planner.status.unreachable', { point: planner.unreachable.unreachable + 1 })
return t(key)
}
@@ -275,8 +278,8 @@ function GpxViewRail({
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit)
if (planner.mode.type !== 'view-gpx') return null
const { readOnlyGpxTour } = planner
if (planner.mode.type !== 'view-gpx' || !readOnlyGpxTour) return null
const { tour } = readOnlyGpxTour
const fromWanderer = tourSource(tour) === 'wanderer'
const analysis = planner.readOnlyGpxAnalysis
@@ -440,8 +443,12 @@ export function TourPlannerRail({ planner,
{recordedTrack?.available && <RecordedTrackCard recordedTrack={recordedTrack} disabled={isSaving} />}
<UnreachableNotice planner={planner} />
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} tourPlaceIds={tourPlaceIds} />
<BikeWaysCard planner={planner} />
<TourPoiSearchCard planner={planner} />
<PaddleEditorCards planner={planner} locked={locked} checklist={paddleChecklist} />
@@ -586,23 +593,6 @@ function NewTourConfirmDialog({ planner, canEdit }: { planner: TourPlannerContro
)
}
/** The GPX import in the tours rail's head, an icon like the editor's; the file drop on the workspace does the same. */
function RailImportButton({ gpxImport, disabled }: { gpxImport: TourGpxImport; disabled: boolean }) {
const { t } = useTranslation()
return (
<BarButton
label={t('tours.import.button')}
tooltip={t('tours.import.hint')}
testId="tour-rail-import-gpx"
disabled={gpxImport.importing || disabled}
onClick={gpxImport.pick}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
{gpxImport.importing ? <Loader2 size={15} strokeWidth={2} className="animate-spin" /> : <FileDown size={15} strokeWidth={2} />}
</BarButton>
)
}
interface TourPlannerToursRailProps extends TourPermissionProps {
planner: TourPlannerController
tours: TourListItem[]
@@ -611,26 +601,55 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
onAssignToDay: AssignTourToDay
onViewGpxTour: (tour: TourListItem) => void
onDeleteTour?: (placeId: number) => void
/** The workspace's GPX import; its button sits beside "New tour". */
/** The workspace's GPX import, an entry of "Add tour". */
gpxImport?: TourGpxImport
/** LIB-02: takes a tour of another trip in; its button sits beside the import. */
/** LIB-02: takes a tour of another trip in, an entry of "Add tour". */
onTakenOver?: (tour: TourListItem) => Promise<void>
}
/** The time facts of the overview: the estimate (walking, or paddling for a paddle tour), the breaks and the planned total. */
/** The words of the time estimate: walking, riding (BIK-01) or paddling, each saying how it is reckoned. */
const ESTIMATE_KEYS = {
hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' },
bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' },
kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' },
} as const
/**
* A point no way of the tour's type reaches: the router would have bound it
* that far off, so no route is drawn to a place the tour does not go. The
* notice names the point and what helps; the last route that was planned stays.
*/
function UnreachableNotice({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const point = planner.unreachable
if (!point) return null
const select = () => planner.setSelectedWaypointId(planner.waypoints[point.unreachable]?.id ?? null)
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-col items-start gap-1.5">
<span data-testid="tour-planner-unreachable">
{t(planner.isBike ? 'tours.planner.unreachable.bike' : 'tours.planner.unreachable.hike', {
point: point.unreachable + 1,
distance: formatDistance(point.snappedMeters / 1000, distanceUnit),
})}
</span>
<CardAction icon={<MapPin size={12} strokeWidth={2} />} onClick={select}>
{t('tours.planner.unreachable.show', { point: point.unreachable + 1 })}
</CardAction>
</span>
</TourNotice>
)
}
/** The time facts of the overview: the estimate (walking, riding or paddling), the breaks and the planned total. */
function plannerTimeFields(planner: TourPlannerController, t: Translate) {
const keys = ESTIMATE_KEYS[planner.tourType]
return [
planner.isPaddle ? {
label: t('tours.paddle.timeEstimated'),
shortLabel: t('tours.paddle.timeEstimatedShort'),
info: { label: t('tours.paddle.timeEstimatedInfoLabel'), description: t('tours.paddle.timeEstimatedInfo') },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
} : {
label: t('tours.planner.walkingEstimated'),
shortLabel: t('tours.planner.walkingEstimatedShort'),
info: { label: t('tours.planner.walkingEstimatedInfoLabel'), description: t('tours.planner.walkingEstimatedInfo') },
{
label: t(keys.labelKey),
shortLabel: t(keys.shortKey),
info: { label: t(keys.infoLabelKey), description: t(keys.infoKey) },
value: planner.walkingDurationMinutes == null
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
@@ -698,15 +717,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && gpxImport && <RailImportButton gpxImport={gpxImport} disabled={isSaving} />}
{canEdit && <TourLibraryEntry planner={planner} onTakenOver={onTakenOver} disabled={isSaving} />}
{canEdit && planner.mode.type !== 'neutral' && (
<BarButton label={t('tours.planner.newTour')} testId="tour-rail-new-tour" disabled={isSaving}
onClick={() => { if (!isSaving) planner.startNewTour() }}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<Plus size={16} strokeWidth={2} />
</BarButton>
)}
{canEdit && <TourAddMenu planner={planner} gpxImport={gpxImport} onTakenOver={onTakenOver} disabled={isSaving} />}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
@@ -21,10 +21,39 @@ describe('TourTypeFields (PAD-01)', () => {
const hike = planner({ tourType: 'hike', isPaddle: false });
render(<TourTypeFields planner={hike} locked={false} />);
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Paddle tour/ }));
expect(screen.queryByTestId('tour-planner-bike-fields')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-type-select'));
fireEvent.click(screen.getByTestId('tour-type-option-kayak'));
expect(hike.setTourType).toHaveBeenCalledWith('kayak');
});
it('BIK-01: the type is one field that lists every type with what it plans, the one chosen marked', () => {
const hike = planner({ tourType: 'hike', isPaddle: false });
render(<TourTypeFields planner={hike} locked={false} />);
const field = screen.getByTestId('tour-type-select');
expect(field).toHaveTextContent('Hike');
expect(field).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(field);
expect(field).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('tour-type-option-hike')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('tour-type-option-bike')).toHaveTextContent(
'By bike, routed for touring, road, gravel or MTB'
);
fireEvent.click(screen.getByTestId('tour-type-option-bike'));
expect(hike.setTourType).toHaveBeenCalledWith('bike');
expect(field).toHaveAttribute('aria-expanded', 'false');
});
it('BIK-01 to BIK-04: a bike tour names its profile like a boat, with what the profile asks for', () => {
const bike = planner({ tourType: 'bike', isPaddle: false, isBike: true, bikeProfile: 'gravel' });
render(<TourTypeFields planner={bike} locked={false} />);
expect(screen.queryByTestId('tour-planner-paddle-fields')).not.toBeInTheDocument();
const fields = screen.getByTestId('tour-planner-bike-fields');
expect(fields).toHaveTextContent('Takes gravel and farm tracks, not technical singletrails.');
fireEvent.click(screen.getByRole('button', { name: 'MTB' }));
expect(bike.setBikeProfile).toHaveBeenCalledWith('mtb');
});
it('a paddle tour asks for exactly one boat and its water, and says so until both are chosen', () => {
const paddleTour = planner({ paddleIncomplete: true });
render(<TourTypeFields planner={paddleTour} locked={false} />);
@@ -1,10 +1,11 @@
import {
BIKE_PROFILES,
MAX_PADDLE_SPEED_KMH,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
} from '@trek/shared';
import { Info, Mountain, Waves } from 'lucide-react';
import { Info } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
@@ -13,6 +14,7 @@ import { fs } from '../../shared/DialogShell';
import { crossesLake } from '../paddle/lakeStyleAdvice';
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
import { TourNotice } from '../tourParts';
import { TourTypeSelect } from './TourTypeSelect';
import type { TourPlannerController } from './useTourPlanner';
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
@@ -78,10 +80,34 @@ function PaddleSpeedInput({ planner, locked }: TourTypeFieldsProps) {
}
/**
* PAD-01: what the tour is. A hike keeps its fields where they were; a paddle
* tour asks for exactly one boat and the water it is on, and offers the
* boat's planning speed beside the paddler's own. Nothing here routes the
* tour or puts it on a day.
* BIK-01 to BIK-04: what a bike tour is ridden on, the way a paddle tour names
* its boat, with a line on what the profile asks the router for. A profile
* plans the route again; it says nothing about whether a way can be ridden.
*/
function BikeProfileField({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation();
return (
<div data-testid="tour-planner-bike-fields" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={planner.bikeProfile}
onChange={planner.setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((profile) => ({ value: profile, label: t(`tours.bike.profile.${profile}`) }))}
/>
<span className="mt-1.5 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.bike.profileHint.${planner.bikeProfile}`)}
</span>
</div>
);
}
/**
* PAD-01, BIK-01: what the tour is. A hike keeps its fields where they were; a
* bike tour names its profile; a paddle tour asks for exactly one boat and the
* water it is on, and offers the boat's planning speed beside the paddler's
* own. Nothing here puts the tour on a day.
*/
export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
const { t } = useTranslation();
@@ -94,18 +120,16 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
<span className={LABEL} id="tour-planner-type-label">
{t('tours.planner.tourType')}
</span>
<Segmented<PlannableTourType>
<TourTypeSelect
value={planner.tourType}
onChange={planner.setTourType}
label={t('tours.planner.tourType')}
fill
options={[
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
]}
disabled={locked}
labelId="tour-planner-type-label"
/>
</div>
{planner.isBike && <BikeProfileField planner={planner} />}
{planner.isPaddle && (
<div data-testid="tour-planner-paddle-fields" className="flex min-w-0 flex-col gap-3">
<div className="min-w-0">
@@ -0,0 +1,110 @@
import { plannableTourTypeSchema, type PlannableTourType } from '@trek/shared';
import { Check, ChevronDown } from 'lucide-react';
import { createElement, useId } from 'react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { PopoverListPanel } from '../../shared/dialogParts';
import { usePopoverList } from '../../shared/usePopoverList';
import { tourDayRowIcon } from '../tourDayRowIcon';
/** The types the planner offers, in the order the contract lists them. */
const PLANNER_TOUR_TYPES = plannableTourTypeSchema.options;
function TypeTile({ type, active = false }: { type: PlannableTourType; active?: boolean }) {
return (
<span
aria-hidden="true"
className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${active ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-secondary'}`}
>
{createElement(tourDayRowIcon(type), { size: 15, strokeWidth: 2 })}
</span>
);
}
/**
* What the tour is, as one field that opens the types with what each one
* plans: a hike on footpaths within its limit, a bike tour on its profile, a
* paddle tour on the water. Room for the types still to come without the row
* of buttons it was growing too long for.
*/
export function TourTypeSelect({
value,
onChange,
disabled = false,
labelId,
}: {
value: PlannableTourType;
onChange: (type: PlannableTourType) => void;
disabled?: boolean;
/** The label over the field, read before the type chosen. */
labelId: string;
}) {
const { t } = useTranslation();
const valueId = useId();
const list = usePopoverList(PLANNER_TOUR_TYPES.indexOf(value), { estimatedHeight: 220 });
const { open, toggle, triggerRef, listId } = list;
const label = t('tours.planner.tourType');
const pick = (type: PlannableTourType) => {
list.close();
onChange(type);
};
return (
<>
<button
ref={triggerRef}
type="button"
data-testid="tour-type-select"
onClick={toggle}
disabled={disabled}
aria-expanded={open}
aria-controls={open ? listId : undefined}
aria-labelledby={`${labelId} ${valueId}`}
className="flex w-full min-w-0 items-center gap-2.5 rounded-[10px] border border-edge bg-surface-input py-1.5 pe-3 ps-1.5 text-start hover:border-content-faint disabled:cursor-default disabled:opacity-50"
>
<TypeTile type={value} />
<span id={valueId} className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
{t(`tourTypes.${value}`)}
</span>
<span className="block truncate text-content-faint" style={fs(11, 'body')}>
{t(`tours.planner.tourTypeHint.${value}`)}
</span>
</span>
<ChevronDown
size={14}
strokeWidth={2.2}
aria-hidden="true"
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
<PopoverListPanel list={list} label={label} className="flex min-w-[260px] flex-col gap-0.5">
{PLANNER_TOUR_TYPES.map((type) => {
const active = type === value;
return (
<button
key={type}
type="button"
data-testid={`tour-type-option-${type}`}
aria-pressed={active}
onClick={() => pick(type)}
className={`flex w-full items-center gap-2.5 rounded-[9px] px-2 py-1.5 text-start hover:bg-surface-tertiary focus-visible:bg-surface-tertiary focus-visible:outline-none ${active ? 'bg-surface-tertiary' : ''}`}
>
<TypeTile type={type} active={active} />
<span className="min-w-0 flex-1">
<span className="block font-semibold text-content" style={fs(12.5, 'body')}>
{t(`tourTypes.${type}`)}
</span>
<span className="block text-content-muted" style={fs(11, 'body')}>
{t(`tours.planner.tourTypeHint.${type}`)}
</span>
</span>
{active && (
<Check size={14} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-muted" />
)}
</button>
);
})}
</PopoverListPanel>
</>
);
}
@@ -3,42 +3,46 @@ import { describe, expect, it, vi } from 'vitest';
import { HikingPreferencesField } from './HikingPreferencesField';
import { hikingCostingOptions } from './tourRouting';
describe('hikingCostingOptions (MF-08)', () => {
describe('hikingCostingOptions (MF-08, RTE-01)', () => {
it('asks the router only for what was switched on, in the options it was checked with', () => {
expect(hikingCostingOptions(null)).toEqual({});
expect(hikingCostingOptions({ avoid_steps: false })).toEqual({});
expect(
hikingCostingOptions({ avoid_steps: true, avoid_ferries: true, prefer_paths: true, shortest: true })
).toEqual({ step_penalty: 600, use_ferry: 0, walkway_factor: 0.5, shortest: true });
expect(hikingCostingOptions({ avoid_climbs: true, avoid_steps: true, avoid_ferries: true })).toEqual({
use_hills: 0.2,
step_penalty: 600,
use_ferry: 0,
});
});
});
describe('HikingPreferencesField (MF-08)', () => {
it('shows each checked preference with what it does, and switching one keeps the others', () => {
describe('HikingPreferencesField (MF-08, RTE-01)', () => {
it('shows the three preferences in order with what each does, and switching one keeps the others', () => {
const setHikingPreferences = vi.fn();
render(
<HikingPreferencesField
planner={{ hikingPreferences: { prefer_paths: true }, setHikingPreferences }}
planner={{ hikingPreferences: { avoid_climbs: true }, setHikingPreferences }}
locked={false}
/>
);
const names = ['avoid_climbs', 'avoid_steps', 'avoid_ferries'].map((key) => `tours.preferences.${key}`);
const switches = screen.getAllByRole('button').filter((button) => button.hasAttribute('aria-pressed'));
expect(switches.map((button) => button.getAttribute('aria-label'))).toEqual(names);
const steps = screen.getByRole('button', { name: 'tours.preferences.avoid_steps' });
expect(steps).toHaveAttribute('aria-pressed', 'false');
expect(steps).toHaveAccessibleDescription('tours.preferences.avoid_stepsHint');
expect(screen.getByRole('button', { name: 'tours.preferences.prefer_paths' })).toHaveAttribute(
'aria-pressed',
'true'
);
const climbs = screen.getByRole('button', { name: 'tours.preferences.avoid_climbs' });
expect(climbs).toHaveAttribute('aria-pressed', 'true');
expect(climbs).toHaveAccessibleDescription('tours.preferences.avoid_climbsHint');
fireEvent.click(steps);
expect(setHikingPreferences).toHaveBeenCalledWith({ prefer_paths: true, avoid_steps: true });
fireEvent.click(screen.getByRole('button', { name: 'tours.preferences.prefer_paths' }));
expect(setHikingPreferences).toHaveBeenLastCalledWith({ prefer_paths: false });
expect(setHikingPreferences).toHaveBeenCalledWith({ avoid_climbs: true, avoid_steps: true });
fireEvent.click(climbs);
expect(setHikingPreferences).toHaveBeenLastCalledWith({ avoid_climbs: false });
});
it('changes nothing while the tour is locked', () => {
const setHikingPreferences = vi.fn();
render(<HikingPreferencesField planner={{ hikingPreferences: null, setHikingPreferences }} locked />);
fireEvent.click(screen.getByRole('button', { name: 'tours.preferences.shortest' }));
fireEvent.click(screen.getByRole('button', { name: 'tours.preferences.avoid_climbs' }));
expect(setHikingPreferences).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,10 @@
import {
bikeProfileSchema,
hikingPreferencesSchema,
MAX_PLANNED_TOUR_DURATION_MINUTES,
paddleLockSchema,
plannableTourTypeSchema,
type BikeProfile,
type HikingPreferences,
type PlannableTourType,
type TourMaxHikingDifficulty,
@@ -17,7 +19,7 @@ import type { PaddleFieldValues } from './usePaddleFields';
* half read.
*/
export interface DraftPayload {
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
name: string;
description?: string;
website?: string;
@@ -34,9 +36,11 @@ export interface DraftPayload {
baseRevision?: number | null;
/** MF-08: what the hike asks the router for besides its limit. */
hikingPreferences?: HikingPreferences | null;
/** BIK-01: the profile a bike tour is routed for. */
bikeProfile?: BikeProfile;
}
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function optionalString(value: unknown): boolean {
return value === undefined || typeof value === 'string';
@@ -70,6 +74,16 @@ function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
);
}
/** The type of a draft and what its route is planned by, each one absent in a draft older than it. */
function validRouteChoices(draft: Partial<DraftPayload>): boolean {
return (
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
(draft.bikeProfile === undefined || bikeProfileSchema.safeParse(draft.bikeProfile).success) &&
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
optionalDifficulty(draft.maxHikingDifficulty)
);
}
export function validDraft(value: unknown): value is DraftPayload {
if (!value || typeof value !== 'object') return false;
const draft = value as Partial<DraftPayload>;
@@ -84,9 +98,7 @@ export function validDraft(value: unknown): value is DraftPayload {
optionalPlaceId(draft.editingPlaceId) &&
optionalPlaceId(draft.derivedFromPlaceId) &&
optionalPlaceId(draft.baseRevision) &&
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
optionalDifficulty(draft.maxHikingDifficulty) &&
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
validRouteChoices(draft) &&
Array.isArray(draft.waypoints) &&
draft.waypoints.every(validDraftWaypoint)
);
@@ -1,6 +1,8 @@
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannableTourTypeSchema,
tourWebsiteSchema,
type BikeProfile,
type HikingPreferences,
type PaddleBoatType,
type PaddleLakeStyle,
@@ -22,7 +24,9 @@ export function openedTourFields(tour: TourListItem) {
breakAdditionalMinutes: tour.break_additional_minutes ?? null,
difficulty: tour.max_hiking_difficulty ?? 2,
preferences: tour.hiking_preferences ?? null,
tourType: (tour.tour_type === 'kayak' ? 'kayak' : 'hike') as PlannableTourType,
// A type nobody can plan (an import of another kind) opens as a hike, as it always has.
tourType: plannableTourTypeSchema.catch('hike').parse(tour.tour_type) as PlannableTourType,
bikeProfile: (tour.bike_profile ?? 'touring') as BikeProfile,
};
}
@@ -32,6 +36,8 @@ export interface TourPlannerFactsInput {
maxHikingDifficulty: TourMaxHikingDifficulty;
/** MF-08: what the hike asks the router for besides its limit. */
hikingPreferences: HikingPreferences | null;
/** BIK-01: the profile a bike tour is routed for. */
bikeProfile: BikeProfile;
website: string;
plannedDurationMinutes: number | null;
breakAdditionalMinutes: number | null;
@@ -46,7 +52,7 @@ export interface TourPlannerFactsInput {
/** A paddle tour's estimate on the water; null for a hike. */
paddleEstimate: { waterMinutes: number | null; estimateMinutes: number | null } | null;
routeAnalysis: RouteGeometryAnalysis | null;
/** The router's own time, seconds; stands in for a hike until the heights are in. */
/** The router's own time, seconds: a bike tour's riding time; stands in for a hike until the heights are in. */
durationSeconds: number | null;
}
@@ -54,28 +60,63 @@ function invalidMinutes(value: number | null): boolean {
return value !== null && (!Number.isInteger(value) || value < 0 || value > MAX_PLANNED_TOUR_DURATION_MINUTES);
}
/**
* The time on the move: a paddle tour on the water at its speed, a bike tour
* by the router, which rides the profile at its speed and weighs the climbs, a
* hike by the signposts' rule over distance, climb and descent once the route
* has its heights, and by the router's own time before that.
*/
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
const { paddleEstimate, routeAnalysis } = input;
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
if (input.tourType === 'bike') return routerMinutes;
return routeAnalysis?.gain != null && routeAnalysis.loss != null
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
: routerMinutes;
}
/** What the type adds to a save: a hike its routing limit, a bike tour its profile, a paddle tour its boat, water and speed. */
function typeFieldsOf(input: TourPlannerFactsInput) {
const isBike = input.tourType === 'bike';
if (input.tourType === 'kayak') {
const { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle } = input.paddle;
return {
tour_type: input.tourType,
max_hiking_difficulty: null,
bike_profile: null,
boat_type: boatType,
water_context: waterContext,
paddle_speed_kmh: speedKmh,
portage_speed_kmh: portageSpeedKmh,
paddle_lake_style: lakeStyle,
hiking_preferences: null,
};
}
return {
tour_type: input.tourType,
max_hiking_difficulty: isBike ? null : input.maxHikingDifficulty,
bike_profile: isBike ? input.bikeProfile : null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
portage_speed_kmh: null,
paddle_lake_style: null,
hiking_preferences: isBike ? null : input.hikingPreferences,
};
}
/**
* What the planner derives from its draft, worked out in one place: whether
* the fields hold, the times, and the fields the tour type adds to a save.
* A paddle tour is timed on the water at its speed; a hike by the signposts'
* rule over distance, climb and descent once the route has its heights, and
* by the router's own time before that.
*/
export function tourPlannerFacts(input: TourPlannerFactsInput) {
const isPaddle = input.tourType === 'kayak';
const { paddleEstimate, routeAnalysis, durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input;
const walkingDurationMinutes = paddleEstimate
? paddleEstimate.waterMinutes === null
? null
: paddleEstimate.estimateMinutes
: routeAnalysis?.gain != null && routeAnalysis.loss != null
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
: durationSeconds === null
? null
: Math.round(durationSeconds / 60);
const { durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input;
const routerMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60);
const walkingDurationMinutes = movingMinutes(input, routerMinutes);
const automaticPlannedTotalMinutes =
walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0);
const { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle } = input.paddle;
const { boatType, waterContext } = input.paddle;
return {
isPaddle,
websiteInvalid: input.website.trim().length > 0 && !tourWebsiteSchema.safeParse(input.website).success,
@@ -92,27 +133,6 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
savedDurationSeconds:
walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60,
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
typeFields: isPaddle
? {
tour_type: input.tourType,
max_hiking_difficulty: null,
boat_type: boatType,
water_context: waterContext,
paddle_speed_kmh: speedKmh,
portage_speed_kmh: portageSpeedKmh,
paddle_lake_style: lakeStyle,
hiking_preferences: null,
}
: {
tour_type: input.tourType,
max_hiking_difficulty: input.maxHikingDifficulty,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
portage_speed_kmh: null,
paddle_lake_style: null,
hiking_preferences: input.hikingPreferences,
},
typeFields: typeFieldsOf(input),
};
}
@@ -8,6 +8,7 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsI
tourType: 'hike',
maxHikingDifficulty: 3,
hikingPreferences: null,
bikeProfile: 'touring',
website: '',
plannedDurationMinutes: null,
breakAdditionalMinutes: null,
@@ -20,6 +21,28 @@ const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsI
});
describe('tourPlannerFacts', () => {
it('times a bike tour by the router even with its heights in, and saves its profile and nothing of a hike', () => {
const analysis = { distanceKm: 30, gain: 400, loss: 400 } as TourPlannerFactsInput['routeAnalysis'];
const facts = tourPlannerFacts(
hike({
tourType: 'bike',
bikeProfile: 'gravel',
routeAnalysis: analysis,
durationSeconds: 5_400,
hikingPreferences: { avoid_steps: true },
})
);
expect(facts.walkingDurationMinutes).toBe(90);
expect(facts.typeFields).toMatchObject({
tour_type: 'bike',
bike_profile: 'gravel',
max_hiking_difficulty: null,
hiking_preferences: null,
boat_type: null,
});
expect(tourPlannerFacts(hike()).typeFields).toMatchObject({ bike_profile: null, max_hiking_difficulty: 3 });
});
it('times a hike by the router until its heights are in, then by the signposts, breaks on top', () => {
expect(tourPlannerFacts(hike({ durationSeconds: 3_570 })).walkingDurationMinutes).toBe(60);
const analysis = { distanceKm: 8, gain: 600, loss: 600 } as TourPlannerFactsInput['routeAnalysis'];
@@ -63,6 +63,13 @@ export function unlinkedPoint<T extends TourPlannerWaypoint>(point: T): T {
}
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
/** BIK-01: a bike tour rides no via ferrata; a leg that followed one is planned by the router again. */
export function ridablePoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.some((point) => point.fixedLeg?.kind === 'ferrata')
? points.map((point) => (point.fixedLeg?.kind === 'ferrata' ? { ...point, fixedLeg: null } : point))
: points;
}
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
...point,
@@ -0,0 +1,20 @@
import type { TourRouteResult } from './tourRouting';
/**
* A point the router found no way for at all: it bound the point to a way
* this far off (or put the whole run on one spot), which would draw a route
* that is not where the tour was planned. `unreachable` is the point's place
* along the tour, counted from 0.
*/
export interface TourRouteUnreachable {
unreachable: number;
snappedMeters: number;
}
/** Whether the router's answer is a route, rather than nothing or a point it could not reach. */
export function isTourRoute(value: TourRouteResult | TourRouteUnreachable | null): value is TourRouteResult {
return !!value && !('unreachable' in value);
}
/** How far the router may move a point onto its way before the point counts as unreachable for the tour's type. */
export const MAX_TOUR_SNAP_M = 300;
@@ -12,7 +12,10 @@ vi.mock('../../Map/valhallaRoute', () => ({
valhallaTurn,
}))
import { enrichTourElevations, parseValhallaElevation, routeWalkingTour } from './tourRouting'
import { enrichTourElevations, parseValhallaElevation, routeBikeTour, routeWalkingTour, type TourRouteResult } from './tourRouting'
/** A route as the planner reads it, for the cases that expect one. */
const route = async (...args: Parameters<typeof routeWalkingTour>) => (await routeWalkingTour(...args)) as TourRouteResult | null
const waypoints = [
{ lat: 47.3822621, lng: 10.9888935 },
@@ -36,12 +39,12 @@ describe('routeWalkingTour', () => {
})
it('TOUR-ROUTING-030: asks the router for the preferences of a hike beside its limit (MF-08)', async () => {
await routeWalkingTour(waypoints, undefined, 3, { avoid_steps: true, shortest: true })
await routeWalkingTour(waypoints, undefined, 3, { avoid_climbs: true, avoid_steps: true })
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {
max_hiking_difficulty: 3,
use_hills: 0.2,
step_penalty: 600,
shortest: true,
})
})
@@ -53,10 +56,10 @@ describe('routeWalkingTour', () => {
geometry: [[47.39, 11.01], [47.391, 11.011], [47.392, 11.012]] as [number, number][],
}
valhallaRun
.mockResolvedValueOnce({ total: { coordinates: [[47.38, 11.0], [47.39, 11.01]], distance: 1500, duration: 1200 } })
.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.4, 11.02]], distance: 1000, duration: 800 } })
.mockResolvedValueOnce({ total: { coordinates: [[47.38, 11.0], [47.39, 11.01]], distance: 1500, duration: 1200 }, snapped: [] })
.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.4, 11.02]], distance: 1000, duration: 800 }, snapped: [] })
const routed = await routeWalkingTour([
const routed = await route([
{ lat: 47.38, lng: 11.0 },
{ lat: 47.39, lng: 11.01, fixedLeg: leg },
{ lat: 47.392, lng: 11.012 },
@@ -72,7 +75,7 @@ describe('routeWalkingTour', () => {
it('TOUR-ROUTING-021: a tour that is all fixed leg asks the router nothing, and a failed run fails the whole route', async () => {
const leg = { kind: 'ferrata' as const, ref: 'way/1', name: null, geometry: [[47.39, 11.01], [47.392, 11.012]] as [number, number][] }
const fixedOnly = await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])
const fixedOnly = await route([{ lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])
expect(valhallaRun).not.toHaveBeenCalled()
expect(fixedOnly?.coordinates).toEqual([[47.39, 11.01], [47.392, 11.012]])
@@ -82,9 +85,9 @@ describe('routeWalkingTour', () => {
it('TOUR-ROUTING-022: hand-drawn legs ask no router and come back as their own parts, routed runs as theirs', async () => {
const free = (from: [number, number], to: [number, number]) => ({ kind: 'free' as const, geometry: [from, to] })
valhallaRun.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.395, 11.015], [47.4, 11.02]], distance: 900, duration: 700 } })
valhallaRun.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.395, 11.015], [47.4, 11.02]], distance: 900, duration: 700 }, snapped: [] })
const routed = await routeWalkingTour([
const routed = await route([
{ lat: 47.38, lng: 11.0, fixedLeg: free([47.38, 11.0], [47.39, 11.01]) },
{ lat: 47.39, lng: 11.01, fixedLeg: free([47.39, 11.01], [47.392, 11.012]) },
{ lat: 47.392, lng: 11.012 },
@@ -105,7 +108,7 @@ describe('routeWalkingTour', () => {
it('TOUR-ROUTING-023: a tour drawn wholly by hand, a paddle tour, never asks the router', async () => {
const free = (from: [number, number], to: [number, number]) => ({ kind: 'free' as const, geometry: [from, to] })
const routed = await routeWalkingTour([
const routed = await route([
{ lat: 53.3, lng: 12.9, fixedLeg: free([53.3, 12.9], [53.31, 12.92]) },
{ lat: 53.31, lng: 12.92 },
])
@@ -186,3 +189,44 @@ describe('routeWalkingTour', () => {
await expect(enrichTourElevations(coordinates)).resolves.toEqual(coordinates)
})
})
describe('routing on land, bike profiles and unreachable points (BIK-01)', () => {
beforeEach(() => {
valhallaRun.mockReset().mockResolvedValue(null)
valhallaBase.mockReset().mockReturnValue('https://valhalla.test')
})
const run = (snappedMeters: number[]) => ({
total: { coordinates: [[47, 11], [47.01, 11.01]], distance: 1500, duration: 300 },
snapped: snappedMeters.map((meters) => ({ asked: [47, 11], at: [47, 11], meters })),
})
it('TOUR-ROUTING-030: asks the bicycle costing with each profile\'s own options', async () => {
valhallaRun.mockResolvedValue(run([3, 4]))
for (const profile of ['touring', 'road', 'gravel', 'mtb'] as const) {
await routeBikeTour(waypoints, profile)
}
expect(valhallaRun.mock.calls.map((call) => [call[1], (call[4] as { bicycle_type: string }).bicycle_type])).toEqual([
['cycling', 'Hybrid'],
['cycling', 'Road'],
['cycling', 'Cross'],
['cycling', 'Mountain'],
])
})
it('TOUR-ROUTING-031: a point the router could only bind far off is named, not routed to', async () => {
valhallaRun.mockResolvedValueOnce(run([12, 2900]))
expect(await routeBikeTour(waypoints, 'touring')).toEqual({ unreachable: 1, snappedMeters: 2900 })
// Within the limit the route stands.
valhallaRun.mockResolvedValueOnce(run([12, 280]))
expect(await routeBikeTour(waypoints, 'touring')).toMatchObject({ distanceMeters: 1500 })
})
it('TOUR-ROUTING-032: a point after a fixed leg is counted along the whole tour', async () => {
const free = { kind: 'free' as const, geometry: [[47.39, 11.01], [47.392, 11.012]] as [number, number][] }
valhallaRun.mockResolvedValueOnce(run([400, 5]))
expect(
await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: free }, { lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
).toEqual({ unreachable: 1, snappedMeters: 400 })
})
})
@@ -1,7 +1,8 @@
import type { HikingPreferences, TourFixedLeg } from '@trek/shared'
import type { BikeProfile, HikingPreferences, TourFixedLeg } from '@trek/shared'
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
import { valhallaBase, valhallaRun, valhallaTurn, type CostingOptions } from '../../Map/valhallaRoute'
import { densifyLine, lineLengthM } from './tourFixedLegs'
import { MAX_TOUR_SNAP_M, type TourRouteUnreachable } from './tourRouteOutcome'
export interface TourRoutePart {
coordinates: [number, number][]
@@ -33,53 +34,104 @@ const TOURS_MAX_HIKING_DIFFICULTY = 2
/** How fast a fixed leg is reckoned until the heights are in: a via ferrata climbs slowly, a free leg is walked. */
const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4, portage: 2 } as const
/** A leg drawn by hand on a bike tour is reckoned at an easy riding pace: no router saw it. */
const FREE_LEG_RIDING_KM_PER_HOUR = 15
/**
* MF-08: a hike's preferences as Valhalla's pedestrian costing options. Each
* one was checked against the public instance before it was offered, and is a
* weighting that gives way where there is no other path:
* MF-08, RTE-01: a hike's preferences as Valhalla's pedestrian costing
* options. Each one was checked against the public instance before it was
* offered, and is a weighting that gives way where there is no other path:
* - avoid_climbs, `use_hills` 0.2: Freiburg Martinstor to the Schlossberg went
* from 1.64 km with a 37 % pitch and 30 m over 25 % to 1.73 km whose steepest
* stretch is 23 % (heights every 30 m). It weighs the grade, not the climb in
* sum: the same height is still gained. 0.1 and below sent Garmisch station
* to the Eckbauer round a 10 km detour with more steep metres, 0.25 and up
* left Freiburg as it was; 0.2 does neither.
* - avoid_steps, `step_penalty` 600 s: Würzburg Altstadt to the Festung went
* from four flights of steps over 1.49 km to one over 2.67 km.
* - avoid_ferries, `use_ferry` 0: Kiel Reventlou to Dietrichsdorf went from the
* Förde ferry (2.7 km) round the fjord on land (8.3 km); Hamburg
* Landungsbrücken to Steinwerder through the old Elbe tunnel instead of the ferry.
* - prefer_paths, `walkway_factor` 0.5: footpaths of their own weigh half of a
* road, which moved Garmisch station to the Partnachklamm onto the paths.
* - shortest, `shortest`: the way by its length, not its time (Garmisch to the
* Eckbauer 5.09 km to 4.96 km); it still honours the penalties above.
* Left out because the router showed no effect on foot: `use_hills` and
* `use_tracks`; nor has it any option for marked, paved or rough ways.
* The router has no option for marked, paved or rough ways on foot.
*/
export function hikingCostingOptions(preferences: HikingPreferences | null | undefined): Record<string, number | boolean> {
export function hikingCostingOptions(preferences: HikingPreferences | null | undefined): Record<string, number> {
return {
...(preferences?.avoid_climbs ? { use_hills: 0.2 } : {}),
...(preferences?.avoid_steps ? { step_penalty: 600 } : {}),
...(preferences?.avoid_ferries ? { use_ferry: 0 } : {}),
...(preferences?.prefer_paths ? { walkway_factor: 0.5 } : {}),
...(preferences?.shortest ? { shortest: true } : {}),
}
}
/**
* BIK-01 to BIK-04: what each bike profile asks Valhalla's bicycle costing for.
* Every one was checked against the public instance with trace_attributes over
* the routes it gave, before it was offered (shares of the length):
* - touring, Hybrid with bad surfaces avoided: Garmisch station to the Eckbauer
* 92 % asphalt and no gravel, where gravel took 18 % gravel on farm tracks.
* - road, Road at full surface avoidance: Herrsching to Andechs 100 % asphalt
* over 6.2 km, where touring took 4.98 km with 7 % dirt and 10 % compacted.
* - gravel, Cross with surfaces barely avoided: Munich Marienplatz to the
* Englischer Garten 39 % compacted paths along the Isar, touring 9 %.
* - mtb, Mountain with no surface avoidance and roads least wanted: Garmisch
* to the Eckbauer 63 % tracks and 38 % gravel.
* `use_roads` and the bicycle type also set the router's riding speed, so the
* time it gives is the profile's. None of it says a way is rideable or safe.
*/
export const BIKE_COSTING: Record<BikeProfile, Readonly<CostingOptions>> = {
touring: { bicycle_type: 'Hybrid', use_roads: 0.2, avoid_bad_surfaces: 1 },
road: { bicycle_type: 'Road', use_roads: 0.5, avoid_bad_surfaces: 1 },
gravel: { bicycle_type: 'Cross', use_roads: 0.1, avoid_bad_surfaces: 0.25 },
mtb: { bicycle_type: 'Mountain', use_roads: 0, avoid_bad_surfaces: 0 },
}
type LandWaypoint = Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }
/**
* Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose
* leg is fixed (a via ferrata on the tour) hands its own line over for that
* leg; the router only plans the runs between fixed legs, one request each.
*/
export async function routeWalkingTour(
waypoints: Array<Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }>,
waypoints: LandWaypoint[],
signal?: AbortSignal,
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
preferences: HikingPreferences | null = null,
): Promise<TourRouteResult | null> {
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'walking', {
max_hiking_difficulty: maxHikingDifficulty,
...hikingCostingOptions(preferences),
}, signal)
}
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
export async function routeBikeTour(
waypoints: LandWaypoint[],
profile: BikeProfile,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'cycling', BIKE_COSTING[profile], signal)
}
async function routeOnLand(
waypoints: LandWaypoint[],
mode: 'walking' | 'cycling',
costingOptions: Readonly<CostingOptions>,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
const lines: TourRoutePart[] = []
let distanceMeters = 0
let durationSeconds = 0
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>) => {
let unreachable: TourRouteUnreachable | null = null
// `first`: where the run starts along the tour, so a point it cannot reach is named by its place in the tour.
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>, first: number) => {
if (run.length < 2) return true
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), 'walking', [], signal, {
max_hiking_difficulty: maxHikingDifficulty,
...hikingCostingOptions(preferences),
})
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), mode, [], signal, costingOptions)
if (!routed) return false
const far = routed.snapped.findIndex((point) => point.meters > MAX_TOUR_SNAP_M)
if (far >= 0) {
unreachable = { unreachable: first + far, snappedMeters: Math.round(routed.snapped[far].meters) }
return false
}
lines.push({ coordinates: routed.total.coordinates, free: false })
distanceMeters += routed.total.distance
durationSeconds += routed.total.duration
@@ -87,23 +139,26 @@ export async function routeWalkingTour(
}
let run: Array<Pick<Waypoint, 'lat' | 'lng'>> = waypoints.slice(0, 1)
let runStart = 0
for (let i = 0; i < waypoints.length - 1; i += 1) {
const leg = waypoints[i].fixedLeg
if (!leg) {
run.push(waypoints[i + 1])
continue
}
if (!(await routeRun(run))) return null
if (!(await routeRun(run, runStart))) return unreachable
const drawn = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
// A hand-drawn leg is sampled along its length, so its heights follow the ground between its ends.
const line = leg.kind === 'ferrata' ? drawn : densifyLine(drawn)
const length = lineLengthM(line)
const kmPerHour = mode === 'cycling' && leg.kind === 'free' ? FREE_LEG_RIDING_KM_PER_HOUR : FIXED_LEG_KM_PER_HOUR[leg.kind]
lines.push({ coordinates: line, free: leg.kind !== 'ferrata', ...(leg.kind === 'portage' ? { land: true } : {}) })
distanceMeters += length
durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR[leg.kind]) * 3600
durationSeconds += (length / 1000 / kmPerHour) * 3600
run = [waypoints[i + 1]]
runStart = i + 1
}
if (!(await routeRun(run))) return null
if (!(await routeRun(run, runStart))) return unreachable
if (lines.length === 0) return null
// One line: each part after the first starts where the one before it ended, so its first point goes.
@@ -0,0 +1,193 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, routeBikeTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
() => ({
routeWalkingTour: vi.fn(),
routeBikeTour: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
})
);
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeBikeTour, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import { useTourPlanner } from './useTourPlanner';
// FE-TOUR-BIKE-001 to FE-TOUR-BIKE-005
const routed = {
coordinates: [
[48, 11],
[48.01, 11.02],
] as [number, number][],
parts: [
{
coordinates: [
[48, 11],
[48.01, 11.02],
] as [number, number][],
free: false,
},
],
distanceMeters: 9000,
durationSeconds: 1800,
};
const enriched = [
[48, 11, 500],
[48.01, 11.02, 560],
] as [number, number, number][];
const savedBike = {
place_id: 77,
name: 'Gravel loop',
tour_type: 'bike' as const,
bike_profile: 'gravel' as const,
distance: 9,
elevation_gain: 60,
elevation_loss: 0,
duration: 30,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: false,
caution: false,
revision: 3,
};
const ferrata = {
kind: 'ferrata' as const,
ref: 'way/9',
name: 'Steig',
geometry: [
[48, 11],
[48.01, 11.02],
] as Array<[number, number]>,
};
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
describe('useTourPlanner, bike tours (BIK-01)', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed);
routeBikeTour.mockReset().mockResolvedValue(routed);
enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset();
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-BIKE-001: a bike tour is routed for its profile, touring first, and never on foot', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 51 }));
act(() => {
result.current.addWaypoint(48, 11);
result.current.addWaypoint(48.01, 11.02);
});
act(() => result.current.setTourType('bike'));
expect(result.current.isBike).toBe(true);
expect(result.current.bikeProfile).toBe('touring');
routeWalkingTour.mockClear();
await settle();
expect(routeBikeTour).toHaveBeenLastCalledWith(expect.any(Array), 'touring', expect.any(AbortSignal));
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(result.current.status).toBe('ready');
// The router's riding time is the tour's time, heights or not.
expect(result.current.walkingDurationMinutes).toBe(30);
});
it('FE-TOUR-BIKE-002: a profile is one step of the history, kept in the draft, and plans the route again', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 52 }));
act(() => {
result.current.addWaypoint(48, 11);
result.current.addWaypoint(48.01, 11.02);
result.current.setTourType('bike');
});
await settle();
act(() => result.current.setBikeProfile('road'));
expect(JSON.parse(localStorage.getItem('tour-draft-52') || 'null')).toMatchObject({
version: 10,
tourType: 'bike',
bikeProfile: 'road',
});
await settle();
expect(routeBikeTour).toHaveBeenLastCalledWith(expect.any(Array), 'road', expect.any(AbortSignal));
// The same profile again is no step; an undo takes it back.
act(() => result.current.setBikeProfile('road'));
act(() => result.current.undo());
expect(result.current.bikeProfile).toBe('touring');
act(() => result.current.redo());
expect(result.current.bikeProfile).toBe('road');
});
it('FE-TOUR-BIKE-003: a failed route keeps the last one planned and says so', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 53 }));
act(() => {
result.current.addWaypoint(48, 11);
result.current.addWaypoint(48.01, 11.02);
result.current.setTourType('bike');
});
await settle();
expect(result.current.route).toEqual(routed.coordinates);
routeBikeTour.mockResolvedValueOnce(null);
act(() => result.current.setBikeProfile('mtb'));
await settle();
expect(result.current.status).toBe('routing-failed');
expect(result.current.error).toBe('routing');
expect(result.current.route).toEqual(routed.coordinates);
expect(result.current.canSave).toBe(false);
});
it('FE-TOUR-BIKE-004: switching to a bike tour drops a via ferrata, the router plans that leg', () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 54 }));
act(() => result.current.appendFixedLeg(ferrata));
expect(result.current.waypoints[0].fixedLeg?.kind).toBe('ferrata');
act(() => result.current.setTourType('bike'));
expect(result.current.waypoints.some((point) => point.fixedLeg)).toBe(false);
act(() => result.current.undo());
expect(result.current.waypoints[0].fixedLeg?.kind).toBe('ferrata');
});
it('FE-TOUR-BIKE-005: opens a saved bike tour with its profile and saves it with nothing of a hike', async () => {
detailTour.mockResolvedValue({
tour: savedBike,
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'end', sequence: 1 },
],
});
updateTour.mockResolvedValue({ tour: { ...savedBike, revision: 4 }, waypoints: [] });
const { result } = renderHook(() => useTourPlanner({ tripId: 55 }));
await act(async () => {
await result.current.openTour({ place_id: 77 });
});
expect(result.current.tourType).toBe('bike');
expect(result.current.bikeProfile).toBe('gravel');
act(() => result.current.setName('Gravel loop, longer'));
await settle();
await act(async () => {
await result.current.save();
});
expect(updateTour).toHaveBeenCalledWith(
55,
77,
expect.objectContaining({
tour_type: 'bike',
bike_profile: 'gravel',
max_hiking_difficulty: null,
hiking_preferences: null,
expected_revision: 3,
})
);
});
});
@@ -293,6 +293,11 @@ describe('useTourPlanner route tools', () => {
act(() => result.current.paddle.setBoatType('canoe'));
act(() => result.current.paddle.setWaterContext('lake'));
// A route planned for another boat is not this one's: Save waits for the canoe's.
expect(result.current.canSave).toBe(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
@@ -324,6 +329,9 @@ describe('useTourPlanner route tools', () => {
act(() => result.current.paddle.setSpeedKmh(6));
act(() => result.current.paddle.setBoatType('other'));
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(result.current.paddle.speedKmh).toBe(6);
expect(result.current.walkingDurationMinutes).toBe(12);
@@ -1459,7 +1459,7 @@ describe('useTourPlanner', () => {
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.01, 11.02)
})
act(() => result.current.setHikingPreferences({ avoid_steps: true, shortest: false }))
act(() => result.current.setHikingPreferences({ avoid_steps: true, avoid_climbs: false }))
expect(result.current.hikingPreferences).toEqual({ avoid_steps: true })
expect(JSON.parse(localStorage.getItem('tour-draft-41') || 'null')).toMatchObject({ hikingPreferences: { avoid_steps: true } })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
@@ -1618,7 +1618,7 @@ describe('useTourPlanner', () => {
expect(result.current.mode).toEqual({ type: 'new-draft' })
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
version: 9,
version: 10,
tourType: 'hike',
name: '',
waypoints: [],
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { hikingPreferencesOf, paddleSpeedKmhSchema, tourWebsiteSchema, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
import { hikingPreferencesOf, paddleSpeedKmhSchema, tourWebsiteSchema, type BikeProfile, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
import { normalizePlannerWaypoints, plannerWaypointsOf, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { hasCompleteElevation, useTourRoute } from './useTourRoute'
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts'
import { usePlannerHistory } from './usePlannerHistory'
import { useTourDraftStorage } from './useTourDraftStorage'
@@ -15,7 +15,7 @@ import { useRouteShaping } from './useRouteShaping'
import { useTourMapView } from './useTourMapView'
import type { DraftPayload } from './tourPlannerDraft'
import { paddleEstimateOf } from './paddlePlanOf'
import { adoptChangedPoints, planPaddleStep, type PaddleTourRoute } from './paddleRouting'
import { adoptChangedPoints } from './paddleRouting'
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -35,9 +35,10 @@ export interface DerivedTourDraft {
inPlace: boolean
/** P2C-03: the import's revision, which a save planned over in place names. */
revision?: number
/** A paddle import stays a paddle tour, with its boat and water. */
/** A paddle import stays a paddle tour, with its boat and water, and a bike import a bike tour with its profile. */
tourType?: PlannableTourType
paddle?: PaddleFieldValues
bikeProfile?: BikeProfile
}
export type TourPlannerStatus =
@@ -52,12 +53,9 @@ export type TourPlannerStatus =
| 'saved'
/** One step of the history: the route, and what the route depends on. */
interface HistoryEntry { waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; preferences: HikingPreferences | null; tourType: PlannableTourType }
const ROUTE_DEBOUNCE_MS = 450
function hasCompleteElevation(points: TourElevationPoint[] | null): points is [number, number, number][] {
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
interface HistoryEntry {
waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; preferences: HikingPreferences | null; bikeProfile: BikeProfile
tourType: PlannableTourType
}
/** Where a new tour was planned from; an edit leaves it as it was saved. */
@@ -113,6 +111,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [waypoints, setWaypoints] = useState<TourPlannerWaypoint[]>(restored?.waypoints ?? [])
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
const [hikingPreferences, setHikingPreferencesState] = useState<HikingPreferences | null>(restored?.hikingPreferences ?? null)
const [bikeProfile, setBikeProfileState] = useState<BikeProfile>(restored?.bikeProfile ?? 'touring')
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
const history = usePlannerHistory<HistoryEntry>()
@@ -120,13 +119,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const revision = useTourRevisionGuard(restored?.baseRevision ?? null)
const { adopt: adoptRevision, refuse: refuseRevision, baseRef: revisionBaseRef } = revision
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
const [route, setRoute] = useState<[number, number][] | null>(null)
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
const [paddleRoute, setPaddleRoute] = useState<PaddleTourRoute | null>(null)
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
const [routeForKey, setRouteForKey] = useState<string | null>(null)
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
const [durationSeconds, setDurationSeconds] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(Boolean(restored))
const [isSaving, setIsSaving] = useState(false)
@@ -143,8 +135,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [routeProfileFocus, setRouteProfileFocus] = useState<RouteProfileFocus | null>(null)
const [newDraftGeneration, setNewDraftGeneration] = useState(0)
const [mapFocusKey, setMapFocusKey] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const generationRef = useRef(0)
const draftRevisionRef = useRef(0)
const saveRequestIdRef = useRef(0)
const saveInFlightRef = useRef(false)
@@ -165,10 +155,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const waypointsRef = useRef(waypoints)
const difficultyRef = useRef(maxHikingDifficulty)
const preferencesRef = useRef(hikingPreferences)
const bikeProfileRef = useRef(bikeProfile)
const tourTypeRef = useRef(tourType)
waypointsRef.current = waypoints
difficultyRef.current = maxHikingDifficulty
const snapshot = useCallback((): HistoryEntry => ({ waypoints: waypointsRef.current, difficulty: difficultyRef.current, preferences: preferencesRef.current, tourType: tourTypeRef.current }), [])
const snapshot = useCallback((): HistoryEntry => ({
waypoints: waypointsRef.current, difficulty: difficultyRef.current, preferences: preferencesRef.current,
bikeProfile: bikeProfileRef.current, tourType: tourTypeRef.current,
}), [])
useEffect(() => {
mountedRef.current = true
@@ -199,11 +193,36 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
invalidateDetailRequest()
}, [tripId, invalidateDetailRequest])
const interruptRouting = useCallback(() => {
generationRef.current += 1
abortRef.current?.abort()
abortRef.current = null
/** A field other than the route changed: the draft is unsaved again, and a finished save is no longer current. */
const markEdited = useCallback(() => {
if (!editPermissionRef.current) return false
invalidateDetailRequest()
draftRevisionRef.current += 1
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus(current => current === 'saved' || current === 'saving' ? (waypointsRef.current.length < 2 ? 'dirty' : 'ready') : current)
return true
}, [invalidateDetailRequest])
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
const loadPaddle = paddle.load
const paddleBoat = paddle.boatType
// The router's own default where the tour names none, so a plan and its cache key agree.
const paddleLakeStyle = paddle.lakeStyle ?? 'direct'
// A point the router set (a paddle tour's) is laid in without a step of the history.
const patchWaypoints = useCallback((next: TourPlannerWaypoint[]) => {
waypointsRef.current = next
setWaypoints(next)
}, [])
const forgetFocus = useCallback(() => setRouteProfileFocus(null), [])
const routing = useTourRoute({
tripId, waypoints, tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, paddleBoat, paddleLakeStyle, retryToken,
error, setStatus, setError, onCleared: forgetFocus, patchWaypoints,
})
const { route, routeParts, paddleRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds,
unreachable, interruptRouting, clearRoute } = routing
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
if (!editPermissionRef.current) return
@@ -238,20 +257,29 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setStatus('dirty')
}, [interruptRouting])
/** A hike's routing choices, its limit and its preferences (MF-08), as one step of the history; the route is planned again. */
const setRoutingChoice = useCallback((change: { difficulty?: TourMaxHikingDifficulty; preferences?: HikingPreferences | null }) => {
/**
* The routing choices, as one step of the history: a hike's limit and its
* preferences (MF-08), a bike tour's profile (BIK-01). The route is planned again.
*/
const setRoutingChoice = useCallback((change: {
difficulty?: TourMaxHikingDifficulty; preferences?: HikingPreferences | null; bikeProfile?: BikeProfile
}) => {
if (!editPermissionRef.current) return
const difficulty = change.difficulty ?? difficultyRef.current
const preferences = change.preferences === undefined ? preferencesRef.current : hikingPreferencesOf(change.preferences)
if (difficulty === difficultyRef.current && JSON.stringify(preferences) === JSON.stringify(preferencesRef.current)) return
const profile = change.bikeProfile ?? bikeProfileRef.current
if (difficulty === difficultyRef.current && profile === bikeProfileRef.current
&& JSON.stringify(preferences) === JSON.stringify(preferencesRef.current)) return
invalidateDetailRequest()
interruptRouting()
draftRevisionRef.current += 1
recordStep(snapshot())
difficultyRef.current = difficulty
preferencesRef.current = preferences
bikeProfileRef.current = profile
setMaxHikingDifficultyState(difficulty)
setHikingPreferencesState(preferences)
setBikeProfileState(profile)
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus('dirty')
@@ -259,6 +287,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [recordStep, invalidateDetailRequest, interruptRouting, snapshot])
const setMaxHikingDifficulty = useCallback((difficulty: TourMaxHikingDifficulty) => setRoutingChoice({ difficulty }), [setRoutingChoice])
const setHikingPreferences = useCallback((preferences: HikingPreferences | null) => setRoutingChoice({ preferences }), [setRoutingChoice])
const setBikeProfile = useCallback((profile: BikeProfile) => setRoutingChoice({ bikeProfile: profile }), [setRoutingChoice])
const addWaypoint = useCallback((lat: number, lng: number) => {
if (!editPermissionRef.current) return
@@ -368,10 +397,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
waypointsRef.current = entry.waypoints
difficultyRef.current = entry.difficulty
preferencesRef.current = entry.preferences
bikeProfileRef.current = entry.bikeProfile
tourTypeRef.current = entry.tourType
setWaypoints(entry.waypoints)
setMaxHikingDifficultyState(entry.difficulty)
setHikingPreferencesState(entry.preferences)
setBikeProfileState(entry.bikeProfile)
setTourTypeState(entry.tourType)
setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null)
setHasUnsavedChanges(true)
@@ -392,16 +423,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (next) restoreStep(next)
}, [redoStep, restoreStep, snapshot])
/** A field other than the route changed: the draft is unsaved again, and a finished save is no longer current. */
const markEdited = useCallback(() => {
if (!editPermissionRef.current) return false
invalidateDetailRequest()
draftRevisionRef.current += 1
setHasUnsavedChanges(true)
setSaveOutcome(null)
setStatus(current => current === 'saved' || current === 'saving' ? (waypointsRef.current.length < 2 ? 'dirty' : 'ready') : current)
return true
}, [invalidateDetailRequest])
const setName = useCallback((value: string) => {
if (value !== name && markEdited()) setNameState(value)
@@ -423,15 +444,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value)
}, [breakAdditionalMinutes, markEdited])
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
const loadPaddle = paddle.load
const paddleBoat = paddle.boatType
const paddleLakeStyle = paddle.lakeStyle
/**
* PAD-01: hike or paddle tour, as one step of the history with the route,
* since a paddle tour draws every leg by hand. Nothing is routed or put on
* a day by the choice itself.
* PAD-01, BIK-01: hike, bike or paddle tour, as one step of the history with
* the route, since a paddle tour draws every leg by hand and a bike tour
* rides no via ferrata. Nothing is put on a day by the choice itself.
*/
const setTourType = useCallback((value: PlannableTourType) => {
if (!editPermissionRef.current || saveInFlightRef.current || value === tourTypeRef.current) return
@@ -441,7 +458,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
recordStep(snapshot())
tourTypeRef.current = value
setTourTypeState(value)
const next = normalizePlannerWaypoints(waypointsRef.current, value === 'kayak')
const next = normalizePlannerWaypoints(value === 'bike' ? ridablePoints(waypointsRef.current) : waypointsRef.current, value === 'kayak')
waypointsRef.current = next
setWaypoints(next)
setHasUnsavedChanges(true)
@@ -458,14 +475,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const paddleSpeedInvalid = paddle.speedKmh !== null && !paddleSpeedKmhSchema.safeParse(paddle.speedKmh).success
const { effectiveSpeedKmh, portageSpeedKmh, boatType, waterContext, speedKmh, lakeStyle } = paddle
// The routed length of each leg while the route is the current points' (PAD-11); straight lines before that.
const routedLegLengths = paddleRoute && routeForKey === tourWaypointKey(waypoints) ? paddleRoute.legLengths : undefined
const routedLegLengths = paddleRoute && routeCurrent ? paddleRoute.legLengths : undefined
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, website, plannedDurationMinutes, breakAdditionalMinutes,
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds,
}), [boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, tourType, waterContext, website])
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, typeFields } = facts
@@ -491,17 +508,13 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
difficultyRef.current = 2
preferencesRef.current = null
setHikingPreferencesState(null)
bikeProfileRef.current = 'touring'
setBikeProfileState('touring')
tourTypeRef.current = 'hike'
setSelectedWaypointId(null)
clearHistory()
setStatus('empty')
setRoute(null)
setRouteParts(null)
setPaddleRoute(null)
setEnrichedGeometry(null)
setRouteForKey(null)
setDistanceMeters(null)
setDurationSeconds(null)
clearRoute()
setError(null)
setHasUnsavedChanges(false)
setDraftRestored(false)
@@ -516,7 +529,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setElevationProfileExpanded(true)
setNewDraftGeneration(generation => generation + 1)
setMapFocusKey(key => key + 1)
}, [adoptRevision, clearHistory, invalidateDetailRequest, interruptRouting, loadPaddle, forgetDraft])
}, [adoptRevision, clearHistory, clearRoute, invalidateDetailRequest, interruptRouting, loadPaddle, forgetDraft])
const forgetDeletedTour = useCallback((placeId: number) => {
deletedTourPlaceIdsRef.current.add(placeId)
@@ -561,6 +574,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
tourTypeRef.current = type
setTourTypeState(type)
if (draft.paddle) loadPaddle(draft.paddle)
if (draft.bikeProfile) {
bikeProfileRef.current = draft.bikeProfile
setBikeProfileState(draft.bikeProfile)
}
setNameState(draft.name)
setDescriptionState(draft.description ?? '')
setWebsiteState(draft.website ?? '')
@@ -656,17 +673,13 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
difficultyRef.current = opened.difficulty
preferencesRef.current = opened.preferences
setHikingPreferencesState(opened.preferences)
bikeProfileRef.current = opened.bikeProfile
setBikeProfileState(opened.bikeProfile)
tourTypeRef.current = opened.tourType
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
clearHistory()
setStatus('dirty')
setRoute(null)
setRouteParts(null)
setPaddleRoute(null)
setEnrichedGeometry(null)
setRouteForKey(null)
setDistanceMeters(null)
setDurationSeconds(null)
clearRoute()
setHasUnsavedChanges(false)
setDraftRestored(false)
setEditingPlaceId(result.tour.place_id)
@@ -686,7 +699,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setOpeningTourId(null)
}
}
}, [adoptRevision, clearHistory, interruptRouting, loadPaddle, forgetDraft, tripId])
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadPaddle, forgetDraft, tripId])
const retry = useCallback(() => {
if (waypointsRef.current.length < 2) return
@@ -705,7 +718,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
})
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
const readOnlyGpxAnalysis = useMemo(
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
[readOnlyGpxTour?.routeGeometry],
@@ -714,116 +726,18 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const focusOwner = mode.type === 'view-gpx' || mode.type === 'edit-saved' ? `${mode.type}:${mode.placeId}` : mode.type
useEffect(() => { setRouteProfileFocus(null) }, [focusOwner, routeAnalysis, readOnlyGpxAnalysis, readOnlyGpxTour])
useEffect(() => {
if (waypoints.length < 2) {
interruptRouting()
setStatus(waypoints.length === 0 ? 'empty' : 'dirty')
setRoute(null)
setRouteParts(null)
setRouteForKey(null)
setEnrichedGeometry(null)
setDistanceMeters(null)
setDurationSeconds(null)
setRouteProfileFocus(null)
setError(null)
return
}
const generation = ++generationRef.current
const controller = new AbortController()
abortRef.current?.abort()
abortRef.current = controller
const stale = () => controller.signal.aborted || generation !== generationRef.current
// Points the router set, changed by its own planning and not by the paddler: no step of the history.
const patch = (next: TourPlannerWaypoint[]) => {
waypointsRef.current = next
setWaypoints(next)
}
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
const routePaddle = async () => {
setStatus('routing')
const step = await planPaddleStep(waypoints, tripId, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }, controller.signal, randomId)
if (stale()) return
if ('points' in step) return patch(normalizePlannerWaypoints(step.points, true))
const planned = step.route
setRoute(planned.coordinates)
setRouteParts(planned.parts)
setPaddleRoute(planned)
setDistanceMeters(planned.distanceMeters)
setDurationSeconds(null)
setRouteForKey(waypointKey)
setEnrichedGeometry(planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]))
if (planned.failures.length > 0) {
setStatus('routing-failed')
setError('paddle-route')
return
}
setStatus('ready')
setError(null)
}
const routeHike = async () => {
setStatus('routing')
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty, hikingPreferences)
if (stale()) return
if (!routed) {
setStatus('routing-failed')
setError('routing')
return
}
setRoute(routed.coordinates)
setRouteParts(routed.parts)
setPaddleRoute(null)
setDistanceMeters(routed.distanceMeters)
setDurationSeconds(routed.durationSeconds)
setRouteForKey(waypointKey)
setEnrichedGeometry(null)
setStatus('enriching-elevation')
const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
if (stale()) return
if (!enriched) {
setStatus('elevation-failed')
setError('elevation')
return
}
setEnrichedGeometry(enriched)
if (!hasCompleteElevation(enriched)) {
setStatus('elevation-failed')
setError('elevation')
return
}
setStatus('ready')
setError(null)
}
const timer = window.setTimeout(() => {
const run = isPaddle ? routePaddle() : routeHike()
run.catch(() => {
if (stale()) return
setStatus('routing-failed')
setError('routing')
})
}, ROUTE_DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [hikingPreferences, interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 9, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId, tourType, baseRevision: revision.base, hikingPreferences,
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
derivedFromPlaceId, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
}
keepDraft(draft)
}, [breakAdditionalMinutes,
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences])
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
bikeProfile])
useEffect(() => {
if (!hasUnsavedChanges) return
@@ -833,7 +747,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
}, [hasUnsavedChanges])
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
&& routeForKey === waypointKey && hasCompleteElevation(enrichedGeometry)
&& routeCurrent && hasCompleteElevation(enrichedGeometry)
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid)
@@ -915,6 +829,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
return {
tripId, canEdit, canAssign,
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
bikeProfile, setBikeProfile, isBike: tourType === 'bike',
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
@@ -923,10 +838,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
...shaping, paddleRoute, routeCurrent: routeForKey === waypointKey,
...shaping, paddleRoute, routeCurrent,
undo, redo, canUndo: history.canUndo, canRedo: history.canRedo,
status, error, retry, isSaving,
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, unreachable,
routeProfileFocus, setRouteProfileFocus,
...mapView,
mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour,
@@ -0,0 +1,264 @@
import type {
BikeProfile,
HikingPreferences,
PaddleBoatType,
PaddleLakeStyle,
PlannableTourType,
TourMaxHikingDifficulty,
} from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { randomId } from '../../../utils/randomId';
import { planPaddleStep, type PaddleTourRoute } from './paddleRouting';
import { normalizePlannerWaypoints, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints';
import { isTourRoute, type TourRouteUnreachable } from './tourRouteOutcome';
import {
enrichTourElevations,
routeBikeTour,
routeWalkingTour,
type TourElevationPoint,
type TourRoutePart,
} from './tourRouting';
import type { TourPlannerStatus } from './useTourPlanner';
const ROUTE_DEBOUNCE_MS = 450;
/** What the route of a draft is planned from: its points, its type and the choices the router is asked for. */
export interface TourRouteInputs {
tripId: number | string;
waypoints: TourPlannerWaypoint[];
tourType: PlannableTourType;
maxHikingDifficulty: TourMaxHikingDifficulty;
hikingPreferences: HikingPreferences | null;
bikeProfile: BikeProfile;
paddleBoat: PaddleBoatType | null;
paddleLakeStyle: PaddleLakeStyle;
/** Raised to plan the same points again. */
retryToken: number;
/** The planner's error, so a point found unreachable is only named while that is the error. */
error: string | null;
setStatus: (status: TourPlannerStatus) => void;
setError: (error: string | null) => void;
onCleared: () => void;
/** Points the router set itself (a paddle tour's), laid in without a step of the history. */
patchWaypoints: (next: TourPlannerWaypoint[]) => void;
}
/** A route as planned, and what it was planned for (`forKey`). */
interface PlannedRoute {
route: [number, number][] | null;
routeParts: TourRoutePart[] | null;
paddleRoute: PaddleTourRoute | null;
enrichedGeometry: TourElevationPoint[] | null;
distanceMeters: number | null;
durationSeconds: number | null;
forKey: string | null;
}
const NO_ROUTE: PlannedRoute = {
route: null,
routeParts: null,
paddleRoute: null,
enrichedGeometry: null,
distanceMeters: null,
durationSeconds: null,
forKey: null,
};
/** One planning run: what it plans from, whether it went stale, and where it writes. */
interface RouteRun {
inputs: Omit<TourRouteInputs, 'retryToken' | 'error' | 'onCleared'>;
plannedKey: string;
signal: AbortSignal;
stale: () => boolean;
setPlanned: (next: PlannedRoute | ((current: PlannedRoute) => PlannedRoute)) => void;
setUnreachable: (unreachable: TourRouteUnreachable | null) => void;
}
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
async function routePaddle({ inputs, plannedKey, signal, stale, setPlanned }: RouteRun) {
const { waypoints, tripId, paddleBoat, paddleLakeStyle, setStatus, setError } = inputs;
setStatus('routing');
const profile = { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle } as const;
const step = await planPaddleStep(waypoints, tripId, profile, signal, randomId);
if (stale()) return;
if ('points' in step) return inputs.patchWaypoints(normalizePlannerWaypoints(step.points, true));
const planned = step.route;
setPlanned({
route: planned.coordinates,
routeParts: planned.parts,
paddleRoute: planned,
enrichedGeometry: planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]),
distanceMeters: planned.distanceMeters,
durationSeconds: null,
forKey: plannedKey,
});
const failed = planned.failures.length > 0;
setStatus(failed ? 'routing-failed' : 'ready');
setError(failed ? 'paddle-route' : null);
}
// A hike or a bike tour on land, then the heights along it.
async function routeOnLand({ inputs, plannedKey, signal, stale, setPlanned, setUnreachable }: RouteRun) {
const { waypoints, tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, setStatus, setError } = inputs;
setStatus('routing');
const routed =
tourType === 'bike'
? await routeBikeTour(waypoints, bikeProfile, signal)
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
if (stale()) return;
if (!isTourRoute(routed)) {
setUnreachable(routed);
setStatus('routing-failed');
setError(routed ? 'unreachable' : 'routing');
return;
}
setUnreachable(null);
setPlanned({
route: routed.coordinates,
routeParts: routed.parts,
paddleRoute: null,
enrichedGeometry: null,
distanceMeters: routed.distanceMeters,
durationSeconds: routed.durationSeconds,
forKey: plannedKey,
});
setStatus('enriching-elevation');
const enriched = await enrichTourElevations(routed.coordinates, signal);
if (stale()) return;
if (enriched) setPlanned((current) => ({ ...current, enrichedGeometry: enriched }));
if (!hasCompleteElevation(enriched)) {
setStatus('elevation-failed');
setError('elevation');
return;
}
setStatus('ready');
setError(null);
}
/**
* The route of the planner's draft, planned again a moment after its points
* or its choices change: a hike on foot within its limit, a bike tour for its
* profile (BIK-01), a paddle tour on the water by the server's router. A hike
* and a bike tour then get their heights; a later change makes an answer
* still on its way stale, and a failure keeps the last route that was planned.
*/
export function useTourRoute(inputs: TourRouteInputs) {
const {
tripId,
waypoints,
tourType,
maxHikingDifficulty,
hikingPreferences,
bikeProfile,
paddleBoat,
paddleLakeStyle,
retryToken,
error,
setStatus,
setError,
onCleared,
patchWaypoints,
} = inputs;
const [planned, setPlanned] = useState<PlannedRoute>(NO_ROUTE);
// A point no way of the tour's type reaches, and how far off the router would have bound it.
const [unreachable, setUnreachable] = useState<TourRouteUnreachable | null>(null);
const abortRef = useRef<AbortController | null>(null);
const generationRef = useRef(0);
/** Any answer still on its way is stale from here on. */
const interruptRouting = useCallback(() => {
generationRef.current += 1;
abortRef.current?.abort();
abortRef.current = null;
}, []);
/** No route at all, as for an empty draft. */
const clearRoute = useCallback(() => setPlanned(NO_ROUTE), []);
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]);
// What a route was planned for besides its points: a route of another type or profile is not this one's.
const choices = [tourType, bikeProfile, maxHikingDifficulty, hikingPreferences, paddleBoat, paddleLakeStyle];
const plannedKey = `${waypointKey}#${JSON.stringify(choices)}`;
useEffect(() => {
if (waypoints.length < 2) {
interruptRouting();
setStatus(waypoints.length === 0 ? 'empty' : 'dirty');
clearRoute();
onCleared();
setError(null);
return;
}
const generation = ++generationRef.current;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
const stale = () => controller.signal.aborted || generation !== generationRef.current;
const run: RouteRun = {
inputs: {
tripId,
waypoints,
tourType,
maxHikingDifficulty,
hikingPreferences,
bikeProfile,
paddleBoat,
paddleLakeStyle,
setStatus,
setError,
patchWaypoints,
},
plannedKey,
signal: controller.signal,
stale,
setPlanned,
setUnreachable,
};
const timer = window.setTimeout(() => {
(tourType === 'kayak' ? routePaddle(run) : routeOnLand(run)).catch(() => {
if (stale()) return;
setStatus('routing-failed');
setError('routing');
});
}, ROUTE_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [
bikeProfile,
clearRoute,
hikingPreferences,
interruptRouting,
maxHikingDifficulty,
onCleared,
paddleBoat,
paddleLakeStyle,
patchWaypoints,
plannedKey,
retryToken,
setError,
setStatus,
tourType,
tripId,
waypoints,
]);
const { forKey, ...shown } = planned;
return {
...shown,
/** The route shown was planned for these points and these choices. */
routeCurrent: forKey === plannedKey,
unreachable: error === 'unreachable' ? unreachable : null,
interruptRouting,
clearRoute,
};
}
export function hasCompleteElevation(points: TourElevationPoint[] | null): points is [number, number, number][] {
return !!points && points.every((point) => point.length === 3 && Number.isFinite(point[2]));
}
@@ -385,6 +385,17 @@ describe('TourTerrainMap', () => {
expect(gl.map.addLayer).toHaveBeenCalledTimes(3);
});
it('FE-COMP-TERRAIN-013b: a map on terms shows its credit above the controls, the others none (MAP-01, BIK-06)', async () => {
const { rerender } = await renderTerrain({ samples: TOUR, baseLayer: 'cycle' });
const credit = screen.getByTestId('base-map-credit');
expect(credit).toHaveTextContent('CyclOSM');
expect(credit.querySelectorAll('a')).toHaveLength(2);
rerender(<TourTerrainMap {...props({ samples: TOUR, baseLayer: 'hiking' })} />);
expect(screen.getByTestId('base-map-credit')).toHaveTextContent('Donation');
rerender(<TourTerrainMap {...props({ samples: TOUR, baseLayer: 'topo' })} />);
expect(screen.queryByTestId('base-map-credit')).toBeNull();
});
it('FE-COMP-TERRAIN-014: removes the map with the view', async () => {
const { unmount } = await renderTerrain({
waypoints: { points: [{ id: 'a', lat: 46, lng: 7 }], selectedId: null, onSelect: vi.fn() },
@@ -14,6 +14,7 @@ import {
type TourLayerOffer,
} from '../../Map/MapLayerSwitcher';
import { MAP_CONTROL_SHADOW } from '../../Map/mapControlShadow';
import { tourRasterBaseOf } from '../../Map/tourRasterBases';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
@@ -108,7 +109,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
}}
>
{baseLayer === 'hiking' && <HikingMapCredit />}
<BaseMapCredit layer={baseLayer} />
{legendAbove && legend}
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
<div className="flex-none">
@@ -144,29 +145,28 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
}
/**
* OpenHikingMap's credit and donation link, which its operator wants readable without a
* click (MAP-01). The 3D map's own attribution is folded away and sits under the panel,
* so it rides above the control row while that map is shown.
* The credit of a map on terms (OpenHikingMap with its donation link, CyclOSM), which its
* operator wants readable without a click (MAP-01, BIK-06). The 3D map's own attribution is
* folded away and sits under the panel, so it rides above the control row while that map is shown.
*/
function HikingMapCredit() {
function BaseMapCredit({ layer }: { layer: TourBaseLayer }) {
const credit = tourRasterBaseOf(layer)?.onTerms?.credit;
if (!credit) return null;
const link = 'text-content-secondary underline-offset-2 hover:underline';
return (
<div
className="pointer-events-auto flex items-center gap-1.5 rounded-full px-3 py-1 text-content-faint"
style={{ ...GLASS, ...fs(10) }}
data-testid="hiking-map-credit"
data-testid="base-map-credit"
>
<a className={link} href="https://wiki.openstreetmap.org/wiki/OpenHikingMap" target="_blank" rel="noreferrer">
&copy; OpenHikingMap
</a>
<span aria-hidden>|</span>
<a className={link} href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
&copy; OpenStreetMap
</a>
<span aria-hidden>|</span>
<a className={link} href="https://openmaps.fr/donate" target="_blank" rel="noreferrer">
{'❤️'} Donation
</a>
{credit.map((part, index) => (
<span key={part.href} className="flex items-center gap-1.5">
{index > 0 && <span aria-hidden>|</span>}
<a className={link} href={part.href} target="_blank" rel="noreferrer">
{part.label}
</a>
</span>
))}
</div>
);
}
@@ -51,7 +51,7 @@ export function sidePadding(panels: PanelWidths, margin: number) {
}
/**
* The source of a base map whose operator forbids prefetching (OpenHikingMap, MAP-01): it
* The source of a base map whose operator forbids prefetching (OpenHikingMap, CyclOSM): it
* loads only what the camera shows, and the flyover's warm-up passes it by.
*/
const VIEW_ONLY_SOURCE = 'base-view-only';
@@ -68,8 +68,8 @@ function rasterStyle(tiles: string[], maxzoom: number, sourceId = 'base', attrib
export function styleFor(layer: TourBaseLayer): string | StyleSpecification {
if (layer === 'satellite') return rasterStyle([SATELLITE_TILE_URL], 19);
if (layer === 'topo') return rasterStyle(TOUR_RASTER_BASES.topo.tiles, TOUR_RASTER_BASES.topo.maxZoom);
if (layer === 'hiking') {
const { tiles, maxZoom, attribution } = TOUR_RASTER_BASES.hiking;
if (layer === 'hiking' || layer === 'cycle') {
const { tiles, maxZoom, attribution } = TOUR_RASTER_BASES[layer];
return rasterStyle(tiles, maxZoom, VIEW_ONLY_SOURCE, attribution);
}
return OPENFREEMAP_DEFAULT_STYLE;
+3 -2
View File
@@ -46,12 +46,13 @@ export function withoutHeights<
/**
* What the time on the move is called: walking for a hike (in the wording the
* place asks for), paddling for a paddle tour.
* place asks for), riding for a bike tour, paddling for a paddle tour.
*/
export function movingTimeLabel(
tour: { tour_type?: string } | undefined,
hikeLabel: string,
t: (key: string) => string
): string {
return tour?.tour_type === 'kayak' ? t('tours.paddle.timeEstimatedShort') : hikeLabel;
if (tour?.tour_type === 'kayak') return t('tours.paddle.timeEstimatedShort');
return tour?.tour_type === 'bike' ? t('tours.bike.timeEstimatedShort') : hikeLabel;
}
+7 -2
View File
@@ -14,6 +14,8 @@ import type { TourBaseLayer } from '../Map/MapLayerSwitcher';
*
* - Hike: the standard map first, as before; topo, OpenHikingMap (MAP-01, while the admin
* allows it) and satellite beside it; via ferratas.
* - Bike (BIK-01): the standard map, topo for the climbs, CyclOSM (BIK-06, while the admin
* allows it) and satellite. No ferratas, a bike tour climbs none.
* - Paddle: the standard map, whose water reads clearly, and satellite. No topo and
* no ferratas, those are hiking maps. Seamarks as an overlay.
* - Any other type (switched off, or one this build does not plan): the standard map
@@ -35,15 +37,18 @@ const HIKE: TourMapLayerSet = {
overlays: ['ferratas'],
};
const HIKE_WITH_HIKING_MAP: TourMapLayerSet = { ...HIKE, bases: ['default', 'topo', 'hiking', 'satellite'] };
const BIKE: TourMapLayerSet = { bases: ['default', 'topo', 'satellite'], defaultBase: 'default', overlays: [] };
const BIKE_WITH_CYCLE_MAP: TourMapLayerSet = { ...BIKE, bases: ['default', 'topo', 'cycle', 'satellite'] };
const PADDLE: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: ['seamarks'] };
const OTHER: TourMapLayerSet = { bases: ['default', 'satellite'], defaultBase: 'default', overlays: [] };
/** `hikingMap`: the admin allows OpenHikingMap on this install (MAP-01). */
/** `hikingMap`, `cycleMap`: the admin allows OpenHikingMap (MAP-01) and CyclOSM (BIK-06) on this install. */
export function tourMapLayersFor(
type: TourTypeKey | null | undefined,
{ hikingMap = false }: { hikingMap?: boolean } = {}
{ hikingMap = false, cycleMap = false }: { hikingMap?: boolean; cycleMap?: boolean } = {}
): TourMapLayerSet {
if (type === 'hike') return hikingMap ? HIKE_WITH_HIKING_MAP : HIKE;
if (type === 'bike') return cycleMap ? BIKE_WITH_CYCLE_MAP : BIKE;
if (type === 'kayak') return PADDLE;
return OTHER;
}
+7 -7
View File
@@ -1,6 +1,7 @@
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, CalendarDays, ChevronDown, Info, Mountain, Plus, Waves } from 'lucide-react'
import { AlertTriangle, CalendarDays, ChevronDown, Info, Plus } from 'lucide-react'
import { createElement } from 'react'
import type { Day, DistanceUnit } from '../../types'
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
import { useTranslation } from '../../i18n'
@@ -14,6 +15,7 @@ import { type TourDay, dayMenuLabel } from './tourDays'
import { tourMetricFacts } from './tourFacts'
import type { TourDayAssignment } from './useTourDayAssignment'
import { useRailFold } from './railFold'
import { tourDayRowIcon } from './tourDayRowIcon'
/**
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
@@ -24,16 +26,14 @@ export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_d
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
// A paddle tour has no grade; its tile names the type instead.
const paddle = tour.tour_type === 'kayak'
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : paddle ? t('tourTypes.kayak') : null
// A tour without a grade (a paddle or a bike tour, an import) names its type instead; a hike keeps the mountain.
const type = tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'hike'
const label = level != null ? t(`tours.planner.difficulty.t${level}`) : type !== 'hike' ? t(`tourTypes.${type}`) : null
return (
<span className="relative flex-none">
<Tooltip label={label ?? ''} disabled={label == null}>
<span aria-label={label ?? undefined} className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content" style={fs(12, 'body')}>
{level != null ? `T${level}` : paddle
? <Waves size={15} strokeWidth={1.9} className="text-content-secondary" />
: <Mountain size={15} strokeWidth={1.9} className="text-content-secondary" />}
{level != null ? `T${level}` : createElement(tourDayRowIcon(type), { size: 15, strokeWidth: 1.9, className: 'text-content-secondary' })}
</span>
</Tooltip>
{source !== 'trek' && (
@@ -8,9 +8,11 @@ import { useTourGpxExport } from './useTourGpxExport';
const repo = vi.hoisted(() => ({ exportGpx: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
const download = vi.hoisted(() => ({ downloadBlob: vi.fn() }));
const share = vi.hoisted(() => ({ canShareFile: vi.fn(() => false), shareFile: vi.fn() }));
vi.mock('../../repo/tourRepo', () => ({ tourRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../utils/fileDownload', () => download);
vi.mock('../../utils/fileShare', () => share);
const target = { tripId: 1, placeId: 7, name: 'Kramer' };
const blob = new Blob(['<gpx/>'], { type: 'application/gpx+xml' });
@@ -26,8 +28,12 @@ function refused(status: number) {
});
}
/** The server's file as handed on: the same bytes under its name, typed as GPX. */
const gpxFile = (name: string) => expect.objectContaining({ name, type: 'application/gpx+xml', size: blob.size });
beforeEach(() => {
vi.clearAllMocks();
share.canShareFile.mockReturnValue(false);
repo.exportGpx.mockResolvedValue({ offline: false, blob, filename: 'Kramer-über.gpx' });
});
@@ -48,7 +54,7 @@ describe('useTourGpxExport (PR1-06A)', () => {
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.exportGpx(target));
expect(repo.exportGpx).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
expect(download.downloadBlob).toHaveBeenCalledWith(blob, 'Kramer-über.gpx');
expect(download.downloadBlob).toHaveBeenCalledWith(gpxFile('Kramer-über.gpx'), 'Kramer-über.gpx');
expect(result.current.exporting).toBe(false);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -57,7 +63,7 @@ describe('useTourGpxExport (PR1-06A)', () => {
repo.exportGpx.mockResolvedValue({ offline: false, blob, filename: null });
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.exportGpx({ ...target, name: ' ' }));
expect(download.downloadBlob).toHaveBeenCalledWith(blob, 'tour.gpx');
expect(download.downloadBlob).toHaveBeenCalledWith(gpxFile('tour.gpx'), 'tour.gpx');
});
it('says why nothing came: offline, a tour without a route, or a failure', async () => {
@@ -95,6 +101,64 @@ describe('useTourGpxExport (PR1-06A)', () => {
});
});
describe('useTourGpxExport, sharing (IO-01)', () => {
it('asks the platform about a GPX file, not about sharing in general', () => {
share.canShareFile.mockReturnValue(true);
const { result } = renderHook(() => useTourGpxExport());
expect(result.current.canShare).toBe(true);
expect(share.canShareFile).toHaveBeenCalledWith('tour.gpx', 'application/gpx+xml');
});
it('hands the same file to the share sheet under the tour name, and a closed sheet says nothing', async () => {
share.shareFile.mockResolvedValue('cancelled');
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.shareGpx(target));
expect(share.shareFile).toHaveBeenCalledWith(gpxFile('Kramer-über.gpx'), 'Kramer');
expect(download.downloadBlob).not.toHaveBeenCalled();
expect(toast.info).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
});
it('keeps the file when the browser wants a fresh tap, and the next tap shares it without fetching again', async () => {
share.shareFile.mockResolvedValueOnce('needs-gesture').mockResolvedValueOnce('shared');
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.shareGpx(target));
expect(toast.info).toHaveBeenCalledWith('tours.gpxExport.shareReady');
await act(() => result.current.shareGpx(target));
expect(repo.exportGpx).toHaveBeenCalledTimes(1);
expect(share.shareFile).toHaveBeenCalledTimes(2);
expect(toast.error).not.toHaveBeenCalled();
});
it('says a failed share leaves the download, and a refusal on the kept file is a failure too', async () => {
share.shareFile.mockResolvedValueOnce('failed');
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.shareGpx(target));
expect(toast.error).toHaveBeenLastCalledWith('tours.gpxExport.shareFailed');
share.shareFile.mockResolvedValueOnce('needs-gesture').mockResolvedValueOnce('needs-gesture');
await act(() => result.current.shareGpx(target));
await act(() => result.current.shareGpx(target));
expect(toast.error).toHaveBeenCalledTimes(2);
});
it('a kept file belongs to its tour: another tour is fetched afresh', async () => {
share.shareFile.mockResolvedValueOnce('needs-gesture').mockResolvedValueOnce('shared');
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.shareGpx(target));
await act(() => result.current.shareGpx({ ...target, placeId: 8 }));
expect(repo.exportGpx).toHaveBeenCalledTimes(2);
});
it('shares nothing when there is no file to hand over', async () => {
repo.exportGpx.mockResolvedValueOnce({ offline: true });
const { result } = renderHook(() => useTourGpxExport());
await act(() => result.current.shareGpx(target));
expect(share.shareFile).not.toHaveBeenCalled();
expect(toast.info).toHaveBeenCalledWith('tours.gpxExport.offline');
});
});
describe('TourGpxExportButton (PR1-06A)', () => {
it('exports from the footer, apart from the navigation', async () => {
render(<TourGpxExportButton target={target} />);
@@ -104,6 +168,27 @@ describe('TourGpxExportButton (PR1-06A)', () => {
expect(repo.exportGpx).toHaveBeenCalledTimes(1);
});
it('offers the share beside the download only where the platform takes GPX files (IO-01)', async () => {
const { unmount } = render(<TourGpxExportButton target={target} />);
expect(screen.queryByTestId('tour-gpx-share')).toBeNull();
unmount();
share.canShareFile.mockReturnValue(true);
share.shareFile.mockResolvedValue('shared');
render(<TourGpxExportButton target={target} />);
const button = screen.getByTestId('tour-gpx-share');
expect(button).toHaveAccessibleName('tours.gpxExport.share');
await act(async () => fireEvent.click(button));
expect(share.shareFile).toHaveBeenCalledTimes(1);
});
it('the planner head shares too, and waits for the save like the download', () => {
share.canShareFile.mockReturnValue(true);
const { rerender } = render(<TourGpxExportBarButton target={target} unsaved />);
expect(screen.getByRole('button', { name: 'tours.gpxExport.shareSaveFirst' })).toBeDisabled();
rerender(<TourGpxExportBarButton target={target} unsaved={false} />);
expect(screen.getByRole('button', { name: 'tours.gpxExport.share' })).toBeEnabled();
});
it('waits in the planner head while the tour has unsaved changes, and says so', () => {
const { rerender } = render(<TourGpxExportBarButton target={target} unsaved />);
expect(screen.getByRole('button', { name: 'tours.gpxExport.saveFirst' })).toBeDisabled();
+47 -11
View File
@@ -3,8 +3,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../i18n';
import { tourRepo } from '../../repo/tourRepo';
import { downloadBlob } from '../../utils/fileDownload';
import { canShareFile, shareFile } from '../../utils/fileShare';
import { useToast } from '../shared/Toast';
const GPX_TYPE = 'application/gpx+xml';
/** What a tour's GPX export needs to know about the tour. */
export interface TourGpxExportTarget {
tripId: number | string;
@@ -13,22 +16,28 @@ export interface TourGpxExportTarget {
}
/**
* PR1-06A: a saved tour handed over as a GPX file, started on purpose. The
* file is the server's (the saved line, as saved), so an unsaved edit in the
* planner is not in it. One export at a time per caller; a dialog closed
* mid-download cancels it. Nothing about the tour changes either way.
* PR1-06A, IO-01: a saved tour handed over as a GPX file, started on purpose:
* downloaded, or given to the system's share sheet where the platform takes
* GPX files. Both use the same file, the server's (the saved line, as saved),
* so an unsaved edit in the planner is not in it. One export at a time per
* caller; a dialog closed mid-download cancels it. Nothing about the tour
* changes either way.
*/
export function useTourGpxExport() {
const { t } = useTranslation();
const toast = useToast();
const [exportingId, setExportingId] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
// A file fetched for a share the browser then refused for want of a fresh tap, kept for the next one.
const readyRef = useRef<{ placeId: number; file: File } | null>(null);
const [canShare] = useState(() => canShareFile('tour.gpx', GPX_TYPE));
useEffect(() => () => abortRef.current?.abort(), []);
const exportGpx = useCallback(
async ({ tripId, placeId, name }: TourGpxExportTarget) => {
if (abortRef.current) return;
/** The tour's file from the server, or null when there is none to hand over, said in a toast. */
const fetchFile = useCallback(
async ({ tripId, placeId, name }: TourGpxExportTarget): Promise<File | null> => {
if (abortRef.current) return null;
const controller = new AbortController();
abortRef.current = controller;
setExportingId(placeId);
@@ -36,13 +45,14 @@ export function useTourGpxExport() {
const result = await tourRepo.exportGpx(tripId, placeId, controller.signal);
if (!('blob' in result)) {
toast.info(t('tours.gpxExport.offline'));
return;
return null;
}
downloadBlob(result.blob, result.filename ?? `${name.trim() || 'tour'}.gpx`);
return new File([result.blob], result.filename ?? `${name.trim() || 'tour'}.gpx`, { type: GPX_TYPE });
} catch (error: unknown) {
if (controller.signal.aborted) return;
if (controller.signal.aborted) return null;
const noRoute = isAxiosError(error) && error.response?.status === 422;
toast.error(t(noRoute ? 'tours.gpxExport.noRoute' : 'tours.gpxExport.failed'));
return null;
} finally {
if (abortRef.current === controller) abortRef.current = null;
if (!controller.signal.aborted) setExportingId(null);
@@ -51,5 +61,31 @@ export function useTourGpxExport() {
[t, toast]
);
return { exportGpx, exportingId, exporting: exportingId !== null };
const exportGpx = useCallback(
async (target: TourGpxExportTarget) => {
const file = await fetchFile(target);
if (file) downloadBlob(file, file.name);
},
[fetchFile]
);
const shareGpx = useCallback(
async (target: TourGpxExportTarget) => {
const ready = readyRef.current?.placeId === target.placeId ? readyRef.current.file : null;
readyRef.current = null;
// A file at hand goes to the sheet before anything is awaited, inside the tap that asked.
const file = ready ?? (await fetchFile(target));
if (!file) return;
const outcome = await shareFile(file, target.name);
if (outcome === 'needs-gesture' && !ready) {
readyRef.current = { placeId: target.placeId, file };
toast.info(t('tours.gpxExport.shareReady'));
} else if (outcome === 'failed' || outcome === 'needs-gesture') {
toast.error(t('tours.gpxExport.shareFailed'));
}
},
[fetchFile, t, toast]
);
return { exportGpx, shareGpx, canShare, exportingId, exporting: exportingId !== null };
}
@@ -1,9 +1,24 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useTileHealth, useTourRasterOverlay } from './useTourRasterOverlay';
import { useBaseMapHealth, useTileHealth, useTourRasterOverlay } from './useTourRasterOverlay';
const SEAMARKS = { id: 'seamarks', url: 'https://x/{z}/{x}/{y}.png', attribution: 'x', minZoom: 6, maxZoom: 18 };
describe('useBaseMapHealth (MAP-01, BIK-06)', () => {
it('watches a map on terms only, names it, and asks afresh when another becomes the base map', () => {
const { result, rerender } = renderHook((layer: string) => useBaseMapHealth(layer), { initialProps: 'topo' });
expect(result.current).toBeUndefined();
rerender('cycle');
expect(result.current).toMatchObject({ base: 'cycle', down: false });
act(() => {
for (let i = 0; i < 4; i += 1) result.current?.onTileError();
});
expect(result.current?.down).toBe(true);
rerender('hiking');
expect(result.current).toMatchObject({ base: 'hiking', down: false });
});
});
describe('useTileHealth', () => {
it('counts a source down after a run of failures, never after a stray one, and up again on one tile', () => {
const { result } = renderHook(() => useTileHealth());
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MapRasterOverlay, ToggleableRasterOverlay } from '../Map/mapRasterOverlay';
import type { TileHealth } from '../Map/tourRasterBases';
import { isOnTermsBase, type BaseMapHealth, type TileHealth } from '../Map/tourRasterBases';
/** Failed tiles in a row, with none arriving between them, before the source counts as down. */
const FAILURES_UNTIL_DOWN = 4;
@@ -24,6 +24,23 @@ export function useTileHealth(): TileHealth & { reset: () => void } {
return useMemo(() => ({ down, onTileError, onTileLoad: reset, reset }), [down, onTileError, reset]);
}
/**
* Whether the base map answers while it is a map on its operator's terms
* (OpenHikingMap, CyclOSM), asked afresh each time one becomes the base map;
* nothing for any other base map.
*/
export function useBaseMapHealth(baseLayer: string): BaseMapHealth | undefined {
const { down, onTileError, onTileLoad, reset } = useTileHealth();
const onTerms = isOnTermsBase(baseLayer) ? baseLayer : null;
useEffect(() => {
if (onTerms) reset();
}, [onTerms, reset]);
return useMemo(
() => (onTerms ? { base: onTerms, down, onTileError, onTileLoad } : undefined),
[onTerms, down, onTileError, onTileLoad]
);
}
/**
* An overlay the planner offers, off until switched on; whether it is on is
* the planner's (`active`, `onToggle`), so it outlasts the map. Its tile
+34 -75
View File
@@ -1,10 +1,10 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Plus } from 'lucide-react'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
import { POPOVER } from '../Packing/packingPopoverStyles'
import { PopoverItem } from '../Packing/PackingPopover'
import { PILL, fs } from './DialogShell'
import { usePopoverList } from './usePopoverList'
// The editors' fields in the cards' language: framed white boxes under small
// Geist eyebrows. The sizes are classes, not fs() styles, so components that
@@ -94,8 +94,6 @@ export interface PillOption<T extends string> {
hint?: string
}
const itemsOf = (list: HTMLElement | null) => Array.from(list?.querySelectorAll('button') ?? [])
/**
* A head band pill that opens a list to pick from, such as the booking type.
* Opening moves the focus onto the current choice; the arrow keys walk the
@@ -116,71 +114,14 @@ export function PillSelect<T extends string>({ value, options, onChange, label,
/** In the body of a dialog instead of on its head band. */
quiet?: boolean
}) {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const startAt = useRef(0)
const listId = useId()
const box = useAnchoredPosition(triggerRef, open, { matchWidth: false, estimatedHeight: 320 })
const list = usePopoverList(options.findIndex(o => o.value === value))
const { open, toggle, triggerRef, listId } = list
const current = options.find(o => o.value === value)
const shown = current ?? fallback
const listShown = open && !!box
const toggle = () => {
if (!open) startAt.current = Math.max(0, options.findIndex(o => o.value === value))
setOpen(o => !o)
}
useEffect(() => {
if (listShown) itemsOf(menuRef.current)[startAt.current]?.focus({ preventScroll: true })
}, [listShown])
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
setOpen(false)
}
// Captured and stopped, so the key takes back the list and not the dialog under it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.stopPropagation()
setOpen(false)
triggerRef.current?.focus()
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
const pick = (next: T) => {
setOpen(false)
list.close()
onChange(next)
triggerRef.current?.focus()
}
const onListKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Tab') {
// Back on the pill before the browser moves on, so Tab carries on from there.
setOpen(false)
triggerRef.current?.focus()
return
}
const items = itemsOf(menuRef.current)
const at = items.indexOf(document.activeElement as HTMLButtonElement)
const last = items.length - 1
let next: number
if (e.key === 'ArrowDown') next = at < 0 || at === last ? 0 : at + 1
else if (e.key === 'ArrowUp') next = at <= 0 ? last : at - 1
else if (e.key === 'Home') next = 0
else if (e.key === 'End') next = last
else return
e.preventDefault()
items[next]?.focus()
}
return (
@@ -193,21 +134,39 @@ export function PillSelect<T extends string>({ value, options, onChange, label,
{shown?.hint && <span className="font-medium text-content-muted">{shown.hint}</span>}
<ChevronDown size={12} strokeWidth={2.2} className={`-me-0.5 flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && box && createPortal(
<div ref={menuRef} id={listId} role="group" aria-label={label} onKeyDown={onListKey}
className="fixed z-[var(--z-toast)] min-w-[200px] overflow-y-auto"
style={{ ...POPOVER, top: box.top, bottom: box.bottom, left: box.left, maxHeight: box.maxHeight }}>
{options.map(o => (
<PopoverItem key={o.value} icon={o.icon ?? null} label={o.label} active={o.value === value} onClick={() => pick(o.value)}
trailing={o.hint ? <span className="font-geist text-content-faint" style={fs(11)}>{o.hint}</span> : undefined} />
))}
</div>,
document.body,
)}
<PopoverListPanel list={list} label={label} className="min-w-[200px]">
{options.map(o => (
<PopoverItem key={o.value} icon={o.icon ?? null} label={o.label} active={o.value === value} onClick={() => pick(o.value)}
trailing={o.hint ? <span className="font-geist text-content-faint" style={fs(11)}>{o.hint}</span> : undefined} />
))}
</PopoverListPanel>
</>
)
}
/**
* The open list of a `usePopoverList` button, in the popover look, laid over
* the page beside its button: where the button sits decides where it opens.
*/
export function PopoverListPanel({ list, label, className = '', children }: {
list: ReturnType<typeof usePopoverList>
/** The list's name for a screen reader. */
label: string
className?: string
children: ReactNode
}) {
const { open, box, menuRef, listId, onListKey } = list
if (!open || !box) return null
return createPortal(
<div ref={menuRef} id={listId} role="group" aria-label={label} onKeyDown={onListKey}
className={`fixed z-[var(--z-toast)] overflow-y-auto ${className}`}
style={{ ...POPOVER, top: box.top, bottom: box.bottom, left: box.left, maxHeight: box.maxHeight }}>
{children}
</div>,
document.body,
)
}
/** The dashed full-width button that adds a row: a stop, a leg. */
export function AddRowButton({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: ReactNode }) {
return (
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '../../../tests/helpers/render';
import { PopoverListPanel } from './dialogParts';
import { usePopoverList } from './usePopoverList';
// FE-POPOVER-LIST-001 to FE-POPOVER-LIST-006
function Picker({ current = 1, onPick = vi.fn() }: { current?: number; onPick?: (index: number) => void }) {
const list = usePopoverList(current);
return (
<>
<button type="button" ref={list.triggerRef} onClick={list.toggle} aria-expanded={list.open}>
trigger
</button>
<PopoverListPanel list={list} label="choices">
{['one', 'two', 'three'].map((name, index) => (
<button
key={name}
type="button"
onClick={() => {
onPick(index);
list.close();
}}
>
{name}
</button>
))}
</PopoverListPanel>
</>
);
}
const trigger = () => screen.getByRole('button', { name: 'trigger' });
afterEach(() => vi.restoreAllMocks());
describe('usePopoverList', () => {
it('FE-POPOVER-LIST-001: opening puts the focus on the current choice', () => {
render(<Picker current={1} />);
fireEvent.click(trigger());
expect(trigger()).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('group', { name: 'choices' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'two' })).toHaveFocus();
});
it('FE-POPOVER-LIST-002: the arrows walk round the list, Home and End go to its ends', () => {
render(<Picker current={0} />);
fireEvent.click(trigger());
const group = screen.getByRole('group');
fireEvent.keyDown(group, { key: 'ArrowUp' });
expect(screen.getByRole('button', { name: 'three' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'ArrowDown' });
expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'End' });
expect(screen.getByRole('button', { name: 'three' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'Home' });
expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
fireEvent.keyDown(group, { key: 'x' });
expect(screen.getByRole('button', { name: 'one' })).toHaveFocus();
});
it('FE-POPOVER-LIST-003: Escape closes the list only, and gives the focus back to the button', () => {
const outer = vi.fn();
document.addEventListener('keydown', outer);
render(<Picker />);
fireEvent.click(trigger());
fireEvent.keyDown(screen.getByRole('button', { name: 'two' }), { key: 'Escape' });
expect(screen.queryByRole('group')).toBeNull();
expect(trigger()).toHaveFocus();
expect(outer).not.toHaveBeenCalled();
document.removeEventListener('keydown', outer);
});
it('FE-POPOVER-LIST-004: Tab closes it and goes on from the button; a click beside it closes it', () => {
render(<Picker />);
fireEvent.click(trigger());
fireEvent.keyDown(screen.getByRole('group'), { key: 'Tab' });
expect(screen.queryByRole('group')).toBeNull();
expect(trigger()).toHaveFocus();
fireEvent.click(trigger());
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('group')).toBeNull();
});
it('FE-POPOVER-LIST-005: a choice closes the list and hands the focus back', () => {
const onPick = vi.fn();
render(<Picker onPick={onPick} />);
fireEvent.click(trigger());
fireEvent.click(screen.getByRole('button', { name: 'three' }));
expect(onPick).toHaveBeenCalledWith(2);
expect(screen.queryByRole('group')).toBeNull();
expect(trigger()).toHaveFocus();
});
it('FE-POPOVER-LIST-006: a list that would run past the window moves back inside it, and stays where it fits', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const isList = this.getAttribute('role') === 'group';
const left = isList ? window.innerWidth - 100 : window.innerWidth - 40;
return { left, right: left + 240, top: 10, bottom: 40, width: 240, height: 30, x: left, y: 10 } as DOMRect;
});
render(<Picker />);
act(() => fireEvent.click(trigger()));
// 240 wide from 100 short of the edge: 140 past it, and the 8 kept clear of it.
expect(screen.getByRole('group').style.translate).toBe('-148px 0');
});
});
@@ -0,0 +1,98 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition';
const itemsOf = (list: HTMLElement | null) => Array.from(list?.querySelectorAll('button') ?? []);
/** Room kept between an open list and the window's sides. */
const EDGE = 8;
/**
* How a button that opens a list to pick from behaves, whatever it looks like:
* opening moves the focus onto the current choice; the arrow keys, Home and End
* walk the list; Escape closes it and leaves a dialog under it open; Tab closes
* it and goes on from the button; a click beside it closes it. The caller draws
* the button and the list and hands `currentIndex`, the choice held now.
*/
export function usePopoverList(currentIndex: number, options: { estimatedHeight?: number } = {}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const startAt = useRef(0);
const listId = useId();
const box = useAnchoredPosition(triggerRef, open, {
matchWidth: false,
estimatedHeight: options.estimatedHeight ?? 320,
});
const listShown = open && !!box;
const toggle = () => {
if (!open) startAt.current = Math.max(0, currentIndex);
setOpen((o) => !o);
};
useEffect(() => {
if (listShown) itemsOf(menuRef.current)[startAt.current]?.focus({ preventScroll: true });
}, [listShown]);
// The list opens from its button's start; one wider than the room beside a button near the
// window's side moves back inside, as far as it must, before it is painted.
useLayoutEffect(() => {
const menu = menuRef.current;
if (!listShown || !menu) return;
menu.style.translate = '';
const rect = menu.getBoundingClientRect();
let shift = Math.min(0, window.innerWidth - EDGE - rect.right);
if (rect.left + shift < EDGE) shift = EDGE - rect.left;
if (shift) menu.style.translate = `${Math.round(shift)}px 0`;
}, [listShown, box]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
setOpen(false);
};
// Captured and stopped, so the key takes back the list and not the dialog under it.
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey, true);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey, true);
};
}, [open]);
/** Closes the list after a choice and hands the focus back to the button. */
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const onListKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Tab') {
// Back on the button before the browser moves on, so Tab carries on from there.
setOpen(false);
triggerRef.current?.focus();
return;
}
const items = itemsOf(menuRef.current);
const at = items.indexOf(document.activeElement as HTMLButtonElement);
const last = items.length - 1;
let next: number;
if (e.key === 'ArrowDown') next = at < 0 || at === last ? 0 : at + 1;
else if (e.key === 'ArrowUp') next = at <= 0 ? last : at - 1;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = last;
else return;
e.preventDefault();
items[next]?.focus();
};
return { open, box, toggle, close, onListKey, triggerRef, menuRef, listId };
}
+11
View File
@@ -40,6 +40,17 @@ export const OPENHIKINGMAP_TILE_ATTRIBUTION =
'&copy; <a href="https://wiki.openstreetmap.org/wiki/OpenHikingMap">OpenHikingMap</a> | &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> | <a href="https://openmaps.fr/donate">❤️ Donation</a>'
export const OPENHIKINGMAP_TILE_MAXZOOM = 18
/**
* BIK-06: CyclOSM, the cycling base map the Tours planner offers a bike tour while the admin
* allows it. Served by OpenStreetMap France on three shards, free for moderate use with a
* Referer under its usage policy; loaded only as viewed, like OpenHikingMap. Checked on
* 19.10.2026: zoom 0 to 20, CORS open, a day's Cache-Control.
*/
export const CYCLOSM_TILE_URL = 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png'
export const CYCLOSM_TILE_ATTRIBUTION =
'&copy; <a href="https://www.cyclosm.org">CyclOSM</a> | &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
export const CYCLOSM_TILE_MAXZOOM = 20
/**
* The basemap for a browser that will not give MapLibre a WebGL context (#2288).
*
+1
View File
@@ -27,6 +27,7 @@ import 'leaflet/dist/leaflet.css'
import './index.css'
import './styles/ferratas.css'
import './styles/paddle.css'
import './styles/bike.css'
// Native HTML5 drag-and-drop never fires on touch input, so the planner's place /
// day reordering was dead on Android and iOS. The `drag-drop-touch` polyfill synthesises
// the standard drag events from touch gestures over draggable elements (#1265). It is
@@ -3,7 +3,7 @@ import { useTranslation } from '../../../i18n'
import { useIsDark } from '../../../hooks/useIsDark'
import {
Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Check, Loader2, Mountain, CarFront, Footprints,
Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Check, Loader2, Mountain, CarFront,
} from 'lucide-react'
import DawarichIcon from '../../../components/shared/DawarichIcon'
import AirTrailIcon from '../../../components/shared/AirTrailIcon'
@@ -66,8 +66,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
toursHikingMap,
toggleToursHikingMap,
mapSwitches,
} = useAddonManager()
if (loading) {
@@ -124,13 +123,16 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
<MSubRow
icon={Footprints}
title={t('admin.toursHikingMap.title')}
subtitle={t('admin.toursHikingMap.subtitle')}
enabled={toursHikingMap}
onToggle={() => void toggleToursHikingMap()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<MSubRow
key={key}
icon={Icon}
title={t(titleKey)}
subtitle={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
)}
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
@@ -561,15 +561,20 @@ describe('TourPlannerMapStage', () => {
]);
});
it('an imported paddle tour is shown with the paddle maps, a type nobody plans with the standard set', () => {
it('an imported paddle tour is shown with the paddle maps, a bike tour with its own, a type nobody plans with the standard set', () => {
const { unmount } = renderStage(
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'kayak' } } })
);
expect(flat().tourLayers?.bases).toEqual(['default', 'satellite']);
unmount();
renderStage(
const bike = renderStage(
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'bike' } } })
);
expect(flat().tourLayers?.bases).toEqual(['default', 'topo', 'satellite']);
bike.unmount();
renderStage(
planner('view-gpx', { mode: { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'run' } } })
);
expect(flat().tourLayers?.bases).toEqual(['default', 'satellite']);
expect(flat().tourLayers?.overlays).toEqual([]);
expect(flat().ferrataLayer).toBeUndefined();
@@ -10,26 +10,34 @@ type Ferrata = Parameters<typeof useTourStageLayers>[1];
const ferrata = { layer: undefined, crossings: [], selected: null } as unknown as Ferrata;
function planner(mode: TourPlannerController['mode'], tourType: Planner['tourType'] = 'hike'): Planner {
return { mode, tourType, mapBaseLayer: 'hiking', mapOverlays: new Set(), toggleMapOverlay: () => {} };
function planner(
mode: TourPlannerController['mode'],
tourType: Planner['tourType'] = 'hike',
mapBaseLayer: Planner['mapBaseLayer'] = 'hiking'
): Planner {
return { mode, tourType, mapBaseLayer, mapOverlays: new Set(), toggleMapOverlay: () => {} };
}
const basesOf = (p: Planner) => renderHook(() => useTourStageLayers(p, ferrata)).result.current;
describe('useTourStageLayers: OpenHikingMap (MAP-01)', () => {
beforeEach(() => useAddonStore.setState({ toursHikingMap: true }));
beforeEach(() => useAddonStore.setState({ toursHikingMap: true, toursCycleMap: true }));
it('offers it while a hike is planned, as a draft or a saved tour in the editor', () => {
for (const mode of [{ type: 'new-draft' }, { type: 'edit-saved', placeId: 4 }] as const) {
it('offers it while a hike is open: planned as a draft, a saved tour in the editor, or an import viewed as it came in', () => {
const viewed = { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'hike' } as TourListItem } as const;
for (const mode of [{ type: 'new-draft' }, { type: 'edit-saved', placeId: 4 }, viewed] as const) {
const layers = basesOf(planner(mode));
expect(layers.tourLayers.bases).toContain('hiking');
expect(layers.baseLayer).toBe('hiking');
}
});
it('not with no tour open, over an import viewed as it came in, or for a paddle tour', () => {
const viewed = { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'hike' } as TourListItem } as const;
for (const p of [planner({ type: 'neutral' }), planner(viewed), planner({ type: 'new-draft' }, 'kayak')]) {
it('not with no tour open, or for a paddle or a bike tour', () => {
for (const p of [
planner({ type: 'neutral' }),
planner({ type: 'new-draft' }, 'kayak'),
planner({ type: 'new-draft' }, 'bike'),
]) {
const layers = basesOf(p);
expect(layers.tourLayers.bases).not.toContain('hiking');
// The choice is kept; the map shows the type's default until a hike is planned again.
@@ -41,4 +49,17 @@ describe('useTourStageLayers: OpenHikingMap (MAP-01)', () => {
useAddonStore.setState({ toursHikingMap: false });
expect(basesOf(planner({ type: 'new-draft' })).tourLayers.bases).not.toContain('hiking');
});
it('BIK-06: CyclOSM for a bike tour that is open, watched for whether it answers, and only while the admin allows it', () => {
const viewed = { type: 'view-gpx', placeId: 9, tour: { place_id: 9, tour_type: 'bike' } as TourListItem } as const;
for (const mode of [{ type: 'new-draft' }, viewed] as const) {
const layers = basesOf(planner(mode, 'bike', 'cycle'));
expect(layers.tourLayers.bases).toEqual(['default', 'topo', 'cycle', 'satellite']);
expect(layers.baseLayer).toBe('cycle');
expect(layers.tourLayers.baseHealth).toMatchObject({ base: 'cycle', down: false });
}
expect(basesOf(planner({ type: 'new-draft' }, 'hike', 'cycle')).baseLayer).toBe('default');
useAddonStore.setState({ toursCycleMap: false });
expect(basesOf(planner({ type: 'new-draft' }, 'bike', 'cycle')).tourLayers.bases).not.toContain('cycle');
});
});
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { paddleMapItems, selectedPaddleKey } from '../../components/Tours/paddle/paddleMapLayer';
import { usePaddlePaint } from '../../components/Tours/paddle/paddlePaint';
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
import { baseLayerFor, SEAMARK_OVERLAY, tourMapLayersFor } from '../../components/Tours/tourMapLayers';
import { useTileHealth, useTourRasterOverlay } from '../../components/Tours/useTourRasterOverlay';
import { useBaseMapHealth, useTourRasterOverlay } from '../../components/Tours/useTourRasterOverlay';
import { useAddonStore } from '../../store/addonStore';
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore';
@@ -26,21 +26,18 @@ export function useTourStageLayers(
ferrata: FerrataLayerState
) {
const { mode } = tourPlanner;
// MAP-01: OpenHikingMap while a hike is being planned (a draft or a saved tour in the editor),
// never over an import viewed as it came in or with no tour open, and only while the admin offers it.
// MAP-01, BIK-06: a type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour) while a
// tour of that type is open, planned or viewed as it came in, never with no tour open, and only
// while the admin offers it.
const hikingMapAllowed = useAddonStore((state) => state.toursHikingMap);
const planning = mode.type === 'new-draft' || mode.type === 'edit-saved';
const cycleMapAllowed = useAddonStore((state) => state.toursCycleMap);
const tourOpen = mode.type !== 'neutral';
const layerSet = tourMapLayersFor(mode.type === 'view-gpx' ? mode.tour.tour_type : tourPlanner.tourType, {
hikingMap: hikingMapAllowed && planning,
hikingMap: hikingMapAllowed && tourOpen,
cycleMap: cycleMapAllowed && tourOpen,
});
const baseLayer = baseLayerFor(layerSet, tourPlanner.mapBaseLayer);
// Whether OpenHikingMap answers, asked afresh each time it becomes the base map.
const hikingMap = useTileHealth();
const { reset: resetHikingMap } = hikingMap;
const showsHikingMap = baseLayer === 'hiking';
useEffect(() => {
if (showsHikingMap) resetHikingMap();
}, [showsHikingMap, resetHikingMap]);
const baseHealth = useBaseMapHealth(baseLayer);
// With no tour open nothing is a hike yet: the ferratas wait for one, as the seamarks wait for a paddle tour.
const offersFerratas = mode.type !== 'neutral' && layerSet.overlays.includes('ferratas');
const { toggleMapOverlay } = tourPlanner;
@@ -74,9 +71,9 @@ export function useTourStageLayers(
bases: layerSet.bases,
overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [],
paddle,
hikingMap: showsHikingMap ? hikingMap : undefined,
baseHealth,
}),
[layerSet, seamarks, paddle, showsHikingMap, hikingMap]
[layerSet, seamarks, paddle, baseHealth]
);
return {
baseLayer,
@@ -8,6 +8,7 @@ import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleOb
import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore'
import { useHighlightedRoute } from '../../components/Tours/bike/useHighlightedRoute'
import { useMapLocationSearch } from './useMapLocationSearch'
import { usePlannerFocusPoints } from './usePlannerFocusPoints'
import { useTripPlanner } from './useTripPlanner'
@@ -75,6 +76,8 @@ export function useTripPlannerPage() {
{ portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc },
)
const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null)
// BIK-01 to BIK-05: the stretches a bike tour's card points at, over the route being edited.
const drawn = useHighlightedRoute(editingRoute && !wandererBrowser.open, route, routeWalking, partStyle.colors)
const { closeGpxTour } = tourPlanner
useEffect(() => {
@@ -116,9 +119,9 @@ export function useTripPlannerPage() {
trackPaddlePlan,
paddleChecklist,
tourDetailActions,
tourMap: { route,
routeWalking,
routeColors: partStyle.colors,
tourMap: { route: drawn.route,
routeWalking: drawn.walking,
routeColors: drawn.colors,
previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points,
focusPoints: mapFocusPoints,
// A picked wanderer trail frames itself; the rest follows the planner.
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import apiClient from '../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../api/tourBaseMaps';
import { tourTrailGradeApi } from '../api/tourTrailGrade';
import { OnlineOnlyError } from './onlineOnly';
import { tourTrailGradeRepo } from './tourTrailGradeRepo';
// FE-REPO-TRAIL-GRADE-001 to FE-REPO-TRAIL-GRADE-003
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
beforeEach(() => {
network.offline = false;
vi.restoreAllMocks();
});
describe('tourTrailGradeRepo (BIK-05)', () => {
it('FE-REPO-TRAIL-GRADE-001: asks only online, and offline says so instead', async () => {
network.offline = true;
const grades = vi.spyOn(tourTrailGradeApi, 'grades');
await expect(tourTrailGradeRepo.grades(3, { way_ids: [1] })).rejects.toBeInstanceOf(OnlineOnlyError);
expect(grades).not.toHaveBeenCalled();
});
it('FE-REPO-TRAIL-GRADE-002: posts the ways to the trip, with a timeout long enough for a long tour', async () => {
const answer = { ways: [], unanswered: [] };
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: answer });
const signal = new AbortController().signal;
await expect(tourTrailGradeRepo.grades(3, { way_ids: [1, 2] }, signal)).resolves.toBe(answer);
expect(post).toHaveBeenCalledWith('/trips/3/tours/trail-grades', { way_ids: [1, 2] }, { signal, timeout: 60_000 });
});
});
describe('tourBaseMaps admin api (MAP-01, BIK-06)', () => {
it('FE-REPO-TRAIL-GRADE-003: each map on terms has its own switch, read and written at its own path', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { enabled: true } });
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: { enabled: false } });
expect(await cycleMapAdminApi.get()).toEqual({ enabled: true });
expect(get).toHaveBeenCalledWith('/admin/tours-cycle-map');
expect(await cycleMapAdminApi.update(false)).toEqual({ enabled: false });
expect(put).toHaveBeenCalledWith('/admin/tours-cycle-map', { enabled: false });
await hikingMapAdminApi.update(true);
expect(put).toHaveBeenLastCalledWith('/admin/tours-hiking-map', { enabled: true });
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
import { tourTrailGradeApi } from '../api/tourTrailGrade';
import { requireOnline } from './onlineOnly';
/**
* BIK-05, online only: the grades come from OpenStreetMap through the server,
* and the server keeps them a day; the client keeps nothing. Offline the card
* says so.
*/
export const tourTrailGradeRepo = {
async grades(
tripId: number | string,
body: TourTrailGradeRequest,
signal?: AbortSignal
): Promise<TourTrailGradeResponse> {
requireOnline();
return await tourTrailGradeApi.grades(tripId, body, signal);
},
};
+4
View File
@@ -29,6 +29,8 @@ interface AddonState {
toursWanderer: boolean
/** Tours is on and the admin offers OpenHikingMap in its planner (MAP-01). */
toursHikingMap: boolean
/** Tours is on and the admin offers CyclOSM in its planner (BIK-06). */
toursCycleMap: boolean
loaded: boolean
loadAddons: () => Promise<void>
isEnabled: (id: string) => boolean
@@ -39,6 +41,7 @@ export const useAddonStore = create<AddonState>((set, get) => ({
bagTracking: false,
toursWanderer: false,
toursHikingMap: false,
toursCycleMap: false,
loaded: false,
loadAddons: async () => {
@@ -47,6 +50,7 @@ export const useAddonStore = create<AddonState>((set, get) => ({
set({ addons: data.addons || [], bagTracking: !!data.bagTracking,
toursWanderer: !!data.toursWanderer,
toursHikingMap: !!data.toursHikingMap,
toursCycleMap: !!data.toursCycleMap,
loaded: true,
})
} catch {
@@ -0,0 +1,26 @@
import { create } from 'zustand';
/**
* BIK-01 to BIK-05: the stretches of the planned route a card points at (a
* surface, a kind of way, a trail grade), drawn over the route on the planner
* map in the colour of what they are. `paint` names a token of the bike
* paints, resolved where the map is drawn, so the theme decides the colour.
*/
export interface RouteHighlight {
/** What is pointed at, so its row can show it is on. */
id: string;
lines: [number, number][][];
paint: string;
}
interface TourRouteHighlightState {
highlight: RouteHighlight | null;
show: (highlight: RouteHighlight | null) => void;
clear: () => void;
}
export const useTourRouteHighlightStore = create<TourRouteHighlightState>((set) => ({
highlight: null,
show: (highlight) => set({ highlight }),
clear: () => set({ highlight: null }),
}));
+49
View File
@@ -0,0 +1,49 @@
/* ============================================================================
Bike tours on the maps and in the planner (BIK-01 to BIK-05): the colours of
the surfaces a route runs on and of the trail grades OpenStreetMap gives its
ways. Global, since the 2D, GL and 3D maps and the planner's cards all read
the tokens.
========================================================================== */
/* Surfaces run from smooth to rough: asphalt in green, rough paving in teal,
compacted ways in blue, gravel in amber, natural ground in brown, what the
data does not say in grey. A colour sorts the surface, it never says a way
can or cannot be ridden. */
:root {
--bike-surface-paved: #16a34a;
--bike-surface-rough: #0d9488;
--bike-surface-compacted: #2563eb;
--bike-surface-gravel: #d97706;
--bike-surface-ground: #92400e;
--bike-surface-unknown: #94a3b8;
--bike-highlight-casing: #ffffff;
}
.dark {
--bike-surface-paved: #22c55e;
--bike-surface-rough: #2dd4bf;
--bike-surface-compacted: #60a5fa;
--bike-surface-gravel: #f59e0b;
--bike-surface-ground: #d6a06e;
--bike-surface-unknown: #64748b;
--bike-highlight-casing: #0f172a;
}
/* The mtb:scale steps in the colours trail maps commonly use for them (S0
green, S1 blue, S2 red, S3 black, S4 and up violet), so a rider reads them
at a glance; in the dark theme the black step turns light. */
:root {
--bike-trail-s0: #16a34a;
--bike-trail-s1: #2563eb;
--bike-trail-s2: #dc2626;
--bike-trail-s3: #111827;
--bike-trail-s4: #7c3aed;
}
.dark {
--bike-trail-s0: #22c55e;
--bike-trail-s1: #60a5fa;
--bike-trail-s2: #f87171;
--bike-trail-s3: #e5e7eb;
--bike-trail-s4: #a78bfa;
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { canShareFile, shareFile } from './fileShare';
const gpx = () => new File(['<gpx/>'], 'Kramer.gpx', { type: 'application/gpx+xml' });
function stubShare(share: unknown, canShare: unknown) {
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
Object.defineProperty(navigator, 'canShare', { value: canShare, configurable: true });
}
afterEach(() => stubShare(undefined, undefined));
describe('canShareFile (IO-01)', () => {
it('asks the platform about this very kind of file', () => {
const canShare = vi.fn((data: ShareData) => data.files?.[0]?.name.endsWith('.gpx') === true);
stubShare(vi.fn(), canShare);
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(true);
expect(canShare.mock.calls[0][0].files?.[0]).toMatchObject({ name: 'tour.gpx', type: 'application/gpx+xml' });
canShare.mockReturnValue(false);
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
});
it('is no without the share API, without canShare, or when the question itself throws', () => {
stubShare(undefined, () => true);
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
stubShare(vi.fn(), undefined);
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
stubShare(vi.fn(), () => {
throw new TypeError('files not supported');
});
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
});
});
describe('shareFile (IO-01)', () => {
it('opens the sheet with the file and its title', async () => {
const share = vi.fn().mockResolvedValue(undefined);
stubShare(share, () => true);
const file = gpx();
await expect(shareFile(file, 'Kramer')).resolves.toBe('shared');
expect(share).toHaveBeenCalledWith({ files: [file], title: 'Kramer' });
});
it('tells a closed sheet, a refused tap and any other failure apart', async () => {
const share = vi.fn();
stubShare(share, () => true);
share.mockRejectedValueOnce(new DOMException('closed', 'AbortError'));
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('cancelled');
share.mockRejectedValueOnce(new DOMException('no activation', 'NotAllowedError'));
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('needs-gesture');
share.mockRejectedValueOnce(new DOMException('bad data', 'DataError'));
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('failed');
share.mockRejectedValueOnce(new Error('odd'));
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('failed');
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Handing a file to the system's share sheet (the Web Share API), where the
* platform takes files of that kind. Chrome takes only an allow-list of file
* types, Safari and the phones most, so whether a share is offered is asked
* per file name and type, never assumed from `navigator.share` alone.
*/
/** Whether this browser would hand a file of this name and type to the share sheet. */
export function canShareFile(name: string, type: string): boolean {
if (typeof navigator === 'undefined' || typeof navigator.share !== 'function') return false;
if (typeof navigator.canShare !== 'function') return false;
try {
return navigator.canShare({ files: [new File([''], name, { type })] });
} catch {
// A browser that cannot even build the question cannot share the file either.
return false;
}
}
/**
* How a share ended: `cancelled` when the sheet was closed without a target,
* `needs-gesture` when the browser refused because the tap that asked lies too
* far back (Safari, after the file had to be fetched first), `failed` otherwise.
*/
export type FileShareOutcome = 'shared' | 'cancelled' | 'needs-gesture' | 'failed';
/** Opens the share sheet with the file; call it straight from the tap that asks for it. */
export async function shareFile(file: File, title: string): Promise<FileShareOutcome> {
try {
await navigator.share({ files: [file], title });
return 'shared';
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled';
if (error instanceof DOMException && error.name === 'NotAllowedError') return 'needs-gesture';
return 'failed';
}
}
+23
View File
@@ -0,0 +1,23 @@
/** The router's line as it sends it: Google's polyline encoding at the given precision. */
export function encodePolyline(points: [number, number][], precision: number): string {
const factor = 10 ** precision;
let last = [0, 0];
return points
.map((point) => {
const scaled = point.map((value) => Math.round(value * factor));
const deltas = scaled.map((value, i) => value - last[i]);
last = scaled;
return deltas
.map((delta) => {
let value = delta < 0 ? ~(delta << 1) : delta << 1;
let chunk = '';
while (value >= 0x20) {
chunk += String.fromCharCode((0x20 | (value & 0x1f)) + 63);
value >>= 5;
}
return chunk + String.fromCharCode(value + 63);
})
.join('');
})
.join('');
}
+2
View File
@@ -60,5 +60,7 @@ export const UNRESET_STORES: Record<string, string> = {
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
useTourPoiStore: 'the places found along a tour; the tests that read it clear it in their own beforeEach',
useTourRouteHighlightStore:
'the stretches a bike card points at; the tests that read it clear it in their own beforeEach',
useTripJournalLinkStore: 'the journal link per trip; the tests that read it reset it in their own beforeEach',
};
+4
View File
@@ -10,6 +10,10 @@ export function hikePlannerDefaults() {
tourType: 'hike' as const,
setTourType: vi.fn(),
isPaddle: false,
isBike: false,
bikeProfile: 'touring' as const,
setBikeProfile: vi.fn(),
unreachable: null,
paddle: {
boatType: null,
waterContext: null,