diff --git a/client/src/components/Map/valhallaRoute.test.ts b/client/src/components/Map/valhallaRoute.test.ts index a6acc53e1..635948f9f 100644 --- a/client/src/components/Map/valhallaRoute.test.ts +++ b/client/src/components/Map/valhallaRoute.test.ts @@ -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> }> = [] + server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => { + bodies.push(await request.json() as { locations: Array> }) + 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) + }) +}) diff --git a/client/src/components/Map/valhallaRoute.ts b/client/src/components/Map/valhallaRoute.ts index d114047bf..0ce46dc11 100644 --- a/client/src/components/Map/valhallaRoute.ts +++ b/client/src/components/Map/valhallaRoute.ts @@ -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 = {}, +): Promise { + 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, + searchFilter: Readonly, +): Promise { + return chainRun(waypoints, 'walking', [], signal, { costingOptions, searchFilter }) +} + +/** How a chain is asked for besides its profile and avoidance. */ +interface RunAsk { + costingOptions: Readonly + searchFilter: Readonly | null +} + +async function chainRun( + waypoints: Waypoint[], + profile: 'driving' | 'walking' | 'cycling', + avoid: readonly AvoidClass[], + signal: AbortSignal | undefined, + ask: RunAsk, ): Promise { 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 = {}, + searchFilter: Readonly | null = null, ): Record { 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 = {}, + signal: AbortSignal | undefined, + { costingOptions, searchFilter }: RunAsk, ): Promise | 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 | 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 diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 8833c719f..c5d8073d0 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -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 && } + @@ -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 [ { diff --git a/client/src/components/Tours/planner/WaypointLegRows.tsx b/client/src/components/Tours/planner/WaypointLegRows.tsx index b629e9dbf..ebcea70e3 100644 --- a/client/src/components/Tours/planner/WaypointLegRows.tsx +++ b/client/src/components/Tours/planner/WaypointLegRows.tsx @@ -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' && } {point.fixedLeg?.kind === 'horse_route' && } + {point.fixedLeg?.kind === 'ferry' && ( + planner.walkFerries.release(point.id)} + /> + )} {routedLegs && } {planner.isSki && } {failure && ( diff --git a/client/src/components/Tours/planner/tourPlannerWaypoints.ts b/client/src/components/Tours/planner/tourPlannerWaypoints.ts index 88308cba5..fde2cac25 100644 --- a/client/src/components/Tours/planner/tourPlannerWaypoints.ts +++ b/client/src/components/Tours/planner/tourPlannerWaypoints.ts @@ -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 }; diff --git a/client/src/components/Tours/planner/tourRouteActions.ts b/client/src/components/Tours/planner/tourRouteActions.ts index 0f8e84825..be9a563e5 100644 --- a/client/src/components/Tours/planner/tourRouteActions.ts +++ b/client/src/components/Tours/planner/tourRouteActions.ts @@ -260,15 +260,16 @@ export function setWaypointLock(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(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; diff --git a/client/src/components/Tours/planner/tourRouteOutcome.ts b/client/src/components/Tours/planner/tourRouteOutcome.ts index ddb17390a..ac5c7c488 100644 --- a/client/src/components/Tours/planner/tourRouteOutcome.ts +++ b/client/src/components/Tours/planner/tourRouteOutcome.ts @@ -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. */ diff --git a/client/src/components/Tours/planner/tourRouteStyle.test.ts b/client/src/components/Tours/planner/tourRouteStyle.test.ts index 6eee9a7da..3c287e510 100644 --- a/client/src/components/Tours/planner/tourRouteStyle.test.ts +++ b/client/src/components/Tours/planner/tourRouteStyle.test.ts @@ -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(); + }); }); diff --git a/client/src/components/Tours/planner/tourRouteStyle.ts b/client/src/components/Tours/planner/tourRouteStyle.ts index 30506421c..7fe3051c4 100644 --- a/client/src/components/Tours/planner/tourRouteStyle.ts +++ b/client/src/components/Tours/planner/tourRouteStyle.ts @@ -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, diff --git a/client/src/components/Tours/planner/tourRouting.test.ts b/client/src/components/Tours/planner/tourRouting.test.ts index b42ff7b7d..36adde2e9 100644 --- a/client/src/components/Tours/planner/tourRouting.test.ts +++ b/client/src/components/Tours/planner/tourRouting.test.ts @@ -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() + }) +}) diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts index 81a349bee..6c5821a6b 100644 --- a/client/src/components/Tours/planner/tourRouting.ts +++ b/client/src/components/Tours/planner/tourRouting.ts @@ -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 { - return routeOnLand(waypoints, 'walking', CITY_WALK_COSTING, signal) +): Promise { + return routeOnLand(waypoints, 'walking', CITY_WALK_COSTING, signal, CITY_WALK_BINDING) } const CITY_WALK_COSTING: Readonly = { use_ferry: 0 } +const CITY_WALK_BINDING: Readonly = { 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[]): 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, 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>, + first: number, + ask: (points: Array>) => Promise, + signal?: AbortSignal, +): Promise { + 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, + signal?: AbortSignal, +): Promise +function routeOnLand( + waypoints: LandWaypoint[], + mode: 'walking' | 'cycling', + costingOptions: Readonly, + signal: AbortSignal | undefined, + searchFilter: Readonly, +): Promise async function routeOnLand( waypoints: LandWaypoint[], mode: 'walking' | 'cycling', costingOptions: Readonly, signal?: AbortSignal, -): Promise { + searchFilter: Readonly | null = null, +): Promise { 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>) => { + 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>, 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 diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index 75f10038e..dcc61dc14 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -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, diff --git a/client/src/components/Tours/planner/useTourPlanner.walkFerry.test.tsx b/client/src/components/Tours/planner/useTourPlanner.walkFerry.test.tsx new file mode 100644 index 000000000..1caeeb710 --- /dev/null +++ b/client/src/components/Tours/planner/useTourPlanner.walkFerry.test.tsx @@ -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); + }); +}); diff --git a/client/src/components/Tours/planner/useTourRoute.ts b/client/src/components/Tours/planner/useTourRoute.ts index d4bbc934a..7e89f40df 100644 --- a/client/src/components/Tours/planner/useTourRoute.ts +++ b/client/src/components/Tours/planner/useTourRoute.ts @@ -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(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(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(null); const abortRef = useRef(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, diff --git a/client/src/components/Tours/walk/WalkFerryLegRow.tsx b/client/src/components/Tours/walk/WalkFerryLegRow.tsx new file mode 100644 index 000000000..527ba79ba --- /dev/null +++ b/client/src/components/Tours/walk/WalkFerryLegRow.tsx @@ -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; + +/** + * 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 ( +
  • + + + + + {editable && ( + + + + )} +
  • + ); +} diff --git a/client/src/components/Tours/walk/WalkFerryNotice.test.tsx b/client/src/components/Tours/walk/WalkFerryNotice.test.tsx new file mode 100644 index 000000000..d3065cb6f --- /dev/null +++ b/client/src/components/Tours/walk/WalkFerryNotice.test.tsx @@ -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 => ({ + 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) { + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/client/src/components/Tours/walk/WalkFerryNotice.tsx b/client/src/components/Tours/walk/WalkFerryNotice.tsx new file mode 100644 index 000000000..0cfcc4490 --- /dev/null +++ b/client/src/components/Tours/walk/WalkFerryNotice.tsx @@ -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['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 ( + + }> + {formatDistance(crossing.metres / 1000, unit ?? 'metric')} + + + + + {minutes === null + ? t('tours.walk.ferry.timeUnknown') + : t('tours.walk.ferry.time', { time: formatPlannedTourDuration(minutes) })} + + + + + ); +} + +function selectAction(planner: Planner, index: number, t: Translate) { + const select = () => planner.setSelectedWaypointId(planner.waypoints[index]?.id ?? null); + return ( + } onClick={select}> + {t('tours.walk.ferry.selectStop', { number: index + 1 })} + + ); +} + +/** + * 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 && ( + }> + + + {t('tours.walk.ferry.gap', { + from: pointName(planner, gap.leg, t), + to: pointName(planner, gap.leg + 1, t), + })} + + {t('tours.walk.ferry.gapHint')} + {selectAction(planner, gap.leg + 1, t)} + + + )} + {crossings.map((crossing) => { + const names = { from: pointName(planner, crossing.leg, t), to: pointName(planner, crossing.leg + 1, t) }; + return ( + } + > + + {t(crossing.accepted ? 'tours.walk.ferry.accepted' : 'tours.walk.ferry.pending', names)} + + {!crossing.accepted && {t('tours.walk.ferry.pendingHint')}} + + {!crossing.accepted && ( + } + onClick={() => accept(crossing)} + disabled={locked} + > + {t('tours.walk.ferry.accept')} + + )} + } onClick={() => show(crossing)}> + {t('tours.walk.ferry.show')} + + {!crossing.accepted && selectAction(planner, crossing.leg + 1, t)} + + + + ); + })} + + ); +} diff --git a/client/src/components/Tours/walk/useWalkFerries.ts b/client/src/components/Tours/walk/useWalkFerries.ts new file mode 100644 index 000000000..303349af0 --- /dev/null +++ b/client/src/components/Tours/walk/useWalkFerries.ts @@ -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, + }; +} diff --git a/client/src/components/Tours/walk/walkFerry.ts b/client/src/components/Tours/walk/walkFerry.ts new file mode 100644 index 000000000..7a2635d05 --- /dev/null +++ b/client/src/components/Tours/walk/walkFerry.ts @@ -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(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): number | null { + return crossing.seconds === null ? null : Math.max(1, Math.round(crossing.seconds / 60)); +} diff --git a/client/src/components/Tours/walk/walkRemaining.ts b/client/src/components/Tours/walk/walkRemaining.ts index 22b99302b..4e2236be2 100644 --- a/client/src/components/Tours/walk/walkRemaining.ts +++ b/client/src/components/Tours/walk/walkRemaining.ts @@ -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 }))), }); } diff --git a/client/src/components/Tours/walk/walkRoutePaint.ts b/client/src/components/Tours/walk/walkRoutePaint.ts new file mode 100644 index 000000000..6cffccf5a --- /dev/null +++ b/client/src/components/Tours/walk/walkRoutePaint.ts @@ -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); +} diff --git a/client/src/components/Tours/walk/walkTimeFields.ts b/client/src/components/Tours/walk/walkTimeFields.ts index 118572838..1d49423ab 100644 --- a/client/src/components/Tours/walk/walkTimeFields.ts +++ b/client/src/components/Tours/walk/walkTimeFields.ts @@ -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; @@ -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[]; 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[], 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((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'); diff --git a/client/src/main.tsx b/client/src/main.tsx index da06fdd24..a55c1fb0a 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -27,6 +27,7 @@ import 'leaflet/dist/leaflet.css' import './index.css' import './styles/ferratas.css' import './styles/paddle.css' +import './styles/walkRoute.css' import './styles/bike.css' import './styles/horse.css' import './styles/hike.css' diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index 91bb9c489..20dd10a1f 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -3,6 +3,7 @@ import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' import { placeActions } from '../../components/Planner/usePlaceActions' import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle' import { usePaddlePaint } from '../../components/Tours/paddle/paddlePaint' +import { useWalkRoutePaint } from '../../components/Tours/walk/walkRoutePaint' import { useTrackPortageParts } from '../../components/Tours/paddle/useTrackPortageParts' import { usePaddleObstacleSync } from '../../components/Tours/paddle/usePaddleObstacleSync' import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation' @@ -68,12 +69,16 @@ export function useTripPlannerPage() { tourConversion.line, trackParts, ]) const editingRoute = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' - // What no router planned is dotted; a paddle tour's portages brown, whitewater amber, gaps red. + // What no router planned is dotted; a paddle tour's portages brown, whitewater amber, gaps red, a walk's ferry blue. const paddlePaint = usePaddlePaint() + const walkPaint = useWalkRoutePaint() + // A route kept from before a plan that failed no longer stands for the points: greyed, not hidden. + const staleRoute = editingRoute && !tourPlanner.routeCurrent && tourPlanner.status === 'routing-failed' const partStyle = tourRouteStyle( !wandererBrowser.open && editingRoute ? tourPlanner.routeParts : !tourConversion.line ? trackParts : null, tourPlanner.isPaddle || !!trackParts, - { portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc }, + { portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc, ...walkPaint }, + staleRoute, ) const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null) // BIK-01 to BIK-05 and MAP-03: the stretches a tour's card points at, over the tour's own line, diff --git a/client/src/styles/walkRoute.css b/client/src/styles/walkRoute.css new file mode 100644 index 000000000..29fe82193 --- /dev/null +++ b/client/src/styles/walkRoute.css @@ -0,0 +1,15 @@ +/* ============================================================================ + City walks on the planner map (CW-12): a crossing by boat, dotted in a water + blue so it never reads as a way walked, and a route kept from before a plan + that failed, greyed while it no longer stands for the points. + ========================================================================== */ + +:root { + --map-walk-ferry: #0e7490; + --map-route-stale: #94a3b8; +} + +.dark { + --map-walk-ferry: #22d3ee; + --map-route-stale: #64748b; +} diff --git a/server/src/nest/roadtrip/roadtrip-router.service.ts b/server/src/nest/roadtrip/roadtrip-router.service.ts index ee1edd2f5..b117fde02 100644 --- a/server/src/nest/roadtrip/roadtrip-router.service.ts +++ b/server/src/nest/roadtrip/roadtrip-router.service.ts @@ -27,7 +27,17 @@ const osrmSchema = z.object({ distance: z.number().nonnegative(), duration: z.number().nonnegative(), legs: z - .array(z.object({ distance: z.number().nonnegative(), duration: z.number().nonnegative() })) + .array( + z.object({ + distance: z.number().nonnegative(), + duration: z.number().nonnegative(), + // CW-12: asked only for a walk on foot, to tell a crossing by boat (`mode` ferry) apart. + steps: z + .array(z.object({ mode: z.string().max(32).optional() })) + .max(20000) + .optional(), + }), + ) .min(1) .max(99), geometry: z.object({ coordinates: z.array(coordinate).min(2).max(200000) }), @@ -49,6 +59,11 @@ const valhallaSchema = z.object({ has_highway: z.boolean().optional(), has_ferry: z.boolean().optional(), }), + // CW-12: where a walk on foot boards a ferry (type 28, or `ferry` on the crossing). + maneuvers: z + .array(z.object({ type: z.number().optional(), ferry: z.boolean().optional() })) + .max(20000) + .optional(), }), ) .min(1) @@ -61,6 +76,48 @@ export interface RoadtripRoute { parts: { distance: number; duration: number }[]; snapped?: SnappedWaypoint[]; avoidMissed: RouteAvoidClass[]; + /** CW-12: on a way asked on foot only, the legs (counted from 0) that still cross by boat. */ + ferryLegs?: number[]; +} + +/** How a route is asked for besides its profile. */ +export interface RouteOptions { + /** + * CW-12: a way that is walked: no point bound to a ferry's edge, ferries + * avoided wherever a way on land exists, and the legs that still cross by + * boat said. A city walk asks this; a road trip day never does. + */ + footOnly?: boolean; +} + +/** Valhalla's maneuver type for boarding a ferry. */ +const FERRY_ENTER = 28; + +type ValhallaLegs = z.infer['trip']['legs']; + +/** Whether a walked leg still crosses by boat (CW-12). */ +function crossesByBoat(leg: ValhallaLegs[number]): boolean { + return ( + leg.summary.has_ferry === true || + (leg.maneuvers ?? []).some((maneuver) => maneuver.ferry === true || maneuver.type === FERRY_ENTER) + ); +} + +/** Valhalla's legs as a route: the line, its length and time, a part per leg, and where each point met a way. */ +function routeOfLegs(points: Point[], legs: ValhallaLegs) { + const shapes = legs.map((leg) => decode(leg.shape)); + const locations = [...shapes.map((shape) => shape[0]), shapes[shapes.length - 1].at(-1)!]; + return { + line: shapes.flat(), + distance: legs.reduce((sum, leg) => sum + leg.summary.length * 1000, 0), + duration: legs.reduce((sum, leg) => sum + leg.summary.time, 0), + parts: legs.map((leg) => ({ distance: leg.summary.length * 1000, duration: leg.summary.time })), + snapped: locations.map(([lat, lng], index): SnappedWaypoint => ({ + asked: [points[index].lat, points[index].lng], + at: [lat, lng], + meters: haversineKm(points[index], { lat, lng }) * 1000, + })), + }; } function decode(shape: string): [number, number][] { @@ -152,8 +209,10 @@ export class RoadtripRouterService { points: Point[], profile: string, avoid: RouteAvoidClass[], + options: RouteOptions = {}, ): Promise { const settings = await this.settings.getUserSettings(userId); + const footOnly = options.footOnly === true && profile === 'walking'; const key = JSON.stringify([ userId, tripId, @@ -163,6 +222,7 @@ export class RoadtripRouterService { avoid, settings.routing_base_url, settings.valhalla_base_url, + ...(footOnly ? ['foot-only'] : []), ]); const cached = this.cache.get(key); if (!profile.startsWith('plugin:') && cached && Date.now() - cached.at < 300000) return cached.route; @@ -171,6 +231,7 @@ export class RoadtripRouterService { let vias: RoutedLeg['vias'] = []; let avoidMissed = profile === 'driving' ? [...avoid] : []; let snapped: SnappedWaypoint[] | undefined; + let ferryLegs: number[] | undefined; const plugin = /^plugin:([^/]+)\/(.+)$/.exec(profile); if (plugin) { const [, id, name] = plugin; @@ -197,6 +258,16 @@ export class RoadtripRouterService { typeof settings.valhalla_base_url === 'string' ? settings.valhalla_base_url.trim().replace(/\/+$/, '') : ''; const valhalla = configuredValhalla || (base ? '' : 'https://valhalla1.openstreetmap.de'); let weighted = false; + if (footOnly && valhalla) { + try { + const legs = await this.valhallaLegs(valhalla, points, 'pedestrian', { use_ferry: 0 }, true); + ({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs)); + ferryLegs = legs.flatMap((leg, index) => (crossesByBoat(leg) ? [index] : [])); + weighted = true; + } catch { + snapped = undefined; + } + } if (mode === 'driving' && avoid.length && valhalla) { try { const options = { @@ -204,28 +275,8 @@ export class RoadtripRouterService { use_highways: avoid.includes('motorway') ? 0 : 1, use_ferry: avoid.includes('ferry') ? 0 : 1, }; - const legs: z.infer['trip']['legs'] = []; - for (let start = 0; start < points.length - 1; start += 9) { - const payload = await this.request(`${valhalla}/route`, { - locations: points.slice(start, start + 10).map((p) => ({ lat: p.lat, lon: p.lng, radius: 50 })), - costing: 'auto', - costing_options: { auto: options }, - directions_options: { units: 'kilometers' }, - }); - legs.push(...valhallaSchema.parse(payload).trip.legs); - } - if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs'); - const shapes = legs.map((leg) => decode(leg.shape)); - line = shapes.flat(); - const locations = [...shapes.map((shape) => shape[0]), shapes[shapes.length - 1].at(-1)!]; - snapped = locations.map(([lat, lng], index) => ({ - asked: [points[index].lat, points[index].lng], - at: [lat, lng], - meters: haversineKm(points[index], { lat, lng }) * 1000, - })); - distance = legs.reduce((sum, leg) => sum + leg.summary.length * 1000, 0); - duration = legs.reduce((sum, leg) => sum + leg.summary.time, 0); - parts = legs.map((leg) => ({ distance: leg.summary.length * 1000, duration: leg.summary.time })); + const legs = await this.valhallaLegs(valhalla, points, 'auto', options); + ({ line, distance, duration, parts, snapped } = routeOfLegs(points, legs)); avoidMissed = avoid.filter((cls) => legs.some((leg) => cls === 'toll' @@ -254,15 +305,20 @@ export class RoadtripRouterService { // browser sends the same thing, see `uTurnParam` in client RouteCalculator.ts. // Only with something in the middle to turn round at. const uTurn = points.length > 2 ? '&continue_straight=false' : ''; + // A walk on foot only asks for the steps, the one place OSRM says a leg is on a ferry. const payload = await this.request( - `${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=false${uTurn}`, + `${origin}/route/v1/${path}/${points.map((p) => `${p.lng},${p.lat}`).join(';')}?overview=full&geometries=geojson&steps=${footOnly}${uTurn}`, ); const parsed = osrmSchema.parse(payload); const route = parsed.routes[0]; line = route.geometry.coordinates.map(([lng, lat]) => [lat, lng]); distance = route.distance; duration = route.duration; - parts = route.legs; + parts = route.legs.map((leg) => ({ distance: leg.distance, duration: leg.duration })); + if (footOnly) + ferryLegs = route.legs.flatMap((leg, index) => + leg.steps?.some((step) => step.mode === 'ferry') ? [index] : [], + ); if (parsed.waypoints?.length === points.length) snapped = parsed.waypoints.map((waypoint, index) => ({ asked: [points[index].lat, points[index].lng], @@ -308,9 +364,41 @@ export class RoadtripRouterService { }, }, avoidMissed, + ...(ferryLegs ? { ferryLegs } : {}), }; this.cache.set(key, { at: Date.now(), route }); if (this.cache.size > 100) this.cache.delete(this.cache.keys().next().value!); return route; } + + /** + * Valhalla's legs through the points, ten locations a request, one after + * another through the paced queue. CW-12: a walk on foot binds no point to + * a ferry's edge (`offBoatLines`). + */ + private async valhallaLegs( + valhalla: string, + points: Point[], + costing: 'auto' | 'pedestrian', + options: Record, + offBoatLines = false, + ): Promise { + const legs: ValhallaLegs = []; + for (let start = 0; start < points.length - 1; start += 9) { + const payload = await this.request(`${valhalla}/route`, { + locations: points.slice(start, start + 10).map((p) => ({ + lat: p.lat, + lon: p.lng, + radius: 50, + ...(offBoatLines ? { search_filter: { exclude_ferry: true } } : {}), + })), + costing, + costing_options: { [costing]: options }, + directions_options: { units: 'kilometers' }, + }); + legs.push(...valhallaSchema.parse(payload).trip.legs); + } + if (legs.length !== points.length - 1) throw new Error('Routing response has mismatched legs'); + return legs; + } } diff --git a/server/src/nest/tours/city-walk-briefing.helpers.ts b/server/src/nest/tours/city-walk-briefing.helpers.ts index 853b701cc..d672723b9 100644 --- a/server/src/nest/tours/city-walk-briefing.helpers.ts +++ b/server/src/nest/tours/city-walk-briefing.helpers.ts @@ -114,8 +114,8 @@ export type FootWay = | { kind: 'routed'; line: Array<[number, number]>; legs: Leg[]; seconds: number } | { kind: 'unrouted'; - why: 'nothing_to_route' | 'router_unavailable' | 'station_off_the_way'; - /** The station no way reaches, counted from 1. */ + why: 'nothing_to_route' | 'router_unavailable' | 'station_off_the_way' | 'ferry_only'; + /** The station no way reaches, or (ferry_only) the one the crossing by boat starts at, counted from 1. */ position: number | null; }; @@ -124,6 +124,8 @@ export interface FootRouteAnswer { leg: { line: Array<[number, number]> }; parts: ReadonlyArray<{ distance: number; duration: number }>; snapped?: ReadonlyArray<{ meters: number }>; + /** CW-12: the legs, counted from 0, the router could only join by boat. */ + ferryLegs?: readonly number[]; } interface Visit { @@ -186,11 +188,16 @@ export function legOf(from: { lat: number; lng: number }, to: { lat: number; lng /** * The router's way as the walk takes it: each leg's distance and walking time, * and the line. A station the router had to move further onto a way than the - * planner allows is one no way on foot reaches, and the way is not taken. + * planner allows is one no way on foot reaches, and the way is not taken. Nor + * is a way that crosses by boat (CW-12): the boat's time would pass for + * walking, so the walk waits for the travellers to take the crossing in the + * planner. */ export function footWayOf(answer: FootRouteAnswer): FootWay { const off = (answer.snapped ?? []).findIndex((point) => point.meters > TOUR_MAX_SNAP_M); if (off >= 0) return { kind: 'unrouted', why: 'station_off_the_way', position: off + 1 }; + const boat = answer.ferryLegs?.[0]; + if (boat !== undefined) return { kind: 'unrouted', why: 'ferry_only', position: boat + 1 }; return { kind: 'routed', line: answer.leg.line, diff --git a/server/src/nest/tours/city-walk-foot-way.service.ts b/server/src/nest/tours/city-walk-foot-way.service.ts index 3ddc58830..80a2a7e77 100644 --- a/server/src/nest/tours/city-walk-foot-way.service.ts +++ b/server/src/nest/tours/city-walk-foot-way.service.ts @@ -16,7 +16,7 @@ const ANY_DAY = 0; * they set one). The proposal of a new walk, its creation and a prepared * change to a saved walk all ask here, so their distances and times come from * one source. Where the router gives no way, the answer says why instead of - * guessing one. + * guessing one; a way that crosses by boat is none on foot (CW-12). */ @Injectable() export class CityWalkFootWayService { @@ -27,7 +27,7 @@ export class CityWalkFootWayService { if (points.length < 2) return { kind: 'unrouted', why: 'nothing_to_route', position: null }; const asked = points.map(({ lat, lng }) => ({ lat, lng })); try { - return footWayOf(await this.router.route(userId, tripId, ANY_DAY, asked, 'walking', [])); + return footWayOf(await this.router.route(userId, tripId, ANY_DAY, asked, 'walking', [], { footOnly: true })); } catch (err: unknown) { logWarn(`[CityWalk] no way on foot for trip ${tripId}: ${err instanceof Error ? err.message : String(err)}`); return { kind: 'unrouted', why: 'router_unavailable', position: null }; diff --git a/server/src/nest/tours/city-walk.mcp.ts b/server/src/nest/tours/city-walk.mcp.ts index 8366ee766..21545d8a0 100644 --- a/server/src/nest/tours/city-walk.mcp.ts +++ b/server/src/nest/tours/city-walk.mcp.ts @@ -46,7 +46,7 @@ export class CityWalkMcp { @Tool({ name: 'propose_city_walk', description: - "Propose a city walk for a trip from the traveller's briefing, without saving anything: where it starts (and ends), the time frame (start_time to end_time, on day_id for its date), their interests (themes: culture for museums and sights, parks for parks and viewpoints, food for cafes, places to eat and markets, mixed for a bit of each; mixed when left out), the stations they asked for (wished), the places of the trip it may use (trip_places) and their fixed appointments with their times. Places for the themes come from the same search as search_tour_pois, around the start, the wishes and the appointments, up to theme_places_max (8 when left out); nothing is made up. Answers the stations in order, each with its source and why it is in (found names the theme, the category and where it was found), the walking distance and time from the one before (walk_from_previous: metres, minutes), the arrival and leave times, the stay where the traveller gave it (stay_source given; null when not known: then every later arrival is only the earliest, arrival_is_earliest; the start and the end are passed, stay_source frame), and its opening hours checked against the arrival on that day where they can be read (hours; an earliest arrival only ever shows a conflict that is certain). The order is chosen on estimates; the walk in that order is then planned on foot by TREK's router (the traveller's own routing server where they set one), whose legs give the distances and times (estimate kind routed). Where the router gives no way, they are estimates over the straight distance with an allowance for streets (estimate kind straight_line with street_factor and pace_kmh, and why: router_unavailable, or station_off_the_way with the position of the station no way on foot reaches); tell the traveller so. totals keeps walking and stays apart, total_complete only when every stay is known, and minutes_left_for_unknown_stays is what the frame leaves for the stays not known. gaps lists free time before an appointment and at the end, wishes_not_met the wishes and appointments that cannot be kept as asked (closed that day, closed or closing at the arrival, late, after the frame), left_out what was not taken and why, searched what was searched. Wished stations and appointments are always in; an appointment is a must and protected. Show the proposal to the traveller; once they confirm, pass stations_for_create to create_city_walk.", + "Propose a city walk for a trip from the traveller's briefing, without saving anything: where it starts (and ends), the time frame (start_time to end_time, on day_id for its date), their interests (themes: culture for museums and sights, parks for parks and viewpoints, food for cafes, places to eat and markets, mixed for a bit of each; mixed when left out), the stations they asked for (wished), the places of the trip it may use (trip_places) and their fixed appointments with their times. Places for the themes come from the same search as search_tour_pois, around the start, the wishes and the appointments, up to theme_places_max (8 when left out); nothing is made up. Answers the stations in order, each with its source and why it is in (found names the theme, the category and where it was found), the walking distance and time from the one before (walk_from_previous: metres, minutes), the arrival and leave times, the stay where the traveller gave it (stay_source given; null when not known: then every later arrival is only the earliest, arrival_is_earliest; the start and the end are passed, stay_source frame), and its opening hours checked against the arrival on that day where they can be read (hours; an earliest arrival only ever shows a conflict that is certain). The order is chosen on estimates; the walk in that order is then planned on foot by TREK's router (the traveller's own routing server where they set one), whose legs give the distances and times (estimate kind routed). Where the router gives no way, they are estimates over the straight distance with an allowance for streets (estimate kind straight_line with street_factor and pace_kmh, and why: router_unavailable, station_off_the_way with the position of the station no way on foot reaches, or ferry_only with the position of the station after which the only way runs by boat, a ferry that is no walking); tell the traveller so. totals keeps walking and stays apart, total_complete only when every stay is known, and minutes_left_for_unknown_stays is what the frame leaves for the stays not known. gaps lists free time before an appointment and at the end, wishes_not_met the wishes and appointments that cannot be kept as asked (closed that day, closed or closing at the arrival, late, after the frame), left_out what was not taken and why, searched what was searched. Wished stations and appointments are always in; an appointment is a must and protected. Show the proposal to the traveller; once they confirm, pass stations_for_create to create_city_walk.", inputSchema: { tripId: idSchema, ...cityWalkBriefingRequestSchema.shape }, annotations: TOOL_ANNOTATIONS_READONLY, when: addonGate(ADDON_IDS.TOURS), @@ -60,7 +60,7 @@ export class CityWalkMcp { @Tool({ name: 'create_city_walk', description: - 'Create a city walk in a trip from stations the traveller confirmed (for instance stations_for_create of propose_city_walk), the way the planner saves a new walk: a new tour of type walk with its stations in their order, each with its name, stay_minutes (0 for a point passed through, null when not known), importance (optional, must, or null for normal), protected (a station any later proposal has to keep where it is), opening_hours as OpenStreetMap writes them and the place of the trip it stands for (linked_place_id; a place that is not of this trip leaves the station unlinked, a tour is refused). The way between the stations is planned on foot by the router the proposal uses, and the walk is saved with it, its distance and its walking time (foot_way planned true). Where the router gives no way (foot_way planned false, with why and position as in the estimate of the proposal), the walk is saved with its stations only (route_pending true, distance and walking time null), and TREK plans the way on foot when someone opens it in the planner and saves it; no straight line is stored as a route. Put it on a day with assign_place_to_day and give it a start time with update_assignment_time. Needs place editing permission on the trip. Create it only after the traveller said yes. A change to a walk already saved is prepared with prepare_tour_change and saved with save_tour_change instead.', + 'Create a city walk in a trip from stations the traveller confirmed (for instance stations_for_create of propose_city_walk), the way the planner saves a new walk: a new tour of type walk with its stations in their order, each with its name, stay_minutes (0 for a point passed through, null when not known), importance (optional, must, or null for normal), protected (a station any later proposal has to keep where it is), opening_hours as OpenStreetMap writes them and the place of the trip it stands for (linked_place_id; a place that is not of this trip leaves the station unlinked, a tour is refused). The way between the stations is planned on foot by the router the proposal uses, and the walk is saved with it, its distance and its walking time (foot_way planned true). Where the router gives no way (foot_way planned false, with why and position as in the estimate of the proposal), the walk is saved with its stations only (route_pending true, distance and walking time null), and TREK plans the way on foot when someone opens it in the planner and saves it (for ferry_only it shows the crossing there, and the travellers take it as a ferry leg or move a station before it saves); no straight line is stored as a route. Put it on a day with assign_place_to_day and give it a start time with update_assignment_time. Needs place editing permission on the trip. Create it only after the traveller said yes. A change to a walk already saved is prepared with prepare_tour_change and saved with save_tour_change instead.', inputSchema: { tripId: idSchema.describe('The trip to create the walk in'), name: create.name, diff --git a/server/src/nest/tours/tour-visits.service.ts b/server/src/nest/tours/tour-visits.service.ts index 58ec7bded..a3331fb60 100644 --- a/server/src/nest/tours/tour-visits.service.ts +++ b/server/src/nest/tours/tour-visits.service.ts @@ -14,6 +14,7 @@ import { parseRouteGeometry } from './tours.helpers'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, NotFoundException } from '@nestjs/common'; import { + ferryCrossingsOf, isClosedLoop, walkRemainderOf, walkStationsOf, @@ -84,14 +85,17 @@ export class TourVisitsService { * off since counts; nothing changes. */ async remaining(tripId: number, placeId: number, dayId: number, assignmentId?: number): Promise { - const [{ walk, progress }, place] = await Promise.all([ + const [{ walk, progress }, place, points] = await Promise.all([ this.dayRun(tripId, placeId, dayId, assignmentId), this.placesRepo.findInTrip(placeId, tripId), + this.waypointsRepo.listForPlace(placeId), ]); const remainder = walkRemainderOf({ stations: progress.stations.map((station) => ({ ...station, stayMinutes: station.stay_minutes })), line: parseRouteGeometry(place?.route_geometry), walkingMinutes: progress.walking_minutes, + // CW-12: a ferry leg is crossed by boat; the saved walking time holds none of it. + crossings: ferryCrossingsOf(points), }); return { place_id: placeId, diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index 591f3dfc6..f9a37de20 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -132,7 +132,7 @@ export class ToursMcp { @Tool({ name: 'get_tour_plan', description: - 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; a leg of a planned ski tour is routed where the piste router planned it and free, a straight line between its points, where it did not), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts. tour.time_estimate is one of them where present: an estimate from distance and heights (or the pace of a run), never a time the travellers planned or recorded.', + 'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point with stage_end true ends a stage of a multi-day tour, and the next stage begins there; the stages run in the order of the route, the first from the start, the last to the end, and a tour without such a point is a tour of one day. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing; a leg of a planned ski tour is routed where the piste router planned it and free, a straight line between its points, where it did not), ferry (a city walk only: a crossing by boat the travellers took on purpose in the planner where no way on foot joins the two stations; the fixed_leg of its control point says where on the line of the leg the boat runs (crossing, the indices of the points it leaves from and lands at), minutes, the time of the boat as the router gave it, null when it gave none, and walk_minutes, the walk to the boat and on from it; the walking time of the walk holds walk_minutes and never the time of the boat), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps (for the leg of a ski tour the straight line) and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. The recorded tracks assigned to the tour (activities, ACT-01) each name their execution (assignment_id and day_id, null for none), the recording (track: its tour in the trip, origin, file name, when it came in) and what the recording measures (metrics: distance_km, elevation_gain_m and elevation_loss_m, null without heights; started_at, ended_at and elapsed_minutes from the first to the last time on its points, pauses included, null when the file carried no times, never estimated); these are the values of the recording, the planned values of the tour stay apart and unchanged. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts. tour.time_estimate is one of them where present: an estimate from distance and heights (or the pace of a run), never a time the travellers planned or recorded.', inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it'), @@ -209,7 +209,7 @@ export class ToursMcp { @Tool({ name: 'get_city_walk_remaining_time', description: - 'Read what is left of a city walk (tour_type walk) on one day it is on, from the stations the travellers checked off that day: the walking time still ahead, the stays still open and the stations still open, each apart. reference says what the estimate counts from: start (nothing visited yet), last_visited (the last station checked off as visited; its stay counts as done) or ambiguous (a station is still planned before the last one visited, so where the walk stands cannot be told: ask the travellers where they are rather than guessing). A status says what was done, never where anyone is now: live_position is always false, TREK knows no live position and no clock. walking.total_minutes is the walking time of the whole walk as saved; walking.remaining_minutes is the part of it still ahead of the reference, the saved walking time shared out by how far along the saved line each station is passed (never by how many stations are open), so stretches already walked are not in it; it holds no stay. walking.upper_bound is true when a station ahead was skipped (listed in skipped_ahead): the saved line still leads through it and the shorter way was never planned, so the time is at most that. walking.after_last_station_minutes is the part from the last station to the end of the line, for a loop (loop true) the way back to its start, still ahead even once every station is done. walking.remaining_minutes is null, with unknown_because, when it cannot be told: no_stations, route_pending (saved with its points only, no line yet), no_walking_time or progress_ambiguous; it is never given as 0 or as the whole walk instead. stays.remaining_known_minutes sums the stays of the open stations as far as entered, stays.unknown lists the open stations whose stay is not known (never counted as 0), stays.optional_known_minutes is the part of the known minutes at optional stations. open_stations lists the stations still planned in order, with importance, stay_minutes and walk_minutes_from_reference (walking time from the reference to it, null where the remaining time is not known); a skipped station is done and not in it. finished is true when no station is open. Visits belong to that day only: the same walk on another day, or a copy, starts from the beginning. A time the traveller tells you (for instance "continue from 2 pm") is theirs to add, not stored here. A walk put on the same day twice runs apart: assignment_id names the run read, the first of the day unless assignmentId names another. derived names the fields worked out on reading from the visits and the saved line (all but walking.total_minutes, the walking time as saved): never present them as stored facts. Reading changes nothing: no status, tour or day plan.', + 'Read what is left of a city walk (tour_type walk) on one day it is on, from the stations the travellers checked off that day: the walking time still ahead, the stays still open and the stations still open, each apart. reference says what the estimate counts from: start (nothing visited yet), last_visited (the last station checked off as visited; its stay counts as done) or ambiguous (a station is still planned before the last one visited, so where the walk stands cannot be told: ask the travellers where they are rather than guessing). A status says what was done, never where anyone is now: live_position is always false, TREK knows no live position and no clock. walking.total_minutes is the walking time of the whole walk as saved; walking.remaining_minutes is the part of it still ahead of the reference, the saved walking time shared out by how far along the saved line each station is passed (never by how many stations are open), so stretches already walked are not in it; it holds no stay, and no crossing by boat: a ferry leg the travellers took (get_tour_plan, leg kind ferry) is not walked and takes no share of it. walking.upper_bound is true when a station ahead was skipped (listed in skipped_ahead): the saved line still leads through it and the shorter way was never planned, so the time is at most that. walking.after_last_station_minutes is the part from the last station to the end of the line, for a loop (loop true) the way back to its start, still ahead even once every station is done. walking.remaining_minutes is null, with unknown_because, when it cannot be told: no_stations, route_pending (saved with its points only, no line yet), no_walking_time or progress_ambiguous; it is never given as 0 or as the whole walk instead. stays.remaining_known_minutes sums the stays of the open stations as far as entered, stays.unknown lists the open stations whose stay is not known (never counted as 0), stays.optional_known_minutes is the part of the known minutes at optional stations. open_stations lists the stations still planned in order, with importance, stay_minutes and walk_minutes_from_reference (walking time from the reference to it, null where the remaining time is not known); a skipped station is done and not in it. finished is true when no station is open. Visits belong to that day only: the same walk on another day, or a copy, starts from the beginning. A time the traveller tells you (for instance "continue from 2 pm") is theirs to add, not stored here. A walk put on the same day twice runs apart: assignment_id names the run read, the first of the day unless assignmentId names another. derived names the fields worked out on reading from the visits and the saved line (all but walking.total_minutes, the walking time as saved): never present them as stored facts. Reading changes nothing: no status, tour or day plan.', inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the city walk, as list_tours gives it'), diff --git a/server/tests/e2e/tours-walk.e2e.test.ts b/server/tests/e2e/tours-walk.e2e.test.ts index 7075c013d..a459ce773 100644 --- a/server/tests/e2e/tours-walk.e2e.test.ts +++ b/server/tests/e2e/tours-walk.e2e.test.ts @@ -370,6 +370,55 @@ describe('City walks e2e (real guards + temp SQLite)', () => { }); }); + describe('a crossing by boat taken as a ferry leg (CW-12)', () => { + const FERRY = { + kind: 'ferry', + geometry: [ + [48.11, 11.51], + [48.12, 11.52], + [48.13, 11.53], + ], + crossing: [0, 1], + minutes: 7, + walk_minutes: 3, + }; + const across = (leg: Point, type = 'walk') => + walkBody({ + tour_type: type, + waypoints: inOrder([ + { lat: 48.1, lng: 11.5 }, + { lat: 48.11, lng: 11.51, fixed_leg: leg }, + { lat: 48.13, lng: 11.53 }, + ]), + }); + + it('keeps the leg as it was taken, the boat apart from the walk, through a save and a read', async () => { + const { placeId, waypoints } = await saveWalk(5, across(FERRY)); + expect(waypoints[1]?.fixed_leg).toEqual(FERRY); + const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', cookie(VIEWER)); + expect(detail.body.waypoints[1].fixed_leg).toEqual(FERRY); + expect(detail.body.tour.duration).toBe(60); + }); + + it('refuses it on any other type, and a crossing that does not run forwards within its leg', async () => { + const bodies = [ + across(FERRY, 'hike'), + across({ ...FERRY, crossing: [1, 0] }), + across({ ...FERRY, crossing: [1, 3] }), + across({ ...FERRY, minutes: -1 }), + ]; + const statuses = await bodies.reduce>( + async (done, body) => [ + ...(await done), + (await request(server).post('/api/trips/5/tours').set('Cookie', cookie()).send(body)).status, + ], + Promise.resolve([]), + ); + expect(statuses).toEqual([400, 400, 400, 400]); + expect(await countRows(orm, Tours)).toBe(0); + }); + }); + describe('stations checked off on a day (CW-03)', () => { it('marks a station visited, then skipped, then takes it back, telling the trip each time', async () => { const { assignmentId, cathedral } = await walkOnDay3(); diff --git a/server/tests/helpers/factories/tours.ts b/server/tests/helpers/factories/tours.ts index 30ce3c5d5..ce617dfe2 100644 --- a/server/tests/helpers/factories/tours.ts +++ b/server/tests/helpers/factories/tours.ts @@ -34,6 +34,8 @@ interface StationColumns { opening_hours?: string | null; /** 1 for a shaping point. */ shaping?: number; + /** The leg to the next point as a fixed line, as JSON. */ + fixed_leg?: string | null; /** MDT-01: 1 for a stop that ends a stage of a multi-day tour. */ stage_end?: number; /** The id of the place of the trip the point stands for. */ diff --git a/server/tests/unit/nest/city-walk-briefing.helpers.test.ts b/server/tests/unit/nest/city-walk-briefing.helpers.test.ts index e814e870f..5ca48f40c 100644 --- a/server/tests/unit/nest/city-walk-briefing.helpers.test.ts +++ b/server/tests/unit/nest/city-walk-briefing.helpers.test.ts @@ -434,4 +434,31 @@ describe('city walk briefing helpers (CW-11)', () => { position: 2, }); }); + + it('CWALK-HELP-015: a way that crosses by boat is none on foot; the estimates stand in and say where the boat starts (CW-12)', () => { + const line: Array<[number, number]> = [ + [45.434, 12.338], + [45.4296, 12.3264], + [45.4268, 12.33], + ]; + const parts = [ + { distance: 1400, duration: 1000 }, + { distance: 500, duration: 520 }, + ]; + const way = footWayOf({ + leg: { line }, + parts, + snapped: [{ meters: 3 }, { meters: 4 }, { meters: 5 }], + ferryLegs: [1], + }); + expect(way).toEqual({ kind: 'unrouted', why: 'ferry_only', position: 2 }); + const { route } = planWalk(plan({ end: null })); + expect(describeWalk(route, FRAME, way).estimate).toMatchObject({ + kind: 'straight_line', + why: 'ferry_only', + position: 2, + }); + // No crossing named, the way stands. + expect(footWayOf({ leg: { line }, parts, ferryLegs: [] })).toMatchObject({ kind: 'routed' }); + }); }); diff --git a/server/tests/unit/nest/roadtrip-router.test.ts b/server/tests/unit/nest/roadtrip-router.test.ts index 75041d0bc..ce812738f 100644 --- a/server/tests/unit/nest/roadtrip-router.test.ts +++ b/server/tests/unit/nest/roadtrip-router.test.ts @@ -211,3 +211,94 @@ describe('server Roadtrip router', () => { expect(await router.profiles()).toContain('plugin:scenic/car'); }); }); + +describe('server Roadtrip router, a walk on foot only (CW-12)', () => { + const encode = (value: number) => { + let remaining = value < 0 ? ~(value << 1) : value << 1; + let encoded = ''; + while (remaining >= 32) { + encoded += String.fromCharCode((32 | (remaining & 31)) + 63); + remaining >>>= 5; + } + return encoded + String.fromCharCode(remaining + 63); + }; + const shape = encode(48000000) + encode(10000000) + encode(1000000) + encode(1000000); + const valhallaLegs = (legs: unknown[]) => new Response(JSON.stringify({ trip: { legs } })); + const walked = { shape, summary: { length: 1.4, time: 1000, has_ferry: false } }; + // Measured on Venice: the boat leg as FOSSGIS answers it, boarding at type 28 with ferry true. + const byBoat = { + shape, + summary: { length: 1.9, time: 1250, has_ferry: true }, + maneuvers: [{ type: 1 }, { type: 28, ferry: true }, { type: 29 }], + }; + + it('asks Valhalla on foot with no point bound to a boat line, and names the legs that still cross by boat', async () => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(valhallaLegs([walked, byBoat])); + const route = await setup().router.route(1, 1, 0, points, 'walking', [], { footOnly: true }); + const [url, init] = vi.mocked(safeFetchAdminConfigured).mock.calls[0] as [string, { body: string }]; + expect(url).toBe('https://valhalla1.openstreetmap.de/route'); + const body = JSON.parse(init.body) as { locations: Array>; costing_options: unknown }; + expect( + body.locations.every((location) => (location.search_filter as { exclude_ferry: boolean }).exclude_ferry), + ).toBe(true); + expect(body.costing_options).toEqual({ pedestrian: { use_ferry: 0 } }); + expect(route.ferryLegs).toEqual([1]); + expect(route.parts).toEqual([ + { distance: 1400, duration: 1000 }, + { distance: 1900, duration: 1250 }, + ]); + expect(route.snapped).toHaveLength(3); + }); + + it('reads a crossing off OSRM steps where no Valhalla is asked, and keeps the foot-only answer apart in the cache', async () => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce( + new Response( + JSON.stringify({ + code: 'Ok', + routes: [ + { + distance: 3000, + duration: 300, + geometry: { coordinates: points.map((p) => [p.lng, p.lat]) }, + legs: [ + { distance: 1000, duration: 100, steps: [{ mode: 'walking' }] }, + { distance: 2000, duration: 200, steps: [{ mode: 'walking' }, { mode: 'ferry' }] }, + ], + }, + ], + }), + ), + ); + const { router } = setup({ routing_base_url: 'http://router.internal' }); + const route = await router.route(1, 1, 0, points, 'walking', [], { footOnly: true }); + expect(safeFetchAdminConfigured).toHaveBeenCalledWith(expect.stringContaining('steps=true'), expect.anything()); + expect(route.ferryLegs).toEqual([1]); + expect(route.parts).toEqual([ + { distance: 1000, duration: 100 }, + { distance: 2000, duration: 200 }, + ]); + // The same points asked without the option are another question, answered without ferries named. + const plain = router.route(1, 1, 0, points, 'walking', []); + await vi.runAllTimersAsync(); + expect(await plain).not.toHaveProperty('ferryLegs'); + expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith( + expect.stringContaining('steps=false'), + expect.anything(), + ); + }); + + it('falls back to OSRM on foot when Valhalla fails, and leaves a drive as it was', async () => { + vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(new Response('', { status: 503 })); + const pending = setup().router.route(1, 1, 0, points, 'walking', [], { footOnly: true }); + await vi.runAllTimersAsync(); + expect((await pending).ferryLegs).toEqual([]); + expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith( + expect.stringContaining('routed-foot'), + expect.anything(), + ); + + const drive = await setup().router.route(1, 1, 0, points, 'driving', [], { footOnly: true }); + expect(drive).not.toHaveProperty('ferryLegs'); + expect(safeFetchAdminConfigured).toHaveBeenLastCalledWith(expect.stringContaining('routed-car'), expect.anything()); + }); +}); diff --git a/server/tests/unit/nest/tour-visits.service.test.ts b/server/tests/unit/nest/tour-visits.service.test.ts index 36345fb5c..e510cac2f 100644 --- a/server/tests/unit/nest/tour-visits.service.test.ts +++ b/server/tests/unit/nest/tour-visits.service.test.ts @@ -365,6 +365,47 @@ describe('TourVisitsService', () => { expect(t.realtime.broadcastMock).not.toHaveBeenCalled(); }); + it('TOUR-VISITS-011: remaining shares no walking time out along a ferry leg the travellers took (CW-12)', async () => { + const { trip, day1 } = await fixture(); + const island = await makePlace(orm(), trip.id, { + name: 'Giudecca', + route_geometry: JSON.stringify([ + [48.1, 11.5], + [48.11, 11.51], + [48.12, 11.52], + [48.13, 11.53], + ]), + }); + await makeTour(orm(), island.id, { tourTypeRef: 'walk', duration: 60 }); + const ferry = { + kind: 'ferry', + geometry: [ + [48.11, 11.51], + [48.12, 11.52], + ], + crossing: [0, 1], + minutes: 7, + walk_minutes: 0, + }; + await addTourWaypoints(orm(), island.id, [ + { lat: 48.1, lng: 11.5, station_key: 'stop-zattere', stay_minutes: 0 }, + { lat: 48.11, lng: 11.51, station_key: 'stop-pier', stay_minutes: 0, fixed_leg: JSON.stringify(ferry) }, + { lat: 48.12, lng: 11.52, station_key: 'stop-redentore', stay_minutes: 20 }, + { lat: 48.13, lng: 11.53, station_key: 'stop-garden', stay_minutes: 30 }, + ]); + const onDay1 = await makeDayAssignment(orm(), day1.id, island.id); + await markStationVisit(orm(), onDay1.id, 'stop-zattere', 'visited'); + + const remaining = await visits.remaining(trip.id, island.id, day1.id); + // Two stretches are walked, one is crossed by boat: the landing is passed after half the walk, not two thirds. + expect(remaining.open_stations.map((station) => [station.key, station.walk_minutes_from_reference])).toEqual([ + ['stop-pier', 30], + ['stop-redentore', 30], + ['stop-garden', 60], + ]); + expect(remaining.walking).toMatchObject({ total_minutes: 60, remaining_minutes: 60 }); + }); + it('TOUR-VISITS-009: a loop keeps its way back once every station is done, and a walk not on that day is a 404', async () => { const { trip, day1, day3, walk } = await fixture(); const loop = await makePlace(orm(), trip.id, { diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 95db8d378..5693ba145 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -756,6 +756,25 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'محسوب', 'tours.walk.stations': 'محطات الجولة', 'tours.walk.stationNumbered': 'المحطة {number}', + 'tours.walk.ferry.gap': 'لا يوجد طريق يربط {from} و{to}، لا سيرًا ولا بالعبّارة.', + 'tours.walk.ferry.gapHint': 'يبقى المسار المخطط سابقًا على الخريطة باهتًا حتى يُعثر على طريق. انقل إحدى المحطتين.', + 'tours.walk.ferry.pending': 'لا يوجد طريق سيرًا بين {from} و{to}: يعبر الموجّه الماء هنا بالعبّارة.', + 'tours.walk.ferry.pendingHint': + 'لا يُحتسب العبور وقتَ مشي. للحفظ، اعتمد العبّارة أو انقل محطة كي يربطهما طريق سيرًا.', + 'tours.walk.ferry.accepted': 'عبّارة من {from} إلى {to}، اعتُمدت عن قصد. يبقى وقتها منفصلًا عن وقت المشي.', + 'tours.walk.ferry.accept': 'اعتماد العبّارة', + 'tours.walk.ferry.show': 'إظهار العبور', + 'tours.walk.ferry.selectStop': 'اختيار المحطة {number}', + 'tours.walk.ferry.time': '{time} بالقارب', + 'tours.walk.ferry.timeUnknown': 'وقت القارب غير معروف', + 'tours.walk.ferry.timeHint': 'كما يعطيه الموجّه للعبور. ليس جزءًا من وقت المشي.', + 'tours.walk.ferry.field': 'عبّارة', + 'tours.walk.ferry.fieldInfoLabel': 'عن وقت العبّارة', + 'tours.walk.ferry.fieldInfo': + 'الوقت الذي تستغرقه العبورات بالقارب كما يعطيه الموجّه. يبقى منفصلًا عن وقت المشي وليس ضمن الإجمالي المخطط.', + 'tours.walk.ferry.leg': 'عبور بالعبّارة', + 'tours.walk.ferry.legInfo': 'عُبر بالقارب لا سيرًا. وقته ليس ضمن وقت المشي.', + 'tours.walk.ferry.release': 'التخطيط سيرًا من جديد', 'tours.walk.stationsUnread': 'تعذّر تحميل محطات هذه الجولة. افتحها مرة أخرى وأنت متصل بالإنترنت لعرضها وتأشيرها.', 'tours.walk.progress': 'تمت زيارة {visited} من {total}', 'tours.walk.remaining.pill': 'متبقٍّ {time} سيرًا', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 298bd7927..d23407cbc 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -755,6 +755,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Hesablanmış', 'tours.walk.stations': 'Gəzintinin dayanacaqları', 'tours.walk.stationNumbered': 'Dayanacaq {number}', + 'tours.walk.ferry.gap': '{from} ilə {to} arasında nə piyada, nə də bərə ilə yol var.', + 'tours.walk.ferry.gapHint': + 'Əvvəl planlanan marşrut yol tapılana qədər xəritədə solğun qalır. İki dayanacaqdan birini köçür.', + 'tours.walk.ferry.pending': + '{from} ilə {to} arasında piyada yol yoxdur: marşrutlaşdırıcı burada suyu bərə ilə keçir.', + 'tours.walk.ferry.pendingHint': + 'Keçid yürüş vaxtı sayılmır. Yadda saxlamaq üçün bərəni qəbul et və ya piyada yol onları birləşdirsin deyə dayanacağı köçür.', + 'tours.walk.ferry.accepted': + '{from}-dan {to}-a bərə, qəsdən qəbul edilib. Onun vaxtı yürüş vaxtından ayrı saxlanılır.', + 'tours.walk.ferry.accept': 'Bərəni qəbul et', + 'tours.walk.ferry.show': 'Keçidi göstər', + 'tours.walk.ferry.selectStop': '{number} nömrəli dayanacağı seç', + 'tours.walk.ferry.time': 'Qayıqla {time}', + 'tours.walk.ferry.timeUnknown': 'Qayıq vaxtı bilinmir', + 'tours.walk.ferry.timeHint': 'Marşrutlaşdırıcının keçid üçün verdiyi kimi. Yürüş vaxtına daxil deyil.', + 'tours.walk.ferry.field': 'Bərə', + 'tours.walk.ferry.fieldInfoLabel': 'Bərə vaxtı haqqında', + 'tours.walk.ferry.fieldInfo': + 'Qayıqla keçidlərin vaxtı, marşrutlaşdırıcının verdiyi kimi. Yürüş vaxtından ayrı saxlanılır və planlanan cəmə daxil deyil.', + 'tours.walk.ferry.leg': 'Bərə keçidi', + 'tours.walk.ferry.legInfo': 'Qayıqla keçilib, piyada deyil. Vaxtı yürüş vaxtına daxil deyil.', + 'tours.walk.ferry.release': 'Yenidən piyada planla', 'tours.walk.stationsUnread': 'Bu gəzintinin dayanacaqları yüklənə bilmədi. Onları görmək və qeyd etmək üçün gəzintini internet bağlantısı ilə yenidən aç.', 'tours.walk.progress': '{total} dayanacaqdan {visited} ziyarət edildi', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 955b72913..b9ea661f9 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -764,6 +764,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculado', 'tours.walk.stations': 'Paradas do passeio', 'tours.walk.stationNumbered': 'Parada {number}', + 'tours.walk.ferry.gap': 'Nenhum caminho liga {from} e {to}, nem a pé nem de balsa.', + 'tours.walk.ferry.gapHint': + 'A rota planejada antes fica no mapa, esmaecida, até que um caminho seja encontrado. Mova uma das duas paradas.', + 'tours.walk.ferry.pending': 'Não há caminho a pé entre {from} e {to}: aqui o roteador atravessa a água de balsa.', + 'tours.walk.ferry.pendingHint': + 'A travessia não conta como tempo de caminhada. Para salvar, aceite a balsa ou mova uma parada para que um caminho a pé as ligue.', + 'tours.walk.ferry.accepted': + 'Balsa de {from} a {to}, aceita de propósito. O tempo dela fica separado do tempo de caminhada.', + 'tours.walk.ferry.accept': 'Aceitar balsa', + 'tours.walk.ferry.show': 'Mostrar travessia', + 'tours.walk.ferry.selectStop': 'Selecionar parada {number}', + 'tours.walk.ferry.time': '{time} de barco', + 'tours.walk.ferry.timeUnknown': 'Tempo de barco desconhecido', + 'tours.walk.ferry.timeHint': 'Como o roteador o informa para a travessia. Não faz parte do tempo de caminhada.', + 'tours.walk.ferry.field': 'Balsa', + 'tours.walk.ferry.fieldInfoLabel': 'Sobre o tempo de balsa', + 'tours.walk.ferry.fieldInfo': + 'O tempo das travessias de barco, como o roteador o informa. Fica separado do tempo de caminhada e não entra no total planejado.', + 'tours.walk.ferry.leg': 'Travessia de balsa', + 'tours.walk.ferry.legInfo': 'Atravessado de barco, não a pé. O tempo não está no tempo de caminhada.', + 'tours.walk.ferry.release': 'Planejar a pé de novo', 'tours.walk.stationsUnread': 'Não foi possível carregar as paradas deste passeio. Abra-o de novo com conexão para vê-las e marcá-las.', 'tours.walk.progress': '{visited} de {total} visitadas', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 27b45dc08..89a5266f2 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -768,6 +768,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculat', 'tours.walk.stations': 'Parades de la ruta', 'tours.walk.stationNumbered': 'Parada {number}', + 'tours.walk.ferry.gap': 'Cap camí uneix {from} i {to}, ni a peu ni amb ferri.', + 'tours.walk.ferry.gapHint': + 'La ruta planificada abans es queda al mapa, en gris, fins que es trobi un camí. Mou una de les dues parades.', + 'tours.walk.ferry.pending': "No hi ha cap camí a peu entre {from} i {to}: aquí el router travessa l'aigua amb ferri.", + 'tours.walk.ferry.pendingHint': + 'La travessia no compta com a temps a peu. Per desar, accepta el ferri o mou una parada perquè un camí a peu les uneixi.', + 'tours.walk.ferry.accepted': + 'Ferri de {from} a {to}, acceptat expressament. El seu temps es manté a part del temps a peu.', + 'tours.walk.ferry.accept': 'Acceptar el ferri', + 'tours.walk.ferry.show': 'Mostrar la travessia', + 'tours.walk.ferry.selectStop': 'Tria la parada {number}', + 'tours.walk.ferry.time': '{time} en vaixell', + 'tours.walk.ferry.timeUnknown': 'Temps en vaixell desconegut', + 'tours.walk.ferry.timeHint': 'Tal com el dona el router per a la travessia. No forma part del temps a peu.', + 'tours.walk.ferry.field': 'Ferri', + 'tours.walk.ferry.fieldInfoLabel': 'Sobre el temps de ferri', + 'tours.walk.ferry.fieldInfo': + 'El temps de les travessies en vaixell, tal com el dona el router. Es manté a part del temps a peu i no és al total planificat.', + 'tours.walk.ferry.leg': 'Travessia en ferri', + 'tours.walk.ferry.legInfo': 'Travessat en vaixell, no a peu. El seu temps no és al temps a peu.', + 'tours.walk.ferry.release': 'Tornar a planificar a peu', 'tours.walk.stationsUnread': "No s'han pogut carregar les parades d'aquesta ruta. Torna a obrir-la amb connexió per veure-les i marcar-les.", 'tours.walk.progress': '{visited} de {total} visitades', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index d0f67d279..2c3fcb92c 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -751,6 +751,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Vypočteno', 'tours.walk.stations': 'Zastávky procházky', 'tours.walk.stationNumbered': 'Zastávka {number}', + 'tours.walk.ferry.gap': 'Mezi {from} a {to} nevede žádná cesta, pěšky ani trajektem.', + 'tours.walk.ferry.gapHint': + 'Dříve naplánovaná trasa zůstane na mapě zašedlá, dokud se cesta nenajde. Přesuň jednu z obou zastávek.', + 'tours.walk.ferry.pending': 'Mezi {from} a {to} nevede žádná pěší cesta: plánovač zde přejíždí vodu trajektem.', + 'tours.walk.ferry.pendingHint': + 'Přeprava se nepočítá do doby chůze. Pro uložení převezmi trajekt nebo přesuň zastávku, aby je spojila pěší cesta.', + 'tours.walk.ferry.accepted': 'Trajekt z {from} do {to}, převzatý záměrně. Jeho čas je oddělen od doby chůze.', + 'tours.walk.ferry.accept': 'Převzít trajekt', + 'tours.walk.ferry.show': 'Ukázat přepravu', + 'tours.walk.ferry.selectStop': 'Vybrat zastávku {number}', + 'tours.walk.ferry.time': '{time} lodí', + 'tours.walk.ferry.timeUnknown': 'Čas lodí neznámý', + 'tours.walk.ferry.timeHint': 'Jak ho plánovač udává pro přepravu. Není součástí doby chůze.', + 'tours.walk.ferry.field': 'Trajekt', + 'tours.walk.ferry.fieldInfoLabel': 'O čase trajektu', + 'tours.walk.ferry.fieldInfo': + 'Čas přeprav lodí, jak ho udává plánovač. Je oddělen od doby chůze a není v plánovaném celku.', + 'tours.walk.ferry.leg': 'Přeprava trajektem', + 'tours.walk.ferry.legInfo': 'Přepluto lodí, ne prošlo. Jeho čas není v době chůze.', + 'tours.walk.ferry.release': 'Znovu naplánovat pěšky', 'tours.walk.stationsUnread': 'Zastávky této procházky se nepodařilo načíst. Otevři ji znovu s připojením, pak je uvidíš a půjde je odškrtat.', 'tours.walk.progress': 'Navštíveno {visited} z {total}', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index c54b606d6..36878848d 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -771,6 +771,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Berechnet', 'tours.walk.stations': 'Stationen', 'tours.walk.stationNumbered': 'Station {number}', + 'tours.walk.ferry.gap': 'Kein Weg verbindet {from} und {to}, weder zu Fuß noch mit einer Fähre.', + 'tours.walk.ferry.gapHint': + 'Die zuvor geplante Route bleibt ausgegraut auf der Karte, bis ein Weg gefunden ist. Verschiebe eine der beiden Stationen.', + 'tours.walk.ferry.pending': + 'Zwischen {from} und {to} gibt es keinen Fußweg: Der Router setzt hier mit einer Fähre über.', + 'tours.walk.ferry.pendingHint': + 'Die Überfahrt zählt nicht als Gehzeit. Zum Speichern übernimm die Fähre oder verschiebe eine Station, damit ein Fußweg sie verbindet.', + 'tours.walk.ferry.accepted': + 'Fähre von {from} nach {to}, bewusst übernommen. Ihre Zeit steht getrennt von der Gehzeit.', + 'tours.walk.ferry.accept': 'Fähre übernehmen', + 'tours.walk.ferry.show': 'Überfahrt zeigen', + 'tours.walk.ferry.selectStop': 'Station {number} wählen', + 'tours.walk.ferry.time': '{time} mit dem Boot', + 'tours.walk.ferry.timeUnknown': 'Bootszeit unbekannt', + 'tours.walk.ferry.timeHint': 'So wie der Router sie für die Überfahrt angibt. Sie gehört nicht zur Gehzeit.', + 'tours.walk.ferry.field': 'Fähre', + 'tours.walk.ferry.fieldInfoLabel': 'Zur Fährzeit', + 'tours.walk.ferry.fieldInfo': + 'Die Zeit der Überfahrten mit dem Boot, so wie der Router sie angibt. Sie steht getrennt von der Gehzeit und ist nicht in der geplanten Gesamtzeit.', + 'tours.walk.ferry.leg': 'Überfahrt mit der Fähre', + 'tours.walk.ferry.legInfo': 'Mit dem Boot übergesetzt, nicht gegangen. Ihre Zeit ist nicht in der Gehzeit.', + 'tours.walk.ferry.release': 'Wieder zu Fuß planen', 'tours.walk.stationsUnread': 'Die Stationen dieses Walks ließen sich nicht laden. Öffne ihn erneut mit Verbindung, um sie zu sehen und abzuhaken.', 'tours.walk.progress': '{visited} von {total} besucht', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 73bba9eac..cefb971c2 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -750,6 +750,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Computed', 'tours.walk.stations': 'Stops of the walk', 'tours.walk.stationNumbered': 'Stop {number}', + 'tours.walk.ferry.gap': 'No way joins {from} and {to}, neither on foot nor by ferry.', + 'tours.walk.ferry.gapHint': + 'The route planned before stays on the map, greyed, until a way is found. Move one of the two stops.', + 'tours.walk.ferry.pending': + 'There is no way on foot between {from} and {to}: the router crosses the water by ferry here.', + 'tours.walk.ferry.pendingHint': + 'The crossing is not counted as walking time. To save, take the ferry or move a stop so a way on foot joins them.', + 'tours.walk.ferry.accepted': + 'Ferry from {from} to {to}, taken on purpose. Its time is kept apart from the walking time.', + 'tours.walk.ferry.accept': 'Take ferry', + 'tours.walk.ferry.show': 'Show crossing', + 'tours.walk.ferry.selectStop': 'Select stop {number}', + 'tours.walk.ferry.time': '{time} by boat', + 'tours.walk.ferry.timeUnknown': 'Boat time unknown', + 'tours.walk.ferry.timeHint': 'As the router gives it for the crossing. It is not part of the walking time.', + 'tours.walk.ferry.field': 'Ferry', + 'tours.walk.ferry.fieldInfoLabel': 'About the ferry time', + 'tours.walk.ferry.fieldInfo': + 'The time the crossings by boat take, as the router gives it. It is kept apart from the walking time and is not in the planned total.', + 'tours.walk.ferry.leg': 'Ferry crossing', + 'tours.walk.ferry.legInfo': 'Crossed by boat, not walked. Its time is not in the walking time.', + 'tours.walk.ferry.release': 'Plan on foot again', 'tours.walk.stationsUnread': 'The stops of this walk could not be loaded. Open it again while online to see them and check them off.', 'tours.walk.progress': '{visited} of {total} visited', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index d05a61faa..915d48b11 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -765,6 +765,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculado', 'tours.walk.stations': 'Paradas del paseo', 'tours.walk.stationNumbered': 'Parada {number}', + 'tours.walk.ferry.gap': 'Ningún camino une {from} y {to}, ni a pie ni en ferri.', + 'tours.walk.ferry.gapHint': + 'La ruta planificada antes queda en el mapa, en gris, hasta que se encuentre un camino. Mueve una de las dos paradas.', + 'tours.walk.ferry.pending': 'No hay camino a pie entre {from} y {to}: aquí el enrutador cruza el agua en ferri.', + 'tours.walk.ferry.pendingHint': + 'La travesía no cuenta como tiempo a pie. Para guardar, acepta el ferri o mueve una parada para que un camino a pie las una.', + 'tours.walk.ferry.accepted': 'Ferri de {from} a {to}, aceptado a propósito. Su tiempo queda aparte del tiempo a pie.', + 'tours.walk.ferry.accept': 'Aceptar ferri', + 'tours.walk.ferry.show': 'Mostrar travesía', + 'tours.walk.ferry.selectStop': 'Elegir parada {number}', + 'tours.walk.ferry.time': '{time} en barco', + 'tours.walk.ferry.timeUnknown': 'Tiempo en barco desconocido', + 'tours.walk.ferry.timeHint': 'Tal como lo da el enrutador para la travesía. No forma parte del tiempo a pie.', + 'tours.walk.ferry.field': 'Ferri', + 'tours.walk.ferry.fieldInfoLabel': 'Sobre el tiempo en ferri', + 'tours.walk.ferry.fieldInfo': + 'El tiempo de las travesías en barco, tal como lo da el enrutador. Queda aparte del tiempo a pie y no está en el total planificado.', + 'tours.walk.ferry.leg': 'Travesía en ferri', + 'tours.walk.ferry.legInfo': 'Cruzado en barco, no a pie. Su tiempo no está en el tiempo a pie.', + 'tours.walk.ferry.release': 'Planificar a pie de nuevo', 'tours.walk.stationsUnread': 'No se pudieron cargar las paradas de este paseo. Vuelve a abrirlo con conexión para verlas y marcarlas.', 'tours.walk.progress': '{visited} de {total} visitadas', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index fc65f52f8..cc1890b8e 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -753,6 +753,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Arvutatud', 'tours.walk.stations': 'Jalutuse peatused', 'tours.walk.stationNumbered': 'Peatus {number}', + 'tours.walk.ferry.gap': '{from} ja {to} vahel pole teed, ei jalgsi ega praamiga.', + 'tours.walk.ferry.gapHint': + 'Varem planeeritud marsruut jääb kaardile hallina, kuni tee leitakse. Liiguta üht kahest peatusest.', + 'tours.walk.ferry.pending': '{from} ja {to} vahel pole jalgteed: marsruutija ületab siin vee praamiga.', + 'tours.walk.ferry.pendingHint': + 'Ülesõitu ei loeta kõndimisajaks. Salvestamiseks võta praam üle või liiguta peatust, et jalgtee need ühendaks.', + 'tours.walk.ferry.accepted': 'Praam {from} kuni {to}, meelega üle võetud. Selle aeg on kõndimisajast eraldi.', + 'tours.walk.ferry.accept': 'Võta praam', + 'tours.walk.ferry.show': 'Näita ülesõitu', + 'tours.walk.ferry.selectStop': 'Vali peatus {number}', + 'tours.walk.ferry.time': '{time} paadiga', + 'tours.walk.ferry.timeUnknown': 'Paadiaeg teadmata', + 'tours.walk.ferry.timeHint': 'Nagu marsruutija selle ülesõidu jaoks annab. See pole kõndimisaja osa.', + 'tours.walk.ferry.field': 'Praam', + 'tours.walk.ferry.fieldInfoLabel': 'Praamiajast', + 'tours.walk.ferry.fieldInfo': + 'Paadiga ülesõitude aeg, nagu marsruutija selle annab. See on kõndimisajast eraldi ega kuulu planeeritud koguaega.', + 'tours.walk.ferry.leg': 'Praamiga ülesõit', + 'tours.walk.ferry.legInfo': 'Ületatud paadiga, mitte jalgsi. Selle aeg pole kõndimisajas.', + 'tours.walk.ferry.release': 'Planeeri uuesti jalgsi', 'tours.walk.stationsUnread': 'Selle jalutuse peatusi ei õnnestunud laadida. Ava jalutus uuesti võrguühendusega, et neid näha ja ära märkida.', 'tours.walk.progress': '{visited} / {total} külastatud', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 2c372287c..b9b981b66 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -778,6 +778,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calculé', 'tours.walk.stations': 'Étapes de la balade', 'tours.walk.stationNumbered': 'Étape {number}', + 'tours.walk.ferry.gap': 'Aucun chemin ne relie {from} et {to}, ni à pied ni en ferry.', + 'tours.walk.ferry.gapHint': + "L'itinéraire planifié avant reste sur la carte, grisé, jusqu'à ce qu'un chemin soit trouvé. Déplace l'une des deux étapes.", + 'tours.walk.ferry.pending': + "Il n'y a pas de chemin à pied entre {from} et {to} : ici, le calculateur traverse l'eau en ferry.", + 'tours.walk.ferry.pendingHint': + "La traversée ne compte pas comme temps de marche. Pour enregistrer, prends le ferry ou déplace une étape pour qu'un chemin à pied les relie.", + 'tours.walk.ferry.accepted': 'Ferry de {from} à {to}, pris exprès. Son temps reste à part du temps de marche.', + 'tours.walk.ferry.accept': 'Prendre le ferry', + 'tours.walk.ferry.show': 'Montrer la traversée', + 'tours.walk.ferry.selectStop': "Choisir l'étape {number}", + 'tours.walk.ferry.time': '{time} en bateau', + 'tours.walk.ferry.timeUnknown': 'Temps en bateau inconnu', + 'tours.walk.ferry.timeHint': + 'Tel que le calculateur le donne pour la traversée. Il ne fait pas partie du temps de marche.', + 'tours.walk.ferry.field': 'Ferry', // same-as-en + 'tours.walk.ferry.fieldInfoLabel': 'À propos du temps de ferry', + 'tours.walk.ferry.fieldInfo': + "Le temps des traversées en bateau, tel que le calculateur le donne. Il reste à part du temps de marche et n'entre pas dans le total prévu.", + 'tours.walk.ferry.leg': 'Traversée en ferry', + 'tours.walk.ferry.legInfo': "Traversé en bateau, pas à pied. Son temps n'est pas dans le temps de marche.", + 'tours.walk.ferry.release': 'Replanifier à pied', 'tours.walk.stationsUnread': "Les étapes de cette balade n'ont pas pu être chargées. Rouvre-la avec une connexion pour les voir et les cocher.", 'tours.walk.progress': '{visited} sur {total} visitées', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index ced6c54a4..1ef42fa92 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -782,6 +782,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Υπολογισμένο', 'tours.walk.stations': 'Στάσεις της βόλτας', 'tours.walk.stationNumbered': 'Στάση {number}', + 'tours.walk.ferry.gap': 'Κανένας δρόμος δεν ενώνει {from} και {to}, ούτε με τα πόδια ούτε με φέρι.', + 'tours.walk.ferry.gapHint': + 'Η διαδρομή που σχεδιάστηκε πριν μένει στον χάρτη, γκριζαρισμένη, μέχρι να βρεθεί δρόμος. Μετακίνησε μία από τις δύο στάσεις.', + 'tours.walk.ferry.pending': + 'Δεν υπάρχει πεζός δρόμος μεταξύ {from} και {to}: εδώ η δρομολόγηση περνά το νερό με φέρι.', + 'tours.walk.ferry.pendingHint': + 'Η διάβαση δεν μετράει ως χρόνος περπατήματος. Για αποθήκευση, κράτησε το φέρι ή μετακίνησε μια στάση ώστε να τις ενώνει πεζός δρόμος.', + 'tours.walk.ferry.accepted': + 'Φέρι από {from} προς {to}, κρατημένο σκόπιμα. Ο χρόνος του μένει χωριστά από τον χρόνο περπατήματος.', + 'tours.walk.ferry.accept': 'Κράτηση φέρι', + 'tours.walk.ferry.show': 'Εμφάνιση διάβασης', + 'tours.walk.ferry.selectStop': 'Επιλογή στάσης {number}', + 'tours.walk.ferry.time': '{time} με σκάφος', + 'tours.walk.ferry.timeUnknown': 'Άγνωστος χρόνος σκάφους', + 'tours.walk.ferry.timeHint': 'Όπως τον δίνει η δρομολόγηση για τη διάβαση. Δεν είναι μέρος του χρόνου περπατήματος.', + 'tours.walk.ferry.field': 'Φέρι', + 'tours.walk.ferry.fieldInfoLabel': 'Σχετικά με τον χρόνο φέρι', + 'tours.walk.ferry.fieldInfo': + 'Ο χρόνος των διαβάσεων με σκάφος, όπως τον δίνει η δρομολόγηση. Μένει χωριστά από τον χρόνο περπατήματος και δεν μπαίνει στο προγραμματισμένο σύνολο.', + 'tours.walk.ferry.leg': 'Διάβαση με φέρι', + 'tours.walk.ferry.legInfo': 'Διασχίστηκε με σκάφος, όχι με τα πόδια. Ο χρόνος του δεν είναι στον χρόνο περπατήματος.', + 'tours.walk.ferry.release': 'Ξανά σχεδιασμός με τα πόδια', 'tours.walk.stationsUnread': 'Δεν φορτώθηκαν οι στάσεις αυτής της βόλτας. Άνοιξέ την ξανά με σύνδεση για να τις δεις και να τις τσεκάρεις.', 'tours.walk.progress': '{visited} από {total} επισκέψεις', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index f458f05aa..020bc5952 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -765,6 +765,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Számított', 'tours.walk.stations': 'A séta megállói', 'tours.walk.stationNumbered': '{number}. megálló', + 'tours.walk.ferry.gap': 'Nincs út {from} és {to} között, sem gyalog, sem komppal.', + 'tours.walk.ferry.gapHint': + 'A korábban tervezett útvonal szürkén a térképen marad, amíg nem talál utat. Helyezd át a két megálló egyikét.', + 'tours.walk.ferry.pending': 'Nincs gyalogos út {from} és {to} között: az útvonaltervező itt komppal kel át a vízen.', + 'tours.walk.ferry.pendingHint': + 'Az átkelés nem számít gyaloglási időnek. A mentéshez fogadd el a kompot, vagy helyezz át egy megállót, hogy gyalogos út kösse össze őket.', + 'tours.walk.ferry.accepted': + 'Komp {from} és {to} között, szándékosan elfogadva. Az ideje külön áll a gyaloglási időtől.', + 'tours.walk.ferry.accept': 'Komp elfogadása', + 'tours.walk.ferry.show': 'Átkelés mutatása', + 'tours.walk.ferry.selectStop': '{number}. megálló kiválasztása', + 'tours.walk.ferry.time': '{time} hajóval', + 'tours.walk.ferry.timeUnknown': 'A hajóidő ismeretlen', + 'tours.walk.ferry.timeHint': 'Ahogy az útvonaltervező megadja az átkeléshez. Nem része a gyaloglási időnek.', + 'tours.walk.ferry.field': 'Komp', + 'tours.walk.ferry.fieldInfoLabel': 'A kompidőről', + 'tours.walk.ferry.fieldInfo': + 'A hajós átkelések ideje, ahogy az útvonaltervező megadja. Külön áll a gyaloglási időtől, és nincs benne a tervezett összidőben.', + 'tours.walk.ferry.leg': 'Kompos átkelés', + 'tours.walk.ferry.legInfo': 'Hajóval átkelve, nem gyalog. Az ideje nincs a gyaloglási időben.', + 'tours.walk.ferry.release': 'Újratervezés gyalog', 'tours.walk.stationsUnread': 'A séta megállóit nem sikerült betölteni. Nyisd meg újra internetkapcsolattal, hogy lásd és kipipálhasd őket.', 'tours.walk.progress': '{total} megállóból {visited} meglátogatva', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index e699fd3ab..079c07af4 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -762,6 +762,29 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Dihitung', 'tours.walk.stations': 'Titik singgah rute', 'tours.walk.stationNumbered': 'Titik {number}', + 'tours.walk.ferry.gap': 'Tidak ada jalan yang menghubungkan {from} dan {to}, baik jalan kaki maupun feri.', + 'tours.walk.ferry.gapHint': + 'Rute yang direncanakan sebelumnya tetap di peta, dibuat pudar, sampai jalan ditemukan. Pindahkan salah satu dari dua perhentian.', + 'tours.walk.ferry.pending': + 'Tidak ada jalan kaki antara {from} dan {to}: di sini perute menyeberangi air dengan feri.', + 'tours.walk.ferry.pendingHint': + 'Penyeberangan tidak dihitung sebagai waktu jalan kaki. Untuk menyimpan, ambil ferinya atau pindahkan perhentian agar jalan kaki menghubungkannya.', + 'tours.walk.ferry.accepted': + 'Feri dari {from} ke {to}, diambil dengan sengaja. Waktunya dipisahkan dari waktu jalan kaki.', + 'tours.walk.ferry.accept': 'Ambil feri', + 'tours.walk.ferry.show': 'Tampilkan penyeberangan', + 'tours.walk.ferry.selectStop': 'Pilih perhentian {number}', + 'tours.walk.ferry.time': '{time} dengan perahu', + 'tours.walk.ferry.timeUnknown': 'Waktu perahu tidak diketahui', + 'tours.walk.ferry.timeHint': + 'Seperti yang diberikan perute untuk penyeberangan. Ini bukan bagian dari waktu jalan kaki.', + 'tours.walk.ferry.field': 'Feri', + 'tours.walk.ferry.fieldInfoLabel': 'Tentang waktu feri', + 'tours.walk.ferry.fieldInfo': + 'Waktu penyeberangan dengan perahu, seperti yang diberikan perute. Dipisahkan dari waktu jalan kaki dan tidak termasuk total yang direncanakan.', + 'tours.walk.ferry.leg': 'Penyeberangan feri', + 'tours.walk.ferry.legInfo': 'Diseberangi dengan perahu, bukan berjalan. Waktunya tidak termasuk waktu jalan kaki.', + 'tours.walk.ferry.release': 'Rencanakan jalan kaki lagi', 'tours.walk.stationsUnread': 'Titik singgah rute ini tidak dapat dimuat. Buka lagi saat online untuk melihat dan mencentangnya.', 'tours.walk.progress': '{visited} dari {total} dikunjungi', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 4667a122e..9df435fc4 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -769,6 +769,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Calcolato', 'tours.walk.stations': 'Tappe del percorso', 'tours.walk.stationNumbered': 'Tappa {number}', + 'tours.walk.ferry.gap': 'Nessun percorso unisce {from} e {to}, né a piedi né in traghetto.', + 'tours.walk.ferry.gapHint': + 'Il percorso pianificato prima resta sulla mappa, in grigio, finché non si trova una via. Sposta una delle due tappe.', + 'tours.walk.ferry.pending': + "Non c'è un percorso a piedi tra {from} e {to}: qui il calcolatore attraversa l'acqua in traghetto.", + 'tours.walk.ferry.pendingHint': + 'La traversata non conta come tempo a piedi. Per salvare, accetta il traghetto o sposta una tappa così che un percorso a piedi le unisca.', + 'tours.walk.ferry.accepted': + 'Traghetto da {from} a {to}, accettato di proposito. Il suo tempo resta separato dal tempo a piedi.', + 'tours.walk.ferry.accept': 'Accetta traghetto', + 'tours.walk.ferry.show': 'Mostra traversata', + 'tours.walk.ferry.selectStop': 'Scegli la tappa {number}', + 'tours.walk.ferry.time': '{time} in barca', + 'tours.walk.ferry.timeUnknown': 'Tempo in barca sconosciuto', + 'tours.walk.ferry.timeHint': 'Come lo indica il calcolatore per la traversata. Non fa parte del tempo a piedi.', + 'tours.walk.ferry.field': 'Traghetto', + 'tours.walk.ferry.fieldInfoLabel': 'Sul tempo in traghetto', + 'tours.walk.ferry.fieldInfo': + 'Il tempo delle traversate in barca, come lo indica il calcolatore. Resta separato dal tempo a piedi e non è nel totale pianificato.', + 'tours.walk.ferry.leg': 'Traversata in traghetto', + 'tours.walk.ferry.legInfo': 'Attraversato in barca, non a piedi. Il suo tempo non è nel tempo a piedi.', + 'tours.walk.ferry.release': 'Ripianifica a piedi', 'tours.walk.stationsUnread': 'Non è stato possibile caricare le tappe di questo percorso. Riaprilo con una connessione per vederle e spuntarle.', 'tours.walk.progress': '{visited} su {total} visitate', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 2b6353c5c..03b927af2 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -742,6 +742,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '計算値', 'tours.walk.stations': '立ち寄り先', 'tours.walk.stationNumbered': '立ち寄り先 {number}', + 'tours.walk.ferry.gap': '{from} と {to} を結ぶ道は、徒歩でもフェリーでもありません。', + 'tours.walk.ferry.gapHint': + '以前に計画したルートは、道が見つかるまで地図上に薄く残ります。2つの立ち寄り先のどちらかを動かしてください。', + 'tours.walk.ferry.pending': '{from} と {to} の間に徒歩の道はありません。ここではルート検索がフェリーで水を渡ります。', + 'tours.walk.ferry.pendingHint': + '渡航は歩行時間に数えません。保存するには、フェリーを採用するか、徒歩の道でつながるよう立ち寄り先を動かしてください。', + 'tours.walk.ferry.accepted': '{from} から {to} へのフェリー、意図して採用済み。その時間は歩行時間とは別です。', + 'tours.walk.ferry.accept': 'フェリーを採用', + 'tours.walk.ferry.show': '渡航を表示', + 'tours.walk.ferry.selectStop': '立ち寄り先 {number} を選択', + 'tours.walk.ferry.time': '船で {time}', + 'tours.walk.ferry.timeUnknown': '船の時間は不明', + 'tours.walk.ferry.timeHint': 'ルート検索が渡航について示す時間です。歩行時間には含まれません。', + 'tours.walk.ferry.field': 'フェリー', + 'tours.walk.ferry.fieldInfoLabel': 'フェリーの時間について', + 'tours.walk.ferry.fieldInfo': + '船での渡航にかかる時間で、ルート検索が示すものです。歩行時間とは別で、予定合計にも含まれません。', + 'tours.walk.ferry.leg': 'フェリーで渡航', + 'tours.walk.ferry.legInfo': '歩かずに船で渡ります。その時間は歩行時間に含まれません。', + 'tours.walk.ferry.release': 'もう一度徒歩で計画', 'tours.walk.stationsUnread': 'この街歩きの立ち寄り先を読み込めませんでした。オンラインの状態で開き直すと、表示してチェックできます。', 'tours.walk.progress': '{total} 件中 {visited} 件訪問済み', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 1ed9c4b67..1e5f5f4ca 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -736,6 +736,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '계산값', 'tours.walk.stations': '산책 정류지', 'tours.walk.stationNumbered': '정류지 {number}', + 'tours.walk.ferry.gap': '{from}와 {to}를 잇는 길이 도보로도 페리로도 없습니다.', + 'tours.walk.ferry.gapHint': + '이전에 계획한 경로는 길을 찾을 때까지 지도에 흐리게 남습니다. 두 정류지 중 하나를 옮기세요.', + 'tours.walk.ferry.pending': '{from}와 {to} 사이에 도보 길이 없습니다: 여기서는 경로 탐색이 페리로 물을 건넙니다.', + 'tours.walk.ferry.pendingHint': + '횡단은 도보 시간에 포함되지 않습니다. 저장하려면 페리를 채택하거나 도보 길이 이어지도록 정류지를 옮기세요.', + 'tours.walk.ferry.accepted': '{from}에서 {to}까지의 페리, 의도적으로 채택됨. 그 시간은 도보 시간과 따로 둡니다.', + 'tours.walk.ferry.accept': '페리 채택', + 'tours.walk.ferry.show': '횡단 보기', + 'tours.walk.ferry.selectStop': '정류지 {number} 선택', + 'tours.walk.ferry.time': '배로 {time}', + 'tours.walk.ferry.timeUnknown': '배 시간 알 수 없음', + 'tours.walk.ferry.timeHint': '경로 탐색이 횡단에 대해 알려 준 시간입니다. 도보 시간에 포함되지 않습니다.', + 'tours.walk.ferry.field': '페리', + 'tours.walk.ferry.fieldInfoLabel': '페리 시간 안내', + 'tours.walk.ferry.fieldInfo': + '배로 건너는 데 걸리는 시간으로, 경로 탐색이 알려 준 값입니다. 도보 시간과 따로 두며 계획 총계에도 들어가지 않습니다.', + 'tours.walk.ferry.leg': '페리 횡단', + 'tours.walk.ferry.legInfo': '걷지 않고 배로 건넙니다. 그 시간은 도보 시간에 없습니다.', + 'tours.walk.ferry.release': '다시 도보로 계획', 'tours.walk.stationsUnread': '이 산책의 정류지를 불러오지 못했습니다. 온라인 상태에서 다시 열면 정류지를 보고 체크할 수 있습니다.', 'tours.walk.progress': '{total}곳 중 {visited}곳 방문', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 09c6f8d77..5b3b9886b 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -767,6 +767,28 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Berekend', 'tours.walk.stations': 'Haltes van de wandeling', 'tours.walk.stationNumbered': 'Halte {number}', + 'tours.walk.ferry.gap': 'Geen weg verbindt {from} en {to}, niet te voet en niet met een veerpont.', + 'tours.walk.ferry.gapHint': + 'De eerder geplande route blijft grijs op de kaart staan tot er een weg is gevonden. Verplaats een van de twee haltes.', + 'tours.walk.ferry.pending': + 'Er is geen looproute tussen {from} en {to}: de routeplanner steekt het water hier over met een veerpont.', + 'tours.walk.ferry.pendingHint': + 'De overtocht telt niet als looptijd. Neem de veerpont over of verplaats een halte zodat een looproute ze verbindt om op te slaan.', + 'tours.walk.ferry.accepted': + 'Veerpont van {from} naar {to}, bewust overgenomen. De tijd ervan staat los van de looptijd.', + 'tours.walk.ferry.accept': 'Veerpont overnemen', + 'tours.walk.ferry.show': 'Overtocht tonen', + 'tours.walk.ferry.selectStop': 'Halte {number} kiezen', + 'tours.walk.ferry.time': '{time} per boot', + 'tours.walk.ferry.timeUnknown': 'Boottijd onbekend', + 'tours.walk.ferry.timeHint': 'Zoals de routeplanner die voor de overtocht geeft. Hoort niet bij de looptijd.', + 'tours.walk.ferry.field': 'Veerpont', + 'tours.walk.ferry.fieldInfoLabel': 'Over de veerponttijd', + 'tours.walk.ferry.fieldInfo': + 'De tijd van de overtochten per boot, zoals de routeplanner die geeft. Staat los van de looptijd en zit niet in het geplande totaal.', + 'tours.walk.ferry.leg': 'Overtocht met veerpont', + 'tours.walk.ferry.legInfo': 'Per boot overgestoken, niet gelopen. De tijd zit niet in de looptijd.', + 'tours.walk.ferry.release': 'Opnieuw te voet plannen', 'tours.walk.stationsUnread': 'De haltes van deze wandeling konden niet worden geladen. Open hem opnieuw met verbinding om ze te zien en af te vinken.', 'tours.walk.progress': '{visited} van {total} bezocht', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 8f8767572..cb9836f5a 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -772,6 +772,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Obliczono', 'tours.walk.stations': 'Przystanki trasy', 'tours.walk.stationNumbered': 'Przystanek {number}', + 'tours.walk.ferry.gap': 'Żadna droga nie łączy {from} i {to}, ani pieszo, ani promem.', + 'tours.walk.ferry.gapHint': + 'Wcześniej zaplanowana trasa zostaje na mapie, wyszarzona, dopóki nie znajdzie się droga. Przesuń jeden z dwóch przystanków.', + 'tours.walk.ferry.pending': + 'Między {from} a {to} nie ma drogi pieszej: wyznaczanie trasy przeprawia się tu promem przez wodę.', + 'tours.walk.ferry.pendingHint': + 'Przeprawa nie liczy się jako czas marszu. Aby zapisać, przyjmij prom albo przesuń przystanek, żeby łączyła je droga piesza.', + 'tours.walk.ferry.accepted': 'Prom z {from} do {to}, przyjęty celowo. Jego czas jest oddzielony od czasu marszu.', + 'tours.walk.ferry.accept': 'Przyjmij prom', + 'tours.walk.ferry.show': 'Pokaż przeprawę', + 'tours.walk.ferry.selectStop': 'Wybierz przystanek {number}', + 'tours.walk.ferry.time': '{time} łodzią', + 'tours.walk.ferry.timeUnknown': 'Czas łodzią nieznany', + 'tours.walk.ferry.timeHint': 'Tak, jak podaje go wyznaczanie trasy dla przeprawy. Nie jest częścią czasu marszu.', + 'tours.walk.ferry.field': 'Prom', + 'tours.walk.ferry.fieldInfoLabel': 'O czasie promu', + 'tours.walk.ferry.fieldInfo': + 'Czas przepraw łodzią, tak jak podaje go wyznaczanie trasy. Jest oddzielony od czasu marszu i nie wchodzi do planowanej sumy.', + 'tours.walk.ferry.leg': 'Przeprawa promem', + 'tours.walk.ferry.legInfo': 'Przepłynięte łodzią, nie przebyte pieszo. Jego czas nie jest w czasie marszu.', + 'tours.walk.ferry.release': 'Zaplanuj znów pieszo', 'tours.walk.stationsUnread': 'Nie udało się wczytać przystanków tej trasy. Otwórz ją ponownie z połączeniem, aby je zobaczyć i odhaczyć.', 'tours.walk.progress': 'Odwiedzono {visited} z {total}', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 335cb553c..ed9237b97 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -774,6 +774,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Расчёт', 'tours.walk.stations': 'Остановки прогулки', 'tours.walk.stationNumbered': 'Остановка {number}', + 'tours.walk.ferry.gap': 'Ни пешком, ни на пароме нет пути между {from} и {to}.', + 'tours.walk.ferry.gapHint': + 'Ранее построенный маршрут остаётся на карте серым, пока путь не найдётся. Передвиньте одну из двух остановок.', + 'tours.walk.ferry.pending': 'Между {from} и {to} нет пешего пути: здесь маршрутизатор пересекает воду на пароме.', + 'tours.walk.ferry.pendingHint': + 'Переправа не считается временем ходьбы. Чтобы сохранить, примите паром или передвиньте остановку, чтобы их соединял пеший путь.', + 'tours.walk.ferry.accepted': + 'Паром от {from} до {to}, принят намеренно. Его время учитывается отдельно от времени ходьбы.', + 'tours.walk.ferry.accept': 'Принять паром', + 'tours.walk.ferry.show': 'Показать переправу', + 'tours.walk.ferry.selectStop': 'Выбрать остановку {number}', + 'tours.walk.ferry.time': '{time} на лодке', + 'tours.walk.ferry.timeUnknown': 'Время на лодке неизвестно', + 'tours.walk.ferry.timeHint': 'Как его даёт маршрутизатор для переправы. Не входит во время ходьбы.', + 'tours.walk.ferry.field': 'Паром', + 'tours.walk.ferry.fieldInfoLabel': 'О времени на пароме', + 'tours.walk.ferry.fieldInfo': + 'Время переправ на лодке, как его даёт маршрутизатор. Учитывается отдельно от времени ходьбы и не входит в плановый итог.', + 'tours.walk.ferry.leg': 'Переправа на пароме', + 'tours.walk.ferry.legInfo': 'Пересечено на лодке, не пешком. Его время не входит во время ходьбы.', + 'tours.walk.ferry.release': 'Снова спланировать пешком', 'tours.walk.stationsUnread': 'Не удалось загрузить остановки этой прогулки. Открой её снова при подключении к сети, чтобы увидеть и отметить их.', 'tours.walk.progress': 'Посещено {visited} из {total}', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 919278531..9becc8091 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -753,6 +753,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Vypočítané', 'tours.walk.stations': 'Zastávky prechádzky', 'tours.walk.stationNumbered': 'Zastávka {number}', + 'tours.walk.ferry.gap': 'Medzi {from} a {to} nevedie žiadna cesta, pešo ani trajektom.', + 'tours.walk.ferry.gapHint': + 'Predtým naplánovaná trasa zostane na mape sivá, kým sa nenájde cesta. Presuň jednu z oboch zastávok.', + 'tours.walk.ferry.pending': 'Medzi {from} a {to} nevedie žiadna pešia cesta: plánovač tu prechádza vodu trajektom.', + 'tours.walk.ferry.pendingHint': + 'Prevoz sa nepočíta do času chôdze. Na uloženie prevezmi trajekt alebo presuň zastávku, aby ich spojila pešia cesta.', + 'tours.walk.ferry.accepted': 'Trajekt z {from} do {to}, prevzatý zámerne. Jeho čas je oddelený od času chôdze.', + 'tours.walk.ferry.accept': 'Prevziať trajekt', + 'tours.walk.ferry.show': 'Ukázať prevoz', + 'tours.walk.ferry.selectStop': 'Vybrať zastávku {number}', + 'tours.walk.ferry.time': '{time} loďou', + 'tours.walk.ferry.timeUnknown': 'Čas loďou neznámy', + 'tours.walk.ferry.timeHint': 'Ako ho plánovač uvádza pre prevoz. Nie je súčasťou času chôdze.', + 'tours.walk.ferry.field': 'Trajekt', + 'tours.walk.ferry.fieldInfoLabel': 'O čase trajektu', + 'tours.walk.ferry.fieldInfo': + 'Čas prevozov loďou, ako ho uvádza plánovač. Je oddelený od času chôdze a nie je v plánovanom súčte.', + 'tours.walk.ferry.leg': 'Prevoz trajektom', + 'tours.walk.ferry.legInfo': 'Preplávané loďou, nie prejdené. Jeho čas nie je v čase chôdze.', + 'tours.walk.ferry.release': 'Znova naplánovať pešo', 'tours.walk.stationsUnread': 'Zastávky tejto prechádzky sa nepodarilo načítať. Otvor ju znova s pripojením, potom ich uvidíš a pôjde ich odškrtnúť.', 'tours.walk.progress': 'Navštívené {visited} z {total}', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index a7406c36f..a15544900 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -750,6 +750,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Beräknad', 'tours.walk.stations': 'Promenadens stopp', 'tours.walk.stationNumbered': 'Stopp {number}', + 'tours.walk.ferry.gap': 'Ingen väg förbinder {from} och {to}, varken till fots eller med färja.', + 'tours.walk.ferry.gapHint': + 'Den tidigare planerade rutten ligger kvar gråtonad på kartan tills en väg hittas. Flytta ett av de två stoppen.', + 'tours.walk.ferry.pending': + 'Det finns ingen gångväg mellan {from} och {to}: här korsar ruttplaneraren vattnet med färja.', + 'tours.walk.ferry.pendingHint': + 'Överfarten räknas inte som gångtid. För att spara, ta färjan eller flytta ett stopp så att en gångväg förbinder dem.', + 'tours.walk.ferry.accepted': 'Färja från {from} till {to}, tagen med avsikt. Dess tid hålls skild från gångtiden.', + 'tours.walk.ferry.accept': 'Ta färjan', + 'tours.walk.ferry.show': 'Visa överfarten', + 'tours.walk.ferry.selectStop': 'Välj stopp {number}', + 'tours.walk.ferry.time': '{time} med båt', + 'tours.walk.ferry.timeUnknown': 'Båttid okänd', + 'tours.walk.ferry.timeHint': 'Som ruttplaneraren anger den för överfarten. Den ingår inte i gångtiden.', + 'tours.walk.ferry.field': 'Färja', + 'tours.walk.ferry.fieldInfoLabel': 'Om färjetiden', + 'tours.walk.ferry.fieldInfo': + 'Tiden som överfarterna med båt tar, som ruttplaneraren anger den. Den hålls skild från gångtiden och ingår inte i den planerade summan.', + 'tours.walk.ferry.leg': 'Överfart med färja', + 'tours.walk.ferry.legInfo': 'Korsat med båt, inte gånget. Tiden ingår inte i gångtiden.', + 'tours.walk.ferry.release': 'Planera till fots igen', 'tours.walk.stationsUnread': 'Promenadens stopp kunde inte läsas in. Öppna den igen med uppkoppling för att se dem och bocka av dem.', 'tours.walk.progress': '{visited} av {total} besökta', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index dc464e639..76b013684 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -724,6 +724,26 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'ค่าคำนวณ', 'tours.walk.stations': 'จุดแวะของทริป', 'tours.walk.stationNumbered': 'จุดแวะที่ {number}', + 'tours.walk.ferry.gap': 'ไม่มีทางเชื่อม {from} กับ {to} ทั้งเดินเท้าและเรือข้ามฟาก', + 'tours.walk.ferry.gapHint': + 'เส้นทางที่วางไว้ก่อนหน้ายังอยู่บนแผนที่แบบจาง จนกว่าจะพบทาง ลองย้ายจุดแวะจุดใดจุดหนึ่งในสองจุดนี้', + 'tours.walk.ferry.pending': 'ไม่มีทางเดินเท้าระหว่าง {from} กับ {to}: ตรงนี้ตัวหาเส้นทางข้ามน้ำด้วยเรือข้ามฟาก', + 'tours.walk.ferry.pendingHint': + 'การข้ามฟากไม่นับเป็นเวลาเดิน หากจะบันทึก ให้รับเรือข้ามฟากนี้ หรือย้ายจุดแวะให้มีทางเดินเท้าเชื่อมกัน', + 'tours.walk.ferry.accepted': 'เรือข้ามฟากจาก {from} ไป {to} รับไว้โดยตั้งใจ เวลาของเรือแยกจากเวลาเดิน', + 'tours.walk.ferry.accept': 'รับเรือข้ามฟาก', + 'tours.walk.ferry.show': 'แสดงการข้ามฟาก', + 'tours.walk.ferry.selectStop': 'เลือกจุดแวะ {number}', + 'tours.walk.ferry.time': 'ทางเรือ {time}', + 'tours.walk.ferry.timeUnknown': 'ไม่ทราบเวลาทางเรือ', + 'tours.walk.ferry.timeHint': 'ตามที่ตัวหาเส้นทางให้ไว้สำหรับการข้ามฟาก ไม่ได้เป็นส่วนหนึ่งของเวลาเดิน', + 'tours.walk.ferry.field': 'เรือข้ามฟาก', + 'tours.walk.ferry.fieldInfoLabel': 'เกี่ยวกับเวลาเรือข้ามฟาก', + 'tours.walk.ferry.fieldInfo': + 'เวลาที่ใช้ข้ามฟากทางเรือตามที่ตัวหาเส้นทางให้ไว้ แยกจากเวลาเดินและไม่รวมในเวลารวมที่วางแผน', + 'tours.walk.ferry.leg': 'ข้ามฟากด้วยเรือ', + 'tours.walk.ferry.legInfo': 'ข้ามด้วยเรือ ไม่ได้เดิน เวลาของมันไม่รวมในเวลาเดิน', + 'tours.walk.ferry.release': 'วางแผนเดินเท้าอีกครั้ง', 'tours.walk.stationsUnread': 'โหลดจุดแวะของทริปนี้ไม่ได้ เปิดทริปอีกครั้งขณะออนไลน์เพื่อดูและติ๊กจุดแวะ', 'tours.walk.progress': 'แวะแล้ว {visited} จาก {total}', 'tours.walk.remaining.pill': 'เหลือเดินอีก {time}', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 209991081..d765adc34 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [ { file: 'tours', - generalKeyCount: 1318, + generalKeyCount: 1335, modules: toursModules, }, { diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 8404ac204..b5ffc636d 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -754,6 +754,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Hesaplanan', 'tours.walk.stations': 'Yürüyüşün durakları', 'tours.walk.stationNumbered': 'Durak {number}', + 'tours.walk.ferry.gap': '{from} ile {to} arasında ne yürüyerek ne de feribotla bir yol var.', + 'tours.walk.ferry.gapHint': + 'Daha önce planlanan rota, bir yol bulunana kadar haritada soluk kalır. İki duraktan birini taşı.', + 'tours.walk.ferry.pending': '{from} ile {to} arasında yürüyüş yolu yok: yönlendirici burada suyu feribotla geçiyor.', + 'tours.walk.ferry.pendingHint': + 'Geçiş yürüyüş süresi sayılmaz. Kaydetmek için feribotu kabul et ya da bir durağı taşı, böylece bir yürüyüş yolu onları bağlasın.', + 'tours.walk.ferry.accepted': + '{from} noktasından {to} noktasına feribot, bilerek kabul edildi. Süresi yürüyüş süresinden ayrı tutulur.', + 'tours.walk.ferry.accept': 'Feribotu kabul et', + 'tours.walk.ferry.show': 'Geçişi göster', + 'tours.walk.ferry.selectStop': '{number}. durağı seç', + 'tours.walk.ferry.time': 'Tekneyle {time}', + 'tours.walk.ferry.timeUnknown': 'Tekne süresi bilinmiyor', + 'tours.walk.ferry.timeHint': 'Yönlendiricinin geçiş için verdiği gibi. Yürüyüş süresinin parçası değildir.', + 'tours.walk.ferry.field': 'Feribot', + 'tours.walk.ferry.fieldInfoLabel': 'Feribot süresi hakkında', + 'tours.walk.ferry.fieldInfo': + 'Tekneyle geçişlerin süresi, yönlendiricinin verdiği gibi. Yürüyüş süresinden ayrı tutulur ve planlanan toplamda yer almaz.', + 'tours.walk.ferry.leg': 'Feribotla geçiş', + 'tours.walk.ferry.legInfo': 'Yürünmedi, tekneyle geçildi. Süresi yürüyüş süresinde yok.', + 'tours.walk.ferry.release': 'Yeniden yürüyerek planla', 'tours.walk.stationsUnread': 'Bu yürüyüşün durakları yüklenemedi. Görmek ve işaretlemek için yürüyüşü bağlantı varken yeniden aç.', 'tours.walk.progress': '{total} duraktan {visited} ziyaret edildi', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 88c764531..fbfed9716 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -768,6 +768,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Розрахунок', 'tours.walk.stations': 'Зупинки прогулянки', 'tours.walk.stationNumbered': 'Зупинка {number}', + 'tours.walk.ferry.gap': 'Ні пішки, ні поромом немає шляху між {from} і {to}.', + 'tours.walk.ferry.gapHint': + 'Раніше побудований маршрут лишається на мапі сірим, доки шлях не знайдеться. Пересуньте одну з двох зупинок.', + 'tours.walk.ferry.pending': 'Між {from} і {to} немає пішого шляху: тут маршрутизатор перетинає воду поромом.', + 'tours.walk.ferry.pendingHint': + 'Переправа не рахується як час ходьби. Щоб зберегти, прийміть пором або пересуньте зупинку, щоб їх з’єднував піший шлях.', + 'tours.walk.ferry.accepted': + 'Пором від {from} до {to}, прийнятий навмисно. Його час рахується окремо від часу ходьби.', + 'tours.walk.ferry.accept': 'Прийняти пором', + 'tours.walk.ferry.show': 'Показати переправу', + 'tours.walk.ferry.selectStop': 'Вибрати зупинку {number}', + 'tours.walk.ferry.time': '{time} човном', + 'tours.walk.ferry.timeUnknown': 'Час човном невідомий', + 'tours.walk.ferry.timeHint': 'Як його дає маршрутизатор для переправи. Не входить у час ходьби.', + 'tours.walk.ferry.field': 'Пором', + 'tours.walk.ferry.fieldInfoLabel': 'Про час на поромі', + 'tours.walk.ferry.fieldInfo': + 'Час переправ човном, як його дає маршрутизатор. Рахується окремо від часу ходьби і не входить до планового підсумку.', + 'tours.walk.ferry.leg': 'Переправа поромом', + 'tours.walk.ferry.legInfo': 'Перетнуто човном, не пішки. Його час не входить у час ходьби.', + 'tours.walk.ferry.release': 'Знову спланувати пішки', 'tours.walk.stationsUnread': 'Не вдалося завантажити зупинки цієї прогулянки. Відкрий її знову з мережею, щоб побачити й відмітити їх.', 'tours.walk.progress': 'Відвідано {visited} з {total}', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 227750134..b40f7deb1 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -752,6 +752,27 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': 'Đã tính', 'tours.walk.stations': 'Các điểm dừng', 'tours.walk.stationNumbered': 'Điểm dừng {number}', + 'tours.walk.ferry.gap': 'Không có đường nào nối {from} và {to}, cả đi bộ lẫn phà.', + 'tours.walk.ferry.gapHint': + 'Lộ trình đã lập trước đó vẫn ở trên bản đồ, được làm mờ, cho đến khi tìm được đường. Hãy dời một trong hai điểm dừng.', + 'tours.walk.ferry.pending': 'Không có đường đi bộ giữa {from} và {to}: ở đây bộ tìm đường qua nước bằng phà.', + 'tours.walk.ferry.pendingHint': + 'Chuyến qua sông không tính là thời gian đi bộ. Để lưu, hãy nhận chuyến phà hoặc dời một điểm dừng để có đường đi bộ nối chúng.', + 'tours.walk.ferry.accepted': + 'Phà từ {from} đến {to}, đã nhận có chủ ý. Thời gian của nó được tách khỏi thời gian đi bộ.', + 'tours.walk.ferry.accept': 'Nhận phà', + 'tours.walk.ferry.show': 'Hiện chuyến qua', + 'tours.walk.ferry.selectStop': 'Chọn điểm dừng {number}', + 'tours.walk.ferry.time': '{time} bằng thuyền', + 'tours.walk.ferry.timeUnknown': 'Không rõ thời gian thuyền', + 'tours.walk.ferry.timeHint': 'Như bộ tìm đường đưa ra cho chuyến qua. Không thuộc thời gian đi bộ.', + 'tours.walk.ferry.field': 'Phà', + 'tours.walk.ferry.fieldInfoLabel': 'Về thời gian đi phà', + 'tours.walk.ferry.fieldInfo': + 'Thời gian các chuyến qua bằng thuyền, như bộ tìm đường đưa ra. Được tách khỏi thời gian đi bộ và không nằm trong tổng đã lập.', + 'tours.walk.ferry.leg': 'Qua bằng phà', + 'tours.walk.ferry.legInfo': 'Qua bằng thuyền, không đi bộ. Thời gian không nằm trong thời gian đi bộ.', + 'tours.walk.ferry.release': 'Lập lại theo đi bộ', 'tours.walk.stationsUnread': 'Không tải được các điểm dừng của lộ trình này. Hãy mở lại khi có kết nối để xem và đánh dấu chúng.', 'tours.walk.progress': 'Đã ghé {visited}/{total}', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 638928548..e99c13f7e 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -700,6 +700,23 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '計算值', 'tours.walk.stations': '漫步站點', 'tours.walk.stationNumbered': '第 {number} 站', + 'tours.walk.ferry.gap': '{from} 與 {to} 之間沒有任何路可通,步行和渡輪都不行。', + 'tours.walk.ferry.gapHint': '之前規劃的路線會以灰色留在地圖上,直到找到路為止。請移動這兩個站點之一。', + 'tours.walk.ferry.pending': '{from} 與 {to} 之間沒有步行道路:路線規劃在這裡搭渡輪過水。', + 'tours.walk.ferry.pendingHint': '渡船不計入步行時間。要儲存,請採用渡輪,或移動一個站點,讓步行道路連通它們。', + 'tours.walk.ferry.accepted': '從 {from} 到 {to} 的渡輪,已有意採用。其時間與步行時間分開。', + 'tours.walk.ferry.accept': '採用渡輪', + 'tours.walk.ferry.show': '顯示渡船', + 'tours.walk.ferry.selectStop': '選擇站點 {number}', + 'tours.walk.ferry.time': '搭船 {time}', + 'tours.walk.ferry.timeUnknown': '搭船時間未知', + 'tours.walk.ferry.timeHint': '為路線規劃給出的渡船時間。不屬於步行時間。', + 'tours.walk.ferry.field': '渡輪', + 'tours.walk.ferry.fieldInfoLabel': '關於渡輪時間', + 'tours.walk.ferry.fieldInfo': '搭船過水所需的時間,由路線規劃給出。與步行時間分開,也不計入計畫總時長。', + 'tours.walk.ferry.leg': '渡輪過水', + 'tours.walk.ferry.legInfo': '搭船而過,並非步行。其時間不計入步行時間。', + 'tours.walk.ferry.release': '重新按步行規劃', 'tours.walk.stationsUnread': '無法載入這次漫步的站點。請在連網時重新開啟,即可查看並勾選。', 'tours.walk.progress': '已到訪 {visited}/{total}', 'tours.walk.remaining.pill': '步行還剩 {time}', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 1f0346242..1132a5aa6 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -701,6 +701,23 @@ const tours: TranslationStrings = { 'tours.walk.totalComputed': '计算值', 'tours.walk.stations': '漫步站点', 'tours.walk.stationNumbered': '第 {number} 站', + 'tours.walk.ferry.gap': '{from} 与 {to} 之间没有任何路可通,步行和渡轮都不行。', + 'tours.walk.ferry.gapHint': '之前规划的路线会以灰色留在地图上,直到找到路为止。请移动这两个站点之一。', + 'tours.walk.ferry.pending': '{from} 与 {to} 之间没有步行道路:路线规划在这里乘渡轮过水。', + 'tours.walk.ferry.pendingHint': '渡船不计入步行时间。要保存,请采用渡轮,或移动一个站点,让步行道路连通它们。', + 'tours.walk.ferry.accepted': '从 {from} 到 {to} 的渡轮,已有意采用。其时间与步行时间分开。', + 'tours.walk.ferry.accept': '采用渡轮', + 'tours.walk.ferry.show': '显示渡船', + 'tours.walk.ferry.selectStop': '选择站点 {number}', + 'tours.walk.ferry.time': '乘船 {time}', + 'tours.walk.ferry.timeUnknown': '乘船时间未知', + 'tours.walk.ferry.timeHint': '为路线规划给出的渡船时间。不属于步行时间。', + 'tours.walk.ferry.field': '渡轮', + 'tours.walk.ferry.fieldInfoLabel': '关于渡轮时间', + 'tours.walk.ferry.fieldInfo': '乘船过水所需的时间,由路线规划给出。与步行时间分开,也不计入计划总时长。', + 'tours.walk.ferry.leg': '渡轮过水', + 'tours.walk.ferry.legInfo': '乘船而过,并非步行。其时间不计入步行时间。', + 'tours.walk.ferry.release': '重新按步行规划', 'tours.walk.stationsUnread': '无法加载这次漫步的站点。请在联网时重新打开,即可查看并勾选。', 'tours.walk.progress': '已到访 {visited}/{total}', 'tours.walk.remaining.pill': '步行还剩 {time}', diff --git a/shared/src/index.ts b/shared/src/index.ts index 54c28de61..274a76553 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -78,6 +78,7 @@ export * from './tours/tour-walk-briefing.schema'; export * from './tours/tour-change.schema'; export * from './tours/walk-hours'; export * from './tours/walk-remaining'; +export * from './tours/walk-ferry'; export * from './tours/tours.metrics'; export * from './tours/tour-gpx-import.schema'; export * from './tours/paddle-plan.schema'; diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts index 28c56c32a..6b04acb2a 100644 --- a/shared/src/tours/tours.schema.test.ts +++ b/shared/src/tours/tours.schema.test.ts @@ -202,6 +202,47 @@ describe('Tours fixed legs', () => { }); }); +describe('Tours ferry legs (CW-12)', () => { + const ferry = (crossing: [number, number], minutes: number | null = 7) => ({ + kind: 'ferry' as const, + geometry: [ + [48, 11], + [48.004, 11.008], + [48.006, 11.012], + [48.01, 11.02], + ] as [number, number][], + crossing, + minutes, + walk_minutes: 2, + }); + const withLeg = (leg: ReturnType, type: 'walk' | 'hike') => ({ + ...baseRequest, + tour_type: type, + ...(type === 'walk' ? { max_hiking_difficulty: null } : {}), + waypoints: [ + { lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: leg }, + { lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 }, + ], + }); + + it('takes a crossing by boat on a city walk, with where on its leg the boat runs and its minutes apart', () => { + expect(tourCreateRequestSchema.parse(withLeg(ferry([1, 2]), 'walk')).waypoints[0]?.fixed_leg).toEqual( + ferry([1, 2]), + ); + expect(tourCreateRequestSchema.parse(withLeg(ferry([1, 2], null), 'walk')).waypoints[0]?.fixed_leg).toMatchObject({ + minutes: null, + }); + }); + + it('refuses a crossing on any other type, or one that does not run forwards between two points of its leg', () => { + expect(() => tourCreateRequestSchema.parse(withLeg(ferry([1, 2]), 'hike'))).toThrow( + /ferry leg belongs to a city walk/, + ); + expect(() => tourCreateRequestSchema.parse(withLeg(ferry([2, 1]), 'walk'))).toThrow(/crossing runs between/); + expect(() => tourCreateRequestSchema.parse(withLeg(ferry([1, 4]), 'walk'))).toThrow(/crossing runs between/); + }); +}); + describe('Tours free legs', () => { const free = (from: [number, number], to: [number, number]) => ({ kind: 'free' as const, geometry: [from, to] }); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index d583a65a5..f4ebc5d5c 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -366,6 +366,9 @@ export const TOUR_FIXED_LEG_END_TOLERANCE_M = 60; */ export const TOUR_WRITE_MAX_BYTES = 2 * 1024 * 1024; +/** The longest a ferry leg's crossing or its walk may take, a whole day. */ +const FERRY_LEG_MAX_MINUTES = 24 * 60; + const fixedLegGeometrySchema = z .array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)])) .min(2) @@ -383,6 +386,9 @@ const fixedLegGeometrySchema = z * section (HOR-01) is a stretch of a signposted horse route from * OpenStreetMap, taken in the direction the rider chose, with the `horse=*` * tags its ways carried then; being on the route is no permission to ride it. + * A ferry leg (CW-12) is a city walk's crossing by boat the travellers took on + * purpose: the router's way from the station to the next, with where on it the + * boat runs, so the boat's time never counts as walking. */ export const tourFixedLegSchema = z.discriminatedUnion('kind', [ z.object({ @@ -416,6 +422,16 @@ export const tourFixedLegSchema = z.discriminatedUnion('kind', [ /** The `horse=*` tags along the section when it was taken, in metres per value. */ access: horseAccessSharesSchema.optional(), }), + z.object({ + kind: z.literal('ferry'), + geometry: fixedLegGeometrySchema, + /** The points of the line the boat leaves from and lands at, by their index. */ + crossing: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]), + /** The crossing's time as the router gave it; null when it gave none. */ + minutes: z.number().int().min(0).max(FERRY_LEG_MAX_MINUTES).nullable(), + /** The walk to the boat and on from it along this leg, by the router. */ + walk_minutes: z.number().int().min(0).max(FERRY_LEG_MAX_MINUTES), + }), ]); export type TourFixedLeg = z.infer; export type TourFixedLegKind = TourFixedLeg['kind']; @@ -589,6 +605,13 @@ export const tourCreateRequestSchema = z message: `Waypoint ${index}'s fixed leg must run from it to the next waypoint`, }); } + if (leg.kind === 'ferry' && !(leg.crossing[0] < leg.crossing[1] && leg.crossing[1] < leg.geometry.length)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['waypoints', index, 'fixed_leg', 'crossing'], + message: 'a crossing runs between two points of its leg, the first before the second', + }); + } } const expectedRole: TourWaypointRole = index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via'; @@ -731,6 +754,9 @@ function tourTypeIssues(value: { kind === 'ferrata' ) issues.push({ path: ['waypoints', index, 'fixed_leg'], message: `${named} has no via ferrata` }); + // CW-12: a crossing by boat is taken on a city walk only, where the way on foot has none. + if (type !== 'walk' && kind === 'ferry') + issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a ferry leg belongs to a city walk' }); if (type !== 'horse' && kind === 'horse_route') issues.push({ path: ['waypoints', index, 'fixed_leg'], diff --git a/shared/src/tours/walk-ferry.spec.ts b/shared/src/tours/walk-ferry.spec.ts new file mode 100644 index 000000000..b13c48ff6 --- /dev/null +++ b/shared/src/tours/walk-ferry.spec.ts @@ -0,0 +1,72 @@ +import { boatSpansOf, ferryCrossingsOf } from './walk-ferry'; +import { stationShares } from './walk-hours'; + +import { describe, expect, it } from 'vitest'; + +const line: Array<[number, number, number]> = [ + [45.4296, 12.3264, 2], + [45.4291, 12.3266, 1], + [45.4275, 12.3282, 1], + [45.4268, 12.33, 2], +]; + +describe('ferry legs of a city walk (CW-12)', () => { + it('reads the boat part of each ferry leg, and nothing of any other leg', () => { + expect( + ferryCrossingsOf([ + { + fixed_leg: { + kind: 'free', + geometry: [ + [1, 1], + [2, 2], + ], + }, + }, + { + fixed_leg: { + kind: 'ferry', + geometry: line.map(([lat, lng]): [number, number] => [lat, lng]), + crossing: [1, 2], + minutes: 7, + walk_minutes: 2, + }, + }, + {}, + ]), + ).toEqual([ + [ + [45.4291, 12.3266], + [45.4275, 12.3282], + ], + ]); + }); + + it('finds each crossing on the saved line by its own points, and leaves out one it cannot find', () => { + const crossing: Array<[number, number]> = [ + [45.4291, 12.3266], + [45.4275, 12.3282], + ]; + expect(boatSpansOf(line, [crossing])).toEqual([[1, 2]]); + expect( + boatSpansOf(line, [ + [ + [1, 1], + [2, 2], + ], + ]), + ).toEqual([]); + }); + + it('counts no distance along a stretch crossed by boat when sharing a walk out', () => { + const stops = [ + { lat: 45.4296, lng: 12.3264 }, + { lat: 45.4275, lng: 12.3282 }, + ]; + const walked = stationShares(stops, line)?.[1] ?? 0; + // The stop where the boat lands is passed after the walk to the pier only. + const byBoat = stationShares(stops, line, [[1, 2]])?.[1] ?? 0; + expect(byBoat).toBeLessThan(walked); + expect(stationShares(stops, line, [])).toEqual(stationShares(stops, line)); + }); +}); diff --git a/shared/src/tours/walk-ferry.ts b/shared/src/tours/walk-ferry.ts new file mode 100644 index 000000000..836b61e55 --- /dev/null +++ b/shared/src/tours/walk-ferry.ts @@ -0,0 +1,50 @@ +import type { TourFixedLeg } from './tours.schema'; + +type LinePoint = readonly [number, number, ...number[]]; + +/** Closer than this, in degrees, a point of the saved line is the point of a leg it was built from. */ +const SAME_POINT_DEG = 1e-6; + +/** CW-12: the boat part of each ferry leg of a city walk, in the order of the legs. */ +export function ferryCrossingsOf( + points: ReadonlyArray<{ fixed_leg?: TourFixedLeg | null }>, +): Array> { + return points.flatMap((point) => { + const leg = point.fixed_leg; + if (leg?.kind !== 'ferry') return []; + return [leg.geometry.slice(leg.crossing[0], leg.crossing[1] + 1).map(([lat, lng]): [number, number] => [lat, lng])]; + }); +} + +function indexOfPoint(line: ReadonlyArray, point: readonly [number, number], from: number): number { + for (let index = from; index < line.length; index += 1) { + const at = line[index]; + if (at && Math.abs(at[0] - point[0]) < SAME_POINT_DEG && Math.abs(at[1] - point[1]) < SAME_POINT_DEG) return index; + } + return -1; +} + +/** + * Where on a walk's saved line its crossings by boat run, as the indices of the + * points the boat leaves from and lands at. The line was put together from the + * legs as they are, so each crossing is found by its own points in turn; one + * that cannot be found is left out rather than guessed. + */ +export function boatSpansOf( + line: ReadonlyArray, + crossings: ReadonlyArray>, +): Array<[number, number]> { + const spans: Array<[number, number]> = []; + let from = 0; + for (const crossing of crossings) { + const first = crossing[0]; + const last = crossing[crossing.length - 1]; + if (!first || !last || crossing.length < 2) continue; + const start = indexOfPoint(line, first, from); + const end = start < 0 ? -1 : indexOfPoint(line, last, start + 1); + if (end < 0) continue; + spans.push([start, end]); + from = end; + } + return spans; +} diff --git a/shared/src/tours/walk-hours.ts b/shared/src/tours/walk-hours.ts index 859c3b862..1c056d833 100644 --- a/shared/src/tours/walk-hours.ts +++ b/shared/src/tours/walk-hours.ts @@ -325,14 +325,22 @@ function passedAt(stops: readonly WalkStop[], flat: ReadonlyArray<[number, numbe /** * How far along the route each stop is passed, from 0 at its start to 1 at its * end, by distance, the stops matched in their order as `passedAt` does. Null - * for a route of fewer than two points. + * for a route of fewer than two points. The stretches of `boatSpans` (CW-12, + * from the index the boat leaves at to the one it lands at) are crossed by + * boat and add no walking distance. */ -export function stationShares(stops: readonly WalkStop[], route: readonly RoutePoint[]): number[] | null { +export function stationShares( + stops: readonly WalkStop[], + route: readonly RoutePoint[], + boatSpans: ReadonlyArray = [], +): number[] | null { if (route.length < 2) return null; const flat = flatRoute(route); + const byBoat = (i: number) => boatSpans.some(([from, to]) => i > from && i <= to); const along = [0]; for (let i = 1; i < flat.length; i += 1) { - along.push((along[i - 1] ?? 0) + Math.sqrt(flatGap(flat[i] ?? [0, 0], flat[i - 1] ?? [0, 0]))); + const step = byBoat(i) ? 0 : Math.sqrt(flatGap(flat[i] ?? [0, 0], flat[i - 1] ?? [0, 0])); + along.push((along[i - 1] ?? 0) + step); } const total = along[along.length - 1] || 1; const indices = passedAt(stops, flat, Math.cos((route[0]?.[0] ?? 0) * RAD)); diff --git a/shared/src/tours/walk-remaining.spec.ts b/shared/src/tours/walk-remaining.spec.ts index 99b6a28c0..e5882c80d 100644 --- a/shared/src/tours/walk-remaining.spec.ts +++ b/shared/src/tours/walk-remaining.spec.ts @@ -157,4 +157,16 @@ describe('walkRemainderOf (CW-13)', () => { expect(noTime.open_stations[0]?.walk_minutes_from_reference).toBeNull(); expect(noTime.stays.remaining_known_minutes).toBe(20); }); + + it('shares no walking time out along a crossing by boat (CW-12)', () => { + // The stretch from the Cathedral to the Market is crossed by boat: the walk is the other three stretches. + const stations = walk(['visited', 'planned', 'planned', 'planned', 'planned']); + const crossings = [[line[1], line[2]] as Array<[number, number]>]; + const result = walkRemainderSchema.parse(walkRemainderOf({ stations, line, walkingMinutes: 60, crossings })); + expect(result.open_stations.map((station) => station.walk_minutes_from_reference)).toEqual([20, 20, 40, 60]); + // Without the crossing every stretch counts alike. + expect(remainder(stations, 60).open_stations.map((station) => station.walk_minutes_from_reference)).toEqual([ + 15, 30, 45, 60, + ]); + }); }); diff --git a/shared/src/tours/walk-remaining.ts b/shared/src/tours/walk-remaining.ts index d905d81cb..42116cd49 100644 --- a/shared/src/tours/walk-remaining.ts +++ b/shared/src/tours/walk-remaining.ts @@ -1,5 +1,6 @@ import type { WalkRemainder, WalkStationRef } from './tour-walk.schema'; import type { StationImportance } from './tours.schema'; +import { boatSpansOf } from './walk-ferry'; import { stationShares, type WalkStop } from './walk-hours'; /** A station of a walk with its status on one day; planned is a station nobody checked off. */ @@ -16,6 +17,8 @@ export interface WalkRemainderInput { /** The saved line; empty or null while the walk waits for its way. */ line: ReadonlyArray | null; walkingMinutes: number | null; + /** CW-12: the boat part of each ferry leg the walk takes; no walking time is shared out along them. */ + crossings?: ReadonlyArray>; } function refOf(stations: readonly WalkRemainderStop[], index: number): WalkStationRef { @@ -56,14 +59,15 @@ function gapOf({ stations, line, walkingMinutes }: WalkRemainderInput, ambiguous * are open. A station skipped ahead keeps the line through it, so the time is * an upper bound; a station planned before the last one visited leaves the * progress unclear and the time unknown. Stays are counted apart, an unknown - * one listed rather than taken as nothing. + * one listed rather than taken as nothing. A crossing by boat (CW-12) is no + * walking and takes no share of the walking time. */ export function walkRemainderOf(input: WalkRemainderInput): WalkRemainder { const { stations, line, walkingMinutes } = input; const { last, openBefore } = progressOf(stations); const ambiguous = openBefore.length > 0; const gap = gapOf(input, ambiguous); - const shares = gap === null && line ? stationShares(stations, line) : null; + const shares = gap === null && line ? stationShares(stations, line, boatSpansOf(line, input.crossings ?? [])) : null; const walking = walkingMinutes ?? 0; const passed = (index: number) => Math.round(walking * (shares?.[index] ?? 0)); const fromReference = last < 0 ? 0 : passed(last);