mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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)),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user