mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user