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 && (