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