mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
© OpenHikingMap
|
||||
</a>
|
||||
<span aria-hidden>|</span>
|
||||
<a className={link} href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
|
||||
© 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -40,6 +40,17 @@ export const OPENHIKINGMAP_TILE_ATTRIBUTION =
|
||||
'© <a href="https://wiki.openstreetmap.org/wiki/OpenHikingMap">OpenHikingMap</a> | © <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 =
|
||||
'© <a href="https://www.cyclosm.org">CyclOSM</a> | © <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).
|
||||
*
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user