diff --git a/client/src/api/paddleObstacles.ts b/client/src/api/paddleObstacles.ts new file mode 100644 index 000000000..94ba29960 --- /dev/null +++ b/client/src/api/paddleObstacles.ts @@ -0,0 +1,16 @@ +import { paddleObstaclesResponseSchema, type PaddleObstaclesResponse } from '@trek/shared'; +import apiClient from './client'; + +/** PAD-05A: the paddle objects of a map area and how far TREK's data covers it. */ +export const paddleObstaclesApi = { + inBox: ( + box: { south: number; west: number; north: number; east: number }, + signal?: AbortSignal + ): Promise => + apiClient + .get('/paddle/obstacles', { + params: { bbox: [box.south, box.west, box.north, box.east].map((v) => v.toFixed(5)).join(',') }, + signal, + }) + .then((r) => paddleObstaclesResponseSchema.parse(r.data)), +}; diff --git a/client/src/api/tourPaddlePlan.ts b/client/src/api/tourPaddlePlan.ts new file mode 100644 index 000000000..0d1c1cd0b --- /dev/null +++ b/client/src/api/tourPaddlePlan.ts @@ -0,0 +1,8 @@ +import type { TourDetailResponse, TourPaddlePlanRequest } from '@trek/shared'; +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 => + apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data), +}; diff --git a/client/src/api/wind.ts b/client/src/api/wind.ts new file mode 100644 index 000000000..56b6fb78d --- /dev/null +++ b/client/src/api/wind.ts @@ -0,0 +1,10 @@ +import { windForecastResponseSchema, type WindForecastResponse } from '@trek/shared'; +import apiClient from './client'; + +/** PAD-08: the wind forecast for one place and one day. */ +export const windApi = { + forecast: (lat: number, lng: number, date: string, signal?: AbortSignal): Promise => + apiClient + .get('/weather/wind', { params: { lat, lng, date }, signal }) + .then((r) => windForecastResponseSchema.parse(r.data)), +}; diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index be5a35a93..e332bf0f4 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,4 +1,5 @@ import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react' +import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer' import type { CSSProperties } from 'react' import { useTranslation } from '../../i18n' import { FerrataLineIcon } from '../Ferratas/FerrataGlyph' @@ -16,6 +17,8 @@ export type TourBaseLayer = BaseLayer | 'topo' export interface TourLayerOffer { bases?: readonly TourBaseLayer[] overlays?: readonly ToggleableRasterOverlay[] + /** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */ + paddle?: PaddleMapLayer } const FROSTED_SHELL = { diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 430fd9720..205a61f4f 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -1291,7 +1291,7 @@ export const MapView = memo(function MapView({ /> {hazards?.length > 0 && } - + {/* The route as it was actually recorded (#2279). Drawn in the casing pane's sibling order so it sits under the planned route rather than diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 590960974..a73b0fd75 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -34,6 +34,7 @@ import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM import { groupCoincidentPlaces } from './coincidentPlaces' import { useHazardLayerGL } from './useHazardLayerGL' import { type FerrataGlMap, isFerrataHit } from '../Ferratas/useFerrataLayerGL' +import { isPaddleHit } from '../Tours/paddle/usePaddleLayerGL' import { formatViaDwell } from './routeViaDwell' import { useDawarichTrailGL } from './useDawarichTrailGL' import { bindDayBoundaryDrag } from './dayBoundaryDrag' @@ -612,7 +613,7 @@ export function MapViewGL({ const mapRef = useRef(null) const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl]) useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory) - useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays) + useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle) // Beneath the planned route's casing, the GL twin of the Leaflet pane order. useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1430,6 +1431,7 @@ export function MapViewGL({ && 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 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/RasterOverlayLeaflet.tsx b/client/src/components/Map/RasterOverlayLeaflet.tsx index 6c1c9d80c..1ccfafcdb 100644 --- a/client/src/components/Map/RasterOverlayLeaflet.tsx +++ b/client/src/components/Map/RasterOverlayLeaflet.tsx @@ -1,6 +1,8 @@ import { TileLayer } from 'react-leaflet'; import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'; import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'; +import { PaddleLayerLeaflet } from '../Tours/paddle/PaddleLayerLeaflet'; +import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'; import type { MapRasterOverlay } from './mapRasterOverlay'; /** Over the basemap's tiles (z-index 1) and every basemap raster, under the route's own panes. */ @@ -28,18 +30,21 @@ function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOver ); } -/** The tour planner's layers on the Leaflet map: the via ferratas and the raster overlays its type offers. */ +/** The tour planner's layers on the Leaflet map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */ export function TourMapLayersLeaflet({ ferrataLayer, overlays, + paddle, }: { ferrataLayer?: FerrataMapLayer; overlays?: readonly MapRasterOverlay[]; + paddle?: PaddleMapLayer; }) { return ( <> + ); } diff --git a/client/src/components/Map/mapRasterOverlay.ts b/client/src/components/Map/mapRasterOverlay.ts index 3fec78fea..18bad6c49 100644 --- a/client/src/components/Map/mapRasterOverlay.ts +++ b/client/src/components/Map/mapRasterOverlay.ts @@ -1,4 +1,6 @@ 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 type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'; @@ -102,12 +104,14 @@ export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: re }, [map, overlays]); } -/** The tour planner's layers on a GL map: the via ferratas and the raster overlays its type offers. */ +/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */ export function useTourMapLayersGL( map: RasterOverlayGlMap | null, ferrataLayer: FerrataMapLayer | undefined, - overlays: readonly MapRasterOverlay[] | undefined + overlays: readonly MapRasterOverlay[] | undefined, + paddle?: PaddleMapLayer ) { useFerrataMapLayerGL(map, ferrataLayer); useRasterOverlaysGL(map, overlays); + usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle); } diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index bc0335540..6c29d3572 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -47,6 +47,7 @@ import { Tooltip } from '../shared/Tooltip'; import TrackColorPicker from '../shared/TrackColorPicker'; import { markdownLinkComponents } from '../shared/markdownLink'; import { TourDayRowAvatar } from './TourDayRowFacts'; +import { PaddleWindSection } from './paddle/PaddleWindCard'; import { TourProvenanceBlock } from './provenance/TourProvenanceSection'; import { tourDayRowIcon } from './tourDayRowIcon'; import { tourDaysOf } from './tourDays'; @@ -156,6 +157,11 @@ function TourDetailMeasures({ ); } +/** Where a tour starts, the place its wind is asked for (PAD-08). */ +function placeStart(place: Place): { lat: number; lng: number } | null { + return place.lat != null && place.lng != null ? { lat: place.lat, lng: place.lng } : null; +} + type Translate = (key: string, params?: Record) => string; /** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */ @@ -640,6 +646,7 @@ export default function TourDetailDialog({ placeId={place.id} /> )} + {tour.tour_type === 'kayak' && } {description && ( diff --git a/client/src/components/Tours/TourWorkspaceRail.tsx b/client/src/components/Tours/TourWorkspaceRail.tsx index a516c3d75..4b81d8e3f 100644 --- a/client/src/components/Tours/TourWorkspaceRail.tsx +++ b/client/src/components/Tours/TourWorkspaceRail.tsx @@ -1,6 +1,7 @@ import type { GpxConversion } from './conversion/useGpxConversion'; 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'; @@ -11,6 +12,7 @@ export default function TourWorkspaceRail({ wanderer, conversion, recordedTrack, + trackPaddlePlan, canEdit, canAssign, }: { @@ -18,6 +20,8 @@ export default function TourWorkspaceRail({ wanderer: WandererBrowser; conversion: GpxConversion; recordedTrack: RecordedTrack; + /** The portages and locks of a recorded paddle track in view. */ + trackPaddlePlan?: TrackPaddlePlan; canEdit: boolean; canAssign: boolean; }) { @@ -30,6 +34,7 @@ export default function TourWorkspaceRail({ canAssign={canAssign} conversion={conversion} recordedTrack={recordedTrack} + trackPaddlePlan={trackPaddlePlan} onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined} /> ); diff --git a/client/src/components/Tours/paddle/PaddleGlyph.tsx b/client/src/components/Tours/paddle/PaddleGlyph.tsx new file mode 100644 index 000000000..c04515dce --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleGlyph.tsx @@ -0,0 +1,48 @@ +import type { CSSProperties, SVGProps } from 'react'; +import { PADDLE_GLYPH_PATHS, type PaddleGlyphKind } from './paddleGlyphPaths'; + +/** + * The paddler's own symbols, drawn on lucide's grid (24, stroke 2, round + * caps) so they sit beside its icons without a seam: a lock as a chamber + * between its two mitre gates, a portage as footsteps (lucide's own), a weir + * as water falling over a ledge, a dam as a wall holding water back, rapids + * as broken water, getting in and out as an arrow into or out of the water. + * The waypoint list, the map and the checklist all use them. + */ +export function PaddleGlyph({ + kind, + size = 16, + strokeWidth = 2, + ...rest +}: { kind: PaddleGlyphKind; size?: number; strokeWidth?: number } & Omit, 'children'>) { + return ( + + ); +} + +/** The portage glyph as an icon component, for menus that take one. */ +export function PortageIcon(props: { size?: number; style?: CSSProperties }) { + return ; +} + +/** The lock glyph as an icon component, for menus that take one. */ +export function LockIcon(props: { size?: number; style?: CSSProperties }) { + return ; +} diff --git a/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx new file mode 100644 index 000000000..3fa6ed1cc --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleLayerLeaflet.tsx @@ -0,0 +1,96 @@ +import L from 'leaflet'; +import { useMemo } from 'react'; +import { Marker, Polyline, Tooltip } from 'react-leaflet'; +import { useTranslation } from '../../../i18n'; +import { markerSize, 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; + +function iconOf(item: PaddleMapItem, selected: boolean): L.DivIcon { + const size = markerSize(item.relation, selected); + return L.divIcon({ + html: paddleMarkerHtml(item, selected), + className: 'trek-paddle-pin', + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + +/** What a marker's tooltip says: the kind of object and its name, if it has one. */ +function useLabel() { + const { t } = useTranslation(); + return (item: PaddleMapItem) => { + const kind = t(`tours.paddle.obstacle.kind.${item.feature.kind}`); + return item.feature.name ? `${kind}: ${item.feature.name}` : kind; + }; +} + +/** + * 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. + */ +export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) { + const label = useLabel(); + const items = layer?.items; + const selectedRef = layer?.selectedRef ?? null; + // 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)])), + [items, selectedRef] + ); + if (!layer || !items?.length) return null; + const select = (ref: string) => ({ + click: (event: L.LeafletMouseEvent) => { + L.DomEvent.stopPropagation(event); + layer.onSelect(ref); + }, + }); + return ( + <> + {items.map((item) => + item.line ? ( + + ) : null + )} + {items.map((item) => + item.line ? ( + + ) : null + )} + {items.map((item) => ( + + + {label(item)} + + + ))} + + ); +} diff --git a/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx b/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx new file mode 100644 index 000000000..0fff717e0 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleObstacleNotice.tsx @@ -0,0 +1,44 @@ +import { AlertTriangle, Info, Loader2, WifiOff } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; +import { useTranslation } from '../../../i18n'; +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' }) : ''; +} + +/** + * 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. + */ +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 warn = ; + if (status === 'idle' || status === 'loading') { + return ( + }> + {t('tours.paddle.obstacles.loading')} + + ); + } + if (status === 'offline') return }>{t('tours.paddle.obstacles.offline')}; + if (status === 'error') return {t('tours.paddle.obstacles.error')}; + const date = dataDate(regions, language); + if (coverage === 'none') return {t('tours.paddle.obstacles.none')}; + if (coverage === 'partial') return {t('tours.paddle.obstacles.partial', { date })}; + return ( + }> + {related.length + ? t('tours.paddle.obstacles.covered', { count: related.length, date }) + : t('tours.paddle.obstacles.coveredEmpty', { date })} + + ); +} diff --git a/client/src/components/Tours/paddle/PaddleRouteStrip.tsx b/client/src/components/Tours/paddle/PaddleRouteStrip.tsx new file mode 100644 index 000000000..8c83712b8 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleRouteStrip.tsx @@ -0,0 +1,76 @@ +import type { PaddleLayout } from '@trek/shared'; +import { useTranslation } from '../../../i18n'; +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'; + +/** Water in the band and in the tiles that explain it: the info blue of the theme. */ +export const WATER_COLOR = 'var(--info)'; + +/** The narrowest a stretch is drawn, in percent, so a short portage still shows. */ +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 + * 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 total = layout.totalM; + 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); + return ( +
+
+ {!compact && + layout.locks.map((lock) => ( + + + + + + ))} +
+ {layout.stretches.map((stretch) => { + const land = stretch.kind === 'portage'; + const length = stretch.toM - stretch.fromM; + return ( + + + + ); + })} +
+
+ {!compact && ( +
+ {km(0)} + {km(total)} +
+ )} +
+ ); +} diff --git a/client/src/components/Tours/paddle/PaddleTimeCard.tsx b/client/src/components/Tours/paddle/PaddleTimeCard.tsx new file mode 100644 index 000000000..6a50cd80f --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleTimeCard.tsx @@ -0,0 +1,434 @@ +import { + DEFAULT_PORTAGE_SPEED_KMH, + MAX_PADDLE_SPEED_KMH, + MAX_PORTAGE_SPEED_KMH, + paddleLayoutOfPoints, + type PaddleLayout, + type PaddleTimeEstimate, +} from '@trek/shared'; +import { AlertTriangle, Timer } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import { formatDistance } from '../../../utils/units'; +import { SoftPill } from '../../Planner/planParts'; +import { fs } from '../../shared/DialogShell'; +import { paddlePlanPoints } from '../planner/paddlePlanOf'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { InfoDot, RailCard, TourNotice } from '../tourParts'; +import { formatPlannedTourDuration } from '../tourPresentation'; +import { PaddleGlyph } from './PaddleGlyph'; +import { PaddleRouteStrip, WATER_COLOR } from './PaddleRouteStrip'; +import { PORTAGE_LINE_COLOR } from '../planner/tourRouteStyle'; + +const minutes = (value: number | null) => (value === null ? '–' : formatPlannedTourDuration(value)); + +/** A speed in km/h typed in place, decimals with either separator; the starting value shows until one is typed. */ +function KmhChip({ + label, + value, + placeholder, + max, + disabled, + onChange, + testId, +}: { + label: string; + value: number | null; + /** The starting value, shown while none is typed; null shows a dash. */ + placeholder: number | null; + /** The highest speed the field takes, in km/h. */ + max: number; + disabled: boolean; + onChange: (kmh: number | null) => void; + testId: string; +}) { + const { locale } = useTranslation(); + const [draft, setDraft] = useState(null); + const shown = (kmh: number) => kmh.toLocaleString(locale, { maximumFractionDigits: 1 }); + const commit = (text: string) => { + const trimmed = text.trim().replace(',', '.'); + if (trimmed === '') return onChange(null); + const kmh = Number(trimmed); + if (Number.isFinite(kmh) && kmh >= 0.5 && kmh <= max) onChange(Math.round(kmh * 10) / 10); + }; + return ( + + ); +} + +/** + * Measures that belong together (a distance and the speed it is covered at) + * as one white badge within the grey tile, its parts split by a hairline, so + * a narrow rail keeps them on the tile's first line. + */ +function MeasureGroup({ parts }: { parts: ReactNode[] }) { + return ( + + {parts.map((part, index) => ( + // The parts are fixed per tile; their order is their identity. + 0 ? 'ms-1.5 border-s border-edge-faint ps-1.5' : ''}> + {part} + + ))} + + ); +} + +/** A measure inside a part's tile: a white badge within the grey one. */ +function MeasureBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * One part of the estimate as a tile: its glyph in the colour the band gives + * it, its name and measures as badges within, and its time as the white badge + * at the end. The tile wraps in a narrow rail rather than cutting anything. + */ +function PartTile({ + icon, + color, + label, + measures, + time, + muted = false, + warn = false, + testId, +}: { + icon: ReactNode; + /** The band's colour for this part, so tile and band read as one. */ + color?: string; + label: string; + measures?: ReactNode; + time: string; + muted?: boolean; + /** A time that leaves something out: drawn in the warning tone. */ + warn?: boolean; + testId?: string; +}) { + return ( +
+ + {icon} + + + {label} + + {measures} + + {time} + +
+ ); +} + +type Translate = (key: string, params?: Record) => string; + +/** What of the estimate is still missing, said plainly, never folded into a total that looks whole. */ +function missingParts(estimate: PaddleTimeEstimate, t: Translate): string[] { + const parts: string[] = []; + if (estimate.waterMinutes === null) parts.push(t('tours.paddle.time.noSpeed')); + if (estimate.locksMissingTime > 0) + parts.push(t('tours.paddle.time.locksMissing', { count: estimate.locksMissingTime })); + return parts; +} + +/** The estimate as the card's headline, with the paddler's own planned total and the gap to it beside it. */ +function EstimateHead({ + total, + complete, + planned, + t, +}: { + total: number; + complete: boolean; + planned: number | null; + t: Translate; +}) { + const deviation = planned === null ? null : planned - total; + return ( +
+
+ + {t('tours.paddle.time.estimate')} + + + + {formatPlannedTourDuration(total)} + + {!complete && {t('tours.paddle.time.incomplete')}} + +
+ {planned !== null && deviation !== null && ( + + {t('tours.paddle.time.plannedOwn')} + {formatPlannedTourDuration(planned)} + {deviation !== 0 && ( + + + {`${deviation > 0 ? '+' : '−'}${formatPlannedTourDuration(Math.abs(deviation))}`} + + + )} + + )} +
+ ); +} + +/** A speed the rail lets the paddler set, with the starting value it falls back to. */ +export interface SpeedControl { + value: number | null; + startValue: number | null; + onChange: (kmh: number | null) => void; +} + +/** The tiles of the parts, water first, then what a portage and a lock add, then the breaks. */ +function PartTiles({ + estimate, + breakMinutes, + locked, + portageSpeed, + paddleSpeed, +}: { + estimate: PaddleTimeEstimate; + breakMinutes: number | null; + locked: boolean; + portageSpeed: SpeedControl; + /** Set here where the tour has no boat form of its own (a recorded track); else shown as it is. */ + paddleSpeed?: SpeedControl; +}) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const km = (m: number) => formatDistance(m / 1000, unit); + const speed = estimate.paddleSpeedKmh; + const water = paddleSpeed ? ( + + ) : speed !== null ? ( + `${speed.toLocaleString()} km/h` + ) : null; + return ( +
+ } + color={WATER_COLOR} + label={t('tours.paddle.time.waterShort')} + measures={} + time={minutes(estimate.waterMinutes)} + /> + {estimate.portageCount > 0 && ( + } + color={PORTAGE_LINE_COLOR} + label={t('tours.paddle.time.carry')} + measures={ + , + ]} + /> + } + time={minutes(estimate.carryMinutes)} + /> + )} + {estimate.portageCount > 0 && ( + } + color={PORTAGE_LINE_COLOR} + label={t('tours.paddle.time.outIn')} + measures={{`${estimate.portageCount}×`}} + time={minutes(estimate.portageExtraMinutes)} + /> + )} + {estimate.locksCounted > 0 && ( + } + label={t('tours.paddle.time.locks')} + measures={{`${estimate.locksCounted}×`}} + time={estimate.locksMissingTime > 0 && estimate.lockMinutes === 0 ? '–' : minutes(estimate.lockMinutes)} + warn={estimate.locksMissingTime > 0} + /> + )} + } + label={t('tours.planner.breaksShort')} + time={minutes(breakMinutes)} + muted={breakMinutes === null} + /> +
+ ); +} + +/** + * The estimate of a paddle tour as one block: the total as its headline beside + * the paddler's own planned total, the band of its stretches, the tiles of its + * parts and what is still missing. The planner and a recorded track show it alike. + */ +export function PaddleEstimateBody({ + estimate, + layout, + breakMinutes, + planned, + locked, + portageSpeed, + paddleSpeed, +}: { + estimate: PaddleTimeEstimate; + layout: PaddleLayout; + breakMinutes: number | null; + planned: number | null; + locked: boolean; + portageSpeed: SpeedControl; + paddleSpeed?: SpeedControl; +}) { + const { t } = useTranslation(); + const missing = missingParts(estimate, t); + return ( +
+ + + + {missing.length > 0 && ( + }> + {missing.join(' ')} + + )} +
+ ); +} + +/** The card's head: its title, and that its values are planning values to adjust. */ +export function PaddleTimeCardShell({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + return ( + } + title={t('tours.paddle.time.title')} + aside={ + + {t('tours.paddle.time.assumption')} + + + } + > + {children} + + ); +} + +/** + * PAD-07: how the time of a paddle tour comes about. Water at the paddling + * speed, portages at the carrying speed with their time out and in, locks at + * the times entered for them, the breaks; their sum is the estimate, set + * beside a total the paddler planned themselves. Every starting value says it + * is one, and what is unknown is named rather than counted as nothing. + */ +export function PaddleTimeCard({ planner, locked }: { planner: TourPlannerController; locked: boolean }) { + const estimate = planner.paddleEstimate; + if (!estimate || planner.waypoints.length < 2) return null; + return ( + + + + ); +} diff --git a/client/src/components/Tours/paddle/PaddleWaypointParts.tsx b/client/src/components/Tours/paddle/PaddleWaypointParts.tsx new file mode 100644 index 000000000..ce52d62df --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleWaypointParts.tsx @@ -0,0 +1,333 @@ +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'; + +const ROLE_GLYPH = { put_in: 'put_in', take_out: 'take_out', re_entry: 'put_in', final_take_out: 'take_out' } as const; + +/** Put-in, take-out, re-entry or final take-out, as the badge of a waypoint row of a paddle tour. */ +export function PaddleRolePill({ role }: { role: PaddleRole }) { + const { t } = useTranslation(); + const land = role === 'take_out' || role === 're_entry'; + return ( + + + {t(`tours.paddle.role.${role}`)} + + ); +} + +/** + * A number of minutes typed in place: a value or nothing. Text that is no + * whole number of minutes in range is dropped on leaving the field, and the + * value before it stays. + */ +export function MinutesInput({ + label, + value, + placeholder, + disabled, + onChange, + testId, +}: { + label: string; + value: number | null; + /** What shows while there is no value: the starting value, or a dash. */ + placeholder: string; + disabled: boolean; + onChange: (minutes: number | null) => void; + testId?: string; +}) { + const [draft, setDraft] = useState(null); + const commit = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (trimmed === '') return onChange(null); + const minutes = Number(trimmed); + if (Number.isInteger(minutes) && minutes >= 0 && minutes <= MAX_PADDLE_STOP_MINUTES) onChange(minutes); + }, + [onChange] + ); + return ( + + ); +} + +/** One labelled value of an opened stop, its input on the right. */ +function StopField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+ + + {label} + + {hint && ( + + {hint} + + )} + + {children} +
+ ); +} + +/** A value of a stop as the closed card shows it: quiet, or in the warning tone when it is missing. */ +function StopPill({ children, missing = false }: { children: ReactNode; missing?: boolean }) { + return ( + + {children} + + ); +} + +/** The quiet way to take a stop off again, at the foot of its opened card. */ +function RemoveStop({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + + ); +} + +/** + * A portage or a lock under its waypoint, in the shape of the via ferrata leg: + * a round glyph, the name and its values as pills. A click opens it to change + * those values in labelled fields, and closes it again. + */ +function StopCard({ + testId, + glyph, + color, + title, + pills, + muted = false, + children, +}: { + testId: string; + glyph: ReactNode; + /** The colour of its ring and its round glyph: the portage brown, or the quiet default. */ + color?: string; + title: string; + pills: ReactNode; + muted?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( +
  • + + {open && ( +
    + {children} +
    + )} +
  • + ); +} + +/** + * A portage under the point it starts at, in the earth brown the map draws it + * in: its length on land and the time for getting out, unloading and back in + * (the starting value until one is typed). + */ +export function PortageLegRow({ + lengthM, + extraMinutes, + disabled, + onExtraChange, + position, + onRemove, +}: { + lengthM: number; + extraMinutes: number | null; + disabled: boolean; + onExtraChange: (minutes: number | null) => void; + /** Where it lies on a recorded track ("at 4.2 km"); a planned tour shows it under its waypoint instead. */ + position?: string; + /** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */ + onRemove?: () => void; +}) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit); + const extra = extraMinutes ?? DEFAULT_PORTAGE_EXTRA_MINUTES; + return ( + } + color={PORTAGE_LINE_COLOR} + title={t('tours.paddle.portage')} + pills={ + <> + {position && {position}} + {formatDistance(lengthM / 1000, unit ?? 'metric')} + {`+${extra} min`} + + } + > +

    + {t('tours.paddle.portageInfo')} +

    + {onRemove && !disabled && } + + + +
    + ); +} + +/** + * A lock at a waypoint with the wait and the passage expected there. A time + * not typed is missing, never zero, and the closed card says so; a lock a + * portage carries round is dimmed and its times do not count. + */ +export function LockRow({ + lock, + bypassed, + disabled, + onChange, + position, +}: { + lock: PaddleLock; + bypassed: boolean; + disabled: boolean; + onChange: (lock: PaddleLock | null) => void; + /** Where it lies on a recorded track ("at 4.2 km"). */ + position?: string; +}) { + const { t } = useTranslation(); + const missing = lock.wait_minutes === null || lock.transit_minutes === null; + const known = (lock.wait_minutes ?? 0) + (lock.transit_minutes ?? 0); + const lockPill = bypassed ? ( + {t('tours.paddle.lockBypassed')} + ) : missing ? ( + {t('tours.paddle.timeMissing')} + ) : ( + {`${known} min`} + ); + return ( + } + title={t('tours.paddle.lock')} + muted={bypassed} + pills={ + <> + {position && {position}} + {lockPill} + + } + > + + + onChange({ ...lock, wait_minutes: minutes })} + /> + + + onChange({ ...lock, transit_minutes: minutes })} + /> + + {!disabled && onChange(null)} />} + + ); +} diff --git a/client/src/components/Tours/paddle/PaddleWindCard.tsx b/client/src/components/Tours/paddle/PaddleWindCard.tsx new file mode 100644 index 000000000..7c936e1b1 --- /dev/null +++ b/client/src/components/Tours/paddle/PaddleWindCard.tsx @@ -0,0 +1,277 @@ +import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared'; +import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { formatDate } from '../../../utils/formatters'; +import { SoftPill } from '../../Planner/planParts'; +import { fs } from '../../shared/DialogShell'; +import { Tooltip } from '../../shared/Tooltip'; +import { RailCard, TourNotice } from '../tourParts'; +import { useTourWind, type TourWind } from './useTourWind'; + +type Translate = (key: string, params?: Record) => string; + +/** The hours a day tour is out on the water, as the strip shows them. */ +const FIRST_HOUR = 6; +const LAST_HOUR = 21; + +/** + * An arrow the way the wind blows: meteorological degrees name where it comes + * from, so the arrow turns half round from them. + */ +function FlowArrow({ from, size }: { from: number; size: number }) { + // Drawn about its own centre, so every hour's arrow turns in place at the same height. + return ( + + ); +} + +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 + * out stays an empty column, never a calm one. + */ +function HourStrip({ hours, t }: { hours: readonly WindHour[]; t: Translate }) { + const shown = hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR); + const top = Math.max(10, ...shown.map((hour) => hour.gusts_kmh ?? hour.speed_kmh ?? 0)); + const height = (value: number | null) => `${Math.max(4, ((value ?? 0) / top) * 100)}%`; + return ( +
    + {shown.map((hour) => { + const from = hour.direction_deg; + const known = hour.speed_kmh !== null; + const label = known + ? t('tours.paddle.wind.hourInfo', { + hour: hour.hour, + speed: hour.speed_kmh, + gusts: hour.gusts_kmh ?? '–', + direction: directionName(from, t) ?? '–', + }) + : t('tours.paddle.wind.hourUnknown', { hour: hour.hour }); + return ( + + + + {from !== null && } + + + {hour.gusts_kmh !== null && ( + + )} + {known && ( + + )} + + + {hour.hour} + + + + ); + })} +
    + ); +} + +/** The forecast of the chosen day: its figures, its hours and where and when it was asked. */ +function Forecast({ forecast, t, locale }: { forecast: WindForecastResponse; t: Translate; locale: string }) { + const day = forecast.day; + const from = day?.direction_dominant_deg ?? null; + const fetched = new Date(forecast.fetched_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); + return ( +
    +
    + {from !== null && ( + } + info={t('tours.paddle.wind.fromInfo', { direction: directionName(from, t) ?? '' })} + /> + )} + + +
    + +
    + + + {t('tours.paddle.wind.legendMean')} + + + + {t('tours.paddle.wind.legendGusts')} + +
    +

    + {t('tours.paddle.wind.source', { source: forecast.source, time: fetched })} +

    +
    + ); +} + +/** Why there is no forecast to show, each case in its own words, with a way to ask again after a failure. */ +function Unavailable({ wind, t, locale }: { wind: TourWind; t: Translate; locale: string }) { + const { state } = wind; + if (state.kind === 'loading') + return
    ; + if (state.kind === 'error') + return ( + }> + + {t('tours.paddle.wind.error')} + + + + ); + if (state.kind === 'offline') + return }>{t('tours.paddle.wind.offline')}; + if (state.kind === 'ready' && state.forecast.status === 'beyond_horizon') { + const from = new Date(Date.parse(state.forecast.date) - (state.forecast.horizon_days - 1) * 86_400_000); + return ( + }> + {t('tours.paddle.wind.beyond', { date: formatDate(from.toISOString().slice(0, 10), locale) })} + + ); + } + const key = state.kind === 'ready' ? 'tours.paddle.wind.past' : `tours.paddle.wind.${state.kind}`; + return }>{t(key)}; +} + +/** What wind means for a paddler, in general terms; it rates no passage and clears nothing. */ +function WindHints({ t }: { t: Translate }) { + const [open, setOpen] = useState(false); + return ( +
    + + {open && ( +
      + {(['headwind', 'drift', 'crossing', 'gusts', 'local'] as const).map((hint) => ( +
    • {t(`tours.paddle.wind.hint.${hint}`)}
    • + ))} +
    + )} +
    + ); +} + +/** + * PAD-08: the wind on the day a paddle tour is planned for, at its start. The + * mean, the gusts and the direction are figures of their own, with the source + * and when it was asked; no value is turned into a verdict, and missing data + * is never shown as calm. + */ +export function PaddleWindCard({ wind }: { wind: TourWind }) { + const { t, locale } = useTranslation(); + const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null; + return ( + } + title={t('tours.paddle.wind.title')} + aside={ + wind.tourDays.length > 1 ? ( + + {wind.tourDays.map(({ day, index }) => ( + + ))} + + ) : wind.day ? ( + {formatDate(wind.day.day.date, locale) || t('dayplan.dayN', { n: wind.day.index + 1 })} + ) : null + } + > +
    + {ready ? : } + +
    +
    + ); +} + +/** The wind card for one tour: its days read from the trip, its start as the place the forecast is for. */ +export function PaddleWindSection({ placeId, start }: { placeId: number | null; start: { lat: number; lng: number } | null }) { + const wind = useTourWind(placeId, start); + return ; +} diff --git a/client/src/components/Tours/paddle/TrackPaddlePlanCard.tsx b/client/src/components/Tours/paddle/TrackPaddlePlanCard.tsx new file mode 100644 index 000000000..a3f7c616d --- /dev/null +++ b/client/src/components/Tours/paddle/TrackPaddlePlanCard.tsx @@ -0,0 +1,184 @@ +import type { TourListItem } from '@trek/shared'; +import { MousePointerClick, Plus, RotateCcw, Save } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import type { TrackPick } from '../../../store/trackMarkToolStore'; +import { formatDistance } from '../../../utils/units'; +import { DialogButton, fs } from '../../shared/DialogShell'; +import { RailCard, TourNotice } from '../tourParts'; +import { PaddleGlyph } from './PaddleGlyph'; +import { PaddleEstimateBody, PaddleTimeCardShell } from './PaddleTimeCard'; +import { LockRow, PortageLegRow } from './PaddleWaypointParts'; +import type { TrackPaddlePlan } from './useTrackPaddlePlan'; + +/** One of the two tools: pressed while a click on the line places its mark. */ +function PickButton({ + active, + disabled, + onPress, + icon, + children, + testId, +}: { + active: boolean; + disabled: boolean; + onPress: () => void; + icon: ReactNode; + children: ReactNode; + testId: string; +}) { + return ( + + ); +} + +/** What a click on the line does now, or why the last one placed nothing. */ +function pickHint(plan: TrackPaddlePlan, t: (key: string) => string): string | null { + if (plan.refused === 'overlap') return t('tours.paddle.marks.overlap'); + if (plan.refused === 'off_line') return t('tours.paddle.marks.offLine'); + if (plan.pick === 'lock') return t('tours.paddle.marks.pickLock'); + if (plan.pick === 'portage') + return t(plan.pendingFromM === null ? 'tours.paddle.marks.pickTakeOut' : 'tours.paddle.marks.pickReEntry'); + return null; +} + +/** + * The portages and locks laid onto a recorded paddle track: two tools to mark + * them by clicking the line, the list of what is marked with its times, and + * save once something changed. The recorded track is never redrawn. + */ +function TrackMarksCard({ plan }: { plan: TrackPaddlePlan }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const at = (m: number) => t('tours.paddle.marks.at', { distance: formatDistance(m / 1000, unit) }); + const locked = !plan.canEdit || plan.saving; + const hint = pickHint(plan, t); + const pick = (kind: TrackPick) => () => plan.startPick(kind); + const empty = plan.marks.portages.length === 0 && plan.marks.locks.length === 0; + return ( + } + title={t('tours.paddle.marks.title')} + > +
    + {plan.canEdit && ( +
    + } + > + {t('tours.paddle.marks.markPortage')} + + } + > + {t('tours.paddle.marks.markLock')} + +
    + )} + {hint && ( + }> + {hint} + + )} + {empty ? ( +

    + {t('tours.paddle.marks.empty')} +

    + ) : ( +
      + {plan.marks.portages.map((portage, index) => ( + plan.edits.setPortageExtra(index, minutes)} + onRemove={() => plan.edits.removePortage(index)} + /> + ))} + {plan.marks.locks.map((lock, index) => ( + stop.atM === lock.at_m)?.bypassed} + disabled={locked} + position={at(lock.at_m)} + onChange={(next) => plan.edits.setLock(index, next)} + /> + ))} +
    + )} + {plan.dirty && plan.canEdit && ( +
    + } onClick={plan.discard} disabled={plan.saving}> + {t('common.discard')} + + } + onClick={() => void plan.save()} + disabled={plan.saving} + > + {t('common.save')} + +
    + )} +
    +
    + ); +} + +/** + * PAD-03 and PAD-07 on a recorded paddle track: its time estimate, reckoned + * with the portages and locks laid onto it, and the card to lay them. + */ +export function TrackPaddlePlanCards({ plan, tour }: { plan: TrackPaddlePlan; tour: TourListItem }) { + if (!plan.active) return null; + const locked = !plan.canEdit || plan.saving; + return ( + <> + + plan.edits.setSpeeds({ portage }), + }} + paddleSpeed={{ + value: plan.speeds.paddle, + startValue: plan.recommendedPaddleSpeed, + onChange: (paddle) => plan.edits.setSpeeds({ paddle }), + }} + /> + + + + ); +} diff --git a/client/src/components/Tours/paddle/paddleGlyphPaths.ts b/client/src/components/Tours/paddle/paddleGlyphPaths.ts new file mode 100644 index 000000000..df568afb1 --- /dev/null +++ b/client/src/components/Tours/paddle/paddleGlyphPaths.ts @@ -0,0 +1,65 @@ +import type { PaddleObstacleKind } from '@trek/shared'; + +/** + * The paddler's symbols as SVG paths on lucide's 24 grid, one source for the + * React glyph and the map markers (Leaflet takes markup, not components). + */ +export type PaddleGlyphKind = + | 'lock' + | 'weir' + | 'dam' + | 'rapids' + | 'waterfall' + | 'put_in' + | 'take_out' + | 'portage' + | 'hazard' + | 'access'; + +const WAVE = 'M3 18c1.5-1.5 3-1.5 4.5 0s3 1.5 4.5 0 3-1.5 4.5 0 3 1.5 4.5 0'; + +export const PADDLE_GLYPH_PATHS: Record = { + lock: ['M3 7h18', 'M3 17h18', 'M4 7l3 5-3 5', 'M20 7l-3 5 3 5', 'M9 12h6'], + weir: ['M2 8h7c3 0 3 8 6 8h7', 'M9 8v12', 'M15 20h7'], + dam: ['M9 4h5l5 16H5z', 'M2 9h5', 'M2 13h4'], + rapids: [ + 'M2 7l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5', + 'M2 12l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5', + 'M2 17l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5', + ], + waterfall: ['M3 5h9v14', 'M15 9v10', 'M19 7v12', 'M3 19h18'], + put_in: ['M12 3v10', 'M8 9l4 4 4-4', WAVE], + take_out: ['M12 14V4', 'M8 8l4-4 4 4', WAVE], + // lucide's footprints + portage: [ + 'M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z', + 'M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z', + 'M16 17h4', + 'M4 13h4', + ], + hazard: ['M12 3 2 20h20Z', 'M12 9v5', 'M12 17h.01'], + // lucide's ban + access: ['M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20Z', 'M4.929 4.929 19.07 19.071'], +}; + +/** The symbol an obstacle of a kind is shown with. */ +export const OBSTACLE_GLYPH: Record = { + weir: 'weir', + dam: 'dam', + lock: 'lock', + lock_gate: 'lock', + waterfall: 'waterfall', + rapids: 'rapids', + hazard: 'hazard', + put_in: 'put_in', + slipway: 'put_in', + egress: 'take_out', + portage: 'portage', + access: 'access', +}; + +/** The symbol as SVG markup for a map marker; it carries no user content. */ +export function paddleGlyphSvg(kind: PaddleGlyphKind, size: number, color: string): string { + const paths = PADDLE_GLYPH_PATHS[kind].map((d) => ``).join(''); + return `${paths}`; +} diff --git a/client/src/components/Tours/paddle/paddleMapLayer.ts b/client/src/components/Tours/paddle/paddleMapLayer.ts new file mode 100644 index 000000000..2e91fe8aa --- /dev/null +++ b/client/src/components/Tours/paddle/paddleMapLayer.ts @@ -0,0 +1,128 @@ +import type { PaddleFeature, PaddleObstacleKind, RelatedObstacle } from '@trek/shared'; +import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg, type PaddleGlyphKind } from './paddleGlyphPaths'; + +/** + * PAD-05B: the paddle obstacles as the planner maps draw them, one model for + * Leaflet and GL. Colours sort objects into what they are, never into safe or + * unsafe: obstacles in slate, access rules in indigo, places to get in and out + * in teal, portage ways in the earth brown of the planner's own portages. + * How strongly an object shows says how it stands to the route. + */ + +export type PaddleMapRelation = 'on_route' | 'near' | 'area'; +type Category = 'obstacle' | 'access' | 'landing' | 'portage'; + +const CATEGORY: Record = { + weir: 'obstacle', + dam: 'obstacle', + lock: 'obstacle', + lock_gate: 'obstacle', + waterfall: 'obstacle', + rapids: 'obstacle', + hazard: 'obstacle', + access: 'access', + put_in: 'landing', + egress: 'landing', + slipway: 'landing', + portage: 'portage', +}; + +export const CATEGORY_COLOR: Record = { + obstacle: '#334155', // theme-lint-disable map colour + access: '#4338ca', // theme-lint-disable map colour + landing: '#0f766e', // theme-lint-disable map colour + portage: '#9a6233', // theme-lint-disable map colour +}; + +export interface PaddleMapItem { + feature: PaddleFeature; + relation: PaddleMapRelation; + color: string; + glyph: PaddleGlyphKind; + /** Where its marker sits: the point itself, or the middle of its line. */ + marker: [number, number]; + /** Its line or outline as [lat, lng] pairs; null for a point. */ + line: [number, number][] | null; +} + +export interface PaddleMapLayer { + items: PaddleMapItem[]; + selectedRef: string | null; + onSelect: (ref: string) => void; +} + +function lineOf(feature: PaddleFeature): [number, number][] | null { + const { geometry } = feature; + if (geometry.type === 'Point') return null; + const ring = geometry.type === 'LineString' ? geometry.coordinates : (geometry.coordinates[0] ?? []); + return ring.map(([lng, lat]) => [lat, lng]); +} + +function markerOf(feature: PaddleFeature, line: [number, number][] | null): [number, number] { + if (feature.geometry.type === 'Point') return [feature.geometry.coordinates[1], feature.geometry.coordinates[0]]; + const points = line ?? []; + return points[Math.floor(points.length / 2)] ?? [0, 0]; +} + +/** The items to draw: the related ones as they stand to the route, the rest of the area quietly. */ +export function paddleMapItems(features: readonly PaddleFeature[], related: readonly RelatedObstacle[]): PaddleMapItem[] { + const relations = new Map(related.map((entry) => [entry.feature.ref, entry.relation])); + return features.map((feature) => { + const line = lineOf(feature); + return { + feature, + relation: relations.get(feature.ref) ?? 'area', + color: CATEGORY_COLOR[CATEGORY[feature.kind]], + glyph: OBSTACLE_GLYPH[feature.kind], + marker: markerOf(feature, line), + line, + }; + }); +} + +/** The marker's size in pixels by how it stands to the route; a selected one is larger still. */ +export function markerSize(relation: PaddleMapRelation, selected: boolean): number { + if (selected) return 36; + return relation === 'on_route' ? 30 : relation === 'near' ? 26 : 22; +} + +/** + * A marker as markup: a white disc with a ring in the object's colour and its + * glyph inside, dashed when the object is only near the route, faded when it + * is merely in the area. It carries no user content. + */ +export function paddleMarkerHtml(item: PaddleMapItem, selected: boolean): string { + const size = markerSize(item.relation, selected); + const ring = selected ? 3 : 2; + const dash = item.relation === 'near' && !selected ? 'dashed' : 'solid'; + const opacity = item.relation === 'area' && !selected ? 0.6 : 1; + const glyph = paddleGlyphSvg(item.glyph, Math.round(size * 0.58), item.color); + const style = `width:${size}px;height:${size}px;border-radius:9999px;background:#fff;border:${ring}px ${dash} ${item.color};box-shadow:0 1px 3px rgba(15,23,42,.35);display:grid;place-items:center;box-sizing:border-box;opacity:${opacity}`; // theme-lint-disable map marker on the map's own white + return `
    ${glyph}
    `; +} + +/** + * The same marker as a standalone SVG, for the GL maps' symbol images: drawn + * at twice its size for sharp pixels on dense screens, the ring dashed or + * faded as the HTML marker is. + */ +export function paddleMarkerSvg(item: PaddleMapItem, selected: boolean): string { + const size = markerSize(item.relation, selected) * 2; + const ring = (selected ? 3 : 2) * 2; + const r = size / 2 - ring / 2 - 2; + const dash = item.relation === 'near' && !selected ? ' stroke-dasharray="6 5"' : ''; + const opacity = item.relation === 'area' && !selected ? 0.6 : 1; + const glyph = Math.round(size * 0.58); + const offset = (size - glyph) / 2; + const paths = PADDLE_GLYPH_PATHS[item.glyph].map((d) => ``).join(''); + return ( + `` + + `` + // theme-lint-disable marker disc + `${paths}` + ); +} + +/** The name of a marker's image on a GL map, one per look. */ +export function paddleMarkerImageId(item: PaddleMapItem, selected: boolean): string { + return `trek-paddle-${item.glyph}-${item.color.slice(1)}-${item.relation}${selected ? '-sel' : ''}`; +} diff --git a/client/src/components/Tours/paddle/usePaddleLayerGL.ts b/client/src/components/Tours/paddle/usePaddleLayerGL.ts new file mode 100644 index 000000000..14578d3fc --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleLayerGL.ts @@ -0,0 +1,184 @@ +import type { Map } from 'mapbox-gl'; +import { useEffect, useRef } from 'react'; +import { markerSize, paddleMarkerImageId, paddleMarkerSvg, type PaddleMapItem, type PaddleMapLayer } from './paddleMapLayer'; + +/** One map type for both engines, as the ferrata layer has: MapLibre answers every call made here. */ +export type PaddleGlMap = Map; + +const SOURCE = 'trek-paddle'; +const CASING = 'trek-paddle-casing'; +const LINE = 'trek-paddle-line'; +const PINS = 'trek-paddle-pins'; +const LAYERS = [CASING, LINE, PINS]; + +const WIDTH = { on_route: 4, near: 3, area: 2 } as const; +const OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const; + +function collection(items: readonly PaddleMapItem[], selectedRef: string | null): GeoJSON.FeatureCollection { + const features: GeoJSON.Feature[] = []; + for (const item of items) { + const selected = item.feature.ref === selectedRef; + if (item.line) { + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: item.line.map(([lat, lng]) => [lng, lat]) }, + properties: { + ref: item.feature.ref, + color: item.color, + width: WIDTH[item.relation], + opacity: OPACITY[item.relation], + near: item.relation === 'near', + }, + }); + } + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [item.marker[1], item.marker[0]] }, + properties: { + ref: item.feature.ref, + icon: paddleMarkerImageId(item, selected), + order: selected ? 3 : item.relation === 'on_route' ? 2 : item.relation === 'near' ? 1 : 0, + }, + }); + } + return { type: 'FeatureCollection', features }; +} + +/** Draws each marker look the map does not have yet; a look is an image of its own. */ +function addImages(map: PaddleGlMap, items: readonly PaddleMapItem[], selectedRef: string | null, gone: () => boolean) { + for (const item of items) { + const selected = item.feature.ref === selectedRef; + const id = paddleMarkerImageId(item, selected); + if (map.hasImage(id)) continue; + const image = new Image(markerSize(item.relation, selected) * 2, markerSize(item.relation, selected) * 2); + image.onload = () => { + if (!gone() && !map.hasImage(id)) map.addImage(id, image, { pixelRatio: 2 }); + }; + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(paddleMarkerSvg(item, selected))}`; + } +} + +function addLayers(map: PaddleGlMap, data: GeoJSON.FeatureCollection): void { + map.addSource(SOURCE, { type: 'geojson', data }); + const lines: ['==', ['geometry-type'], string] = ['==', ['geometry-type'], 'LineString']; + map.addLayer({ + id: CASING, + type: 'line', + source: SOURCE, + filter: lines, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { + 'line-color': '#ffffff', // theme-lint-disable map casing + 'line-width': ['+', ['get', 'width'], 3], + 'line-opacity': ['get', 'opacity'], + }, + }); + map.addLayer({ + id: LINE, + type: 'line', + source: SOURCE, + filter: lines, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + paint: { 'line-color': ['get', 'color'], 'line-width': ['get', 'width'], 'line-opacity': ['get', 'opacity'] }, + }); + map.addLayer({ + id: PINS, + type: 'symbol', + source: SOURCE, + filter: ['==', ['geometry-type'], 'Point'], + layout: { + 'icon-image': ['get', 'icon'], + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + 'symbol-sort-key': ['get', 'order'], + }, + }); +} + +/** + * Whether a click at `point` landed on a paddle object. The map's own click + * handler asks this first, so picking an obstacle opens its card instead of + * also setting a waypoint under it. + */ +export function isPaddleHit( + map: { getLayer?(id: string): unknown; queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[] }, + point: Point +): boolean { + if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false; + const layers = [PINS, LINE].filter((id) => map.getLayer?.(id)); + return layers.length > 0 && map.queryRenderedFeatures(point, { layers }).length > 0; +} + +/** + * PAD-05B on a GL map (MapLibre or Mapbox, the 2D planner and the 3D + * terrain alike): the paddle objects as lines on a white casing and as round + * glyph markers, in the same look as on Leaflet. Kept through a basemap + * change, which rebuilds the style and drops them, and removed with the layer. + */ +export function usePaddleLayerGL(map: PaddleGlMap | null, layer: PaddleMapLayer | undefined) { + const onSelect = useRef(layer?.onSelect); + useEffect(() => { + onSelect.current = layer?.onSelect; + }, [layer?.onSelect]); + const items = layer?.items; + const selectedRef = layer?.selectedRef ?? null; + + useEffect(() => { + if (!map || !items?.length) return; + let gone = false; + const data = collection(items, selectedRef); + const apply = () => { + try { + addImages(map, items, selectedRef, () => gone); + const source = map.getSource(SOURCE) as { setData?: (next: GeoJSON.FeatureCollection) => void } | undefined; + if (source?.setData) source.setData(data); + else addLayers(map, data); + } catch { + // A style still loading takes the layers on its next styledata event. + } + }; + apply(); + map.on('styledata', apply); + return () => { + gone = true; + map.off('styledata', apply); + }; + }, [items, map, selectedRef]); + + useEffect(() => { + if (!map) return; + const click = (event: { features?: Array<{ properties?: { ref?: unknown } }> }) => { + const ref = event.features?.[0]?.properties?.ref; + if (typeof ref === 'string') onSelect.current?.(ref); + }; + const enter = () => { + map.getCanvas().style.cursor = 'pointer'; + }; + const leave = () => { + map.getCanvas().style.cursor = ''; + }; + for (const id of [PINS, LINE]) { + map.on('click', id, click); + map.on('mouseenter', id, enter); + map.on('mouseleave', id, leave); + } + return () => { + for (const id of [PINS, LINE]) { + map.off('click', id, click); + map.off('mouseenter', id, enter); + map.off('mouseleave', id, leave); + } + }; + }, [map]); + + // Without objects to show, the layers go; the next objects bring them back. + useEffect(() => { + if (!map || items?.length) return; + try { + for (const id of [...LAYERS].reverse()) if (map.getLayer(id)) map.removeLayer(id); + if (map.getSource(SOURCE)) map.removeSource(SOURCE); + } catch { + // A map torn down meanwhile has nothing left to remove. + } + }, [items, map]); +} diff --git a/client/src/components/Tours/paddle/usePaddleObstacleSync.ts b/client/src/components/Tours/paddle/usePaddleObstacleSync.ts new file mode 100644 index 000000000..884867a87 --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleObstacleSync.ts @@ -0,0 +1,80 @@ +import { relateObstaclesToRoute, type PaddleCoverageState } from '@trek/shared'; +import { useEffect, useMemo, useState } from 'react'; +import { + PaddleObstaclesOfflineError, + paddleObstacleRepo, + tilesAround, + type PaddleObstaclesAround, + type Tile, +} from '../../../repo/paddleObstacleRepo'; +import { clearPaddleObstacles, publishPaddleObstacles, type PaddleObstacleStatus } from '../../../store/paddleObstacleStore'; + +type Line = readonly (readonly [number, number])[]; + +/** The area a line asks for, as tiles snapped outward to a 0.05 degree grid, so moving a point a little asks nothing new. */ +function tilesOf(line: Line): Tile[] { + const snap = (value: number, outward: 'down' | 'up') => + (outward === 'down' ? Math.floor(value * 20) : Math.ceil(value * 20)) / 20; + return tilesAround(line).map((tile) => ({ + south: snap(tile.south, 'down'), + west: snap(tile.west, 'down'), + north: snap(tile.north, 'up'), + east: snap(tile.east, 'up'), + })); +} + +interface Loaded { + key: string; + status: PaddleObstacleStatus; + data: PaddleObstaclesAround | null; +} + +/** + * PAD-05B: keeps the obstacle store in step with the paddle tour open in the + * planner. It asks for the area around the line (again only when the line + * leaves it), relates what came back to the line as it is now, and publishes + * both; with no paddle tour open the store is emptied. + */ +export function usePaddleObstacleSync(line: Line | null, active: boolean) { + const key = active && line && line.length >= 2 ? JSON.stringify(tilesOf(line)) : null; + const [loaded, setLoaded] = useState(null); + + useEffect(() => { + if (!key) return; + const controller = new AbortController(); + paddleObstacleRepo + .inTiles(JSON.parse(key) as Tile[], controller.signal) + .then((data) => { + if (!controller.signal.aborted) setLoaded({ key, status: 'ready', data }); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setLoaded({ key, status: err instanceof PaddleObstaclesOfflineError ? 'offline' : 'error', data: null }); + }); + return () => controller.abort(); + }, [key]); + + const current = loaded && loaded.key === key ? loaded : null; + const related = useMemo( + () => (current?.data && line ? relateObstaclesToRoute(line, current.data.features) : []), + [current, line] + ); + + useEffect(() => { + if (!key) { + clearPaddleObstacles(); + return; + } + const coverage: PaddleCoverageState = current?.data?.coverage ?? 'none'; + publishPaddleObstacles({ + status: current?.status ?? 'loading', + features: current?.data?.features ?? [], + related, + coverage, + regions: current?.data?.regions ?? [], + truncated: current?.data?.truncated ?? false, + }); + }, [current, key, related]); + + useEffect(() => () => clearPaddleObstacles(), []); +} diff --git a/client/src/components/Tours/paddle/usePaddleWaypointActions.ts b/client/src/components/Tours/paddle/usePaddleWaypointActions.ts new file mode 100644 index 000000000..d7a698875 --- /dev/null +++ b/client/src/components/Tours/paddle/usePaddleWaypointActions.ts @@ -0,0 +1,23 @@ +import type { PaddleLock } from '@trek/shared'; +import { useCallback } from 'react'; +import { setPortageExtraMinutes, setWaypointLock, togglePortageLeg } from '../planner/tourRouteActions'; +import type { TourPlannerController } from '../planner/useTourPlanner'; + +/** The paddle edits of the planner's waypoint rows, each one step of its history. */ +export function usePaddleWaypointActions(planner: TourPlannerController) { + const { editPaddlePoints } = planner; + return { + togglePortage: useCallback( + (id: string) => editPaddlePoints((points) => togglePortageLeg(points, id)), + [editPaddlePoints] + ), + setPortageExtra: useCallback( + (id: string, minutes: number | null) => editPaddlePoints((points) => setPortageExtraMinutes(points, id, minutes)), + [editPaddlePoints] + ), + setLock: useCallback( + (id: string, lock: PaddleLock | null) => editPaddlePoints((points) => setWaypointLock(points, id, lock)), + [editPaddlePoints] + ), + }; +} diff --git a/client/src/components/Tours/paddle/useTourWind.ts b/client/src/components/Tours/paddle/useTourWind.ts new file mode 100644 index 000000000..3017885b0 --- /dev/null +++ b/client/src/components/Tours/paddle/useTourWind.ts @@ -0,0 +1,81 @@ +import type { WindForecastResponse } from '@trek/shared'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { WindOfflineError, windRepo } from '../../../repo/windRepo'; +import { useTripStore } from '../../../store/tripStore'; +import type { Day } from '../../../types'; +import { useTourDays, type TourDay } from '../tourDays'; + +export type TourWindState = + | { kind: 'no_day' } + | { kind: 'no_date' } + | { kind: 'no_place' } + | { kind: 'loading' } + | { kind: 'offline' } + | { kind: 'error' } + | { kind: 'ready'; forecast: WindForecastResponse }; + +const NO_DAYS: Day[] = []; + +/** The day to show first: the first one still to come, else the first of the tour. */ +function firstDayOf(tourDays: readonly TourDay[], today: string): TourDay | null { + return tourDays.find(({ day }) => !!day.date && day.date >= today) ?? tourDays[0] ?? null; +} + +/** + * PAD-08: the wind forecast for a paddle tour on the day it is planned for, + * at its start. A tour on several days shows one at a time, the one chosen, + * never another day without saying so. Without a day, a date, a place or a + * connection it says which is missing; nothing here changes the tour. + */ +export function useTourWind(placeId: number | null, start: { lat: number; lng: number } | null) { + const days = useTripStore((state) => state.days) ?? NO_DAYS; + const tourDays = useTourDays(placeId ?? -1, days); + const today = new Date().toISOString().slice(0, 10); + const [chosenDayId, setChosenDayId] = useState(null); + const day = useMemo( + () => tourDays.find((entry) => entry.day.id === chosenDayId) ?? firstDayOf(tourDays, today), + [chosenDayId, today, tourDays] + ); + const [attempt, setAttempt] = useState(0); + const lat = start?.lat ?? null; + const lng = start?.lng ?? null; + const date = day?.day.date ?? null; + // One request per place, day and retry; the answer is kept with the key it was asked for. + const key = date && lat !== null && lng !== null ? `${lat},${lng},${date},${attempt}` : null; + const [result, setResult] = useState<{ key: string; state: TourWindState } | null>(null); + + useEffect(() => { + if (!key || !date || lat === null || lng === null) return; + const controller = new AbortController(); + windRepo + .forecast(lat, lng, date, controller.signal) + .then((forecast) => { + if (!controller.signal.aborted) setResult({ key, state: { kind: 'ready', forecast } }); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setResult({ key, state: { kind: err instanceof WindOfflineError ? 'offline' : 'error' } }); + }); + return () => controller.abort(); + }, [date, key, lat, lng]); + + const state: TourWindState = !day + ? { kind: 'no_day' } + : !date + ? { kind: 'no_date' } + : lat === null || lng === null + ? { kind: 'no_place' } + : result && result.key === key + ? result.state + : { kind: 'loading' }; + + return { + state, + day, + tourDays, + chooseDay: setChosenDayId, + retry: useCallback(() => setAttempt((value) => value + 1), []), + }; +} + +export type TourWind = ReturnType; diff --git a/client/src/components/Tours/paddle/useTrackPaddlePlan.ts b/client/src/components/Tours/paddle/useTrackPaddlePlan.ts new file mode 100644 index 000000000..84fe79541 --- /dev/null +++ b/client/src/components/Tours/paddle/useTrackPaddlePlan.ts @@ -0,0 +1,132 @@ +import { + DEFAULT_PORTAGE_SPEED_KMH, + estimatePaddleTime, + paddleLayoutOfTrack, + recommendedPaddleSpeedKmh, + type TourListItem, +} from '@trek/shared'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { useTranslation } from '../../../i18n'; +import { tourRepo } from '../../../repo/tourRepo'; +import { + loadTrackMarks, + markTrackMarksSaved, + startTrackPick, + trackMarkEdits, + useTrackMarkToolStore, + type TrackPick, +} from '../../../store/trackMarkToolStore'; +import { useToast } from '../../shared/Toast'; +import type { TourPlannerController } from '../planner/useTourPlanner'; + +interface TrackPaddlePlanOptions { + tripId: number; + planner: TourPlannerController; + canEdit: boolean; + /** The tour as it stands after the save, for the lists and the map to take it in. */ + onSaved: (tour: TourListItem) => Promise | void; +} + +/** + * PAD-03 and PAD-07 for a recorded paddle track: the portages and locks laid + * onto it and the speeds its estimate is reckoned with. The marks live in a + * store the map shares (it is clicked to place them); this loads them when a + * paddle import is opened, drops them when it is left, and saves them through + * the offline core. The track itself is never changed. + */ +export function useTrackPaddlePlan({ tripId, planner, canEdit, onSaved }: TrackPaddlePlanOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const [saving, setSaving] = useState(false); + const tour = planner.mode.type === 'view-gpx' ? planner.mode.tour : null; + const paddleTour = tour?.tour_type === 'kayak' ? tour : null; + const totalM = (planner.readOnlyGpxAnalysis?.distanceKm ?? 0) * 1000; + const state = useTrackMarkToolStore( + useShallow((store) => ({ + placeId: store.placeId, + marks: store.marks, + speeds: store.speeds, + dirty: store.dirty, + pick: store.pick, + pendingFromM: store.pendingFromM, + refused: store.refused, + })) + ); + + // The tour opened, saved elsewhere meanwhile, or left: the marks follow it, unsaved ones are dropped. + useEffect(() => { + if (!paddleTour) { + loadTrackMarks(null, null, { paddle: null, portage: null }); + return; + } + loadTrackMarks(paddleTour.place_id, paddleTour.track_marks, { + paddle: paddleTour.paddle_speed_kmh ?? null, + portage: paddleTour.portage_speed_kmh ?? null, + }); + }, [paddleTour]); + + const active = !!paddleTour && state.placeId === paddleTour.place_id; + const layout = useMemo(() => paddleLayoutOfTrack(totalM, state.marks), [state.marks, totalM]); + const recommended = recommendedPaddleSpeedKmh(paddleTour?.boat_type ?? null); + const estimate = useMemo( + () => + estimatePaddleTime(layout, { + paddleSpeedKmh: state.speeds.paddle ?? recommended, + portageSpeedKmh: state.speeds.portage, + breakMinutes: null, + }), + [layout, recommended, state.speeds.paddle, state.speeds.portage] + ); + + const save = useCallback(async () => { + if (!paddleTour || saving || !canEdit) return; + setSaving(true); + try { + const body = { paddle_speed_kmh: state.speeds.paddle, portage_speed_kmh: state.speeds.portage, track_marks: state.marks }; + const result = await tourRepo.updatePaddlePlan(tripId, paddleTour.place_id, body); + markTrackMarksSaved(result.tour.track_marks, { + paddle: result.tour.paddle_speed_kmh ?? null, + portage: result.tour.portage_speed_kmh ?? null, + }); + await onSaved(result.tour); + toast.success(t('tours.paddle.marks.saved')); + } catch { + toast.error(t('tours.paddle.marks.failed')); + } finally { + setSaving(false); + } + }, [canEdit, onSaved, paddleTour, saving, state.marks, state.speeds, t, toast, tripId]); + + const discard = useCallback(() => { + if (!paddleTour) return; + loadTrackMarks(paddleTour.place_id, paddleTour.track_marks, { + paddle: paddleTour.paddle_speed_kmh ?? null, + portage: paddleTour.portage_speed_kmh ?? null, + }); + }, [paddleTour]); + + return { + /** A recorded paddle track is open: the card shows. */ + active, + canEdit, + saving, + marks: state.marks, + speeds: state.speeds, + dirty: state.dirty, + pick: state.pick, + pendingFromM: state.pendingFromM, + refused: state.refused, + totalM, + layout, + estimate, + recommendedPaddleSpeed: recommended, + defaultPortageSpeed: DEFAULT_PORTAGE_SPEED_KMH, + startPick: (pick: TrackPick) => startTrackPick(state.pick === pick ? null : pick), + edits: trackMarkEdits, + save, + discard, + }; +} + +export type TrackPaddlePlan = ReturnType; diff --git a/client/src/components/Tours/paddle/useTrackPortageParts.ts b/client/src/components/Tours/paddle/useTrackPortageParts.ts new file mode 100644 index 000000000..dfc0cd5a4 --- /dev/null +++ b/client/src/components/Tours/paddle/useTrackPortageParts.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; +import { useTrackMarkToolStore } from '../../../store/trackMarkToolStore'; +import { splitTrackByPortages } from '../../../utils/paddleTrackMarks'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import type { TourRoutePart } from '../planner/tourRouting'; + +/** + * The recorded paddle track in view, cut where portages are marked on it, as + * the parts the planner map draws: water as the plain line, each carried + * stretch on its own. Null while no paddle track with a portage is open. + */ +export function useTrackPortageParts( + planner: Pick +): TourRoutePart[] | null { + const placeId = planner.mode.type === 'view-gpx' ? planner.mode.placeId : null; + const portages = useTrackMarkToolStore((state) => (state.placeId === placeId ? state.marks.portages : null)); + const line = planner.mode.type === 'view-gpx' ? planner.readOnlyGpxAnalysis?.routeCoordinates : undefined; + return useMemo(() => { + if (!line?.length || !portages?.length) return null; + return splitTrackByPortages(line, portages).map((part) => ({ + coordinates: part.coordinates, + free: false, + ...(part.land ? { land: true } : {}), + })); + }, [line, portages]); +} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 7ed246eee..c8c82af79 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -536,9 +536,12 @@ describe('TourPlannerRail', () => { fireEvent.dragEnd(rows[0], { dataTransfer: transfer }); expect(reorderWaypoint).not.toHaveBeenCalled(); - const moveDown = screen.getAllByRole('button', { name: 'Move waypoint down' })[0]; - expect(moveDown).toBeEnabled(); - moveDown.focus(); + // The row's menu opens from the keyboard and lands on its first action that can run. + const more = screen.getAllByRole('button', { name: 'More actions' })[0]; + more.focus(); + await user.keyboard(' '); + const moveDown = screen.getByRole('button', { name: 'Move waypoint down' }); + expect(moveDown).toHaveFocus(); await user.keyboard(' '); expect(moveWaypoint).toHaveBeenCalledWith('a', 1); expect(reorderWaypoint).not.toHaveBeenCalled(); @@ -573,9 +576,8 @@ describe('TourPlannerRail', () => { expect(screen.getByRole('button', { name: 'Maximum trail difficulty' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Back to Tours' })).toBeDisabled() - expect(screen.getAllByLabelText('Move waypoint up')).toHaveLength(2) - expect(screen.getAllByLabelText('Move waypoint up').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) - expect(screen.getAllByLabelText('Move waypoint down').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) + expect(screen.getAllByLabelText('More actions')).toHaveLength(2) + expect(screen.getAllByLabelText('More actions').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) expect(screen.getAllByLabelText('Remove waypoint').every(element => element.getAttribute('aria-disabled') === 'true')).toBe(true) expect(within(screen.getByRole('status')).getByLabelText('Saving…')).toBeInTheDocument() }) diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index e9617af43..0e95d8c97 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -19,6 +19,10 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../ import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts' import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts' import { TourDetailsSection, TourNameField } from './TourEditorFields' +import { PaddleTimeCard } from '../paddle/PaddleTimeCard' +import { PaddleWindSection } from '../paddle/PaddleWindCard' +import { TrackPaddlePlanCards } from '../paddle/TrackPaddlePlanCard' +import type { TrackPaddlePlan } from '../paddle/useTrackPaddlePlan' import { TourTypeFields } from './TourTypeFields' import TourWaypointList from './TourWaypointList' import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry' @@ -219,15 +223,23 @@ function NeutralRail({ ) } +/** Where a recorded track starts, the place its wind is asked for. */ +function trackStart(line: readonly (readonly [number, number])[] | undefined): { lat: number; lng: number } | null { + const first = line?.[0] + return first ? { lat: first[0], lng: first[1] } : null +} + /** The rail for an imported tour: shown as it came in, measured, never edited. */ function GpxViewRail({ planner, canEdit, conversion, + trackPaddlePlan, }: { planner: TourPlannerController canEdit: boolean conversion?: GpxConversion + trackPaddlePlan?: TrackPaddlePlan }) { const { t } = useTranslation() const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) @@ -264,6 +276,10 @@ function GpxViewRail({
    {conversion && } + {trackPaddlePlan && } + {tour.tour_type === 'kayak' && ( + + )} } title={t('tours.planner.inspector.title')}> {analysis ? (
    @@ -291,11 +307,13 @@ function GpxViewRail({ export function TourPlannerRail({ planner, onOpenWanderer, conversion, - recordedTrack, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController + recordedTrack, trackPaddlePlan, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController /** Planning an editable tour from the import in view. */ conversion?: GpxConversion /** The way back to the recorded track of an import planned over in place. */ recordedTrack?: RecordedTrack + /** The portages and locks of a recorded paddle track in view (PAD-03). */ + trackPaddlePlan?: TrackPaddlePlan /** Browse the user's wanderer trails instead; offered while nothing is being edited. */ onOpenWanderer?: () => void } & TourPermissionProps) { @@ -321,7 +339,7 @@ export function TourPlannerRail({ planner, return if (planner.mode.type === 'view-gpx') - return + return const isSavedEdit = planner.mode.type === 'edit-saved' @@ -350,6 +368,12 @@ export function TourPlannerRail({ planner, + {planner.isPaddle && } + + {planner.isPaddle && ( + + )} +
    diff --git a/client/src/components/Tours/planner/TourTypeFields.tsx b/client/src/components/Tours/planner/TourTypeFields.tsx index 9601ef6cd..5e5226d36 100644 --- a/client/src/components/Tours/planner/TourTypeFields.tsx +++ b/client/src/components/Tours/planner/TourTypeFields.tsx @@ -10,6 +10,7 @@ import { useTranslation } from '../../../i18n'; import { SoftPill } from '../../Planner/planParts'; import { INPUT, LABEL, Segmented } from '../../shared/dialogParts'; import { fs } from '../../shared/DialogShell'; +import { PaddleObstacleNotice } from '../paddle/PaddleObstacleNotice'; import { TourNotice } from '../tourParts'; import type { TourPlannerController } from './useTourPlanner'; @@ -160,8 +161,8 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) { ) )}
    - {/* PAD-04: what the map does not know yet stays visible, never read as open water. */} - }>{t('tours.paddle.noWaterData')} + {/* PAD-04/05B: what the map knows about the water, and where it knows nothing, never read as open water. */} + {planner.paddleIncomplete && ( }> {t('tours.paddle.chooseBoat')} diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index 377f2e3f5..e11a1a7f0 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -1,4 +1,4 @@ -import type { TourFixedLeg } from '@trek/shared'; +import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared'; import { ArrowDown, ArrowDownUp, @@ -7,11 +7,12 @@ import { Info, MapPin, MapPinPlus, + MoreHorizontal, Repeat, Spline, Trash2, } from 'lucide-react'; -import { Fragment, useState, type ReactNode } from 'react'; +import { Fragment, useState, type MouseEvent, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { @@ -24,8 +25,13 @@ import { formatDistance } from '../../../utils/units'; import { FerrataGlyph } from '../../Ferratas/FerrataGlyph'; import { SoftPill } from '../../Planner/planParts'; import { fs } from '../../shared/DialogShell'; +import { ContextMenu, useContextMenu } from '../../shared/ContextMenu'; import { Tooltip } from '../../shared/Tooltip'; import { RailCard, TourNotice } from '../tourParts'; +import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph'; +import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts'; +import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; +import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf'; import { lineLengthM } from './tourFixedLegs'; import type { TourPlannerController } from './useTourPlanner'; import { useWaypointPlaceNames } from './useWaypointPlaceNames'; @@ -45,6 +51,7 @@ function WaypointAction({ danger = false, active, onAct, + onPress, children, }: { label: string; @@ -53,6 +60,8 @@ function WaypointAction({ /** A switch rather than a one-off action: pressed while on. */ active?: boolean; onAct: () => void; + /** Told the click itself, for a control that opens a menu at its place. */ + onPress?: (event: MouseEvent) => void; children: ReactNode; }) { return ( @@ -65,7 +74,9 @@ function WaypointAction({ aria-pressed={active} onClick={(event) => { event.stopPropagation(); - if (!disabled) onAct(); + if (disabled) return; + if (onPress) onPress(event); + else onAct(); }} className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:bg-surface-card hover:text-content'}`} > @@ -246,10 +257,13 @@ function WaypointRowLabel({ point, placeName, loopEnd, + paddleRole, }: { point: TourPlannerController['waypoints'][number]; placeName: string | undefined; loopEnd: boolean; + /** On a paddle tour the badge names the point for the paddler: put-in, take-out, re-entry, final take-out. */ + paddleRole?: PaddleRole | null; }) { const { t } = useTranslation(); const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`; @@ -264,7 +278,9 @@ function WaypointRowLabel({ > {name ?? coordinates} - {point.role !== 'via' && !loopEnd && {t(ROLE_KEY[point.role])}} + {paddleRole !== undefined + ? paddleRole && !loopEnd && + : point.role !== 'via' && !loopEnd && {t(ROLE_KEY[point.role])}} {name && !loopEnd && ( planner.moveWaypoint(point.id, -1) }, + { + label: t('tours.planner.moveDown'), + icon: ArrowDown, + disabled: index === lastIndex, + onClick: () => planner.moveWaypoint(point.id, 1), + }, + ...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata' + ? [{ divider: true }, { + label: t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg'), + icon: Spline, + onClick: () => planner.toggleWaypointFreeLeg(point.id), + }] + : []), + ...(planner.isPaddle + ? [ + { divider: true }, + ...(hasLeg + ? [{ + label: t(portage ? 'tours.paddle.portageOff' : 'tours.paddle.portageOn'), + icon: PortageIcon, + onClick: () => paddle.togglePortage(point.id), + }] + : []), + { + label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'), + icon: LockIcon, + onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }), + }, + ] + : []), + ]; return ( planner.moveWaypoint(point.id, -1)} + label={t('tours.planner.moreActions')} + disabled={isSaving} + active={!!menu.menu} + onAct={() => undefined} + onPress={(event) => (menu.menu ? menu.close() : menu.open(event, items, true))} > - + - planner.moveWaypoint(point.id, 1)} - > - - - {canDrawLeg && ( - planner.toggleWaypointFreeLeg(point.id)} - > - - - )} + ); } +/** + * What a paddle tour shows under a waypoint: the portage that starts there, + * and the lock at it with its times. + */ +function PaddleRowExtras({ + planner, + point, + index, + layout, + editable, +}: { + planner: TourPlannerController; + point: TourPlannerController['waypoints'][number]; + index: number; + layout: PaddleLayout; + editable: boolean; +}) { + const paddle = usePaddleWaypointActions(planner); + const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index); + const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined; + return ( + <> + {portage && ( + paddle.setPortageExtra(point.id, minutes)} + /> + )} + {point.lock && ( + paddle.setLock(point.id, next)} + /> + )} + + ); +} + /** * The row under the last waypoint that turns on "set points" for the map the * planner shows, the same switch as the one in the map's layer pill; while it @@ -393,6 +475,10 @@ export default function TourWaypointList({ } const lastIndex = planner.waypoints.length - 1; const movable = canEdit && !isSaving; + const paddleRoles = planner.isPaddle ? paddleRolesById(planner.waypoints) : null; + // A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler. + const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined); + const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null; const endDrag = () => { setDraggedWaypointId(null); setDropTarget(null); @@ -523,7 +609,12 @@ export default function TourWaypointList({ className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]" aria-current={selected ? 'true' : undefined} > - + {canEdit && ( } + {paddleLayout && ( + + )} ); })} diff --git a/client/src/components/Tours/planner/paddlePlanOf.ts b/client/src/components/Tours/planner/paddlePlanOf.ts new file mode 100644 index 000000000..b8cb55ef4 --- /dev/null +++ b/client/src/components/Tours/planner/paddlePlanOf.ts @@ -0,0 +1,42 @@ +import { + estimatePaddleTime, + paddleLayoutOfPoints, + paddleRolesOf, + type PaddlePlanPoint, + type PaddleRole, + type PaddleTimeEstimate, +} from '@trek/shared'; +import type { TourPlannerWaypoint } from './useTourPlanner'; + +/** The planner's control points as the shared paddle plan reads them. */ +export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] { + return points.map((point) => ({ + lat: point.lat, + lng: point.lng, + fixed_leg: point.fixedLeg ?? null, + lock: point.lock ?? null, + })); +} + +/** Put-in, take-out, re-entry and final take-out by control point id. */ +export function paddleRolesById(points: readonly TourPlannerWaypoint[]): Map { + const roles = paddleRolesOf(paddlePlanPoints(points)); + return new Map(points.map((point, index) => [point.id, roles[index] ?? null])); +} + +/** + * PAD-07: the estimate of a paddle tour as planned, without its breaks (the + * planner adds those as it does for a hike). A speed the paddler typed that + * does not hold leaves the water time unknown rather than wrong. + */ +export function paddleEstimateOf( + points: readonly TourPlannerWaypoint[], + paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null }, + speedInvalid: boolean +): PaddleTimeEstimate { + return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), { + paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh, + portageSpeedKmh: paddle.portageSpeedKmh, + breakMinutes: null, + }); +} diff --git a/client/src/components/Tours/planner/tourPlannerDraft.ts b/client/src/components/Tours/planner/tourPlannerDraft.ts new file mode 100644 index 000000000..c6d67749f --- /dev/null +++ b/client/src/components/Tours/planner/tourPlannerDraft.ts @@ -0,0 +1,67 @@ +import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleLockSchema, plannableTourTypeSchema, type PlannableTourType, type TourMaxHikingDifficulty } from '@trek/shared' +import type { PaddleFieldValues } from './usePaddleFields' +import type { TourPlannerWaypoint } from './useTourPlanner' + +/** + * The tour planner's draft as it is kept in local storage between visits, and + * the check a stored draft has to pass before the planner takes it back: a + * draft from an older version, or one edited by hand, is dropped rather than + * half read. + */ +export interface DraftPayload { + version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 + name: string + description?: string + website?: string + plannedDurationMinutes?: number | null + breakAdditionalMinutes?: number | null + waypoints: TourPlannerWaypoint[] + maxHikingDifficulty?: TourMaxHikingDifficulty + editingPlaceId?: number | null + /** The import a new tour is being planned from; carried until it is saved. */ + derivedFromPlaceId?: number | null + tourType?: PlannableTourType + paddle?: PaddleFieldValues +} + +const DRAFT_VERSIONS: ReadonlyArray = [1, 2, 3, 4, 5, 6, 7, 8] + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string' +} + +function optionalMinutes(value: number | null | undefined): boolean { + return value === undefined || value === null + || (Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES) +} + +function optionalPlaceId(value: number | null | undefined): boolean { + return value == null || (Number.isInteger(value) && value > 0) +} + +function optionalDifficulty(value: number | null | undefined): boolean { + return value == null || (Number.isInteger(value) && value >= 1 && value <= 6) +} + +function validDraftWaypoint(point: TourPlannerWaypoint): boolean { + return !!point && typeof point.id === 'string' + && Number.isFinite(point.lat) && Math.abs(point.lat) <= 90 + && Number.isFinite(point.lng) && Math.abs(point.lng) <= 180 + && (point.lock == null || paddleLockSchema.safeParse(point.lock).success) +} + +export function validDraft(value: unknown): value is DraftPayload { + if (!value || typeof value !== 'object') return false + const draft = value as Partial + return draft.version !== undefined && DRAFT_VERSIONS.includes(draft.version) && typeof draft.name === 'string' + && optionalString(draft.description) + && optionalString(draft.website) + && optionalMinutes(draft.plannedDurationMinutes) + && optionalMinutes(draft.breakAdditionalMinutes) + && optionalPlaceId(draft.editingPlaceId) && + optionalPlaceId(draft.derivedFromPlaceId) + && optionalDifficulty(draft.maxHikingDifficulty) + && (draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) + && Array.isArray(draft.waypoints) + && draft.waypoints.every(validDraftWaypoint) +} diff --git a/client/src/components/Tours/planner/tourRouteActions.test.ts b/client/src/components/Tours/planner/tourRouteActions.test.ts index ca30745c0..d4fb4683c 100644 --- a/client/src/components/Tours/planner/tourRouteActions.test.ts +++ b/client/src/components/Tours/planner/tourRouteActions.test.ts @@ -4,8 +4,12 @@ import { closeLoop, freeLegBetween, isClosedLoop, + portageLegBetween, reverseRoute, + setPortageExtraMinutes, + setWaypointLock, toggleFreeLeg, + togglePortageLeg, type RoutePoint, } from './tourRouteActions'; @@ -110,3 +114,41 @@ describe('free legs (P2A-04)', () => { expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C)); }); }); + +describe('portages and locks (PAD-03, PAD-07)', () => { + const LOCK = { wait_minutes: 10, transit_minutes: null }; + + it('carries the leg after a point and paddles it again on the second press', () => { + const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a'); + expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B)); + expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B)); + expect(togglePortageLeg(carried, 'c')).toEqual(carried); + }); + + it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => { + const points = [{ ...A, fixedLeg: portageLegBetween(A, B) }, { ...B, fixedLeg: portageLegBetween(B, C, 25) }, C]; + const aligned = alignFreeLegs(points, true); + expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['portage', 'portage', undefined]); + expect(aligned[0].fixedLeg).toMatchObject({ extra_minutes: 25 }); + expect(aligned[1].fixedLeg).toMatchObject({ extra_minutes: null }); + }); + + it('a hike carries no boat: a portage becomes a routed leg and a lock goes', () => { + const aligned = alignFreeLegs([{ ...A, fixedLeg: portageLegBetween(A, B), lock: LOCK }, B]); + expect(aligned[0]).toEqual(A); + }); + + it('sets the time out and in of a portage, and a lock with its times or none', () => { + const carried = [{ ...A, fixedLeg: portageLegBetween(A, B) }, B]; + expect(setPortageExtraMinutes(carried, 'a', 12)[0].fixedLeg).toMatchObject({ extra_minutes: 12 }); + expect(setPortageExtraMinutes([A, B], 'a', 12)).toEqual([A, B]); + const locked = setWaypointLock([A, B], 'b', LOCK); + expect(locked[1].lock).toEqual(LOCK); + expect(setWaypointLock(locked, 'b', null)[1]).toEqual(B); + }); + + it('closing a loop leaves the lock at the start, not on the way back', () => { + const loop = closeLoop([{ ...A, lock: LOCK }, B, C], 'back'); + expect(loop[3]).toEqual({ id: 'back', lat: A.lat, lng: A.lng }); + }); +}); diff --git a/client/src/components/Tours/planner/tourRouteActions.ts b/client/src/components/Tours/planner/tourRouteActions.ts index c42732fc0..a8e08d5a2 100644 --- a/client/src/components/Tours/planner/tourRouteActions.ts +++ b/client/src/components/Tours/planner/tourRouteActions.ts @@ -1,9 +1,10 @@ -import type { TourFixedLeg } from '@trek/shared'; +import type { PaddleLock, TourFixedLeg } from '@trek/shared'; /** * The route tools of the tour planner as plain list operations: reversing the * tour (PR1-02), closing it back to its start (PR1-01) and drawing a leg by - * hand (P2A-04). Each takes the ordered waypoints and returns the new order; + * hand (P2A-04), and a paddle tour's portages and locks (PAD-03, PAD-07). Each + * takes the ordered waypoints and returns the new order; * the planner commits the result as one step of its history, so one undo * takes the whole action back. */ @@ -12,6 +13,7 @@ export interface RoutePoint { lat: number; lng: number; fixedLeg?: TourFixedLeg | null; + lock?: PaddleLock | null; } /** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */ @@ -40,7 +42,7 @@ export function isClosedLoop(points: readonly { lat: number; lng: number }[]): b export function closeLoop(points: readonly T[], newId: string): T[] { const first = points[0]; if (!first || points.length < 2 || isClosedLoop(points)) return [...points]; - const { fixedLeg: _leg, ...start } = first; + const { fixedLeg: _leg, lock: _lock, ...start } = first; return [...points, { ...start, id: newId } as T]; } @@ -73,23 +75,112 @@ export function freeLegBetween(from: { lat: number; lng: number }, to: { lat: nu }; } +/** A portage from this point straight to the next: the boat is carried, on land. */ +export function portageLegBetween( + from: { lat: number; lng: number }, + to: { lat: number; lng: number }, + extraMinutes: number | null = null +): TourFixedLeg { + return { ...freeLegBetween(from, to), kind: 'portage', extra_minutes: extraMinutes }; +} + +function isPortage(leg: TourFixedLeg | null | undefined): leg is Extract { + return leg?.kind === 'portage'; +} + /** - * Every hand-drawn leg redrawn straight to the waypoint that now follows it, - * so moving a point drags its free legs along instead of tearing them off. A - * free leg on the last point leads nowhere and goes. With `allFree` every leg - * becomes hand-drawn, the way a paddle tour is planned. + * A portage's time for getting out and back in belongs to its first leg. After + * a reversal or a removal another leg can lead the portage; the time moves to + * it rather than getting lost in the middle. + */ +function extrasOnFirstLeg(points: T[]): T[] { + let runStart = -1; + points.forEach((point, index) => { + const leg = point.fixedLeg; + if (!isPortage(leg)) { + runStart = -1; + return; + } + if (runStart < 0) { + runStart = index; + return; + } + const first = points[runStart].fixedLeg; + if (leg.extra_minutes != null && isPortage(first) && first.extra_minutes == null) { + points[runStart] = { ...points[runStart], fixedLeg: { ...first, extra_minutes: leg.extra_minutes } }; + } + if (leg.extra_minutes != null) points[index] = { ...point, fixedLeg: { ...leg, extra_minutes: null } }; + }); + return points; +} + +/** + * Every hand-drawn leg, and every portage, redrawn straight to the waypoint + * that now follows it, so moving a point drags them along instead of tearing + * them off. A leg on the last point leads nowhere and goes, and so does a lock + * nobody paddles to on a tour that is no paddle tour. With `allFree` every + * leg that is not a portage becomes hand-drawn, the way a paddle tour is planned. */ export function alignFreeLegs(points: readonly T[], allFree = false): T[] { - return points.map((point, index) => { + const aligned = points.map((point, index) => { const next = points[index + 1]; - const free = allFree || point.fixedLeg?.kind === 'free'; - if (!free) return point; + const leg = point.fixedLeg; + const kept = allFree ? point : dropPaddleOnly(point); + const free = allFree || leg?.kind === 'free'; + if (!free && !(allFree && isPortage(leg))) return kept; if (!next) { - if (!point.fixedLeg) return point; - const { fixedLeg: _dropped, ...rest } = point; + if (!kept.fixedLeg) return kept; + const { fixedLeg: _dropped, ...rest } = kept; return rest as T; } - return { ...point, fixedLeg: freeLegBetween(point, next) }; + return { + ...kept, + fixedLeg: isPortage(leg) ? portageLegBetween(point, next, leg.extra_minutes ?? null) : freeLegBetween(point, next), + }; + }); + return allFree ? extrasOnFirstLeg(aligned) : aligned; +} + +/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */ +function dropPaddleOnly(point: T): T { + const { lock: _lock, ...rest } = point; + if (!isPortage(point.fixedLeg)) return (point.lock ? rest : point) as T; + const { fixedLeg: _leg, ...plain } = rest; + return plain as T; +} + +/** PAD-03: the leg after this point carried as a portage, or paddled again. */ +export function togglePortageLeg(points: readonly T[], id: string): T[] { + const index = points.findIndex((point) => point.id === id); + const point = points[index]; + const next = points[index + 1]; + if (!point || !next) return [...points]; + const copy = [...points]; + copy[index] = { + ...point, + fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next), + }; + return copy; +} + +/** PAD-07: the time for getting out and back in at the portage that starts after this point. */ +export function setPortageExtraMinutes( + points: readonly T[], + id: string, + minutes: number | null +): T[] { + return points.map((point) => + point.id === id && isPortage(point.fixedLeg) ? { ...point, fixedLeg: { ...point.fixedLeg, extra_minutes: minutes } } : point + ); +} + +/** PAD-07: a lock at this point with the times expected there, or no lock (null). */ +export function setWaypointLock(points: readonly T[], id: string, lock: PaddleLock | null): T[] { + return points.map((point) => { + if (point.id !== id) return point; + if (lock) return { ...point, lock }; + const { lock: _dropped, ...rest } = point; + return rest as T; }); } diff --git a/client/src/components/Tours/planner/tourRouteStyle.ts b/client/src/components/Tours/planner/tourRouteStyle.ts new file mode 100644 index 000000000..75806bb06 --- /dev/null +++ b/client/src/components/Tours/planner/tourRouteStyle.ts @@ -0,0 +1,32 @@ +import type { TourRoutePart } from './tourRouting'; + +/** + * PAD-03: a portage on the map, in an earth brown over a white rim, dotted the + * way the map draws a walk: the boat is carried there, not paddled. Map + * colours are literals by design. + */ +export const PORTAGE_LINE_COLOR = '#9a6233'; // theme-lint-disable map colour of a carried stretch +const PORTAGE_CASING_COLOR = '#ffffff'; // theme-lint-disable rim under the carried stretch + +export interface TourRouteStyle { + /** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */ + walking: boolean[] | null; + /** Per line: the portage brown, or the route's own colour where undefined. */ + colors: ({ line: string; casing: string } | undefined)[] | null; +} + +/** + * How the planner map draws the parts of a tour being edited. A hike draws + * what no router planned dotted, so a routed path and a hand-drawn one never + * look alike. A paddle tour is drawn by hand throughout, so its water is the + * plain line and only its portages stand out, dotted and brown. + */ +export function tourRouteStyle(parts: readonly TourRoutePart[] | null, isPaddle: boolean): TourRouteStyle { + if (!parts?.length) return { walking: null, colors: null }; + const walking = parts.map((part) => (isPaddle ? !!part.land : part.free)); + const colors = parts.map((part) => (part.land ? { line: PORTAGE_LINE_COLOR, casing: PORTAGE_CASING_COLOR } : undefined)); + return { + walking: walking.some(Boolean) ? walking : null, + colors: colors.some(Boolean) ? colors : null, + }; +} diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts index 316329ad4..2f6906aaf 100644 --- a/client/src/components/Tours/planner/tourRouting.ts +++ b/client/src/components/Tours/planner/tourRouting.ts @@ -7,6 +7,8 @@ export interface TourRoutePart { coordinates: [number, number][] /** Drawn by hand: no router planned or checked it. */ free: boolean + /** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */ + land?: boolean } export interface TourRouteResult { @@ -24,7 +26,7 @@ export interface TourRouteResult { const TOURS_MAX_HIKING_DIFFICULTY = 2 /** How fast a fixed leg is reckoned until the heights are in: a via ferrata climbs slowly, a free leg is walked. */ -const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4 } as const +const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4, portage: 2 } as const /** * Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose @@ -61,9 +63,9 @@ export async function routeWalkingTour( if (!(await routeRun(run))) return null const drawn = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng]) // A hand-drawn leg is sampled along its length, so its heights follow the ground between its ends. - const line = leg.kind === 'free' ? densifyLine(drawn) : drawn + const line = leg.kind === 'ferrata' ? drawn : densifyLine(drawn) const length = lineLengthM(line) - lines.push({ coordinates: line, free: leg.kind === 'free' }) + lines.push({ coordinates: line, free: leg.kind !== 'ferrata', ...(leg.kind === 'portage' ? { land: true } : {}) }) distanceMeters += length durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR[leg.kind]) * 3600 run = [waypoints[i + 1]] @@ -76,13 +78,16 @@ export async function routeWalkingTour( return { coordinates, parts: mergeParts(lines), distanceMeters, durationSeconds } } -/** Neighbouring parts of the same kind as one, so a run of hand-drawn legs is one dotted line. */ +/** Neighbouring parts of the same kind as one, so a run of hand-drawn legs is one dotted line and a portage one line on land. */ function mergeParts(parts: TourRoutePart[]): TourRoutePart[] { const merged: TourRoutePart[] = [] for (const part of parts) { const previous = merged[merged.length - 1] - if (previous && previous.free === part.free) previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)] - else merged.push({ coordinates: [...part.coordinates], free: part.free }) + if (previous && previous.free === part.free && !!previous.land === !!part.land) { + previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)] + } else { + merged.push({ coordinates: [...part.coordinates], free: part.free, ...(part.land ? { land: true } : {}) }) + } } return merged } diff --git a/client/src/components/Tours/planner/usePaddleFields.ts b/client/src/components/Tours/planner/usePaddleFields.ts index 8e74e2f64..b033a5201 100644 --- a/client/src/components/Tours/planner/usePaddleFields.ts +++ b/client/src/components/Tours/planner/usePaddleFields.ts @@ -11,27 +11,24 @@ export interface PaddleFieldValues { waterContext: PaddleWaterContext | null; /** The speed the paddler chose, km/h; null while the boat's suggested speed applies. */ speedKmh: number | null; + /** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */ + portageSpeedKmh?: number | null; } -export const NO_PADDLE_FIELDS: PaddleFieldValues = { boatType: null, waterContext: null, speedKmh: null }; +export const NO_PADDLE_FIELDS: PaddleFieldValues = { boatType: null, waterContext: null, speedKmh: null, portageSpeedKmh: null }; /** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */ export function paddleFieldsOf( - tour: Pick + tour: Pick ): PaddleFieldValues { return { boatType: tour.boat_type ?? null, waterContext: tour.water_context ?? null, speedKmh: tour.paddle_speed_kmh ?? null, + portageSpeedKmh: tour.portage_speed_kmh ?? null, }; } -/** Minutes on the water at a speed, unknown without a distance or a speed. */ -export function paddleMinutes(distanceKm: number | null | undefined, speedKmh: number | null): number | null { - if (distanceKm == null || speedKmh == null || speedKmh <= 0) return null; - return Math.round((distanceKm / speedKmh) * 60); -} - /** * PAD-01: the boat, the water and the planning speed of a paddle tour. A * speed the paddler typed stays when the boat changes; the boat's suggestion @@ -48,7 +45,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea const same = merged.boatType === values.boatType && merged.waterContext === values.waterContext && - Object.is(merged.speedKmh, values.speedKmh); + Object.is(merged.speedKmh, values.speedKmh) && + Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null); if (!same && onEdit()) setValues(merged); }, [onEdit, values] @@ -57,12 +55,15 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea const recommended = recommendedPaddleSpeedKmh(values.boatType); return { ...values, + portageSpeedKmh: values.portageSpeedKmh ?? null, recommendedSpeedKmh: recommended, /** The speed the time is worked out with: the chosen one, else the boat's suggestion. */ effectiveSpeedKmh: values.speedKmh ?? recommended, setBoatType: useCallback((boatType: PaddleBoatType) => edit({ boatType }), [edit]), setWaterContext: useCallback((waterContext: PaddleWaterContext) => edit({ waterContext }), [edit]), setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]), + /** PAD-07: the carrying speed, or back to the starting value with null. */ + setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]), /** Back to the boat's suggestion, by choice. */ adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]), /** Loaded from a tour or a draft, or cleared; not an edit. */ diff --git a/client/src/components/Tours/planner/useTourPlanner.test.tsx b/client/src/components/Tours/planner/useTourPlanner.test.tsx index 70fdcf049..16f55a00e 100644 --- a/client/src/components/Tours/planner/useTourPlanner.test.tsx +++ b/client/src/components/Tours/planner/useTourPlanner.test.tsx @@ -1596,7 +1596,7 @@ describe('useTourPlanner', () => { expect(result.current.mode).toEqual({ type: 'new-draft' }) expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({ - version: 7, + version: 8, tourType: 'hike', name: '', waypoints: [], diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 62a13aba9..2154ccdf0 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, plannableTourTypeSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared' +import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, plannableTourTypeSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty, type PaddleLock } from '@trek/shared' import { tourRepo } from '../../../repo/tourRepo' import { randomId } from '../../../utils/randomId' import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry' @@ -7,7 +7,9 @@ import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type T import { keepJoinedFixedLegs } from './tourFixedLegs' import { alignFreeLegs, closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions' import { useTourMapView } from './useTourMapView' -import { NO_PADDLE_FIELDS, paddleFieldsOf, paddleMinutes, usePaddleFields, type PaddleFieldValues } from './usePaddleFields' +import { validDraft, type DraftPayload } from './tourPlannerDraft' +import { paddleEstimateOf } from './paddlePlanOf' +import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields' import { hikingTimeMinutes } from '../hikingTime' import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' @@ -18,6 +20,8 @@ export interface TourPlannerWaypoint { role: TourWaypointRole /** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */ fixedLeg?: TourFixedLeg | null + /** PAD-07: a lock at this point of a paddle tour, with the times expected there. */ + lock?: PaddleLock | null } /** What a tour planned from an import starts with. */ @@ -50,22 +54,6 @@ export type TourPlannerStatus = | 'saving' | 'saved' -interface DraftPayload { - version: 1 | 2 | 3 | 4 | 5 | 6 | 7 - name: string - description?: string - website?: string - plannedDurationMinutes?: number | null - breakAdditionalMinutes?: number | null - waypoints: TourPlannerWaypoint[] - maxHikingDifficulty?: TourMaxHikingDifficulty - editingPlaceId?: number | null - /** The import a new tour is being planned from; carried until it is saved. */ - derivedFromPlaceId?: number | null - tourType?: PlannableTourType - paddle?: PaddleFieldValues -} - /** One step of the history: the route, and what the route depends on. */ interface HistoryEntry { waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; tourType: PlannableTourType } @@ -95,26 +83,13 @@ export function tourWaypointKey(points: TourPlannerWaypoint[]): string { function fixedLegKey(leg: TourFixedLeg | null | undefined): string { if (!leg) return '' - return leg.kind === 'ferrata' ? `:${leg.ref}` : ':free' + return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}` } +/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock. */ function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean { - return tourWaypointKey(a) === tourWaypointKey(b) -} - -const DRAFT_VERSIONS: ReadonlyArray = [1, 2, 3, 4, 5, 6, 7] - -function optionalString(value: unknown): boolean { - return value === undefined || typeof value === 'string' -} - -function optionalMinutes(value: number | null | undefined): boolean { - return value === undefined || value === null - || (Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES) -} - -function optionalPlaceId(value: number | null | undefined): boolean { - return value == null || (Number.isInteger(value) && value > 0) + const extras = (points: TourPlannerWaypoint[]) => JSON.stringify(points.map(point => [point.fixedLeg?.kind === 'portage' ? point.fixedLeg.extra_minutes ?? null : null, point.lock ?? null])) + return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b) } /** Where a new tour was planned from; an edit leaves it as it was saved. */ @@ -122,16 +97,6 @@ function derivedFromField(placeId: number | null, derivedFrom: number | null): { return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {} } -function optionalDifficulty(value: number | null | undefined): boolean { - return value == null || (Number.isInteger(value) && value >= 1 && value <= 6) -} - -function validDraftWaypoint(point: TourPlannerWaypoint): boolean { - return !!point && typeof point.id === 'string' - && Number.isFinite(point.lat) && Math.abs(point.lat) <= 90 - && Number.isFinite(point.lng) && Math.abs(point.lng) <= 180 -} - function trimmedOrNull(value: string): string | null { return value.trim() || null } @@ -140,22 +105,6 @@ function parsedWebsiteOrNull(value: string) { return value.trim() ? tourWebsiteSchema.parse(value) : null } -function validDraft(value: unknown): value is DraftPayload { - if (!value || typeof value !== 'object') return false - const draft = value as Partial - return draft.version !== undefined && DRAFT_VERSIONS.includes(draft.version) && typeof draft.name === 'string' - && optionalString(draft.description) - && optionalString(draft.website) - && optionalMinutes(draft.plannedDurationMinutes) - && optionalMinutes(draft.breakAdditionalMinutes) - && optionalPlaceId(draft.editingPlaceId) && - optionalPlaceId(draft.derivedFromPlaceId) - && optionalDifficulty(draft.maxHikingDifficulty) - && (draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) - && Array.isArray(draft.waypoints) - && draft.waypoints.every(validDraftWaypoint) -} - export interface UseTourPlannerOptions extends TourPermissionProps { tripId: number | string active?: boolean @@ -378,6 +327,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe commitWaypoints(current => reverseRoute(current)) }, [commitWaypoints]) + /** PAD-03/PAD-07: a paddler's change to the control points (a portage, a lock), as one step. */ + const editPaddlePoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => { + if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current !== 'kayak') return + commitWaypoints(change) + }, [commitWaypoints]) + /** P2A-04: the leg after this waypoint drawn by hand, or routed again. */ const toggleWaypointFreeLeg = useCallback((id: string) => { if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current === 'kayak') return @@ -531,8 +486,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe const paddleSpeedInvalid = paddle.speedKmh !== null && !paddleSpeedKmhSchema.safeParse(paddle.speedKmh).success const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null) // A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in. - const walkingDurationMinutes = isPaddle - ? paddleMinutes(routeAnalysis?.distanceKm ?? (distanceMeters === null ? null : distanceMeters / 1000), paddleSpeedInvalid ? null : paddle.effectiveSpeedKmh) + const { effectiveSpeedKmh, portageSpeedKmh } = paddle + const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid) : null, + [effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, waypoints]) + const walkingDurationMinutes = paddleEstimate + ? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes : routeAnalysis?.gain != null && routeAnalysis.loss != null ? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss) : durationSeconds === null ? null : Math.round(durationSeconds / 60) @@ -545,9 +503,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe // What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed. const { boatType, waterContext, speedKmh } = paddle const typeFields = useMemo(() => tourType === 'kayak' - ? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh } - : { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null }, - [boatType, maxHikingDifficulty, speedKmh, tourType, waterContext]) + ? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh } + : { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null }, + [boatType, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext]) // The router's own time stands in for a hike without heights; a paddle tour has only its speed. const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60 const plannedTotalInvalid = plannedDurationMinutes === null @@ -724,6 +682,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe lng: point.lng, role: point.role, ...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}), + ...(point.lock ? { lock: point.lock } : {}), })), openedType === 'kayak') localStorage.removeItem(storageKey) contextGenerationRef.current += 1 @@ -848,14 +807,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe useEffect(() => { if (!hasUnsavedChanges) return - const draft: DraftPayload = { version: 7, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId, + const draft: DraftPayload = { version: 8, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId, derivedFromPlaceId, tourType, - paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh }, + paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh }, } localStorage.setItem(storageKey, JSON.stringify(draft)) }, [breakAdditionalMinutes, derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website, - tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh]) + tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh]) useEffect(() => { if (!hasUnsavedChanges) return @@ -891,6 +850,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe role: point.role, sequence, ...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}), + ...(point.lock ? { lock: point.lock } : {}), })), duration_seconds: savedDurationSeconds, planned_duration_minutes: plannedDurationMinutes, @@ -949,7 +909,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, waypoints, selectedWaypointId, setSelectedWaypointId, addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, - reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, + reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, paddleEstimate, undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0, status, error, retry, isSaving, route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, diff --git a/client/src/components/Tours/terrain/useTerrainMap.ts b/client/src/components/Tours/terrain/useTerrainMap.ts index 70d9abe7e..77248686d 100644 --- a/client/src/components/Tours/terrain/useTerrainMap.ts +++ b/client/src/components/Tours/terrain/useTerrainMap.ts @@ -1,6 +1,7 @@ import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl'; import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { isFerrataHit } from '../../Ferratas/useFerrataLayerGL'; +import { isPaddleHit } from '../paddle/usePaddleLayerGL'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; import { addTerrainLayers, @@ -71,7 +72,7 @@ function createTerrainMap( // Clicking the terrain adds a control point, the same as on the flat planner map; // a click on a via ferrata opens its card instead. map.on('click', (event) => { - if (isFerrataHit(map, event.point)) return; + if (isFerrataHit(map, event.point) || isPaddleHit(map, event.point)) return; scene.current.onAdd?.(event.lngLat.lat, event.lngLat.lng); }); map.on('style.load', () => { diff --git a/client/src/components/Tours/terrain/useTourTerrain.ts b/client/src/components/Tours/terrain/useTourTerrain.ts index 4f1406dfa..d8129ffc2 100644 --- a/client/src/components/Tours/terrain/useTourTerrain.ts +++ b/client/src/components/Tours/terrain/useTourTerrain.ts @@ -1,4 +1,5 @@ import type { FerrataBbox } from '@trek/shared'; +import { type PaddleGlMap, usePaddleLayerGL } from '../paddle/usePaddleLayerGL'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'; import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer'; @@ -65,6 +66,7 @@ export function useTourTerrain(options: Options) { useTerrainFerratas(gl, options.ferrataLayer, options.onViewportChange); // MapLibre answers every call the shared overlay hook makes, as it does the ferrata hook's. useRasterOverlaysGL(gl ? (gl.map as unknown as RasterOverlayGlMap) : null, options.tourLayers?.overlays); + usePaddleLayerGL(gl ? (gl.map as unknown as PaddleGlMap) : null, options.tourLayers?.paddle); const replay = useTerrainReplay({ gl, ready, samples, focus, onFocusChange, scene, preloaded }); const { stop } = replay; diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index c36d9582d..4a48595c8 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useLayoutEffect, useRef } from 'react' +import React, { useState, useEffect, useLayoutEffect, useRef, type ComponentType, type CSSProperties } from 'react' import { createPortal } from 'react-dom' import { LucideIcon } from 'lucide-react' interface MenuItem { label?: string - icon?: LucideIcon + /** A lucide icon, or any icon drawn the same way (TREK's own glyphs). */ + icon?: LucideIcon | ComponentType<{ size?: number; style?: CSSProperties }> onClick?: () => void disabled?: boolean danger?: boolean diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 904676b1f..251324d7d 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -140,6 +140,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { tourConversion, tourDetailActions, recordedTrack, + trackPaddlePlan, permissions: { canPlaceEdit, canDayEdit }, tourDetails, tourMap, @@ -652,7 +653,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { planner={tourPlanner} wanderer={wandererBrowser} conversion={tourConversion} - recordedTrack={recordedTrack} canEdit={canPlaceEdit} canAssign={canDayEdit} /> + recordedTrack={recordedTrack} trackPaddlePlan={trackPaddlePlan} canEdit={canPlaceEdit} canAssign={canDayEdit} />
    diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 8738f4b27..fd4543b3e 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -14,6 +14,8 @@ import { isRtlLanguage, useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore'; import { useFerrataAdopt } from './useFerrataAdopt'; +import { pickOnTrack, refuseTrackPick, useTrackMarkToolStore } from '../../store/trackMarkToolStore'; +import { projectOntoTrack, TRACK_PICK_TOLERANCE_M } from '../../utils/paddleTrackMarks'; import { useTourMapPoints } from './useTourMapPoints'; import { useTourStageLayers } from './useTourStageLayers'; import type { useTripPlanner } from './useTripPlanner'; @@ -141,6 +143,18 @@ export function TourPlannerMapStage({ previewPoints: tourMap.previewPoints, }); const onAdd = waypoints.onAdd; + // PAD-03 on a recorded paddle track: with a marking tool on, a click on the line marks the spot on it. + const trackPick = useTrackMarkToolStore((state) => state.pick); + const trackLine = tourPlanner.mode.type === 'view-gpx' ? analysis?.routeCoordinates : null; + const pickOnLine = canPlaceEdit && trackPick !== null && !!trackLine?.length; + const onTrackClick = pickOnLine + ? (lat: number, lng: number) => { + const projected = projectOntoTrack(trackLine, { lat, lng }); + if (!projected || projected.offM > TRACK_PICK_TOLERANCE_M) refuseTrackPick(); + else pickOnTrack(projected.atM); + } + : undefined; + const onAddOrMark = onAdd ?? onTrackClick; // What each panel covers of the map, a collapsed one its rail. const leftInset = leftHidden ? 56 : leftPanelPx; const rightInset = rightHidden ? 56 : rightPanelPx; @@ -150,7 +164,7 @@ export function TourPlannerMapStage({ {/* Around either map, to follow its presses and show the crosshair while placing. */}
    @@ -184,10 +198,11 @@ export function TourPlannerMapStage({ places={[]} route={tourMap.route} routeWalking={tourMap.routeWalking} + routeColors={tourMap.routeColors} followSelection={false} onMapClick={ - onAdd - ? ({ latlng }: { latlng: { lat: number; lng: number } }) => onAdd(latlng.lat, latlng.lng) + onAddOrMark + ? ({ latlng }: { latlng: { lat: number; lng: number } }) => onAddOrMark(latlng.lat, latlng.lng) : undefined } tileUrl={mapTileUrl} diff --git a/client/src/pages/tripPlanner/useTourMapPoints.ts b/client/src/pages/tripPlanner/useTourMapPoints.ts index bfcaf2485..44c5dd6c6 100644 --- a/client/src/pages/tripPlanner/useTourMapPoints.ts +++ b/client/src/pages/tripPlanner/useTourMapPoints.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard'; +import { paddleRolesById } from '../../components/Tours/planner/paddlePlanOf'; import { isClosedLoop } from '../../components/Tours/planner/tourRouteActions'; import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames'; @@ -25,6 +26,7 @@ type PointsPlanner = Pick< | 'addWaypoint' | 'undo' | 'maxHikingDifficulty' + | 'isPaddle' >; interface Options { @@ -67,16 +69,23 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, }; const onAdd = guard(gate(placeOn ? addPoint : undefined)); // Kept by identity while nothing about the points changed, so the map does not redraw its pins on every render. - const { waypoints } = tourPlanner; + const { waypoints, isPaddle } = tourPlanner; const points = useMemo(() => { if (editing) { // The way back of a loop lies on the start; one pin stands for both, and moving it moves both. const shown = isClosedLoop(waypoints) ? waypoints.slice(0, -1) : waypoints; - return shown.map((point, index) => ({ - ...point, - name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }), - detail: t(ROLE_KEY[point.role]), - })); + // A paddle tour names a pin for the paddler (put-in, take-out, re-entry) and says where a lock is. + const roles = isPaddle ? paddleRolesById(waypoints) : null; + return shown.map((point, index) => { + const role = roles?.get(point.id); + const detail = role ? t(`tours.paddle.role.${role}`) : roles ? '' : t(ROLE_KEY[point.role]); + const lock = point.lock ? t('tours.paddle.lock') : ''; + return { + ...point, + name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }), + detail: [detail, lock].filter(Boolean).join(', ') || t(ROLE_KEY[point.role]), + }; + }); } if (!previewPoints?.length) return NO_POINTS; const last = previewPoints.length - 1; @@ -91,7 +100,7 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, detail: t(ROLE_KEY[role]), } as const; }); - }, [editing, waypoints, placeNames, previewPoints, t]); + }, [editing, isPaddle, waypoints, placeNames, previewPoints, t]); return { waypoints: { diff --git a/client/src/pages/tripPlanner/useTourStageLayers.ts b/client/src/pages/tripPlanner/useTourStageLayers.ts index 384e56f16..691556a9c 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.ts @@ -1,4 +1,7 @@ import { useCallback, useMemo } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { paddleMapItems } from '../../components/Tours/paddle/paddleMapLayer'; +import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore'; import type { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer'; import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner'; import { baseLayerFor, SEAMARK_OVERLAY, tourMapLayersFor } from '../../components/Tours/tourMapLayers'; @@ -31,9 +34,18 @@ export function useTourStageLayers( tourPlanner.mapOverlays.has(SEAMARK_OVERLAY.id), toggleSeamarks ); + // PAD-05B: the obstacles the sync hook read around the paddle tour, only while one is open. + const { features, related, selectedRef } = usePaddleObstacleStore( + useShallow((state) => ({ features: state.features, related: state.related, selectedRef: state.selectedRef })) + ); + const paddleItems = useMemo(() => paddleMapItems(features, related), [features, related]); + const paddle = useMemo( + () => (paddleItems.length ? { items: paddleItems, selectedRef, onSelect: selectPaddleObstacle } : undefined), + [paddleItems, selectedRef] + ); const tourLayers = useMemo( - () => ({ bases: layerSet.bases, overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [] }), - [layerSet, seamarks] + () => ({ bases: layerSet.bases, overlays: layerSet.overlays.includes('seamarks') ? [seamarks] : [], paddle }), + [layerSet, seamarks, paddle] ); return { baseLayer: baseLayerFor(layerSet, tourPlanner.mapBaseLayer), diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts index 1c63a3132..235ef63b0 100644 --- a/client/src/pages/tripPlanner/useTourWorkspace.ts +++ b/client/src/pages/tripPlanner/useTourWorkspace.ts @@ -2,6 +2,7 @@ import type { TourListItem } from '@trek/shared'; import { useCallback } from 'react'; import { useGpxConversion } from '../../components/Tours/conversion/useGpxConversion'; import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'; +import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan'; import { useRecordedTrack } from '../../components/Tours/useRecordedTrack'; import { useTourGpxImport } from '../../components/Tours/useTourGpxImport'; import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser'; @@ -117,6 +118,17 @@ export function useTourWorkspace( }, }); + // PAD-03/PAD-07: portages and locks laid onto a recorded paddle track, without redrawing it. + const trackPaddlePlan = useTrackPaddlePlan({ + tripId, + planner: tourPlanner, + canEdit: canPlaceEdit, + onSaved: (tour) => { + upsertTour(tour); + void invalidateTourPlaceIds({ placeIds: [tour.place_id] }); + }, + }); + const tourDetailActions = useTourDetailActions(planner); return { @@ -126,6 +138,7 @@ export function useTourWorkspace( wandererBrowser, tourConversion, recordedTrack, + trackPaddlePlan, tourDetailActions, }; } diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 0e1d11579..fc77f3f0a 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' import { placeActions } from '../../components/Planner/usePlaceActions' +import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle' +import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPortageParts' +import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync' import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation' import { usePlanMapViewport } from '../../hooks/usePlanMapViewport' import { useMapLocationSearch } from './useMapLocationSearch' @@ -22,7 +25,7 @@ export function useTripPlannerPage() { const canPlaceEdit = can('place_edit', trip) const canDayEdit = can('day_edit', trip) - const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, tourDetailActions } = + const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack, trackPaddlePlan, tourDetailActions } = useTourWorkspace(planner, canPlaceEdit, canDayEdit) const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null) @@ -63,13 +66,21 @@ export function useTripPlannerPage() { const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints) + const trackParts = useTrackPortageParts(tourPlanner) + // PAD-05B: the obstacles around the paddle tour in view, for the map and the rails alike. + const paddleLine = tourPlanner.mode.type === 'view-gpx' + ? (tourPlanner.mode.tour.tour_type === 'kayak' ? tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? null : null) + : tourPlanner.isPaddle ? tourPlanner.route : null + usePaddleObstacleSync(paddleLine, activeTab === 'tour-planner') const route = useMemo<[number, number][][] | null>(() => { if (wandererBrowser.open) return wandererBrowser.line ? [wandererBrowser.line] : null if (tourPlanner.mode.type === 'view-gpx') { const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates if (!coordinates?.length) return null // While an editable version is previewed, the import (dashed) and the suggestion side by side. - return tourConversion.line ? [coordinates, tourConversion.line] : [coordinates] + if (tourConversion.line) return [coordinates, tourConversion.line] + // A paddle track with portages marked on it: the carried stretches as their own lines. + return trackParts ? trackParts.map(part => part.coordinates) : [coordinates] } if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') { // Routed and hand-drawn stretches as their own lines, so the map can tell them apart. @@ -81,13 +92,15 @@ export function useTripPlannerPage() { wandererBrowser.open, wandererBrowser.line, tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route, tourPlanner.routeParts, - tourConversion.line, + tourConversion.line, trackParts, ]) const editingRoute = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' - // A hand-drawn stretch is drawn dotted, the way the map draws what no router planned. - const routeWalking = !wandererBrowser.open && editingRoute && tourPlanner.routeParts?.some(part => part.free) - ? tourPlanner.routeParts.map(part => part.free) - : route && route.length === 2 && tourConversion.line ? [true, false] : null + // A hand-drawn stretch of a hike is dotted, a paddle tour's portages dotted and brown. + const partStyle = tourRouteStyle( + !wandererBrowser.open && editingRoute ? tourPlanner.routeParts : !tourConversion.line ? trackParts : null, + tourPlanner.isPaddle || !!trackParts, + ) + const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null) const { closeGpxTour } = tourPlanner useEffect(() => { @@ -126,9 +139,11 @@ export function useTripPlannerPage() { tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour }, tourConversion, recordedTrack, + trackPaddlePlan, tourDetailActions, tourMap: { route, routeWalking, + routeColors: partStyle.colors, previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points, focusPoints: mapFocusPoints, // A picked wanderer trail frames itself; the rest follows the planner. diff --git a/client/src/repo/paddleObstacleRepo.ts b/client/src/repo/paddleObstacleRepo.ts new file mode 100644 index 000000000..40ee09b65 --- /dev/null +++ b/client/src/repo/paddleObstacleRepo.ts @@ -0,0 +1,98 @@ +import { + PADDLE_OBSTACLES_MAX_SPAN_DEG, + type PaddleCoverageRegion, + type PaddleCoverageState, + type PaddleFeature, +} from '@trek/shared'; +import { paddleObstaclesApi } from '../api/paddleObstacles'; +import { isEffectivelyOffline } from '../sync/networkMode'; + +export interface PaddleObstaclesAround { + features: PaddleFeature[]; + coverage: PaddleCoverageState; + regions: PaddleCoverageRegion[]; + truncated: boolean; +} + +/** Thrown instead of asking while the app is offline, so the view can say so rather than show a failure. */ +export class PaddleObstaclesOfflineError extends Error { + constructor() { + super('Paddle obstacles need a connection'); + this.name = 'PaddleObstaclesOfflineError'; + } +} + +/** A margin around the line, in degrees, so an obstacle just off its ends is still asked for. */ +const MARGIN_DEG = 0.01; +/** One request's side, a little under the server's cap. */ +const TILE_DEG = PADDLE_OBSTACLES_MAX_SPAN_DEG * 0.8; + +export interface Tile { + south: number; + west: number; + north: number; + east: number; +} + +/** The box around a line, with its margin, split into tiles the server takes. */ +export function tilesAround(line: readonly (readonly [number, number])[]): Tile[] { + if (line.length === 0) return []; + let south = 90; + let north = -90; + let west = 180; + let east = -180; + for (const [lat, lng] of line) { + south = Math.min(south, lat); + north = Math.max(north, lat); + west = Math.min(west, lng); + east = Math.max(east, lng); + } + south -= MARGIN_DEG; + north += MARGIN_DEG; + west -= MARGIN_DEG; + east += MARGIN_DEG; + const tiles: Tile[] = []; + for (let s = south; s < north; s += TILE_DEG) { + for (let w = west; w < east; w += TILE_DEG) { + tiles.push({ south: s, west: w, north: Math.min(north, s + TILE_DEG), east: Math.min(east, w + TILE_DEG) }); + } + } + return tiles; +} + +const WORST: Record = { none: 0, partial: 1, covered: 2 }; + +/** + * PAD-05A/05B: the paddle objects around a route, online only. They are map + * data read from OpenStreetMap, not trip data, so nothing is queued; offline + * nothing is asked, and the planner says the data is out of reach rather + * than showing a route free of obstacles. + */ +export const paddleObstacleRepo = { + async around(line: readonly (readonly [number, number])[], signal?: AbortSignal): Promise { + return this.inTiles(tilesAround(line), signal); + }, + + /** The objects of these tiles, merged, with the weakest coverage among them. */ + async inTiles(tiles: readonly Tile[], signal?: AbortSignal): Promise { + if (isEffectivelyOffline()) throw new PaddleObstaclesOfflineError(); + const answers = await Promise.all(tiles.map((tile) => paddleObstaclesApi.inBox(tile, signal))); + const features = new Map(); + const regions = new Map(); + let coverage: PaddleCoverageState = answers.length ? 'covered' : 'none'; + for (const answer of answers) { + for (const feature of answer.features) features.set(`${feature.region_id}:${feature.ref}`, feature); + for (const region of answer.coverage.regions) regions.set(region.id, region); + if (WORST[answer.coverage.state] < WORST[coverage]) coverage = answer.coverage.state; + } + // Two regions overlap at a border: an object read from both is one object. + const byRef = new Map(); + for (const feature of features.values()) if (!byRef.has(feature.ref)) byRef.set(feature.ref, feature); + return { + features: [...byRef.values()], + coverage, + regions: [...regions.values()], + truncated: answers.some((answer) => answer.truncated), + }; + }, +}; diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index 1a0bea840..3c7698072 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -1,7 +1,8 @@ -import { computeTourMetrics, type GeometryPoint, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared' +import { computeTourMetrics, estimatePaddleTime, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourPaddlePlanRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared' import { toursApi } from '../api/client' import { tourGpxApi } from '../api/tourGpx' import { recordedTrackApi } from '../api/recordedTrack' +import { tourPaddlePlanApi } from '../api/tourPaddlePlan' import { tourCopiesApi } from '../api/tourCopies' import { offlineDb, replaceTripTours, type CachedTour } from '../db/offlineDb' import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' @@ -51,6 +52,7 @@ function routeFields(body: TourCreateRequest) { boat_type: body.boat_type ?? null, water_context: body.water_context ?? null, paddle_speed_kmh: body.paddle_speed_kmh ?? null, + portage_speed_kmh: body.portage_speed_kmh ?? null, duration: body.duration_seconds == null ? null : Math.round(body.duration_seconds / 60), match_confidence: 1, caution: false, @@ -189,6 +191,48 @@ export const tourRepo = { return result }, + /** + * PAD-03/PAD-07: a recorded paddle track's portages, locks and speeds. Made + * offline, the cached tour takes them at once, with the moving time the + * server will work out from them, and the write waits in the queue. + */ + async updatePaddlePlan(tripId: number | string, placeId: number, body: TourPaddlePlanRequest): Promise { + if (await mutationQueue.mustQueue('tours', placeId)) { + const tid = Number(tripId) + const existing = await offlineDb.tours.get(placeId) + if (!existing) throw new Error('Tour not available offline') + const estimate = estimatePaddleTime(paddleLayoutOfTrack((existing.distance ?? 0) * 1000, body.track_marks), { + paddleSpeedKmh: body.paddle_speed_kmh ?? recommendedPaddleSpeedKmh(existing.boat_type), + portageSpeedKmh: body.portage_speed_kmh, + breakMinutes: null, + }) + const moving = paddleMovingSeconds(estimate) + const empty = body.track_marks.portages.length === 0 && body.track_marks.locks.length === 0 + const tour: CachedTour = { + ...existing, + paddle_speed_kmh: body.paddle_speed_kmh, + portage_speed_kmh: body.portage_speed_kmh, + track_marks: empty ? null : body.track_marks, + duration: moving === null ? null : Math.round(moving / 60), + } + await offlineDb.tours.put(tour) + await mutationQueue.enqueue({ + id: generateUUID(), + tripId: tid, + method: 'PUT', + url: `/trips/${tripId}/tours/${placeId}/paddle-plan`, + body, + resource: 'tours', + entityId: placeId, + }) + mutationQueue.sendSoon() + return { tour: toListItem(tour), waypoints: existing.waypoints ?? [] } + } + const result = await tourPaddlePlanApi.update(tripId, placeId, body) + await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + return result + }, + /** Online only, like the import: the recorded track is kept on the server, not in the cache. */ async restoreRecorded(tripId: number | string, placeId: number): Promise { const result = await recordedTrackApi.restore(tripId, placeId) diff --git a/client/src/repo/windRepo.ts b/client/src/repo/windRepo.ts new file mode 100644 index 000000000..3c566ec53 --- /dev/null +++ b/client/src/repo/windRepo.ts @@ -0,0 +1,23 @@ +import type { WindForecastResponse } from '@trek/shared'; +import { windApi } from '../api/wind'; +import { isEffectivelyOffline } from '../sync/networkMode'; + +/** Thrown instead of asking while the app is offline, so the view can say so rather than show a failure. */ +export class WindOfflineError extends Error { + constructor() { + super('Wind forecast needs a connection'); + this.name = 'WindOfflineError'; + } +} + +/** + * PAD-08: the wind forecast, online only on purpose. A forecast kept on the + * device would be read later as the current one; it is asked again every time + * and never cached here, and offline nothing is asked at all. + */ +export const windRepo = { + async forecast(lat: number, lng: number, date: string, signal?: AbortSignal): Promise { + if (isEffectivelyOffline()) throw new WindOfflineError(); + return windApi.forecast(lat, lng, date, signal); + }, +}; diff --git a/client/src/store/paddleObstacleStore.ts b/client/src/store/paddleObstacleStore.ts new file mode 100644 index 000000000..a378f83a9 --- /dev/null +++ b/client/src/store/paddleObstacleStore.ts @@ -0,0 +1,51 @@ +import type { PaddleCoverageRegion, PaddleCoverageState, PaddleFeature, RelatedObstacle } from '@trek/shared'; +import { create } from 'zustand'; + +/** Whether the obstacles around the open paddle tour are there to show, and if not, why. */ +export type PaddleObstacleStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error'; + +interface PaddleObstacleState { + status: PaddleObstacleStatus; + /** Every object read around the route. */ + features: PaddleFeature[]; + /** The ones on or near the route, in the order the route reaches them. */ + related: RelatedObstacle[]; + coverage: PaddleCoverageState; + regions: PaddleCoverageRegion[]; + truncated: boolean; + /** The object whose card is open, by OSM reference; the map and the checklist show the same one. */ + selectedRef: string | null; +} + +const EMPTY: PaddleObstacleState = { + status: 'idle', + features: [], + related: [], + coverage: 'none', + regions: [], + truncated: false, + selectedRef: null, +}; + +/** + * PAD-05B: the paddle obstacles of the tour open in the planner, shared by + * the map that draws them and the rails that list them. One sync hook writes + * it; everything else reads. + */ +export const usePaddleObstacleStore = create(() => EMPTY); + +export function publishPaddleObstacles(next: Omit): void { + usePaddleObstacleStore.setState((state) => ({ + ...next, + // A selection that is no longer among the objects is dropped with them. + selectedRef: next.features.some((feature) => feature.ref === state.selectedRef) ? state.selectedRef : null, + })); +} + +export function selectPaddleObstacle(ref: string | null): void { + usePaddleObstacleStore.setState({ selectedRef: ref }); +} + +export function clearPaddleObstacles(): void { + usePaddleObstacleStore.setState(EMPTY); +} diff --git a/client/src/store/trackMarkToolStore.ts b/client/src/store/trackMarkToolStore.ts new file mode 100644 index 000000000..6cddca583 --- /dev/null +++ b/client/src/store/trackMarkToolStore.ts @@ -0,0 +1,104 @@ +import type { PaddleLock, TrackMarks } from '@trek/shared'; +import { create } from 'zustand'; +import { + NO_TRACK_MARKS, + withLock, + withLockTimes, + withoutPortage, + withPortage, + withPortageExtra, +} from '../utils/paddleTrackMarks'; + +/** What a click on the recorded track does next: nothing, mark a lock, or one end of a portage. */ +export type TrackPick = 'lock' | 'portage' | null; + +export interface TrackSpeeds { + paddle: number | null; + portage: number | null; +} + +interface TrackMarkToolState { + /** The tour the marks below belong to; null while no recorded paddle track is open. */ + placeId: number | null; + /** The marks as they are being laid, not yet saved. */ + marks: TrackMarks; + speeds: TrackSpeeds; + /** Whether anything changed since they were loaded or saved. */ + dirty: boolean; + pick: TrackPick; + /** Where the portage being marked starts, once its first end is set. */ + pendingFromM: number | null; + /** The last click was on a portage already there, or off the line: the hint says so. */ + refused: 'overlap' | 'off_line' | null; +} + +function initial(): TrackMarkToolState { + return { + placeId: null, + marks: NO_TRACK_MARKS, + speeds: { paddle: null, portage: null }, + dirty: false, + pick: null, + pendingFromM: null, + refused: null, + }; +} + +/** + * PAD-03 for a recorded paddle track: the marks being laid onto it, shared by + * the rail that lists them and the map that is clicked to place them. A click + * on the line gives a distance along it; a lock takes one, a portage two. + */ +export const useTrackMarkToolStore = create(() => initial()); + +const set = useTrackMarkToolStore.setState; + +export function loadTrackMarks(placeId: number | null, marks: TrackMarks | null | undefined, speeds: TrackSpeeds): void { + set({ ...initial(), placeId, marks: marks ?? NO_TRACK_MARKS, speeds }); +} + +export function startTrackPick(pick: TrackPick): void { + set({ pick, pendingFromM: null, refused: null }); +} + +/** A click on the line at this distance along it, for the tool that is on. */ +export function pickOnTrack(atM: number): void { + const { pick, pendingFromM, marks } = useTrackMarkToolStore.getState(); + if (pick === 'lock') { + set({ marks: withLock(marks, atM), dirty: true, pick: null, refused: null }); + return; + } + if (pick !== 'portage') return; + if (pendingFromM === null) { + set({ pendingFromM: atM, refused: null }); + return; + } + const next = withPortage(marks, pendingFromM, atM); + if (next === marks) { + set({ refused: 'overlap', pendingFromM: null }); + return; + } + set({ marks: next, dirty: true, pick: null, pendingFromM: null, refused: null }); +} + +export function refuseTrackPick(): void { + set({ refused: 'off_line' }); +} + +function edit(marks: TrackMarks): void { + set({ marks, dirty: true }); +} + +export const trackMarkEdits = { + removePortage: (index: number) => edit(withoutPortage(useTrackMarkToolStore.getState().marks, index)), + setPortageExtra: (index: number, minutes: number | null) => + edit(withPortageExtra(useTrackMarkToolStore.getState().marks, index, minutes)), + setLock: (index: number, lock: PaddleLock | null) => + edit(withLockTimes(useTrackMarkToolStore.getState().marks, index, lock)), + setSpeeds: (speeds: Partial) => + set((state) => ({ speeds: { ...state.speeds, ...speeds }, dirty: true })), +}; + +export function markTrackMarksSaved(marks: TrackMarks | null | undefined, speeds: TrackSpeeds): void { + set({ marks: marks ?? NO_TRACK_MARKS, speeds, dirty: false, pick: null, pendingFromM: null, refused: null }); +} diff --git a/client/src/utils/paddleTrackMarks.test.ts b/client/src/utils/paddleTrackMarks.test.ts new file mode 100644 index 000000000..88ecf2bce --- /dev/null +++ b/client/src/utils/paddleTrackMarks.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + loadTrackMarks, + markTrackMarksSaved, + pickOnTrack, + refuseTrackPick, + startTrackPick, + trackMarkEdits, + useTrackMarkToolStore, +} from '../store/trackMarkToolStore'; +import { + NO_TRACK_MARKS, + projectOntoTrack, + splitTrackByPortages, + withLock, + withLockTimes, + withoutPortage, + withPortage, + withPortageExtra, +} from './paddleTrackMarks'; + +/** A straight track east along 53°N, one point per kilometre (near enough). */ +const TRACK: [number, number][] = [0, 1, 2, 3, 4].map((km) => [53, 13 + km / 66.9]); + +describe('paddle track marks (PAD-03 on a recorded track)', () => { + it('snaps a click to its distance along the track and says how far off the line it was', () => { + const on = projectOntoTrack(TRACK, { lat: 53, lng: 13 + 1.5 / 66.9 }); + expect(on?.atM).toBeGreaterThan(1450); + expect(on?.atM).toBeLessThan(1550); + expect(on?.offM).toBeLessThan(1); + const off = projectOntoTrack(TRACK, { lat: 53.01, lng: 13 + 2 / 66.9 }); + expect(off?.offM).toBeGreaterThan(1000); + expect(projectOntoTrack([[53, 13]], { lat: 53, lng: 13 })).toBeNull(); + }); + + it('adds locks in order and portages from either end, never one over another', () => { + const locked = withLock(withLock(NO_TRACK_MARKS, 3000), 1000); + expect(locked.locks.map((lock) => lock.at_m)).toEqual([1000, 3000]); + const one = withPortage(NO_TRACK_MARKS, 900, 400); + expect(one.portages).toEqual([{ from_m: 400, to_m: 900, extra_minutes: null }]); + expect(withPortage(one, 800, 1200)).toBe(one); + expect(withPortage(one, 500, 500)).toBe(one); + const two = withPortage(one, 2000, 1500); + expect(two.portages.map((p) => p.from_m)).toEqual([400, 1500]); + }); + + it('changes and removes what is marked', () => { + const marks = withLock(withPortage(NO_TRACK_MARKS, 100, 300), 2000); + expect(withPortageExtra(marks, 0, 20).portages[0].extra_minutes).toBe(20); + expect(withoutPortage(marks, 0).portages).toEqual([]); + expect(withLockTimes(marks, 0, { wait_minutes: 5, transit_minutes: 10 }).locks[0]).toMatchObject({ + at_m: 2000, + wait_minutes: 5, + }); + expect(withLockTimes(marks, 0, null).locks).toEqual([]); + }); + + it('cuts the track where a portage starts and ends, the parts meeting exactly', () => { + const parts = splitTrackByPortages(TRACK, [{ from_m: 1500, to_m: 2500, extra_minutes: null }]); + expect(parts.map((part) => part.land)).toEqual([false, true, false]); + expect(parts[0].coordinates[parts[0].coordinates.length - 1]).toEqual(parts[1].coordinates[0]); + expect(parts[1].coordinates[parts[1].coordinates.length - 1]).toEqual(parts[2].coordinates[0]); + expect(splitTrackByPortages(TRACK, [])).toHaveLength(1); + }); +}); + +describe('trackMarkToolStore', () => { + it('marks a lock with one click and a portage with two, then turns the tool off', () => { + loadTrackMarks(7, null, { paddle: null, portage: null }); + startTrackPick('lock'); + pickOnTrack(1200); + expect(useTrackMarkToolStore.getState()).toMatchObject({ pick: null, dirty: true }); + startTrackPick('portage'); + pickOnTrack(400); + expect(useTrackMarkToolStore.getState().pendingFromM).toBe(400); + pickOnTrack(800); + const state = useTrackMarkToolStore.getState(); + expect(state.marks.portages).toEqual([{ from_m: 400, to_m: 800, extra_minutes: null }]); + expect(state.marks.locks.map((lock) => lock.at_m)).toEqual([1200]); + }); + + it('says why a click placed nothing: off the line, or on a portage already there', () => { + loadTrackMarks(7, { portages: [{ from_m: 100, to_m: 500, extra_minutes: null }], locks: [] }, { paddle: null, portage: null }); + startTrackPick('portage'); + refuseTrackPick(); + expect(useTrackMarkToolStore.getState().refused).toBe('off_line'); + pickOnTrack(200); + pickOnTrack(700); + expect(useTrackMarkToolStore.getState().refused).toBe('overlap'); + expect(useTrackMarkToolStore.getState().marks.portages).toHaveLength(1); + }); + + it('keeps edits until saved, and a save leaves the marks as the server has them', () => { + loadTrackMarks(7, null, { paddle: null, portage: null }); + trackMarkEdits.setSpeeds({ portage: 3 }); + expect(useTrackMarkToolStore.getState()).toMatchObject({ dirty: true, speeds: { portage: 3 } }); + pickOnTrack(100); + expect(useTrackMarkToolStore.getState().marks.locks).toEqual([]); + markTrackMarksSaved(null, { paddle: 4, portage: 3 }); + expect(useTrackMarkToolStore.getState()).toMatchObject({ dirty: false, marks: NO_TRACK_MARKS }); + }); +}); diff --git a/client/src/utils/paddleTrackMarks.ts b/client/src/utils/paddleTrackMarks.ts new file mode 100644 index 000000000..760f6c83a --- /dev/null +++ b/client/src/utils/paddleTrackMarks.ts @@ -0,0 +1,123 @@ +import type { PaddleLock, TrackMarks } from '@trek/shared'; +import { haversineKm } from './geo'; + +/** + * PAD-03 for a recorded track: portages and locks laid onto the line as it + * was recorded, by their distance from its start. Nothing here moves a point + * of the track; a mark is only ever a distance along it. + */ + +export const NO_TRACK_MARKS: TrackMarks = { portages: [], locks: [] }; + +/** How far from the line, in metres, a click still counts as a click on it. */ +export const TRACK_PICK_TOLERANCE_M = 250; + +interface Projection { + /** Metres along the track to the nearest point of it. */ + atM: number; + /** How far the click was from that point, in metres. */ + offM: number; +} + +/** + * The nearest point of the track to a click, as its distance along the line + * and the gap to it. The nearest point is found flat within each segment, + * near enough at the length of a segment; null for a track of fewer than two points. + */ +export function projectOntoTrack( + track: readonly (readonly [number, number])[], + point: { lat: number; lng: number } +): Projection | null { + if (track.length < 2) return null; + const toRad = Math.PI / 180; + const metresPerLat = 111_195; + let along = 0; + let best: Projection | null = null; + for (let i = 1; i < track.length; i += 1) { + const [aLat, aLng] = track[i - 1]; + const [bLat, bLng] = track[i]; + const metresPerLng = metresPerLat * Math.cos(((aLat + bLat) / 2) * toRad); + const bx = (bLng - aLng) * metresPerLng; + const by = (bLat - aLat) * metresPerLat; + const px = (point.lng - aLng) * metresPerLng; + const py = (point.lat - aLat) * metresPerLat; + const lengthSq = bx * bx + by * by; + const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, (px * bx + py * by) / lengthSq)); + const offM = Math.hypot(px - t * bx, py - t * by); + // Along the line by great circle, as every other measure of the track is taken. + const length = haversineKm({ lat: aLat, lng: aLng }, { lat: bLat, lng: bLng }) * 1000; + if (!best || offM < best.offM) best = { atM: along + t * length, offM }; + along += length; + } + return best; +} + +/** The marks with a lock added at a distance along the track, its times still to enter. */ +export function withLock(marks: TrackMarks, atM: number): TrackMarks { + const lock = { at_m: Math.round(atM), wait_minutes: null, transit_minutes: null }; + return { ...marks, locks: [...marks.locks, lock].sort((a, b) => a.at_m - b.at_m) }; +} + +/** + * The marks with a portage between two distances, in either order. A portage + * that would overlap one already there is not added: the marks come back as + * they were, and the caller can say why. + */ +export function withPortage(marks: TrackMarks, aM: number, bM: number): TrackMarks { + const fromM = Math.round(Math.min(aM, bM)); + const toM = Math.round(Math.max(aM, bM)); + if (toM <= fromM) return marks; + if (marks.portages.some((p) => fromM < p.to_m && toM > p.from_m)) return marks; + const portage = { from_m: fromM, to_m: toM, extra_minutes: null }; + return { ...marks, portages: [...marks.portages, portage].sort((a, b) => a.from_m - b.from_m) }; +} + +export function withoutPortage(marks: TrackMarks, index: number): TrackMarks { + return { ...marks, portages: marks.portages.filter((_, i) => i !== index) }; +} + +export function withPortageExtra(marks: TrackMarks, index: number, minutes: number | null): TrackMarks { + return { ...marks, portages: marks.portages.map((p, i) => (i === index ? { ...p, extra_minutes: minutes } : p)) }; +} + +/** The lock at this index with new times, or gone with null. */ +export function withLockTimes(marks: TrackMarks, index: number, lock: PaddleLock | null): TrackMarks { + if (!lock) return { ...marks, locks: marks.locks.filter((_, i) => i !== index) }; + return { ...marks, locks: marks.locks.map((l, i) => (i === index ? { ...l, ...lock } : l)) }; +} + +/** + * The track cut into the stretches paddled and the stretches carried, for the + * map to draw the portages in their own colour. Each cut falls on the + * interpolated point at the portage's distance, so the parts meet exactly. + */ +export function splitTrackByPortages( + track: readonly (readonly [number, number])[], + portages: TrackMarks['portages'] +): Array<{ coordinates: [number, number][]; land: boolean }> { + if (track.length < 2 || portages.length === 0) return [{ coordinates: track.map(([a, b]) => [a, b]), land: false }]; + const cuts = portages.flatMap((p) => [p.from_m, p.to_m]).sort((a, b) => a - b); + const parts: Array<{ coordinates: [number, number][]; land: boolean }> = []; + let current: [number, number][] = [[track[0][0], track[0][1]]]; + let land = false; + let along = 0; + let cut = 0; + for (let i = 1; i < track.length; i += 1) { + const a = track[i - 1]; + const b = track[i]; + const length = haversineKm({ lat: a[0], lng: a[1] }, { lat: b[0], lng: b[1] }) * 1000; + while (cut < cuts.length && cuts[cut] <= along + length) { + const t = length === 0 ? 0 : (cuts[cut] - along) / length; + const at: [number, number] = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + current.push(at); + if (current.length >= 2) parts.push({ coordinates: current, land }); + current = [at]; + land = !land; + cut += 1; + } + current.push([b[0], b[1]]); + along += length; + } + if (current.length >= 2) parts.push({ coordinates: current, land }); + return parts; +} diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index 0214921e4..d9258eded 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -60,6 +60,8 @@ "PackingTemplateCategories": "packing", "PackingTemplateItems": "packing", "PackingTemplates": "packing", + "PaddleFeatures": "paddle-obstacles", + "PaddleRegions": "paddle-obstacles", "PasswordResetTokens": "auth", "PhotoProviderFields": "addons", "PhotoProviders": "addons", @@ -104,6 +106,7 @@ "Tags": "tags", "TodoCategoryAssignees": "todo", "TodoItems": "todo", + "TourObstacleReviews": "tours", "TourSources": "tours", "TourTypes": "tours", "TourWaypoints": "tours", diff --git a/server/src/db/entities/PaddleFeatures.entity.ts b/server/src/db/entities/PaddleFeatures.entity.ts new file mode 100644 index 000000000..b644146f6 --- /dev/null +++ b/server/src/db/entities/PaddleFeatures.entity.ts @@ -0,0 +1,54 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PaddleFeaturesRepository } from '../repositories/PaddleFeatures.repository'; +import { PaddleRegions } from './PaddleRegions.entity'; + +export class PaddleFeatures { + [EntityRepositoryType]?: PaddleFeaturesRepository; + id!: number & Opt; + region!: Ref; + region_id!: string; + osm_ref!: string; + kind!: string; + group_key?: string | null; + name?: string | null; + geometry!: string; + tags!: string; + tag_hash!: string; + min_lat!: number; + min_lng!: number; + max_lat!: number; + max_lng!: number; +} + +export const PaddleFeaturesSchema = defineEntity({ + class: PaddleFeatures, + repository: () => PaddleFeaturesRepository, + indexes: [ + { + name: 'idx_paddle_features_box', + properties: ['min_lat', 'max_lat', 'min_lng', 'max_lng'], + }, + ], + uniques: [ + { + name: 'idx_paddle_features_region_ref', + properties: ['region_id', 'osm_ref'], + }, + ], + properties: { + id: p.integer().primary(), + region: () => p.manyToOne(PaddleRegions).ref().deleteRule('cascade').hidden(), + region_id: p.text().persist(false), + osm_ref: p.text(), + kind: p.text(), + group_key: p.text().nullable(), + name: p.text().nullable(), + geometry: p.text(), + tags: p.text(), + tag_hash: p.text(), + min_lat: p.double(), + min_lng: p.double(), + max_lat: p.double(), + max_lng: p.double(), + }, +}); diff --git a/server/src/db/entities/PaddleRegions.entity.ts b/server/src/db/entities/PaddleRegions.entity.ts new file mode 100644 index 000000000..68b8b1f9a --- /dev/null +++ b/server/src/db/entities/PaddleRegions.entity.ts @@ -0,0 +1,52 @@ +import { Collection, EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PaddleRegionsRepository } from '../repositories/PaddleRegions.repository'; +import { DbTimestampType } from '../types'; +import { PaddleFeatures } from './PaddleFeatures.entity'; + +export class PaddleRegions { + [EntityRepositoryType]?: PaddleRegionsRepository; + id!: string; + name!: string; + boundary?: string | null; + min_lat?: number | null; + min_lng?: number | null; + max_lat?: number | null; + max_lng?: number | null; + status: TPaddleRegionsStatus & Opt = PaddleRegionsStatus.QUEUED; + source_timestamp?: string | null; + imported_at?: string | null; + feature_count: number & Opt = 0; + error?: string | null; + created_at?: string | null; + paddle_features_collection = new Collection(this); +} + +export const PaddleRegionsStatus = { + QUEUED: 'queued', + IMPORTING: 'importing', + READY: 'ready', + FAILED: 'failed', +} as const; + +export type TPaddleRegionsStatus = (typeof PaddleRegionsStatus)[keyof typeof PaddleRegionsStatus]; + +export const PaddleRegionsSchema = defineEntity({ + class: PaddleRegions, + repository: () => PaddleRegionsRepository, + properties: { + id: p.text().primary(), + name: p.text(), + boundary: p.text().nullable(), + min_lat: p.double().nullable(), + min_lng: p.double().nullable(), + max_lat: p.double().nullable(), + max_lng: p.double().nullable(), + status: p.enum(() => PaddleRegionsStatus).default('queued'), + source_timestamp: p.text().nullable(), + imported_at: p.type(DbTimestampType).nullable(), + feature_count: p.integer().default(0), + error: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + paddle_features_collection: () => p.oneToMany(PaddleFeatures).mappedBy('region').hidden(), + }, +}); diff --git a/server/src/db/entities/TourObstacleReviews.entity.ts b/server/src/db/entities/TourObstacleReviews.entity.ts new file mode 100644 index 000000000..88d33cbd2 --- /dev/null +++ b/server/src/db/entities/TourObstacleReviews.entity.ts @@ -0,0 +1,46 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TourObstacleReviewsRepository } from '../repositories/TourObstacleReviews.repository'; +import { DbTimestampType } from '../types'; +import { Tours } from './Tours.entity'; + +export class TourObstacleReviews { + [EntityRepositoryType]?: TourObstacleReviewsRepository; + [PrimaryKeyProp]?: ['place', 'obstacle_key']; + place!: Ref; + place_id!: number; + obstacle_key!: string; + decision!: TTourObstacleReviewsDecision; + note?: string | null; + route_fingerprint!: string; + source_fingerprint!: string; + obstacle_snapshot!: string; + decided_at!: string; + decided_by?: number | null; +} + +export const TourObstacleReviewsDecision = { + PASS: 'pass', + PORTAGE: 'portage', + REROUTE: 'reroute', + NOT_AFFECTED: 'not_affected', +} as const; + +export type TTourObstacleReviewsDecision = (typeof TourObstacleReviewsDecision)[keyof typeof TourObstacleReviewsDecision]; + +export const TourObstacleReviewsSchema = defineEntity({ + class: TourObstacleReviews, + repository: () => TourObstacleReviewsRepository, + uniques: [{ properties: ['place', 'obstacle_key'] }], + properties: { + place: () => p.manyToOne(Tours).primary().ref().name('place_id').deleteRule('cascade').hidden(), + place_id: p.integer().persist(false), + obstacle_key: p.text().primary(), + decision: p.enum(() => TourObstacleReviewsDecision), + note: p.text().nullable(), + route_fingerprint: p.text(), + source_fingerprint: p.text(), + obstacle_snapshot: p.text(), + decided_at: p.type(DbTimestampType), + decided_by: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/TourWaypoints.entity.ts b/server/src/db/entities/TourWaypoints.entity.ts index 0d9f53e2d..57bda5c7d 100644 --- a/server/src/db/entities/TourWaypoints.entity.ts +++ b/server/src/db/entities/TourWaypoints.entity.ts @@ -12,6 +12,7 @@ export class TourWaypoints { role!: string; sequence!: number; fixed_leg?: string | null; + lock?: string | null; } export const TourWaypointsSchema = defineEntity({ @@ -27,5 +28,6 @@ export const TourWaypointsSchema = defineEntity({ role: p.text(), sequence: p.integer(), fixed_leg: p.text().nullable(), + lock: p.text().nullable(), }, }); diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index 485ca4820..1d5aa24ae 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -1,6 +1,7 @@ import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; import { ToursRepository } from '../repositories/Tours.repository'; import { Places } from './Places.entity'; +import { TourObstacleReviews } from './TourObstacleReviews.entity'; import { TourSources } from './TourSources.entity'; import { TourTypes } from './TourTypes.entity'; import { TourWaypoints } from './TourWaypoints.entity'; @@ -30,7 +31,10 @@ export class Tours { boat_type?: TToursBoatType | null; water_context?: TToursWaterContext | null; paddle_speed_kmh?: number | null; + portage_speed_kmh?: number | null; + track_marks?: string | null; tourTypeRef!: Ref; + tour_obstacle_reviews_collection = new Collection(this); tour_sources: Ref | null = null; tour_waypoints_collection = new Collection(this); } @@ -92,7 +96,10 @@ export const ToursSchema = defineEntity({ boat_type: p.enum(() => ToursBoatType).nullable(), water_context: p.enum(() => ToursWaterContext).nullable(), paddle_speed_kmh: p.double().nullable(), + portage_speed_kmh: p.double().nullable(), + track_marks: p.text().nullable(), tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'), + tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(), tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(), tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(), }, diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index eb04fe91a..cf28dd9c9 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -61,6 +61,8 @@ import { PackingItemsSchema } from './PackingItems.entity'; import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.entity'; import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity'; import { PackingTemplatesSchema } from './PackingTemplates.entity'; +import { PaddleFeaturesSchema } from './PaddleFeatures.entity'; +import { PaddleRegionsSchema } from './PaddleRegions.entity'; import { PasswordResetTokensSchema } from './PasswordResetTokens.entity'; import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity'; import { PhotoProvidersSchema } from './PhotoProviders.entity'; @@ -103,6 +105,7 @@ import { ShareTokensSchema } from './ShareTokens.entity'; import { TagsSchema } from './Tags.entity'; import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity'; import { TodoItemsSchema } from './TodoItems.entity'; +import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity'; import { TourSourcesSchema } from './TourSources.entity'; import { TourTypesSchema } from './TourTypes.entity'; import { TourWaypointsSchema } from './TourWaypoints.entity'; @@ -198,6 +201,8 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ PackingTemplateCategoriesSchema, PackingTemplateItemsSchema, PackingTemplatesSchema, + PaddleFeaturesSchema, + PaddleRegionsSchema, PasswordResetTokensSchema, PhotoProviderFieldsSchema, PhotoProvidersSchema, @@ -240,6 +245,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ TagsSchema, TodoCategoryAssigneesSchema, TodoItemsSchema, + TourObstacleReviewsSchema, TourSourcesSchema, TourTypesSchema, TourWaypointsSchema, diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 4f41354cf..bd7ef469c 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -1,5 +1,15 @@ import type { JsonColumn } from '../utils/json-column'; -import { FERRATAS_RESULT_CAP, ferrataSchema, tourFixedLegSchema, type Ferrata, type TourFixedLeg } from '@trek/shared'; +import { + FERRATAS_RESULT_CAP, + ferrataSchema, + paddleLockSchema, + tourFixedLegSchema, + trackMarksSchema, + type Ferrata, + type PaddleLock, + type TourFixedLeg, + type TrackMarks, +} from '@trek/shared'; import { z } from 'zod'; @@ -135,3 +145,24 @@ export const TOUR_WAYPOINT_FIXED_LEG: JsonColumn = { schema: tourFixedLegSchema.nullable(), fallback: () => null, }; + +/** + * PAD-07: a lock at a control point of a paddle tour, with the times the + * paddler expects there. A row that does not decode reads as no lock, so the + * estimate names nothing it cannot read. + */ +export const TOUR_WAYPOINT_LOCK: JsonColumn = { + column: 'tour_waypoints.lock', + schema: paddleLockSchema.nullable(), + fallback: () => null, +}; + +/** + * PAD-03: portages and locks laid onto a recorded track. A row that does not + * decode reads as no marks; the track itself is untouched either way. + */ +export const TOUR_TRACK_MARKS: JsonColumn = { + column: 'tours.track_marks', + schema: trackMarksSchema.nullable(), + fallback: () => null, +}; diff --git a/server/src/db/kysely/PaddleFeatures.table.ts b/server/src/db/kysely/PaddleFeatures.table.ts new file mode 100644 index 000000000..d3dec0971 --- /dev/null +++ b/server/src/db/kysely/PaddleFeatures.table.ts @@ -0,0 +1,19 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface PaddleFeaturesTable { + id: InsertOptional; + region_id: string; + osm_ref: string; + kind: string; + group_key: InsertOptional; + name: InsertOptional; + geometry: string; + tags: string; + tag_hash: string; + min_lat: number; + min_lng: number; + max_lat: number; + max_lng: number; +} diff --git a/server/src/db/kysely/PaddleRegions.table.ts b/server/src/db/kysely/PaddleRegions.table.ts new file mode 100644 index 000000000..45bff24f3 --- /dev/null +++ b/server/src/db/kysely/PaddleRegions.table.ts @@ -0,0 +1,19 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface PaddleRegionsTable { + id: string; + name: string; + boundary: InsertOptional; + min_lat: InsertOptional; + min_lng: InsertOptional; + max_lat: InsertOptional; + max_lng: InsertOptional; + status: InsertOptional; + source_timestamp: InsertOptional; + imported_at: InsertOptional; + feature_count: InsertOptional; + error: InsertOptional; + created_at: InsertOptional; +} diff --git a/server/src/db/kysely/TourObstacleReviews.table.ts b/server/src/db/kysely/TourObstacleReviews.table.ts new file mode 100644 index 000000000..f4a0bea19 --- /dev/null +++ b/server/src/db/kysely/TourObstacleReviews.table.ts @@ -0,0 +1,15 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface TourObstacleReviewsTable { + place_id: number; + obstacle_key: string; + decision: string; + note: InsertOptional; + route_fingerprint: string; + source_fingerprint: string; + obstacle_snapshot: string; + decided_at: string; + decided_by: InsertOptional; +} diff --git a/server/src/db/kysely/TourWaypoints.table.ts b/server/src/db/kysely/TourWaypoints.table.ts index a6e06ecbb..aa3bd4327 100644 --- a/server/src/db/kysely/TourWaypoints.table.ts +++ b/server/src/db/kysely/TourWaypoints.table.ts @@ -10,4 +10,5 @@ export interface TourWaypointsTable { role: string; sequence: number; fixed_leg: InsertOptional; + lock: InsertOptional; } diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index 309bbb9f3..0cbae41e7 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -23,4 +23,6 @@ export interface ToursTable { boat_type: InsertOptional; water_context: InsertOptional; paddle_speed_kmh: InsertOptional; + portage_speed_kmh: InsertOptional; + track_marks: InsertOptional; } diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index 1668f25ba..1e17ce653 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -66,6 +66,8 @@ import type { PackingItemsTable } from './PackingItems.table'; import type { PackingTemplateCategoriesTable } from './PackingTemplateCategories.table'; import type { PackingTemplateItemsTable } from './PackingTemplateItems.table'; import type { PackingTemplatesTable } from './PackingTemplates.table'; +import type { PaddleFeaturesTable } from './PaddleFeatures.table'; +import type { PaddleRegionsTable } from './PaddleRegions.table'; import type { PasswordResetTokensTable } from './PasswordResetTokens.table'; import type { PhotoProviderFieldsTable } from './PhotoProviderFields.table'; import type { PhotoProvidersTable } from './PhotoProviders.table'; @@ -109,6 +111,7 @@ import type { ShareTokensTable } from './ShareTokens.table'; import type { TagsTable } from './Tags.table'; import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table'; import type { TodoItemsTable } from './TodoItems.table'; +import type { TourObstacleReviewsTable } from './TourObstacleReviews.table'; import type { TourSourcesTable } from './TourSources.table'; import type { TourTypesTable } from './TourTypes.table'; import type { TourWaypointsTable } from './TourWaypoints.table'; @@ -209,6 +212,8 @@ export interface DB { packing_template_categories: PackingTemplateCategoriesTable; packing_template_items: PackingTemplateItemsTable; packing_templates: PackingTemplatesTable; + paddle_features: PaddleFeaturesTable; + paddle_regions: PaddleRegionsTable; password_reset_tokens: PasswordResetTokensTable; photo_provider_fields: PhotoProviderFieldsTable; photo_providers: PhotoProvidersTable; @@ -252,6 +257,7 @@ export interface DB { tags: TagsTable; todo_category_assignees: TodoCategoryAssigneesTable; todo_items: TodoItemsTable; + tour_obstacle_reviews: TourObstacleReviewsTable; tour_sources: TourSourcesTable; tour_types: TourTypesTable; tour_waypoints: TourWaypointsTable; diff --git a/server/src/db/migrations/Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks.ts b/server/src/db/migrations/Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks.ts new file mode 100644 index 000000000..b3b717c72 --- /dev/null +++ b/server/src/db/migrations/Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * PAD-03 and PAD-07: a paddle tour's portages, locks and carrying speed. + * + * A planned tour marks a portage on the leg itself (`fixed_leg` kind + * `portage`, no column needed) and a lock on the control point it sits at: + * `tour_waypoints.lock` is the JSON of the times the paddler expects there. + * A recorded track has no control points to mark, and is never redrawn, so + * `tours.track_marks` holds its portages and locks as distances along it. + * `tours.portage_speed_kmh` is the carrying speed the paddler chose; NULL + * means the starting value applies. Every existing row reads NULL: nothing + * carried, no lock, nothing changes its meaning. + */ +export class Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks extends Migration { + override name = 'Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks'; + + override up(): void { + this.addSql('ALTER TABLE tour_waypoints ADD COLUMN lock TEXT'); + this.addSql('ALTER TABLE tours ADD COLUMN portage_speed_kmh REAL'); + this.addSql('ALTER TABLE tours ADD COLUMN track_marks TEXT'); + } +} diff --git a/server/src/db/migrations/Migration20200101044100_paddle_obstacles_from_openstreetmap.ts b/server/src/db/migrations/Migration20200101044100_paddle_obstacles_from_openstreetmap.ts new file mode 100644 index 000000000..c1aeef6be --- /dev/null +++ b/server/src/db/migrations/Migration20200101044100_paddle_obstacles_from_openstreetmap.ts @@ -0,0 +1,63 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * PAD-05A: the paddle obstacles TREK reads out of regional OpenStreetMap + * extracts (Geofabrik), held per region. + * + * `paddle_regions` is one extract an admin chose: its Geofabrik id, its name + * and boundary (the area the data covers, so a tour outside every region is + * told so rather than shown as free of obstacles), the state of the last read + * and the date of the data in it. + * + * `paddle_features` is one OSM object the read kept: its OSM reference + * (`node/…`, `way/…`) within its region, what kind of obstacle or access it + * is, the objects of one lock grouped under a shared key, its own geometry as + * JSON (a weir across a river stays a line), the tags it was judged by and a + * hash of them, so a later read can tell a changed object from an unchanged + * one. The bounding box columns let a map view ask for its area without + * reading the JSON. A region's rows go with it. + */ +export class Migration20200101044100_paddle_obstacles_from_openstreetmap extends Migration { + override name = 'Migration20200101044100_paddle_obstacles_from_openstreetmap'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS paddle_regions ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + boundary TEXT, + min_lat REAL, + min_lng REAL, + max_lat REAL, + max_lng REAL, + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'importing', 'ready', 'failed')), + source_timestamp TEXT, + imported_at DATETIME, + feature_count INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + this.addSql(` + CREATE TABLE IF NOT EXISTS paddle_features ( + id INTEGER PRIMARY KEY, + region_id TEXT NOT NULL REFERENCES paddle_regions(id) ON DELETE CASCADE, + osm_ref TEXT NOT NULL, + kind TEXT NOT NULL, + group_key TEXT, + name TEXT, + geometry TEXT NOT NULL, + tags TEXT NOT NULL, + tag_hash TEXT NOT NULL, + min_lat REAL NOT NULL, + min_lng REAL NOT NULL, + max_lat REAL NOT NULL, + max_lng REAL NOT NULL + ) + `); + this.addSql( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_paddle_features_region_ref ON paddle_features(region_id, osm_ref)', + ); + this.addSql('CREATE INDEX IF NOT EXISTS idx_paddle_features_box ON paddle_features(min_lat, max_lat, min_lng, max_lng)'); + } +} diff --git a/server/src/db/migrations/Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions.ts b/server/src/db/migrations/Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions.ts new file mode 100644 index 000000000..a0373c454 --- /dev/null +++ b/server/src/db/migrations/Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * PAD-06: what the paddler decided about an obstacle on a tour, and against + * what. `obstacle_key` names the obstacle (its OSM reference, or the shared + * key of a lock's objects); `decision` is pass, portage, reroute or + * not_affected. A decision holds only for the line and the obstacle it was + * made on: `route_fingerprint` is the tour's line near the obstacle and + * `source_fingerprint` the obstacle's data, both as they were then. When + * either differs now the decision is shown again as one to review, never + * silently carried over or dropped. `obstacle_snapshot` keeps the obstacle's + * name and kind at the time, so a decision about an object the data no + * longer holds still says what it was about. `decided_by` names who decided, + * kept as a plain id: a decision outlives its author, who then reads as + * unknown. It goes with its tour. + */ +export class Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions extends Migration { + override name = 'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_obstacle_reviews ( + place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE, + obstacle_key TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('pass', 'portage', 'reroute', 'not_affected')), + note TEXT, + route_fingerprint TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + obstacle_snapshot TEXT NOT NULL, + decided_at DATETIME NOT NULL, + decided_by INTEGER, + PRIMARY KEY (place_id, obstacle_key) + ) + `); + } +} diff --git a/server/src/db/repositories/PaddleFeatures.repository.ts b/server/src/db/repositories/PaddleFeatures.repository.ts new file mode 100644 index 000000000..c237a79e3 --- /dev/null +++ b/server/src/db/repositories/PaddleFeatures.repository.ts @@ -0,0 +1,80 @@ +import type { PaddleFeatures } from '../entities/PaddleFeatures.entity'; +import type { DB } from '../kysely/db'; +import { TrekRepository } from './_shared/trek-repository'; + +/** One stored paddle object, its geometry and tags still JSON text. */ +export interface PaddleFeatureRow { + region_id: string; + osm_ref: string; + kind: string; + group_key: string | null; + name: string | null; + geometry: string; + tags: string; + tag_hash: string; + min_lat: number; + min_lng: number; + max_lat: number; + max_lng: number; +} + +/** A box in degrees, south to north and west to east. */ +export interface Box { + south: number; + west: number; + north: number; + east: number; +} + +/** Rows written per statement, kept well under SQLite's bound-parameter limit. */ +const INSERT_CHUNK = 200; + +/** + * `paddle_features`: the paddle objects TREK read out of a region's extract + * (PAD-05A). A region's rows are only ever replaced whole, inside the + * transaction the import holds, so a reader never sees half a region. + */ +export class PaddleFeaturesRepository extends TrekRepository { + private db() { + return this.kysely>(); + } + + /** The objects whose box reaches into `box`, at most `limit` of them, in a stable order. */ + async findInBox(box: Box, limit: number): Promise { + return await this.db() + .selectFrom('paddle_features') + .select([ + 'region_id', + 'osm_ref', + 'kind', + 'group_key', + 'name', + 'geometry', + 'tags', + 'tag_hash', + 'min_lat', + 'min_lng', + 'max_lat', + 'max_lng', + ]) + .where('min_lat', '<=', box.north) + .where('max_lat', '>=', box.south) + .where('min_lng', '<=', box.east) + .where('max_lng', '>=', box.west) + .orderBy('id', 'asc') + .limit(limit) + .execute(); + } + + /** Drops a region's objects and writes the new read in their place; the caller holds the transaction. */ + async replaceRegion(regionId: string, rows: readonly PaddleFeatureRow[]): Promise { + await this.db().deleteFrom('paddle_features').where('region_id', '=', regionId).execute(); + for (let start = 0; start < rows.length; start += INSERT_CHUNK) { + await this.db() + .insertInto('paddle_features') + .values(rows.slice(start, start + INSERT_CHUNK).map((row) => ({ ...row, region_id: regionId }))) + .onConflict((oc) => oc.columns(['region_id', 'osm_ref']).doNothing()) + .execute(); + } + } +} diff --git a/server/src/db/repositories/PaddleRegions.repository.ts b/server/src/db/repositories/PaddleRegions.repository.ts new file mode 100644 index 000000000..a80610f72 --- /dev/null +++ b/server/src/db/repositories/PaddleRegions.repository.ts @@ -0,0 +1,115 @@ +import type { PaddleRegions } from '../entities/PaddleRegions.entity'; +import type { DB } from '../kysely/db'; +import { TrekRepository } from './_shared/trek-repository'; +import type { Box } from './PaddleFeatures.repository'; + +export type PaddleRegionStatus = 'queued' | 'importing' | 'ready' | 'failed'; + +/** One region an admin chose, as stored; the boundary is still GeoJSON text. */ +export interface PaddleRegionRow { + id: string; + name: string; + boundary: string | null; + min_lat: number | null; + min_lng: number | null; + max_lat: number | null; + max_lng: number | null; + status: PaddleRegionStatus; + source_timestamp: string | null; + imported_at: string | null; + feature_count: number; + error: string | null; +} + +/** `status` narrowed to the four values the column's CHECK constraint allows. */ +type RegionsDB = { + paddle_regions: Omit & { status: PaddleRegionStatus }; +}; + +const COLUMNS = [ + 'id', + 'name', + 'boundary', + 'min_lat', + 'min_lng', + 'max_lat', + 'max_lng', + 'status', + 'source_timestamp', + 'imported_at', + 'feature_count', + 'error', +] as const; + +/** `paddle_regions`: the regional extracts an admin chose for paddle data (PAD-05A). */ +export class PaddleRegionsRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + async list(): Promise { + return await this.db().selectFrom('paddle_regions').select(COLUMNS).orderBy('name', 'asc').execute(); + } + + async findById(id: string): Promise { + return await this.db().selectFrom('paddle_regions').select(COLUMNS).where('id', '=', id).executeTakeFirst(); + } + + /** The regions with data whose box reaches into `box`. */ + async readyInBox(box: Box): Promise { + return await this.db() + .selectFrom('paddle_regions') + .select(COLUMNS) + .where('status', '=', 'ready') + .where('min_lat', '<=', box.north) + .where('max_lat', '>=', box.south) + .where('min_lng', '<=', box.east) + .where('max_lng', '>=', box.west) + .execute(); + } + + /** The first region waiting for its read, oldest first. */ + async nextQueued(): Promise { + return await this.db() + .selectFrom('paddle_regions') + .select(COLUMNS) + .where('status', '=', 'queued') + .orderBy('created_at', 'asc') + .executeTakeFirst(); + } + + async insertRegion(row: Pick): Promise { + await this.db() + .insertInto('paddle_regions') + .values({ ...row, status: 'queued', feature_count: 0 }) + .onConflict((oc) => oc.column('id').doNothing()) + .execute(); + } + + async updateRegion( + id: string, + patch: Partial>, + ): Promise { + await this.db().updateTable('paddle_regions').set(patch).where('id', '=', id).execute(); + } + + /** Queues every region that has data again, for the monthly refresh; returns how many. */ + async queueReady(): Promise { + const result = await this.db() + .updateTable('paddle_regions') + .set({ status: 'queued' }) + .where('status', 'in', ['ready', 'failed']) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0); + } + + /** A region left half read by a restart goes back to the queue. */ + async requeueInterrupted(): Promise { + await this.db().updateTable('paddle_regions').set({ status: 'queued' }).where('status', '=', 'importing').execute(); + } + + async deleteRegion(id: string): Promise { + const result = await this.db().deleteFrom('paddle_regions').where('id', '=', id).executeTakeFirst(); + return Number(result.numDeletedRows ?? 0) > 0; + } +} diff --git a/server/src/db/repositories/TourObstacleReviews.repository.ts b/server/src/db/repositories/TourObstacleReviews.repository.ts new file mode 100644 index 000000000..260432dd5 --- /dev/null +++ b/server/src/db/repositories/TourObstacleReviews.repository.ts @@ -0,0 +1,4 @@ +import type { TourObstacleReviews } from '../entities/TourObstacleReviews.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class TourObstacleReviewsRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TourWaypoints.repository.ts b/server/src/db/repositories/TourWaypoints.repository.ts index a7adee458..21bb5c61f 100644 --- a/server/src/db/repositories/TourWaypoints.repository.ts +++ b/server/src/db/repositories/TourWaypoints.repository.ts @@ -1,9 +1,9 @@ import { decodeJson, encodeJson } from '../../utils/json-column'; import type { TourWaypoints } from '../entities/TourWaypoints.entity'; -import { TOUR_WAYPOINT_FIXED_LEG } from '../json-columns'; +import { TOUR_WAYPOINT_FIXED_LEG, TOUR_WAYPOINT_LOCK } from '../json-columns'; import type { DB } from '../kysely/db'; import { TrekRepository } from './_shared/trek-repository'; -import type { TourFixedLeg } from '@trek/shared'; +import type { PaddleLock, TourFixedLeg } from '@trek/shared'; /** One saved routing control point of a tour, in route order. */ export interface TourWaypointRow { @@ -13,6 +13,8 @@ export interface TourWaypointRow { sequence: number; /** The leg to the next waypoint as a fixed line; absent for a routed leg. */ fixed_leg?: TourFixedLeg | null; + /** A lock at this point of a paddle tour; absent where there is none. */ + lock?: PaddleLock | null; } /** The row as stored, the fixed leg still JSON text. */ @@ -22,12 +24,14 @@ interface StoredWaypoint { role: TourWaypointRow['role']; sequence: number; fixed_leg: string | null; + lock: string | null; } -/** A stored row as callers read it: the fixed leg decoded, and left out entirely for a routed leg. */ -function readWaypoint({ fixed_leg, ...point }: StoredWaypoint): TourWaypointRow { +/** A stored row as callers read it: the fixed leg and the lock decoded, each left out entirely where there is none. */ +function readWaypoint({ fixed_leg, lock, ...point }: StoredWaypoint): TourWaypointRow { const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint'); - return leg ? { ...point, fixed_leg: leg } : point; + const stop = decodeJson(TOUR_WAYPOINT_LOCK, lock, 'tour waypoint'); + return { ...point, ...(leg ? { fixed_leg: leg } : {}), ...(stop ? { lock: stop } : {}) }; } /** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */ @@ -50,11 +54,11 @@ export class TourWaypointsRepository extends TrekRepository { return this.kysely(); } - /** TW1 — `SELECT lat, lng, role, sequence, fixed_leg FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */ + /** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */ async listForPlace(place_id: number): Promise { const rows = await this.db() .selectFrom('tour_waypoints') - .select(['lat', 'lng', 'role', 'sequence', 'fixed_leg']) + .select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock']) .where('place_id', '=', place_id) .orderBy('sequence', 'asc') .execute(); @@ -66,7 +70,7 @@ export class TourWaypointsRepository extends TrekRepository { const rows = await this.db() .selectFrom('tour_waypoints as tw') .innerJoin('places as p', 'p.id', 'tw.place_id') - .select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg']) + .select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg', 'tw.lock']) .where('p.trip_id', '=', trip_id) .orderBy('tw.place_id', 'asc') .orderBy('tw.sequence', 'asc') @@ -75,7 +79,7 @@ export class TourWaypointsRepository extends TrekRepository { } /** - * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg) + * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock) * VALUES (...)` for each point. The caller owns the transaction: a create * writes the tour first, an edit clears the old points first. */ @@ -89,6 +93,7 @@ export class TourWaypointsRepository extends TrekRepository { role: w.role, sequence: w.sequence, fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null, + lock: w.lock ? encodeJson(TOUR_WAYPOINT_LOCK, w.lock) : null, })), ); } diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 8760de64a..1c44ff2d6 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -31,6 +31,9 @@ export interface TourListRow { boat_type: string | null; water_context: string | null; paddle_speed_kmh: number | null; + portage_speed_kmh: number | null; + /** The marks laid onto a recorded track, still JSON text. */ + track_marks: string | null; has_recorded_track: number; planned: number; has_waypoints: number; @@ -58,6 +61,8 @@ export interface TourRow { boat_type: string | null; water_context: string | null; paddle_speed_kmh: number | null; + portage_speed_kmh: number | null; + track_marks: string | null; } const _tourRowKeys: AssertRowKeys = true; @@ -77,6 +82,7 @@ export interface TourInsert { boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; + portage_speed_kmh?: number | null; } /** What a route edit replaces on an existing tour: the type and everything that belongs to it. */ @@ -88,14 +94,24 @@ export type TourUpdate = Omit< | 'boat_type' | 'water_context' | 'paddle_speed_kmh' + | 'portage_speed_kmh' > & { planned_duration_minutes?: number | null; break_additional_minutes?: number | null; boat_type: PaddleBoatType | null; water_context: PaddleWaterContext | null; paddle_speed_kmh: number | null; + portage_speed_kmh: number | null; }; +/** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */ +export interface TourPaddlePlanUpdate { + paddle_speed_kmh: number | null; + portage_speed_kmh: number | null; + track_marks: string | null; + duration: number | null; +} + type ToursKyselyDB = Pick; /** One day a tour of the trip sits on, in the order of the trip. */ @@ -173,6 +189,8 @@ export class ToursRepository extends TrekRepository { 't.boat_type as boat_type', 't.water_context as water_context', 't.paddle_speed_kmh as paddle_speed_kmh', + 't.portage_speed_kmh as portage_speed_kmh', + 't.track_marks as track_marks', eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'), eb .exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')) @@ -359,6 +377,7 @@ export class ToursRepository extends TrekRepository { derived_from_place_id?: number | null; origin?: TToursOrigin; origin_file_name?: string | null; + track_marks?: string | null; }, ): Promise { await this.insert({ @@ -380,6 +399,8 @@ export class ToursRepository extends TrekRepository { boat_type: input.boat_type ?? null, water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, + portage_speed_kmh: input.portage_speed_kmh ?? null, + track_marks: input.track_marks ?? null, }); } @@ -430,6 +451,27 @@ export class ToursRepository extends TrekRepository { boat_type: input.boat_type, water_context: input.water_context, paddle_speed_kmh: input.paddle_speed_kmh, + portage_speed_kmh: input.portage_speed_kmh, + }) + .where('place_id', '=', place_id) + .where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id)) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + + /** + * The paddle plan of a recorded track of the trip: its speeds, the marks + * laid onto it and the moving time they make. The track itself is not + * touched. Returns whether a row of that trip was updated. + */ + async updatePaddlePlanInTrip(trip_id: number, place_id: number, input: TourPaddlePlanUpdate): Promise { + const result = await this.db() + .updateTable('tours') + .set({ + paddle_speed_kmh: input.paddle_speed_kmh, + portage_speed_kmh: input.portage_speed_kmh, + track_marks: input.track_marks, + duration: input.duration, }) .where('place_id', '=', place_id) .where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id)) diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e8f161095..e60fec620 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -43,6 +43,7 @@ import { DaysModule } from './days/days.module'; import { DocSyncModule } from './doc-sync/doc-sync.module'; import { FeedsModule } from './feeds/feeds.module'; import { FerratasModule } from './ferratas/ferratas.module'; +import { PaddleObstaclesModule } from './paddle-obstacles/paddle-obstacles.module'; import { FilesModule } from './files/files.module'; import { GeoModule } from './geo/geo.module'; import { GoogleQuotaModule } from './google-quota/google-quota.module'; @@ -155,6 +156,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; ToursModule, WandererModule, FerratasModule, + PaddleObstaclesModule, TripsModule, CollabModule, FilesModule, diff --git a/server/src/nest/paddle-obstacles/geofabrik.client.ts b/server/src/nest/paddle-obstacles/geofabrik.client.ts new file mode 100644 index 000000000..4396566df --- /dev/null +++ b/server/src/nest/paddle-obstacles/geofabrik.client.ts @@ -0,0 +1,101 @@ +import { readCappedJson } from '../../utils/cappedFetch'; +import { regionBoundarySchema, type RegionBoundary } from './paddle-geo'; +import { Injectable } from '@nestjs/common'; +import { z } from 'zod'; + +import { createWriteStream } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { ReadableStream as WebReadableStream } from 'node:stream/web'; + +/** + * The outbound half of PAD-05A: Geofabrik's list of regional extracts and the + * download of one. Only Geofabrik's own host is ever asked, at addresses its + * list names, so an admin choosing a region can point TREK nowhere else. + */ + +export const GEOFABRIK_HOST = 'https://download.geofabrik.de/'; +const INDEX_URL = `${GEOFABRIK_HOST}index-v1.json`; +const INDEX_TIMEOUT_MS = 20_000; +/** The list is a few megabytes; far more means it is not the list. */ +const MAX_INDEX_BYTES = 32 * 1024 * 1024; +const INDEX_FRESH_MS = 24 * 60 * 60 * 1000; +/** A whole country is a few gigabytes; a regional extract far less. More than this is refused. */ +export const MAX_EXTRACT_BYTES = 4 * 1024 * 1024 * 1024; +const DOWNLOAD_TIMEOUT_MS = 60 * 60 * 1000; + +const indexSchema = z.object({ + features: z.array( + z.object({ + properties: z.object({ + id: z.string(), + parent: z.string().optional(), + name: z.string(), + urls: z.object({ pbf: z.string().optional() }).passthrough(), + }), + geometry: z.unknown(), + }), + ), +}); + +export interface GeofabrikRegion { + id: string; + name: string; + parent: string | null; + pbfUrl: string; + boundary: RegionBoundary | null; +} + +@Injectable() +export class GeofabrikClient { + private index: { regions: Map; fetchedAt: number } | null = null; + + /** Every region the list names that has an extract on Geofabrik's host, by id. */ + async regions(now: number = Date.now()): Promise> { + if (this.index && now - this.index.fetchedAt < INDEX_FRESH_MS) return this.index.regions; + const response = await fetch(INDEX_URL, { signal: AbortSignal.timeout(INDEX_TIMEOUT_MS) }); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new Error(`Geofabrik index ${response.status}`); + } + const parsed = indexSchema.parse(await readCappedJson(response, MAX_INDEX_BYTES)); + const regions = new Map(); + for (const feature of parsed.features) { + const { id, name, parent, urls } = feature.properties; + if (!urls.pbf?.startsWith(GEOFABRIK_HOST) || !urls.pbf.endsWith('-latest.osm.pbf')) continue; + const boundary = regionBoundarySchema.safeParse(feature.geometry); + regions.set(id, { id, name, parent: parent ?? null, pbfUrl: urls.pbf, boundary: boundary.success ? boundary.data : null }); + } + this.index = { regions, fetchedAt: now }; + return regions; + } + + /** + * One region's extract written to `target`. The address must be one the + * list named; the body is counted as it streams and cut at + * MAX_EXTRACT_BYTES, and the file goes when the download fails. + */ + async download(url: string, target: string): Promise { + if (!url.startsWith(GEOFABRIK_HOST) || !url.endsWith('-latest.osm.pbf')) throw new Error('Not a Geofabrik extract'); + const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) }); + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined); + throw new Error(`Geofabrik download ${response.status}`); + } + let received = 0; + const counter = new Transform({ + transform(chunk: Buffer, _encoding, done) { + received += chunk.length; + if (received > MAX_EXTRACT_BYTES) done(new Error('Extract larger than allowed')); + else done(null, chunk); + }, + }); + try { + await pipeline(Readable.fromWeb(response.body as unknown as WebReadableStream), counter, createWriteStream(target)); + } catch (err) { + await unlink(target).catch(() => undefined); + throw err; + } + } +} diff --git a/server/src/nest/paddle-obstacles/osm-pbf.reader.ts b/server/src/nest/paddle-obstacles/osm-pbf.reader.ts new file mode 100644 index 000000000..f44a2615e --- /dev/null +++ b/server/src/nest/paddle-obstacles/osm-pbf.reader.ts @@ -0,0 +1,317 @@ +import { open } from 'node:fs/promises'; +import { inflateSync } from 'node:zlib'; + +/** + * A reader for OpenStreetMap's PBF format, as Geofabrik's regional extracts + * come: the file is a run of blobs, each a length-prefixed header and a + * (usually zlib-compressed) block. A block holds its own string table and + * groups of nodes (packed as "dense" nodes) and ways. Relations are not read: + * every object PAD-05A evaluates is a node or a way. + * + * Only what the paddle data needs is decoded, and nothing is kept between + * blocks, so a region of any size is read in the memory of one block (a few + * megabytes). The format: https://wiki.openstreetmap.org/wiki/PBF_Format + */ + +/** A blob's size may not exceed this, by the format's own rule. */ +const MAX_BLOB_BYTES = 32 * 1024 * 1024; +const MAX_HEADER_BYTES = 64 * 1024; + +export interface OsmNode { + id: number; + lat: number; + lon: number; + tags: Record; +} + +export interface OsmWay { + id: number; + refs: number[]; + tags: Record; +} + +export interface OsmBlockVisitor { + /** Every node of the block, tagged or not; `tags` is empty for most. */ + node?: (node: OsmNode) => void; + way?: (way: OsmWay) => void; +} + +/** A protobuf field walker over one buffer, enough for the OSM schema. */ +class Proto { + pos: number; + + constructor( + private readonly buf: Buffer, + start = 0, + private readonly end = buf.length, + ) { + this.pos = start; + } + + get done(): boolean { + return this.pos >= this.end; + } + + /** An unsigned varint as a number: exact up to 2^53, which every OSM id and offset stays under. */ + varint(): number { + let result = 0; + let scale = 1; + for (;;) { + if (this.pos >= this.end) throw new Error('Truncated varint'); + const byte = this.buf[this.pos++] as number; + result += (byte & 0x7f) * scale; + if (byte < 0x80) return result; + scale *= 128; + } + } + + /** A zigzag-encoded signed varint. */ + svarint(): number { + const raw = this.varint(); + return raw % 2 === 0 ? raw / 2 : -(raw + 1) / 2; + } + + /** The next field's number and wire type, or null at the end. */ + key(): { field: number; wire: number } | null { + if (this.done) return null; + const key = this.varint(); + return { field: Math.floor(key / 8), wire: key % 8 }; + } + + /** The bounds of a length-delimited field, consumed. */ + span(): [number, number] { + const length = this.varint(); + const start = this.pos; + this.pos += length; + if (this.pos > this.end) throw new Error('Truncated field'); + return [start, this.pos]; + } + + bytes(): Buffer { + const [start, stop] = this.span(); + return this.buf.subarray(start, stop); + } + + skip(wire: number): void { + if (wire === 0) this.varint(); + else if (wire === 1) this.pos += 8; + else if (wire === 2) this.span(); + else if (wire === 5) this.pos += 4; + else throw new Error(`Unsupported wire type ${wire}`); + } + + /** A packed field of varints (or zigzag varints), decoded whole. */ + packed(signed: boolean): number[] { + const [start, stop] = this.span(); + const inner = new Proto(this.buf, start, stop); + const values: number[] = []; + while (!inner.done) values.push(signed ? inner.svarint() : inner.varint()); + return values; + } +} + +/** The fields of a blob header: its type ("OSMHeader" or "OSMData") and the size of the blob after it. */ +function readBlobHeader(buf: Buffer): { type: string; size: number } { + const p = new Proto(buf); + let type = ''; + let size = 0; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1 && key.wire === 2) type = p.bytes().toString('utf8'); + else if (key.field === 3 && key.wire === 0) size = p.varint(); + else p.skip(key.wire); + } + return { type, size }; +} + +/** A blob's contents, inflated: the format allows raw or zlib data, and TREK reads those two. */ +function readBlob(buf: Buffer): Buffer { + const p = new Proto(buf); + let raw: Buffer | null = null; + let zlib: Buffer | null = null; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1 && key.wire === 2) raw = p.bytes(); + else if (key.field === 3 && key.wire === 2) zlib = p.bytes(); + else p.skip(key.wire); + } + if (raw) return raw; + if (zlib) return inflateSync(zlib, { maxOutputLength: MAX_BLOB_BYTES }); + throw new Error('Unsupported blob compression'); +} + +/** + * The replication timestamp of an extract, from its header block: the moment + * the data is current to, in seconds. Null when the header carries none. + */ +export function readHeaderTimestamp(block: Buffer): number | null { + const p = new Proto(block); + for (let key = p.key(); key; key = p.key()) { + if (key.field === 32 && key.wire === 0) return p.varint(); + p.skip(key.wire); + } + return null; +} + +interface BlockContext { + strings: string[]; + granularity: number; + latOffset: number; + lonOffset: number; +} + +function tagsOf(keys: readonly number[], values: readonly number[], strings: readonly string[]): Record { + const tags: Record = {}; + keys.forEach((key, index) => { + const value = values[index]; + if (value !== undefined) tags[strings[key] ?? ''] = strings[value] ?? ''; + }); + return tags; +} + +function readDense(buf: Buffer, ctx: BlockContext, visit: (node: OsmNode) => void): void { + const p = new Proto(buf); + let ids: number[] = []; + let lats: number[] = []; + let lons: number[] = []; + let keysVals: number[] = []; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1) ids = p.packed(true); + else if (key.field === 8) lats = p.packed(true); + else if (key.field === 9) lons = p.packed(true); + else if (key.field === 10) keysVals = p.packed(false); + else p.skip(key.wire); + } + let id = 0; + let lat = 0; + let lon = 0; + let kv = 0; + ids.forEach((delta, index) => { + id += delta; + lat += lats[index] ?? 0; + lon += lons[index] ?? 0; + const tags: Record = {}; + // keys_vals runs key, value, key, value ... with a 0 closing each node. + while (kv < keysVals.length && keysVals[kv] !== 0) { + tags[ctx.strings[keysVals[kv] as number] ?? ''] = ctx.strings[keysVals[kv + 1] as number] ?? ''; + kv += 2; + } + kv += 1; + visit({ + id, + lat: (ctx.latOffset + ctx.granularity * lat) * 1e-9, + lon: (ctx.lonOffset + ctx.granularity * lon) * 1e-9, + tags, + }); + }); +} + +function readNode(buf: Buffer, ctx: BlockContext): OsmNode { + const p = new Proto(buf); + let id = 0; + let lat = 0; + let lon = 0; + let keys: number[] = []; + let values: number[] = []; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1) id = p.svarint(); + else if (key.field === 2) keys = p.packed(false); + else if (key.field === 3) values = p.packed(false); + else if (key.field === 8) lat = p.svarint(); + else if (key.field === 9) lon = p.svarint(); + else p.skip(key.wire); + } + return { + id, + lat: (ctx.latOffset + ctx.granularity * lat) * 1e-9, + lon: (ctx.lonOffset + ctx.granularity * lon) * 1e-9, + tags: tagsOf(keys, values, ctx.strings), + }; +} + +function readWay(buf: Buffer, strings: readonly string[]): OsmWay { + const p = new Proto(buf); + let id = 0; + let keys: number[] = []; + let values: number[] = []; + let deltas: number[] = []; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1) id = p.varint(); + else if (key.field === 2) keys = p.packed(false); + else if (key.field === 3) values = p.packed(false); + else if (key.field === 8) deltas = p.packed(true); + else p.skip(key.wire); + } + let ref = 0; + const refs = deltas.map((delta) => (ref += delta)); + return { id, refs, tags: tagsOf(keys, values, strings) }; +} + +/** One data block: its string table, then each group's nodes and ways handed to the visitor. */ +export function readDataBlock(block: Buffer, visitor: OsmBlockVisitor): void { + const p = new Proto(block); + const strings: string[] = []; + const groups: Buffer[] = []; + const ctx: BlockContext = { strings, granularity: 100, latOffset: 0, lonOffset: 0 }; + for (let key = p.key(); key; key = p.key()) { + if (key.field === 1 && key.wire === 2) { + const table = new Proto(p.bytes()); + for (let entry = table.key(); entry; entry = table.key()) { + if (entry.field === 1 && entry.wire === 2) strings.push(table.bytes().toString('utf8')); + else table.skip(entry.wire); + } + } else if (key.field === 2 && key.wire === 2) groups.push(p.bytes()); + else if (key.field === 17 && key.wire === 0) ctx.granularity = p.varint(); + else if (key.field === 19 && key.wire === 0) ctx.latOffset = p.svarint(); + else if (key.field === 20 && key.wire === 0) ctx.lonOffset = p.svarint(); + else p.skip(key.wire); + } + for (const group of groups) { + const g = new Proto(group); + for (let key = g.key(); key; key = g.key()) { + if (key.field === 1 && key.wire === 2 && visitor.node) visitor.node(readNode(g.bytes(), ctx)); + else if (key.field === 2 && key.wire === 2 && visitor.node) readDense(g.bytes(), ctx, visitor.node); + else if (key.field === 3 && key.wire === 2 && visitor.way) visitor.way(readWay(g.bytes(), strings)); + else g.skip(key.wire); + } + } +} + +/** + * Every block of a PBF file in order, read from disk one at a time. The + * header block is answered with its timestamp; data blocks go to the visitor. + * `onBlock` is told the bytes read so far, for progress and to give way to + * other work between blocks. + */ +export async function readPbfFile( + path: string, + visitor: OsmBlockVisitor, + onBlock?: (bytesRead: number) => Promise | void, +): Promise<{ timestamp: number | null }> { + const file = await open(path, 'r'); + let timestamp: number | null = null; + try { + let offset = 0; + const lengthBuf = Buffer.alloc(4); + for (;;) { + const { bytesRead } = await file.read(lengthBuf, 0, 4, offset); + if (bytesRead === 0) break; + if (bytesRead < 4) throw new Error('Truncated blob length'); + const headerLength = lengthBuf.readUInt32BE(0); + if (headerLength > MAX_HEADER_BYTES) throw new Error('Blob header too large'); + const headerBuf = Buffer.alloc(headerLength); + await file.read(headerBuf, 0, headerLength, offset + 4); + const header = readBlobHeader(headerBuf); + if (header.size > MAX_BLOB_BYTES) throw new Error('Blob too large'); + const blobBuf = Buffer.alloc(header.size); + const read = await file.read(blobBuf, 0, header.size, offset + 4 + headerLength); + if (read.bytesRead < header.size) throw new Error('Truncated blob'); + const block = readBlob(blobBuf); + if (header.type === 'OSMHeader') timestamp = readHeaderTimestamp(block); + else if (header.type === 'OSMData') readDataBlock(block, visitor); + offset += 4 + headerLength + header.size; + await onBlock?.(offset); + } + } finally { + await file.close(); + } + return { timestamp }; +} diff --git a/server/src/nest/paddle-obstacles/paddle-extract.ts b/server/src/nest/paddle-obstacles/paddle-extract.ts new file mode 100644 index 000000000..1d6088bab --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-extract.ts @@ -0,0 +1,189 @@ +import { readPbfFile, type OsmWay } from './osm-pbf.reader'; +import { keptTags, paddleKindOf, paddleNameOf } from './paddle-tags'; +import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared'; + +import { createHash } from 'node:crypto'; + +/** + * PAD-05A: the paddle objects of one regional extract, read in two passes. + * The first keeps every tagged node that is one, and the ways that are, with + * the ids of their nodes; the second looks those nodes' positions up. Only + * the nodes a kept way needs are held, so a region is read in bounded memory. + */ + +/** A way longer than this keeps every n-th node so its line stays readable; its ends are always kept. */ +const MAX_WAY_NODES = 2000; +/** Lock chambers and gates closer than this, in metres, belong to one lock. */ +const LOCK_GROUP_M = 250; + +export interface ExtractedFeature { + osm_ref: string; + kind: PaddleObstacleKind; + name: string | null; + geometry: PaddleGeometry; + tags: Record; + tag_hash: string; + group_key: string | null; + min_lat: number; + min_lng: number; + max_lat: number; + max_lng: number; +} + +interface PendingWay { + way: OsmWay; + kind: PaddleObstacleKind; +} + +function hashOf(tags: Record, geometry: PaddleGeometry): string { + const sorted = Object.keys(tags) + .sort() + .map((key) => [key, tags[key]]); + const rounded = JSON.stringify(geometry, (_key, value: unknown) => + typeof value === 'number' ? Math.round(value * 1e6) / 1e6 : value, + ); + return createHash('sha1').update(JSON.stringify(sorted)).update(rounded).digest('hex').slice(0, 16); +} + +function boxOf(points: ReadonlyArray): Pick { + let minLat = 90; + let minLng = 180; + let maxLat = -90; + let maxLng = -180; + for (const [lng, lat] of points) { + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + } + return { min_lat: minLat, min_lng: minLng, max_lat: maxLat, max_lng: maxLng }; +} + +function thinned(refs: readonly number[]): number[] { + if (refs.length <= MAX_WAY_NODES) return [...refs]; + const step = Math.ceil(refs.length / MAX_WAY_NODES); + const kept = refs.filter((_, index) => index % step === 0); + const last = refs[refs.length - 1]; + if (last !== undefined && kept[kept.length - 1] !== last) kept.push(last); + return kept; +} + +function build( + osmRef: string, + kind: PaddleObstacleKind, + rawTags: Record, + geometry: PaddleGeometry, + points: ReadonlyArray, +): ExtractedFeature { + const tags = keptTags(rawTags); + return { + osm_ref: osmRef, + kind, + name: paddleNameOf(rawTags), + geometry, + tags, + tag_hash: hashOf(tags, geometry), + group_key: null, + ...boxOf(points), + }; +} + +function wayFeature(pending: PendingWay, coords: ReadonlyMap): ExtractedFeature | null { + const points = pending.way.refs.flatMap((ref) => { + const at = coords.get(ref); + return at ? [at] : []; + }); + if (points.length < 2) return null; + const first = points[0] as readonly [number, number]; + const last = points[points.length - 1] as readonly [number, number]; + const closed = points.length >= 4 && first[0] === last[0] && first[1] === last[1]; + const line = points.map(([lng, lat]) => [lng, lat] as [number, number]); + const geometry: PaddleGeometry = closed ? { type: 'Polygon', coordinates: [line] } : { type: 'LineString', coordinates: line }; + return build(`way/${pending.way.id}`, pending.kind, pending.way.tags, geometry, points); +} + +function centreOf(feature: ExtractedFeature): [number, number] { + return [(feature.min_lat + feature.max_lat) / 2, (feature.min_lng + feature.max_lng) / 2]; +} + +function metresBetween(a: [number, number], b: [number, number]): number { + const cos = Math.cos((((a[0] + b[0]) / 2) * Math.PI) / 180); + return Math.hypot((b[0] - a[0]) * 111_195, (b[1] - a[1]) * 111_195 * cos); +} + +/** + * The chambers and gates of one lock share a key, so the checklist asks + * about the lock once and not about each gate. Parts closer than + * LOCK_GROUP_M join a group; separate locks stay apart. + */ +export function groupLockParts(features: ExtractedFeature[]): void { + const parts = features.filter((feature) => feature.kind === 'lock' || feature.kind === 'lock_gate'); + const parent = new Map(parts.map((part) => [part, part])); + const root = (part: ExtractedFeature): ExtractedFeature => { + let current = part; + while (parent.get(current) !== current) current = parent.get(current) as ExtractedFeature; + return current; + }; + // Sorted by longitude, so each part is only compared with its neighbours within reach. + const sorted = [...parts].sort((a, b) => centreOf(a)[1] - centreOf(b)[1]); + sorted.forEach((part, index) => { + const centre = centreOf(part); + for (let j = index + 1; j < sorted.length; j++) { + const other = sorted[j] as ExtractedFeature; + const otherCentre = centreOf(other); + if ((otherCentre[1] - centre[1]) * 111_195 * Math.cos((centre[0] * Math.PI) / 180) > LOCK_GROUP_M) break; + if (metresBetween(centre, otherCentre) <= LOCK_GROUP_M) parent.set(root(other), root(part)); + } + }); + const groups = new Map(); + for (const part of parts) groups.set(root(part), [...(groups.get(root(part)) ?? []), part]); + for (const members of groups.values()) { + if (members.length < 2) continue; + const key = `lock:${members.map((member) => member.osm_ref).sort()[0]}`; + for (const member of members) member.group_key = key; + } +} + +/** Every paddle object of the extract at `path`, and the date its data is current to. */ +export async function extractPaddleFeatures( + path: string, + onProgress?: (bytesRead: number) => Promise | void, +): Promise<{ timestamp: number | null; features: ExtractedFeature[] }> { + const features: ExtractedFeature[] = []; + const pending: PendingWay[] = []; + const needed = new Set(); + const { timestamp } = await readPbfFile( + path, + { + node: (node) => { + if (Object.keys(node.tags).length === 0) return; + const kind = paddleKindOf(node.tags); + if (!kind) return; + const point: [number, number] = [node.lon, node.lat]; + features.push(build(`node/${node.id}`, kind, node.tags, { type: 'Point', coordinates: point }, [point])); + }, + way: (way) => { + const kind = paddleKindOf(way.tags); + if (!kind) return; + const refs = thinned(way.refs); + pending.push({ way: { ...way, refs }, kind }); + for (const ref of refs) needed.add(ref); + }, + }, + onProgress, + ); + if (pending.length > 0) { + const coords = new Map(); + await readPbfFile(path, { + node: (node) => { + if (needed.has(node.id)) coords.set(node.id, [node.lon, node.lat]); + }, + }); + for (const way of pending) { + const feature = wayFeature(way, coords); + if (feature) features.push(feature); + } + } + groupLockParts(features); + return { timestamp, features }; +} diff --git a/server/src/nest/paddle-obstacles/paddle-geo.ts b/server/src/nest/paddle-obstacles/paddle-geo.ts new file mode 100644 index 000000000..946969bf4 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-geo.ts @@ -0,0 +1,90 @@ +import type { Box } from '../../db/repositories/PaddleFeatures.repository'; +import { PADDLE_OBSTACLES_MAX_SPAN_DEG, type PaddleCoverageState } from '@trek/shared'; +import { z } from 'zod'; + +/** Geofabrik's region boundaries: a polygon or several, rings of [lng, lat]. */ +const ring = z.array(z.tuple([z.number(), z.number()])); +export const regionBoundarySchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('Polygon'), coordinates: z.array(ring) }), + z.object({ type: z.literal('MultiPolygon'), coordinates: z.array(z.array(ring)) }), +]); +export type RegionBoundary = z.infer; + +/** `south,west,north,east` as a box, or null when it is not one or wider than a request may ask. */ +export function parseBox(text: string): Box | null { + const [south, west, north, east] = text.split(',').map(Number); + if ([south, west, north, east].some((value) => value === undefined || !Number.isFinite(value))) return null; + const box = { south: south as number, west: west as number, north: north as number, east: east as number }; + if (box.south > box.north || box.west > box.east) return null; + if (Math.abs(box.south) > 90 || Math.abs(box.north) > 90 || Math.abs(box.west) > 180 || Math.abs(box.east) > 180) + return null; + if (box.north - box.south > PADDLE_OBSTACLES_MAX_SPAN_DEG || box.east - box.west > PADDLE_OBSTACLES_MAX_SPAN_DEG) + return null; + return box; +} + +/** Read as plain values: this workspace compiles without strict mode, where the schema's tuples are "maybe two". */ +function inRing(points: ReadonlyArray>, lat: number, lng: number): boolean { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const xi = Number(points[i]?.[0]); + const yi = Number(points[i]?.[1]); + const xj = Number(points[j]?.[0]); + const yj = Number(points[j]?.[1]); + if (yi > lat !== yj > lat && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** Whether a point lies inside a region's boundary: in an outer ring and in none of its holes. */ +export function boundaryContains(boundary: RegionBoundary, lat: number, lng: number): boolean { + const polygons = boundary.type === 'Polygon' ? [boundary.coordinates] : boundary.coordinates; + return polygons.some( + ([outer, ...holes]) => !!outer && inRing(outer, lat, lng) && !holes.some((hole) => inRing(hole, lat, lng)), + ); +} + +/** The points a box is checked at: its corners and its middle. */ +function samplesOf(box: Box): Array<[number, number]> { + const midLat = (box.south + box.north) / 2; + const midLng = (box.west + box.east) / 2; + return [ + [box.south, box.west], + [box.south, box.east], + [box.north, box.west], + [box.north, box.east], + [midLat, midLng], + ]; +} + +/** + * How far TREK's data reaches over a box whose area some region's box + * reaches into: covered when every sample point lies inside a region's + * boundary, partial otherwise; without such a region, none. A region whose + * boundary is unknown counts by its box alone. + */ +export function coverageOf(box: Box, boundaries: ReadonlyArray): PaddleCoverageState { + if (boundaries.length === 0) return 'none'; + const inside = samplesOf(box).map(([lat, lng]) => + boundaries.some((boundary) => boundary === null || boundaryContains(boundary, lat, lng)), + ); + // A region's box reaching in without any sample inside its boundary still touches the area at an edge. + return inside.every(Boolean) ? 'covered' : 'partial'; +} + +/** The box around a boundary, for the region's row. */ +export function boxOfBoundary(boundary: RegionBoundary): Box { + const polygons = boundary.type === 'Polygon' ? [boundary.coordinates] : boundary.coordinates; + const box = { south: 90, west: 180, north: -90, east: -180 }; + for (const polygon of polygons) { + for (const point of polygon[0] ?? []) { + const lng = Number(point[0]); + const lat = Number(point[1]); + box.south = Math.min(box.south, lat); + box.north = Math.max(box.north, lat); + box.west = Math.min(box.west, lng); + box.east = Math.max(box.east, lng); + } + } + return box; +} diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts new file mode 100644 index 000000000..e605288e8 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.controller.ts @@ -0,0 +1,81 @@ +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { AdminGuard } from '../auth-core/admin.guard'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { PaddleObstaclesQueryDto, PaddleRegionCatalogQueryDto, PaddleRegionRequestDto } from './paddle-obstacles.dto'; +import { PaddleObstaclesService } from './paddle-obstacles.service'; +import { PaddleRegionsService } from './paddle-regions.service'; +import { Body, Controller, Get, HttpCode, Post, Query, UseGuards } from '@nestjs/common'; +import { + paddleObstaclesResponseSchema, + paddleRegionCatalogResponseSchema, + paddleRegionListResponseSchema, + paddleRegionSchema, + successResponseSchema, +} from '@trek/shared'; + +/** + * GET /api/paddle/obstacles?bbox=: the paddle objects of a map area and its + * coverage (PAD-05A), for anyone signed in while Tours is on. The data is + * OpenStreetMap's, read from regional extracts, not trip data. + */ +@Controller('api/paddle') +@UseGuards(AddonGuard, JwtAuthGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class PaddleObstaclesController { + constructor(private readonly obstacles: PaddleObstaclesService) {} + + @Get('obstacles') + @ResponseContract(paddleObstaclesResponseSchema) + async inBox(@Query() query: PaddleObstaclesQueryDto) { + return await this.obstacles.inBox(query.bbox); + } +} + +/** + * /api/admin/paddle-regions: which regions TREK reads paddle data for. The + * list, Geofabrik's catalogue to pick from, adding (which starts the first + * read), reading again and removing. Ids travel in the body or the query: + * Geofabrik's are plain words, but a route segment is no place for a choice + * that names an outside source. + */ +@Controller('api/admin/paddle-regions') +@UseGuards(JwtAuthGuard, AdminGuard) +export class PaddleRegionsController { + constructor(private readonly regions: PaddleRegionsService) {} + + @Get() + @ResponseContract(paddleRegionListResponseSchema) + async list() { + return { regions: await this.regions.list() }; + } + + @Get('catalog') + @ResponseContract(paddleRegionCatalogResponseSchema) + async catalog(@Query() query: PaddleRegionCatalogQueryDto) { + return { regions: await this.regions.catalog(query.q) }; + } + + @Post() + @ResponseContract(paddleRegionSchema) + async add(@Body() body: PaddleRegionRequestDto) { + return await this.regions.add(body.id); + } + + @Post('refresh') + @HttpCode(200) + @ResponseContract(paddleRegionSchema) + async refresh(@Body() body: PaddleRegionRequestDto) { + return await this.regions.refresh(body.id); + } + + @Post('remove') + @HttpCode(200) + @ResponseContract(successResponseSchema) + async remove(@Body() body: PaddleRegionRequestDto) { + await this.regions.remove(body.id); + return { success: true as const }; + } +} diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts new file mode 100644 index 000000000..c0c3ca8f7 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.dto.ts @@ -0,0 +1,7 @@ +import { paddleObstaclesQuerySchema, paddleRegionCatalogQuerySchema, paddleRegionRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class PaddleObstaclesQueryDto extends createZodDto(paddleObstaclesQuerySchema) {} +export class PaddleRegionCatalogQueryDto extends createZodDto(paddleRegionCatalogQuerySchema) {} +export class PaddleRegionRequestDto extends createZodDto(paddleRegionRequestSchema) {} diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts new file mode 100644 index 000000000..249d5ce99 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.module.ts @@ -0,0 +1,25 @@ +import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity'; +import { PaddleRegions } from '../../db/entities/PaddleRegions.entity'; +import { AddonsModule } from '../addons/addons.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; +import { GeofabrikClient } from './geofabrik.client'; +import { PaddleObstaclesController, PaddleRegionsController } from './paddle-obstacles.controller'; +import { PaddleObstaclesService } from './paddle-obstacles.service'; +import { PaddleRegionsJob } from './paddle-regions.job'; +import { PaddleRegionsService } from './paddle-regions.service'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; + +/** + * PAD-05A: paddle obstacles from OpenStreetMap. The regions an admin chose, + * their background reads from Geofabrik's extracts, and the objects of a map + * area for the planner. PaddleObstaclesService is exported for the tours + * domain's route checks (PAD-06, PAD-09). + */ +@Module({ + imports: [AddonsModule, SchedulingModule, MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures])], + controllers: [PaddleObstaclesController, PaddleRegionsController], + providers: [GeofabrikClient, PaddleObstaclesService, PaddleRegionsService, PaddleRegionsJob], + exports: [PaddleObstaclesService], +}) +export class PaddleObstaclesModule {} diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts new file mode 100644 index 000000000..0f1baaaf1 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts @@ -0,0 +1,86 @@ +import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity'; +import { PaddleRegions } from '../../db/entities/PaddleRegions.entity'; +import type { Box, PaddleFeatureRow, PaddleFeaturesRepository } from '../../db/repositories/PaddleFeatures.repository'; +import type { PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository'; +import { logWarn } from '../audit/audit-log.logger'; +import { badRequest } from '../common/domain-error'; +import { coverageOf, parseBox, regionBoundarySchema, type RegionBoundary } from './paddle-geo'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + PADDLE_OBSTACLES_CAP, + paddleFeatureSchema, + type PaddleFeature, + type PaddleObstaclesResponse, +} from '@trek/shared'; + +export const PADDLE_BOX_INVALID_MESSAGE = 'bbox must be south,west,north,east and at most one degree a side'; + +function featureOf(row: PaddleFeatureRow): PaddleFeature | null { + try { + const parsed = paddleFeatureSchema.safeParse({ + ref: row.osm_ref, + kind: row.kind, + group_key: row.group_key, + name: row.name, + geometry: JSON.parse(row.geometry), + tags: JSON.parse(row.tags), + tag_hash: row.tag_hash, + region_id: row.region_id, + }); + if (parsed.success) return parsed.data; + } catch { + // Unreadable JSON falls through to the log line below. + } + logWarn(`[Paddle] stored object ${row.region_id}/${row.osm_ref} does not read; left out`); + return null; +} + +/** + * PAD-05A, the reading side: the paddle objects inside a map area and how far + * TREK's data covers it. An area outside every region answers `none`, not an + * empty list read as "no obstacles"; one cut at the cap says so. + */ +@Injectable() +export class PaddleObstaclesService { + constructor( + @InjectRepository(PaddleFeatures) private readonly features: PaddleFeaturesRepository, + @InjectRepository(PaddleRegions) private readonly regions: PaddleRegionsRepository, + ) {} + + async inBox(bbox: string): Promise { + const box = parseBox(bbox); + if (!box) throw badRequest(PADDLE_BOX_INVALID_MESSAGE); + return await this.read(box); + } + + /** The same answer for a box already known to be valid, for the server's own route checks. */ + async read(box: Box): Promise { + const [rows, regions] = await Promise.all([ + this.features.findInBox(box, PADDLE_OBSTACLES_CAP + 1), + this.regions.readyInBox(box), + ]); + const boundaries = regions.map((region): RegionBoundary | null => { + if (!region.boundary) return null; + try { + const parsed = regionBoundarySchema.safeParse(JSON.parse(region.boundary)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } + }); + return { + features: rows.slice(0, PADDLE_OBSTACLES_CAP).flatMap((row) => featureOf(row) ?? []), + coverage: { + state: coverageOf(box, boundaries), + regions: regions.map((region) => ({ + id: region.id, + name: region.name, + source_timestamp: region.source_timestamp, + imported_at: region.imported_at === null ? null : `${region.imported_at.replace(' ', 'T')}Z`, + })), + }, + truncated: rows.length > PADDLE_OBSTACLES_CAP, + }; + } +} diff --git a/server/src/nest/paddle-obstacles/paddle-regions.job.ts b/server/src/nest/paddle-obstacles/paddle-regions.job.ts new file mode 100644 index 000000000..6b0462d26 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-regions.job.ts @@ -0,0 +1,33 @@ +import { logError, logInfo } from '../audit/audit-log.logger'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { PaddleRegionsService } from './paddle-regions.service'; +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; + +/** + * PAD-05A: paddle data stays current. On boot a read a restart cut short is + * queued again; on the 3rd of every month at 04:20 every region with data is + * read again from Geofabrik's newest extract (they are rebuilt daily). The + * reads run one after another in the background. + */ +@Injectable() +export class PaddleRegionsJob implements OnApplicationBootstrap { + constructor( + private readonly regions: PaddleRegionsService, + private readonly registrar: CronRegistrarService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + void this.registrar.runOnBoot('paddle-regions-resume', () => this.regions.resumeAfterRestart()); + this.registrar.register('paddle-regions-refresh', '20 4 3 * *', () => this.tick()); + } + + async tick(): Promise { + try { + const queued = await this.regions.queueRefresh(); + if (queued > 0) logInfo(`[Paddle] monthly refresh: ${queued} region(s) queued`); + } catch (err) { + logError(`[Paddle] monthly refresh: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/server/src/nest/paddle-obstacles/paddle-regions.service.ts b/server/src/nest/paddle-obstacles/paddle-regions.service.ts new file mode 100644 index 000000000..b554f99da --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-regions.service.ts @@ -0,0 +1,196 @@ +import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity'; +import { PaddleRegions } from '../../db/entities/PaddleRegions.entity'; +import type { PaddleFeatureRow, PaddleFeaturesRepository } from '../../db/repositories/PaddleFeatures.repository'; +import type { PaddleRegionRow, PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository'; +import { dbNow } from '../../db/types'; +import { DataPathsService } from '../app-config/data-paths.service'; +import { logError, logInfo } from '../audit/audit-log.logger'; +import { DomainError } from '../common/domain-error'; +import { withRequestContext } from '../database/request-context'; +import { UnitOfWork } from '../database/unit-of-work'; +import { GeofabrikClient } from './geofabrik.client'; +import { extractPaddleFeatures, type ExtractedFeature } from './paddle-extract'; +import { boxOfBoundary } from './paddle-geo'; +import { MikroORM } from '@mikro-orm/core'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; + +import { mkdir, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +const CATALOG_LIMIT = 25; + +export const REGION_UNKNOWN_MESSAGE = 'Geofabrik has no extract by that id'; +export const CATALOG_UNAVAILABLE_MESSAGE = 'The list of regions cannot be fetched right now'; + +function toRegion(row: PaddleRegionRow): PaddleRegion { + return { + id: row.id, + name: row.name, + status: row.status, + source_timestamp: row.source_timestamp, + imported_at: row.imported_at === null ? null : `${row.imported_at.replace(' ', 'T')}Z`, + feature_count: row.feature_count, + error: row.error, + }; +} + +function toRow(feature: ExtractedFeature): PaddleFeatureRow { + return { + region_id: '', + osm_ref: feature.osm_ref, + kind: feature.kind, + group_key: feature.group_key, + name: feature.name, + geometry: JSON.stringify(feature.geometry), + tags: JSON.stringify(feature.tags), + tag_hash: feature.tag_hash, + min_lat: feature.min_lat, + min_lng: feature.min_lng, + max_lat: feature.max_lat, + max_lng: feature.max_lng, + }; +} + +/** + * PAD-05A: the regions TREK holds paddle data for. An admin picks a region + * from Geofabrik's list; TREK downloads its extract into the data folder's + * temporary space, reads the paddle objects out of it, replaces the region's + * rows in one transaction and deletes the file. Regions are read one at a + * time, in the background, never on a request's path; a failed read keeps the + * region's previous data and says why. + */ +@Injectable() +export class PaddleRegionsService { + private running: Promise | null = null; + + constructor( + private readonly geofabrik: GeofabrikClient, + private readonly paths: DataPathsService, + private readonly uow: UnitOfWork, + private readonly orm: MikroORM, + @InjectRepository(PaddleRegions) private readonly regions: PaddleRegionsRepository, + @InjectRepository(PaddleFeatures) private readonly features: PaddleFeaturesRepository, + ) {} + + async list(): Promise { + return (await this.regions.list()).map(toRegion); + } + + /** Regions of Geofabrik's list whose name or id matches, with the regions they lie in. */ + async catalog(query: string): Promise { + let all: Awaited>; + try { + all = await this.geofabrik.regions(); + } catch (err) { + logError(`[Paddle] Geofabrik index: ${err instanceof Error ? err.message : String(err)}`); + throw new DomainError(502, CATALOG_UNAVAILABLE_MESSAGE, { code: 'catalog_unavailable', details: { code: 'catalog_unavailable' } }); + } + const needle = query.toLowerCase(); + const path = (id: string | null): string[] => { + const names: string[] = []; + for (let at = id; at; at = all.get(at)?.parent ?? null) names.unshift(all.get(at)?.name ?? at); + return names; + }; + return [...all.values()] + .filter((region) => region.name.toLowerCase().includes(needle) || region.id.includes(needle)) + .sort((a, b) => Number(!a.name.toLowerCase().startsWith(needle)) - Number(!b.name.toLowerCase().startsWith(needle))) + .slice(0, CATALOG_LIMIT) + .map((region) => ({ id: region.id, name: region.name, path: path(region.parent) })); + } + + /** Adds a region and queues its first read; a region already there is queued again. */ + async add(id: string): Promise { + const region = (await this.geofabrik.regions().catch(() => new Map())).get(id); + if (!region) throw new DomainError(400, REGION_UNKNOWN_MESSAGE, { code: 'unknown_region', details: { code: 'unknown_region' } }); + const box = region.boundary ? boxOfBoundary(region.boundary) : null; + await this.regions.insertRegion({ + id, + name: region.name, + boundary: region.boundary ? JSON.stringify(region.boundary) : null, + min_lat: box?.south ?? null, + min_lng: box?.west ?? null, + max_lat: box?.north ?? null, + max_lng: box?.east ?? null, + }); + await this.regions.updateRegion(id, { status: 'queued', error: null }); + this.kick(); + const row = await this.regions.findById(id); + if (!row) throw new Error('Added region could not be read back'); + return toRegion(row); + } + + async refresh(id: string): Promise { + const row = await this.regions.findById(id); + if (!row) throw new DomainError(404, 'Region not found'); + await this.regions.updateRegion(id, { status: 'queued', error: null }); + this.kick(); + return toRegion({ ...row, status: 'queued', error: null }); + } + + async remove(id: string): Promise { + if (!(await this.regions.deleteRegion(id))) throw new DomainError(404, 'Region not found'); + } + + /** Every region with data queued again, for the monthly refresh; the reads follow in the background. */ + async queueRefresh(): Promise { + const queued = await this.regions.queueReady(); + if (queued > 0) this.kick(); + return queued; + } + + /** A restart left a read unfinished: it goes back to the queue, and the queue starts. */ + async resumeAfterRestart(): Promise { + await this.regions.requeueInterrupted(); + this.kick(); + } + + /** Starts the background reads unless they already run; the promise is for tests to wait on. */ + kick(): Promise { + if (!this.running) { + this.running = withRequestContext(this.orm, () => this.drain()).finally(() => { + this.running = null; + }); + } + return this.running; + } + + private async drain(): Promise { + for (let next = await this.regions.nextQueued(); next; next = await this.regions.nextQueued()) { + await this.importRegion(next); + } + } + + /** @txIndependent the download and the read sit between the status writes; only the swap of rows is one transaction. */ + private async importRegion(region: PaddleRegionRow): Promise { + await this.regions.updateRegion(region.id, { status: 'importing', error: null }); + const dir = join(this.paths.tmpDir, 'paddle'); + const file = join(dir, `${region.id}-${Date.now()}.osm.pbf`); + try { + const known = (await this.geofabrik.regions()).get(region.id); + if (!known) throw new Error(REGION_UNKNOWN_MESSAGE); + await mkdir(dir, { recursive: true }); + const started = Date.now(); + await this.geofabrik.download(known.pbfUrl, file); + const { timestamp, features } = await extractPaddleFeatures(file); + await this.uow.transactional(async () => { + await this.features.replaceRegion(region.id, features.map(toRow)); + await this.regions.updateRegion(region.id, { + status: 'ready', + feature_count: features.length, + source_timestamp: timestamp === null ? null : new Date(timestamp * 1000).toISOString(), + imported_at: dbNow(), + error: null, + }); + }); + logInfo(`[Paddle] ${region.id}: ${features.length} objects read in ${Math.round((Date.now() - started) / 1000)} s`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logError(`[Paddle] ${region.id}: ${message}`); + await this.regions.updateRegion(region.id, { status: 'failed', error: message.slice(0, 500) }); + } finally { + await unlink(file).catch(() => undefined); + } + } +} diff --git a/server/src/nest/paddle-obstacles/paddle-tags.ts b/server/src/nest/paddle-obstacles/paddle-tags.ts new file mode 100644 index 000000000..b928fc283 --- /dev/null +++ b/server/src/nest/paddle-obstacles/paddle-tags.ts @@ -0,0 +1,86 @@ +import type { PaddleObstacleKind } from '@trek/shared'; + +/** + * PAD-05A: which OpenStreetMap objects are paddle obstacles, places to get + * in and out, portage ways or access rules, and which of their tags are kept. + * The tag variants read are listed here and nowhere else: + * + * - waterway=weir, dam, lock_gate, waterfall; lock=yes on a waterway (a lock chamber) + * - rapids: whitewater=rapid, waterway=rapids + * - hazard: whitewater=hazard, and hazard=* or obstacle=* on a waterway + * - put-in and egress: canoe=put_in / egress, whitewater=put_in / egress / put_in;egress + * (the older whitewater scheme), leisure=slipway as a slipway + * - portage: canoe=portage, portage=yes, whitewater=portage_way + * - access: a waterway (river, canal, stream) whose canoe, boat or their + * conditional variants say who may paddle it + * + * Relations (a multipolygon lock basin) are not read; their member ways are. + */ + +const WATERWAYS = new Set(['river', 'canal', 'stream', 'fairway', 'tidal_channel']); +const PUT_IN = new Set(['put_in', 'put_in;egress', 'egress;put_in']); + +/** The kind an object is, or null when it is none of PAD-05A's. */ +export function paddleKindOf(tags: Readonly>): PaddleObstacleKind | null { + const waterway = tags.waterway; + const whitewater = tags.whitewater; + const canoe = tags.canoe; + if (waterway === 'weir') return 'weir'; + if (waterway === 'dam') return 'dam'; + if (waterway === 'lock_gate') return 'lock_gate'; + if (waterway === 'waterfall') return 'waterfall'; + if (tags.lock === 'yes' && (waterway === undefined || WATERWAYS.has(waterway))) return 'lock'; + if (whitewater === 'rapid' || waterway === 'rapids') return 'rapids'; + if (whitewater === 'hazard') return 'hazard'; + if (canoe === 'portage' || tags.portage === 'yes' || whitewater === 'portage_way') return 'portage'; + if (canoe === 'put_in' || (whitewater !== undefined && PUT_IN.has(whitewater))) return 'put_in'; + if (canoe === 'egress' || whitewater === 'egress') return 'egress'; + if (tags.leisure === 'slipway') return 'slipway'; + if (waterway !== undefined && WATERWAYS.has(waterway)) { + if (tags.hazard !== undefined || tags.obstacle !== undefined) return 'hazard'; + const ruled = ['canoe', 'boat', 'canoe:conditional', 'boat:conditional'].some((key) => tags[key] !== undefined); + if (ruled) return 'access'; + } + return null; +} + +const KEPT = new Set([ + 'name', + 'lock_name', + 'ref', + 'waterway', + 'lock', + 'whitewater', + 'canoe', + 'boat', + 'access', + 'portage', + 'hazard', + 'obstacle', + 'leisure', + 'height', + 'width', + 'operator', + 'opening_hours', + 'seasonal', + 'description', + 'note', + 'fee', + 'website', + 'motorboat', +]); +const KEPT_PREFIXES = ['canoe:', 'boat:', 'whitewater:', 'portage:', 'lock:', 'access:']; + +/** The tags an object is judged and shown by; everything else of OSM's is left behind. */ +export function keptTags(tags: Readonly>): Record { + const kept: Record = {}; + for (const [key, value] of Object.entries(tags)) { + if (KEPT.has(key) || KEPT_PREFIXES.some((prefix) => key.startsWith(prefix))) kept[key] = value; + } + return kept; +} + +/** The name a paddler knows it by: its own, else the lock's. */ +export function paddleNameOf(tags: Readonly>): string | null { + return tags.name ?? tags.lock_name ?? null; +} diff --git a/server/src/nest/tours/dto/tour-paddle-plan.dto.ts b/server/src/nest/tours/dto/tour-paddle-plan.dto.ts new file mode 100644 index 000000000..ec7f25c92 --- /dev/null +++ b/server/src/nest/tours/dto/tour-paddle-plan.dto.ts @@ -0,0 +1,5 @@ +import { tourPaddlePlanRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class TourPaddlePlanDto extends createZodDto(tourPaddlePlanRequestSchema) {} diff --git a/server/src/nest/tours/tour-paddle-plan.service.ts b/server/src/nest/tours/tour-paddle-plan.service.ts new file mode 100644 index 000000000..3873553a1 --- /dev/null +++ b/server/src/nest/tours/tour-paddle-plan.service.ts @@ -0,0 +1,110 @@ +import { Places } from '../../db/entities/Places.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import { TOUR_TRACK_MARKS } from '../../db/json-columns'; +import type { PlacesRepository } from '../../db/repositories/Places.repository'; +import type { ToursRepository } from '../../db/repositories/Tours.repository'; +import { encodeJson } from '../../utils/json-column'; +import { DomainError } from '../common/domain-error'; +import { toRowId } from '../common/row-id'; +import { UnitOfWork } from '../database/unit-of-work'; +import { PlacesService } from '../places/places.service'; +import { computeTourMetrics, parseRouteGeometry } from './tours.helpers'; +import { ToursService } from './tours.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + estimatePaddleTime, + paddleLayoutOfTrack, + paddleMovingSeconds, + recommendedPaddleSpeedKmh, + type PaddleBoatType, + type TourDetailResponse, + type TourPaddlePlanRequest, +} from '@trek/shared'; + +/** A mark may sit this far past the end of the track it was laid on, for rounding along the line. */ +const TRACK_END_TOLERANCE_M = 1; + +/** + * PAD-03 and PAD-07 for a recorded track: a GPX import keeps the line it was + * recorded with, so its portages and locks are laid onto it as distances + * rather than drawn into it, and nothing redraws the track. A planned tour + * carries the same plan in its legs and control points and saves it with its + * route; this service is for the tracks only. + * + * The moving time the plan makes is stored as the tour's duration, reckoned + * with the one estimate the planner and the detail view show. + */ +@Injectable() +export class TourPaddlePlanService { + private readonly logger = new Logger(TourPaddlePlanService.name); + + constructor( + private readonly uow: UnitOfWork, + private readonly places: PlacesService, + private readonly tours: ToursService, + @InjectRepository(Tours) private readonly toursRepo: ToursRepository, + @InjectRepository(Places) private readonly placesRepo: PlacesRepository, + ) {} + + async update( + tripId: string, + placeId: string, + input: TourPaddlePlanRequest, + socketId?: string, + ): Promise { + const tid = toRowId(tripId); + const pid = toRowId(placeId); + if (tid === null || pid === null) throw new NotFoundException('Tour not found'); + + await this.uow.transactional(async () => { + const row = await this.toursRepo.findInTrip(tid, pid); + if (!row) throw new NotFoundException('Tour not found'); + if (row.tour_type !== 'kayak') { + throw new DomainError(400, 'Only a paddle tour has a paddle plan', { + code: 'not_a_paddle_tour', + details: { code: 'not_a_paddle_tour' }, + }); + } + if (row.has_waypoints) { + throw new DomainError(400, 'A planned tour saves its portages and locks with its route', { + code: 'planned_tour', + details: { code: 'planned_tour' }, + }); + } + const place = await this.placesRepo.findInTrip(pid, tid); + const totalM = computeTourMetrics(parseRouteGeometry(place?.route_geometry)).distanceKm * 1000; + const marks = input.track_marks; + const beyond = [...marks.portages.map((mark) => mark.to_m), ...marks.locks.map((lock) => lock.at_m)].some( + (at) => at > totalM + TRACK_END_TOLERANCE_M, + ); + if (beyond) + throw new DomainError(400, 'A mark lies beyond the end of the track', { + code: 'mark_beyond_track', + details: { code: 'mark_beyond_track' }, + }); + + const estimate = estimatePaddleTime(paddleLayoutOfTrack(totalM, marks), { + paddleSpeedKmh: input.paddle_speed_kmh ?? recommendedPaddleSpeedKmh(row.boat_type as PaddleBoatType | null), + portageSpeedKmh: input.portage_speed_kmh, + breakMinutes: null, + }); + const moving = paddleMovingSeconds(estimate); + const empty = marks.portages.length === 0 && marks.locks.length === 0; + await this.toursRepo.updatePaddlePlanInTrip(tid, pid, { + paddle_speed_kmh: input.paddle_speed_kmh, + portage_speed_kmh: input.portage_speed_kmh, + track_marks: empty ? null : encodeJson(TOUR_TRACK_MARKS, marks), + duration: moving === null ? null : Math.round(moving / 60), + }); + }); + + const result = await this.tours.getTour(tripId, placeId); + try { + this.places.broadcast(tripId, 'tours:changed', { placeIds: [pid] }, socketId); + } catch { + this.logger.warn(`Committed paddle plan of tour ${placeId}: realtime invalidation failed`); + } + return result; + } +} diff --git a/server/src/nest/tours/tours.controller.ts b/server/src/nest/tours/tours.controller.ts index 1000247f8..71f0a6dc4 100644 --- a/server/src/nest/tours/tours.controller.ts +++ b/server/src/nest/tours/tours.controller.ts @@ -5,6 +5,8 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { ResponseContract } from '../common/response-contract'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { TourCopyDto, TourCreateDto } from './dto/tour-create.dto'; +import { TourPaddlePlanDto } from './dto/tour-paddle-plan.dto'; +import { TourPaddlePlanService } from './tour-paddle-plan.service'; import { ToursService } from './tours.service'; import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common'; import { tourCreateResponseSchema, tourDetailResponseSchema } from '@trek/shared'; @@ -20,7 +22,10 @@ import { tourCreateResponseSchema, tourDetailResponseSchema } from '@trek/shared @UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) @RequireAddon(ADDON_IDS.TOURS, 'Tours') export class ToursController { - constructor(private readonly tours: ToursService) {} + constructor( + private readonly tours: ToursService, + private readonly paddlePlans: TourPaddlePlanService, + ) {} @Get() async list(@Param('tripId') tripId: string) { @@ -66,6 +71,22 @@ export class ToursController { return await this.tours.copyTour(tripId, placeId, body.name, socketId); } + /** + * PAD-03/PAD-07: the portages, locks and speeds of a recorded paddle track, + * laid onto it without redrawing it. A planned tour saves these with its route. + */ + @RequirePermission('place_edit') + @Put(':placeId/paddle-plan') + @ResponseContract(tourDetailResponseSchema) + async updatePaddlePlan( + @Param('tripId') tripId: string, + @Param('placeId') placeId: string, + @Body() body: TourPaddlePlanDto, + @Headers('x-socket-id') socketId?: string, + ) { + return await this.paddlePlans.update(tripId, placeId, body, socketId); + } + /** Takes an import planned over in place back to its recorded track; the planning is dropped. */ @RequirePermission('place_edit') @Post(':placeId/restore-recorded') diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 94397f81f..5caaed108 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -9,6 +9,7 @@ import { PlaceImportModule } from '../place-import/place-import.module'; import { PlacesModule } from '../places/places.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { WandererModule } from '../wanderer/wanderer.module'; +import { TourPaddlePlanService } from './tour-paddle-plan.service'; import { ToursImportController } from './tours-import.controller'; import { ToursWandererController } from './tours-wanderer.controller'; import { ToursWandererService } from './tours-wanderer.service'; @@ -38,6 +39,6 @@ import { Module } from '@nestjs/common'; MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, Places]), ], controllers: [ToursController, ToursImportController, ToursWandererController], - providers: [ToursService, ToursWandererService, ToursMcp], + providers: [ToursService, TourPaddlePlanService, ToursWandererService, ToursMcp], }) export class ToursModule {} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 7d58d6aed..dc23bcc20 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -12,6 +12,8 @@ import type { ToursRepository, TourUpdate, } from '../../db/repositories/Tours.repository'; +import { TOUR_TRACK_MARKS } from '../../db/json-columns'; +import { decodeJson } from '../../utils/json-column'; import { DomainError } from '../common/domain-error'; import { toRowId } from '../common/row-id'; import { UnitOfWork } from '../database/unit-of-work'; @@ -95,6 +97,8 @@ export class ToursService { boat_type: r.boat_type as PaddleBoatType | null, water_context: r.water_context as PaddleWaterContext | null, paddle_speed_kmh: r.paddle_speed_kmh, + portage_speed_kmh: r.portage_speed_kmh, + track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'), has_recorded_track: Boolean(r.has_recorded_track), planned: Boolean(r.planned), caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD, @@ -125,6 +129,7 @@ export class ToursService { boat_type: input.boat_type ?? null, water_context: input.water_context ?? null, paddle_speed_kmh: input.paddle_speed_kmh ?? null, + portage_speed_kmh: input.portage_speed_kmh ?? null, }; } @@ -336,6 +341,7 @@ export class ToursService { boat_type: row.boat_type as PaddleBoatType | null, water_context: row.water_context as PaddleWaterContext | null, paddle_speed_kmh: row.paddle_speed_kmh, + portage_speed_kmh: row.portage_speed_kmh, }); await this.waypointsRepo.deleteForPlace(pid); await this.toursRepo.clearRecordedTrack(pid); @@ -397,6 +403,8 @@ export class ToursService { boat_type: source.boat_type as PaddleBoatType | null, water_context: source.water_context as PaddleWaterContext | null, paddle_speed_kmh: source.paddle_speed_kmh, + portage_speed_kmh: source.portage_speed_kmh, + track_marks: source.track_marks, wanderer_ref: source.wanderer_ref, difficulty: source.difficulty, derived_from_place_id: pid, @@ -544,6 +552,8 @@ export class ToursService { max_hiking_difficulty: null, derived_from_place_id: null, origin: 'gpx', + portage_speed_kmh: null, + track_marks: null, has_recorded_track: 0, planned: 0, has_waypoints: 0, diff --git a/server/src/nest/weather/dto/wind-query.dto.ts b/server/src/nest/weather/dto/wind-query.dto.ts new file mode 100644 index 000000000..bba4b72e2 --- /dev/null +++ b/server/src/nest/weather/dto/wind-query.dto.ts @@ -0,0 +1,5 @@ +import { windQuerySchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class WindQueryDto extends createZodDto(windQuerySchema) {} diff --git a/server/src/nest/weather/weather.controller.ts b/server/src/nest/weather/weather.controller.ts index f26dcfd86..4e130ed08 100644 --- a/server/src/nest/weather/weather.controller.ts +++ b/server/src/nest/weather/weather.controller.ts @@ -1,8 +1,11 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { ApiError } from './weather.impl'; import { WeatherService } from './weather.service'; +import { WindQueryDto } from './dto/wind-query.dto'; +import { WindService } from './wind.service'; +import { ResponseContract } from '../common/response-contract'; import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common'; -import type { WeatherResult } from '@trek/shared'; +import { windForecastResponseSchema, type WeatherResult, type WindForecastResponse } from '@trek/shared'; /** * /api/weather — first migrated leaf module (the pilot). @@ -19,7 +22,21 @@ import type { WeatherResult } from '@trek/shared'; @Controller('api/weather') @UseGuards(JwtAuthGuard) export class WeatherController { - constructor(private readonly weather: WeatherService) {} + constructor( + private readonly weather: WeatherService, + private readonly wind: WindService, + ) {} + + /** + * PAD-08: the wind forecast for one place and day, with gusts and direction. + * Forecast only: a past day or one beyond the forecast's reach answers with + * its status and no figures, and a failed request is a 502 of its own. + */ + @Get('wind') + @ResponseContract(windForecastResponseSchema) + async getWind(@Query() query: WindQueryDto): Promise { + return await this.wind.forecast(query); + } @Get() async getWeather( diff --git a/server/src/nest/weather/weather.module.ts b/server/src/nest/weather/weather.module.ts index a59cdf958..f926dffdc 100644 --- a/server/src/nest/weather/weather.module.ts +++ b/server/src/nest/weather/weather.module.ts @@ -2,11 +2,12 @@ import { WeatherController } from './weather.controller'; import { WeatherMcp } from './weather.mcp'; import { WeatherRpc } from './weather.rpc'; import { WeatherService } from './weather.service'; +import { WindService } from './wind.service'; import { Module } from '@nestjs/common'; /** Weather domain (pilot leaf module). Registered in AppModule. */ @Module({ controllers: [WeatherController], - providers: [WeatherService, WeatherMcp, WeatherRpc], + providers: [WeatherService, WindService, WeatherMcp, WeatherRpc], }) export class WeatherModule {} diff --git a/server/src/nest/weather/wind.service.ts b/server/src/nest/weather/wind.service.ts new file mode 100644 index 000000000..dabb59bb9 --- /dev/null +++ b/server/src/nest/weather/wind.service.ts @@ -0,0 +1,145 @@ +import { readCappedJson } from '../../utils/cappedFetch'; +import { logWarn } from '../audit/audit-log.logger'; +import { DomainError } from '../common/domain-error'; +import { Injectable } from '@nestjs/common'; +import { WIND_FORECAST_DAYS, type WindForecastResponse, type WindForecastStatus, type WindQuery } from '@trek/shared'; +import { z } from 'zod'; + +/** Open-Meteo on the request path: a deadline and a size cap, as the weather calls have. */ +const WIND_TIMEOUT_MS = 8000; +const MAX_WIND_BYTES = 256 * 1024; +/** A forecast is rerun every few hours; half an hour keeps it fresh without asking for every pan. */ +const CACHE_TTL_MS = 30 * 60 * 1000; +const CACHE_MAX_ENTRIES = 500; +const DAY_MS = 24 * 60 * 60 * 1000; + +const series = z.array(z.number().nullable()); +/** The part of Open-Meteo's answer the wind is read from; anything else in it is ignored. */ +const openMeteoWindSchema = z.object({ + timezone: z.string(), + hourly: z.object({ + time: z.array(z.string()), + wind_speed_10m: series, + wind_gusts_10m: series.optional(), + wind_direction_10m: series.optional(), + }), + daily: z.object({ + wind_speed_10m_max: series, + wind_gusts_10m_max: series.optional(), + wind_direction_10m_dominant: series.optional(), + }), +}); + +export const WIND_UNAVAILABLE_MESSAGE = 'The wind forecast is not available right now'; + +/** Rounded to a whole km/h or degree; a missing value stays missing. */ +function whole(value: number | null | undefined): number | null { + return value === null || value === undefined || !Number.isFinite(value) ? null : Math.round(value); +} + +/** + * PAD-08: the wind for a place on one day, from the Open-Meteo forecast TREK's + * weather already uses. Only the forecast is asked: a day in the past or + * beyond the forecast's reach answers with its status and no figures, so a + * climate value is never passed off as the day's wind. A failed request is a + * 502 the client can tell from "no forecast yet", never an empty answer. + */ +@Injectable() +export class WindService { + private readonly cache = new Map(); + private readonly pending = new Map>(); + + async forecast(query: WindQuery, now: number = Date.now()): Promise { + const status = statusOf(query.date, now); + if (status !== 'forecast') return empty(query, status, now); + const key = `${query.lat.toFixed(2)}_${query.lng.toFixed(2)}_${query.date}`; + const hit = this.cache.get(key); + if (hit && hit.expiresAt > now) return hit.value; + const running = this.pending.get(key); + if (running) return running; + const request = this.fetchDay(query, now) + .then((value) => { + this.remember(key, value, now); + return value; + }) + .finally(() => this.pending.delete(key)); + this.pending.set(key, request); + return request; + } + + private remember(key: string, value: WindForecastResponse, now: number): void { + if (this.cache.size >= CACHE_MAX_ENTRIES) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) this.cache.delete(oldest); + } + this.cache.set(key, { value, expiresAt: now + CACHE_TTL_MS }); + } + + private async fetchDay(query: WindQuery, now: number): Promise { + const url = + `https://api.open-meteo.com/v1/forecast?latitude=${query.lat}&longitude=${query.lng}` + + '&hourly=wind_speed_10m,wind_gusts_10m,wind_direction_10m' + + '&daily=wind_speed_10m_max,wind_gusts_10m_max,wind_direction_10m_dominant' + + `&timezone=auto&wind_speed_unit=kmh&start_date=${query.date}&end_date=${query.date}`; + let parsed: z.infer; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(WIND_TIMEOUT_MS) }); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new Error(`Open-Meteo ${response.status}`); + } + parsed = openMeteoWindSchema.parse(await readCappedJson(response, MAX_WIND_BYTES)); + } catch (err) { + logWarn(`[Wind] forecast for ${query.date} failed: ${err instanceof Error ? err.message : String(err)}`); + throw new DomainError(502, WIND_UNAVAILABLE_MESSAGE, { + code: 'wind_unavailable', + details: { code: 'wind_unavailable' }, + }); + } + const { hourly, daily } = parsed; + return { + status: 'forecast', + date: query.date, + lat: query.lat, + lng: query.lng, + timezone: parsed.timezone, + source: 'Open-Meteo', + fetched_at: new Date(now).toISOString(), + horizon_days: WIND_FORECAST_DAYS, + hours: hourly.time.slice(0, 24).map((time, index) => ({ + hour: Number(time.slice(11, 13)) || index, + speed_kmh: whole(hourly.wind_speed_10m[index]), + gusts_kmh: whole(hourly.wind_gusts_10m?.[index]), + direction_deg: whole(hourly.wind_direction_10m?.[index]), + })), + day: { + speed_max_kmh: whole(daily.wind_speed_10m_max[0]), + gusts_max_kmh: whole(daily.wind_gusts_10m_max?.[0]), + direction_dominant_deg: whole(daily.wind_direction_10m_dominant?.[0]), + }, + }; + } +} + +/** Past, within the forecast's reach, or beyond it, counted in whole days from today (UTC). */ +function statusOf(date: string, now: number): WindForecastStatus { + const today = Date.parse(new Date(now).toISOString().slice(0, 10)); + const days = Math.round((Date.parse(date) - today) / DAY_MS); + if (!Number.isFinite(days) || days < 0) return 'past'; + return days >= WIND_FORECAST_DAYS ? 'beyond_horizon' : 'forecast'; +} + +function empty(query: WindQuery, status: WindForecastStatus, now: number): WindForecastResponse { + return { + status, + date: query.date, + lat: query.lat, + lng: query.lng, + timezone: null, + source: 'Open-Meteo', + fetched_at: new Date(now).toISOString(), + horizon_days: WIND_FORECAST_DAYS, + hours: [], + day: null, + }; +} diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index 16027fcee..1ca4f6cf9 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -638,6 +638,142 @@ describe('Tours e2e (real guards + temp SQLite)', () => { }); }); + describe('portages, locks and the paddle plan (PAD-03, PAD-07)', () => { + const leg = (kind: 'free' | 'portage', from: [number, number], to: [number, number], extra?: number) => ({ + kind, + geometry: [from, to], + ...(extra === undefined ? {} : { extra_minutes: extra }), + }); + const carried = (overrides: Record = {}) => + tourBody({ + tour_type: 'kayak', + max_hiking_difficulty: null, + boat_type: 'canoe', + water_context: 'river', + portage_speed_kmh: 2.5, + route_geometry: GEOMETRY, + waypoints: [ + { lat: 47, lng: 11, role: 'start', sequence: 0, fixed_leg: leg('free', [47, 11], [47.01, 11.01]) }, + { + lat: 47.01, + lng: 11.01, + role: 'via', + sequence: 1, + fixed_leg: leg('portage', [47.01, 11.01], [47.02, 11.02], 15), + lock: { wait_minutes: 20, transit_minutes: null }, + }, + { lat: 47.02, lng: 11.02, role: 'end', sequence: 2 }, + ], + ...overrides, + }); + const paddlePlan = (placeId: number, body: Record, userId = OWNER) => + request(server).put(`/api/trips/5/tours/${placeId}/paddle-plan`).set('Cookie', sessionCookie(userId)).send(body); + const importPaddleTrack = async () => { + const res = await importGpx(OWNER, TRACK_GPX, 5, { + tour_type: 'kayak', + boat_type: 'kayak', + water_context: 'lake', + }); + expect(res.status).toBe(201); + return res.body.tours[0] as { place_id: number; distance: number }; + }; + + it('saves a portage with its time out and in, a lock with its times and the carrying speed, and reads them back', async () => { + const res = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(carried()); + expect(res.status).toBe(201); + expect(res.body.tour).toMatchObject({ portage_speed_kmh: 2.5, track_marks: null }); + expect(res.body.waypoints[1]).toMatchObject({ + fixed_leg: { kind: 'portage', extra_minutes: 15 }, + lock: { wait_minutes: 20, transit_minutes: null }, + }); + expect(res.body.waypoints[0].lock).toBeUndefined(); + const detail = await request(server) + .get(`/api/trips/5/tours/${res.body.tour.place_id}`) + .set('Cookie', sessionCookie(OWNER)); + expect(detail.body).toEqual(res.body); + }); + + it('400 for a portage, a lock or a carrying speed on a hike; nothing written', async () => { + const onHike = carried({ tour_type: 'hike', max_hiking_difficulty: 2, boat_type: null, water_context: null }); + const res = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(onHike); + expect(res.status).toBe(400); + expect(await countRows(orm, Tours)).toBe(0); + }); + + it('lays portages and locks onto a recorded paddle track, works out its moving time and leaves the track as it was', async () => { + const track = await importPaddleTrack(); + const before = await placeColumns(track.place_id, 'route_geometry'); + const totalM = track.distance * 1000; + const res = await paddlePlan(track.place_id, { + paddle_speed_kmh: 4, + portage_speed_kmh: null, + track_marks: { + portages: [{ from_m: 400, to_m: 800, extra_minutes: null }], + locks: [{ at_m: Math.round(totalM - 200), wait_minutes: 15, transit_minutes: 10 }], + }, + }); + expect(res.status).toBe(200); + expect(res.body.tour.track_marks.portages).toEqual([{ from_m: 400, to_m: 800, extra_minutes: null }]); + // Water at 4 km/h, 400 m carried at 2 km/h, 10 minutes out and in, 25 minutes at the lock. + const waterMinutes = ((totalM - 400) / 1000 / 4) * 60; + expect(res.body.tour.duration).toBe(Math.round(waterMinutes + 12 + 10 + 25)); + expect(await placeColumns(track.place_id, 'route_geometry')).toEqual(before); + expect(broadcast.mock.calls.map((c) => c[1])).toContain('tours:changed'); + + const cleared = await paddlePlan(track.place_id, { + paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: { portages: [], locks: [] }, + }); + expect(cleared.body.tour.track_marks).toBeNull(); + }); + + it('refuses a plan for a hike, for a planned tour, a mark past the end of the track and overlapping portages', async () => { + const hike = (await importGpx(OWNER, FLAT_GPX, 5)).body.tours[0].place_id as number; + const planned = await createViaApi(5, carried()); + const track = await importPaddleTrack(); + const none = { portages: [], locks: [] }; + const plan = (marks: Record) => ({ + paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: marks, + }); + const results = await Promise.all([ + paddlePlan(hike, plan(none)), + paddlePlan(planned, plan(none)), + paddlePlan( + track.place_id, + plan({ portages: [], locks: [{ at_m: 999999, wait_minutes: 1, transit_minutes: 1 }] }), + ), + paddlePlan( + track.place_id, + plan({ + portages: [ + { from_m: 100, to_m: 500, extra_minutes: null }, + { from_m: 400, to_m: 900, extra_minutes: null }, + ], + locks: [], + }), + ), + ]); + expect(results.map((res) => res.status)).toEqual([400, 400, 400, 400]); + expect(results[0].body.code).toBe('not_a_paddle_tour'); + expect(results[1].body.code).toBe('planned_tour'); + expect(results[2].body.code).toBe('mark_beyond_track'); + }); + + it('403 without place_edit, 404 for a tour of another trip', async () => { + const track = await importPaddleTrack(); + const body = { paddle_speed_kmh: null, portage_speed_kmh: null, track_marks: { portages: [], locks: [] } }; + expect((await paddlePlan(track.place_id, body, VIEWER)).status).toBe(403); + const elsewhere = await request(server) + .put(`/api/trips/6/tours/${track.place_id}/paddle-plan`) + .set('Cookie', sessionCookie(OWNER)) + .send(body); + expect(elsewhere.status).toBe(404); + }); + }); + describe('GPX preview and typed import (PAD-02)', () => { it('shows every track with its whole line and measures, and stores nothing', async () => { const res = await previewGpx(OWNER, TRACK_GPX); diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts index 2547bfd07..3d1455dfa 100644 --- a/server/tests/unit/db/entities-index.test.ts +++ b/server/tests/unit/db/entities-index.test.ts @@ -36,14 +36,15 @@ describe('ALL_ENTITIES', () => { * + tour_types, tours and tour_waypoints from the Tours migration, * + scheduler_leases from the cron lease migration * + user_sessions, the session rows behind revocable session tokens - * + ferrata_tiles, the stored via ferrata grid cells) + * + ferrata_tiles, the stored via ferrata grid cells + * + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions) * is registered, and no two share a class name (a silent duplicate would * shadow one of them in `orm.getMetadata()`, exactly the kind of drift * `entity-schema-parity.test.ts` can't see — it iterates the metadata map, * which de-duplicates by class name). */ - it('ENT-003: exactly 135 entities, every class name unique', () => { - expect(ALL_ENTITIES.length).toBe(135); + it('ENT-003: exactly 138 entities, every class name unique', () => { + expect(ALL_ENTITIES.length).toBe(138); const names = ALL_ENTITIES.map((s) => s.meta.className); expect(new Set(names).size).toBe(names.length); }); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index bb6c47156..2e807a0c7 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -26,7 +26,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; // + scheduler_leases: name, owner, expires_at (the scheduler always writes all three). // + user_sessions: id, user_id, expires_at. // + ferrata_tiles: tile_key, payload, fetched_at (a fetched tile always carries all three). -const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 455; +// + paddle_regions: id, name; paddle_features: id, region_id, osm_ref, kind, geometry, tags, tag_hash and +// its bounding box (a feature is only ever written whole, from one read of its region). +// + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at. +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 475; describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => { diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 6de801660..f9e421fee 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -51,6 +51,9 @@ const UNNUMBERED = [ 'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track', 'Migration20200101043800_a_tour_records_where_it_came_from', 'Migration20200101043900_a_tour_can_be_paddled', + 'Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks', + 'Migration20200101044100_paddle_obstacles_from_openstreetmap', + 'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/Tours.repository.test.ts b/server/tests/unit/db/repositories/Tours.repository.test.ts index 662df9837..e85e3d276 100644 --- a/server/tests/unit/db/repositories/Tours.repository.test.ts +++ b/server/tests/unit/db/repositories/Tours.repository.test.ts @@ -95,6 +95,8 @@ describe('ToursRepository reads', () => { boat_type: null, water_context: null, paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: null, has_recorded_track: 0, }, { @@ -120,6 +122,8 @@ describe('ToursRepository reads', () => { boat_type: null, water_context: null, paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: null, has_recorded_track: 0, }, ]); @@ -201,12 +205,14 @@ describe('ToursRepository writes', () => { boat_type: null, water_context: null, paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: null, has_recorded_track: 0, }); }); // A route edit always writes the type's own fields; a hike's are empty. - const HIKE_UPDATE = { ...METRICS, boat_type: null, water_context: null, paddle_speed_kmh: null }; + const HIKE_UPDATE = { ...METRICS, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null }; it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => { const { trip, other } = fixture(); @@ -270,6 +276,8 @@ describe('ToursRepository writes', () => { boat_type: null, water_context: null, paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: null, }, ]); expect(await repo.listRowsForTrip(other.id)).toEqual([]); diff --git a/server/tests/unit/nest/tours.controller.test.ts b/server/tests/unit/nest/tours.controller.test.ts index 32b85d537..eba2d60fd 100644 --- a/server/tests/unit/nest/tours.controller.test.ts +++ b/server/tests/unit/nest/tours.controller.test.ts @@ -16,6 +16,7 @@ import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissi import type { PlacesService } from '../../../src/nest/places/places.service'; import { ToursImportController } from '../../../src/nest/tours/tours-import.controller'; import { ToursController } from '../../../src/nest/tours/tours.controller'; +import type { TourPaddlePlanService } from '../../../src/nest/tours/tour-paddle-plan.service'; import type { ToursService } from '../../../src/nest/tours/tours.service'; import type { User } from '../../../src/types'; import { HttpException, RequestMethod } from '@nestjs/common'; @@ -98,6 +99,8 @@ function importFixture( return { addons, places, tours, controller, result }; } +const paddlePlansStub = { update: vi.fn() } as unknown as TourPaddlePlanService; + describe('ToursController planner routes', () => { it('does not access the legacy global database initializer', () => { expect(legacyDatabaseAccess).not.toHaveBeenCalled(); @@ -115,6 +118,7 @@ describe('ToursController planner routes', () => { ['detail', RequestMethod.GET, ':placeId', undefined], ['create', RequestMethod.POST, '/', 'place_edit'], ['update', RequestMethod.PUT, ':placeId', 'place_edit'], + ['updatePaddlePlan', RequestMethod.PUT, ':placeId/paddle-plan', 'place_edit'], ] as const; for (const [handler, method, path, permission] of routes) { const fn = ToursController.prototype[handler]; @@ -129,7 +133,7 @@ describe('ToursController planner routes', () => { it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => { const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService; - const controller = new ToursController(service); + const controller = new ToursController(service, paddlePlansStub); expect(await controller.create('7', request, 'socket-1')).toBe(result); expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1'); @@ -138,7 +142,7 @@ describe('ToursController planner routes', () => { it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => { const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService; - const controller = new ToursController(service); + const controller = new ToursController(service, paddlePlansStub); expect(await controller.detail('7', '1')).toBe(result); expect(service.getTour).toHaveBeenCalledWith('7', '1'); @@ -147,17 +151,27 @@ describe('ToursController planner routes', () => { it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => { const result = { tour: { place_id: 1 }, waypoints: request.waypoints }; const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService; - const controller = new ToursController(service); + const controller = new ToursController(service, paddlePlansStub); expect(await controller.update('7', '1', request, 'socket-2')).toBe(result); expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2'); }); + it('TOURS-CTL-006: PUT /tours/:placeId/paddle-plan delegates the validated plan and socket id (PAD-03)', async () => { + const result = { tour: { place_id: 1 }, waypoints: [] }; + const paddlePlans = { update: vi.fn().mockResolvedValue(result) } as unknown as TourPaddlePlanService; + const controller = new ToursController({} as ToursService, paddlePlans); + const plan = { paddle_speed_kmh: 4, portage_speed_kmh: null, track_marks: { portages: [], locks: [] } }; + + expect(await controller.updatePaddlePlan('7', '1', plan, 'socket-3')).toBe(result); + expect(paddlePlans.update).toHaveBeenCalledWith('7', '1', plan, 'socket-3'); + }); + it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => { const tours = [{ place_id: 1 }]; const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService; - expect(await new ToursController(service).list('7')).toEqual({ tours }); + expect(await new ToursController(service, paddlePlansStub).list('7')).toEqual({ tours }); expect(service.listTours).toHaveBeenCalledWith('7'); }); }); diff --git a/server/tests/unit/nest/weather.controller.test.ts b/server/tests/unit/nest/weather.controller.test.ts index 4f19b4649..cadf448e6 100644 --- a/server/tests/unit/nest/weather.controller.test.ts +++ b/server/tests/unit/nest/weather.controller.test.ts @@ -1,12 +1,13 @@ import { WeatherController } from '../../../src/nest/weather/weather.controller'; import { ApiError } from '../../../src/nest/weather/weather.impl'; import type { WeatherService } from '../../../src/nest/weather/weather.service'; +import type { WindService } from '../../../src/nest/weather/wind.service'; import { HttpException } from '@nestjs/common'; import { describe, it, expect, vi } from 'vitest'; -function makeController(svc: Partial) { - return new WeatherController(svc as WeatherService); +function makeController(svc: Partial, wind: Partial = {}) { + return new WeatherController(svc as WeatherService, wind as WindService); } /** Run `fn`, expecting it to throw an HttpException; return its { status, body }. */ @@ -21,6 +22,16 @@ async function thrown(fn: () => Promise): Promise<{ status: number; bod throw new Error('expected the handler to throw'); } +describe('WeatherController GET /api/weather/wind (PAD-08)', () => { + it('hands the validated query to the wind forecast and answers with it', async () => { + const answer = { status: 'past', hours: [], day: null }; + const forecast = vi.fn().mockResolvedValue(answer); + const c = makeController({}, { forecast }); + expect(await c.getWind({ lat: 47.5, lng: 9.7, date: '2026-10-18' })).toBe(answer); + expect(forecast).toHaveBeenCalledWith({ lat: 47.5, lng: 9.7, date: '2026-10-18' }); + }); +}); + describe('WeatherController (parity with the legacy /api/weather route)', () => { const sample = { temp: 21, main: 'Clear', description: 'Klar', type: 'current' }; diff --git a/server/tests/unit/nest/weather/wind.service.test.ts b/server/tests/unit/nest/weather/wind.service.test.ts new file mode 100644 index 000000000..aa63c0f41 --- /dev/null +++ b/server/tests/unit/nest/weather/wind.service.test.ts @@ -0,0 +1,107 @@ +/** + * PAD-08: the wind forecast for a paddle tour's day. WIND-001..006. + * + * Only a forecast is ever answered: a past day and one beyond the forecast's + * reach say so without figures, a missing value stays null, and a failed + * request is a 502 of its own rather than an empty forecast. + */ +import { DomainError } from '../../../../src/nest/common/domain-error'; +import { WindService } from '../../../../src/nest/weather/wind.service'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ logWarn: vi.fn() })); + +const NOW = Date.parse('2026-10-16T10:00:00Z'); + +function openMeteo(overrides: Record = {}) { + return { + timezone: 'Europe/Berlin', + hourly: { + time: ['2026-10-18T00:00', '2026-10-18T01:00', '2026-10-18T14:00'], + wind_speed_10m: [5.4, null, 18.6], + wind_gusts_10m: [9.1, null, 41.2], + wind_direction_10m: [270, null, 225.4], + }, + daily: { wind_speed_10m_max: [18.6], wind_gusts_10m_max: [41.2], wind_direction_10m_dominant: [240] }, + ...overrides, + }; +} + +function answer(body: unknown, status = 200) { + return vi.fn(async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('WindService', () => { + it('WIND-001: reads mean wind, gusts and direction per hour, and the day by its maxima and dominant direction', async () => { + const fetchSpy = answer(openMeteo()); + vi.stubGlobal('fetch', fetchSpy); + const result = await new WindService().forecast({ lat: 47.55, lng: 9.68, date: '2026-10-18' }, NOW); + expect(result).toMatchObject({ status: 'forecast', timezone: 'Europe/Berlin', source: 'Open-Meteo', horizon_days: 16 }); + expect(result.hours).toEqual([ + { hour: 0, speed_kmh: 5, gusts_kmh: 9, direction_deg: 270 }, + { hour: 1, speed_kmh: null, gusts_kmh: null, direction_deg: null }, + { hour: 14, speed_kmh: 19, gusts_kmh: 41, direction_deg: 225 }, + ]); + expect(result.day).toEqual({ speed_max_kmh: 19, gusts_max_kmh: 41, direction_dominant_deg: 240 }); + const url = String(fetchSpy.mock.calls[0]?.[0 as never]); + expect(url).toContain('start_date=2026-10-18'); + expect(url).not.toContain('archive'); + }); + + it('WIND-002: a past day and a day beyond the forecast are named, with no figures and no request', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + const service = new WindService(); + const past = await service.forecast({ lat: 47, lng: 9, date: '2026-10-15' }, NOW); + const far = await service.forecast({ lat: 47, lng: 9, date: '2026-11-20' }, NOW); + expect([past.status, far.status]).toEqual(['past', 'beyond_horizon']); + expect([past.hours, far.hours, past.day, far.day]).toEqual([[], [], null, null]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('WIND-003: the last day the forecast reaches is still asked', async () => { + vi.stubGlobal('fetch', answer(openMeteo())); + const result = await new WindService().forecast({ lat: 47, lng: 9, date: '2026-10-31' }, NOW); + expect(result.status).toBe('forecast'); + }); + + it('WIND-004: gusts and directions the source leaves out stay unknown, never calm', async () => { + vi.stubGlobal( + 'fetch', + answer( + openMeteo({ + hourly: { time: ['2026-10-18T09:00'], wind_speed_10m: [12] }, + daily: { wind_speed_10m_max: [12] }, + }), + ), + ); + const result = await new WindService().forecast({ lat: 47, lng: 9, date: '2026-10-18' }, NOW); + expect(result.hours).toEqual([{ hour: 9, speed_kmh: 12, gusts_kmh: null, direction_deg: null }]); + expect(result.day).toEqual({ speed_max_kmh: 12, gusts_max_kmh: null, direction_dominant_deg: null }); + }); + + it('WIND-005: a failed or malformed answer is a 502 of its own', async () => { + const service = new WindService(); + vi.stubGlobal('fetch', answer({ reason: 'down' }, 503)); + await expect(service.forecast({ lat: 47, lng: 9, date: '2026-10-18' }, NOW)).rejects.toBeInstanceOf(DomainError); + vi.stubGlobal('fetch', answer({ hourly: 'nonsense' })); + await expect(service.forecast({ lat: 47, lng: 9, date: '2026-10-19' }, NOW)).rejects.toMatchObject({ + code: 'wind_unavailable', + }); + }); + + it('WIND-006: one place and day is asked once while fresh, and once for callers asking together', async () => { + const fetchSpy = answer(openMeteo()); + vi.stubGlobal('fetch', fetchSpy); + const service = new WindService(); + const query = { lat: 47.551, lng: 9.682, date: '2026-10-18' }; + await Promise.all([service.forecast(query, NOW), service.forecast(query, NOW)]); + await service.forecast({ ...query, lat: 47.552 }, NOW + 60_000); + expect(fetchSpy).toHaveBeenCalledTimes(1); + await service.forecast(query, NOW + 31 * 60_000); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index d1cff8e4d..f9d206de8 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -333,6 +333,121 @@ const tours: TranslationStrings = { 'Ein Import ist eine aufgezeichnete Strecke. Plane eine bearbeitbare Paddeltour: TREK nimmt Kontrollpunkte daraus, verbindet sie mit geraden Linien, wie eine Paddeltour gezeichnet wird, und zeigt, wie nah das herankommt. Nichts ändert sich, bis du dich entscheidest.', 'tours.conversion.closePaddle': 'Der Vorschlag folgt der Strecke eng. Seine Punkte sind mit geraden Linien verbunden, daher ist es nicht dieselbe Linie.', + 'tours.planner.moreActions': 'Weitere Aktionen', + 'tours.paddle.role.put_in': 'Einstieg', + 'tours.paddle.role.take_out': 'Ausstieg', + 'tours.paddle.role.re_entry': 'Wiedereinstieg', + 'tours.paddle.role.final_take_out': 'Endausstieg', + 'tours.paddle.portage': 'Umtragen', + 'tours.paddle.portageOn': 'Ab hier umtragen', + 'tours.paddle.portageOff': 'Diese Strecke wieder paddeln', + 'tours.paddle.portageInfo': + 'Hier wird das Boot getragen. Die Strecke zählt als Land, mit Zeit für Aussteigen, Ausladen und Wiedereinsetzen.', + 'tours.paddle.portageExtra': 'Aus und ein', + 'tours.paddle.lock': 'Schleuse', + 'tours.paddle.lockAdd': 'Schleuse an diesem Punkt', + 'tours.paddle.lockRemove': 'Schleuse entfernen', + 'tours.paddle.lockWait': 'Warten', + 'tours.paddle.lockTransit': 'Durchfahrt', + 'tours.paddle.lockBypassed': 'Umtragen, es zählt die Portage', + 'tours.paddle.time.title': 'Zeitschätzung', + 'tours.paddle.time.assumption': 'Planannahmen', + 'tours.paddle.time.assumptionInfo': + 'TREK beginnt mit 4 km/h auf dem Wasser, 2 km/h beim Tragen und 10 Minuten zum Aus- und Einsetzen je Portage. Das sind Startwerte zum Anpassen, keine Norm und keine Messung. Schleusenzeiten sind nur, was du einträgst.', + 'tours.paddle.time.water': 'Auf dem Wasser', + 'tours.paddle.time.carry': 'Tragen', + 'tours.paddle.time.carrySpeed': 'Tragetempo', + 'tours.paddle.time.outIn': 'Aus- und Einsetzen', + 'tours.paddle.time.locks': 'Schleusen', + 'tours.paddle.time.estimate': 'Schätzung', + 'tours.paddle.time.incomplete': 'Unvollständig', + 'tours.paddle.time.plannedOwn': 'Deine geplante Dauer', + 'tours.paddle.time.noSpeed': 'Ohne Paddeltempo bleibt die Zeit auf dem Wasser offen.', + 'tours.paddle.time.locksMissing': '{count} Schleusen haben noch keine Zeiten.', + 'tours.paddle.time.locksMissing.one': 'Eine Schleuse hat noch keine Zeiten.', + 'tours.paddle.startValue': 'Startwert', + 'tours.paddle.timeMissing': 'Zeit fehlt', + 'tours.paddle.time.waterShort': 'Wasser', + 'tours.paddle.portageRemove': 'Portage entfernen', + 'tours.paddle.marks.title': 'Portagen und Schleusen', + 'tours.paddle.marks.markPortage': 'Portage', + 'tours.paddle.marks.markLock': 'Schleuse', + 'tours.paddle.marks.pickLock': 'Klicke auf der Linie, wo die Schleuse liegt.', + 'tours.paddle.marks.pickTakeOut': 'Klicke auf der Linie, wo du aussteigst.', + 'tours.paddle.marks.pickReEntry': 'Jetzt dort, wo du wieder einsetzt.', + 'tours.paddle.marks.overlap': 'Dort ist schon eine Portage markiert. Markiere die neue daneben.', + 'tours.paddle.marks.offLine': 'Das war zu weit von der Linie. Klicke direkt auf die Strecke.', + 'tours.paddle.marks.empty': 'Noch nichts markiert. Die aufgezeichnete Strecke bleibt, wie sie ist.', + 'tours.paddle.marks.at': 'bei {distance}', + 'tours.paddle.marks.saved': 'Portagen und Schleusen gespeichert', + 'tours.paddle.marks.failed': 'Speichern hat nicht geklappt. Bitte versuche es erneut.', + 'tours.paddle.wind.title': 'Wind', + 'tours.paddle.wind.hourInfo': '{hour} Uhr: {speed} km/h, Böen {gusts} km/h, aus {direction}', + 'tours.paddle.wind.hourUnknown': '{hour} Uhr: kein Wert', + 'tours.paddle.wind.error': 'Die Windvorhersage ist gerade nicht abrufbar.', + 'tours.paddle.wind.retry': 'Erneut versuchen', + 'tours.paddle.wind.offline': 'Ohne Verbindung gibt es keine Windvorhersage.', + 'tours.paddle.wind.beyond': 'Für diesen Tag gibt es noch keine Vorhersage. Sie kommt ab {date}.', + 'tours.paddle.wind.past': 'Dieser Tag liegt zurück. Eine Vorhersage gibt es nur für kommende Tage.', + 'tours.paddle.wind.no_day': + 'Ordne die Tour einem Reisetag zu, dann siehst du hier die Windvorhersage für diesen Tag.', + 'tours.paddle.wind.no_date': 'Dieser Reisetag hat kein Datum, deshalb gibt es keine Vorhersage.', + 'tours.paddle.wind.no_place': 'Die Tour hat noch keinen Startpunkt, für den die Vorhersage gelten könnte.', + 'tours.paddle.wind.hintsTitle': 'Was Wind beim Paddeln bedeutet', + 'tours.paddle.wind.dir.N': 'N', + 'tours.paddle.wind.dir.NNE': 'NNO', + 'tours.paddle.wind.dir.NE': 'NO', + 'tours.paddle.wind.dir.ENE': 'ONO', + 'tours.paddle.wind.dir.E': 'O', + 'tours.paddle.wind.dir.ESE': 'OSO', + 'tours.paddle.wind.dir.SE': 'SO', + 'tours.paddle.wind.dir.SSE': 'SSO', + 'tours.paddle.wind.dir.S': 'S', + 'tours.paddle.wind.dir.SSW': 'SSW', + 'tours.paddle.wind.dir.SW': 'SW', + 'tours.paddle.wind.dir.WSW': 'WSW', + 'tours.paddle.wind.dir.W': 'W', + 'tours.paddle.wind.dir.WNW': 'WNW', + 'tours.paddle.wind.dir.NW': 'NW', + 'tours.paddle.wind.dir.NNW': 'NNW', + 'tours.paddle.wind.legendMean': 'Mittelwind', + 'tours.paddle.wind.legendGusts': 'Böen', + 'tours.paddle.wind.mean': 'Mittel', + 'tours.paddle.wind.gusts': 'Böen', + 'tours.paddle.wind.fromInfo': 'Wind aus {direction}, die Richtung, aus der er den Tag über meist weht', + 'tours.paddle.wind.meanInfo': 'Der stärkste stündliche Mittelwind des Tages', + 'tours.paddle.wind.gustsInfo': 'Die stärkste Böe des Tages', + 'tours.paddle.wind.source': '{source}, Stand {time}, Vorhersage am Start', + 'tours.paddle.wind.hint.headwind': 'Gegenwind kostet Zeit und Kraft.', + 'tours.paddle.wind.hint.drift': 'Seitenwind drückt das Boot ab.', + 'tours.paddle.wind.hint.crossing': 'Offene Querungen sind ausgesetzter als die Ufernähe.', + 'tours.paddle.wind.hint.gusts': 'Böen sind stärker als der Mittelwind und kommen plötzlich.', + 'tours.paddle.wind.hint.local': 'Kein Wert ist eine Entwarnung. Prüfe die Lage vor Ort.', + 'tours.paddle.obstacles.loading': 'Hindernisse an der Strecke werden geladen …', + 'tours.paddle.obstacles.covered': + '{count} Hindernisse und Ein- und Ausstiege an der Strecke, aus OpenStreetMap, Stand {date}. Die Daten können Lücken haben, prüfe das Gewässer selbst.', + 'tours.paddle.obstacles.covered.one': + '{count} Hindernis oder Ein- und Ausstieg an der Strecke, aus OpenStreetMap, Stand {date}. Die Daten können Lücken haben, prüfe das Gewässer selbst.', + 'tours.paddle.obstacles.coveredEmpty': + 'Keine bekannten Hindernisse an der Strecke (OpenStreetMap, Stand {date}). Prüfe das Gewässer trotzdem selbst.', + 'tours.paddle.obstacles.partial': + 'Hindernisdaten decken nur einen Teil der Strecke ab (Stand {date}). Den Rest prüfst du selbst.', + 'tours.paddle.obstacles.none': + 'Für dieses Gebiet liegen keine Hindernisdaten vor. Wehre, Schleusen und Umtragestellen prüfst du hier selbst.', + 'tours.paddle.obstacles.offline': 'Ohne Verbindung lädt TREK keine Hindernisdaten. Prüfe das Gewässer selbst.', + 'tours.paddle.obstacles.error': 'Die Hindernisdaten ließen sich nicht laden. Prüfe das Gewässer selbst.', + 'tours.paddle.obstacle.kind.weir': 'Wehr', + 'tours.paddle.obstacle.kind.dam': 'Staudamm', + 'tours.paddle.obstacle.kind.lock': 'Schleuse', + 'tours.paddle.obstacle.kind.lock_gate': 'Schleusentor', + 'tours.paddle.obstacle.kind.waterfall': 'Wasserfall', + 'tours.paddle.obstacle.kind.rapids': 'Stromschnelle', + 'tours.paddle.obstacle.kind.hazard': 'Gefahrenstelle', + 'tours.paddle.obstacle.kind.put_in': 'Einstieg', + 'tours.paddle.obstacle.kind.egress': 'Ausstieg', + 'tours.paddle.obstacle.kind.slipway': 'Slipanlage', + 'tours.paddle.obstacle.kind.portage': 'Umtragestelle', + 'tours.paddle.obstacle.kind.access': 'Befahrungsregel', }; export default tours; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index f0f40fc99..84d9b41bd 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -324,5 +324,118 @@ const tours: TranslationStrings = { 'An import is a recorded track. Plan an editable paddle tour: TREK takes control points from it, joins them with straight lines as a paddle tour is drawn, and shows how close that comes. Nothing changes until you choose.', 'tours.conversion.closePaddle': 'The suggestion follows the track closely. Its points are joined by straight lines, so it is not the same line.', + 'tours.planner.moreActions': 'More actions', + 'tours.paddle.role.put_in': 'Put-in', + 'tours.paddle.role.take_out': 'Take-out', + 'tours.paddle.role.re_entry': 'Re-entry', + 'tours.paddle.role.final_take_out': 'Final take-out', + 'tours.paddle.portage': 'Portage', + 'tours.paddle.portageOn': 'Carry the boat from here', + 'tours.paddle.portageOff': 'Paddle this stretch again', + 'tours.paddle.portageInfo': + 'The boat is carried here. The stretch counts as land, with time for getting out, unloading and putting back in.', + 'tours.paddle.portageExtra': 'Out and in', + 'tours.paddle.lock': 'Lock', + 'tours.paddle.lockAdd': 'Lock at this point', + 'tours.paddle.lockRemove': 'Remove the lock', + 'tours.paddle.lockWait': 'Wait', + 'tours.paddle.lockTransit': 'Passage', + 'tours.paddle.lockBypassed': 'Carried round, the portage counts', + 'tours.paddle.time.title': 'Time estimate', + 'tours.paddle.time.assumption': 'Planning values', + 'tours.paddle.time.assumptionInfo': + 'TREK starts with 4 km/h on the water, 2 km/h carrying and 10 minutes to get out and back in at each portage. These are starting values to adjust, not norms and not measurements. Lock times are only what you enter.', + 'tours.paddle.time.water': 'On the water', + 'tours.paddle.time.carry': 'Carrying', + 'tours.paddle.time.carrySpeed': 'Carrying speed', + 'tours.paddle.time.outIn': 'Out and back in', + 'tours.paddle.time.locks': 'Locks', + 'tours.paddle.time.estimate': 'Estimate', + 'tours.paddle.time.incomplete': 'Incomplete', + 'tours.paddle.time.plannedOwn': 'Your planned total', + 'tours.paddle.time.noSpeed': 'Without a paddling speed the time on the water stays open.', + 'tours.paddle.time.locksMissing': '{count} locks have no times yet.', + 'tours.paddle.time.locksMissing.one': 'One lock has no times yet.', + 'tours.paddle.startValue': 'Starting value', + 'tours.paddle.timeMissing': 'Time missing', + 'tours.paddle.time.waterShort': 'Water', + 'tours.paddle.portageRemove': 'Remove the portage', + 'tours.paddle.marks.title': 'Portages and locks', + 'tours.paddle.marks.markPortage': 'Portage', + 'tours.paddle.marks.markLock': 'Lock', + 'tours.paddle.marks.pickLock': 'Click the line where the lock is.', + 'tours.paddle.marks.pickTakeOut': 'Click the line where you get out.', + 'tours.paddle.marks.pickReEntry': 'Now click where you put back in.', + 'tours.paddle.marks.overlap': 'A portage is already marked there. Mark the new one beside it.', + 'tours.paddle.marks.offLine': 'That was too far from the line. Click right on the track.', + 'tours.paddle.marks.empty': 'Nothing marked yet. The recorded track stays as it is.', + 'tours.paddle.marks.at': 'at {distance}', + 'tours.paddle.marks.saved': 'Portages and locks saved', + 'tours.paddle.marks.failed': 'Saving did not work. Please try again.', + 'tours.paddle.wind.title': 'Wind', + 'tours.paddle.wind.hourInfo': '{hour}:00: {speed} km/h, gusts {gusts} km/h, from {direction}', + 'tours.paddle.wind.hourUnknown': '{hour}:00: no value', + 'tours.paddle.wind.error': 'The wind forecast cannot be fetched right now.', + 'tours.paddle.wind.retry': 'Try again', + 'tours.paddle.wind.offline': 'Without a connection there is no wind forecast.', + 'tours.paddle.wind.beyond': 'No forecast for this day yet. It comes from {date}.', + 'tours.paddle.wind.past': 'This day has passed. A forecast exists only for days to come.', + 'tours.paddle.wind.no_day': 'Put the tour on a day of the trip to see the wind forecast for that day.', + 'tours.paddle.wind.no_date': 'This day of the trip has no date, so there is no forecast for it.', + 'tours.paddle.wind.no_place': 'The tour has no start point yet to ask the forecast for.', + 'tours.paddle.wind.hintsTitle': 'What wind means for paddling', + 'tours.paddle.wind.dir.N': 'N', + 'tours.paddle.wind.dir.NNE': 'NNE', + 'tours.paddle.wind.dir.NE': 'NE', + 'tours.paddle.wind.dir.ENE': 'ENE', + 'tours.paddle.wind.dir.E': 'E', + 'tours.paddle.wind.dir.ESE': 'ESE', + 'tours.paddle.wind.dir.SE': 'SE', + 'tours.paddle.wind.dir.SSE': 'SSE', + 'tours.paddle.wind.dir.S': 'S', + 'tours.paddle.wind.dir.SSW': 'SSW', + 'tours.paddle.wind.dir.SW': 'SW', + 'tours.paddle.wind.dir.WSW': 'WSW', + 'tours.paddle.wind.dir.W': 'W', + 'tours.paddle.wind.dir.WNW': 'WNW', + 'tours.paddle.wind.dir.NW': 'NW', + 'tours.paddle.wind.dir.NNW': 'NNW', + 'tours.paddle.wind.legendMean': 'Mean wind', + 'tours.paddle.wind.legendGusts': 'Gusts', + 'tours.paddle.wind.mean': 'Mean', + 'tours.paddle.wind.gusts': 'Gusts', + 'tours.paddle.wind.fromInfo': 'Wind from {direction}, the direction it blows from most of the day', + 'tours.paddle.wind.meanInfo': 'The strongest hourly mean wind of the day', + 'tours.paddle.wind.gustsInfo': 'The strongest gust of the day', + 'tours.paddle.wind.source': '{source}, {time}, forecast at the start', + 'tours.paddle.wind.hint.headwind': 'Headwind costs time and strength.', + 'tours.paddle.wind.hint.drift': 'Crosswind pushes the boat off course.', + 'tours.paddle.wind.hint.crossing': 'Open crossings are more exposed than the shore.', + 'tours.paddle.wind.hint.gusts': 'Gusts are stronger than the mean and come suddenly.', + 'tours.paddle.wind.hint.local': 'No figure is an all-clear. Check the conditions on site.', + 'tours.paddle.obstacles.loading': 'Loading the obstacles along the route …', + 'tours.paddle.obstacles.covered': + '{count} obstacles and access points along the route, from OpenStreetMap as of {date}. The data can have gaps, so check the water yourself.', + 'tours.paddle.obstacles.covered.one': + '{count} obstacle or access point along the route, from OpenStreetMap as of {date}. The data can have gaps, so check the water yourself.', + 'tours.paddle.obstacles.coveredEmpty': + 'No known obstacles along the route (OpenStreetMap as of {date}). Check the water yourself all the same.', + 'tours.paddle.obstacles.partial': + 'Obstacle data covers only part of the route (as of {date}). Check the rest yourself.', + 'tours.paddle.obstacles.none': 'No obstacle data for this area. Check weirs, locks and portages here yourself.', + 'tours.paddle.obstacles.offline': 'Without a connection TREK loads no obstacle data. Check the water yourself.', + 'tours.paddle.obstacles.error': 'The obstacle data could not be loaded. Check the water yourself.', + 'tours.paddle.obstacle.kind.weir': 'Weir', + 'tours.paddle.obstacle.kind.dam': 'Dam', + 'tours.paddle.obstacle.kind.lock': 'Lock', + 'tours.paddle.obstacle.kind.lock_gate': 'Lock gate', + 'tours.paddle.obstacle.kind.waterfall': 'Waterfall', + 'tours.paddle.obstacle.kind.rapids': 'Rapids', + 'tours.paddle.obstacle.kind.hazard': 'Hazard', + 'tours.paddle.obstacle.kind.put_in': 'Put-in', + 'tours.paddle.obstacle.kind.egress': 'Take-out', + 'tours.paddle.obstacle.kind.slipway': 'Slipway', + 'tours.paddle.obstacle.kind.portage': 'Portage', + 'tours.paddle.obstacle.kind.access': 'Access rule', }; export default tours; diff --git a/shared/src/index.ts b/shared/src/index.ts index aa91abf14..1440536ea 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -13,6 +13,10 @@ export * from './common/pagination.schema'; // Domain contracts export * from './weather/weather.schema'; +export * from './weather/wind.schema'; +export * from './paddle/paddle-obstacles.schema'; +export * from './paddle/paddle-route-relation'; +export * from './paddle/paddle-regions.schema'; export * from './public-api/public-api.schema'; export * from './airport/airport.schema'; export * from './config/config.schema'; @@ -52,6 +56,8 @@ export * from './tours/tours.schema'; export * from './tours/tour-provenance.schema'; export * from './tours/tours.metrics'; export * from './tours/tour-gpx-import.schema'; +export * from './tours/paddle-plan.schema'; +export * from './tours/paddle-plan'; export * from './wanderer/wanderer.schema'; export * from './geo/haversine'; export * from './collection/collection.schema'; diff --git a/shared/src/paddle/paddle-obstacles.schema.ts b/shared/src/paddle/paddle-obstacles.schema.ts new file mode 100644 index 000000000..87ef9e496 --- /dev/null +++ b/shared/src/paddle/paddle-obstacles.schema.ts @@ -0,0 +1,133 @@ +import { z } from 'zod'; + +/** + * PAD-05A: paddle obstacles and access rules read out of OpenStreetMap, region + * by region. Every object keeps its own OSM reference and geometry (a weir + * across a river is a line, never only a point), the tags it was judged by + * and a hash of them, so a later read tells a changed object from an + * unchanged one. The map shows them (PAD-05B), the checklist decides about + * them (PAD-06); neither turns a missing object into a free passage. + */ + +export const paddleObstacleKindSchema = z.enum([ + 'weir', + 'dam', + 'lock', + 'lock_gate', + 'waterfall', + 'rapids', + 'hazard', + 'put_in', + 'egress', + 'slipway', + 'portage', + 'access', +]); +export type PaddleObstacleKind = z.infer; + +/** The kinds a paddler has to decide about; the rest are places and ways that help. */ +export const PADDLE_BLOCKING_KINDS: readonly PaddleObstacleKind[] = [ + 'weir', + 'dam', + 'lock', + 'lock_gate', + 'waterfall', + 'rapids', + 'hazard', + 'access', +]; + +const position = z.tuple([z.number().finite(), z.number().finite()]); +/** GeoJSON order, [lng, lat], as OSM tools exchange it. */ +export const paddleGeometrySchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('Point'), coordinates: position }), + z.object({ type: z.literal('LineString'), coordinates: z.array(position).min(2) }), + z.object({ type: z.literal('Polygon'), coordinates: z.array(z.array(position).min(4)).min(1) }), +]); +export type PaddleGeometry = z.infer; + +export const paddleFeatureSchema = z.object({ + /** `node/123` or `way/456`: the object in OpenStreetMap. */ + ref: z.string(), + kind: paddleObstacleKindSchema, + /** The objects of one lock (chamber, gates) share a key; null for an object on its own. */ + group_key: z.string().nullable(), + name: z.string().nullable(), + geometry: paddleGeometrySchema, + /** The tags it was judged by, as OSM holds them. */ + tags: z.record(z.string(), z.string()), + /** A hash of its tags and geometry: a new value means the object changed. */ + tag_hash: z.string(), + region_id: z.string(), +}); +export type PaddleFeature = z.infer; + +export const paddleCoverageStateSchema = z.enum(['covered', 'partial', 'none']); +export type PaddleCoverageState = z.infer; + +export const paddleCoverageRegionSchema = z.object({ + id: z.string(), + name: z.string(), + /** The date the region's data is current to, ISO 8601; null when its file named none. */ + source_timestamp: z.string().nullable(), + /** When TREK last read the region, ISO 8601. */ + imported_at: z.string().nullable(), +}); +export type PaddleCoverageRegion = z.infer; + +export const paddleObstaclesQuerySchema = z.object({ + /** south,west,north,east in degrees. */ + bbox: z.string().regex(/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/), +}); +export type PaddleObstaclesQuery = z.infer; + +/** The most objects one answer carries; a wider area answers cut and says so. */ +export const PADDLE_OBSTACLES_CAP = 2000; +/** The widest area one request may ask for, in degrees a side. */ +export const PADDLE_OBSTACLES_MAX_SPAN_DEG = 1; + +export const paddleObstaclesResponseSchema = z.object({ + features: z.array(paddleFeatureSchema), + /** Whether the area lies inside regions TREK holds data for: wholly, in part, or not at all. */ + coverage: z.object({ + state: paddleCoverageStateSchema, + regions: z.array(paddleCoverageRegionSchema), + }), + /** The answer was cut at PADDLE_OBSTACLES_CAP. */ + truncated: z.boolean(), +}); +export type PaddleObstaclesResponse = z.infer; + +export const PADDLE_OBSTACLES_ATTRIBUTION = '© OpenStreetMap contributors (ODbL)'; + +/** What the access tags say about paddling here, read canoe first, then boat. */ +export interface PaddleAccess { + /** The value the tags give; null when none of them speak to boats. */ + value: string | null; + /** Which tag it came from: the canoe-specific one wins over the general boat one. */ + source: 'canoe' | 'boat' | 'access' | null; + /** A condition the rule carries (season, hours), as written; it is shown, never evaluated. */ + condition: string | null; + /** The value forbids paddling outright, as far as the tags go. */ + forbidden: boolean; + /** Private ground or water: no permission, whatever the line suggests. */ + private: boolean; +} + +/** + * The access rule for paddlers in an object's tags. `canoe` is read before + * `boat`, and `access` last; a conditional rule is kept as written. A value + * nobody gave stays null: missing is no permission. + */ +export function paddleAccessOf(tags: Readonly>): PaddleAccess { + const pick = (['canoe', 'boat', 'access'] as const).find((key) => tags[key] !== undefined); + const conditional = tags['canoe:conditional'] ?? tags['boat:conditional'] ?? null; + const value = pick ? (tags[pick] ?? null) : null; + return { + value, + source: pick ?? null, + condition: conditional, + forbidden: value === 'no', + private: value === 'private', + }; +} diff --git a/shared/src/paddle/paddle-regions.schema.ts b/shared/src/paddle/paddle-regions.schema.ts new file mode 100644 index 000000000..140dc8fda --- /dev/null +++ b/shared/src/paddle/paddle-regions.schema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +/** + * PAD-05A, the admin side: the regions TREK reads paddle data for. Each is + * one of Geofabrik's regional OpenStreetMap extracts, chosen by its id there + * (`germany/bayern/oberpfalz`); TREK downloads it, reads the paddle objects + * out and keeps nothing else of it. A region is read again every month. + */ + +export const paddleRegionStatusSchema = z.enum(['queued', 'importing', 'ready', 'failed']); +export type PaddleRegionStatus = z.infer; + +export const paddleRegionSchema = z.object({ + id: z.string(), + name: z.string(), + status: paddleRegionStatusSchema, + /** The date the region's data is current to, ISO 8601; null before the first read. */ + source_timestamp: z.string().nullable(), + /** When TREK last read it, ISO 8601. */ + imported_at: z.string().nullable(), + feature_count: z.number().int(), + /** Why the last read failed, as plain text; null otherwise. */ + error: z.string().nullable(), +}); +export type PaddleRegion = z.infer; + +export const paddleRegionListResponseSchema = z.object({ regions: z.array(paddleRegionSchema) }); +export type PaddleRegionListResponse = z.infer; + +export const paddleRegionCatalogEntrySchema = z.object({ + id: z.string(), + name: z.string(), + /** The regions it lies in, outermost first, for telling "Limburg" from "Limburg". */ + path: z.array(z.string()), +}); +export type PaddleRegionCatalogEntry = z.infer; + +export const paddleRegionCatalogQuerySchema = z.object({ q: z.string().trim().min(2).max(80) }); +export const paddleRegionCatalogResponseSchema = z.object({ regions: z.array(paddleRegionCatalogEntrySchema) }); +export type PaddleRegionCatalogResponse = z.infer; + +/** Geofabrik's region ids: lower case words, digits and dashes. */ +export const paddleRegionIdSchema = z + .string() + .trim() + .min(2) + .max(120) + .regex(/^[a-z0-9-]+$/); + +export const paddleRegionRequestSchema = z.object({ id: paddleRegionIdSchema }); +export type PaddleRegionRequest = z.infer; diff --git a/shared/src/paddle/paddle-route-relation.ts b/shared/src/paddle/paddle-route-relation.ts new file mode 100644 index 000000000..035dc504f --- /dev/null +++ b/shared/src/paddle/paddle-route-relation.ts @@ -0,0 +1,123 @@ +import type { PaddleFeature, PaddleGeometry } from './paddle-obstacles.schema'; + +/** + * PAD-05A/05B: how an obstacle stands to a paddle route. Only a line that + * crosses it, or passes within ON_ROUTE_M, makes it an obstacle on the route. + * Within NEAR_M it is near, and stays uncertain: a weir on the next arm of a + * river is close without being in the way. Farther off it is not related. + */ + +/** Closer than this, in metres, the route meets the object. */ +export const ON_ROUTE_M = 25; +/** Closer than this the object is near the route, its relation to it uncertain. */ +export const NEAR_M = 150; + +export type PaddleRelation = 'on_route' | 'near'; + +export interface RelatedObstacle { + feature: PaddleFeature; + relation: PaddleRelation; + /** The shortest distance between the route and the object, in metres. */ + distanceM: number; + /** How far along the route the closest point lies, in metres from its start. */ + alongM: number; +} + +type LatLng = readonly [number, number]; + +const METRES_PER_DEG = 111_195; + +interface Local { + x: number; + y: number; +} + +/** A flat view around a latitude, good to a few metres over the length of a day tour. */ +function projector(lat0: number): (lat: number, lng: number) => Local { + const cos = Math.cos((lat0 * Math.PI) / 180); + return (lat, lng) => ({ x: lng * METRES_PER_DEG * cos, y: lat * METRES_PER_DEG }); +} + +function closestOnSegment(p: Local, a: Local, b: Local): { distance: number; t: number } { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy; + const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq)); + return { distance: Math.hypot(p.x - a.x - t * dx, p.y - a.y - t * dy), t }; +} + +function crosses(a: Local, b: Local, c: Local, d: Local): number | null { + const denominator = (b.x - a.x) * (d.y - c.y) - (b.y - a.y) * (d.x - c.x); + if (denominator === 0) return null; + const t = ((c.x - a.x) * (d.y - c.y) - (c.y - a.y) * (d.x - c.x)) / denominator; + const u = ((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)) / denominator; + return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : null; +} + +/** The object's outline as segments (a point is one segment of length zero). */ +function segmentsOf(geometry: PaddleGeometry, project: (lat: number, lng: number) => Local): Array<[Local, Local]> { + const line = + geometry.type === 'Point' + ? [geometry.coordinates, geometry.coordinates] + : geometry.type === 'LineString' + ? geometry.coordinates + : (geometry.coordinates[0] ?? []); + const points = line.map(([lng, lat]) => project(lat, lng)); + const segments: Array<[Local, Local]> = []; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]; + const b = points[i]; + if (a && b) segments.push([a, b]); + } + return segments; +} + +/** + * The obstacles on or near a route, in the order the route reaches them. The + * route is `[lat, lng]` pairs as the planner draws it; an empty or one-point + * route relates nothing. + */ +export function relateObstaclesToRoute( + route: readonly LatLng[], + features: readonly PaddleFeature[], +): RelatedObstacle[] { + if (route.length < 2) return []; + const project = projector(route[0]?.[0] ?? 0); + const path = route.map(([lat, lng]) => project(lat, lng)); + const along: number[] = [0]; + for (let i = 1; i < path.length; i++) { + const a = path[i - 1] as Local; + const b = path[i] as Local; + along.push((along[i - 1] ?? 0) + Math.hypot(b.x - a.x, b.y - a.y)); + } + const related: RelatedObstacle[] = []; + for (const feature of features) { + let best = { distance: Number.POSITIVE_INFINITY, alongM: 0 }; + const outline = segmentsOf(feature.geometry, project); + for (let i = 1; i < path.length; i++) { + const a = path[i - 1] as Local; + const b = path[i] as Local; + const start = along[i - 1] ?? 0; + const length = (along[i] ?? start) - start; + for (const [c, d] of outline) { + const hit = crosses(a, b, c, d); + if (hit !== null) { + if (best.distance > 0 || start + hit * length < best.alongM) best = { distance: 0, alongM: start + hit * length }; + continue; + } + for (const p of [c, d]) { + const near = closestOnSegment(p, a, b); + if (near.distance < best.distance) best = { distance: near.distance, alongM: start + near.t * length }; + } + } + } + if (best.distance > NEAR_M) continue; + related.push({ + feature, + relation: best.distance <= ON_ROUTE_M ? 'on_route' : 'near', + distanceM: Math.round(best.distance), + alongM: Math.round(best.alongM), + }); + } + return related.sort((a, b) => a.alongM - b.alongM); +} diff --git a/shared/src/tours/paddle-plan.schema.ts b/shared/src/tours/paddle-plan.schema.ts new file mode 100644 index 000000000..6f9cea444 --- /dev/null +++ b/shared/src/tours/paddle-plan.schema.ts @@ -0,0 +1,81 @@ +import { z } from 'zod'; + +/** + * PAD-03 and PAD-07: what a paddle tour carries beyond its line. A stretch the + * boat is carried (a portage), a lock with the times the paddler expects at it, + * and the planning speeds the estimate is reckoned with. These are the + * paddler's own plan, never facts from a data source. + */ + +/** TREK's planning starting values, to adjust: not norms, not measurements. */ +export const DEFAULT_PORTAGE_SPEED_KMH = 2; +export const DEFAULT_PORTAGE_EXTRA_MINUTES = 10; + +/** The highest carrying speed a paddler can enter, in km/h. */ +export const MAX_PORTAGE_SPEED_KMH = 10; +export const portageSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PORTAGE_SPEED_KMH); + +/** Minutes one portage or one lock may take in a plan; a day tour stays within a day. */ +export const MAX_PADDLE_STOP_MINUTES = 12 * 60; +export const paddleStopMinutesSchema = z.number().int().min(0).max(MAX_PADDLE_STOP_MINUTES); + +/** + * A lock on the tour, with the time the paddler expects to wait for it and to + * pass it. Either may be unknown: an unknown time is missing from the + * estimate, never zero. + */ +export const paddleLockSchema = z.object({ + wait_minutes: paddleStopMinutesSchema.nullable(), + transit_minutes: paddleStopMinutesSchema.nullable(), +}); +export type PaddleLock = z.infer; + +/** The most portages or locks one recorded track may be marked with. */ +export const MAX_TRACK_MARKS = 50; + +/** + * Marks laid onto a recorded track (a GPX import), which is never redrawn: a + * portage is the stretch between two distances along it, a lock one distance. + * Distances are metres from the start of the track as recorded. + */ +export const trackPortageMarkSchema = z + .object({ + from_m: z.number().finite().min(0), + to_m: z.number().finite().min(0), + /** Getting out, unloading and putting back in; null takes the starting value. */ + extra_minutes: paddleStopMinutesSchema.nullable(), + }) + .refine((mark) => mark.to_m > mark.from_m, { message: 'a portage ends after it begins', path: ['to_m'] }); +export type TrackPortageMark = z.infer; + +export const trackLockMarkSchema = paddleLockSchema.extend({ + at_m: z.number().finite().min(0), +}); +export type TrackLockMark = z.infer; + +export const trackMarksSchema = z + .object({ + portages: z.array(trackPortageMarkSchema).max(MAX_TRACK_MARKS), + locks: z.array(trackLockMarkSchema).max(MAX_TRACK_MARKS), + }) + .superRefine((marks, ctx) => { + const sorted = [...marks.portages].sort((a, b) => a.from_m - b.from_m); + sorted.forEach((mark, i) => { + const before = sorted[i - 1]; + if (before && mark.from_m < before.to_m) + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['portages'], message: 'portages must not overlap' }); + }); + }); +export type TrackMarks = z.infer; + +/** + * PUT /api/trips/:tripId/tours/:placeId/paddle-plan: the paddle plan of a tour + * that has no control points of its own, a recorded track. A planned tour + * saves the same values with its route. + */ +export const tourPaddlePlanRequestSchema = z.object({ + paddle_speed_kmh: z.number().finite().min(0.5).max(20).nullable(), + portage_speed_kmh: portageSpeedKmhSchema.nullable(), + track_marks: trackMarksSchema, +}); +export type TourPaddlePlanRequest = z.infer; diff --git a/shared/src/tours/paddle-plan.spec.ts b/shared/src/tours/paddle-plan.spec.ts new file mode 100644 index 000000000..54a2b0783 --- /dev/null +++ b/shared/src/tours/paddle-plan.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; + +import { + estimatePaddleTime, + paddleLayoutOfPoints, + paddleLayoutOfTrack, + paddleMovingSeconds, + paddleRolesOf, + type PaddlePlanPoint, +} from './paddle-plan'; +import { tourPaddlePlanRequestSchema, trackMarksSchema } from './paddle-plan.schema'; +import { tourCreateRequestSchema } from './tours.schema'; + +/** A point `km` kilometres east of 53°N 13°E along the parallel, near enough for these sums. */ +function east(km: number): { lat: number; lng: number } { + return { lat: 53, lng: 13 + km / (111.19493 * Math.cos((53 * Math.PI) / 180)) }; +} + +function leg(kind: 'free' | 'portage', from: { lat: number; lng: number }, to: { lat: number; lng: number }, extra?: number) { + return { + kind, + geometry: [ + [from.lat, from.lng], + [to.lat, to.lng], + ] as [number, number][], + ...(extra === undefined ? {} : { extra_minutes: extra }), + }; +} + +describe('paddle plan (PAD-03, PAD-07)', () => { + it('names put-in, take-out, re-entry and final take-out from the legs around each point', () => { + const [a, b, c, d, e] = [east(0), east(1), east(1.2), east(1.4), east(3)]; + const points: PaddlePlanPoint[] = [ + { ...a, fixed_leg: leg('free', a, b) }, + { ...b, fixed_leg: leg('portage', b, c) }, + { ...c, fixed_leg: leg('portage', c, d) }, + { ...d, fixed_leg: leg('free', d, e) }, + { ...e }, + ]; + expect(paddleRolesOf(points)).toEqual(['put_in', 'take_out', null, 're_entry', 'final_take_out']); + }); + + it("reckons the card's example: 8 km water, a 0.4 km portage, a lock gone through and a break make 192 minutes", () => { + const [a, b, c, d, e] = [east(0), east(4), east(4.4), east(6), east(8.4)]; + const points: PaddlePlanPoint[] = [ + { ...a, fixed_leg: leg('free', a, b) }, + { ...b, fixed_leg: leg('portage', b, c) }, + { ...c, fixed_leg: leg('free', c, d) }, + { ...d, fixed_leg: leg('free', d, e), lock: { wait_minutes: 20, transit_minutes: 10 } }, + { ...e }, + ]; + const estimate = estimatePaddleTime(paddleLayoutOfPoints(points), { + paddleSpeedKmh: 4, + portageSpeedKmh: null, + breakMinutes: 20, + }); + expect(estimate.waterM).toBeCloseTo(8000, -1); + expect(estimate.landM).toBeCloseTo(400, -1); + expect(estimate.waterMinutes).toBe(120); + expect(estimate.carryMinutes).toBe(12); + expect(estimate.portageExtraMinutes).toBe(10); + expect(estimate.lockMinutes).toBe(30); + expect(estimate.estimateMinutes).toBe(192); + expect(estimate.complete).toBe(true); + expect(paddleMovingSeconds(estimate)).toBe(172 * 60); + }); + + it('joins consecutive portage legs into one portage that keeps the time of its first leg', () => { + const [a, b, c, d] = [east(0), east(1), east(1.2), east(2)]; + const layout = paddleLayoutOfPoints([ + { ...a, fixed_leg: leg('portage', a, b, 25) }, + { ...b, fixed_leg: leg('portage', b, c) }, + { ...c, fixed_leg: leg('free', c, d) }, + { ...d }, + ]); + expect(layout.stretches.map((s) => [s.kind, s.extraMinutes, s.startIndex])).toEqual([ + ['portage', 25, 0], + ['water', null, 2], + ]); + }); + + it('counts a lock a portage carries round with the portage, and names a lock without times as missing', () => { + const [a, b, c, d] = [east(0), east(2), east(2.3), east(4)]; + const estimate = estimatePaddleTime( + paddleLayoutOfPoints([ + { ...a, fixed_leg: leg('free', a, b), lock: { wait_minutes: null, transit_minutes: 5 } }, + { ...b, fixed_leg: leg('portage', b, c), lock: { wait_minutes: 30, transit_minutes: 10 } }, + { ...c, fixed_leg: leg('free', c, d) }, + { ...d }, + ]), + { paddleSpeedKmh: 4, portageSpeedKmh: 2, breakMinutes: null }, + ); + expect(estimate.locksCounted).toBe(1); + expect(estimate.lockMinutes).toBe(5); + expect(estimate.locksMissingTime).toBe(1); + expect(estimate.complete).toBe(false); + }); + + it('leaves the water time unknown without a paddling speed, and the estimate incomplete', () => { + const [a, b] = [east(0), east(2)]; + const estimate = estimatePaddleTime(paddleLayoutOfPoints([{ ...a, fixed_leg: leg('free', a, b) }, { ...b }]), { + paddleSpeedKmh: null, + portageSpeedKmh: null, + breakMinutes: null, + }); + expect(estimate.waterMinutes).toBeNull(); + expect(estimate.complete).toBe(false); + expect(paddleMovingSeconds(estimate)).toBeNull(); + }); + + it('reads a recorded track from its marks: water around each portage, a lock inside one bypassed', () => { + const layout = paddleLayoutOfTrack(5000, { + portages: [{ from_m: 1000, to_m: 1300, extra_minutes: null }], + locks: [ + { at_m: 1200, wait_minutes: 10, transit_minutes: 10 }, + { at_m: 4000, wait_minutes: 15, transit_minutes: 5 }, + ], + }); + expect(layout.stretches.map((s) => [s.kind, s.fromM, s.toM])).toEqual([ + ['water', 0, 1000], + ['portage', 1000, 1300], + ['water', 1300, 5000], + ]); + expect(layout.locks.map((lock) => lock.bypassed)).toEqual([true, false]); + const estimate = estimatePaddleTime(layout, { paddleSpeedKmh: 4, portageSpeedKmh: null, breakMinutes: 0 }); + expect(estimate.lockMinutes).toBe(20); + expect(estimate.portageCount).toBe(1); + }); + + it('refuses overlapping or reversed portages on a track', () => { + expect( + trackMarksSchema.safeParse({ + portages: [ + { from_m: 0, to_m: 500, extra_minutes: null }, + { from_m: 400, to_m: 900, extra_minutes: null }, + ], + locks: [], + }).success, + ).toBe(false); + expect( + tourPaddlePlanRequestSchema.safeParse({ + paddle_speed_kmh: null, + portage_speed_kmh: null, + track_marks: { portages: [{ from_m: 600, to_m: 500, extra_minutes: null }], locks: [] }, + }).success, + ).toBe(false); + }); + + it('keeps portages, locks and a carrying speed to paddle tours', () => { + const route = [ + [53, 13, 0], + [53, 13.01, 0], + ] as [number, number, number][]; + const base = { + name: 'Carry', + route_geometry: route, + waypoints: [ + { lat: 53, lng: 13, role: 'start', sequence: 0, fixed_leg: leg('portage', { lat: 53, lng: 13 }, { lat: 53, lng: 13.01 }) }, + { lat: 53, lng: 13.01, role: 'end', sequence: 1, lock: { wait_minutes: 5, transit_minutes: 5 } }, + ], + }; + const hike = tourCreateRequestSchema.safeParse({ ...base, tour_type: 'hike', portage_speed_kmh: 2 }); + expect(hike.success).toBe(false); + expect(hike.error?.issues.map((issue) => issue.path.join('.'))).toEqual( + expect.arrayContaining(['portage_speed_kmh', 'waypoints.0.fixed_leg', 'waypoints.1.lock']), + ); + const paddle = tourCreateRequestSchema.safeParse({ + ...base, + tour_type: 'kayak', + boat_type: 'canoe', + water_context: 'river', + portage_speed_kmh: 2.5, + }); + expect(paddle.success).toBe(true); + }); +}); diff --git a/shared/src/tours/paddle-plan.ts b/shared/src/tours/paddle-plan.ts new file mode 100644 index 000000000..9f3c4d483 --- /dev/null +++ b/shared/src/tours/paddle-plan.ts @@ -0,0 +1,244 @@ +import { haversineMetres } from '../geo/haversine'; +import { + DEFAULT_PORTAGE_EXTRA_MINUTES, + DEFAULT_PORTAGE_SPEED_KMH, + type PaddleLock, + type TrackMarks, +} from './paddle-plan.schema'; + +/** + * PAD-03 and PAD-07 as plain arithmetic, so the planner, the detail view and + * the server read one answer: which stretches of a paddle tour are on water + * and which are carried, where its locks are, what each part takes and what + * of the estimate is still missing. + */ + +/** What a point of a paddle tour is to the paddler, read from the legs on either side of it. */ +export type PaddleRole = 'put_in' | 'take_out' | 're_entry' | 'final_take_out'; + +interface PlanLeg { + kind: string; + /** Read as plain values: a workspace compiled without strict mode sees the schema's tuples as "maybe two". */ + geometry: ReadonlyArray>; + extra_minutes?: number | null; +} + +/** A control point as far as the paddle plan reads it. */ +export interface PaddlePlanPoint { + lat: number; + lng: number; + fixed_leg?: PlanLeg | null; + lock?: PaddleLock | null; +} + +export interface PaddleStretch { + kind: 'water' | 'portage'; + fromM: number; + toM: number; + /** A portage's time for getting out, unloading and back in; null means the starting value. */ + extraMinutes: number | null; + /** The control point the stretch starts at, for a planned tour. */ + startIndex: number | null; +} + +export interface PaddleLockStop extends PaddleLock { + atM: number; + /** A planned portage carries round it: its times count instead of the lock's. */ + bypassed: boolean; + /** The control point it sits at, for a planned tour. */ + index: number | null; +} + +export interface PaddleLayout { + totalM: number; + stretches: PaddleStretch[]; + locks: PaddleLockStop[]; +} + +function isPortage(leg: PlanLeg | null | undefined): boolean { + return leg?.kind === 'portage'; +} + +function lineLengthM(line: PlanLeg['geometry']): number { + let total = 0; + for (let i = 1; i < line.length; i++) { + const a = line[i - 1]; + const b = line[i]; + if (a && b) total += haversineMetres(Number(a[0]), Number(a[1]), Number(b[0]), Number(b[1])); + } + return total; +} + +/** + * The role of every control point: the first puts in, the last takes out for + * good, a point where water gives way to a portage is a take-out and where a + * portage meets water again a re-entry. Points within a stretch have none. + */ +export function paddleRolesOf(points: readonly PaddlePlanPoint[]): Array { + return points.map((point, index) => { + if (index === 0) return 'put_in'; + if (index === points.length - 1) return 'final_take_out'; + const carriedIn = isPortage(points[index - 1]?.fixed_leg); + const carriedOut = isPortage(point.fixed_leg); + if (!carriedIn && carriedOut) return 'take_out'; + if (carriedIn && !carriedOut) return 're_entry'; + return null; + }); +} + +function bypassedBy(stretches: readonly PaddleStretch[], atM: number): boolean { + return stretches.some((s) => s.kind === 'portage' && atM >= s.fromM - 0.5 && atM <= s.toM + 0.5); +} + +/** The stretches and locks of a planned tour, measured along the legs between its control points. */ +export function paddleLayoutOfPoints(points: readonly PaddlePlanPoint[]): PaddleLayout { + const stretches: PaddleStretch[] = []; + const at: number[] = []; + let distance = 0; + points.forEach((point, index) => { + at.push(distance); + const next = points[index + 1]; + if (!next) return; + const leg = point.fixed_leg; + const length = leg ? lineLengthM(leg.geometry) : haversineMetres(point.lat, point.lng, next.lat, next.lng); + const kind = isPortage(leg) ? 'portage' : 'water'; + const last = stretches[stretches.length - 1]; + if (last && last.kind === kind) last.toM = distance + length; + else + stretches.push({ + kind, + fromM: distance, + toM: distance + length, + extraMinutes: kind === 'portage' ? (leg?.extra_minutes ?? null) : null, + startIndex: index, + }); + distance += length; + }); + const locks = points.flatMap((point, index): PaddleLockStop[] => { + if (!point.lock) return []; + const atM = at[index] ?? 0; + return [{ ...point.lock, atM, bypassed: bypassedBy(stretches, atM), index }]; + }); + return { totalM: distance, stretches, locks }; +} + +/** The stretches and locks of a recorded track, from the marks laid onto it. */ +export function paddleLayoutOfTrack(totalM: number, marks: TrackMarks | null | undefined): PaddleLayout { + const portages = [...(marks?.portages ?? [])] + .map((mark) => ({ ...mark, from_m: Math.min(mark.from_m, totalM), to_m: Math.min(mark.to_m, totalM) })) + .filter((mark) => mark.to_m > mark.from_m) + .sort((a, b) => a.from_m - b.from_m); + const stretches: PaddleStretch[] = []; + let cursor = 0; + for (const mark of portages) { + if (mark.from_m > cursor) + stretches.push({ kind: 'water', fromM: cursor, toM: mark.from_m, extraMinutes: null, startIndex: null }); + stretches.push({ + kind: 'portage', + fromM: mark.from_m, + toM: mark.to_m, + extraMinutes: mark.extra_minutes, + startIndex: null, + }); + cursor = mark.to_m; + } + if (totalM > cursor) stretches.push({ kind: 'water', fromM: cursor, toM: totalM, extraMinutes: null, startIndex: null }); + const locks = (marks?.locks ?? []) + .filter((lock) => lock.at_m <= totalM) + .map((lock) => ({ + wait_minutes: lock.wait_minutes, + transit_minutes: lock.transit_minutes, + atM: lock.at_m, + bypassed: bypassedBy(stretches, lock.at_m), + index: null, + })) + .sort((a, b) => a.atM - b.atM); + return { totalM, stretches, locks }; +} + +export interface PaddleTimeEstimate { + waterM: number; + landM: number; + portageCount: number; + /** The speeds the estimate used; the paddling one is null while the paddler has entered none. */ + paddleSpeedKmh: number | null; + portageSpeedKmh: number; + /** Null while there is no paddling speed: unknown, never zero. */ + waterMinutes: number | null; + carryMinutes: number; + portageExtraMinutes: number; + /** The known lock times of the locks the boat goes through. */ + lockMinutes: number; + locksCounted: number; + /** Locks gone through whose wait or transit time is not entered. */ + locksMissingTime: number; + breakMinutes: number; + /** Every known part added up; with `complete` false it leaves something out. */ + estimateMinutes: number; + complete: boolean; +} + +/** + * The estimate of PAD-07: water at the paddling speed, each portage carried at + * the carrying speed plus its time for getting out and back in, each lock gone + * through at the times entered for it, and the breaks. A lock a portage carries + * round counts with the portage, not again. What is not known is left out and + * named, so the sum is never passed off as complete. + */ +export function estimatePaddleTime( + layout: PaddleLayout, + speeds: { paddleSpeedKmh: number | null; portageSpeedKmh: number | null; breakMinutes: number | null }, +): PaddleTimeEstimate { + const portageSpeedKmh = speeds.portageSpeedKmh ?? DEFAULT_PORTAGE_SPEED_KMH; + let waterM = 0; + let landM = 0; + let portageExtraMinutes = 0; + let portageCount = 0; + for (const stretch of layout.stretches) { + const length = stretch.toM - stretch.fromM; + if (stretch.kind === 'water') { + waterM += length; + continue; + } + landM += length; + portageCount += 1; + portageExtraMinutes += stretch.extraMinutes ?? DEFAULT_PORTAGE_EXTRA_MINUTES; + } + const waterMinutes = speeds.paddleSpeedKmh ? (waterM / 1000 / speeds.paddleSpeedKmh) * 60 : null; + const carryMinutes = (landM / 1000 / portageSpeedKmh) * 60; + let lockMinutes = 0; + let locksCounted = 0; + let locksMissingTime = 0; + for (const lock of layout.locks) { + if (lock.bypassed) continue; + locksCounted += 1; + lockMinutes += (lock.wait_minutes ?? 0) + (lock.transit_minutes ?? 0); + if (lock.wait_minutes === null || lock.transit_minutes === null) locksMissingTime += 1; + } + const breakMinutes = speeds.breakMinutes ?? 0; + const estimateMinutes = Math.round( + (waterMinutes ?? 0) + carryMinutes + portageExtraMinutes + lockMinutes + breakMinutes, + ); + return { + waterM, + landM, + portageCount, + paddleSpeedKmh: speeds.paddleSpeedKmh, + portageSpeedKmh, + waterMinutes: waterMinutes === null ? null : Math.round(waterMinutes), + carryMinutes: Math.round(carryMinutes), + portageExtraMinutes, + lockMinutes, + locksCounted, + locksMissingTime, + breakMinutes, + estimateMinutes, + complete: waterMinutes !== null && locksMissingTime === 0, + }; +} + +/** The moving part of the estimate (no breaks), in seconds, as a tour's duration is stored. */ +export function paddleMovingSeconds(estimate: PaddleTimeEstimate): number | null { + if (estimate.waterMinutes === null) return null; + return Math.round((estimate.estimateMinutes - estimate.breakMinutes) * 60); +} diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index dd77af628..5987636cd 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -1,3 +1,4 @@ +import { paddleLockSchema, paddleStopMinutesSchema, portageSpeedKmhSchema, trackMarksSchema } from './paddle-plan.schema'; import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema'; import { z } from 'zod'; @@ -114,6 +115,10 @@ export const tourSchema = z.object({ water_context: paddleWaterContextSchema.nullable().optional(), /** The speed the paddler chose; null while the boat's suggested speed applies. */ paddle_speed_kmh: z.number().nullable().optional(), + /** PAD-07: the carrying speed of a paddle tour's portages; null while the starting value applies. */ + portage_speed_kmh: z.number().nullable().optional(), + /** PAD-03: portages and locks laid onto a recorded track; null for a planned tour and a track without any. */ + track_marks: trackMarksSchema.nullable().optional(), }); export type Tour = z.infer; @@ -170,7 +175,9 @@ const fixedLegGeometrySchema = z * own line from OpenStreetMap, since a router walks around a ferrata wherever * it can. A free leg (P2A-04) is drawn by hand, straight from one waypoint to * the next: off the path network, across a lake, wherever no router goes. - * Nothing about a free leg was checked by a router. + * Nothing about a free leg was checked by a router. A portage (PAD-03) is a + * free leg on land: the boat is carried along it, so it counts as land and + * not as water, with the time getting out and back in takes. */ export const tourFixedLegSchema = z.discriminatedUnion('kind', [ z.object({ @@ -184,6 +191,12 @@ export const tourFixedLegSchema = z.discriminatedUnion('kind', [ kind: z.literal('free'), geometry: fixedLegGeometrySchema, }), + z.object({ + kind: z.literal('portage'), + geometry: fixedLegGeometrySchema, + /** Getting out, unloading and back in, on the first leg of a portage; null takes the starting value. */ + extra_minutes: paddleStopMinutesSchema.nullable().optional(), + }), ]); export type TourFixedLeg = z.infer; export type TourFixedLegKind = TourFixedLeg['kind']; @@ -195,6 +208,8 @@ export const tourWaypointSchema = z.object({ sequence: z.number().int().nonnegative(), /** The leg to the next waypoint as a fixed line; absent or null for a routed one. */ fixed_leg: tourFixedLegSchema.nullable().optional(), + /** PAD-07: a lock at this point of a paddle tour, with the times the paddler expects there. */ + lock: paddleLockSchema.nullable().optional(), }); export type TourWaypoint = z.infer; @@ -228,6 +243,7 @@ export const tourCreateRequestSchema = z boat_type: paddleBoatTypeSchema.nullable().optional(), water_context: paddleWaterContextSchema.nullable().optional(), paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(), + portage_speed_kmh: portageSpeedKmhSchema.nullable().optional(), duration_seconds: z.number().finite().nonnegative().nullable().optional(), planned_duration_minutes: plannedTourDurationMinutesSchema.optional(), break_additional_minutes: plannedTourDurationMinutesSchema.optional(), @@ -303,8 +319,10 @@ function tourTypeIssues(value: { boat_type?: PaddleBoatType | null; water_context?: PaddleWaterContext | null; paddle_speed_kmh?: number | null; -}): Array<{ path: string[]; message: string }> { - const issues: Array<{ path: string[]; message: string }> = []; + portage_speed_kmh?: number | null; + waypoints?: ReadonlyArray<{ fixed_leg?: { kind: string } | null; lock?: unknown }>; +}): Array<{ path: Array; message: string }> { + const issues: Array<{ path: Array; message: string }> = []; if (value.tour_type === 'kayak') { if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' }); if (value.water_context == null) @@ -315,9 +333,15 @@ function tourTypeIssues(value: { } if (value.max_hiking_difficulty === null) issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' }); - for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh'] as const) { + for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh', 'portage_speed_kmh'] as const) { if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` }); } + // A portage and a lock are a paddler's: a hike carries no boat. + (value.waypoints ?? []).forEach((point, index) => { + if (point.fixed_leg?.kind === 'portage') + issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' }); + if (point.lock != null) issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' }); + }); return issues; } diff --git a/shared/src/weather/wind.schema.ts b/shared/src/weather/wind.schema.ts new file mode 100644 index 000000000..10150bdb3 --- /dev/null +++ b/shared/src/weather/wind.schema.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +/** + * PAD-08: the wind forecast for one place and one day, for a paddle tour on + * that day. It only ever carries a forecast: a day already gone or beyond the + * forecast's reach says so and carries no figures, so a climate value or last + * year's wind can never be read as this day's. Speeds are km/h, directions + * degrees the wind comes from (meteorological), gusts the strongest within the + * hour. A value the source did not give is null, never zero. + */ + +/** How many days ahead the forecast reaches, today included. */ +export const WIND_FORECAST_DAYS = 16; + +export const windQuerySchema = z.object({ + lat: z.coerce.number().finite().min(-90).max(90), + lng: z.coerce.number().finite().min(-180).max(180), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); +export type WindQuery = z.infer; + +export const windHourSchema = z.object({ + /** Local hour of the place, 0 to 23. */ + hour: z.number().int().min(0).max(23), + speed_kmh: z.number().nullable(), + gusts_kmh: z.number().nullable(), + direction_deg: z.number().nullable(), +}); +export type WindHour = z.infer; + +export const windDaySchema = z.object({ + /** The strongest hourly mean of the day: a maximum, not an average. */ + speed_max_kmh: z.number().nullable(), + gusts_max_kmh: z.number().nullable(), + /** Where the wind came from most over the day. */ + direction_dominant_deg: z.number().nullable(), +}); +export type WindDay = z.infer; + +export const windForecastStatusSchema = z.enum(['forecast', 'past', 'beyond_horizon']); +export type WindForecastStatus = z.infer; + +export const windForecastResponseSchema = z.object({ + status: windForecastStatusSchema, + date: z.string(), + lat: z.number(), + lng: z.number(), + /** The place's time zone the hours are in; null when there is no forecast. */ + timezone: z.string().nullable(), + source: z.literal('Open-Meteo'), + /** When TREK asked the source for these figures, ISO 8601. */ + fetched_at: z.string(), + horizon_days: z.number().int(), + hours: z.array(windHourSchema), + day: windDaySchema.nullable(), +}); +export type WindForecastResponse = z.infer; + +/** The 16 compass points a direction is named by, from north clockwise. */ +export const COMPASS_POINTS = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', +] as const; +export type CompassPoint = (typeof COMPASS_POINTS)[number]; + +/** The compass point a direction in degrees falls on. */ +export function compassPointOf(degrees: number): CompassPoint { + const index = Math.round((((degrees % 360) + 360) % 360) / 22.5) % 16; + return COMPASS_POINTS[index] ?? 'N'; +}