feat(tours): plan a city walk with its stops, stays, importance and protection (CW-01, CW-02, CW-10)

City walk is a type of the planner: routed on foot without a hiking limit, on
the standard map. Each stop is edited in its own dialog, its name, its stay
(empty is not known, 0 a point passed through), its importance and whether a
proposed change must leave it; none of it plans the route again. The walk's
time is walking plus the stays, at least that much while a stay is unknown,
with no breaks on top.
This commit is contained in:
Maurice
2026-10-19 22:28:12 +02:00
parent 33255b168f
commit 5d65bbd03b
41 changed files with 1298 additions and 45 deletions
+7 -1
View File
@@ -1,4 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
@@ -62,6 +62,12 @@ export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
// CW-05: a city walk's own, each in the colour of the explore category it comes from.
museum: { key: 'museum', labelKey: 'tours.poi.cat.museum', Icon: Landmark, color: coreColor('museum') },
park: { key: 'park', labelKey: 'tours.poi.cat.park', Icon: Trees, color: coreColor('nature') },
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
}
// A Map rather than an object, so a category named after an Object.prototype member
@@ -0,0 +1,152 @@
import type { StationImportance } from '@trek/shared';
import { MapPin, ShieldCheck } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from '../../../i18n';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { EditorField, INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import { parseStay, withStationFields } from './stationModel';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
type Importance = StationImportance | 'normal';
const IMPORTANCES: readonly Importance[] = ['normal', 'optional', 'must'];
/**
* CW-02, CW-03, CW-10: one station of a city walk, as a dialog: its name, how
* long it is stayed at (empty is unknown, 0 a point passed through), how much
* it matters and whether a proposed change must leave it. Saving is one step
* of the history; nothing here plans the route again.
*/
export function StationDialog({
point,
fallbackName,
onSave,
onClose,
}: {
point: TourPlannerWaypoint;
/** The name the row shows while the station has none of its own. */
fallbackName?: string;
onSave: (next: TourPlannerWaypoint) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const labelId = useId();
const nameId = useId();
const stayId = useId();
const [name, setName] = useState(point.name ?? '');
const [stay, setStay] = useState(point.stayMinutes == null ? '' : String(point.stayMinutes));
const [importance, setImportance] = useState<Importance>(point.importance ?? 'normal');
const [protectedStation, setProtectedStation] = useState(point.protected === true);
const stayMinutes = parseStay(stay);
const stayInvalid = stayMinutes === undefined;
const save = () => {
if (stayInvalid) return;
onSave(
withStationFields(point, {
name,
stayMinutes,
importance: importance === 'normal' ? null : importance,
protected: protectedStation,
})
);
};
return (
<DialogShell
onClose={onClose}
labelledBy={labelId}
width="narrow"
onSubmit={save}
header={
<DialogHeader
tile={
<DialogTile>
<MapPin size={16} strokeWidth={2} />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={onClose}
eyebrow={t('tours.walk.station.eyebrow')}
title={name.trim() || fallbackName || t('tours.walk.station.unnamed')}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" type="submit" disabled={stayInvalid} data-testid="station-save">
{t('common.save')}
</DialogButton>
</DialogFooter>
}
>
<div className="flex flex-col gap-4" data-testid="station-dialog">
<EditorField label={t('tours.walk.station.name')} htmlFor={nameId}>
<input
id={nameId}
className={INPUT}
value={name}
maxLength={200}
placeholder={fallbackName ?? t('tours.walk.station.namePlaceholder')}
onChange={(event) => setName(event.target.value)}
/>
</EditorField>
<EditorField
label={t('tours.walk.station.stay')}
htmlFor={stayId}
hint={t('tours.walk.station.stayHint')}
error={stayInvalid ? t('tours.walk.station.stayInvalid') : undefined}
>
<input
id={stayId}
className={INPUT}
inputMode="numeric"
value={stay}
placeholder={t('tours.walk.station.stayUnknown')}
onChange={(event) => setStay(event.target.value)}
/>
</EditorField>
<div className="min-w-0">
<span className={LABEL}>{t('tours.walk.station.importance')}</span>
<Segmented<Importance>
value={importance}
onChange={setImportance}
label={t('tours.walk.station.importance')}
fill
options={IMPORTANCES.map((value) => ({ value, label: t(`tours.walk.importance.${value}`) }))}
/>
<span className="mt-1.5 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.walk.importanceHint.${importance}`)}
</span>
</div>
<div className="flex items-start gap-2.5">
<ShieldCheck size={15} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.walk.station.protected')}
</span>
<span className="leading-snug text-content-faint" style={fs(11, 'body')}>
{t('tours.walk.station.protectedHint')}
</span>
</span>
<ToggleSwitch
on={protectedStation}
onToggle={() => setProtectedStation((value) => !value)}
label={t('tours.walk.station.protected')}
/>
</div>
</div>
</DialogShell>
);
}
@@ -157,31 +157,34 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
const manual = planner.plannedDurationMinutes != null;
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
{/* CW-02: a city walk stops at its stations; their stays take the place of breaks. */}
{planner.tourType !== 'walk' && (
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
)}
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-planned-duration"
@@ -612,6 +612,7 @@ const ESTIMATE_KEYS = {
hike: { labelKey: 'tours.planner.walkingEstimated', shortKey: 'tours.planner.walkingEstimatedShort', infoLabelKey: 'tours.planner.walkingEstimatedInfoLabel', infoKey: 'tours.planner.walkingEstimatedInfo' },
bike: { labelKey: 'tours.bike.timeEstimated', shortKey: 'tours.bike.timeEstimatedShort', infoLabelKey: 'tours.bike.timeEstimatedInfoLabel', infoKey: 'tours.bike.timeEstimatedInfo' },
kayak: { labelKey: 'tours.paddle.timeEstimated', shortKey: 'tours.paddle.timeEstimatedShort', infoLabelKey: 'tours.paddle.timeEstimatedInfoLabel', infoKey: 'tours.paddle.timeEstimatedInfo' },
walk: { labelKey: 'tours.walk.timeEstimated', shortKey: 'tours.walk.timeEstimatedShort', infoLabelKey: 'tours.walk.timeEstimatedInfoLabel', infoKey: 'tours.walk.timeEstimatedInfo' },
} as const
/**
@@ -645,6 +646,7 @@ function UnreachableNotice({ planner }: { planner: TourPlannerController }) {
/** The time facts of the overview: the estimate (walking, riding or paddling), the breaks and the planned total. */
function plannerTimeFields(planner: TourPlannerController, t: Translate) {
const keys = ESTIMATE_KEYS[planner.tourType]
const stays = planner.stationStays
return [
{
label: t(keys.labelKey),
@@ -654,13 +656,25 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate) {
? t('tours.planner.walkingTimeUnknown')
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
stays
? {
// CW-02: a city walk's stays in place of breaks, the unknown ones named, never counted as nothing.
label: t('tours.walk.stays'),
value: stays.unknownStays > 0
? t('tours.walk.totalAtLeast', { time: formatPlannedTourDuration(stays.stayMinutes) })
: formatPlannedTourDuration(stays.stayMinutes),
}
: {
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
value: planner.plannedTotalMinutes == null
? t('tours.planner.walkingTimeUnknown')
: stays?.partial && planner.plannedDurationMinutes == null
? t('tours.walk.totalAtLeast', { time: formatPlannedTourDuration(planner.plannedTotalMinutes) })
: formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
}
@@ -17,6 +17,7 @@ import {
Navigation,
RefreshCw,
Repeat,
SquarePen,
Spline,
Trash2,
Unlink,
@@ -46,6 +47,8 @@ import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
import { RailCard } from '../tourParts';
import { StationDialog } from './StationDialog';
import { StationPills } from './stationParts';
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
import type { PaddleLegFailure } from './paddleRouting';
import { lineLengthM } from './tourFixedLegs';
@@ -297,6 +300,7 @@ function WaypointRowLabel({
loopEnd,
paddleRole,
linked = false,
station = false,
}: {
point: TourPlannerController['waypoints'][number];
placeName: string | undefined;
@@ -305,10 +309,12 @@ function WaypointRowLabel({
paddleRole?: PaddleRole | null;
/** PR1-10: the point stands for this place of the trip, named after it. */
linked?: boolean;
/** CW-01: a city walk's station, which shows its stay, importance and protection. */
station?: boolean;
}) {
const { t } = useTranslation();
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
const name = loopEnd ? t('tours.planner.loopEnd') : placeName;
const name = loopEnd ? t('tours.planner.loopEnd') : (point.name ?? placeName);
return (
<>
<span className="flex w-full min-w-0 items-center gap-1.5">
@@ -332,6 +338,7 @@ function WaypointRowLabel({
)}
{point.auto && <AutoPointPill />}
</span>
{station && !loopEnd && <StationPills point={point} />}
{linked && !loopEnd ? (
// A stop standing for a place of the trip says so where the coordinates would be (PR1-10).
<Tooltip label={t('tours.placePicker.linkedHint')}>
@@ -451,6 +458,7 @@ function WaypointRowActions({
selected,
isSaving,
onUnlink,
stationName,
}: {
planner: TourPlannerController;
point: WaypointPoint;
@@ -460,11 +468,21 @@ function WaypointRowActions({
isSaving: boolean;
/** PR1-10: given for a point that stands for a place; the place itself stays. */
onUnlink?: () => void;
/** CW-01: the name the row shows, for a station without one of its own. */
stationName?: string;
}) {
const { t } = useTranslation();
const menu = useContextMenu();
const paddle = usePaddleWaypointActions(planner);
const [stationOpen, setStationOpen] = useState(false);
const station = planner.tourType === 'walk' && !point.shaping;
const items: MenuItems = [
...(station
? [
{ label: t('tours.walk.station.edit'), icon: SquarePen, onClick: () => setStationOpen(true) },
{ divider: true } as const,
]
: []),
{
label: t('tours.planner.moveUp'),
icon: ArrowUp,
@@ -505,6 +523,17 @@ function WaypointRowActions({
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
<ContextMenu menu={menu.menu} onClose={menu.close} />
{stationOpen && (
<StationDialog
point={point}
fallbackName={stationName}
onClose={() => setStationOpen(false)}
onSave={(next) => {
planner.editWaypoints((current) => current.map((item) => (item.id === next.id ? next : item)));
setStationOpen(false);
}}
/>
)}
</span>
);
}
@@ -927,6 +956,7 @@ export default function TourWaypointList({
loopEnd={loopEnd}
paddleRole={paddleRoleOf(point.id)}
linked={!!linkedPlace}
station={planner.tourType === 'walk' && !point.shaping}
/>
</button>
{canEdit && (
@@ -938,6 +968,7 @@ export default function TourWaypointList({
selected={selected}
isSaving={isSaving}
onUnlink={point.linkedPlaceId ? () => tripPlaces.unlink(point.id) : undefined}
stationName={linkedPlace?.name ?? placeNames[point.id]}
/>
)}
</div>
@@ -0,0 +1,62 @@
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
/**
* CW-02: a city walk's time, walking and staying kept apart. A stay nobody
* entered is unknown, never nothing: the total then counts only what is
* known and says it is at least that much. A point passed through is a stay
* of 0, entered on purpose.
*/
export interface WalkTimes {
walkingMinutes: number | null;
/** The stays entered, summed. */
stayMinutes: number;
/** Stations whose stay nobody entered. */
unknownStays: number;
/** Walking and the known stays; null while the walking time is unknown. */
totalMinutes: number | null;
/** The total leaves out a stay or the walking time that is not known. */
partial: boolean;
}
/** The stations of a city walk: its points that are no shaping points. */
export function stationsOf(points: readonly TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.filter((point) => !point.shaping);
}
export function walkTimes(points: readonly TourPlannerWaypoint[], walkingMinutes: number | null): WalkTimes {
const stations = stationsOf(points);
const stayMinutes = stations.reduce((sum, station) => sum + (station.stayMinutes ?? 0), 0);
const unknownStays = stations.filter((station) => station.stayMinutes == null).length;
return {
walkingMinutes,
stayMinutes,
unknownStays,
totalMinutes: walkingMinutes === null ? null : walkingMinutes + stayMinutes,
partial: unknownStays > 0,
};
}
/** What the station dialog edits; the rest of the point stays as it is. */
export type StationFields = Pick<TourPlannerWaypoint, 'name' | 'stayMinutes' | 'importance' | 'protected'>;
/** The point with the fields as edited, each one left out where it is empty. */
export function withStationFields(point: TourPlannerWaypoint, fields: StationFields): TourPlannerWaypoint {
const { name: _name, stayMinutes: _stay, importance: _importance, protected: _protected, ...rest } = point;
const name = fields.name?.trim();
return {
...rest,
...(name ? { name } : {}),
...(fields.stayMinutes != null ? { stayMinutes: fields.stayMinutes } : {}),
...(fields.importance ? { importance: fields.importance } : {}),
...(fields.protected ? { protected: true } : {}),
};
}
/** A stay as typed: empty is unknown, a whole number from 0 to a day is minutes, anything else is not taken. */
export function parseStay(text: string): number | null | undefined {
const trimmed = text.trim();
if (trimmed === '') return null;
if (!/^\d{1,4}$/.test(trimmed)) return undefined;
const minutes = Number(trimmed);
return minutes <= 24 * 60 ? minutes : undefined;
}
@@ -0,0 +1,38 @@
import { Clock, ShieldCheck } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
import { formatPlannedTourDuration } from '../tourPresentation';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
/**
* CW-02, CW-03, CW-10: what a city walk's station row says besides its name:
* the stay (or that it is not known), an importance other than normal, and a
* protection, each a quiet pill.
*/
export function StationPills({ point }: { point: TourPlannerWaypoint }) {
const { t } = useTranslation();
return (
<span className="flex flex-wrap items-center gap-1" data-testid="station-pills">
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
{point.stayMinutes == null
? t('tours.walk.station.stayUnknownShort')
: point.stayMinutes === 0
? t('tours.walk.station.passThrough')
: formatPlannedTourDuration(point.stayMinutes)}
</SoftPill>
{point.importance && (
<SoftPill tone={point.importance === 'must' ? 'warning' : undefined}>
{t(`tours.walk.importance.${point.importance}`)}
</SoftPill>
)}
{point.protected && (
<Tooltip label={t('tours.walk.station.protectedHint')}>
<span className="inline-flex" aria-label={t('tours.walk.station.protected')}>
<SoftPill icon={<ShieldCheck size={10} strokeWidth={2.2} aria-hidden="true" />} />
</span>
</Tooltip>
)}
</span>
);
}
@@ -52,6 +52,8 @@ export interface TourPlannerFactsInput {
/** A paddle tour's estimate on the water; null for a hike. */
paddleEstimate: { waterMinutes: number | null; estimateMinutes: number | null } | null;
routeAnalysis: RouteGeometryAnalysis | null;
/** CW-02: a city walk's stays, summed as far as they are known; null for any other type, which takes its breaks. */
stationStayMinutes?: number | null;
/** The router's own time, seconds: a bike tour's riding time; stands in for a hike until the heights are in. */
durationSeconds: number | null;
}
@@ -69,7 +71,8 @@ function invalidMinutes(value: number | null): boolean {
function movingMinutes(input: TourPlannerFactsInput, routerMinutes: number | null): number | null {
const { paddleEstimate, routeAnalysis } = input;
if (paddleEstimate) return paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes;
if (input.tourType === 'bike') return routerMinutes;
// A bike tour is timed by the router for its profile, a city walk by the router on foot.
if (input.tourType === 'bike' || input.tourType === 'walk') return routerMinutes;
return routeAnalysis?.gain != null && routeAnalysis.loss != null
? hikingTimeMinutes(routeAnalysis.distanceKm, routeAnalysis.gain, routeAnalysis.loss)
: routerMinutes;
@@ -92,16 +95,17 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
hiking_preferences: null,
};
}
const isHike = input.tourType === 'hike';
return {
tour_type: input.tourType,
max_hiking_difficulty: isBike ? null : input.maxHikingDifficulty,
max_hiking_difficulty: isHike ? input.maxHikingDifficulty : null,
bike_profile: isBike ? input.bikeProfile : null,
boat_type: null,
water_context: null,
paddle_speed_kmh: null,
portage_speed_kmh: null,
paddle_lake_style: null,
hiking_preferences: isBike ? null : input.hikingPreferences,
hiking_preferences: isHike ? input.hikingPreferences : null,
};
}
@@ -114,8 +118,9 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
const { durationSeconds, breakAdditionalMinutes, plannedDurationMinutes } = input;
const routerMinutes = durationSeconds === null ? null : Math.round(durationSeconds / 60);
const walkingDurationMinutes = movingMinutes(input, routerMinutes);
const automaticPlannedTotalMinutes =
walkingDurationMinutes === null ? null : walkingDurationMinutes + (breakAdditionalMinutes ?? 0);
// A city walk's stops are its stays; it has no breaks on top, so nothing counts twice.
const onTop = input.tourType === 'walk' ? (input.stationStayMinutes ?? 0) : (breakAdditionalMinutes ?? 0);
const automaticPlannedTotalMinutes = walkingDurationMinutes === null ? null : walkingDurationMinutes + onTop;
const { boatType, waterContext } = input.paddle;
return {
isPaddle,
@@ -1,4 +1,4 @@
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
import type { PaddleLock, PlannableTourType, StationImportance, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
import type { AutoBase } from './paddleRouting';
import { keepJoinedFixedLegs } from './tourFixedLegs';
import { alignFreeLegs } from './tourRouteActions';
@@ -21,6 +21,18 @@ export interface TourPlannerWaypoint {
carryNext?: boolean;
/** PR1-10: the place of the trip this point stands for, chosen on purpose; the point keeps its own spot. */
linkedPlaceId?: number;
/** CW-03: the point's saved key, the same across saves, which a city walk's visits hang on. */
key?: string;
/** The point's own name: a city walk's station, a place found along the route. */
name?: string | null;
/** CW-02: a station's stay; null not known, 0 a point passed through. */
stayMinutes?: number | null;
/** CW-03: how much a station matters; absent is normal. */
importance?: StationImportance | null;
/** CW-10: a station a proposed change must leave where it is. */
protected?: boolean;
/** CW-04: the station's opening hours as OpenStreetMap writes them. */
openingHours?: string | null;
}
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
@@ -34,6 +46,12 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
...(point.linked_place_id ? { linkedPlaceId: point.linked_place_id } : {}),
...(point.key ? { key: point.key } : {}),
...(point.name ? { name: point.name } : {}),
...(point.stay_minutes != null ? { stayMinutes: point.stay_minutes } : {}),
...(point.importance ? { importance: point.importance } : {}),
...(point.protected ? { protected: true } : {}),
...(point.opening_hours ? { openingHours: point.opening_hours } : {}),
}));
}
@@ -42,7 +60,9 @@ export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () =>
* keeps and nothing of the editor's own (ids, automatic marks, a portage
* waiting for its next point).
*/
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[], tourType?: PlannableTourType): TourWaypoint[] {
// A stay, an importance and a protection belong to a city walk's stations; another type sends none.
const walk = tourType === 'walk';
return points.map((point, sequence) => ({
lat: point.lat,
lng: point.lng,
@@ -52,6 +72,12 @@ export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWa
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
...(point.linkedPlaceId ? { linked_place_id: point.linkedPlaceId } : {}),
...(point.key ? { key: point.key } : {}),
...(point.name ? { name: point.name } : {}),
...(walk && point.stayMinutes != null ? { stay_minutes: point.stayMinutes } : {}),
...(walk && point.importance ? { importance: point.importance } : {}),
...(walk && point.protected ? { protected: true } : {}),
...(point.openingHours ? { opening_hours: point.openingHours } : {}),
}));
}
@@ -103,6 +103,18 @@ export async function routeWalkingTour(
}, signal)
}
/**
* CW-01: a city walk, on foot between its stations. No hiking limit is sent,
* so the router keeps to the ways it takes for walking anyway (T1), and no
* route choice of a hike.
*/
export async function routeCityWalk(
waypoints: LandWaypoint[],
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'walking', {}, signal)
}
/** BIK-01: a bike tour, routed for its profile; a leg drawn by hand is kept as drawn. */
export async function routeBikeTour(
waypoints: LandWaypoint[],
@@ -5,6 +5,7 @@ import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions'
import { walkTimes } from './stationModel'
import { hasCompleteElevation, useTourRoute } from './useTourRoute'
import { normalizePlannerWaypoints, plannerWaypointsOf, ridablePoints, sameWaypointSnapshot, savedWaypointsOf, unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints'
import { openedTourFields, tourPlannerFacts } from './tourPlannerFacts'
@@ -478,12 +479,14 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const routedLegLengths = paddleRoute && routeCurrent ? paddleRoute.legLengths : undefined
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
// CW-02: a city walk's stays, what of them is known.
const stationStays = useMemo(() => (tourType === 'walk' ? walkTimes(waypoints, null) : null), [tourType, waypoints])
const facts = useMemo(() => tourPlannerFacts({
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, website, plannedDurationMinutes, breakAdditionalMinutes,
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds,
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stationStayMinutes: stationStays?.stayMinutes ?? null,
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, tourType, waterContext, website])
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, stationStays, tourType, waterContext, website])
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, typeFields } = facts
@@ -774,7 +777,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
website: parsedWebsiteOrNull(website),
...typeFields,
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: savedWaypointsOf(waypoints),
waypoints: savedWaypointsOf(waypoints, tourType),
duration_seconds: savedDurationSeconds,
planned_duration_minutes: plannedDurationMinutes,
break_additional_minutes: breakAdditionalMinutes,
@@ -834,7 +837,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
description, setDescription, website, setWebsite, websiteInvalid,
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid, stationStays,
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
@@ -14,6 +14,7 @@ import { isTourRoute, type TourRouteUnreachable } from './tourRouteOutcome';
import {
enrichTourElevations,
routeBikeTour,
routeCityWalk,
routeWalkingTour,
type TourElevationPoint,
type TourRoutePart,
@@ -104,7 +105,9 @@ async function routeOnLand({ inputs, plannedKey, signal, stale, setPlanned, setU
const routed =
tourType === 'bike'
? await routeBikeTour(waypoints, bikeProfile, signal)
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
: tourType === 'walk'
? await routeCityWalk(waypoints, signal)
: await routeWalkingTour(waypoints, signal, maxHikingDifficulty, hikingPreferences);
if (stale()) return;
if (!isTourRoute(routed)) {
setUnreachable(routed);