fix(tours): keep a city walk on foot across the water, mark a crossing by boat apart from the walking time and save it only once taken

This commit is contained in:
Maurice
2026-10-21 13:16:43 +02:00
parent 41f3267503
commit 6e13f7e655
72 changed files with 2312 additions and 109 deletions
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaRunOnFoot, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -630,3 +630,61 @@ describe('valhallaTraceAttributes (BIK-01)', () => {
expect(asked).toBe(false)
})
})
describe('crossings by boat on a walk (CW-12)', () => {
/** A walk's leg as the public instance answers one: the boat boards at point 1 and lands at point 2. */
const ferryLeg = (maneuvers?: unknown[]) => ({
shape: SHAPE,
summary: { length: 1.5, time: 900, has_ferry: true },
...(maneuvers ? { maneuvers } : {}),
})
// The San Tomà to Ca' Rezzonico crossing as measured on Venice, laid onto the test shape.
const boarding = [
{ type: 1, begin_shape_index: 0, end_shape_index: 1, time: 100, length: 0.2 },
{ type: 28, ferry: true, begin_shape_index: 1, end_shape_index: 2, time: 744.959, length: 1.236 },
]
const walked = { shape: SHAPE, summary: { length: 1, time: 700, has_ferry: false } }
it('VALHALLA-FERRY-001: reads where a leg goes by boat off its maneuvers, with the crossing\'s own length and time', () => {
const run = runFrom({ trip: { legs: [ferryLeg(boarding)] } })
expect(run?.legs[0].ferrySpans).toEqual([{ from: 1, to: 2, metres: 1236, seconds: 744.959 }])
})
it('VALHALLA-FERRY-002: the joined line keeps each crossing on its own points', () => {
const run = runFrom({ trip: { legs: [walked, ferryLeg(boarding)] } })
// The second leg does not start where the first ends, so its three points follow the first three.
expect(run?.total.coordinates).toHaveLength(6)
expect(run?.total.ferrySpans).toEqual([{ from: 4, to: 5, metres: 1236, seconds: 744.959 }])
expect(run?.legs[0]).not.toHaveProperty('ferrySpans')
})
it('VALHALLA-FERRY-003: a ferry the maneuvers do not place takes the whole leg, its time unknown', () => {
const run = runFrom({ trip: { legs: [ferryLeg()] } })
expect(run?.legs[0].ferrySpans).toEqual([{ from: 0, to: 2, metres: 0, seconds: null }])
})
it('VALHALLA-FERRY-004: crossings that touch are one, and a crossing with no time leaves the sum unknown', () => {
const run = runFrom({
trip: {
legs: [ferryLeg([
{ type: 28, ferry: true, begin_shape_index: 0, end_shape_index: 1, time: 60, length: 0.1 },
{ type: 28, ferry: true, begin_shape_index: 1, end_shape_index: 2, length: 0.2 },
])],
},
})
expect(run?.legs[0].ferrySpans).toEqual([{ from: 0, to: 2, metres: 300, seconds: null }])
})
it('VALHALLA-FERRY-005: binds no point to a boat line when asked, and only then', async () => {
const bodies: Array<{ locations: Array<Record<string, unknown>> }> = []
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
bodies.push(await request.json() as { locations: Array<Record<string, unknown>> })
return HttpResponse.json({ trip: { legs: [walked] } })
}))
const points = [{ lat: 45.4342798, lng: 12.338111 }, { lat: 45.4367609, lng: 12.3287531 }]
await valhallaRunOnFoot(points, undefined, { use_ferry: 0 }, { exclude_ferry: true })
await valhallaRun(points, 'walking', [], undefined, { use_ferry: 0 })
expect(bodies[0].locations.map(location => location.search_filter)).toEqual([{ exclude_ferry: true }, { exclude_ferry: true }])
expect(bodies[1].locations.every(location => !('search_filter' in location))).toBe(true)
})
})
+119 -7
View File
@@ -100,6 +100,27 @@ const CHUNK_PAUSE_MS = 1100
*/
const CHUNK_RETRIES = 1
/**
* A stretch of a leg the router takes by boat, by the indices of the leg's
* line where the boat leaves and lands, with the crossing's own length in
* metres and its time in seconds (null where the router gave none).
*/
export interface FerrySpan {
from: number
to: number
metres: number
seconds: number | null
}
/**
* What the router may bind a point to. `exclude_ferry` keeps a point off a
* ferry's edge: a stop beside a pier, bound onto the boat line, would make the
* route ride the boat to its next landing however the costing weighs ferries.
*/
export interface SearchFilter {
exclude_ferry?: boolean
}
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
export interface ValhallaLeg {
coordinates: [number, number][]
@@ -111,6 +132,8 @@ export interface ValhallaLeg {
hasToll: boolean
hasHighway: boolean
hasFerry: boolean
/** CW-12: where the leg goes by boat, read off its maneuvers; absent where it does not. */
ferrySpans?: FerrySpan[]
}
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
@@ -195,6 +218,37 @@ export async function valhallaRun(
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
): Promise<ValhallaRun | null> {
return chainRun(waypoints, profile, avoid, signal, { costingOptions, searchFilter: null })
}
/**
* CW-12: a chain walked on foot, every point bound off the boat lines. Measured
* on Venice: a stop by a vaporetto pier bound onto the boat line ended the
* route on the ferry to its next landing, which `use_ferry` cannot weigh away
* because the edge is where the route starts, not a choice along it.
*/
export function valhallaRunOnFoot(
waypoints: Waypoint[],
signal: AbortSignal | undefined,
costingOptions: Readonly<CostingOptions>,
searchFilter: Readonly<SearchFilter>,
): Promise<ValhallaRun | null> {
return chainRun(waypoints, 'walking', [], signal, { costingOptions, searchFilter })
}
/** How a chain is asked for besides its profile and avoidance. */
interface RunAsk {
costingOptions: Readonly<CostingOptions>
searchFilter: Readonly<SearchFilter> | null
}
async function chainRun(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal: AbortSignal | undefined,
ask: RunAsk,
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
@@ -208,7 +262,7 @@ export async function valhallaRun(
// rate limit on the last piece of a split day would otherwise throw away the pieces
// that already answered and ask for all of them again — three times over, against a
// host that allows one request a second.
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal, costingOptions), signal)
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal, ask), signal)
// A day is one answer or none. Half a day of legs would be worse than no answer:
// the schedule chains leg times blindly and cannot tell that it is missing some, so
// it would print a complete, plausible, wrong timetable rather than a gap.
@@ -319,6 +373,7 @@ function routeBody(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
costingOptions: Readonly<CostingOptions> = {},
searchFilter: Readonly<SearchFilter> | null = null,
): Record<string, unknown> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
@@ -328,7 +383,12 @@ function routeBody(
const options: CostingOptions = { ...costingOptions }
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
return {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
locations: waypoints.map(w => ({
lat: w.lat,
lon: w.lng,
radius: SNAP_RADIUS_M,
...(searchFilter ? { search_filter: searchFilter } : {}),
})),
costing,
costing_options: { [costing]: options },
directions_options: { units: 'kilometers' },
@@ -445,10 +505,10 @@ async function requestRun(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
signal: AbortSignal | undefined,
{ costingOptions, searchFilter }: RunAsk,
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions, searchFilter), signal)
return data === null ? null : runFrom(data)
}
@@ -508,10 +568,14 @@ export function alternatesFrom(data: unknown): ValhallaLeg[] | null {
*/
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
const coordinates: [number, number][] = []
const ferrySpans: FerrySpan[] = []
for (const leg of legs) {
const last = coordinates[coordinates.length - 1]
const first = leg.coordinates[0]
const continues = last && first && last[0] === first[0] && last[1] === first[1]
// Where the leg's first point lands on the joined line, so its boat stretches keep pointing at their own points.
const offset = continues ? coordinates.length - 1 : coordinates.length
for (const span of leg.ferrySpans ?? []) ferrySpans.push({ ...span, from: span.from + offset, to: span.to + offset })
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
}
return {
@@ -523,6 +587,7 @@ function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
hasToll: legs.some(l => l.hasToll),
hasHighway: legs.some(l => l.hasHighway),
hasFerry: legs.some(l => l.hasFerry),
...(ferrySpans.length ? { ferrySpans } : {}),
}
}
@@ -560,19 +625,66 @@ export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
const measure = measured(summary, decodePolyline(leg.shape, 6))
if (!measure) return null
legs.push(measure)
const ferrySpans = measure.hasFerry ? ferrySpansOf(leg.maneuvers, measure.coordinates.length) : []
legs.push(ferrySpans.length ? { ...measure, ferrySpans } : measure)
}
return { legs, total: joinLegs(legs) }
}
interface ValhallaSummary { [key: string]: number | boolean | undefined }
interface ValhallaManeuver {
type?: number
ferry?: boolean
begin_shape_index?: number
end_shape_index?: number
time?: number
length?: number
}
function tripOf(data: unknown) {
return (data as {
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
trip?: {
legs?: { shape?: string; summary?: ValhallaSummary; maneuvers?: ValhallaManeuver[] }[]
summary?: ValhallaSummary
}
})?.trip
}
/** Valhalla's maneuver type for boarding a ferry; the maneuver also carries `ferry: true` for its crossing. */
const FERRY_ENTER = 28
/**
* CW-12: the stretches of a leg the router takes by boat, read off its
* maneuvers: a crossing is the maneuver that boards the ferry, from the point
* it starts at to the one it ends at, with its own length and time. Measured
* on Venice, the San Tomà to Ca' Rezzonico line came back as one maneuver of
* type 28, ferry true, 1.236 km and 745 s over shape points 24 to 46, then the
* walk off it. Crossings that touch are one. A leg the summary says has a
* ferry but whose maneuvers do not say where is taken whole, without a time:
* the crossing is somewhere on it, and none of it is counted as walked.
*/
function ferrySpansOf(maneuvers: ValhallaManeuver[] | undefined, points: number): FerrySpan[] {
const spans: FerrySpan[] = []
for (const maneuver of maneuvers ?? []) {
if (maneuver.ferry !== true && maneuver.type !== FERRY_ENTER) continue
const from = Number(maneuver.begin_shape_index)
const to = Number(maneuver.end_shape_index)
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to <= from || to >= points) continue
const metres = Number.isFinite(maneuver.length) ? Number(maneuver.length) * 1000 : 0
const seconds = Number.isFinite(maneuver.time) ? Number(maneuver.time) : null
const previous = spans[spans.length - 1]
if (previous && previous.to === from) {
previous.to = to
previous.metres += metres
previous.seconds = previous.seconds === null || seconds === null ? null : previous.seconds + seconds
} else {
spans.push({ from, to, metres, seconds })
}
}
return spans.length || points < 2 ? spans : [{ from: 0, to: points - 1, metres: 0, seconds: null }]
}
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
if (!coordinates.length) return null
@@ -54,6 +54,7 @@ import { TourGpxExportBarButton } from '../TourGpxExportButton'
import { TourSaveToCollectionBarButton } from '../collection/TourSaveToCollectionButton'
import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisionNotice'
import { WalkImportDraftNotice } from '../walk/WalkImportDialog'
import { WalkFerryNotice } from '../walk/WalkFerryNotice'
import { WalkRoutePendingNotice } from '../walk/WalkRoutePending'
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
import { TourScopeSummary } from '../TourScopeSummary'
@@ -152,8 +153,8 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
function retryable(planner: TourPlannerController): boolean {
// A point no way reaches stays unreachable until it moves; asking again changes nothing.
if (planner.error === 'paddle-route' || planner.error === 'unreachable') return false
// A point no way reaches stays unreachable until it moves, a walk's leg no way joins too; asking again changes nothing.
if (planner.error === 'paddle-route' || planner.error === 'unreachable' || planner.error === 'walk-gap') return false
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
}
@@ -487,6 +488,7 @@ export function TourPlannerRail({ planner,
{recordedTrack?.available && <RecordedTrackCard recordedTrack={recordedTrack} disabled={isSaving} />}
<UnreachableNotice planner={planner} />
<WalkFerryNotice planner={planner} />
<TourStageLossNotice planner={planner} />
<TourStageDayNotice planner={planner} />
@@ -731,7 +733,7 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate): TourMe
}
if (planner.stationStays) {
return walkTimeFields({ walking: estimate, walkingMinutes: planner.walkingDurationMinutes, stays: planner.stationStays,
manualMinutes: planner.plannedDurationMinutes, t })
manualMinutes: planner.plannedDurationMinutes, ferries: planner.walkFerries.crossings, t })
}
return [
{
@@ -16,6 +16,7 @@ import type { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageWay, type usePortageRouting } from '../paddle/usePortageRouting';
import { SkiLegFailureRow, SkiLegSteps } from '../ski/SkiLegRows';
import { WalkFerryLegRow } from '../walk/WalkFerryLegRow';
import type { PaddleLegFailure } from './paddleRouting';
import { lineLengthM } from './tourFixedLegs';
import type { TourPlannerController } from './useTourPlanner';
@@ -244,6 +245,13 @@ export function WaypointLegRows({
))}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} ride={planner.isHorse} />}
{point.fixedLeg?.kind === 'horse_route' && <HorseLegRow leg={point.fixedLeg} />}
{point.fixedLeg?.kind === 'ferry' && (
<WalkFerryLegRow
leg={point.fixedLeg}
editable={editable}
onRelease={() => planner.walkFerries.release(point.id)}
/>
)}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{planner.isSki && <SkiLegExtras planner={planner} point={point} index={index} editable={editable} />}
{failure && (
@@ -8,6 +8,7 @@ import type {
TourWaypointRole,
} from '@trek/shared';
import { rideLegs, withoutHorseRoutes } from '../horse/horseLegs';
import { walkOnly } from '../walk/walkFerry';
import type { AutoBase } from './paddleRouting';
import { keepJoinedFixedLegs } from './tourFixedLegs';
import { alignFreeLegs } from './tourRouteActions';
@@ -122,8 +123,8 @@ export function ridablePoints(points: TourPlannerWaypoint[]): TourPlannerWaypoin
/**
* Roles from the order; free legs follow their points, and a paddle tour keeps
* its portages and locks. A ride (HOR-01) has every leg on a horse route or
* drawn by hand; any other type gives a horse route section back to the router.
* A stage end (MDT-01) stays only where one can be: at a stop between start and
* drawn by hand; any other type gives a horse route section back to the router,
* and only a city walk keeps a crossing by boat (CW-12). A stage end (MDT-01) stays only where one can be: at a stop between start and
* end. A mark that ends up elsewhere after a change (its point moved to an end,
* made a shaping point) is dropped here, and the planner says so rather than
* move it to another point.
@@ -133,7 +134,7 @@ export function normalizePlannerWaypoints(
tourType: PlannableTourType = 'hike'
): TourPlannerWaypoint[] {
const joined = keepJoinedFixedLegs(alignFreeLegs(points, tourType === 'kayak'));
const typed = tourType === 'horse' ? rideLegs(joined) : withoutHorseRoutes(joined);
const typed = tourType === 'horse' ? rideLegs(joined) : withoutHorseRoutes(walkOnly(joined, tourType));
return typed.map((point, index) => {
const role = index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via';
if (!point.stageEnd || (role === 'via' && !point.shaping)) return { ...point, role };
@@ -260,15 +260,16 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
/**
* The leg after this waypoint drawn by hand, or routed again (on a paddle
* tour: on the water). A via ferrata, a portage and a horse route section keep
* their line; the last point has no leg to change.
* tour: on the water). A via ferrata, a portage, a horse route section and a
* walk's crossing by boat keep their line; the last point has no leg to change.
*/
export function toggleFreeLeg<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];
const kind = point?.fixedLeg?.kind;
if (!point || !next || kind === 'ferrata' || kind === 'horse_route' || isPortage(point.fixedLeg)) return [...points];
if (!point || !next || kind === 'ferrata' || kind === 'horse_route' || kind === 'ferry' || isPortage(point.fixedLeg))
return [...points];
const copy = [...points];
if (point.fixedLeg?.kind === 'free') {
const { fixedLeg: _dropped, ...rest } = point;
@@ -12,9 +12,19 @@ export interface TourRouteUnreachable {
snappedMeters: number;
}
/** Whether the router's answer is a route, rather than nothing or a point it could not reach. */
export function isTourRoute(value: TourRouteResult | TourRouteUnreachable | null): value is TourRouteResult {
return !!value && !('unreachable' in value);
/**
* CW-12: two points of a city walk no way joins, on foot or by boat. `gap` is
* the leg between them, by the place along the tour of the point it starts at.
*/
export interface TourRouteGap {
gap: number;
}
/** Whether the router's answer is a route, rather than nothing, a point it could not reach or a leg it found no way for. */
export function isTourRoute(
value: TourRouteResult | TourRouteUnreachable | TourRouteGap | null
): value is TourRouteResult {
return !!value && !('unreachable' in value) && !('gap' in value);
}
/** How far the router may move a point onto its way before the point counts as unreachable for the tour's type. */
@@ -1,4 +1,4 @@
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-004: how the planner map draws the parts of a tour (PAD-03, PAD-11, SKI-02).
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-006: how the planner map draws the parts of a tour (PAD-03, PAD-11, SKI-02, CW-12).
import { describe, expect, it } from 'vitest';
import { tourRouteStyle } from './tourRouteStyle';
import type { TourRoutePart } from './tourRouting';
@@ -60,4 +60,30 @@ describe('tourRouteStyle', () => {
expect(style.colors?.map((color) => color?.line)).toEqual(['#d51e1e', '#6b7280', '#1f2937', undefined]);
expect(style.colors?.[0]?.casing).toBe('white');
});
it('FE-TOUR-ROUTE-STYLE-005: a city walk dots its crossing by boat in the water blue, and only that stretch', () => {
const style = tourRouteStyle([part(), part({ mark: 'ferry' }), part()], false, {
...PAINT,
ferry: 'blue',
stale: 'grey',
});
expect(style.walking).toEqual([false, true, false]);
expect(style.colors).toEqual([undefined, { line: 'blue', casing: 'white' }, undefined]);
});
it('FE-TOUR-ROUTE-STYLE-006: a route kept from before a failed plan is greyed whole, its dots kept', () => {
const style = tourRouteStyle(
[part(), part({ mark: 'ferry' })],
false,
{ ...PAINT, ferry: 'blue', stale: 'grey' },
true
);
expect(style.walking).toEqual([false, true]);
expect(style.colors).toEqual([
{ line: 'grey', casing: 'white' },
{ line: 'grey', casing: 'white' },
]);
// Without the grey resolved the route keeps its own colours rather than losing them.
expect(tourRouteStyle([part()], false, PAINT, true).colors).toBeNull();
});
});
@@ -5,13 +5,17 @@ import type { TourRoutePart } from './tourRouting';
* PAD-03, PAD-11: the paddle paints of the planner map, resolved from the
* paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and
* boat passes in amber, a gap in the data or a leg no route was found for in
* red, each over a rim in the map's own white.
* red, each over a rim in the map's own white. CW-12: a city walk's crossing
* by boat in water blue, and a route kept from before a failed plan in grey
* (`useWalkRoutePaint`).
*/
export interface RoutePaint {
portage: string;
rapids: string;
gap: string;
casing: string;
ferry?: string;
stale?: string;
}
export interface TourRouteStyle {
@@ -28,24 +32,34 @@ function paddleColor(part: TourRoutePart, paint: RoutePaint): { line: string; ca
return undefined;
}
function partColor(part: TourRoutePart, isPaddle: boolean, paint: RoutePaint) {
if (part.ski) return { line: skiPaintOf(part.ski), casing: paint.casing };
if (part.mark === 'ferry' && paint.ferry) return { line: paint.ferry, casing: paint.casing };
return isPaddle ? paddleColor(part, paint) : undefined;
}
/**
* How the planner map draws the parts of a tour being edited. What no router
* planned is dotted, so a routed path and a hand-drawn one never look alike:
* a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs
* and stretches drawn by hand. A paddle tour also colours what the paddler
* should look at.
* and stretches drawn by hand, and a city walk's crossing by boat (CW-12),
* which is no walking. A paddle tour also colours what the paddler should
* look at. A `stale` route, kept while the points it was planned for changed
* and no new one could be planned, is greyed whole rather than hidden.
*/
export function tourRouteStyle(
parts: readonly TourRoutePart[] | null,
isPaddle: boolean,
paint: RoutePaint
paint: RoutePaint,
stale = false
): TourRouteStyle {
if (!parts?.length) return { walking: null, colors: null };
const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted');
// SKI-02: a ski tour's stretches in the colours of their grades and kinds, over the same rim.
const colors = parts.map((part) =>
part.ski ? { line: skiPaintOf(part.ski), casing: paint.casing } : isPaddle ? paddleColor(part, paint) : undefined
const walking = parts.map(
(part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted' || part.mark === 'ferry'
);
const greyed = stale && paint.stale ? { line: paint.stale, casing: paint.casing } : null;
// SKI-02: a ski tour's stretches in the colours of their grades and kinds, over the same rim.
const colors = parts.map((part) => greyed ?? partColor(part, isPaddle, paint));
return {
walking: walking.some(Boolean) ? walking : null,
colors: colors.some(Boolean) ? colors : null,
@@ -1,14 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { valhallaBase, valhallaRun, valhallaTurn } = vi.hoisted(() => ({
const { valhallaBase, valhallaRun, valhallaRunOnFoot, valhallaTurn } = vi.hoisted(() => ({
valhallaBase: vi.fn(),
valhallaRun: vi.fn(),
valhallaRunOnFoot: vi.fn(),
valhallaTurn: vi.fn(async () => {}),
}))
vi.mock('../../Map/valhallaRoute', () => ({
valhallaBase,
valhallaRun,
valhallaRunOnFoot,
valhallaTurn,
}))
@@ -231,9 +233,11 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
})
it('TOUR-ROUTING-033: a city walk goes on foot with no hiking limit, no choice of a hike, and avoids ferries (CW-01)', async () => {
valhallaRun.mockResolvedValueOnce(run([3, 4]))
valhallaRunOnFoot.mockResolvedValueOnce(run([3, 4]))
expect(await routeCityWalk(waypoints)).toMatchObject({ distanceMeters: 1500, durationSeconds: 300 })
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, { use_ferry: 0 })
// CW-12: and binds no stop to a boat line.
expect(valhallaRunOnFoot).toHaveBeenCalledWith(waypoints, undefined, { use_ferry: 0 }, { exclude_ferry: true })
expect(valhallaRun).not.toHaveBeenCalled()
})
it('TOUR-ROUTING-034: a run goes on foot, keeps off tracks and ferries where it can, with no hiking limit (RUN-01)', async () => {
@@ -256,3 +260,89 @@ describe('routing on land, bike profiles and unreachable points (BIK-01)', () =>
})
})
})
describe('a city walk across the water (CW-12)', () => {
beforeEach(() => {
valhallaRun.mockReset().mockResolvedValue(null)
valhallaRunOnFoot.mockReset().mockResolvedValue(null)
valhallaBase.mockReset().mockReturnValue('https://valhalla.test')
})
// The reported walk from Piazza San Marco: the second leg crosses on the boat from point 1 to point 2 of its line.
const sanMarco = { lat: 45.4342798, lng: 12.338111 }
const saoneri = { lat: 45.4367609, lng: 12.3287531 }
const moresco = { lat: 45.4361113, lng: 12.3214155 }
const walkedLeg = { coordinates: [[45.4343, 12.3381], [45.4355, 12.333], [45.4368, 12.3288]], distance: 900, duration: 650, hasFerry: false }
const boatLeg = {
coordinates: [[45.4368, 12.3288], [45.4352, 12.3275], [45.4331, 12.3262], [45.4361, 12.3214]],
distance: 1800,
duration: 1100,
hasFerry: true,
ferrySpans: [{ from: 1, to: 2, metres: 1236, seconds: 745 }],
}
const crossingRun = {
legs: [walkedLeg, boatLeg],
total: {
coordinates: [...walkedLeg.coordinates, ...boatLeg.coordinates.slice(1)],
distance: 2700,
duration: 1750,
ferrySpans: [{ from: 3, to: 4, metres: 1236, seconds: 745 }],
},
snapped: [3, 4, 5].map((meters) => ({ asked: [0, 0], at: [0, 0], meters })),
}
it('TOUR-ROUTING-036: a crossing the router still takes is its own part, out of the walking time and named on its leg', async () => {
valhallaRunOnFoot.mockResolvedValueOnce(crossingRun)
const routed = (await routeCityWalk([sanMarco, saoneri, moresco])) as TourRouteResult
expect(routed.parts.map((part) => [part.coordinates.length, part.mark ?? null])).toEqual([[4, null], [2, 'ferry'], [2, null]])
// 1750 s routed, of which the boat's 745 s are no walking.
expect(routed.durationSeconds).toBe(1005)
expect(routed.distanceMeters).toBe(2700)
expect(routed.ferries).toEqual([
{ leg: 1, geometry: boatLeg.coordinates, crossing: [1, 2], metres: 1236, seconds: 745, walkSeconds: 355, accepted: false },
])
})
it('TOUR-ROUTING-037: a walk over the bridges carries no crossing, and a hike is never bound off the boat lines', async () => {
valhallaRunOnFoot.mockResolvedValueOnce({ ...crossingRun, legs: [walkedLeg], total: { coordinates: walkedLeg.coordinates, distance: 900, duration: 650 } })
const routed = (await routeCityWalk([sanMarco, saoneri])) as TourRouteResult
expect(routed).not.toHaveProperty('ferries')
expect(routed.durationSeconds).toBe(650)
valhallaRun.mockResolvedValueOnce(crossingRun)
const hike = (await routeWalkingTour([sanMarco, saoneri, moresco])) as TourRouteResult
expect(valhallaRun.mock.calls[0]).toHaveLength(5)
expect(hike).not.toHaveProperty('ferries')
expect(hike.durationSeconds).toBe(1750)
})
it('TOUR-ROUTING-038: a ferry leg taken on purpose keeps its line and its walk, the router asked only around it', async () => {
const ferry = {
kind: 'ferry' as const,
geometry: boatLeg.coordinates as [number, number][],
crossing: [1, 2] as [number, number],
minutes: 12,
walk_minutes: 6,
}
valhallaRunOnFoot.mockResolvedValueOnce({ total: { coordinates: walkedLeg.coordinates, distance: 900, duration: 650 }, legs: [walkedLeg], snapped: [] })
const routed = (await routeCityWalk([sanMarco, { ...saoneri, fixedLeg: ferry }, moresco])) as TourRouteResult
expect(valhallaRunOnFoot).toHaveBeenCalledTimes(1)
expect(routed.durationSeconds).toBe(650 + 6 * 60)
expect(routed.parts.map((part) => part.mark ?? null)).toEqual([null, 'ferry', null])
expect(routed.ferries).toMatchObject([{ leg: 1, crossing: [1, 2], seconds: 720, walkSeconds: 360, accepted: true }])
})
it('TOUR-ROUTING-039: two stops no way joins are named by halving the walk, and a host that answers after all names none', async () => {
const answered = { total: { coordinates: [[0, 0], [1, 1]], distance: 1, duration: 1 }, legs: [], snapped: [] }
const stops = [sanMarco, saoneri, moresco, { lat: 45.43, lng: 12.31 }, { lat: 45.42, lng: 12.3 }]
// The whole walk fails, its first half [0..2] answers, then [2..3] fails: the gap is the leg from stop 2.
valhallaRunOnFoot.mockResolvedValueOnce(null).mockResolvedValueOnce(answered).mockResolvedValueOnce(null)
expect(await routeCityWalk(stops)).toEqual({ gap: 2 })
expect(valhallaRunOnFoot.mock.calls.map((call) => (call[0] as unknown[]).length)).toEqual([5, 3, 2])
valhallaRunOnFoot.mockReset().mockResolvedValueOnce(null).mockResolvedValue(answered)
expect(await routeCityWalk([sanMarco, saoneri])).toBeNull()
})
})
@@ -8,10 +8,19 @@ import type {
TourFixedLeg,
} from '@trek/shared'
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun, valhallaTurn, type CostingOptions } from '../../Map/valhallaRoute'
import {
valhallaBase,
valhallaRun,
valhallaRunOnFoot,
valhallaTurn,
type CostingOptions,
type FerrySpan,
type SearchFilter,
type ValhallaLeg,
} from '../../Map/valhallaRoute'
import { hikingCosting, TOURS_MAX_HIKING_DIFFICULTY } from './hikingCosting'
import { densifyLine, lineLengthM } from './tourFixedLegs'
import { MAX_TOUR_SNAP_M, type TourRouteUnreachable } from './tourRouteOutcome'
import { MAX_TOUR_SNAP_M, type TourRouteGap, type TourRouteUnreachable } from './tourRouteOutcome'
export interface TourRoutePart {
coordinates: [number, number][]
@@ -22,20 +31,43 @@ export interface TourRoutePart {
/**
* PAD-11: on a routed paddle tour, a stretch the paddler should look at:
* whitewater or a boat pass, a gap in the data the router bridged, or a leg
* it could not plan (drawn straight, and nothing checked it).
* it could not plan (drawn straight, and nothing checked it). CW-12: on a
* city walk, a crossing by boat, which is no walking.
*/
mark?: 'rapids' | 'gap' | 'unrouted'
mark?: 'rapids' | 'gap' | 'unrouted' | 'ferry'
/** SKI-02: a stretch of a ski tour the piste router planned, coloured by what it is; `summer` a summer path. */
ski?: { kind: SkiSegmentKind | 'summer'; difficulty: SkiRunDifficulty | null; scheme?: SkiColourScheme }
}
/**
* CW-12: a city walk's crossing by boat, on the leg from one point to the
* next: the leg's line, where on it the boat runs, and the boat's own length
* and time apart from the walk to it and on from it. `accepted` once the
* travellers took it as a ferry leg of the walk; until then the walk is not
* saved.
*/
export interface WalkFerryCrossing {
/** The leg, by the place along the tour of the point it starts at. */
leg: number
geometry: [number, number][]
/** The points of `geometry` the boat leaves from and lands at. */
crossing: [number, number]
metres: number
/** Null where the router gave no time for the boat. */
seconds: number | null
walkSeconds: number
accepted: boolean
}
export interface TourRouteResult {
coordinates: [number, number][]
/** The same line cut where it changes between routed and hand-drawn, for the map to tell them apart. */
parts: TourRoutePart[]
distanceMeters: number
/** Null where no time is worked out at all: a ride (HOR-01). */
/** Null where no time is worked out at all: a ride (HOR-01). On a city walk, the walking alone (CW-12). */
durationSeconds: number | null
/** CW-12: a city walk's crossings by boat, in the order of its legs; absent where it takes none. */
ferries?: WalkFerryCrossing[]
}
/** How fast a fixed leg is reckoned until the heights are in: a via ferrata climbs slowly, a free leg is walked. */
@@ -82,20 +114,25 @@ export async function routeWalkingTour(
}
/**
* 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. Ferries are avoided: a walk is walked, and with the
* router's default Venice was crossed on the vaporetto lines instead of the
* bridges. Where no way on land exists the router still takes the ferry.
* CW-01, CW-12: 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. Ferries are avoided: a walk is walked, and with
* the router's default Venice was crossed on the vaporetto lines instead of
* the bridges (the reported Piazza San Marco to Hotel Moresco walk came back
* as 3.29 km and 37 min with 1.24 km of it on the San Tomà boat; avoided, it
* is 2.79 km over the bridges). No station is bound to a boat line either.
* Where no way on land exists the router still takes the ferry, and the walk
* says so (`ferries`); where no way exists at all, the leg is named (`gap`).
*/
export async function routeCityWalk(
waypoints: LandWaypoint[],
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
return routeOnLand(waypoints, 'walking', CITY_WALK_COSTING, signal)
): Promise<TourRouteResult | TourRouteUnreachable | TourRouteGap | null> {
return routeOnLand(waypoints, 'walking', CITY_WALK_COSTING, signal, CITY_WALK_BINDING)
}
const CITY_WALK_COSTING: Readonly<CostingOptions> = { use_ferry: 0 }
const CITY_WALK_BINDING: Readonly<SearchFilter> = { exclude_ferry: true }
/**
* RUN-01, RUN-02: what each run profile asks Valhalla's pedestrian costing
@@ -177,29 +214,142 @@ export function drawnTour(waypoints: LandWaypoint[]): TourRouteResult | null {
return { coordinates, parts: mergeParts(lines), distanceMeters, durationSeconds: null }
}
/** CW-12: the line of a leg cut where the boat runs, the crossing marked as no walking. */
function splitAtCrossings(coordinates: [number, number][], spans: readonly Pick<FerrySpan, 'from' | 'to'>[]): TourRoutePart[] {
const parts: TourRoutePart[] = []
let at = 0
for (const { from, to } of spans) {
if (from > at) parts.push({ coordinates: coordinates.slice(at, from + 1), free: false })
parts.push({ coordinates: coordinates.slice(from, to + 1), free: false, mark: 'ferry' })
at = to
}
if (at < coordinates.length - 1) parts.push({ coordinates: coordinates.slice(at), free: false })
return parts
}
/**
* CW-12: the crossing of a routed leg, from where its first boat stretch
* leaves to where its last one lands. The walk on the leg is the router's time
* less the boat's; where the router gave the boat no time, the walk is the
* leg's time shared out by the length walked.
*/
function crossingOf(leg: ValhallaLeg, index: number): WalkFerryCrossing | null {
const spans = leg.ferrySpans ?? []
const first = spans[0]
const last = spans[spans.length - 1]
if (!first || !last) return null
const crossing: [number, number] = [first.from, last.to]
const metres = spans.reduce((sum, span) => sum + span.metres, 0) || lineLengthM(leg.coordinates.slice(first.from, last.to + 1))
const seconds = spans.every((span) => span.seconds !== null)
? spans.reduce((sum, span) => sum + (span.seconds ?? 0), 0)
: null
const length = lineLengthM(leg.coordinates)
const walkSeconds = seconds !== null
? Math.max(0, leg.duration - seconds)
: length > 0 ? leg.duration * Math.max(0, 1 - metres / length) : 0
return { leg: index, geometry: leg.coordinates, crossing, metres, seconds, walkSeconds, accepted: false }
}
/** CW-12: a ferry leg the travellers took, as the walk counts it: the boat apart from the walk. */
function acceptedCrossing(leg: Extract<TourFixedLeg, { kind: 'ferry' }>, index: number): WalkFerryCrossing {
const geometry = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
const crossing: [number, number] = [leg.crossing[0], leg.crossing[1]]
return {
leg: index,
geometry,
crossing,
metres: lineLengthM(geometry.slice(crossing[0], crossing[1] + 1)),
seconds: leg.minutes === null ? null : leg.minutes * 60,
walkSeconds: leg.walk_minutes * 60,
accepted: true,
}
}
/**
* CW-12: the leg of a run no way joins, found by halving the run: whichever
* half still fails holds it. Null where every piece answers after all, a
* failure of the host rather than of the way, or once the run is called off.
*/
async function gapOf(
run: Array<Pick<Waypoint, 'lat' | 'lng'>>,
first: number,
ask: (points: Array<Pick<Waypoint, 'lat' | 'lng'>>) => Promise<unknown>,
signal?: AbortSignal,
): Promise<TourRouteGap | null> {
let low = 0
let high = run.length - 1
let failed = false
while (high - low > 1) {
const middle = Math.floor((low + high) / 2)
const answer = await ask(run.slice(low, middle + 1))
if (signal?.aborted) return null
if (answer) {
low = middle
} else {
high = middle
failed = true
}
}
if (!failed && (await ask(run.slice(low, high + 1)))) return null
return signal?.aborted ? null : { gap: first + low }
}
// Only a walk bound off the boat lines (CW-12) names a leg no way joins; every other tour fails as before.
function routeOnLand(
waypoints: LandWaypoint[],
mode: 'walking' | 'cycling',
costingOptions: Readonly<CostingOptions>,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null>
function routeOnLand(
waypoints: LandWaypoint[],
mode: 'walking' | 'cycling',
costingOptions: Readonly<CostingOptions>,
signal: AbortSignal | undefined,
searchFilter: Readonly<SearchFilter>,
): Promise<TourRouteResult | TourRouteUnreachable | TourRouteGap | null>
async function routeOnLand(
waypoints: LandWaypoint[],
mode: 'walking' | 'cycling',
costingOptions: Readonly<CostingOptions>,
signal?: AbortSignal,
): Promise<TourRouteResult | TourRouteUnreachable | null> {
searchFilter: Readonly<SearchFilter> | null = null,
): Promise<TourRouteResult | TourRouteUnreachable | TourRouteGap | null> {
const lines: TourRoutePart[] = []
const ferries: WalkFerryCrossing[] = []
let distanceMeters = 0
let durationSeconds = 0
let unreachable: TourRouteUnreachable | null = null
let unreachable: TourRouteUnreachable | TourRouteGap | null = null
// A city walk binds no point to a boat line (CW-12); no other type asks for that.
const ask = (points: Array<Pick<Waypoint, 'lat' | 'lng'>>) => {
const asked = points.map(({ lat, lng }) => ({ lat, lng }))
return searchFilter
? valhallaRunOnFoot(asked, signal, costingOptions, searchFilter)
: valhallaRun(asked, mode, [], signal, costingOptions)
}
// `first`: where the run starts along the tour, so a point it cannot reach is named by its place in the tour.
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>, first: number) => {
if (run.length < 2) return true
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), mode, [], signal, costingOptions)
if (!routed) return false
const routed = await ask(run)
if (!routed) {
if (searchFilter && !signal?.aborted) unreachable = await gapOf(run, first, ask, signal)
return false
}
const far = routed.snapped.findIndex((point) => point.meters > MAX_TOUR_SNAP_M)
if (far >= 0) {
unreachable = { unreachable: first + far, snappedMeters: Math.round(routed.snapped[far].meters) }
return false
}
lines.push({ coordinates: routed.total.coordinates, free: false })
const crossings = searchFilter
? (routed.legs ?? []).flatMap((leg, index) => crossingOf(leg, first + index) ?? [])
: []
lines.push(...(crossings.length
? splitAtCrossings(routed.total.coordinates, routed.total.ferrySpans ?? [])
: [{ coordinates: routed.total.coordinates, free: false }]))
ferries.push(...crossings)
distanceMeters += routed.total.distance
durationSeconds += routed.total.duration
- crossings.reduce((sum, crossing) => sum + (routed.legs[crossing.leg - first]?.duration ?? 0) - crossing.walkSeconds, 0)
return true
}
@@ -214,6 +364,16 @@ async function routeOnLand(
// A section of a horse route is a ride's (HOR-01), which no router plans and no clock times.
if (leg.kind === 'horse_route') return null
if (!(await routeRun(run, runStart))) return unreachable
if (leg.kind === 'ferry') {
const crossing = acceptedCrossing(leg, i)
lines.push(...splitAtCrossings(crossing.geometry, [{ from: crossing.crossing[0], to: crossing.crossing[1] }]))
ferries.push(crossing)
distanceMeters += lineLengthM(crossing.geometry)
durationSeconds += crossing.walkSeconds
run = [waypoints[i + 1]]
runStart = i + 1
continue
}
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 === 'ferrata' ? drawn : densifyLine(drawn)
@@ -230,18 +390,27 @@ async function routeOnLand(
// One line: each part after the first starts where the one before it ended, so its first point goes.
const coordinates = lines.flatMap((line, index) => (index === 0 ? line.coordinates : line.coordinates.slice(1)))
return { coordinates, parts: mergeParts(lines), distanceMeters, durationSeconds }
return { coordinates, parts: mergeParts(lines), distanceMeters, durationSeconds, ...(ferries.length ? { ferries } : {}) }
}
/** 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. */
/**
* 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; a crossing by boat stays a
* line of its own.
*/
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.land === !!part.land) {
if (previous && previous.free === part.free && !!previous.land === !!part.land && previous.mark === part.mark) {
previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)]
} else {
merged.push({ coordinates: [...part.coordinates], free: part.free, ...(part.land ? { land: true } : {}) })
merged.push({
coordinates: [...part.coordinates],
free: part.free,
...(part.land ? { land: true } : {}),
...(part.mark ? { mark: part.mark } : {}),
})
}
}
return merged
@@ -29,6 +29,7 @@ import { savedSkiSeed } from '../ski/savedSkiRoute'
import { useSkiFields } from '../ski/useSkiFields'
import { lostStageEnds } from '../stages/tourStages'
import { useTourStages } from '../stages/useTourStages'
import { useWalkFerries } from '../walk/useWalkFerries'
/** What a tour planned from an import starts with. */
export interface DerivedTourDraft {
@@ -226,7 +227,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
invalidateDetailRequest()
}, [tripId, invalidateDetailRequest])
/** A field other than the route changed: the draft is unsaved again, and a finished save is no longer current. */
const markEdited = useCallback(() => {
if (!editPermissionRef.current) return false
@@ -467,7 +467,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (next) restoreStep(next)
}, [redoStep, restoreStep, snapshot])
const setName = useCallback((value: string) => {
if (value !== name && markEdited()) setNameState(value)
}, [markEdited, name])
@@ -488,7 +487,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
if (value !== breakAdditionalMinutes && markEdited()) setBreakAdditionalMinutesState(value)
}, [breakAdditionalMinutes, markEdited])
/**
* PAD-01, BIK-01, RUN-01, HOR-01, SKI-02: the type, as one step of the history
* with the route, since a paddle tour draws every leg by hand, neither a bike
@@ -809,7 +807,6 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const focusOwner = mode.type === 'view-gpx' || mode.type === 'edit-saved' ? `${mode.type}:${mode.placeId}` : mode.type
useEffect(() => { setRouteProfileFocus(null) }, [focusOwner, routeAnalysis, readOnlyGpxAnalysis, readOnlyGpxTour])
useEffect(() => {
if (!hasUnsavedChanges) return
const draft: DraftPayload = { version: 11, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
@@ -830,8 +827,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
return () => window.removeEventListener('beforeunload', warn)
}, [hasUnsavedChanges])
// CW-12: a city walk's crossings by boat; one the travellers have not taken on purpose holds the save.
const walkFerries = useWalkFerries({ tourType, ferries: routing.ferries, routeCurrent, walkGap: routing.walkGap, waypoints, editWaypoints: commitWaypoints })
const canSave = canEdit && !isSaving && hasUnsavedChanges && name.trim().length > 0 && status === 'ready'
&& routeCurrent && hasCompleteElevation(enrichedGeometry)
&& routeCurrent && hasCompleteElevation(enrichedGeometry) && !walkFerries.blocksSave
&& description.length <= 2000 && !websiteInvalid && !plannedDurationInvalid && !breakAdditionalInvalid && !plannedTotalInvalid
&& !paddleIncomplete && !(isPaddle && paddleSpeedInvalid) && !runPaceMissing && !runPaceInvalid && !skiMissing
@@ -932,7 +931,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint, editWaypoints: commitWaypoints,
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
...shaping, legVariants, roundHikes, paddleRoute, skiRoute, routeCurrent, stages,
...shaping, legVariants, roundHikes, paddleRoute, skiRoute, routeCurrent, stages, walkFerries,
undo, redo, canUndo: history.canUndo, canRedo: history.canRedo, historyMark: history.mark,
status, error, retry, isSaving,
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds, unreachable,
@@ -0,0 +1,174 @@
import { act, renderHook } from '@testing-library/react';
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
() => ({
routeWalkingTour: vi.fn(),
routeCityWalk: vi.fn(),
enrichTourElevations: vi.fn(),
createTour: vi.fn(),
detailTour: vi.fn(),
updateTour: vi.fn(),
})
);
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
import type { TourRouteResult, WalkFerryCrossing } from './tourRouting';
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
// FE-TOUR-WALK-FERRY-001 to FE-TOUR-WALK-FERRY-003: a city walk the router can only join by boat (CW-12).
const SAN_MARCO: [number, number] = [45.4342798, 12.338111];
const ZATTERE: [number, number] = [45.4296, 12.3264];
const REDENTORE: [number, number] = [45.4268, 12.33];
const boatLine: [number, number][] = [ZATTERE, [45.4291, 12.3266], [45.4275, 12.3282], REDENTORE];
const crossing = (accepted: boolean): WalkFerryCrossing => ({
leg: 1,
geometry: boatLine,
crossing: [1, 2],
metres: 268,
seconds: 396,
walkSeconds: 120,
accepted,
});
const routedWith = (ferry: WalkFerryCrossing | null): TourRouteResult => ({
coordinates: [SAN_MARCO, ZATTERE, ...boatLine.slice(1)],
parts: [
{ coordinates: [SAN_MARCO, ZATTERE, boatLine[1]], free: false },
{ coordinates: boatLine.slice(1, 3), free: false, mark: 'ferry' },
{ coordinates: boatLine.slice(2), free: false },
],
distanceMeters: 1900,
durationSeconds: 1020,
...(ferry ? { ferries: [ferry] } : {}),
});
const heights = (line: [number, number][]) => line.map(([lat, lng]) => [lat, lng, 2] as [number, number, number]);
const answered = (body: TourCreateRequest): TourCreateResponse => ({
tour: {
place_id: 61,
name: body.name,
tour_type: 'walk',
distance: 1.9,
elevation_gain: 0,
elevation_loss: 0,
duration: 17,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
planned: false,
caution: false,
revision: 1,
},
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })),
});
const TO_GIUDECCA: DerivedTourDraft = {
name: 'Venice to the Redentore',
difficulty: 2,
points: [
{ lat: SAN_MARCO[0], lng: SAN_MARCO[1], name: 'Piazza San Marco' },
{ lat: ZATTERE[0], lng: ZATTERE[1], name: 'Zattere' },
{ lat: REDENTORE[0], lng: REDENTORE[1], name: 'Il Redentore' },
],
inPlace: false,
tourType: 'walk',
};
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
}
describe('useTourPlanner, a city walk across the water (CW-12)', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
routeWalkingTour.mockReset();
routeCityWalk.mockReset().mockResolvedValue(routedWith(crossing(false)));
enrichTourElevations.mockReset().mockImplementation(async (line: [number, number][]) => heights(line));
createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body));
detailTour.mockReset();
updateTour.mockReset();
});
afterEach(() => vi.useRealTimers());
it('FE-TOUR-WALK-FERRY-001: a crossing the router took holds the save until it is taken as a ferry leg', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 41 }));
act(() => {
result.current.startDerivedTour(TO_GIUDECCA);
});
await settle();
expect(result.current.status).toBe('ready');
expect(result.current.walkFerries.pending).toHaveLength(1);
expect(result.current.canSave).toBe(false);
routeCityWalk.mockResolvedValue(routedWith(crossing(true)));
act(() => result.current.walkFerries.accept(result.current.walkFerries.pending[0] as WalkFerryCrossing));
expect(result.current.waypoints[1]?.fixedLeg).toEqual({
kind: 'ferry',
geometry: boatLine,
crossing: [1, 2],
minutes: 7,
walk_minutes: 2,
});
await settle();
expect(result.current.walkFerries.pending).toHaveLength(0);
expect(result.current.canSave).toBe(true);
await act(async () => {
await result.current.save();
});
const body = createTour.mock.calls[0]?.[1] as TourCreateRequest;
expect(body.waypoints[1]?.fixed_leg).toMatchObject({ kind: 'ferry', crossing: [1, 2], minutes: 7 });
// The walking time saved is the walk alone; the boat's minutes stay on the leg.
expect(body.duration_seconds).toBe(17 * 60);
});
it('FE-TOUR-WALK-FERRY-002: a ferry leg given back is planned on foot again, as one step of the history', async () => {
const { result } = renderHook(() => useTourPlanner({ tripId: 42 }));
act(() => {
result.current.startDerivedTour(TO_GIUDECCA);
});
await settle();
act(() => result.current.walkFerries.accept(result.current.walkFerries.pending[0] as WalkFerryCrossing));
const id = result.current.waypoints[1]?.id as string;
act(() => result.current.walkFerries.release(id));
expect(result.current.waypoints[1]?.fixedLeg).toBeUndefined();
act(() => result.current.undo());
expect(result.current.waypoints[1]?.fixedLeg?.kind).toBe('ferry');
});
it('FE-TOUR-WALK-FERRY-003: stops no way joins keep the route planned before, no longer current, and name the leg', async () => {
routeCityWalk.mockResolvedValue(routedWith(null));
const { result } = renderHook(() => useTourPlanner({ tripId: 43 }));
act(() => {
result.current.startDerivedTour(TO_GIUDECCA);
});
await settle();
const before = result.current.route;
expect(result.current.canSave).toBe(true);
routeCityWalk.mockResolvedValue({ gap: 1 });
act(() => {
result.current.setWaypointPosition(result.current.waypoints[2]?.id as string, 45.41, 12.35);
});
await settle();
expect(result.current.status).toBe('routing-failed');
expect(result.current.error).toBe('walk-gap');
expect(result.current.walkFerries.gap).toEqual({ leg: 1 });
expect(result.current.route).toBe(before);
expect(result.current.routeCurrent).toBe(false);
expect(result.current.canSave).toBe(false);
});
});
@@ -28,7 +28,7 @@ import {
tourWaypointKey,
type TourPlannerWaypoint,
} from './tourPlannerWaypoints';
import { isTourRoute, type TourRouteUnreachable } from './tourRouteOutcome';
import { isTourRoute, type TourRouteGap, type TourRouteUnreachable } from './tourRouteOutcome';
import {
enrichTourElevations,
rideTour,
@@ -38,6 +38,7 @@ import {
routeWalkingTour,
type TourElevationPoint,
type TourRoutePart,
type WalkFerryCrossing,
} from './tourRouting';
import type { TourPlannerStatus } from './useTourPlanner';
@@ -78,6 +79,8 @@ interface PlannedRoute {
enrichedGeometry: TourElevationPoint[] | null;
distanceMeters: number | null;
durationSeconds: number | null;
/** CW-12: a city walk's crossings by boat; null where it takes none. */
ferries: WalkFerryCrossing[] | null;
forKey: string | null;
/** The run it was planned in: its points with what was planned along them, its choices and the retry. */
forRun: string | null;
@@ -91,6 +94,7 @@ const NO_ROUTE: PlannedRoute = {
enrichedGeometry: null,
distanceMeters: null,
durationSeconds: null,
ferries: null,
forKey: null,
forRun: null,
};
@@ -123,6 +127,7 @@ function skiPlanned(route: SkiTourRoute, plannedKey: string, runKey: string): Pl
enrichedGeometry: route.elevations,
distanceMeters: route.distanceMeters,
durationSeconds: route.durationSeconds,
ferries: null,
forKey: plannedKey,
forRun: runKey,
};
@@ -180,7 +185,7 @@ interface RouteRun {
signal: AbortSignal;
stale: () => boolean;
setPlanned: (next: PlannedRoute | ((current: PlannedRoute) => PlannedRoute)) => void;
setUnreachable: (unreachable: TourRouteUnreachable | null) => void;
setUnreachable: (unreachable: TourRouteUnreachable | TourRouteGap | null) => void;
}
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
@@ -200,6 +205,7 @@ async function routePaddle({ inputs, plannedKey, runKey, signal, stale, setPlann
enrichedGeometry: planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]),
distanceMeters: planned.distanceMeters,
durationSeconds: null,
ferries: null,
forKey: plannedKey,
forRun: runKey,
});
@@ -229,9 +235,10 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann
const routed = await routeLand(inputs, signal);
if (stale()) return;
if (!isTourRoute(routed)) {
// A failure keeps the route planned before; it no longer stands for these points (CW-12: no way at all is a gap).
setUnreachable(routed);
setStatus('routing-failed');
setError(routed ? 'unreachable' : 'routing');
setError(!routed ? 'routing' : 'gap' in routed ? 'walk-gap' : 'unreachable');
return;
}
setUnreachable(null);
@@ -243,6 +250,7 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann
enrichedGeometry: null,
distanceMeters: routed.distanceMeters,
durationSeconds: routed.durationSeconds,
ferries: routed.ferries ?? null,
forKey: plannedKey,
forRun: runKey,
});
@@ -377,8 +385,8 @@ export function useTourRoute(inputs: TourRouteInputs) {
patchWaypoints,
} = inputs;
const [planned, setPlanned] = useState<PlannedRoute>(NO_ROUTE);
// A point no way of the tour's type reaches, and how far off the router would have bound it.
const [unreachable, setUnreachable] = useState<TourRouteUnreachable | null>(null);
// A point no way of the tour's type reaches, and how far off the router would have bound it; or a walk's leg no way joins.
const [unreachable, setUnreachable] = useState<TourRouteUnreachable | TourRouteGap | null>(null);
const abortRef = useRef<AbortController | null>(null);
const generationRef = useRef(0);
const { openSavedSkiRoute, takeSavedSki } = useSavedSkiSeed();
@@ -483,7 +491,9 @@ export function useTourRoute(inputs: TourRouteInputs) {
...shown,
/** The route shown was planned for these points and these choices. */
routeCurrent: forKey === plannedKey,
unreachable: error === 'unreachable' ? unreachable : null,
unreachable: error === 'unreachable' && unreachable && 'unreachable' in unreachable ? unreachable : null,
/** CW-12: the leg of a city walk no way joins, while that is the error. */
walkGap: error === 'walk-gap' && unreachable && 'gap' in unreachable ? unreachable.gap : null,
interruptRouting,
clearRoute,
openSavedSkiRoute,
@@ -0,0 +1,60 @@
import type { TourFixedLeg } from '@trek/shared';
import { Footprints, Ship } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { formatPlannedTourDuration } from '../tourPresentation';
import { FERRY_CSS_COLOR } from './walkRoutePaint';
type FerryLeg = Extract<TourFixedLeg, { kind: 'ferry' }>;
/**
* CW-12: a crossing by boat the travellers took, as a badge on its leg in the
* dotted look and water blue the map draws it in, with the boat's time inside
* (or that it is not known). Giving it back plans the leg on foot again.
*/
export function WalkFerryLegRow({
leg,
onRelease,
editable,
}: {
leg: FerryLeg;
onRelease: () => void;
editable: boolean;
}) {
const { t } = useTranslation();
const time =
leg.minutes === null
? t('tours.walk.ferry.timeUnknown')
: t('tours.walk.ferry.time', { time: formatPlannedTourDuration(Math.max(1, leg.minutes)) });
return (
<li data-testid="walk-ferry-leg" className="flex min-w-0 items-center gap-1 py-0.5 ps-[34px]">
<Tooltip label={t('tours.walk.ferry.legInfo')}>
<span className="flex h-[26px] min-w-0 flex-1 items-center gap-1.5 rounded-full border border-dotted border-edge bg-surface-card pe-1 ps-2">
<Ship size={13} strokeWidth={2} className="flex-none" style={{ color: FERRY_CSS_COLOR }} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{t('tours.walk.ferry.leg')}
</span>
<span
className={`flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary ${leg.minutes === null ? 'opacity-60' : ''}`}
style={fs(10.5)}
>
{time}
</span>
</span>
</Tooltip>
{editable && (
<Tooltip label={t('tours.walk.ferry.release')}>
<button
type="button"
onClick={onRelease}
aria-label={t('tours.walk.ferry.release')}
className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted hover:bg-surface-secondary hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Footprints size={13} strokeWidth={2} />
</button>
</Tooltip>
)}
</li>
);
}
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import type { WalkFerryCrossing } from '../planner/tourRouting';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { WalkFerryNotice } from './WalkFerryNotice';
// FE-WALK-FERRY-NOTICE-001 to FE-WALK-FERRY-NOTICE-003: a city walk's crossings by boat in the planner (CW-12).
const crossing = (extra: Partial<WalkFerryCrossing> = {}): WalkFerryCrossing => ({
leg: 0,
geometry: [
[45.43, 12.33],
[45.429, 12.329],
[45.427, 12.33],
],
crossing: [1, 2],
metres: 268,
seconds: 396,
walkSeconds: 120,
accepted: false,
...extra,
});
function plannerWith(walkFerries: Partial<TourPlannerController['walkFerries']>) {
const actions = { accept: vi.fn(), release: vi.fn(), show: vi.fn(), select: vi.fn() };
const planner = {
walkFerries: { crossings: [], pending: [], blocksSave: false, gap: null, ...actions, ...walkFerries },
waypoints: [
{ id: 'a', lat: 45.43, lng: 12.33, role: 'start', name: 'Zattere' },
{ id: 'b', lat: 45.427, lng: 12.33, role: 'end', name: null },
],
setSelectedWaypointId: actions.select,
canEdit: true,
isSaving: false,
} as unknown as TourPlannerController;
return { planner, actions };
}
describe('WalkFerryNotice (CW-12)', () => {
it('FE-WALK-FERRY-NOTICE-001: a crossing not taken yet names its stops, its boat time, and how to go on', () => {
const pending = crossing();
const { planner, actions } = plannerWith({ crossings: [pending], pending: [pending], blocksSave: true });
render(<WalkFerryNotice planner={planner} />);
expect(screen.getByTestId('walk-ferry')).toHaveTextContent(
'There is no way on foot between Zattere and Stop 2: the router crosses the water by ferry here.'
);
expect(screen.getByTestId('walk-ferry-time')).toHaveTextContent('7 min by boat');
fireEvent.click(screen.getByRole('button', { name: 'Take ferry' }));
expect(actions.accept).toHaveBeenCalledWith(pending);
fireEvent.click(screen.getByRole('button', { name: 'Show crossing' }));
expect(actions.show).toHaveBeenCalledWith(pending);
fireEvent.click(screen.getByRole('button', { name: 'Select stop 2' }));
expect(actions.select).toHaveBeenCalledWith('b');
});
it('FE-WALK-FERRY-NOTICE-002: a crossing taken says so, and a boat time the router did not give is said to be unknown', () => {
const kept = crossing({ accepted: true, seconds: null });
const { planner } = plannerWith({ crossings: [kept] });
render(<WalkFerryNotice planner={planner} />);
expect(screen.getByTestId('walk-ferry')).toHaveTextContent('Ferry from Zattere to Stop 2, taken on purpose.');
expect(screen.getByTestId('walk-ferry-time')).toHaveTextContent('Boat time unknown');
expect(screen.queryByRole('button', { name: 'Take ferry' })).toBeNull();
});
it('FE-WALK-FERRY-NOTICE-003: stops no way joins are named, with the stop to move', () => {
const { planner, actions } = plannerWith({ gap: { leg: 0 } });
render(<WalkFerryNotice planner={planner} />);
expect(screen.getByTestId('walk-ferry-gap')).toHaveTextContent(
'No way joins Zattere and Stop 2, neither on foot nor by ferry.'
);
fireEvent.click(screen.getByRole('button', { name: 'Select stop 2' }));
expect(actions.select).toHaveBeenCalledWith('b');
});
});
@@ -0,0 +1,117 @@
import { Check, Eye, MapPin, Ship, Unlink } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { Tooltip } from '../../shared/Tooltip';
import type { WalkFerryCrossing } from '../planner/tourRouting';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { ferryMinutesOf } from './walkFerry';
import { FERRY_CSS_COLOR } from './walkRoutePaint';
type Planner = Pick<
TourPlannerController,
'walkFerries' | 'waypoints' | 'setSelectedWaypointId' | 'canEdit' | 'isSaving'
>;
type Translate = ReturnType<typeof useTranslation>['t'];
function pointName(planner: Planner, index: number, t: Translate): string {
return planner.waypoints[index]?.name?.trim() || t('tours.walk.stationNumbered', { number: index + 1 });
}
/** The crossing's length and time as badges; a time the router did not give says so, dimmed. */
function CrossingFacts({ crossing }: { crossing: WalkFerryCrossing }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const minutes = ferryMinutesOf(crossing);
return (
<span className="flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Ship size={10} strokeWidth={2.2} className="flex-none" style={{ color: FERRY_CSS_COLOR }} />}>
{formatDistance(crossing.metres / 1000, unit ?? 'metric')}
</SoftPill>
<Tooltip label={t('tours.walk.ferry.timeHint')}>
<span data-testid="walk-ferry-time" className={`inline-flex ${minutes === null ? 'opacity-60' : ''}`}>
<SoftPill>
{minutes === null
? t('tours.walk.ferry.timeUnknown')
: t('tours.walk.ferry.time', { time: formatPlannedTourDuration(minutes) })}
</SoftPill>
</span>
</Tooltip>
</span>
);
}
function selectAction(planner: Planner, index: number, t: Translate) {
const select = () => planner.setSelectedWaypointId(planner.waypoints[index]?.id ?? null);
return (
<CardAction icon={<MapPin size={12} strokeWidth={2} />} onClick={select}>
{t('tours.walk.ferry.selectStop', { number: index + 1 })}
</CardAction>
);
}
/**
* CW-12: a city walk's crossings by boat in the planner. One the router took
* is named with its length and the boat's time, shown on the map on demand,
* and holds the save until the travellers take it as a ferry leg or move a
* stop; one taken is said once more, so the walk's time reads right. A leg no
* way joins at all names its two stops; the route planned before stays,
* greyed, until a way is found.
*/
export function WalkFerryNotice({ planner }: { planner: Planner }) {
const { t } = useTranslation();
const { gap, crossings, accept, show } = planner.walkFerries;
const locked = !planner.canEdit || planner.isSaving;
return (
<>
{gap && (
<TourNotice tone="warning" icon={<Unlink size={13} strokeWidth={2} />}>
<span className="flex flex-col items-start gap-1.5" data-testid="walk-ferry-gap">
<span>
{t('tours.walk.ferry.gap', {
from: pointName(planner, gap.leg, t),
to: pointName(planner, gap.leg + 1, t),
})}
</span>
<span className="text-content-muted">{t('tours.walk.ferry.gapHint')}</span>
{selectAction(planner, gap.leg + 1, t)}
</span>
</TourNotice>
)}
{crossings.map((crossing) => {
const names = { from: pointName(planner, crossing.leg, t), to: pointName(planner, crossing.leg + 1, t) };
return (
<TourNotice
key={`${crossing.leg}:${crossing.accepted ? 'kept' : 'open'}`}
tone={crossing.accepted ? 'neutral' : 'warning'}
icon={<Ship size={13} strokeWidth={2} />}
>
<span className="flex flex-col items-start gap-1.5" data-testid="walk-ferry">
<span>{t(crossing.accepted ? 'tours.walk.ferry.accepted' : 'tours.walk.ferry.pending', names)}</span>
<CrossingFacts crossing={crossing} />
{!crossing.accepted && <span className="text-content-muted">{t('tours.walk.ferry.pendingHint')}</span>}
<span className="flex flex-wrap gap-1.5">
{!crossing.accepted && (
<CardAction
icon={<Check size={12} strokeWidth={2} />}
onClick={() => accept(crossing)}
disabled={locked}
>
{t('tours.walk.ferry.accept')}
</CardAction>
)}
<CardAction icon={<Eye size={12} strokeWidth={2} />} onClick={() => show(crossing)}>
{t('tours.walk.ferry.show')}
</CardAction>
{!crossing.accepted && selectAction(planner, crossing.leg + 1, t)}
</span>
</span>
</TourNotice>
);
})}
</>
);
}
@@ -0,0 +1,75 @@
import type { PlannableTourType } from '@trek/shared';
import { useCallback } from 'react';
import { focusPaddleArea } from '../../../store/paddleObstacleStore';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { WalkFerryCrossing } from '../planner/tourRouting';
import { crossingLine, ferryLegOf } from './walkFerry';
interface WalkFerriesInput {
tourType: PlannableTourType;
ferries: WalkFerryCrossing[] | null;
routeCurrent: boolean;
walkGap: number | null;
waypoints: TourPlannerWaypoint[];
editWaypoints: (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
}
/**
* CW-12: a city walk's crossings by boat as the planner handles them. A
* crossing the router took is shown and explained but not saved until the
* travellers take it as a ferry leg (`accept`, one step of the history), so
* the boat never passes for walking unnoticed. `gap` is the leg no way
* joins at all, while that is why the route failed.
*/
export function useWalkFerries({
tourType,
ferries,
routeCurrent,
walkGap,
waypoints,
editWaypoints,
}: WalkFerriesInput) {
const crossings = tourType === 'walk' && routeCurrent ? (ferries ?? []) : [];
const pending = crossings.filter((crossing) => !crossing.accepted);
const accept = useCallback(
(crossing: WalkFerryCrossing) => {
editWaypoints((current) => {
const from = current[crossing.leg];
const to = current[crossing.leg + 1];
const leg = from && to && !from.fixedLeg ? ferryLegOf(crossing, from, to) : null;
return leg
? current.map((point, index) => (index === crossing.leg ? { ...point, fixedLeg: leg } : point))
: current;
});
},
[editWaypoints]
);
/** A ferry leg given back: the leg is planned on foot again, as one step of the history. */
const release = useCallback(
(id: string) => {
editWaypoints((current) =>
current.map((point) => {
if (point.id !== id || point.fixedLeg?.kind !== 'ferry') return point;
const { fixedLeg: _crossing, ...onFoot } = point;
return onFoot;
})
);
},
[editWaypoints]
);
const show = useCallback((crossing: WalkFerryCrossing) => focusPaddleArea(crossingLine(crossing)), []);
return {
crossings,
pending,
/** Save waits while a crossing is not taken on purpose. */
blocksSave: pending.length > 0,
gap: tourType === 'walk' && walkGap !== null && waypoints[walkGap + 1] ? { leg: walkGap } : null,
accept,
release,
show,
};
}
@@ -0,0 +1,61 @@
import { TOUR_FIXED_LEG_MAX_POINTS, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
import { haversineKm } from '../../../utils/geo';
import type { WalkFerryCrossing } from '../planner/tourRouting';
interface LegPoint {
lat: number;
lng: number;
fixedLeg?: TourFixedLeg | null;
}
/** CW-12: only a city walk takes a crossing by boat; any other type gives the leg back to its router. */
export function walkOnly<T extends LegPoint>(points: readonly T[], tourType: PlannableTourType): T[] {
if (tourType === 'walk') return [...points];
return points.map((point) => {
if (point.fixedLeg?.kind !== 'ferry') return point;
const { fixedLeg: _crossing, ...routed } = point;
return routed as T;
});
}
/** Closer than this, in metres, a leg's end already is its point. */
const SAME_END_M = 1;
function off(point: LegPoint, end: readonly [number, number] | undefined): boolean {
return !end || haversineKm(point, { lat: end[0], lng: end[1] }) * 1000 > SAME_END_M;
}
/**
* CW-12: a crossing taken on purpose, as the ferry leg the walk keeps. The
* router's line starts and ends where it bound the points, which may lie
* further off than a fixed leg may; the points themselves are put at its ends,
* so the leg always joins them. Null for a line longer than a leg may be.
*/
export function ferryLegOf(crossing: WalkFerryCrossing, from: LegPoint, to: LegPoint): TourFixedLeg | null {
const head = off(from, crossing.geometry[0]);
const tail = off(to, crossing.geometry[crossing.geometry.length - 1]);
const geometry: [number, number][] = [
...(head ? [[from.lat, from.lng] as [number, number]] : []),
...crossing.geometry,
...(tail ? [[to.lat, to.lng] as [number, number]] : []),
];
if (geometry.length > TOUR_FIXED_LEG_MAX_POINTS) return null;
const shift = head ? 1 : 0;
return {
kind: 'ferry',
geometry,
crossing: [crossing.crossing[0] + shift, crossing.crossing[1] + shift],
minutes: crossing.seconds === null ? null : Math.round(crossing.seconds / 60),
walk_minutes: Math.round(crossing.walkSeconds / 60),
};
}
/** The crossing as a line of its own, for the map to show it. */
export function crossingLine(crossing: WalkFerryCrossing): [number, number][] {
return crossing.geometry.slice(crossing.crossing[0], crossing.crossing[1] + 1);
}
/** The minutes a crossing takes by boat, null where the router gave none. */
export function ferryMinutesOf(crossing: Pick<WalkFerryCrossing, 'seconds'>): number | null {
return crossing.seconds === null ? null : Math.max(1, Math.round(crossing.seconds / 60));
}
@@ -1,4 +1,4 @@
import { walkRemainderOf, type WalkRemainder, type WalkStationRef } from '@trek/shared';
import { ferryCrossingsOf, walkRemainderOf, type WalkRemainder, type WalkStationRef } from '@trek/shared';
import { useMemo } from 'react';
import type { useTranslation } from '../../../i18n';
import type { VisitsByStation } from '../../../store/tourVisitStore';
@@ -44,6 +44,8 @@ export function walkRemainingOf({
})),
line: route,
walkingMinutes,
// CW-12: a ferry leg taken from a station is crossed by boat and takes none of the walking time.
crossings: ferryCrossingsOf(stations.map((station) => ({ fixed_leg: station.fixedLeg }))),
});
}
@@ -0,0 +1,15 @@
import { useMapPaint } from '../../Map/mapPaint';
/** CW-12: the colours of a walk's crossing by boat and of a route kept from before (styles/walkRoute.css). */
const WALK_ROUTE_TOKENS = {
ferry: '--map-walk-ferry',
stale: '--map-route-stale',
} as const;
/** A crossing's water blue, for markup on the page. */
export const FERRY_CSS_COLOR = `var(${WALK_ROUTE_TOKENS.ferry})`;
/** The colours resolved for map paint, again whenever the theme changes. */
export function useWalkRoutePaint() {
return useMapPaint(WALK_ROUTE_TOKENS);
}
@@ -1,6 +1,8 @@
import { someStaysUnknown, walkTimesOf, type WalkTimes } from '../planner/stationModel';
import type { WalkFerryCrossing } from '../planner/tourRouting';
import type { TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, plannedTotalText } from '../tourPresentation';
import { ferryMinutesOf } from './walkFerry';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -27,9 +29,27 @@ export interface WalkTimeFieldsInput {
stays: WalkStays;
/** A total entered by hand, which stays apart from the one worked out; null when there is none. */
manualMinutes: number | null;
/** CW-12: the walk's crossings by boat, whose time is never walking time. */
ferries?: readonly Pick<WalkFerryCrossing, 'seconds'>[];
t: Translate;
}
/** CW-12: the time of a walk's crossings by boat, as a field of its own; none without a crossing. */
function ferryField(ferries: readonly Pick<WalkFerryCrossing, 'seconds'>[], t: Translate): TourMetricField[] {
if (ferries.length === 0) return [];
const minutes = ferries.map(ferryMinutesOf);
const known = minutes.every((value) => value !== null);
return [
{
label: t('tours.walk.ferry.field'),
value: known
? formatPlannedTourDuration(minutes.reduce<number>((sum, value) => sum + (value ?? 0), 0))
: t('tours.walk.ferry.timeUnknown'),
info: { label: t('tours.walk.ferry.fieldInfoLabel'), description: t('tours.walk.ferry.fieldInfo') },
},
];
}
/**
* CW-02: a city walk's times as fields, in one wording for the planner and the
* tour detail: the walking time, the stays, and the planned total of both. A
@@ -42,6 +62,7 @@ export function walkTimeFields({
walkingMinutes,
stays,
manualMinutes,
ferries = [],
t,
}: WalkTimeFieldsInput): TourMetricField[] {
const notCalculated = t('tours.planner.walkingTimeUnknown');
@@ -52,6 +73,7 @@ export function walkTimeFields({
const staysText = walkStaysText(stays, t);
const parts: TourMetricField[] = [
{ ...walking, value: walkingMinutes == null ? notCalculated : formatPlannedTourDuration(walkingMinutes) },
...ferryField(ferries, t),
...(staysText ? [{ label: t('tours.walk.stays'), value: staysText }] : []),
];
const planned = t('tours.planner.plannedShort');