feat(tours): paddle portages and locks, paddle time, wind at the start and obstacle data from OpenStreetMap

PAD-03 portages and locks on planned tours and recorded tracks, PAD-07 paddle time with portage and lock stops, PAD-08 wind forecast for the start, PAD-05A regional obstacle import from Geofabrik extracts with admin endpoints and a monthly refresh, PAD-05B first cut of the obstacle layer on Leaflet, GL and 3D. Work in progress: checklist (PAD-06), check bench (PAD-09), admin UI, translations and tests follow.
This commit is contained in:
Maurice
2026-10-16 23:44:51 +02:00
parent d705bd9dd0
commit 2c3b20bb57
117 changed files with 7043 additions and 199 deletions
+16
View File
@@ -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<PaddleObstaclesResponse> =>
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)),
};
+8
View File
@@ -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<TourDetailResponse> =>
apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data),
};
+10
View File
@@ -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<WindForecastResponse> =>
apiClient
.get('/weather/wind', { params: { lat, lng, date }, signal })
.then((r) => windForecastResponseSchema.parse(r.data)),
};
@@ -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 = {
+1 -1
View File
@@ -1291,7 +1291,7 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} />
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} paddle={tourLayers?.paddle} />
{/* 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
+3 -1
View File
@@ -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<any | null>(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
@@ -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 (
<>
<FerrataMapLayerLeaflet layer={ferrataLayer} />
<RasterOverlaysLeaflet overlays={overlays} />
<PaddleLayerLeaflet layer={paddle} />
</>
);
}
@@ -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);
}
@@ -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, unknown>) => 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' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
{description && (
<DialogSection className="flex-none" label={t('places.formDescription')}>
@@ -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}
/>
);
@@ -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<SVGProps<SVGSVGElement>, 'children'>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
data-paddle-glyph={kind}
{...rest}
>
{PADDLE_GLYPH_PATHS[kind].map((d) => (
<path key={d} d={d} />
))}
</svg>
);
}
/** The portage glyph as an icon component, for menus that take one. */
export function PortageIcon(props: { size?: number; style?: CSSProperties }) {
return <PaddleGlyph kind="portage" {...props} />;
}
/** The lock glyph as an icon component, for menus that take one. */
export function LockIcon(props: { size?: number; style?: CSSProperties }) {
return <PaddleGlyph kind="lock" {...props} />;
}
@@ -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 ? (
<Polyline
key={`${item.feature.ref}-casing`}
positions={item.line}
pathOptions={{ color: '#ffffff', weight: LINE_WEIGHT[item.relation] + 3, opacity: LINE_OPACITY[item.relation] }} // theme-lint-disable map casing
interactive={false}
/>
) : null
)}
{items.map((item) =>
item.line ? (
<Polyline
key={`${item.feature.ref}-line`}
positions={item.line}
bubblingMouseEvents={false}
pathOptions={{
color: item.color,
weight: LINE_WEIGHT[item.relation],
opacity: LINE_OPACITY[item.relation],
dashArray: item.relation === 'near' ? '6 5' : undefined,
lineCap: 'round',
}}
eventHandlers={select(item.feature.ref)}
/>
) : null
)}
{items.map((item) => (
<Marker
key={`${item.feature.ref}-pin`}
position={item.marker}
icon={icons.get(item.feature.ref) as L.DivIcon}
zIndexOffset={item.feature.ref === selectedRef ? 900 : item.relation === 'on_route' ? 600 : 300}
eventHandlers={select(item.feature.ref)}
>
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
{label(item)}
</Tooltip>
</Marker>
))}
</>
);
}
@@ -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 = <AlertTriangle size={13} strokeWidth={2} />;
if (status === 'idle' || status === 'loading') {
return (
<TourNotice role="status" icon={<Loader2 size={13} strokeWidth={2} className="animate-spin" />}>
{t('tours.paddle.obstacles.loading')}
</TourNotice>
);
}
if (status === 'offline') return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.obstacles.offline')}</TourNotice>;
if (status === 'error') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.error')}</TourNotice>;
const date = dataDate(regions, language);
if (coverage === 'none') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.none')}</TourNotice>;
if (coverage === 'partial') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.partial', { date })}</TourNotice>;
return (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{related.length
? t('tours.paddle.obstacles.covered', { count: related.length, date })
: t('tours.paddle.obstacles.coveredEmpty', { date })}
</TourNotice>
);
}
@@ -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 (
<div data-testid="paddle-route-strip" className="min-w-0">
<div className={`relative ${compact ? '' : 'py-[3px]'}`}>
{!compact &&
layout.locks.map((lock) => (
<Tooltip key={`${lock.atM}-${lock.index ?? ''}`} label={`${t('tours.paddle.lock')} ${km(lock.atM)}`}>
<span
data-testid="paddle-route-strip-lock"
className={`absolute top-1/2 z-10 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge rtl:translate-x-1/2 ${lock.bypassed ? 'opacity-45' : ''}`}
// rtl-lint-disable the band runs from the put-in, along the line, in both directions alike
style={{ insetInlineStart: `${(lock.atM / total) * 100}%` }}
>
<PaddleGlyph kind="lock" size={10} strokeWidth={2.6} />
</span>
</Tooltip>
))}
<div className="flex h-2.5 w-full gap-[2px] overflow-hidden rounded-full">
{layout.stretches.map((stretch) => {
const land = stretch.kind === 'portage';
const length = stretch.toM - stretch.fromM;
return (
<Tooltip
key={`${stretch.kind}-${stretch.fromM}`}
label={`${t(land ? 'tours.paddle.portage' : 'tours.paddle.time.water')} ${km(length)}`}
>
<span
data-stretch={stretch.kind}
className={`h-full min-w-[3px] ${land ? '' : 'bg-info'}`}
style={{
flexGrow: share(length),
flexBasis: 0,
...(land ? { background: PORTAGE_LINE_COLOR } : {}),
}}
/>
</Tooltip>
);
})}
</div>
</div>
{!compact && (
<div className="mt-1 flex justify-between font-geist tabular-nums text-content-faint" style={fs(10)}>
<span>{km(0)}</span>
<span>{km(total)}</span>
</div>
)}
</div>
);
}
@@ -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<string | null>(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 (
<label className="inline-flex items-center gap-0.5 rounded-sm focus-within:outline focus-within:outline-1 focus-within:outline-accent">
<input
data-testid={testId}
type="text"
inputMode="decimal"
aria-label={label}
disabled={disabled}
value={draft ?? (value === null ? '' : shown(value))}
placeholder={placeholder === null ? '–' : shown(placeholder)}
onFocus={() => setDraft(value === null ? '' : shown(value))}
onChange={(event) => setDraft(event.target.value.replace(/[^\d.,]/g, '').slice(0, 4))}
onBlur={() => {
if (draft !== null) commit(draft);
setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
className="w-[3ch] min-w-0 border-b border-dashed border-edge bg-transparent text-end font-geist tabular-nums text-content outline-none placeholder:text-content-secondary"
style={fs(10.5)}
/>
<span className="text-content-faint" style={fs(10.5)}>
km/h
</span>
</label>
);
}
/**
* 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 (
<span
className="inline-flex h-[20px] items-center rounded-full bg-surface-card px-1.5 font-geist tabular-nums text-content-secondary ring-1 ring-inset ring-edge-faint"
style={fs(10.5)}
>
{parts.map((part, index) => (
// The parts are fixed per tile; their order is their identity.
<span key={index} className={index > 0 ? 'ms-1.5 border-s border-edge-faint ps-1.5' : ''}>
{part}
</span>
))}
</span>
);
}
/** A measure inside a part's tile: a white badge within the grey one. */
function MeasureBadge({ children }: { children: ReactNode }) {
return (
<span
className="inline-flex h-[20px] items-center rounded-full bg-surface-card px-1.5 font-geist tabular-nums text-content-secondary ring-1 ring-inset ring-edge-faint"
style={fs(10.5)}
>
{children}
</span>
);
}
/**
* 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 (
<div
data-testid={testId}
className="flex min-w-0 flex-wrap items-center gap-1.5 rounded-[12px] bg-surface-secondary py-1.5 pe-1.5 ps-1.5"
>
<span
className="grid h-[22px] w-[22px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm"
style={color ? { color } : undefined}
>
{icon}
</span>
<span
className={`min-w-0 truncate font-semibold ${muted ? 'text-content-muted' : 'text-content'}`}
style={fs(12, 'body')}
>
{label}
</span>
{measures}
<span
className={`ms-auto inline-flex h-[22px] flex-none items-center rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums shadow-sm ${warn ? 'text-warning' : 'text-content'}`}
style={fs(11.5)}
>
{time}
</span>
</div>
);
}
type Translate = (key: string, params?: Record<string, unknown>) => 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 (
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-1.5">
<div className="min-w-0 flex-1">
<span className="block text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.time.estimate')}
</span>
<span className="flex items-center gap-2">
<span
data-testid="paddle-time-estimate"
className="font-geist font-semibold tabular-nums text-content"
style={fs(20)}
>
{formatPlannedTourDuration(total)}
</span>
{!complete && <SoftPill tone="warning">{t('tours.paddle.time.incomplete')}</SoftPill>}
</span>
</div>
{planned !== null && deviation !== null && (
<span
data-testid="paddle-time-planned"
className="inline-flex h-[24px] flex-none items-center gap-1 rounded-full bg-surface-secondary pe-1 ps-2 text-content-muted"
style={fs(11, 'body')}
>
{t('tours.paddle.time.plannedOwn')}
<MeasureBadge>{formatPlannedTourDuration(planned)}</MeasureBadge>
{deviation !== 0 && (
<span data-testid="paddle-time-deviation">
<SoftPill tone={deviation < 0 ? 'warning' : 'neutral'}>
{`${deviation > 0 ? '+' : '−'}${formatPlannedTourDuration(Math.abs(deviation))}`}
</SoftPill>
</span>
)}
</span>
)}
</div>
);
}
/** 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 ? (
<KmhChip
key="speed"
testId="paddle-time-water-speed"
label={t('tours.paddle.speed')}
value={paddleSpeed.value}
placeholder={paddleSpeed.startValue}
max={MAX_PADDLE_SPEED_KMH}
disabled={locked}
onChange={paddleSpeed.onChange}
/>
) : speed !== null ? (
`${speed.toLocaleString()} km/h`
) : null;
return (
<div className="flex flex-col gap-1.5">
<PartTile
testId="paddle-time-water"
icon={<PaddleGlyph kind="put_in" size={12} strokeWidth={2.4} />}
color={WATER_COLOR}
label={t('tours.paddle.time.waterShort')}
measures={<MeasureGroup parts={water === null ? [km(estimate.waterM)] : [km(estimate.waterM), water]} />}
time={minutes(estimate.waterMinutes)}
/>
{estimate.portageCount > 0 && (
<PartTile
testId="paddle-time-carry"
icon={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
color={PORTAGE_LINE_COLOR}
label={t('tours.paddle.time.carry')}
measures={
<MeasureGroup
parts={[
km(estimate.landM),
<KmhChip
key="speed"
testId="paddle-time-carry-speed"
label={t('tours.paddle.time.carrySpeed')}
value={portageSpeed.value}
placeholder={portageSpeed.startValue}
max={MAX_PORTAGE_SPEED_KMH}
disabled={locked}
onChange={portageSpeed.onChange}
/>,
]}
/>
}
time={minutes(estimate.carryMinutes)}
/>
)}
{estimate.portageCount > 0 && (
<PartTile
testId="paddle-time-out-in"
icon={<PaddleGlyph kind="take_out" size={12} strokeWidth={2.4} />}
color={PORTAGE_LINE_COLOR}
label={t('tours.paddle.time.outIn')}
measures={<MeasureBadge>{`${estimate.portageCount}×`}</MeasureBadge>}
time={minutes(estimate.portageExtraMinutes)}
/>
)}
{estimate.locksCounted > 0 && (
<PartTile
testId="paddle-time-locks"
icon={<PaddleGlyph kind="lock" size={12} strokeWidth={2.4} />}
label={t('tours.paddle.time.locks')}
measures={<MeasureBadge>{`${estimate.locksCounted}×`}</MeasureBadge>}
time={estimate.locksMissingTime > 0 && estimate.lockMinutes === 0 ? '–' : minutes(estimate.lockMinutes)}
warn={estimate.locksMissingTime > 0}
/>
)}
<PartTile
testId="paddle-time-breaks"
icon={<Timer size={12} strokeWidth={2.4} />}
label={t('tours.planner.breaksShort')}
time={minutes(breakMinutes)}
muted={breakMinutes === null}
/>
</div>
);
}
/**
* 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 (
<div data-testid="paddle-time-card" className="flex flex-col gap-3">
<EstimateHead
total={estimate.estimateMinutes + (breakMinutes ?? 0)}
complete={estimate.complete}
planned={planned}
t={t}
/>
<PaddleRouteStrip layout={layout} />
<PartTiles
estimate={estimate}
breakMinutes={breakMinutes}
locked={locked}
portageSpeed={portageSpeed}
paddleSpeed={paddleSpeed}
/>
{missing.length > 0 && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{missing.join(' ')}
</TourNotice>
)}
</div>
);
}
/** 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 (
<RailCard
icon={<Timer size={14} strokeWidth={2} />}
title={t('tours.paddle.time.title')}
aside={
<span className="flex flex-none items-center gap-1">
<SoftPill tone="info">{t('tours.paddle.time.assumption')}</SoftPill>
<InfoDot label={t('tours.paddle.time.assumption')} description={t('tours.paddle.time.assumptionInfo')} />
</span>
}
>
{children}
</RailCard>
);
}
/**
* 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 (
<PaddleTimeCardShell>
<PaddleEstimateBody
estimate={estimate}
layout={paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints))}
breakMinutes={planner.breakAdditionalMinutes}
planned={planner.plannedDurationMinutes}
locked={locked}
portageSpeed={{
value: planner.paddle.portageSpeedKmh,
startValue: DEFAULT_PORTAGE_SPEED_KMH,
onChange: planner.paddle.setPortageSpeedKmh,
}}
/>
</PaddleTimeCardShell>
);
}
@@ -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 (
<span
data-paddle-role={role}
className="inline-flex h-[20px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary px-2 font-geist leading-none text-content-secondary"
style={fs(10.5)}
>
<PaddleGlyph
kind={ROLE_GLYPH[role]}
size={11}
strokeWidth={2.4}
style={land ? { color: PORTAGE_LINE_COLOR } : undefined}
/>
{t(`tours.paddle.role.${role}`)}
</span>
);
}
/**
* 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<string | null>(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 (
<label className="inline-flex h-[26px] flex-none items-center gap-1 rounded-full border border-edge bg-surface-card pe-2.5 ps-2.5 transition-colors focus-within:border-accent">
<input
data-testid={testId}
type="text"
inputMode="numeric"
aria-label={label}
disabled={disabled}
value={draft ?? (value === null ? '' : String(value))}
placeholder={placeholder}
onFocus={() => setDraft(value === null ? '' : String(value))}
onChange={(event) => setDraft(event.target.value.replace(/[^\d]/g, '').slice(0, 3))}
onBlur={() => {
if (draft !== null) commit(draft);
setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setDraft(null);
event.currentTarget.blur();
}
}}
className="w-[3ch] min-w-0 bg-transparent text-end font-geist font-semibold tabular-nums text-content outline-none placeholder:font-normal placeholder:text-content-faint"
style={fs(12)}
/>
<span className="text-content-faint" style={fs(11)}>
min
</span>
</label>
);
}
/** One labelled value of an opened stop, its input on the right. */
function StopField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-content" style={fs(12, 'body')}>
{label}
</span>
{hint && (
<span className="block truncate text-content-faint" style={fs(10.5, 'body')}>
{hint}
</span>
)}
</span>
{children}
</div>
);
}
/** 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 (
<span
className={`flex-none rounded-full px-1.5 font-geist tabular-nums leading-[18px] ${missing ? 'bg-warning-soft text-warning' : 'bg-surface-secondary text-content-secondary'}`}
style={fs(10.5)}
>
{children}
</span>
);
}
/** 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 (
<button
type="button"
onClick={onRemove}
className="flex items-center gap-1 self-start rounded-full px-1 text-danger transition-opacity hover:opacity-80"
style={fs(11.5, 'body')}
>
<X size={12} strokeWidth={2.4} />
{label}
</button>
);
}
/**
* 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 (
<li data-testid={testId} className="flex min-w-0 flex-col gap-1 py-0.5 ps-[34px]">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className={`flex h-[28px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1.5 ps-1 text-start shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover ${muted ? 'opacity-60' : ''}`}
style={color ? { boxShadow: `inset 0 0 0 1px ${color}` } : undefined}
>
<span
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-content-secondary text-surface-card"
style={color ? { background: color } : undefined}
>
{glyph}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{title}
</span>
{pills}
<ChevronDown
size={13}
strokeWidth={2.2}
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card px-2.5 py-2">
{children}
</div>
)}
</li>
);
}
/**
* 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 (
<StopCard
testId="tour-planner-portage-leg"
glyph={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
color={PORTAGE_LINE_COLOR}
title={t('tours.paddle.portage')}
pills={
<>
{position && <StopPill>{position}</StopPill>}
<StopPill>{formatDistance(lengthM / 1000, unit ?? 'metric')}</StopPill>
<StopPill>{`+${extra} min`}</StopPill>
</>
}
>
<p className="m-0 leading-snug text-content-muted" style={fs(11.5, 'body')}>
{t('tours.paddle.portageInfo')}
</p>
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
<StopField
label={t('tours.paddle.portageExtra')}
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
>
<MinutesInput
testId="tour-planner-portage-extra"
label={t('tours.paddle.portageExtra')}
value={extraMinutes}
placeholder={String(DEFAULT_PORTAGE_EXTRA_MINUTES)}
disabled={disabled}
onChange={onExtraChange}
/>
</StopField>
</StopCard>
);
}
/**
* 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 ? (
<StopPill>{t('tours.paddle.lockBypassed')}</StopPill>
) : missing ? (
<StopPill missing>{t('tours.paddle.timeMissing')}</StopPill>
) : (
<StopPill>{`${known} min`}</StopPill>
);
return (
<StopCard
testId="tour-planner-lock"
glyph={<PaddleGlyph kind="lock" size={12} strokeWidth={2.4} />}
title={t('tours.paddle.lock')}
muted={bypassed}
pills={
<>
{position && <StopPill>{position}</StopPill>}
{lockPill}
</>
}
>
<StopField label={t('tours.paddle.lockWait')}>
<MinutesInput
testId="tour-planner-lock-wait"
label={t('tours.paddle.lockWait')}
value={lock.wait_minutes}
placeholder="–"
disabled={disabled}
onChange={(minutes) => onChange({ ...lock, wait_minutes: minutes })}
/>
</StopField>
<StopField label={t('tours.paddle.lockTransit')}>
<MinutesInput
testId="tour-planner-lock-transit"
label={t('tours.paddle.lockTransit')}
value={lock.transit_minutes}
placeholder="–"
disabled={disabled}
onChange={(minutes) => onChange({ ...lock, transit_minutes: minutes })}
/>
</StopField>
{!disabled && <RemoveStop label={t('tours.paddle.lockRemove')} onRemove={() => onChange(null)} />}
</StopCard>
);
}
@@ -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, unknown>) => 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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.6}
strokeLinecap="round"
strokeLinejoin="round"
className="flex-none"
style={{ transform: `rotate(${from + 180}deg)` }}
aria-hidden="true"
>
<path d="M12 20V4" />
<path d="M6 10l6-6 6 6" />
</svg>
);
}
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 (
<Tooltip label={info}>
<span
data-testid={testId}
className="inline-flex h-[24px] min-w-0 flex-none items-center gap-1 rounded-full bg-surface-secondary pe-[3px] ps-[3px] text-content-muted"
style={fs(10.5, 'body')}
>
{label && <span className="ps-1">{label}</span>}
<span
className="inline-flex h-[18px] flex-none items-center gap-1 rounded-full bg-surface-card px-1.5 font-geist font-semibold tabular-nums text-content shadow-sm"
style={fs(11)}
>
{icon}
{value}
</span>
</span>
</Tooltip>
);
}
/**
* 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 (
<div data-testid="paddle-wind-hours" className="flex items-end gap-[3px]">
{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 (
<Tooltip key={hour.hour} label={label}>
<span className="flex min-w-0 flex-1 flex-col items-center gap-1">
<span className="grid h-3.5 w-3.5 place-items-center text-content-secondary">
{from !== null && <FlowArrow from={from} size={11} />}
</span>
<span
className={`relative flex h-12 w-full items-end justify-center overflow-hidden rounded-[3px] ${known ? 'bg-surface-secondary' : 'border border-dashed border-edge'}`}
>
{hour.gusts_kmh !== null && (
<span className="absolute inset-x-0 bottom-0 bg-info-soft" style={{ height: height(hour.gusts_kmh) }} />
)}
{known && (
<span className="relative w-full rounded-t-[2px] bg-info" style={{ height: height(hour.speed_kmh) }} />
)}
</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(9.5)}>
{hour.hour}
</span>
</span>
</Tooltip>
);
})}
</div>
);
}
/** 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 (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-1">
{from !== null && (
<DayFigure
testId="paddle-wind-direction"
value={directionName(from, t) ?? ''}
icon={<FlowArrow from={from} size={11} />}
info={t('tours.paddle.wind.fromInfo', { direction: directionName(from, t) ?? '' })}
/>
)}
<DayFigure
testId="paddle-wind-mean"
label={t('tours.paddle.wind.mean')}
value={day?.speed_max_kmh == null ? '–' : `${day.speed_max_kmh} km/h`}
info={t('tours.paddle.wind.meanInfo')}
/>
<DayFigure
testId="paddle-wind-gusts"
label={t('tours.paddle.wind.gusts')}
value={day?.gusts_max_kmh == null ? '–' : `${day.gusts_max_kmh} km/h`}
info={t('tours.paddle.wind.gustsInfo')}
/>
</div>
<HourStrip hours={forecast.hours} t={t} />
<div className="-mt-1 flex items-center gap-3 text-content-muted" style={fs(10.5, 'body')}>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-[2px] bg-info" />
{t('tours.paddle.wind.legendMean')}
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-[2px] bg-info-soft ring-1 ring-inset ring-edge-faint" />
{t('tours.paddle.wind.legendGusts')}
</span>
</div>
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.wind.source', { source: forecast.source, time: fetched })}
</p>
</div>
);
}
/** 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 <div data-testid="paddle-wind-loading" className="h-24 animate-pulse rounded-[10px] bg-surface-secondary" />;
if (state.kind === 'error')
return (
<TourNotice tone="warning" icon={<RefreshCw size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2">
{t('tours.paddle.wind.error')}
<button type="button" onClick={wind.retry} className="font-semibold underline-offset-2 hover:underline">
{t('tours.paddle.wind.retry')}
</button>
</span>
</TourNotice>
);
if (state.kind === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.wind.offline')}</TourNotice>;
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 (
<TourNotice icon={<Wind size={13} strokeWidth={2} />}>
{t('tours.paddle.wind.beyond', { date: formatDate(from.toISOString().slice(0, 10), locale) })}
</TourNotice>
);
}
const key = state.kind === 'ready' ? 'tours.paddle.wind.past' : `tours.paddle.wind.${state.kind}`;
return <TourNotice icon={<Wind size={13} strokeWidth={2} />}>{t(key)}</TourNotice>;
}
/** 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 (
<div className="border-t border-edge-faint pt-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-1.5 text-start font-semibold text-content-secondary hover:text-content"
style={fs(11.5, 'body')}
>
<span className="min-w-0 flex-1 truncate">{t('tours.paddle.wind.hintsTitle')}</span>
<ChevronDown size={13} strokeWidth={2.2} className={`flex-none transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<ul className="m-0 mt-2 flex list-disc flex-col gap-1.5 ps-4 text-content-muted" style={fs(11.5, 'body')}>
{(['headwind', 'drift', 'crossing', 'gusts', 'local'] as const).map((hint) => (
<li key={hint}>{t(`tours.paddle.wind.hint.${hint}`)}</li>
))}
</ul>
)}
</div>
);
}
/**
* 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 (
<RailCard
icon={<Wind size={14} strokeWidth={2} />}
title={t('tours.paddle.wind.title')}
aside={
wind.tourDays.length > 1 ? (
<span className="flex flex-none items-center gap-1">
{wind.tourDays.map(({ day, index }) => (
<button
key={day.id}
type="button"
aria-pressed={wind.day?.day.id === day.id}
onClick={() => wind.chooseDay(day.id)}
className={`rounded-full px-2 font-semibold leading-[20px] transition-colors ${wind.day?.day.id === day.id ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary ring-1 ring-inset ring-edge-faint hover:text-content'}`}
style={fs(10.5)}
>
{t('dayplan.dayN', { n: index + 1 })}
</button>
))}
</span>
) : wind.day ? (
<SoftPill>{formatDate(wind.day.day.date, locale) || t('dayplan.dayN', { n: wind.day.index + 1 })}</SoftPill>
) : null
}
>
<div data-testid="paddle-wind-card" className="flex flex-col gap-3">
{ready ? <Forecast forecast={ready} t={t} locale={locale} /> : <Unavailable wind={wind} t={t} locale={locale} />}
<WindHints t={t} />
</div>
</RailCard>
);
}
/** 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 <PaddleWindCard wind={wind} />;
}
@@ -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 (
<button
type="button"
data-testid={testId}
aria-pressed={active}
disabled={disabled}
onClick={onPress}
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint enabled:hover:text-content'}`}
style={fs(11.5, 'body')}
>
<Plus size={12} strokeWidth={2.6} className="flex-none" />
<span className="flex-none">{icon}</span>
<span className="min-w-0 truncate">{children}</span>
</button>
);
}
/** 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 (
<RailCard
icon={<PaddleGlyph kind="portage" size={14} />}
title={t('tours.paddle.marks.title')}
>
<div data-testid="track-marks-card" className="flex flex-col gap-2.5">
{plan.canEdit && (
<div className="flex gap-1.5">
<PickButton
testId="track-marks-pick-portage"
active={plan.pick === 'portage'}
disabled={locked}
onPress={pick('portage')}
icon={<PaddleGlyph kind="portage" size={13} strokeWidth={2.2} />}
>
{t('tours.paddle.marks.markPortage')}
</PickButton>
<PickButton
testId="track-marks-pick-lock"
active={plan.pick === 'lock'}
disabled={locked}
onPress={pick('lock')}
icon={<PaddleGlyph kind="lock" size={13} strokeWidth={2.2} />}
>
{t('tours.paddle.marks.markLock')}
</PickButton>
</div>
)}
{hint && (
<TourNotice tone={plan.refused ? 'warning' : 'neutral'} icon={<MousePointerClick size={13} strokeWidth={2} />}>
{hint}
</TourNotice>
)}
{empty ? (
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.paddle.marks.empty')}
</p>
) : (
<ul className="m-0 -ms-[34px] flex list-none flex-col p-0">
{plan.marks.portages.map((portage, index) => (
<PortageLegRow
key={`p-${portage.from_m}`}
lengthM={portage.to_m - portage.from_m}
extraMinutes={portage.extra_minutes}
disabled={locked}
position={at(portage.from_m)}
onExtraChange={(minutes) => plan.edits.setPortageExtra(index, minutes)}
onRemove={() => plan.edits.removePortage(index)}
/>
))}
{plan.marks.locks.map((lock, index) => (
<LockRow
key={`l-${lock.at_m}-${index}`}
lock={lock}
bypassed={!!plan.layout.locks.find((stop) => stop.atM === lock.at_m)?.bypassed}
disabled={locked}
position={at(lock.at_m)}
onChange={(next) => plan.edits.setLock(index, next)}
/>
))}
</ul>
)}
{plan.dirty && plan.canEdit && (
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={plan.discard} disabled={plan.saving}>
{t('common.discard')}
</DialogButton>
<DialogButton
variant="primary"
icon={<Save size={14} strokeWidth={2} />}
onClick={() => void plan.save()}
disabled={plan.saving}
>
{t('common.save')}
</DialogButton>
</div>
)}
</div>
</RailCard>
);
}
/**
* 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 (
<>
<PaddleTimeCardShell>
<PaddleEstimateBody
estimate={plan.estimate}
layout={plan.layout}
breakMinutes={tour.break_additional_minutes ?? null}
planned={tour.planned_duration_minutes ?? null}
locked={locked}
portageSpeed={{
value: plan.speeds.portage,
startValue: plan.defaultPortageSpeed,
onChange: (portage) => plan.edits.setSpeeds({ portage }),
}}
paddleSpeed={{
value: plan.speeds.paddle,
startValue: plan.recommendedPaddleSpeed,
onChange: (paddle) => plan.edits.setSpeeds({ paddle }),
}}
/>
</PaddleTimeCardShell>
<TrackMarksCard plan={plan} />
</>
);
}
@@ -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<PaddleGlyphKind, readonly string[]> = {
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<PaddleObstacleKind, PaddleGlyphKind> = {
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) => `<path d="${d}"/>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
}
@@ -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<PaddleObstacleKind, Category> = {
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<Category, string> = {
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 `<div style="${style}">${glyph}</div>`;
}
/**
* 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) => `<path d="${d}"/>`).join('');
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" opacity="${opacity}">` +
`<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="#fff" stroke="${item.color}" stroke-width="${ring}"${dash}/>` + // theme-lint-disable marker disc
`<svg x="${offset}" y="${offset}" width="${glyph}" height="${glyph}" viewBox="0 0 24 24" fill="none" stroke="${item.color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">${paths}</svg></svg>`
);
}
/** 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' : ''}`;
}
@@ -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<Point>(
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]);
}
@@ -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<Loaded | null>(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(), []);
}
@@ -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]
),
};
}
@@ -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<number | null>(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<typeof useTourWind>;
@@ -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> | 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<typeof useTrackPaddlePlan>;
@@ -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<TourPlannerController, 'mode' | 'readOnlyGpxAnalysis'>
): 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]);
}
@@ -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()
})
@@ -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({
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
{conversion && <TourConversionSection conversion={conversion} />}
{trackPaddlePlan && <TrackPaddlePlanCards plan={trackPaddlePlan} tour={tour} />}
{tour.tour_type === 'kayak' && (
<PaddleWindSection placeId={tour.place_id} start={trackStart(analysis?.routeCoordinates)} />
)}
<RailCard icon={<Mountain size={14} strokeWidth={2} />} title={t('tours.planner.inspector.title')}>
{analysis ? (
<div className="flex flex-col gap-3">
@@ -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 <NeutralRail planner={planner} canEdit={canEdit} onOpenWanderer={onOpenWanderer}
/>
if (planner.mode.type === 'view-gpx')
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} />
return <GpxViewRail planner={planner} canEdit={canEdit} conversion={conversion} trackPaddlePlan={trackPaddlePlan} />
const isSavedEdit = planner.mode.type === 'edit-saved'
@@ -350,6 +368,12 @@ export function TourPlannerRail({ planner,
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
{planner.isPaddle && <PaddleTimeCard planner={planner} locked={locked} />}
{planner.isPaddle && (
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
)}
<TourDetailsSection planner={planner} locked={locked} onDifficultyChange={changeDifficulty} />
</div>
@@ -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) {
)
)}
</div>
{/* PAD-04: what the map does not know yet stays visible, never read as open water. */}
<TourNotice icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.paddle.noWaterData')}</TourNotice>
{/* PAD-04/05B: what the map knows about the water, and where it knows nothing, never read as open water. */}
<PaddleObstacleNotice />
{planner.paddleIncomplete && (
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
{t('tours.paddle.chooseBoat')}
@@ -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<HTMLButtonElement>) => 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}
</span>
{point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
{paddleRole !== undefined
? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
: point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
</span>
{name && !loopEnd && (
<span
@@ -280,9 +296,11 @@ function WaypointRowLabel({
}
/**
* The controls of a waypoint row, out on hover as a plan row's are: move it up
* or down, draw the leg after it by hand (not on a paddle tour, where every leg
* is, nor on a via ferrata), and remove it.
* The controls of a waypoint row, out on hover as a plan row's are: a menu for
* what is done now and then (move it up or down, draw the leg after it by
* hand on a hike, carry the boat from here or mark a lock on a paddle tour)
* and remove beside it. Two controls keep the place name readable in a narrow
* rail however many tools a tour type adds.
*/
function WaypointRowActions({
planner,
@@ -300,36 +318,57 @@ function WaypointRowActions({
isSaving: boolean;
}) {
const { t } = useTranslation();
const menu = useContextMenu();
const paddle = usePaddleWaypointActions(planner);
const free = point.fixedLeg?.kind === 'free';
const canDrawLeg = !planner.isPaddle && index < lastIndex && point.fixedLeg?.kind !== 'ferrata';
const portage = point.fixedLeg?.kind === 'portage';
const hasLeg = index < lastIndex;
const items = [
{ label: t('tours.planner.moveUp'), icon: ArrowUp, disabled: index === 0, onClick: () => 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 (
<span
className={`flex-none items-center gap-0.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}
className={`flex-none items-center gap-0.5 group-focus-within:flex group-hover:flex ${selected || menu.menu ? 'flex' : 'hidden'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => 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))}
>
<ArrowUp size={13} strokeWidth={2.2} />
<MoreHorizontal size={14} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
{canDrawLeg && (
<WaypointAction
label={t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg')}
disabled={isSaving}
active={free}
onAct={() => planner.toggleWaypointFreeLeg(point.id)}
>
<Spline size={13} strokeWidth={2.2} />
</WaypointAction>
)}
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
@@ -338,10 +377,53 @@ function WaypointRowActions({
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
<ContextMenu menu={menu.menu} onClose={menu.close} />
</span>
);
}
/**
* 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 && (
<PortageLegRow
lengthM={portage.toM - portage.fromM}
extraMinutes={portage.extraMinutes}
disabled={!editable}
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
/>
)}
{point.lock && (
<LockRow
lock={point.lock}
bypassed={!!lock?.bypassed}
disabled={!editable}
onChange={(next) => 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}
>
<WaypointRowLabel point={point} placeName={placeNames[point.id]} loopEnd={loopEnd} />
<WaypointRowLabel
point={point}
placeName={placeNames[point.id]}
loopEnd={loopEnd}
paddleRole={paddleRoleOf(point.id)}
/>
</button>
{canEdit && (
<WaypointRowActions
@@ -542,6 +633,15 @@ export default function TourWaypointList({
))}
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
{paddleLayout && (
<PaddleRowExtras
planner={planner}
point={point}
index={index}
layout={paddleLayout}
editable={canEdit && !isSaving}
/>
)}
</Fragment>
);
})}
@@ -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<string, PaddleRole | null> {
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,
});
}
@@ -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<DraftPayload['version']> = [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<DraftPayload>
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)
}
@@ -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 });
});
});
@@ -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<T extends RoutePoint>(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<TourFixedLeg, { kind: 'portage' }> {
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<T extends RoutePoint>(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<T extends RoutePoint>(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<T extends RoutePoint>(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<T extends RoutePoint>(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<T extends RoutePoint>(
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<T extends RoutePoint>(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;
});
}
@@ -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,
};
}
@@ -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
}
@@ -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<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh'>
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'>
): 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. */
@@ -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: [],
@@ -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<DraftPayload['version']> = [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<DraftPayload>
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,
@@ -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', () => {
@@ -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;
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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} /></LazyPanel>
recordedTrack={recordedTrack} trackPaddlePlan={trackPaddlePlan} canEdit={canPlaceEdit} canAssign={canDayEdit} /></LazyPanel>
</div>
</div>
<div className="absolute bottom-[10px] right-[10px] top-[10px] z-20 hidden md:block">
@@ -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. */}
<div
ref={attachSurface}
className={`absolute inset-0 ${placeOn ? PLACING_CURSOR : ''}`}
className={`absolute inset-0 ${placeOn || pickOnLine ? PLACING_CURSOR : ''}`}
data-testid="tour-map-surface"
data-placing={placeOn || undefined}
>
@@ -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}
@@ -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: {
@@ -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),
@@ -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,
};
}
@@ -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.
+98
View File
@@ -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<PaddleCoverageState, number> = { 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<PaddleObstaclesAround> {
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<PaddleObstaclesAround> {
if (isEffectivelyOffline()) throw new PaddleObstaclesOfflineError();
const answers = await Promise.all(tiles.map((tile) => paddleObstaclesApi.inBox(tile, signal)));
const features = new Map<string, PaddleFeature>();
const regions = new Map<string, PaddleCoverageRegion>();
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<string, PaddleFeature>();
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),
};
},
};
+45 -1
View File
@@ -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<TourDetailResponse> {
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<TourDetailResponse> {
const result = await recordedTrackApi.restore(tripId, placeId)
+23
View File
@@ -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<WindForecastResponse> {
if (isEffectivelyOffline()) throw new WindOfflineError();
return windApi.forecast(lat, lng, date, signal);
},
};
+51
View File
@@ -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<PaddleObstacleState>(() => EMPTY);
export function publishPaddleObstacles(next: Omit<PaddleObstacleState, 'selectedRef'>): 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);
}
+104
View File
@@ -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<TrackMarkToolState>(() => 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<TrackSpeeds>) =>
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 });
}
+102
View File
@@ -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 });
});
});
+123
View File
@@ -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;
}