From 03d6457bde84168fb8dcc1b9a0ae50d1965f1a92 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 16 Oct 2026 23:03:09 +0000 Subject: [PATCH] feat(tours): paddle obstacle card, admin regions panel and obstacle marks on the route band PAD-05B: a click on an obstacle on the map or on the route band opens its card in the look of the via ferrata card: the kind with its glyph, the name, how it stands to the route (on it at a distance along it, near it with the link marked unclear, or only in the area), the paddling rule its tags give with canoe read before boat, a condition as written, height, grade, hours and fees, the source with the date of the data and the OpenStreetMap link. A missing rule reads as unknown, never as allowed, and a portage way recorded as private says so. Objects only in the area show from zoom 13 on, on Leaflet and GL alike; on GL the lines only near the route are dashed as on Leaflet, one click handler picks the marker over the line, and a picked marker no longer flickers. The route band marks the obstacles on and near the route by their share of the measured line. The obstacle notice also names an answer cut at its limit. PAD-05A: the admin area gets a Paddle water data card under the addons, on desktop and phone: a search in Geofabrik's list with each region's path, the regions with their state (waiting, reading with its progress, ready with objects and data date, failed with the reason), reading again and removing after a confirmation. The list follows a read without reloading. Region changes write audit rows. Map colours of paddle objects and portages come from theme tokens read at draw time, as the via ferratas do; the ferrata layer now shares that reader. The rail takes the planner page's parts as one bag, the map hit tests of the tour layers are one helper, and the unused noWaterData string is gone from every locale. --- client/src/api/paddleRegions.ts | 32 ++ client/src/api/tourPaddlePlan.ts | 6 +- .../Admin/paddle/PaddleRegionsPanel.tsx | 278 ++++++++++++++++++ .../Admin/paddle/paddleRegionsModel.ts | 20 ++ .../Admin/paddle/usePaddleRegionsAdmin.ts | 173 +++++++++++ .../components/Ferratas/FerrataDetailCard.tsx | 12 +- .../Ferratas/FerrataDetailOverlay.tsx | 24 +- .../src/components/Ferratas/ferrataPaint.ts | 25 +- .../components/Ferratas/useFerrataPaint.ts | 31 +- client/src/components/Map/MapViewGL.tsx | 8 +- client/src/components/Map/mapControlShadow.ts | 8 + client/src/components/Map/mapPaint.ts | 49 +++ client/src/components/Map/mapRasterOverlay.ts | 15 +- .../src/components/Map/useMapCardDismiss.ts | 34 +++ .../Tours/TourWorkspaceRail.test.tsx | 39 +-- .../components/Tours/TourWorkspaceRail.tsx | 34 ++- .../components/Tours/paddle/FigureBadge.tsx | 43 +++ .../Tours/paddle/PaddleLayerLeaflet.tsx | 29 +- .../Tours/paddle/PaddleObstacleCard.tsx | 176 +++++++++++ .../Tours/paddle/PaddleObstacleNotice.tsx | 35 ++- .../Tours/paddle/PaddleObstacleOverlay.tsx | 57 ++++ .../Tours/paddle/PaddleRouteStrip.tsx | 52 +++- .../Tours/paddle/PaddleTimeCard.tsx | 6 +- .../Tours/paddle/PaddleWaypointParts.tsx | 14 +- .../Tours/paddle/PaddleWindCard.tsx | 75 +++-- .../Tours/paddle/TrackPaddlePlanCard.tsx | 10 +- .../Tours/paddle/paddleGlyphPaths.ts | 11 +- .../components/Tours/paddle/paddleMapLayer.ts | 70 +++-- .../Tours/paddle/paddleObstacleModel.ts | 128 ++++++++ .../components/Tours/paddle/paddlePaint.ts | 53 ++++ .../Tours/paddle/usePaddleLayerGL.ts | 177 ++++++++--- .../Tours/paddle/usePaddleObstacleSync.ts | 12 +- .../Tours/paddle/useTrackPaddlePlan.ts | 6 +- .../Tours/paddle/useTrackPortageParts.ts | 2 +- .../Tours/planner/TourTypeFields.test.tsx | 5 +- .../Tours/planner/TourTypeFields.tsx | 2 +- .../Tours/planner/TourWaypointList.tsx | 36 ++- .../Tours/planner/tourPlannerDraft.ts | 94 +++--- .../Tours/planner/tourRouteActions.ts | 8 +- .../Tours/planner/tourRouteStyle.ts | 20 +- .../Tours/planner/usePaddleFields.ts | 7 +- .../Tours/planner/useTourPlanner.ts | 2 +- .../components/Tours/terrain/useTerrainMap.ts | 7 +- .../Tours/terrain/useTourTerrain.ts | 2 +- client/src/main.tsx | 1 + client/src/mobile/screens/admin/MAdmin.tsx | 16 +- client/src/pages/AdminPage.test.tsx | 5 + client/src/pages/AdminPage.tsx | 2 + client/src/pages/TripPlannerPage.tsx | 9 +- .../pages/tripPlanner/TourPlannerMapStage.tsx | 32 +- .../src/pages/tripPlanner/useTourMapPoints.ts | 10 +- .../pages/tripPlanner/useTourStageLayers.ts | 8 +- .../src/pages/tripPlanner/useTourWorkspace.ts | 2 +- .../pages/tripPlanner/useTripPlannerPage.ts | 3 + client/src/repo/paddleRegionRepo.ts | 34 +++ client/src/store/paddleObstacleStore.ts | 3 + client/src/store/trackMarkToolStore.ts | 6 +- client/src/styles/paddle.css | 27 ++ client/src/utils/paddleTrackMarks.test.ts | 6 +- client/tests/helpers/storeRegistry.ts | 4 + client/tests/helpers/tourPlannerStub.ts | 4 + .../tests/unit/mobile/admin/MAdmin.test.tsx | 6 + .../paddle-obstacles.controller.ts | 38 ++- .../paddle-obstacles.module.ts | 3 +- server/tests/e2e/paddle-obstacles.e2e.test.ts | 6 + .../paddle-obstacles.controller.test.ts | 39 ++- shared/src/i18n/ar/tours.ts | 2 - shared/src/i18n/az/tours.ts | 2 - shared/src/i18n/br/tours.ts | 2 - shared/src/i18n/ca/tours.ts | 2 - shared/src/i18n/cs/tours.ts | 2 - shared/src/i18n/de/admin.ts | 29 ++ shared/src/i18n/de/tours.ts | 32 +- shared/src/i18n/en/admin.ts | 29 ++ shared/src/i18n/en/tours.ts | 31 +- shared/src/i18n/es/tours.ts | 2 - shared/src/i18n/et/tours.ts | 2 - shared/src/i18n/fr/tours.ts | 2 - shared/src/i18n/gr/tours.ts | 2 - shared/src/i18n/hu/tours.ts | 2 - shared/src/i18n/id/tours.ts | 2 - shared/src/i18n/it/tours.ts | 2 - shared/src/i18n/ja/tours.ts | 2 - shared/src/i18n/ko/tours.ts | 2 - shared/src/i18n/nl/tours.ts | 2 - shared/src/i18n/pl/tours.ts | 2 - shared/src/i18n/ru/tours.ts | 2 - shared/src/i18n/sk/tours.ts | 2 - shared/src/i18n/sv/tours.ts | 2 - shared/src/i18n/th/tours.ts | 2 - shared/src/i18n/tr/tours.ts | 2 - shared/src/i18n/uk/tours.ts | 2 - shared/src/i18n/vi/tours.ts | 2 - shared/src/i18n/zh-TW/tours.ts | 1 - shared/src/i18n/zh/tours.ts | 1 - .../src/paddle/paddle-route-relation.spec.ts | 10 +- shared/src/paddle/paddle-route-relation.ts | 46 ++- 97 files changed, 1924 insertions(+), 492 deletions(-) create mode 100644 client/src/api/paddleRegions.ts create mode 100644 client/src/components/Admin/paddle/PaddleRegionsPanel.tsx create mode 100644 client/src/components/Admin/paddle/paddleRegionsModel.ts create mode 100644 client/src/components/Admin/paddle/usePaddleRegionsAdmin.ts create mode 100644 client/src/components/Map/mapPaint.ts create mode 100644 client/src/components/Map/useMapCardDismiss.ts create mode 100644 client/src/components/Tours/paddle/FigureBadge.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstacleCard.tsx create mode 100644 client/src/components/Tours/paddle/PaddleObstacleOverlay.tsx create mode 100644 client/src/components/Tours/paddle/paddleObstacleModel.ts create mode 100644 client/src/components/Tours/paddle/paddlePaint.ts create mode 100644 client/src/repo/paddleRegionRepo.ts create mode 100644 client/src/styles/paddle.css diff --git a/client/src/api/paddleRegions.ts b/client/src/api/paddleRegions.ts new file mode 100644 index 000000000..9b17019d3 --- /dev/null +++ b/client/src/api/paddleRegions.ts @@ -0,0 +1,32 @@ +import { + paddleRegionCatalogResponseSchema, + paddleRegionListResponseSchema, + paddleRegionSchema, + successResponseSchema, + type PaddleRegionRequest, +} from '@trek/shared'; +import apiClient from './client'; + +/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */ +const GEOFABRIK = { timeout: 30_000 }; + +/** PAD-05A, the admin side: the regions TREK reads paddle data for. */ +export const paddleRegionsApi = { + list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions), + catalog: (q: string, signal?: AbortSignal) => + apiClient + .get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK }) + .then((r) => paddleRegionCatalogResponseSchema.parse(r.data).regions), + add: (id: string) => + apiClient + .post('/admin/paddle-regions', { id } satisfies PaddleRegionRequest, GEOFABRIK) + .then((r) => paddleRegionSchema.parse(r.data)), + refresh: (id: string) => + apiClient + .post('/admin/paddle-regions/refresh', { id } satisfies PaddleRegionRequest) + .then((r) => paddleRegionSchema.parse(r.data)), + remove: (id: string) => + apiClient + .post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest) + .then((r) => successResponseSchema.parse(r.data)), +}; diff --git a/client/src/api/tourPaddlePlan.ts b/client/src/api/tourPaddlePlan.ts index 0d1c1cd0b..7c230c589 100644 --- a/client/src/api/tourPaddlePlan.ts +++ b/client/src/api/tourPaddlePlan.ts @@ -3,6 +3,10 @@ import apiClient from './client'; /** PAD-03/PAD-07: the portages, locks and speeds laid onto a recorded paddle track. */ export const tourPaddlePlanApi = { - update: (tripId: number | string, placeId: number | string, body: TourPaddlePlanRequest): Promise => + update: ( + tripId: number | string, + placeId: number | string, + body: TourPaddlePlanRequest + ): Promise => apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data), }; diff --git a/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx new file mode 100644 index 000000000..160b98680 --- /dev/null +++ b/client/src/components/Admin/paddle/PaddleRegionsPanel.tsx @@ -0,0 +1,278 @@ +import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; +import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react'; +import { useId } from 'react'; +import { useTranslation } from '../../../i18n'; +import { + SettingRows, + SETTINGS_BUTTON, + SETTINGS_ICON_BUTTON, + SettingsCard, + SettingsHint, + StatusPill, +} from '../../Settings/settingsKit'; +import ConfirmDialog from '../../shared/ConfirmDialog'; +import { EditorField, INPUT } from '../../shared/dialogParts'; +import { fs } from '../../shared/DialogShell'; +import ErrorBoundary from '../../shared/ErrorBoundary'; +import { Tooltip } from '../../shared/Tooltip'; +import { progressPercent, regionStatusTone } from './paddleRegionsModel'; +import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; + +type Admin = ReturnType; + +/** + * PAD-05A in the admin area: the regions TREK reads paddle obstacles for, + * picked from Geofabrik's list. Both the desktop and the phone admin show + * this one card under the addons, while the Tours addon is on. + */ +export default function PaddleRegionsPanel() { + return ( + + + + ); +} + +function Panel() { + const { t } = useTranslation(); + const admin = usePaddleRegionsAdmin(); + if (!admin.toursOn) return null; + return ( + 0 ? {admin.regions.length} : undefined} + > + {admin.offline && ( +

+ + {t('admin.paddleRegions.offline')} +

+ )} + {admin.error && !admin.offline && ( +
+ {admin.error} + +
+ )} +
+ + +
+ {t('admin.paddleRegions.attribution')} + +
+ ); +} + +/** A Geofabrik region with the regions it lies in, outermost first, so two of one name tell apart. */ +function RegionPath({ hit }: { hit: PaddleRegionCatalogEntry }) { + if (hit.path.length === 0) return null; + return ( + + {hit.path.map((part, index) => ( + + {index > 0 && + ))} + + ); +} + +function CatalogSearch({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + const id = useId(); + const { search } = admin; + return ( +
+ +
+
+
+ {search.searching && ( +

+ + {t('common.loading')} +

+ )} + {search.error && ( +

+ {search.error} +

+ )} + {search.hits && search.hits.length === 0 && {t('admin.paddleRegions.noHits')}} + {search.hits && search.hits.length > 0 && ( + + {search.hits.map((hit) => ( +
+
+ + {hit.name} + + +
+ {admin.added.has(hit.id) ? ( + }>{t('admin.paddleRegions.added')} + ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} + +function RegionList({ admin }: { admin: Admin }) { + const { t } = useTranslation(); + if (admin.loading) { + return ( +

+ + {t('common.loading')} +

+ ); + } + if (admin.regions.length === 0) { + return ( +
+ {t('admin.paddleRegions.empty')} +
+ ); + } + return ( + + {admin.regions.map((region) => ( + + ))} + + ); +} + +function RegionStatus({ region }: { region: PaddleRegion }) { + const { t, language } = useTranslation(); + const percent = progressPercent(region); + const label = + region.status === 'importing' && region.progress + ? t(`admin.paddleRegions.phase.${region.progress.phase}`) + : t(`admin.paddleRegions.status.${region.status}`); + const share = + percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`; + return ( + : undefined} + > + {label} + {share} + + ); +} + +function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { + const { t, language } = useTranslation(); + const busy = admin.busyId === region.id; + const date = region.source_timestamp + ? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' }) + : null; + return ( +
+ + + +
+ + {region.name} + + + + {region.imported_at && ( + {t('admin.paddleRegions.objects', { count: region.feature_count })} + )} + {region.imported_at && date && {t('admin.paddleRegions.asOf', { date })}} + + {region.status === 'failed' && region.error && ( +

+ {region.error} + {region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''} +

+ )} +
+ + + + + + +
+ ); +} diff --git a/client/src/components/Admin/paddle/paddleRegionsModel.ts b/client/src/components/Admin/paddle/paddleRegionsModel.ts new file mode 100644 index 000000000..fb00acc98 --- /dev/null +++ b/client/src/components/Admin/paddle/paddleRegionsModel.ts @@ -0,0 +1,20 @@ +import type { PaddleRegion } from '@trek/shared'; + +/** A region waiting for its read or being read: the list asks again until none is. */ +export function isReading(region: Pick): boolean { + return region.status === 'queued' || region.status === 'importing'; +} + +/** The share of a read under way as a whole percent, or null while its size is unknown. */ +export function progressPercent(region: Pick): number | null { + const fraction = region.progress?.fraction; + return fraction === null || fraction === undefined ? null : Math.round(fraction * 100); +} + +/** The status badge's tone: what state the read is in, never whether the water is safe. */ +export function regionStatusTone(status: PaddleRegion['status']): 'neutral' | 'accent' | 'success' | 'danger' { + if (status === 'importing') return 'accent'; + if (status === 'ready') return 'success'; + if (status === 'failed') return 'danger'; + return 'neutral'; +} diff --git a/client/src/components/Admin/paddle/usePaddleRegionsAdmin.ts b/client/src/components/Admin/paddle/usePaddleRegionsAdmin.ts new file mode 100644 index 000000000..294f53ab8 --- /dev/null +++ b/client/src/components/Admin/paddle/usePaddleRegionsAdmin.ts @@ -0,0 +1,173 @@ +import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNetworkMode } from '../../../hooks/useNetworkMode'; +import { useTranslation } from '../../../i18n'; +import { paddleRegionErrorCode, paddleRegionRepo } from '../../../repo/paddleRegionRepo'; +import { useAddonStore } from '../../../store/addonStore'; +import { getApiErrorMessage } from '../../../types'; +import { useToast } from '../../shared/Toast'; +import { isReading } from './paddleRegionsModel'; + +/** How often the list is asked again while a region waits or is read. */ +export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000; +/** How long typing rests before Geofabrik's list is searched. */ +export const CATALOG_SEARCH_DEBOUNCE_MS = import.meta.env.MODE === 'test' ? 10 : 350; + +type Translate = ReturnType['t']; + +function errorText(t: Translate, err: unknown): string { + const code = paddleRegionErrorCode(err); + if (code === 'offline') return t('admin.paddleRegions.offline'); + if (code === 'catalog_unavailable') return t('admin.paddleRegions.catalogUnavailable'); + return getApiErrorMessage(err, t('common.error')); +} + +/** + * The regions as the server holds them, asked again every few seconds while + * any of them is read, and on `reload` after a change. Every answer is set + * from the request's own callback, so a slow one never overwrites a newer. + */ +function useRegionList(active: boolean, offline: boolean, t: Translate) { + const [regions, setRegions] = useState([]); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(null); + const [asked, setAsked] = useState(0); + useEffect(() => { + if (!active || offline) return; + let cancelled = false; + paddleRegionRepo.list().then( + (list) => { + if (cancelled) return; + setRegions(list); + setError(null); + setLoaded(true); + }, + (err: unknown) => { + if (cancelled) return; + setError(errorText(t, err)); + setLoaded(true); + } + ); + return () => { + cancelled = true; + }; + }, [active, offline, t, asked]); + const reading = regions.some(isReading); + useEffect(() => { + if (!active || offline || !reading) return; + const timer = window.setInterval(() => setAsked((n) => n + 1), PADDLE_REGIONS_POLL_MS); + return () => window.clearInterval(timer); + }, [active, offline, reading]); + const reload = useCallback(() => setAsked((n) => n + 1), []); + return { regions, loading: !loaded && !offline, error, reload }; +} + +/** Geofabrik's list searched as the admin types, the last search winning. */ +function useCatalogSearch(offline: boolean, t: Translate) { + const [query, setQuery] = useState(''); + const [result, setResult] = useState<{ + needle: string; + hits: PaddleRegionCatalogEntry[] | null; + error: string | null; + } | null>(null); + const needle = query.trim(); + const wanted = !offline && needle.length >= 2; + useEffect(() => { + if (!wanted) return; + const controller = new AbortController(); + const timer = window.setTimeout(() => { + paddleRegionRepo + .catalog(needle, controller.signal) + .then((hits) => { + if (!controller.signal.aborted) setResult({ needle, hits, error: null }); + }) + .catch((err: unknown) => { + if (!controller.signal.aborted) setResult({ needle, hits: null, error: errorText(t, err) }); + }); + }, CATALOG_SEARCH_DEBOUNCE_MS); + return () => { + controller.abort(); + window.clearTimeout(timer); + }; + }, [needle, wanted, t]); + // An answer counts only for the words it was asked for. + const current = wanted && result?.needle === needle ? result : null; + return { query, setQuery, hits: current?.hits ?? null, searching: wanted && !current, error: current?.error ?? null }; +} + +/** + * PAD-05A in the admin area: the regions TREK reads paddle data for, a search + * in Geofabrik's list to add one, reading a region again and removing it + * after a confirmation. Shown only while the Tours addon is on; the status of + * a read follows without reloading the page. + */ +export function usePaddleRegionsAdmin() { + const { t } = useTranslation(); + const toast = useToast(); + const { offline } = useNetworkMode(); + const toursOn = useAddonStore((state) => state.isEnabled('tours')); + const list = useRegionList(toursOn, offline, t); + const search = useCatalogSearch(offline, t); + const [busyId, setBusyId] = useState(null); + const [removing, setRemoving] = useState(null); + const pending = useRef(false); + const { reload } = list; + + const run = useCallback( + async (id: string, action: () => Promise, done?: string) => { + if (pending.current) return; + pending.current = true; + setBusyId(id); + try { + await action(); + if (done) toast.success(done); + } catch (err) { + toast.error(errorText(t, err)); + } finally { + pending.current = false; + setBusyId(null); + reload(); + } + }, + [reload, t, toast] + ); + + const { setQuery } = search; + const add = useCallback( + (id: string) => + run( + id, + async () => { + await paddleRegionRepo.add(id); + setQuery(''); + }, + t('admin.paddleRegions.toast.added') + ), + [run, setQuery, t] + ); + const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]); + const confirmRemove = useCallback(() => { + const region = removing; + setRemoving(null); + if (region) void run(region.id, () => paddleRegionRepo.remove(region.id), t('admin.paddleRegions.toast.removed')); + }, [removing, run, t]); + const added = useMemo(() => new Set(list.regions.map((region) => region.id)), [list.regions]); + + return { + toursOn, + offline, + regions: list.regions, + loading: list.loading, + error: list.error, + retry: reload, + search, + added, + busyId, + add, + refresh, + removing, + askRemove: setRemoving, + cancelRemove: () => setRemoving(null), + confirmRemove, + }; +} diff --git a/client/src/components/Ferratas/FerrataDetailCard.tsx b/client/src/components/Ferratas/FerrataDetailCard.tsx index 2c948fdf3..ddbfbf935 100644 --- a/client/src/components/Ferratas/FerrataDetailCard.tsx +++ b/client/src/components/Ferratas/FerrataDetailCard.tsx @@ -5,20 +5,12 @@ import type { Ferrata } from '@trek/shared'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { formatDistance } from '../../utils/units'; -import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow'; +import { MAP_CARD_GLASS } from '../Map/mapControlShadow'; import { SoftPill } from '../Planner/planParts'; import { fs } from '../shared/DialogShell'; import { type FerrataGroup, ferrataLabel } from './ferrataGroups'; import { ferrataOsmUrl } from './ferrataModel'; -/** The map controls' frosted glass, so the card reads as one of them. */ -const MAP_GLASS = { - background: 'var(--sidebar-bg)', - backdropFilter: 'blur(20px) saturate(180%)', - WebkitBackdropFilter: 'blur(20px) saturate(180%)', - boxShadow: MAP_CONTROL_SHADOW, -}; - /** * One via ferrata, picked on the map: its name, the route it belongs to, its * grade range and total length as badges, and the way back to the source. @@ -57,7 +49,7 @@ export default function FerrataDetailCard({ return (
diff --git a/client/src/components/Ferratas/FerrataDetailOverlay.tsx b/client/src/components/Ferratas/FerrataDetailOverlay.tsx index dedfc4e07..014431e5a 100644 --- a/client/src/components/Ferratas/FerrataDetailOverlay.tsx +++ b/client/src/components/Ferratas/FerrataDetailOverlay.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; +import { useMapCardDismiss } from '../Map/useMapCardDismiss'; import FerrataDetailCard from './FerrataDetailCard'; import type { FerrataGroup } from './ferrataGroups'; import type { FerrataSelection } from './useFerrataMapLayer'; @@ -39,26 +40,7 @@ export default function FerrataDetailOverlay({ onTourIds?: ReadonlySet; }) { const ref = useRef(null); - const open = !!selection; - - useEffect(() => { - if (!open) return; - const onDown = (event: PointerEvent) => { - const card = ref.current; - if (card && event.target instanceof Node && card.contains(event.target)) return; - onOutsidePress?.(); - onClose(); - }; - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !event.defaultPrevented) onClose(); - }; - document.addEventListener('pointerdown', onDown, true); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('pointerdown', onDown, true); - document.removeEventListener('keydown', onKey); - }; - }, [open, onClose, onOutsidePress]); + useMapCardDismiss(ref, !!selection, onClose, onOutsidePress); if (!selection) return null; return ( diff --git a/client/src/components/Ferratas/ferrataPaint.ts b/client/src/components/Ferratas/ferrataPaint.ts index 6d2002051..052096a22 100644 --- a/client/src/components/Ferratas/ferrataPaint.ts +++ b/client/src/components/Ferratas/ferrataPaint.ts @@ -1,3 +1,5 @@ +import { readMapPaint, sameMapPaint } from '../Map/mapPaint'; + /** * The colours the via ferrata lines are drawn in. * @@ -10,9 +12,9 @@ * * Map paint cannot take a `var(--token)`: Leaflet writes the colour into an * SVG `stroke` attribute and GL paint wants a resolved colour string. So the - * `--map-ferrata*` tokens are read off the document when the layer draws (the - * precedent is the hazard overlay in useHazardLayerGL), which keeps light and - * dark mode apart without a colour literal in the layer. + * `--map-ferrata*` tokens are read off the document when the layer draws + * (`mapPaint.ts`; the precedent is the hazard overlay in useHazardLayerGL), + * which keeps light and dark mode apart without a colour literal in the layer. */ export interface FerrataPaint { /** The dashed line itself. */ @@ -23,27 +25,16 @@ export interface FerrataPaint { highlight: string; } -const TOKENS = { +export const FERRATA_TOKENS = { line: '--map-ferrata', casing: '--map-ferrata-casing', highlight: '--map-ferrata-lit', } as const; -/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */ -function token(name: string, style: CSSStyleDeclaration | null): string { - const value = style?.getPropertyValue(name).trim(); - return value || 'currentColor'; -} - export function ferrataPaint(): FerrataPaint { - const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement); - return { - line: token(TOKENS.line, style), - casing: token(TOKENS.casing, style), - highlight: token(TOKENS.highlight, style), - }; + return readMapPaint(FERRATA_TOKENS); } export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean { - return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight; + return sameMapPaint(a, b); } diff --git a/client/src/components/Ferratas/useFerrataPaint.ts b/client/src/components/Ferratas/useFerrataPaint.ts index c2a431ffa..0766e30c1 100644 --- a/client/src/components/Ferratas/useFerrataPaint.ts +++ b/client/src/components/Ferratas/useFerrataPaint.ts @@ -1,30 +1,7 @@ -import { useEffect, useState } from 'react'; +import { useMapPaint } from '../Map/mapPaint'; +import { FERRATA_TOKENS, type FerrataPaint } from './ferrataPaint'; -import { type FerrataPaint, ferrataPaint, sameFerrataPaint } from './ferrataPaint'; - -/** The attributes `applyAppearance()` writes on `` that change what the tokens resolve to. */ -const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme']; - -/** - * The line colours, resolved again whenever the light/dark class, the colour - * scheme or a custom accent on `` changes. Map paint holds resolved - * strings rather than `var(--token)`, so without this a theme toggle would leave - * the lines in the old scheme's colours. Same observer approach as `useIsDark`, - * which only watches the class. The reference only changes when a colour does, - * so a memo keyed on it does not churn. - */ +/** The line colours, resolved again whenever the theme on `` changes (see `useMapPaint`). */ export function useFerrataPaint(): FerrataPaint { - const [paint, setPaint] = useState(ferrataPaint); - - useEffect(() => { - if (typeof document === 'undefined') return; - const observer = new MutationObserver(() => { - const next = ferrataPaint(); - setPaint((current) => (sameFerrataPaint(current, next) ? current : next)); - }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES }); - return () => observer.disconnect(); - }, []); - - return paint; + return useMapPaint(FERRATA_TOKENS); } diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index a73b0fd75..e782240f7 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -9,7 +9,7 @@ import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n/TranslationContext' import { applySatellite } from './glRasterLayers' -import { useTourMapLayersGL } from './mapRasterOverlay' +import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' import { useMarkerThumbs } from './useMarkerThumbs' @@ -33,8 +33,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters' import { groupCoincidentPlaces } from './coincidentPlaces' import { useHazardLayerGL } from './useHazardLayerGL' -import { type FerrataGlMap, isFerrataHit } from '../Ferratas/useFerrataLayerGL' -import { isPaddleHit } from '../Tours/paddle/usePaddleLayerGL' +import type { FerrataGlMap } from '../Ferratas/useFerrataLayerGL' import { formatViaDwell } from './routeViaDwell' import { useDawarichTrailGL } from './useDawarichTrailGL' import { bindDayBoundaryDrag } from './dayBoundaryDrag' @@ -1430,8 +1429,7 @@ export function MapViewGL({ && typeof map.queryRenderedFeatures === 'function' && map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0 ) return - if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead - if (isPaddleHit(map, e.point)) return // so does a picked paddle obstacle + if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) }) // Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore diff --git a/client/src/components/Map/mapControlShadow.ts b/client/src/components/Map/mapControlShadow.ts index 3a24f123d..3981eb182 100644 --- a/client/src/components/Map/mapControlShadow.ts +++ b/client/src/components/Map/mapControlShadow.ts @@ -8,3 +8,11 @@ * exactly that reason — the others were the ones that needed to match it. */ export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)' + +/** The frosted glass of the map's floating cards (a ferrata's, a paddle obstacle's), so they read as its controls. */ +export const MAP_CARD_GLASS = { + background: 'var(--sidebar-bg)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + boxShadow: MAP_CONTROL_SHADOW, +} diff --git a/client/src/components/Map/mapPaint.ts b/client/src/components/Map/mapPaint.ts new file mode 100644 index 000000000..f941573d0 --- /dev/null +++ b/client/src/components/Map/mapPaint.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; + +/** + * Colours for map paint, read off the theme. Map paint cannot take a + * `var(--token)`: Leaflet writes the colour into an SVG attribute and GL + * paint wants a resolved colour string. So the map layers name the tokens + * they draw with, and the colours are read off the document when they draw, + * which keeps light and dark mode apart without a colour literal in a layer. + */ + +/** The attributes `applyAppearance()` writes on `` that change what the tokens resolve to. */ +const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme']; + +/** What each token resolves to on the root, `currentColor` where nothing defines it (a test DOM). */ +export function readMapPaint(tokens: Readonly>): Record { + const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement); + const keys = Object.keys(tokens) as K[]; + return Object.fromEntries( + keys.map((key) => [key, style?.getPropertyValue(tokens[key]).trim() || 'currentColor']) + ) as Record; +} + +export function sameMapPaint( + a: Readonly>, + b: Readonly> +): boolean { + return (Object.keys(a) as K[]).every((key) => a[key] === b[key]); +} + +/** + * The colours, read again whenever the light or dark class, the colour scheme + * or a custom accent on `` changes; without this a theme toggle would + * leave a layer in the old scheme's colours. The answer only changes identity + * when a colour does, so a memo keyed on it does not churn. `tokens` is a + * constant of the caller's module. + */ +export function useMapPaint(tokens: Readonly>): Record { + const [paint, setPaint] = useState(() => readMapPaint(tokens)); + useEffect(() => { + if (typeof document === 'undefined') return; + const observer = new MutationObserver(() => { + const next = readMapPaint(tokens); + setPaint((current) => (sameMapPaint(current, next) ? current : next)); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES }); + return () => observer.disconnect(); + }, [tokens]); + return paint; +} diff --git a/client/src/components/Map/mapRasterOverlay.ts b/client/src/components/Map/mapRasterOverlay.ts index 18bad6c49..3875a0b96 100644 --- a/client/src/components/Map/mapRasterOverlay.ts +++ b/client/src/components/Map/mapRasterOverlay.ts @@ -1,9 +1,9 @@ import type { Map } from 'mapbox-gl'; -import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'; -import { type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL'; import { useEffect } from 'react'; -import { useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'; +import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'; 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 } from './glRasterLayers'; /** @@ -115,3 +115,12 @@ export function useTourMapLayersGL( useRasterOverlaysGL(map, overlays); usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle); } + +/** + * Whether a click at `point` landed on an object of the tour layers, a via + * ferrata or a paddle obstacle. The maps ask this before their own click + * handler, so picking one opens its card instead of also setting a waypoint. + */ +export function isTourLayerHit(map: Parameters>[0], point: Point): boolean { + return isFerrataHit(map, point) || isPaddleHit(map, point); +} diff --git a/client/src/components/Map/useMapCardDismiss.ts b/client/src/components/Map/useMapCardDismiss.ts new file mode 100644 index 000000000..a9aaa9b92 --- /dev/null +++ b/client/src/components/Map/useMapCardDismiss.ts @@ -0,0 +1,34 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * Closes a card floating over the map on a press anywhere outside it and on + * Escape, while it is open. The press outside is handed to `onOutsidePress` + * as well, so the map under it can leave the click that follows alone (see + * `useOutsidePressGuard`). A press on another object closes this card first, + * and the click then opens that object's. + */ +export function useMapCardDismiss( + ref: RefObject, + open: boolean, + onClose: () => void, + onOutsidePress?: () => void +): void { + useEffect(() => { + if (!open) return; + const onDown = (event: PointerEvent) => { + const card = ref.current; + if (card && event.target instanceof Node && card.contains(event.target)) return; + onOutsidePress?.(); + onClose(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.defaultPrevented) onClose(); + }; + document.addEventListener('pointerdown', onDown, true); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', onDown, true); + document.removeEventListener('keydown', onKey); + }; + }, [open, onClose, onOutsidePress, ref]); +} diff --git a/client/src/components/Tours/TourWorkspaceRail.test.tsx b/client/src/components/Tours/TourWorkspaceRail.test.tsx index e30e40810..7996715b7 100644 --- a/client/src/components/Tours/TourWorkspaceRail.test.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '../../../tests/helpers/render'; -import TourWorkspaceRail from './TourWorkspaceRail'; +import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail'; import type { GpxConversion } from './conversion/useGpxConversion'; import type { TourPlannerController } from './planner/useTourPlanner'; import type { RecordedTrack } from './useRecordedTrack'; @@ -23,18 +23,13 @@ function wanderer(overrides: Partial): WandererBrowser { return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser; } +function workspace(browser: WandererBrowser): TourWorkspaceRailParts { + return { tourPlanner: planner, wandererBrowser: browser, tourConversion: conversion, recordedTrack }; +} + describe('TourWorkspaceRail', () => { it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => { - render( - - ); + render(); expect(screen.getByText('wanderer trails')).toBeInTheDocument(); expect(screen.queryByText('tour editor')).not.toBeInTheDocument(); }); @@ -42,16 +37,7 @@ describe('TourWorkspaceRail', () => { it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => { const browser = wanderer({}); seen.rail.length = 0; - const view = render( - - ); + const view = render(); expect(screen.getByText('tour editor')).toBeInTheDocument(); expect(seen.rail[seen.rail.length - 1]).toMatchObject({ planner, @@ -62,16 +48,7 @@ describe('TourWorkspaceRail', () => { onOpenWanderer: browser.openBrowser, }); - view.rerender( - - ); + view.rerender(); expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined(); }); }); diff --git a/client/src/components/Tours/TourWorkspaceRail.tsx b/client/src/components/Tours/TourWorkspaceRail.tsx index 4b81d8e3f..44b00f693 100644 --- a/client/src/components/Tours/TourWorkspaceRail.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.tsx @@ -1,38 +1,40 @@ import type { GpxConversion } from './conversion/useGpxConversion'; +import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan'; import { TourPlannerRail } from './planner/TourPlannerPanels'; import type { TourPlannerController } from './planner/useTourPlanner'; -import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan'; import type { RecordedTrack } from './useRecordedTrack'; import WandererBrowserRail from './wanderer/WandererBrowserRail'; import type { WandererBrowser } from './wanderer/useWandererBrowser'; -/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */ -export default function TourWorkspaceRail({ - planner, - wanderer, - conversion, - recordedTrack, - trackPaddlePlan, - canEdit, - canAssign, -}: { - planner: TourPlannerController; - wanderer: WandererBrowser; - conversion: GpxConversion; +/** What the tours workspace hands its left rail: the parts of the planner page's state, by their names there. */ +export interface TourWorkspaceRailParts { + tourPlanner: TourPlannerController; + wandererBrowser: WandererBrowser; + tourConversion: GpxConversion; recordedTrack: RecordedTrack; /** The portages and locks of a recorded paddle track in view. */ trackPaddlePlan?: TrackPaddlePlan; +} + +/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */ +export default function TourWorkspaceRail({ + workspace, + canEdit, + canAssign, +}: { + workspace: TourWorkspaceRailParts; canEdit: boolean; canAssign: boolean; }) { + const { tourPlanner, wandererBrowser: wanderer, tourConversion, recordedTrack, trackPaddlePlan } = workspace; return wanderer.open ? ( ) : ( + {label && {label}} + + {icon} + {value} + + + ); + return info ? {badge} : badge; +} diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx index 3fa6ed1cc..5d86fff1c 100644 --- a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx @@ -1,8 +1,14 @@ import L from 'leaflet'; -import { useMemo } from 'react'; -import { Marker, Polyline, Tooltip } from 'react-leaflet'; +import { useMemo, useState } from 'react'; +import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'; import { useTranslation } from '../../../i18n'; -import { markerSize, paddleMarkerHtml, type PaddleMapItem, type PaddleMapLayer } from './paddleMapLayer'; +import { + markerSize, + paddleItemsAtZoom, + paddleMarkerHtml, + type PaddleMapItem, + type PaddleMapLayer, +} from './paddleMapLayer'; const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const; const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const; @@ -30,13 +36,18 @@ function useLabel() { * The paddle obstacles on the Leaflet map (PAD-05B): each line on a white * casing in its category's colour, dashed while it is only near the route, * and a round marker with its glyph; the quieter, the less it bears on the - * route. A click picks the object and goes no further, so it never also sets - * a waypoint under it. + * route, and the objects only in the area wait for a closer zoom. A click + * picks the object and goes no further, so it never also sets a waypoint + * under it. */ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) { const label = useLabel(); - const items = layer?.items; + const map = useMap(); + const [zoom, setZoom] = useState(() => map.getZoom()); + useMapEvents({ zoomend: () => setZoom(map.getZoom()) }); const selectedRef = layer?.selectedRef ?? null; + const all = layer?.items; + const items = useMemo(() => (all ? paddleItemsAtZoom(all, zoom, selectedRef) : undefined), [all, zoom, selectedRef]); // Rebuilt only when the items or the selection change, not on every render of the map. const icons = useMemo( () => new Map((items ?? []).map((item) => [item.feature.ref, iconOf(item, item.feature.ref === selectedRef)])), @@ -56,7 +67,11 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) { ) : null diff --git a/client/src/components/Tours/paddle/PaddleObstacleCard.tsx b/client/src/components/Tours/paddle/PaddleObstacleCard.tsx new file mode 100644 index 000000000..d27156bc4 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstacleCard.tsx @@ -0,0 +1,176 @@ +import { ExternalLink, X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import type { DistanceUnit } from '../../../types'; +import { formatDistance } from '../../../utils/units'; +import { MAP_CARD_GLASS } from '../../Map/mapControlShadow'; +import { SoftPill } from '../../Planner/planParts'; +import { fs } from '../../shared/DialogShell'; +import { FigureBadge } from './FigureBadge'; +import { PaddleGlyph } from './PaddleGlyph'; +import { OBSTACLE_GLYPH } from './paddleGlyphPaths'; +import { KNOWN_ACCESS_VALUES, metresOf, osmUrlOf, type PaddleObstacleDetail } from './paddleObstacleModel'; +import { paddleKindCss } from './paddlePaint'; + +const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES; + +/** How the object stands to the route: on it at a distance along it, near it and uncertain, or only in the area. */ +function RelationBadges({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) { + const { t } = useTranslation(); + return ( +
+ {detail.relation === 'on_route' && ( + + )} + {detail.relation === 'near' && ( + <> + + {t('tours.paddle.obstacleCard.uncertain')} + + )} + {detail.relation === 'area' && {t('tours.paddle.obstacleCard.area')}} + {detail.privateWay && {t('tours.paddle.obstacleCard.privateWay')}} +
+ ); +} + +/** The paddling rule the tags give (canoe read before boat), its condition as written, and the facts worth knowing. */ +function AccessAndFacts({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) { + const { t } = useTranslation(); + const { access } = detail; + const accessValue = (value: string) => + ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value; + const factValue = (key: string, value: string) => { + const metres = key === 'height' || key === 'width' ? metresOf(value) : null; + return metres === null ? value : formatDistance(metres / 1000, unit); + }; + const ruled = access.source !== null && access.value !== null; + return ( +
+ {ruled && ( + + )} + {!ruled && !access.condition && {t('tours.paddle.obstacleCard.noAccessInfo')}} + {access.condition && ( + + )} + {detail.facts.map((fact) => ( + + ))} +
+ ); +} + +/** The way to the source and how current it is; a record in the data is no check on site. */ +function SourceLines({ detail }: { detail: PaddleObstacleDetail }) { + const { t, language } = useTranslation(); + const date = detail.dataAsOf ? new Date(detail.dataAsOf).toLocaleDateString(language, { dateStyle: 'medium' }) : null; + return ( + <> + + {t('ferratas.viewInOsm')} + +

+ {date ? t('tours.paddle.obstacleCard.source', { date }) : t('tours.paddle.obstacleCard.sourceUndated')}{' '} + {t('ferratas.attribution')} +

+ + ); +} + +/** + * One paddle object picked on the map or in the checklist (PAD-05B), in the + * look of the via ferrata card: what it is, how it stands to the route (an + * object only near it is said to be uncertain, never on it), the paddling + * rule its tags give with canoe read before boat, the facts worth knowing, + * and where the data comes from and how current it is. A missing rule reads + * as unknown, never as allowed; the card says it is data, not a check on site. + * `children` takes what the checklist adds below the facts. + */ +export default function PaddleObstacleCard({ + detail, + onClose, + width, + children, +}: { + detail: PaddleObstacleDetail; + onClose?: () => void; + width?: number; + children?: ReactNode; +}) { + const { t } = useTranslation(); + const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric'; + const { feature } = detail; + const kind = t(`tours.paddle.obstacle.kind.${feature.kind}`); + const color = paddleKindCss(feature.kind); + return ( +
+
+ + + +
+

+ {feature.name ?? kind} +

+ {feature.name &&

{kind}

} +
+ {onClose && ( + + )} +
+ + + {detail.note && ( +

+ {detail.note} +

+ )} + {children} + +
+ ); +} diff --git a/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx b/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx index 0fff717e0..65d65b824 100644 --- a/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx +++ b/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx @@ -5,21 +5,31 @@ import { usePaddleObstacleStore } from '../../../store/paddleObstacleStore'; import { TourNotice } from '../tourParts'; /** The oldest data date among the regions, as a short local date; the route is only as current as its oldest part. */ -function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string { - const times = regions.map((region) => region.source_timestamp).filter((time): time is string => !!time).sort(); - return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : ''; +function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string | null { + const times = regions + .map((region) => region.source_timestamp) + .filter((time): time is string => !!time) + .sort(); + return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null; } /** * PAD-05B: what TREK knows about the water along the route, in one line under - * the boat fields. It never reads as an all-clear: covered or not, the - * paddler checks the water, and where nothing is known it says so instead of - * leaving an empty map to be read as open water. + * the boat fields and in the recorded track's rail. It never reads as an + * all-clear: covered or not, the paddler checks the water, where nothing is + * known it says so instead of leaving an empty map to be read as open water, + * and an answer cut at its limit says that more is there. */ export function PaddleObstacleNotice() { const { t, language } = useTranslation(); - const { status, coverage, related, regions } = usePaddleObstacleStore( - useShallow((state) => ({ status: state.status, coverage: state.coverage, related: state.related, regions: state.regions })) + const { status, coverage, related, regions, truncated } = usePaddleObstacleStore( + useShallow((state) => ({ + status: state.status, + coverage: state.coverage, + related: state.related, + regions: state.regions, + truncated: state.truncated, + })) ); const warn = ; if (status === 'idle' || status === 'loading') { @@ -29,11 +39,14 @@ export function PaddleObstacleNotice() { ); } - if (status === 'offline') return }>{t('tours.paddle.obstacles.offline')}; + if (status === 'offline') + return }>{t('tours.paddle.obstacles.offline')}; if (status === 'error') return {t('tours.paddle.obstacles.error')}; - const date = dataDate(regions, language); + const date = dataDate(regions, language) ?? t('tours.paddle.obstacles.dateUnknown'); if (coverage === 'none') return {t('tours.paddle.obstacles.none')}; - if (coverage === 'partial') return {t('tours.paddle.obstacles.partial', { date })}; + if (coverage === 'partial') + return {t('tours.paddle.obstacles.partial', { date })}; + if (truncated) return {t('tours.paddle.obstacles.truncated')}; return ( }> {related.length diff --git a/client/src/components/Tours/paddle/PaddleObstacleOverlay.tsx b/client/src/components/Tours/paddle/PaddleObstacleOverlay.tsx new file mode 100644 index 000000000..a3f7bcf9a --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstacleOverlay.tsx @@ -0,0 +1,57 @@ +import { useCallback, useMemo, useRef, type ReactNode } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore'; +import { useMapCardDismiss } from '../../Map/useMapCardDismiss'; +import PaddleObstacleCard from './PaddleObstacleCard'; +import { paddleObstacleDetail, type PaddleObstacleDetail } from './paddleObstacleModel'; + +/** + * The picked paddle object's card over the planner map (PAD-05B), where a + * via ferrata's card sits and closing as that one does: on a press outside + * it and on Escape, the press handed on so the map leaves its click alone. + * The pick lives in the obstacle store, so the map and the checklist open + * and close the same card. `extra` adds the checklist's part for the object. + */ +export default function PaddleObstacleOverlay({ + onOutsidePress, + insetEnd, + bottom, + width, + extra, +}: { + onOutsidePress?: () => void; + insetEnd: number; + bottom: number; + width?: number; + extra?: (detail: PaddleObstacleDetail) => ReactNode; +}) { + const ref = useRef(null); + const { features, related, regions, selectedRef } = usePaddleObstacleStore( + useShallow((state) => ({ + features: state.features, + related: state.related, + regions: state.regions, + selectedRef: state.selectedRef, + })) + ); + const detail = useMemo( + () => paddleObstacleDetail({ features, related, regions, selectedRef }), + [features, related, regions, selectedRef] + ); + const close = useCallback(() => selectPaddleObstacle(null), []); + useMapCardDismiss(ref, !!detail, close, onOutsidePress); + + if (!detail) return null; + return ( +
+ + {extra?.(detail)} + +
+ ); +} diff --git a/client/src/components/Tours/paddle/PaddleRouteStrip.tsx b/client/src/components/Tours/paddle/PaddleRouteStrip.tsx index 8c83712b8..1ec425354 100644 --- a/client/src/components/Tours/paddle/PaddleRouteStrip.tsx +++ b/client/src/components/Tours/paddle/PaddleRouteStrip.tsx @@ -1,11 +1,16 @@ import type { PaddleLayout } from '@trek/shared'; +import { useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { useTranslation } from '../../../i18n'; +import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore'; import { useSettingsStore } from '../../../store/settingsStore'; import { formatDistance } from '../../../utils/units'; import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; -import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle'; import { PaddleGlyph } from './PaddleGlyph'; +import { OBSTACLE_GLYPH } from './paddleGlyphPaths'; +import { routeStripObstacles } from './paddleObstacleModel'; +import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint'; /** Water in the band and in the tiles that explain it: the info blue of the theme. */ export const WATER_COLOR = 'var(--info)'; @@ -16,13 +21,26 @@ const MIN_SHARE = 1.5; /** * A paddle tour from put-in to take-out as one band, to scale: water in the * planner's blue, portages in the earth brown the map draws them in, each lock - * a mark at its place (struck through where a portage carries round it). It + * a mark at its place (struck through where a portage carries round it). The + * obstacles the data knows on or near the route sit on it too, ringed in the + * colour of their kind, dashed when only near; a click opens one's card. It * stands where a hike shows its elevation profile; a paddle tour has none. */ export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLayout; compact?: boolean }) { const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const { related, routeLengthM, selectedRef } = usePaddleObstacleStore( + useShallow((state) => ({ + related: state.related, + routeLengthM: state.routeLengthM, + selectedRef: state.selectedRef, + })) + ); const total = layout.totalM; + const obstacles = useMemo( + () => (compact ? [] : routeStripObstacles(related, routeLengthM, total)), + [compact, related, routeLengthM, total] + ); if (total <= 0 || layout.stretches.length === 0) return null; const share = (m: number) => Math.max(MIN_SHARE, (m / total) * 100); const km = (m: number) => formatDistance(m / 1000, unit); @@ -35,13 +53,39 @@ export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLa ))} + {obstacles.map((obstacle) => { + const kind = t(`tours.paddle.obstacle.kind.${obstacle.kind}`); + const color = paddleKindCss(obstacle.kind); + const picked = obstacle.ref === selectedRef; + return ( + + + + ); + })}
{layout.stretches.map((stretch) => { const land = stretch.kind === 'portage'; @@ -57,7 +101,7 @@ export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLa style={{ flexGrow: share(length), flexBasis: 0, - ...(land ? { background: PORTAGE_LINE_COLOR } : {}), + ...(land ? { background: PORTAGE_CSS_COLOR } : {}), }} /> diff --git a/client/src/components/Tours/paddle/PaddleTimeCard.tsx b/client/src/components/Tours/paddle/PaddleTimeCard.tsx index 6a50cd80f..cd3e72ab8 100644 --- a/client/src/components/Tours/paddle/PaddleTimeCard.tsx +++ b/client/src/components/Tours/paddle/PaddleTimeCard.tsx @@ -18,8 +18,8 @@ import type { TourPlannerController } from '../planner/useTourPlanner'; import { InfoDot, RailCard, TourNotice } from '../tourParts'; import { formatPlannedTourDuration } from '../tourPresentation'; import { PaddleGlyph } from './PaddleGlyph'; +import { PORTAGE_CSS_COLOR } from './paddlePaint'; import { PaddleRouteStrip, WATER_COLOR } from './PaddleRouteStrip'; -import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle'; const minutes = (value: number | null) => (value === null ? '–' : formatPlannedTourDuration(value)); @@ -284,7 +284,7 @@ function PartTiles({ } - color={PORTAGE_LINE_COLOR} + color={PORTAGE_CSS_COLOR} label={t('tours.paddle.time.carry')} measures={ } - color={PORTAGE_LINE_COLOR} + color={PORTAGE_CSS_COLOR} label={t('tours.paddle.time.outIn')} measures={{`${estimate.portageCount}×`}} time={minutes(estimate.portageExtraMinutes)} diff --git a/client/src/components/Tours/paddle/PaddleWaypointParts.tsx b/client/src/components/Tours/paddle/PaddleWaypointParts.tsx index ce52d62df..8db1a47d0 100644 --- a/client/src/components/Tours/paddle/PaddleWaypointParts.tsx +++ b/client/src/components/Tours/paddle/PaddleWaypointParts.tsx @@ -1,17 +1,12 @@ -import { - DEFAULT_PORTAGE_EXTRA_MINUTES, - MAX_PADDLE_STOP_MINUTES, - type PaddleLock, - type PaddleRole, -} from '@trek/shared'; +import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared'; import { ChevronDown, X } from 'lucide-react'; import { useCallback, useState, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { formatDistance } from '../../../utils/units'; import { fs } from '../../shared/DialogShell'; -import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle'; import { PaddleGlyph } from './PaddleGlyph'; +import { PORTAGE_CSS_COLOR } from './paddlePaint'; const ROLE_GLYPH = { put_in: 'put_in', take_out: 'take_out', re_entry: 'put_in', final_take_out: 'take_out' } as const; @@ -29,7 +24,7 @@ export function PaddleRolePill({ role }: { role: PaddleRole }) { kind={ROLE_GLYPH[role]} size={11} strokeWidth={2.4} - style={land ? { color: PORTAGE_LINE_COLOR } : undefined} + style={land ? { color: PORTAGE_CSS_COLOR } : undefined} /> {t(`tours.paddle.role.${role}`)} @@ -233,7 +228,7 @@ export function PortageLegRow({ } - color={PORTAGE_LINE_COLOR} + color={PORTAGE_CSS_COLOR} title={t('tours.paddle.portage')} pills={ <> @@ -306,7 +301,6 @@ export function LockRow({ } > - ) => string; @@ -45,37 +46,6 @@ function directionName(degrees: number | null, t: Translate): string | null { return degrees === null ? null : t(`tours.paddle.wind.dir.${compassPointOf(degrees)}`); } -/** - * A figure of the day as a badge holding its value as a white badge, in the - * planner's badge-in-badge look; what it means exactly is in its tooltip. - */ -function DayFigure({ label, value, icon, info, testId }: { - label?: string; - value: string; - icon?: ReactNode; - info: string; - testId: string; -}) { - return ( - - - {label && {label}} - - {icon} - {value} - - - - ); -} - /** * The day's hours as columns: the mean wind as a bar, the gusts as the paler * bar behind it, an arrow the way it blows on top. An hour the source left @@ -108,10 +78,16 @@ function HourStrip({ hours, t }: { hours: readonly WindHour[]; t: Translate }) { className={`relative flex h-12 w-full items-end justify-center overflow-hidden rounded-[3px] ${known ? 'bg-surface-secondary' : 'border border-dashed border-edge'}`} > {hour.gusts_kmh !== null && ( - + )} {known && ( - + )} @@ -134,20 +110,20 @@ function Forecast({ forecast, t, locale }: { forecast: WindForecastResponse; t:
{from !== null && ( - } info={t('tours.paddle.wind.fromInfo', { direction: directionName(from, t) ?? '' })} /> )} - - ); } - const key = state.kind === 'ready' ? 'tours.paddle.wind.past' : `tours.paddle.wind.${state.kind}`; - return }>{t(key)}; + // A day without a forecast to ask for: past, not on a day, a day without a date, a tour without a start. + const notice = state.kind === 'ready' ? t('tours.paddle.wind.past') : t(`tours.paddle.wind.${state.kind}`); + return }>{notice}; } /** What wind means for a paddler, in general terms; it rates no passage and clears nothing. */ @@ -215,7 +192,11 @@ function WindHints({ t }: { t: Translate }) { style={fs(11.5, 'body')} > {t('tours.paddle.wind.hintsTitle')} - + {open && (