mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(tours): route choices for a hike, each checked against the router (MF-08)
A hike can ask the router for more than its difficulty limit: avoid steps, avoid ferries, prefer footpaths, shorter over faster. They sit under the limit in the planner's details as switches, each with what it really does, and the info dot says they weigh the way and give way where there is no other, and that none grades a path. Switching one plans the route again as one step of the history, and the choice is kept in the draft. Only preferences the public Valhalla was seen to honour are offered, with the measured cases beside hikingCostingOptions: step_penalty took Würzburg to the Festung from four flights to one, use_ferry 0 sent Kiel round the Förde and Hamburg through the Elbe tunnel, walkway_factor moved Garmisch to the Partnachklamm onto the paths, shortest cut the way to the Eckbauer. use_hills and use_tracks showed no effect on foot and are left out, as are marked, paved and rough ways, for which the router has no option. tours.hiking_preferences (migration 045000) holds them as JSON, NULL for none, so no tour saved before routes differently. Only a hike keeps them; a paddle tour naming one is refused. A copy carries them, and list_tours and get_tour_plan read them. The trip copy's waypoint links now follow the copied places inside TourWaypointsRepository.insertForPlace.
This commit is contained in:
@@ -191,7 +191,7 @@ export async function valhallaRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<Record<string, number | boolean>> = {},
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
@@ -315,14 +315,14 @@ function routeBody(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<Record<string, number | boolean>> = {},
|
||||
): Record<string, unknown> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = { ...costingOptions }
|
||||
const options: Record<string, number | boolean> = { ...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 })),
|
||||
@@ -366,7 +366,7 @@ async function requestRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<Record<string, number | boolean>> = {},
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
|
||||
return data === null ? null : runFrom(data)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { HIKING_PREFERENCE_KEYS, type HikingPreferenceKey } from '@trek/shared';
|
||||
import { ChartNoAxesColumnIncreasing, Footprints, Ruler, Ship } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { BOX } from '../../Planner/bookings/bookingParts';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { InfoDot } from '../tourParts';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
const ICON: Record<HikingPreferenceKey, ComponentType<{ size?: number; strokeWidth?: number; className?: string }>> = {
|
||||
avoid_steps: ChartNoAxesColumnIncreasing,
|
||||
avoid_ferries: Ship,
|
||||
prefer_paths: Footprints,
|
||||
shortest: Ruler,
|
||||
};
|
||||
|
||||
/**
|
||||
* MF-08: what a hike asks the router for besides its difficulty limit, as
|
||||
* switches under it. Only preferences the router was seen to honour are here
|
||||
* (hikingCostingOptions says which and how they were checked), each with what
|
||||
* it really does in a line of its own; the info dot says they weigh the way
|
||||
* and give way where there is none other, and that none of them grades a path.
|
||||
* Switching one plans the route again, as one step of the history.
|
||||
*/
|
||||
export function HikingPreferencesField({
|
||||
planner,
|
||||
locked,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'hikingPreferences' | 'setHikingPreferences'>;
|
||||
locked: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = planner.hikingPreferences;
|
||||
const toggle = (key: HikingPreferenceKey) => {
|
||||
if (!locked) planner.setHikingPreferences({ ...current, [key]: !current?.[key] });
|
||||
};
|
||||
return (
|
||||
<div className="min-w-0" data-testid="tour-hiking-preferences">
|
||||
<div className="mb-1.5 flex min-h-[18px] items-center gap-1.5">
|
||||
<span className="font-semibold uppercase tracking-[0.06em] text-content-muted" style={fs(10, 'caption')}>
|
||||
{t('tours.preferences.title')}
|
||||
</span>
|
||||
<InfoDot label={t('tours.preferences.infoLabel')} description={t('tours.preferences.info')} />
|
||||
</div>
|
||||
<ul className={`${BOX} m-0 flex list-none flex-col divide-y divide-edge-faint p-0`}>
|
||||
{HIKING_PREFERENCE_KEYS.map((key) => {
|
||||
const Icon = ICON[key];
|
||||
const hintId = `tour-preference-${key}-hint`;
|
||||
return (
|
||||
<li key={key} className="flex items-center gap-2.5 px-3 py-2">
|
||||
<Icon size={14} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t(`tours.preferences.${key}`)}
|
||||
</span>
|
||||
<span id={hintId} className="leading-snug text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tours.preferences.${key}Hint`)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={locked ? 'pointer-events-none opacity-50' : undefined}>
|
||||
<ToggleSwitch
|
||||
on={current?.[key] === true}
|
||||
onToggle={() => toggle(key)}
|
||||
label={t(`tours.preferences.${key}`)}
|
||||
describedBy={hintId}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import CustomSelect from '../../shared/CustomSelect';
|
||||
import { INPUT, LABEL } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { FoldButton, InfoDot, TourNotice, TourSection } from '../tourParts';
|
||||
import { HikingPreferencesField } from './HikingPreferencesField';
|
||||
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
@@ -350,7 +351,10 @@ export function TourDetailsSection({
|
||||
<TourWebsiteField planner={planner} locked={locked} />
|
||||
{/* A paddle tour is not graded on the hiking scale. */}
|
||||
{!planner.isPaddle && (
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
<>
|
||||
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
|
||||
<HikingPreferencesField planner={planner} locked={locked} />
|
||||
</>
|
||||
)}
|
||||
<TourTimeFields planner={planner} locked={locked} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { HikingPreferencesField } from './HikingPreferencesField';
|
||||
import { hikingCostingOptions } from './tourRouting';
|
||||
|
||||
describe('hikingCostingOptions (MF-08)', () => {
|
||||
it('asks the router only for what was switched on, in the options it was checked with', () => {
|
||||
expect(hikingCostingOptions(null)).toEqual({});
|
||||
expect(hikingCostingOptions({ avoid_steps: false })).toEqual({});
|
||||
expect(
|
||||
hikingCostingOptions({ avoid_steps: true, avoid_ferries: true, prefer_paths: true, shortest: true })
|
||||
).toEqual({ step_penalty: 600, use_ferry: 0, walkway_factor: 0.5, shortest: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('HikingPreferencesField (MF-08)', () => {
|
||||
it('shows each checked preference with what it does, and switching one keeps the others', () => {
|
||||
const setHikingPreferences = vi.fn();
|
||||
render(
|
||||
<HikingPreferencesField
|
||||
planner={{ hikingPreferences: { prefer_paths: true }, setHikingPreferences }}
|
||||
locked={false}
|
||||
/>
|
||||
);
|
||||
const steps = screen.getByRole('button', { name: 'tours.preferences.avoid_steps' });
|
||||
expect(steps).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(steps).toHaveAccessibleDescription('tours.preferences.avoid_stepsHint');
|
||||
expect(screen.getByRole('button', { name: 'tours.preferences.prefer_paths' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
fireEvent.click(steps);
|
||||
expect(setHikingPreferences).toHaveBeenCalledWith({ prefer_paths: true, avoid_steps: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tours.preferences.prefer_paths' }));
|
||||
expect(setHikingPreferences).toHaveBeenLastCalledWith({ prefer_paths: false });
|
||||
});
|
||||
|
||||
it('changes nothing while the tour is locked', () => {
|
||||
const setHikingPreferences = vi.fn();
|
||||
render(<HikingPreferencesField planner={{ hikingPreferences: null, setHikingPreferences }} locked />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tours.preferences.shortest' }));
|
||||
expect(setHikingPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
hikingPreferencesSchema,
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
paddleLockSchema,
|
||||
plannableTourTypeSchema,
|
||||
type HikingPreferences,
|
||||
type PlannableTourType,
|
||||
type TourMaxHikingDifficulty,
|
||||
} from '@trek/shared';
|
||||
@@ -30,6 +32,8 @@ export interface DraftPayload {
|
||||
paddle?: PaddleFieldValues;
|
||||
/** P2C-03: the revision of the tour the draft edits, as it was loaded. */
|
||||
baseRevision?: number | null;
|
||||
/** MF-08: what the hike asks the router for besides its limit. */
|
||||
hikingPreferences?: HikingPreferences | null;
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
@@ -80,6 +84,7 @@ export function validDraft(value: unknown): value is DraftPayload {
|
||||
optionalPlaceId(draft.editingPlaceId) &&
|
||||
optionalPlaceId(draft.derivedFromPlaceId) &&
|
||||
optionalPlaceId(draft.baseRevision) &&
|
||||
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
|
||||
optionalDifficulty(draft.maxHikingDifficulty) &&
|
||||
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
|
||||
Array.isArray(draft.waypoints) &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
tourWebsiteSchema,
|
||||
type HikingPreferences,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleWaterContext,
|
||||
@@ -14,6 +15,8 @@ import { hikingTimeMinutes } from '../hikingTime';
|
||||
export interface TourPlannerFactsInput {
|
||||
tourType: PlannableTourType;
|
||||
maxHikingDifficulty: TourMaxHikingDifficulty;
|
||||
/** MF-08: what the hike asks the router for besides its limit. */
|
||||
hikingPreferences: HikingPreferences | null;
|
||||
website: string;
|
||||
plannedDurationMinutes: number | null;
|
||||
breakAdditionalMinutes: number | null;
|
||||
@@ -84,6 +87,7 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
|
||||
paddle_speed_kmh: speedKmh,
|
||||
portage_speed_kmh: portageSpeedKmh,
|
||||
paddle_lake_style: lakeStyle,
|
||||
hiking_preferences: null,
|
||||
}
|
||||
: {
|
||||
tour_type: input.tourType,
|
||||
@@ -93,6 +97,7 @@ export function tourPlannerFacts(input: TourPlannerFactsInput) {
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
hiking_preferences: input.hikingPreferences,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTourDraftStorage } from './useTourDraftStorage';
|
||||
const hike = (overrides: Partial<TourPlannerFactsInput> = {}): TourPlannerFactsInput => ({
|
||||
tourType: 'hike',
|
||||
maxHikingDifficulty: 3,
|
||||
hikingPreferences: null,
|
||||
website: '',
|
||||
plannedDurationMinutes: null,
|
||||
breakAdditionalMinutes: null,
|
||||
|
||||
@@ -35,6 +35,16 @@ describe('routeWalkingTour', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-030: asks the router for the preferences of a hike beside its limit (MF-08)', async () => {
|
||||
await routeWalkingTour(waypoints, undefined, 3, { avoid_steps: true, shortest: true })
|
||||
|
||||
expect(valhallaRun).toHaveBeenCalledWith(waypoints, 'walking', [], undefined, {
|
||||
max_hiking_difficulty: 3,
|
||||
step_penalty: 600,
|
||||
shortest: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('TOUR-ROUTING-020: takes a fixed leg as its own line and routes only the runs around it', async () => {
|
||||
const leg = {
|
||||
kind: 'ferrata' as const,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TourFixedLeg } from '@trek/shared'
|
||||
import type { HikingPreferences, TourFixedLeg } from '@trek/shared'
|
||||
import type { Waypoint } from '../../../types'
|
||||
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
|
||||
import { densifyLine, lineLengthM } from './tourFixedLegs'
|
||||
@@ -34,6 +34,31 @@ const TOURS_MAX_HIKING_DIFFICULTY = 2
|
||||
/** How fast a fixed leg is reckoned until the heights are in: a via ferrata climbs slowly, a free leg is walked. */
|
||||
const FIXED_LEG_KM_PER_HOUR = { ferrata: 1.5, free: 4, portage: 2 } as const
|
||||
|
||||
/**
|
||||
* MF-08: a hike's preferences as Valhalla's pedestrian costing options. Each
|
||||
* one was checked against the public instance before it was offered, and is a
|
||||
* weighting that gives way where there is no other path:
|
||||
* - avoid_steps, `step_penalty` 600 s: Würzburg Altstadt to the Festung went
|
||||
* from four flights of steps over 1.49 km to one over 2.67 km.
|
||||
* - avoid_ferries, `use_ferry` 0: Kiel Reventlou to Dietrichsdorf went from the
|
||||
* Förde ferry (2.7 km) round the fjord on land (8.3 km); Hamburg
|
||||
* Landungsbrücken to Steinwerder through the old Elbe tunnel instead of the ferry.
|
||||
* - prefer_paths, `walkway_factor` 0.5: footpaths of their own weigh half of a
|
||||
* road, which moved Garmisch station to the Partnachklamm onto the paths.
|
||||
* - shortest, `shortest`: the way by its length, not its time (Garmisch to the
|
||||
* Eckbauer 5.09 km to 4.96 km); it still honours the penalties above.
|
||||
* Left out because the router showed no effect on foot: `use_hills` and
|
||||
* `use_tracks`; nor has it any option for marked, paved or rough ways.
|
||||
*/
|
||||
export function hikingCostingOptions(preferences: HikingPreferences | null | undefined): Record<string, number | boolean> {
|
||||
return {
|
||||
...(preferences?.avoid_steps ? { step_penalty: 600 } : {}),
|
||||
...(preferences?.avoid_ferries ? { use_ferry: 0 } : {}),
|
||||
...(preferences?.prefer_paths ? { walkway_factor: 0.5 } : {}),
|
||||
...(preferences?.shortest ? { shortest: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tours deliberately bypasses the OSRM-first RouteCalculator. A waypoint whose
|
||||
* leg is fixed (a via ferrata on the tour) hands its own line over for that
|
||||
@@ -43,6 +68,7 @@ export async function routeWalkingTour(
|
||||
waypoints: Array<Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }>,
|
||||
signal?: AbortSignal,
|
||||
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
|
||||
preferences: HikingPreferences | null = null,
|
||||
): Promise<TourRouteResult | null> {
|
||||
const lines: TourRoutePart[] = []
|
||||
let distanceMeters = 0
|
||||
@@ -51,6 +77,7 @@ export async function routeWalkingTour(
|
||||
if (run.length < 2) return true
|
||||
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), 'walking', [], signal, {
|
||||
max_hiking_difficulty: maxHikingDifficulty,
|
||||
...hikingCostingOptions(preferences),
|
||||
})
|
||||
if (!routed) return false
|
||||
lines.push({ coordinates: routed.total.coordinates, free: false })
|
||||
|
||||
@@ -1018,7 +1018,7 @@ describe('useTourPlanner', () => {
|
||||
expect(result.current.waypoints.map(point => point.role)).toEqual(['start', 'end'])
|
||||
expect(result.current.hasUnsavedChanges).toBe(false)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
||||
expect(routeWalkingTour).toHaveBeenCalledWith(result.current.waypoints, expect.any(AbortSignal), 2)
|
||||
expect(routeWalkingTour).toHaveBeenCalledWith(result.current.waypoints, expect.any(AbortSignal), 2, null)
|
||||
expect(result.current.status).toBe('ready')
|
||||
expect(result.current.route).toEqual(routed.coordinates)
|
||||
|
||||
@@ -1453,6 +1453,28 @@ describe('useTourPlanner', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-040: hiking preferences are a step of the history, kept in the draft and sent with the route (MF-08)', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 41 }))
|
||||
act(() => {
|
||||
result.current.addWaypoint(48, 11)
|
||||
result.current.addWaypoint(48.01, 11.02)
|
||||
})
|
||||
act(() => result.current.setHikingPreferences({ avoid_steps: true, shortest: false }))
|
||||
expect(result.current.hikingPreferences).toEqual({ avoid_steps: true })
|
||||
expect(JSON.parse(localStorage.getItem('tour-draft-41') || 'null')).toMatchObject({ hikingPreferences: { avoid_steps: true } })
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
|
||||
expect(routeWalkingTour).toHaveBeenLastCalledWith(expect.any(Array), expect.any(AbortSignal), 2, { avoid_steps: true })
|
||||
|
||||
// The same choice again is no step; an undo takes the preferences back with it.
|
||||
act(() => result.current.setHikingPreferences({ avoid_steps: true }))
|
||||
act(() => result.current.undo())
|
||||
expect(result.current.hikingPreferences).toBeNull()
|
||||
act(() => result.current.redo())
|
||||
expect(result.current.hikingPreferences).toEqual({ avoid_steps: true })
|
||||
act(() => result.current.startNewTour(true))
|
||||
expect(result.current.hikingPreferences).toBeNull()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-010: starts a clean T2 draft and clears route, selection, history, and stored draft', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 16 }))
|
||||
act(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { hikingPreferencesOf, paddleSpeedKmhSchema, tourWebsiteSchema, type HikingPreferences, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
@@ -52,7 +52,7 @@ export type TourPlannerStatus =
|
||||
| 'saved'
|
||||
|
||||
/** One step of the history: the route, and what the route depends on. */
|
||||
interface HistoryEntry { waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; tourType: PlannableTourType }
|
||||
interface HistoryEntry { waypoints: TourPlannerWaypoint[]; difficulty: TourMaxHikingDifficulty; preferences: HikingPreferences | null; tourType: PlannableTourType }
|
||||
|
||||
const ROUTE_DEBOUNCE_MS = 450
|
||||
|
||||
@@ -112,6 +112,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [breakAdditionalMinutes, setBreakAdditionalMinutesState] = useState<number | null>(restored?.breakAdditionalMinutes ?? null)
|
||||
const [waypoints, setWaypoints] = useState<TourPlannerWaypoint[]>(restored?.waypoints ?? [])
|
||||
const [maxHikingDifficulty, setMaxHikingDifficultyState] = useState<TourMaxHikingDifficulty>(restored?.maxHikingDifficulty ?? 2)
|
||||
const [hikingPreferences, setHikingPreferencesState] = useState<HikingPreferences | null>(restored?.hikingPreferences ?? null)
|
||||
const [tourType, setTourTypeState] = useState<PlannableTourType>(restored?.tourType ?? 'hike')
|
||||
const [selectedWaypointId, setSelectedWaypointId] = useState<string | null>(restored?.waypoints[0]?.id ?? null)
|
||||
const history = usePlannerHistory<HistoryEntry>()
|
||||
@@ -163,10 +164,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
editingPlaceIdRef.current = editingPlaceId
|
||||
const waypointsRef = useRef(waypoints)
|
||||
const difficultyRef = useRef(maxHikingDifficulty)
|
||||
const preferencesRef = useRef(hikingPreferences)
|
||||
const tourTypeRef = useRef(tourType)
|
||||
waypointsRef.current = waypoints
|
||||
difficultyRef.current = maxHikingDifficulty
|
||||
const snapshot = useCallback((): HistoryEntry => ({ waypoints: waypointsRef.current, difficulty: difficultyRef.current, tourType: tourTypeRef.current }), [])
|
||||
preferencesRef.current = hikingPreferences
|
||||
const snapshot = useCallback((): HistoryEntry => ({ waypoints: waypointsRef.current, difficulty: difficultyRef.current, preferences: preferencesRef.current, tourType: tourTypeRef.current }), [])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
@@ -236,20 +239,27 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('dirty')
|
||||
}, [interruptRouting])
|
||||
|
||||
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
|
||||
/** A hike's routing choices, its limit and its preferences (MF-08), as one step of the history; the route is planned again. */
|
||||
const setRoutingChoice = useCallback((change: { difficulty?: TourMaxHikingDifficulty; preferences?: HikingPreferences | null }) => {
|
||||
if (!editPermissionRef.current) return
|
||||
if (value === difficultyRef.current) return
|
||||
const difficulty = change.difficulty ?? difficultyRef.current
|
||||
const preferences = change.preferences === undefined ? preferencesRef.current : hikingPreferencesOf(change.preferences)
|
||||
if (difficulty === difficultyRef.current && JSON.stringify(preferences) === JSON.stringify(preferencesRef.current)) return
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
recordStep(snapshot())
|
||||
difficultyRef.current = value
|
||||
setMaxHikingDifficultyState(value)
|
||||
difficultyRef.current = difficulty
|
||||
preferencesRef.current = preferences
|
||||
setMaxHikingDifficultyState(difficulty)
|
||||
setHikingPreferencesState(preferences)
|
||||
setHasUnsavedChanges(true)
|
||||
setSaveOutcome(null)
|
||||
setStatus('dirty')
|
||||
setError(null)
|
||||
}, [recordStep, invalidateDetailRequest, interruptRouting, snapshot])
|
||||
const setMaxHikingDifficulty = useCallback((difficulty: TourMaxHikingDifficulty) => setRoutingChoice({ difficulty }), [setRoutingChoice])
|
||||
const setHikingPreferences = useCallback((preferences: HikingPreferences | null) => setRoutingChoice({ preferences }), [setRoutingChoice])
|
||||
|
||||
const addWaypoint = useCallback((lat: number, lng: number) => {
|
||||
if (!editPermissionRef.current) return
|
||||
@@ -358,9 +368,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
draftRevisionRef.current += 1
|
||||
waypointsRef.current = entry.waypoints
|
||||
difficultyRef.current = entry.difficulty
|
||||
preferencesRef.current = entry.preferences
|
||||
tourTypeRef.current = entry.tourType
|
||||
setWaypoints(entry.waypoints)
|
||||
setMaxHikingDifficultyState(entry.difficulty)
|
||||
setHikingPreferencesState(entry.preferences)
|
||||
setTourTypeState(entry.tourType)
|
||||
setSelectedWaypointId(id => entry.waypoints.some(point => point.id === id) ? id : entry.waypoints[0]?.id ?? null)
|
||||
setHasUnsavedChanges(true)
|
||||
@@ -451,10 +463,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
|
||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
|
||||
const facts = useMemo(() => tourPlannerFacts({
|
||||
tourType, maxHikingDifficulty, website, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
tourType, maxHikingDifficulty, hikingPreferences, website, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
|
||||
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds,
|
||||
}), [boatType, breakAdditionalMinutes, durationSeconds, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
|
||||
}), [boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
|
||||
plannedDurationMinutes, portageSpeedKmh, routeAnalysis, speedKmh, tourType, waterContext, website])
|
||||
const { websiteInvalid, plannedDurationInvalid, breakAdditionalInvalid, paddleIncomplete, walkingDurationMinutes,
|
||||
plannedTotalMinutes, plannedTotalInvalid, savedDurationSeconds, typeFields } = facts
|
||||
@@ -478,6 +490,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
loadPaddle(NO_PADDLE_FIELDS)
|
||||
waypointsRef.current = []
|
||||
difficultyRef.current = 2
|
||||
preferencesRef.current = null
|
||||
setHikingPreferencesState(null)
|
||||
tourTypeRef.current = 'hike'
|
||||
setSelectedWaypointId(null)
|
||||
clearHistory()
|
||||
@@ -641,6 +655,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
loadPaddle(paddleFieldsOf(result.tour))
|
||||
waypointsRef.current = nextWaypoints
|
||||
difficultyRef.current = result.tour.max_hiking_difficulty ?? 2
|
||||
preferencesRef.current = result.tour.hiking_preferences ?? null
|
||||
setHikingPreferencesState(preferencesRef.current)
|
||||
tourTypeRef.current = openedType
|
||||
setSelectedWaypointId(nextWaypoints[0]?.id ?? null)
|
||||
clearHistory()
|
||||
@@ -750,7 +766,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
const routeHike = async () => {
|
||||
setStatus('routing')
|
||||
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
|
||||
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty, hikingPreferences)
|
||||
if (stale()) return
|
||||
if (!routed) {
|
||||
setStatus('routing-failed')
|
||||
@@ -797,18 +813,18 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
|
||||
}, [hikingPreferences, interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 9, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, tourType, baseRevision: revision.base,
|
||||
derivedFromPlaceId, tourType, baseRevision: revision.base, hikingPreferences,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
keepDraft(draft)
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base])
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
@@ -899,7 +915,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
return {
|
||||
tripId, canEdit, canAssign,
|
||||
name, setName, maxHikingDifficulty, setMaxHikingDifficulty,
|
||||
name, setName, maxHikingDifficulty, setMaxHikingDifficulty, hikingPreferences, setHikingPreferences,
|
||||
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
|
||||
description, setDescription, website, setWebsite, websiteInvalid,
|
||||
plannedDurationMinutes, setPlannedDurationMinutes, plannedDurationInvalid,
|
||||
|
||||
@@ -35,6 +35,7 @@ export class Tours {
|
||||
track_marks?: string | null;
|
||||
paddle_lake_style?: TToursPaddleLakeStyle | null;
|
||||
revision: number & Opt = 1;
|
||||
hiking_preferences?: string | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
@@ -110,6 +111,7 @@ export const ToursSchema = defineEntity({
|
||||
track_marks: p.text().nullable(),
|
||||
paddle_lake_style: p.enum(() => ToursPaddleLakeStyle).nullable(),
|
||||
revision: p.integer().default(1),
|
||||
hiking_preferences: p.text().nullable(),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
|
||||
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JsonColumn } from '../utils/json-column';
|
||||
import {
|
||||
FERRATAS_RESULT_CAP,
|
||||
ferrataSchema,
|
||||
hikingPreferencesSchema,
|
||||
obstacleSnapshotSchema,
|
||||
paddleGeometrySchema,
|
||||
paddleLockSchema,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
tourFixedLegSchema,
|
||||
trackMarksSchema,
|
||||
type Ferrata,
|
||||
type HikingPreferences,
|
||||
type ObstacleSnapshot,
|
||||
type PaddleGeometry,
|
||||
type PaddleLock,
|
||||
@@ -175,6 +177,13 @@ export const TOUR_TRACK_MARKS: JsonColumn<TrackMarks | null> = {
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** MF-08: a hike's routing preferences. A broken row reads as none, so the hike routes as it would without. */
|
||||
export const TOUR_HIKING_PREFERENCES: JsonColumn<HikingPreferences | null> = {
|
||||
column: 'tours.hiking_preferences',
|
||||
schema: hikingPreferencesSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** A paddle object's own geometry (PAD-05A). A broken row has none, and the object is left out rather than drawn anywhere. */
|
||||
export const PADDLE_FEATURE_GEOMETRY: JsonColumn<PaddleGeometry | null> = {
|
||||
column: 'paddle_features.geometry',
|
||||
|
||||
@@ -27,4 +27,5 @@ export interface ToursTable {
|
||||
track_marks: InsertOptional<string | null>;
|
||||
paddle_lake_style: InsertOptional<string | null>;
|
||||
revision: InsertOptional<number>;
|
||||
hiking_preferences: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* MF-08: what a hike asks the router for besides its difficulty limit (steps,
|
||||
* ferries, footpaths, the shorter way), as JSON. NULL for none, which every
|
||||
* tour saved before keeps, so no existing route changes by itself.
|
||||
*/
|
||||
export class Migration20200101045000_a_hike_keeps_its_routing_preferences extends Migration {
|
||||
override name = 'Migration20200101045000_a_hike_keeps_its_routing_preferences';
|
||||
|
||||
override up(): void {
|
||||
this.addSql('ALTER TABLE tours ADD COLUMN hiking_preferences TEXT');
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,18 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
/**
|
||||
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock, shaping)
|
||||
* VALUES (...)` for each point. The caller owns the transaction: a create
|
||||
* writes the tour first, an edit clears the old points first.
|
||||
* writes the tour first, an edit clears the old points first. A trip copy
|
||||
* hands its map of copied places, which the links follow.
|
||||
*/
|
||||
async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise<void> {
|
||||
async insertForPlace(
|
||||
place_id: number,
|
||||
waypoints: readonly TourWaypointRow[],
|
||||
copiedPlaceIds?: ReadonlyMap<number, number>,
|
||||
): Promise<void> {
|
||||
if (waypoints.length === 0) return;
|
||||
// In a trip copy a point standing for a place of the trip stands for that place's copy (PR1-10).
|
||||
const linkOf = (id: number | null | undefined) =>
|
||||
id == null ? null : copiedPlaceIds ? (copiedPlaceIds.get(id) ?? null) : id;
|
||||
await this.insertMany(
|
||||
waypoints.map((w) => ({
|
||||
place: place_id,
|
||||
@@ -117,7 +125,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null,
|
||||
lock: w.lock ? encodeJson(TOUR_WAYPOINT_LOCK, w.lock) : null,
|
||||
shaping: w.shaping ? 1 : 0,
|
||||
linkedPlace: w.linked_place_id ?? null,
|
||||
linkedPlace: linkOf(w.linked_place_id),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface TourListRow {
|
||||
has_waypoints: number;
|
||||
/** P2C-03: the tour's technical revision. */
|
||||
revision: number;
|
||||
/** MF-08: a hike's routing preferences, still JSON text. */
|
||||
hiking_preferences: string | null;
|
||||
}
|
||||
|
||||
/** Every `tours` column, as the trip copy reads and re-inserts it. */
|
||||
@@ -68,6 +70,7 @@ export interface TourRow {
|
||||
paddle_lake_style: string | null;
|
||||
track_marks: string | null;
|
||||
revision: number;
|
||||
hiking_preferences: string | null;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
@@ -89,6 +92,8 @@ export interface TourInsert {
|
||||
paddle_speed_kmh?: number | null;
|
||||
portage_speed_kmh?: number | null;
|
||||
paddle_lake_style?: PaddleLakeStyle | null;
|
||||
/** MF-08: a hike's routing preferences as JSON text; left out, none. */
|
||||
hiking_preferences?: string | null;
|
||||
}
|
||||
|
||||
/** What a route edit replaces on an existing tour: the type and everything that belongs to it. */
|
||||
@@ -204,6 +209,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
't.paddle_lake_style as paddle_lake_style',
|
||||
't.track_marks as track_marks',
|
||||
't.revision as revision',
|
||||
't.hiking_preferences as hiking_preferences',
|
||||
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
|
||||
eb
|
||||
.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id'))
|
||||
@@ -461,6 +467,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
portage_speed_kmh: input.portage_speed_kmh ?? null,
|
||||
paddle_lake_style: input.paddle_lake_style ?? null,
|
||||
track_marks: input.track_marks ?? null,
|
||||
hiking_preferences: input.hiking_preferences ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -541,6 +548,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
paddle_speed_kmh: input.paddle_speed_kmh,
|
||||
portage_speed_kmh: input.portage_speed_kmh,
|
||||
paddle_lake_style: input.paddle_lake_style,
|
||||
...(input.hiking_preferences !== undefined ? { hiking_preferences: input.hiking_preferences } : {}),
|
||||
})
|
||||
.where('place_id', '=', place_id)
|
||||
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
|
||||
|
||||
@@ -48,7 +48,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_steps, avoid_ferries, prefer_paths, shortest; weightings, never a grade of the path), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TOUR_TRACK_MARKS } from '../../db/json-columns';
|
||||
import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository';
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
ToursRepository,
|
||||
TourUpdate,
|
||||
} from '../../db/repositories/Tours.repository';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
hikingPreferencesOf,
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
plannedTourDurationMinutesSchema,
|
||||
tourWebsiteSchema,
|
||||
@@ -67,6 +68,12 @@ export interface ImportGpxAsTourResult {
|
||||
/** A GPX file imported without a type is a hike. */
|
||||
const HIKE_IMPORT: TourGpxImportFields = { tour_type: 'hike' };
|
||||
|
||||
/** MF-08: a save's hiking preferences as stored: a hike's switched-on ones as JSON, else NULL. */
|
||||
function hikingFields(input: TourCreateRequest): string | null {
|
||||
const preferences = input.tour_type === 'hike' ? hikingPreferencesOf(input.hiking_preferences) : null;
|
||||
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ToursService {
|
||||
private readonly logger = new Logger(ToursService.name);
|
||||
@@ -112,6 +119,7 @@ export class ToursService {
|
||||
caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD,
|
||||
has_waypoints: Boolean(r.has_waypoints),
|
||||
revision: r.revision,
|
||||
hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +148,8 @@ export class ToursService {
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
portage_speed_kmh: input.portage_speed_kmh ?? null,
|
||||
paddle_lake_style: input.paddle_lake_style ?? null,
|
||||
// MF-08: only a hike keeps preferences, and only those switched on; none is NULL.
|
||||
hiking_preferences: hikingFields(input),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -436,6 +446,7 @@ export class ToursService {
|
||||
portage_speed_kmh: source.portage_speed_kmh,
|
||||
paddle_lake_style: source.paddle_lake_style as PaddleLakeStyle | null,
|
||||
track_marks: source.track_marks,
|
||||
hiking_preferences: source.hiking_preferences,
|
||||
wanderer_ref: source.wanderer_ref,
|
||||
difficulty: source.difficulty,
|
||||
derived_from_place_id: pid,
|
||||
@@ -628,6 +639,7 @@ export class ToursService {
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -754,11 +754,7 @@ export class TripsService {
|
||||
waypointsByPlace.set(place_id, list);
|
||||
}
|
||||
for (const [placeId, waypoints] of waypointsByPlace) {
|
||||
// A point standing for a place of the trip stands for that place's copy (PR1-10).
|
||||
const remapped = waypoints.map((point) =>
|
||||
point.linked_place_id ? { ...point, linked_place_id: placeMap.get(point.linked_place_id) ?? null } : point,
|
||||
);
|
||||
await this.tourWaypointsRepo.insertForPlace(placeMap.get(placeId)!, remapped);
|
||||
await this.tourWaypointsRepo.insertForPlace(placeMap.get(placeId)!, waypoints, placeMap);
|
||||
}
|
||||
|
||||
const oldAssignments = await this.dayAssignmentsRepo.listAllForTrip(sourceTripId); // TP48
|
||||
|
||||
@@ -425,4 +425,52 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => {
|
||||
expect(detail.body.waypoints[0].linked_place_id).toBe(lake.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hiking preferences (MF-08)', () => {
|
||||
it('a hike keeps the preferences switched on, reads them back, and a copy carries them', async () => {
|
||||
const placeId = await createViaApi(
|
||||
5,
|
||||
tourBody({ hiking_preferences: { avoid_steps: true, prefer_paths: true, shortest: false } }),
|
||||
);
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.tour.hiking_preferences).toEqual({ avoid_steps: true, prefer_paths: true });
|
||||
const copy = await request(server)
|
||||
.post(`/api/trips/5/tours/${placeId}/copy`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send({ name: 'Copy' });
|
||||
expect(copy.body.tour.hiking_preferences).toEqual({ avoid_steps: true, prefer_paths: true });
|
||||
|
||||
// An edit that switches them all off keeps none; one without the field keeps none too.
|
||||
const off = await request(server)
|
||||
.put(`/api/trips/5/tours/${placeId}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody({ hiking_preferences: { avoid_steps: false } }));
|
||||
expect(off.body.tour.hiking_preferences).toBeNull();
|
||||
expect((await findRow(orm, Tours, { place: placeId }))?.hiking_preferences).toBeNull();
|
||||
});
|
||||
|
||||
it('a tour saved before keeps none, an unknown preference is refused, and a paddle tour has none', async () => {
|
||||
const plain = await createViaApi();
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${plain}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.tour.hiking_preferences).toBeNull();
|
||||
const unknown = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody({ hiking_preferences: { avoid_hills: true } }));
|
||||
expect(unknown.status).toBe(400);
|
||||
const paddle = await request(server)
|
||||
.post('/api/trips/5/tours')
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(
|
||||
tourBody({
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'kayak',
|
||||
water_context: 'lake',
|
||||
max_hiking_difficulty: null,
|
||||
hiking_preferences: { avoid_steps: true },
|
||||
}),
|
||||
);
|
||||
expect(paddle.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans',
|
||||
'Migration20200101044800_a_tour_counts_its_revisions',
|
||||
'Migration20200101044900_a_tour_waypoint_can_be_a_place_of_the_trip',
|
||||
'Migration20200101045000_a_hike_keeps_its_routing_preferences',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -313,6 +313,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': 'مكان من الرحلة',
|
||||
'tours.placePicker.linkedHint': 'تمثّل هذه المحطة مكانًا في الرحلة. حذفها أو فك الربط لا يغيّر المكان.',
|
||||
'tours.placePicker.unlink': 'فك الربط بالمكان',
|
||||
'tours.preferences.title': 'اختيار المسار',
|
||||
'tours.preferences.infoLabel': 'ما الذي يفعله اختيار المسار',
|
||||
'tours.preferences.info':
|
||||
'تؤثر هذه الخيارات في الطريق الذي يختاره الموجّه. حيث لا يوجد طريق آخر، يمر المسار هناك رغم ذلك. ولا تقول شيئًا عن صعوبة الطريق؛ فحد الصعوبة أعلاه يحدد ما يمكن للمسار استخدامه.',
|
||||
'tours.preferences.avoid_steps': 'تجنّب الدرج',
|
||||
'tours.preferences.avoid_stepsHint': 'الدرج فقط حيث لا يوجد طريق بديل.',
|
||||
'tours.preferences.avoid_ferries': 'تجنّب العبّارات',
|
||||
'tours.preferences.avoid_ferriesHint': 'عبر البر حيث يوجد طريق بديل، حتى لو كان أطول.',
|
||||
'tours.preferences.prefer_paths': 'تفضيل ممرات المشاة',
|
||||
'tours.preferences.prefer_pathsHint': 'مسارات مستقلة بدل الطرق وأرصفتها حيث توجد.',
|
||||
'tours.preferences.shortest': 'الأقصر بدل الأسرع',
|
||||
'tours.preferences.shortestHint': 'يُختار الطريق بطوله لا بزمنه؛ غالبًا أقصر قليلًا، وليس دائمًا أسرع.',
|
||||
'tours.planner.tourType': 'نوع الجولة',
|
||||
'tours.paddle.boat': 'القارب',
|
||||
'tours.paddle.boat.kayak': 'كاياك',
|
||||
|
||||
@@ -304,6 +304,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Bu dayanacaq səfərin bir yerini göstərir. Silmək və ya əlaqəni ləğv etmək yerin özünü dəyişmir.',
|
||||
'tours.placePicker.unlink': 'Yerlə əlaqəni ləğv et',
|
||||
'tours.preferences.title': 'Marşrut seçimi',
|
||||
'tours.preferences.infoLabel': 'Marşrut seçimi nə edir',
|
||||
'tours.preferences.info':
|
||||
'Bu seçimlər marşrutlaşdırıcının hansı yolu seçəcəyinə təsir edir. Başqa yol yoxdursa, marşrut yenə oradan keçir. Yolun çətinliyi barədə heç nə demir; marşrutun nədən istifadə edə biləcəyini yuxarıdakı çətinlik həddi müəyyən edir.',
|
||||
'tours.preferences.avoid_steps': 'Pilləkənlərdən qaç',
|
||||
'tours.preferences.avoid_stepsHint': 'Pilləkən yalnız dolama yol olmayan yerdə.',
|
||||
'tours.preferences.avoid_ferries': 'Bərələrdən qaç',
|
||||
'tours.preferences.avoid_ferriesHint': 'Dolama yol varsa, daha uzun olsa da quru ilə.',
|
||||
'tours.preferences.prefer_paths': 'Piyada yollarına üstünlük ver',
|
||||
'tours.preferences.prefer_pathsHint': 'Olan yerdə yollar və səkilər əvəzinə ayrıca cığırlar.',
|
||||
'tours.preferences.shortest': 'Daha sürətli yox, daha qısa',
|
||||
'tours.preferences.shortestHint':
|
||||
'Yol vaxta görə yox, uzunluğa görə seçilir; çox vaxt bir az qısa, həmişə daha sürətli deyil.',
|
||||
'tours.planner.tourType': 'Tur növü',
|
||||
'tours.paddle.boat': 'Qayıq',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -310,6 +310,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Esta parada representa um local da viagem. Removê-la ou desfazer o vínculo não altera o local.',
|
||||
'tours.placePicker.unlink': 'Desvincular do local',
|
||||
'tours.preferences.title': 'Escolha da rota',
|
||||
'tours.preferences.infoLabel': 'O que a escolha da rota faz',
|
||||
'tours.preferences.info':
|
||||
'Estas escolhas pesam o caminho que o roteador escolhe. Onde não há outro caminho, a rota passa por ali mesmo assim. Não dizem nada sobre a dificuldade de um caminho; o limite de dificuldade acima define o que a rota pode usar.',
|
||||
'tours.preferences.avoid_steps': 'Evitar escadas',
|
||||
'tours.preferences.avoid_stepsHint': 'Escadas só onde não há caminho alternativo.',
|
||||
'tours.preferences.avoid_ferries': 'Evitar balsas',
|
||||
'tours.preferences.avoid_ferriesHint': 'Por terra onde houver caminho alternativo, mesmo mais longo.',
|
||||
'tours.preferences.prefer_paths': 'Preferir trilhas a pé',
|
||||
'tours.preferences.prefer_pathsHint': 'Caminhos próprios em vez de estradas e calçadas, onde houver.',
|
||||
'tours.preferences.shortest': 'Mais curto em vez de mais rápido',
|
||||
'tours.preferences.shortestHint':
|
||||
'O caminho escolhido pela distância, não pelo tempo; em geral um pouco mais curto, nem sempre mais rápido.',
|
||||
'tours.planner.tourType': 'Tipo de passeio',
|
||||
'tours.paddle.boat': 'Embarcação',
|
||||
'tours.paddle.boat.kayak': 'Caiaque',
|
||||
|
||||
@@ -310,6 +310,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Aquesta parada representa un lloc del viatge. Treure-la o desfer l’enllaç no canvia el lloc.',
|
||||
'tours.placePicker.unlink': 'Desenllaça del lloc',
|
||||
'tours.preferences.title': 'Elecció de ruta',
|
||||
'tours.preferences.infoLabel': 'Què fa l’elecció de ruta',
|
||||
'tours.preferences.info':
|
||||
'Aquestes opcions ponderen el camí que tria el càlcul de ruta. On no hi ha cap altre camí, la ruta hi passa igualment. No diuen res de la dificultat d’un camí; el límit de dificultat de dalt fixa què pot fer servir la ruta.',
|
||||
'tours.preferences.avoid_steps': 'Evita escales',
|
||||
'tours.preferences.avoid_stepsHint': 'Escales només on no hi ha cap volta possible.',
|
||||
'tours.preferences.avoid_ferries': 'Evita transbordadors',
|
||||
'tours.preferences.avoid_ferriesHint': 'Per terra on hi hagi una volta, encara que sigui més llarga.',
|
||||
'tours.preferences.prefer_paths': 'Prefereix camins de vianants',
|
||||
'tours.preferences.prefer_pathsHint': 'Camins propis en lloc de carreteres i voreres, on n’hi hagi.',
|
||||
'tours.preferences.shortest': 'Més curt que ràpid',
|
||||
'tours.preferences.shortestHint':
|
||||
'El camí triat per la llargada, no pel temps; sovint una mica més curt, no sempre més ràpid.',
|
||||
'tours.planner.tourType': 'Tipus de ruta',
|
||||
'tours.paddle.boat': 'Embarcació',
|
||||
'tours.paddle.boat.kayak': 'Caiac',
|
||||
|
||||
@@ -304,6 +304,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Tato zastávka představuje místo cesty. Odebrání nebo zrušení propojení místo nezmění.',
|
||||
'tours.placePicker.unlink': 'Odpojit od místa',
|
||||
'tours.preferences.title': 'Volba trasy',
|
||||
'tours.preferences.infoLabel': 'Co volba trasy dělá',
|
||||
'tours.preferences.info':
|
||||
'Tato volba ovlivňuje, kterou cestu plánovač vybere. Kde jiná cesta není, vede trasa tudy i tak. O obtížnosti cesty neříká nic; co smí trasa použít, určuje limit obtížnosti výše.',
|
||||
'tours.preferences.avoid_steps': 'Vyhnout se schodům',
|
||||
'tours.preferences.avoid_stepsHint': 'Schody jen tam, kde není cesta kolem.',
|
||||
'tours.preferences.avoid_ferries': 'Vyhnout se přívozům',
|
||||
'tours.preferences.avoid_ferriesHint': 'Po souši, kde vede cesta kolem, i když je delší.',
|
||||
'tours.preferences.prefer_paths': 'Upřednostnit pěšiny',
|
||||
'tours.preferences.prefer_pathsHint': 'Samostatné cesty místo silnic a jejich chodníků, kde jsou.',
|
||||
'tours.preferences.shortest': 'Kratší místo rychlejší',
|
||||
'tours.preferences.shortestHint': 'Cesta vybraná podle délky, ne podle času; často o něco kratší, ne vždy rychlejší.',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
'tours.paddle.boat': 'Loď',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -312,6 +312,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Diese Station steht für einen Ort der Reise. Entfernen oder Lösen ändert den Ort nicht.',
|
||||
'tours.placePicker.unlink': 'Vom Ort lösen',
|
||||
'tours.preferences.title': 'Routenwahl',
|
||||
'tours.preferences.infoLabel': 'Was die Routenwahl bewirkt',
|
||||
'tours.preferences.info':
|
||||
'Diese Wahl gewichtet, welchen Weg der Router nimmt. Gibt es keinen anderen Weg, führt die Route trotzdem dort entlang. Über die Schwierigkeit eines Weges sagt sie nichts; was die Route nutzen darf, legt das Schwierigkeitslimit darüber fest.',
|
||||
'tours.preferences.avoid_steps': 'Treppen meiden',
|
||||
'tours.preferences.avoid_stepsHint': 'Treppen nur, wo es keinen Weg außen herum gibt.',
|
||||
'tours.preferences.avoid_ferries': 'Fähren meiden',
|
||||
'tours.preferences.avoid_ferriesHint': 'Über Land, wo es einen Weg außen herum gibt, auch wenn er länger ist.',
|
||||
'tours.preferences.prefer_paths': 'Fußwege bevorzugen',
|
||||
'tours.preferences.prefer_pathsHint': 'Eigene Wege statt Straßen und ihrer Gehsteige, wo es sie gibt.',
|
||||
'tours.preferences.shortest': 'Kürzer statt schneller',
|
||||
'tours.preferences.shortestHint': 'Der Weg nach Länge statt nach Gehzeit; oft etwas kürzer, nicht immer schneller.',
|
||||
'tours.planner.tourType': 'Tourtyp',
|
||||
'tours.paddle.boat': 'Boot',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -303,6 +303,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'This stop stands for a place of the trip. Removing it, or undoing the link, leaves the place as it is.',
|
||||
'tours.placePicker.unlink': 'Unlink from the place',
|
||||
'tours.preferences.title': 'Route choice',
|
||||
'tours.preferences.infoLabel': 'What the route choice does',
|
||||
'tours.preferences.info':
|
||||
'These choices weigh the way the router picks. Where there is no other way, the route still goes there. They say nothing about how difficult a path is; the difficulty limit above sets what the route may use.',
|
||||
'tours.preferences.avoid_steps': 'Avoid steps',
|
||||
'tours.preferences.avoid_stepsHint': 'Steps and stairs only where there is no way round.',
|
||||
'tours.preferences.avoid_ferries': 'Avoid ferries',
|
||||
'tours.preferences.avoid_ferriesHint': 'Over land where there is a way round, even when it is longer.',
|
||||
'tours.preferences.prefer_paths': 'Prefer footpaths',
|
||||
'tours.preferences.prefer_pathsHint': 'Paths of their own over roads and their pavements, where there are some.',
|
||||
'tours.preferences.shortest': 'Shorter over faster',
|
||||
'tours.preferences.shortestHint':
|
||||
'The way chosen by its length, not by the time it takes; often a little shorter, not always quicker.',
|
||||
'tours.planner.tourType': 'Tour type',
|
||||
'tours.paddle.boat': 'Boat',
|
||||
'tours.paddle.boat.kayak': 'Kayak',
|
||||
|
||||
@@ -309,6 +309,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Esta parada representa un lugar del viaje. Quitarla o deshacer el vínculo no cambia el lugar.',
|
||||
'tours.placePicker.unlink': 'Desvincular del lugar',
|
||||
'tours.preferences.title': 'Elección de ruta',
|
||||
'tours.preferences.infoLabel': 'Qué hace la elección de ruta',
|
||||
'tours.preferences.info':
|
||||
'Estas opciones ponderan el camino que elige el enrutador. Donde no hay otro camino, la ruta pasa igualmente por ahí. No dicen nada sobre la dificultad de un camino; el límite de dificultad de arriba fija lo que la ruta puede usar.',
|
||||
'tours.preferences.avoid_steps': 'Evitar escaleras',
|
||||
'tours.preferences.avoid_stepsHint': 'Escaleras solo donde no hay forma de rodearlas.',
|
||||
'tours.preferences.avoid_ferries': 'Evitar ferris',
|
||||
'tours.preferences.avoid_ferriesHint': 'Por tierra donde haya una forma de rodear, aunque sea más larga.',
|
||||
'tours.preferences.prefer_paths': 'Preferir senderos peatonales',
|
||||
'tours.preferences.prefer_pathsHint': 'Caminos propios en lugar de carreteras y aceras, donde los haya.',
|
||||
'tours.preferences.shortest': 'Más corto antes que más rápido',
|
||||
'tours.preferences.shortestHint':
|
||||
'El camino elegido por su longitud, no por el tiempo; a menudo algo más corto, no siempre más rápido.',
|
||||
'tours.planner.tourType': 'Tipo de ruta',
|
||||
'tours.paddle.boat': 'Embarcación',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -305,6 +305,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': 'Reisi koht',
|
||||
'tours.placePicker.linkedHint': 'See peatus tähistab reisi kohta. Eemaldamine või seose lõpetamine kohta ei muuda.',
|
||||
'tours.placePicker.unlink': 'Eemalda seos kohaga',
|
||||
'tours.preferences.title': 'Marsruudi valik',
|
||||
'tours.preferences.infoLabel': 'Mida marsruudi valik teeb',
|
||||
'tours.preferences.info':
|
||||
'Need valikud kaaluvad, millise tee marsruutija valib. Kus teist teed pole, läheb marsruut ikkagi sealt. Tee raskuse kohta need midagi ei ütle; mida marsruut kasutada tohib, määrab ülal olev raskuspiir.',
|
||||
'tours.preferences.avoid_steps': 'Väldi treppe',
|
||||
'tours.preferences.avoid_stepsHint': 'Trepid ainult seal, kus ringi minna ei saa.',
|
||||
'tours.preferences.avoid_ferries': 'Väldi praame',
|
||||
'tours.preferences.avoid_ferriesHint': 'Mööda maad, kus saab ringi minna, isegi kui see on pikem.',
|
||||
'tours.preferences.prefer_paths': 'Eelista jalgradu',
|
||||
'tours.preferences.prefer_pathsHint': 'Omaette rajad teede ja kõnniteede asemel, kus neid on.',
|
||||
'tours.preferences.shortest': 'Lühem kiirema asemel',
|
||||
'tours.preferences.shortestHint': 'Tee valitakse pikkuse, mitte aja järgi; sageli veidi lühem, mitte alati kiirem.',
|
||||
'tours.planner.tourType': 'Matka tüüp',
|
||||
'tours.paddle.boat': 'Paat',
|
||||
'tours.paddle.boat.kayak': 'Süst',
|
||||
|
||||
@@ -315,6 +315,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Cette étape représente un lieu du voyage. La retirer ou défaire le lien ne modifie pas le lieu.',
|
||||
'tours.placePicker.unlink': 'Détacher du lieu',
|
||||
'tours.preferences.title': 'Choix de l’itinéraire',
|
||||
'tours.preferences.infoLabel': 'Ce que fait le choix de l’itinéraire',
|
||||
'tours.preferences.info':
|
||||
'Ces choix pondèrent le chemin que choisit le calcul d’itinéraire. Là où il n’y a pas d’autre chemin, l’itinéraire y passe quand même. Ils ne disent rien de la difficulté d’un chemin ; la limite de difficulté ci-dessus fixe ce que l’itinéraire peut emprunter.',
|
||||
'tours.preferences.avoid_steps': 'Éviter les escaliers',
|
||||
'tours.preferences.avoid_stepsHint': 'Des escaliers seulement là où on ne peut pas les contourner.',
|
||||
'tours.preferences.avoid_ferries': 'Éviter les bacs',
|
||||
'tours.preferences.avoid_ferriesHint': 'Par voie terrestre là où on peut contourner, même si c’est plus long.',
|
||||
'tours.preferences.prefer_paths': 'Préférer les sentiers',
|
||||
'tours.preferences.prefer_pathsHint': 'Des chemins à part plutôt que les routes et leurs trottoirs, là où il y en a.',
|
||||
'tours.preferences.shortest': 'Plus court plutôt que plus rapide',
|
||||
'tours.preferences.shortestHint':
|
||||
'Le chemin choisi selon sa longueur, pas selon le temps ; souvent un peu plus court, pas toujours plus rapide.',
|
||||
'tours.planner.tourType': 'Type de sortie',
|
||||
'tours.paddle.boat': 'Embarcation',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -316,6 +316,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Αυτή η στάση αντιστοιχεί σε ένα μέρος του ταξιδιού. Η αφαίρεση ή η αποσύνδεση δεν αλλάζει το μέρος.',
|
||||
'tours.placePicker.unlink': 'Αποσύνδεση από το μέρος',
|
||||
'tours.preferences.title': 'Επιλογή διαδρομής',
|
||||
'tours.preferences.infoLabel': 'Τι κάνει η επιλογή διαδρομής',
|
||||
'tours.preferences.info':
|
||||
'Αυτές οι επιλογές σταθμίζουν τον δρόμο που επιλέγει ο δρομολογητής. Όπου δεν υπάρχει άλλος δρόμος, η διαδρομή περνά από εκεί ούτως ή άλλως. Δεν λένε τίποτα για τη δυσκολία ενός μονοπατιού· το όριο δυσκολίας παραπάνω ορίζει τι μπορεί να χρησιμοποιήσει η διαδρομή.',
|
||||
'tours.preferences.avoid_steps': 'Αποφυγή σκαλοπατιών',
|
||||
'tours.preferences.avoid_stepsHint': 'Σκαλοπάτια μόνο όπου δεν υπάρχει παράκαμψη.',
|
||||
'tours.preferences.avoid_ferries': 'Αποφυγή πορθμείων',
|
||||
'tours.preferences.avoid_ferriesHint': 'Από ξηράς όπου υπάρχει παράκαμψη, ακόμη κι αν είναι μεγαλύτερη.',
|
||||
'tours.preferences.prefer_paths': 'Προτίμηση πεζόδρομων',
|
||||
'tours.preferences.prefer_pathsHint': 'Ξεχωριστά μονοπάτια αντί για δρόμους και πεζοδρόμια, όπου υπάρχουν.',
|
||||
'tours.preferences.shortest': 'Συντομότερη αντί για ταχύτερη',
|
||||
'tours.preferences.shortestHint':
|
||||
'Ο δρόμος επιλέγεται με βάση το μήκος, όχι τον χρόνο· συχνά λίγο μικρότερος, όχι πάντα ταχύτερος.',
|
||||
'tours.planner.tourType': 'Τύπος διαδρομής',
|
||||
'tours.paddle.boat': 'Σκάφος',
|
||||
'tours.paddle.boat.kayak': 'Καγιάκ',
|
||||
|
||||
@@ -310,6 +310,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Ez a megálló az út egy helyét jelöli. Eltávolítása vagy a kapcsolat bontása nem változtat a helyen.',
|
||||
'tours.placePicker.unlink': 'Leválasztás a helyről',
|
||||
'tours.preferences.title': 'Útvonalválasztás',
|
||||
'tours.preferences.infoLabel': 'Mit tesz az útvonalválasztás',
|
||||
'tours.preferences.info':
|
||||
'Ezek a beállítások azt súlyozzák, melyik utat választja az útvonaltervező. Ahol nincs más út, az útvonal mégis arra vezet. A nehézségről nem mondanak semmit; hogy mit használhat az útvonal, a fenti nehézségi korlát szabja meg.',
|
||||
'tours.preferences.avoid_steps': 'Lépcsők kerülése',
|
||||
'tours.preferences.avoid_stepsHint': 'Lépcső csak ott, ahol nincs kerülőút.',
|
||||
'tours.preferences.avoid_ferries': 'Kompok kerülése',
|
||||
'tours.preferences.avoid_ferriesHint': 'Szárazföldön, ahol van kerülőút, akkor is, ha hosszabb.',
|
||||
'tours.preferences.prefer_paths': 'Gyalogutak előnyben',
|
||||
'tours.preferences.prefer_pathsHint': 'Önálló utak az utak és járdáik helyett, ahol vannak.',
|
||||
'tours.preferences.shortest': 'Rövidebb a gyorsabb helyett',
|
||||
'tours.preferences.shortestHint':
|
||||
'Az utat a hossza szerint választja, nem az idő szerint; gyakran kicsit rövidebb, nem mindig gyorsabb.',
|
||||
'tours.planner.tourType': 'Túratípus',
|
||||
'tours.paddle.boat': 'Hajó',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -303,6 +303,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Pemberhentian ini mewakili tempat dalam perjalanan. Menghapus atau melepas tautannya tidak mengubah tempat itu.',
|
||||
'tours.placePicker.unlink': 'Lepas dari tempat',
|
||||
'tours.preferences.title': 'Pilihan rute',
|
||||
'tours.preferences.infoLabel': 'Apa yang dilakukan pilihan rute',
|
||||
'tours.preferences.info':
|
||||
'Pilihan ini memberi bobot pada jalan yang dipilih perute. Jika tidak ada jalan lain, rute tetap lewat sana. Pilihan ini tidak mengatakan apa pun soal tingkat kesulitan jalur; batas kesulitan di atas menentukan apa yang boleh dipakai rute.',
|
||||
'tours.preferences.avoid_steps': 'Hindari tangga',
|
||||
'tours.preferences.avoid_stepsHint': 'Tangga hanya jika tidak ada jalan memutar.',
|
||||
'tours.preferences.avoid_ferries': 'Hindari feri',
|
||||
'tours.preferences.avoid_ferriesHint': 'Lewat darat jika ada jalan memutar, meski lebih panjang.',
|
||||
'tours.preferences.prefer_paths': 'Utamakan jalur pejalan kaki',
|
||||
'tours.preferences.prefer_pathsHint': 'Jalur tersendiri alih-alih jalan raya dan trotoarnya, bila ada.',
|
||||
'tours.preferences.shortest': 'Lebih pendek ketimbang lebih cepat',
|
||||
'tours.preferences.shortestHint':
|
||||
'Jalan dipilih berdasarkan panjangnya, bukan waktunya; sering sedikit lebih pendek, tidak selalu lebih cepat.',
|
||||
'tours.planner.tourType': 'Jenis tur',
|
||||
'tours.paddle.boat': 'Perahu',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -310,6 +310,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Questa tappa rappresenta un luogo del viaggio. Toglierla o sciogliere il collegamento non cambia il luogo.',
|
||||
'tours.placePicker.unlink': 'Scollega dal luogo',
|
||||
'tours.preferences.title': 'Scelta del percorso',
|
||||
'tours.preferences.infoLabel': 'Cosa fa la scelta del percorso',
|
||||
'tours.preferences.info':
|
||||
'Queste scelte pesano la strada che sceglie il calcolo del percorso. Dove non c’è un’altra via, il percorso passa comunque di lì. Non dicono nulla sulla difficoltà di un sentiero; il limite di difficoltà sopra stabilisce cosa può usare il percorso.',
|
||||
'tours.preferences.avoid_steps': 'Evita le scale',
|
||||
'tours.preferences.avoid_stepsHint': 'Scale solo dove non c’è modo di aggirarle.',
|
||||
'tours.preferences.avoid_ferries': 'Evita i traghetti',
|
||||
'tours.preferences.avoid_ferriesHint': 'Via terra dove c’è un modo di aggirare, anche se più lungo.',
|
||||
'tours.preferences.prefer_paths': 'Preferisci i sentieri',
|
||||
'tours.preferences.prefer_pathsHint': 'Sentieri propri invece di strade e marciapiedi, dove ci sono.',
|
||||
'tours.preferences.shortest': 'Più breve invece che più veloce',
|
||||
'tours.preferences.shortestHint':
|
||||
'La strada scelta per lunghezza, non per tempo; spesso un po’ più breve, non sempre più veloce.',
|
||||
'tours.planner.tourType': 'Tipo di tour',
|
||||
'tours.paddle.boat': 'Imbarcazione',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -295,6 +295,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': '旅の場所',
|
||||
'tours.placePicker.linkedHint': 'この地点は旅の場所を表しています。削除やリンク解除をしても場所は変わりません。',
|
||||
'tours.placePicker.unlink': '場所とのリンクを解除',
|
||||
'tours.preferences.title': 'ルートの選び方',
|
||||
'tours.preferences.infoLabel': 'ルートの選び方の働き',
|
||||
'tours.preferences.info':
|
||||
'これらの選択はルート計算が選ぶ道の重み付けです。ほかに道がない場合は、それでもその道を通ります。道の難しさについては何も示しません。ルートが使える道は上の難易度上限で決まります。',
|
||||
'tours.preferences.avoid_steps': '階段を避ける',
|
||||
'tours.preferences.avoid_stepsHint': '回り道がない場所だけ階段を通ります。',
|
||||
'tours.preferences.avoid_ferries': 'フェリーを避ける',
|
||||
'tours.preferences.avoid_ferriesHint': '回り道があれば、長くても陸路を通ります。',
|
||||
'tours.preferences.prefer_paths': '歩道を優先',
|
||||
'tours.preferences.prefer_pathsHint': 'あれば、車道や歩道より独立した道を通ります。',
|
||||
'tours.preferences.shortest': '速さより短さ',
|
||||
'tours.preferences.shortestHint':
|
||||
'所要時間ではなく距離で道を選びます。少し短くなることが多いですが、速いとは限りません。',
|
||||
'tours.planner.tourType': 'ツアーの種類',
|
||||
'tours.paddle.boat': 'ボート',
|
||||
'tours.paddle.boat.kayak': 'カヤック',
|
||||
|
||||
@@ -294,6 +294,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': '여행 장소',
|
||||
'tours.placePicker.linkedHint': '이 정류지는 여행 장소를 나타냅니다. 지우거나 연결을 풀어도 장소는 바뀌지 않습니다.',
|
||||
'tours.placePicker.unlink': '장소와 연결 해제',
|
||||
'tours.preferences.title': '경로 선택',
|
||||
'tours.preferences.infoLabel': '경로 선택이 하는 일',
|
||||
'tours.preferences.info':
|
||||
'이 선택은 경로 계산이 어떤 길을 고를지에 가중치를 줍니다. 다른 길이 없으면 경로는 그래도 그 길로 갑니다. 길의 난이도에 대해서는 아무것도 말하지 않으며, 경로가 쓸 수 있는 길은 위의 난이도 제한이 정합니다.',
|
||||
'tours.preferences.avoid_steps': '계단 피하기',
|
||||
'tours.preferences.avoid_stepsHint': '돌아갈 길이 없을 때만 계단을 지납니다.',
|
||||
'tours.preferences.avoid_ferries': '페리 피하기',
|
||||
'tours.preferences.avoid_ferriesHint': '돌아가는 길이 있으면 더 길어도 육로로 갑니다.',
|
||||
'tours.preferences.prefer_paths': '보행로 우선',
|
||||
'tours.preferences.prefer_pathsHint': '있다면 도로와 인도 대신 별도의 길로 갑니다.',
|
||||
'tours.preferences.shortest': '빠르기보다 짧게',
|
||||
'tours.preferences.shortestHint':
|
||||
'걸리는 시간이 아니라 길이로 길을 고릅니다. 대개 조금 짧지만 항상 빠르지는 않습니다.',
|
||||
'tours.planner.tourType': '투어 유형',
|
||||
'tours.paddle.boat': '보트',
|
||||
'tours.paddle.boat.kayak': '카약',
|
||||
|
||||
@@ -311,6 +311,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Deze stop staat voor een plek van de reis. Verwijderen of de koppeling opheffen laat de plek zoals ze is.',
|
||||
'tours.placePicker.unlink': 'Loskoppelen van de plek',
|
||||
'tours.preferences.title': 'Routekeuze',
|
||||
'tours.preferences.infoLabel': 'Wat de routekeuze doet',
|
||||
'tours.preferences.info':
|
||||
'Deze keuzes wegen welke weg de routeplanner kiest. Waar geen andere weg is, gaat de route er toch langs. Ze zeggen niets over hoe moeilijk een pad is; de moeilijkheidsgrens hierboven bepaalt wat de route mag gebruiken.',
|
||||
'tours.preferences.avoid_steps': 'Trappen vermijden',
|
||||
'tours.preferences.avoid_stepsHint': 'Trappen alleen waar er geen weg omheen is.',
|
||||
'tours.preferences.avoid_ferries': 'Veerponten vermijden',
|
||||
'tours.preferences.avoid_ferriesHint': 'Over land waar er een weg omheen is, ook als die langer is.',
|
||||
'tours.preferences.prefer_paths': 'Voetpaden voorkeur geven',
|
||||
'tours.preferences.prefer_pathsHint': 'Eigen paden in plaats van wegen en stoepen, waar ze er zijn.',
|
||||
'tours.preferences.shortest': 'Korter in plaats van sneller',
|
||||
'tours.preferences.shortestHint': 'De weg gekozen op lengte, niet op tijd; vaak iets korter, niet altijd sneller.',
|
||||
'tours.planner.tourType': 'Soort tocht',
|
||||
'tours.paddle.boat': 'Boot',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -317,6 +317,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Ten przystanek oznacza miejsce podróży. Usunięcie go lub rozłączenie nie zmienia miejsca.',
|
||||
'tours.placePicker.unlink': 'Odłącz od miejsca',
|
||||
'tours.preferences.title': 'Wybór trasy',
|
||||
'tours.preferences.infoLabel': 'Co robi wybór trasy',
|
||||
'tours.preferences.info':
|
||||
'Te ustawienia ważą, którą drogę wybiera wyznaczanie trasy. Gdzie nie ma innej drogi, trasa i tak tamtędy prowadzi. Nie mówią nic o trudności ścieżki; to, czego trasa może użyć, ustala limit trudności powyżej.',
|
||||
'tours.preferences.avoid_steps': 'Unikaj schodów',
|
||||
'tours.preferences.avoid_stepsHint': 'Schody tylko tam, gdzie nie da się ich obejść.',
|
||||
'tours.preferences.avoid_ferries': 'Unikaj promów',
|
||||
'tours.preferences.avoid_ferriesHint': 'Lądem, gdzie da się obejść, nawet jeśli dłużej.',
|
||||
'tours.preferences.prefer_paths': 'Preferuj ścieżki piesze',
|
||||
'tours.preferences.prefer_pathsHint': 'Osobne ścieżki zamiast dróg i chodników, gdzie są.',
|
||||
'tours.preferences.shortest': 'Krócej zamiast szybciej',
|
||||
'tours.preferences.shortestHint':
|
||||
'Droga wybrana według długości, nie czasu; często trochę krótsza, nie zawsze szybsza.',
|
||||
'tours.planner.tourType': 'Rodzaj wycieczki',
|
||||
'tours.paddle.boat': 'Łódź',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -319,6 +319,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': 'Место поездки',
|
||||
'tours.placePicker.linkedHint': 'Эта остановка обозначает место поездки. Удаление или отвязка не меняют само место.',
|
||||
'tours.placePicker.unlink': 'Отвязать от места',
|
||||
'tours.preferences.title': 'Выбор маршрута',
|
||||
'tours.preferences.infoLabel': 'Что делает выбор маршрута',
|
||||
'tours.preferences.info':
|
||||
'Эти настройки влияют на то, какой путь выбирает маршрутизатор. Где другого пути нет, маршрут всё равно идёт там. О сложности тропы они ничего не говорят; что может использовать маршрут, задаёт предел сложности выше.',
|
||||
'tours.preferences.avoid_steps': 'Избегать лестниц',
|
||||
'tours.preferences.avoid_stepsHint': 'Лестницы только там, где их не обойти.',
|
||||
'tours.preferences.avoid_ferries': 'Избегать паромов',
|
||||
'tours.preferences.avoid_ferriesHint': 'По суше, где есть обход, даже если он длиннее.',
|
||||
'tours.preferences.prefer_paths': 'Предпочитать пешеходные тропы',
|
||||
'tours.preferences.prefer_pathsHint': 'Отдельные тропы вместо дорог и тротуаров, где они есть.',
|
||||
'tours.preferences.shortest': 'Короче, а не быстрее',
|
||||
'tours.preferences.shortestHint':
|
||||
'Путь выбирается по длине, а не по времени; часто чуть короче, но не всегда быстрее.',
|
||||
'tours.planner.tourType': 'Тип тура',
|
||||
'tours.paddle.boat': 'Лодка',
|
||||
'tours.paddle.boat.kayak': 'Каяк',
|
||||
|
||||
@@ -305,6 +305,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Táto zastávka predstavuje miesto cesty. Odstránenie alebo zrušenie prepojenia miesto nezmení.',
|
||||
'tours.placePicker.unlink': 'Odpojiť od miesta',
|
||||
'tours.preferences.title': 'Voľba trasy',
|
||||
'tours.preferences.infoLabel': 'Čo robí voľba trasy',
|
||||
'tours.preferences.info':
|
||||
'Táto voľba ovplyvňuje, ktorú cestu plánovač vyberie. Kde iná cesta nie je, vedie trasa tadiaľ aj tak. O náročnosti cesty nehovorí nič; čo smie trasa použiť, určuje limit náročnosti vyššie.',
|
||||
'tours.preferences.avoid_steps': 'Vyhnúť sa schodom',
|
||||
'tours.preferences.avoid_stepsHint': 'Schody len tam, kde nie je cesta okolo.',
|
||||
'tours.preferences.avoid_ferries': 'Vyhnúť sa prievozom',
|
||||
'tours.preferences.avoid_ferriesHint': 'Po súši, kde vedie cesta okolo, aj keď je dlhšia.',
|
||||
'tours.preferences.prefer_paths': 'Uprednostniť chodníky',
|
||||
'tours.preferences.prefer_pathsHint': 'Samostatné cesty namiesto ciest a chodníkov, kde sú.',
|
||||
'tours.preferences.shortest': 'Kratšie namiesto rýchlejšie',
|
||||
'tours.preferences.shortestHint':
|
||||
'Cesta vybraná podľa dĺžky, nie podľa času; často o niečo kratšia, nie vždy rýchlejšia.',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
'tours.paddle.boat': 'Loď',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -302,6 +302,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Det här stoppet står för en plats på resan. Att ta bort det eller lösa kopplingen ändrar inte platsen.',
|
||||
'tours.placePicker.unlink': 'Lossa från platsen',
|
||||
'tours.preferences.title': 'Ruttval',
|
||||
'tours.preferences.infoLabel': 'Vad ruttvalet gör',
|
||||
'tours.preferences.info':
|
||||
'De här valen väger vilken väg ruttplaneraren väljer. Där det inte finns någon annan väg går rutten ändå där. De säger inget om hur svår en stig är; svårighetsgränsen ovanför bestämmer vad rutten får använda.',
|
||||
'tours.preferences.avoid_steps': 'Undvik trappor',
|
||||
'tours.preferences.avoid_stepsHint': 'Trappor bara där det inte finns någon väg runt.',
|
||||
'tours.preferences.avoid_ferries': 'Undvik färjor',
|
||||
'tours.preferences.avoid_ferriesHint': 'Över land där det finns en väg runt, även om den är längre.',
|
||||
'tours.preferences.prefer_paths': 'Föredra gångvägar',
|
||||
'tours.preferences.prefer_pathsHint': 'Egna gångvägar i stället för vägar och trottoarer, där de finns.',
|
||||
'tours.preferences.shortest': 'Kortare i stället för snabbare',
|
||||
'tours.preferences.shortestHint': 'Vägen väljs efter längd, inte efter tid; ofta lite kortare, inte alltid snabbare.',
|
||||
'tours.planner.tourType': 'Turtyp',
|
||||
'tours.paddle.boat': 'Båt',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -290,6 +290,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': 'สถานที่ในทริป',
|
||||
'tours.placePicker.linkedHint': 'จุดแวะนี้แทนสถานที่หนึ่งในทริป การลบหรือยกเลิกการเชื่อมโยงจะไม่เปลี่ยนสถานที่นั้น',
|
||||
'tours.placePicker.unlink': 'ยกเลิกการเชื่อมกับสถานที่',
|
||||
'tours.preferences.title': 'การเลือกเส้นทาง',
|
||||
'tours.preferences.infoLabel': 'การเลือกเส้นทางทำอะไร',
|
||||
'tours.preferences.info':
|
||||
'ตัวเลือกเหล่านี้ให้น้ำหนักกับทางที่ระบบเลือก หากไม่มีทางอื่น เส้นทางก็ยังผ่านตรงนั้น ตัวเลือกไม่ได้บอกความยากของทาง ขีดจำกัดความยากด้านบนกำหนดว่าเส้นทางใช้ทางใดได้',
|
||||
'tours.preferences.avoid_steps': 'หลีกเลี่ยงบันได',
|
||||
'tours.preferences.avoid_stepsHint': 'ผ่านบันไดเฉพาะเมื่อไม่มีทางอ้อม',
|
||||
'tours.preferences.avoid_ferries': 'หลีกเลี่ยงเรือข้ามฟาก',
|
||||
'tours.preferences.avoid_ferriesHint': 'ไปทางบกเมื่อมีทางอ้อม แม้จะไกลกว่า',
|
||||
'tours.preferences.prefer_paths': 'เน้นทางเดินเท้า',
|
||||
'tours.preferences.prefer_pathsHint': 'ใช้ทางของตัวเองแทนถนนและทางเท้าข้างถนนเมื่อมี',
|
||||
'tours.preferences.shortest': 'สั้นกว่าแทนเร็วกว่า',
|
||||
'tours.preferences.shortestHint': 'เลือกทางตามระยะทาง ไม่ใช่เวลา มักสั้นกว่าเล็กน้อย แต่ไม่ได้เร็วกว่าเสมอ',
|
||||
'tours.planner.tourType': 'ประเภททริป',
|
||||
'tours.paddle.boat': 'เรือ',
|
||||
'tours.paddle.boat.kayak': 'เรือคายัก',
|
||||
|
||||
@@ -304,6 +304,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Bu durak gezinin bir yerini temsil eder. Kaldırmak ya da bağlantıyı çözmek yeri değiştirmez.',
|
||||
'tours.placePicker.unlink': 'Yerden ayır',
|
||||
'tours.preferences.title': 'Rota seçimi',
|
||||
'tours.preferences.infoLabel': 'Rota seçimi ne yapar',
|
||||
'tours.preferences.info':
|
||||
'Bu seçimler rota planlayıcının hangi yolu seçeceğini ağırlıklandırır. Başka yol yoksa rota yine oradan geçer. Bir patikanın zorluğu hakkında bir şey söylemez; rotanın neyi kullanabileceğini yukarıdaki zorluk sınırı belirler.',
|
||||
'tours.preferences.avoid_steps': 'Merdivenlerden kaçın',
|
||||
'tours.preferences.avoid_stepsHint': 'Merdiven yalnızca dolaşacak yol yoksa.',
|
||||
'tours.preferences.avoid_ferries': 'Feribotlardan kaçın',
|
||||
'tours.preferences.avoid_ferriesHint': 'Dolaşacak yol varsa daha uzun olsa da karadan.',
|
||||
'tours.preferences.prefer_paths': 'Yaya yollarını tercih et',
|
||||
'tours.preferences.prefer_pathsHint': 'Varsa yollar ve kaldırımlar yerine ayrı patikalar.',
|
||||
'tours.preferences.shortest': 'Hızlı yerine kısa',
|
||||
'tours.preferences.shortestHint':
|
||||
'Yol süreye göre değil uzunluğa göre seçilir; çoğu zaman biraz daha kısa, her zaman daha hızlı değil.',
|
||||
'tours.planner.tourType': 'Tur türü',
|
||||
'tours.paddle.boat': 'Tekne',
|
||||
'tours.paddle.boat.kayak': 'Kano (kayak)',
|
||||
|
||||
@@ -317,6 +317,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Ця зупинка позначає місце подорожі. Видалення чи відв’язування не змінює саме місце.',
|
||||
'tours.placePicker.unlink': 'Відв’язати від місця',
|
||||
'tours.preferences.title': 'Вибір маршруту',
|
||||
'tours.preferences.infoLabel': 'Що робить вибір маршруту',
|
||||
'tours.preferences.info':
|
||||
'Ці налаштування впливають на те, який шлях обирає маршрутизатор. Де іншого шляху немає, маршрут однаково йде там. Про складність стежки вони нічого не кажуть; що може використовувати маршрут, визначає межа складності вище.',
|
||||
'tours.preferences.avoid_steps': 'Уникати сходів',
|
||||
'tours.preferences.avoid_stepsHint': 'Сходи лише там, де їх не обійти.',
|
||||
'tours.preferences.avoid_ferries': 'Уникати поромів',
|
||||
'tours.preferences.avoid_ferriesHint': 'Суходолом, де є обхід, навіть якщо він довший.',
|
||||
'tours.preferences.prefer_paths': 'Віддавати перевагу пішохідним стежкам',
|
||||
'tours.preferences.prefer_pathsHint': 'Окремі стежки замість доріг і тротуарів, де вони є.',
|
||||
'tours.preferences.shortest': 'Коротше, а не швидше',
|
||||
'tours.preferences.shortestHint':
|
||||
'Шлях обирається за довжиною, а не за часом; часто трохи коротший, не завжди швидший.',
|
||||
'tours.planner.tourType': 'Тип туру',
|
||||
'tours.paddle.boat': 'Човен',
|
||||
'tours.paddle.boat.kayak': 'Каяк',
|
||||
|
||||
@@ -304,6 +304,19 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linkedHint':
|
||||
'Điểm dừng này đại diện cho một địa điểm của chuyến đi. Xóa hoặc bỏ liên kết không làm thay đổi địa điểm.',
|
||||
'tours.placePicker.unlink': 'Bỏ liên kết với địa điểm',
|
||||
'tours.preferences.title': 'Chọn tuyến đường',
|
||||
'tours.preferences.infoLabel': 'Chọn tuyến đường làm gì',
|
||||
'tours.preferences.info':
|
||||
'Các lựa chọn này điều chỉnh con đường bộ định tuyến chọn. Nơi không có đường khác, tuyến vẫn đi qua đó. Chúng không nói gì về độ khó của đường; giới hạn độ khó phía trên quyết định tuyến được dùng những gì.',
|
||||
'tours.preferences.avoid_steps': 'Tránh bậc thang',
|
||||
'tours.preferences.avoid_stepsHint': 'Chỉ đi bậc thang khi không có đường vòng.',
|
||||
'tours.preferences.avoid_ferries': 'Tránh phà',
|
||||
'tours.preferences.avoid_ferriesHint': 'Đi đường bộ nơi có đường vòng, kể cả khi dài hơn.',
|
||||
'tours.preferences.prefer_paths': 'Ưu tiên lối đi bộ',
|
||||
'tours.preferences.prefer_pathsHint': 'Lối riêng thay vì đường xe và vỉa hè, nơi có.',
|
||||
'tours.preferences.shortest': 'Ngắn hơn thay vì nhanh hơn',
|
||||
'tours.preferences.shortestHint':
|
||||
'Chọn đường theo độ dài chứ không theo thời gian; thường ngắn hơn một chút, không phải lúc nào cũng nhanh hơn.',
|
||||
'tours.planner.tourType': 'Loại chuyến',
|
||||
'tours.paddle.boat': 'Thuyền',
|
||||
'tours.paddle.boat.kayak': 'Thuyền kayak',
|
||||
|
||||
@@ -278,6 +278,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': '行程地點',
|
||||
'tours.placePicker.linkedHint': '此站點代表行程中的一個地點。移除或解除連結不會改變該地點。',
|
||||
'tours.placePicker.unlink': '解除與地點的連結',
|
||||
'tours.preferences.title': '路線偏好',
|
||||
'tours.preferences.infoLabel': '路線偏好的作用',
|
||||
'tours.preferences.info':
|
||||
'這些選項會影響路線規劃選擇哪條路。沒有其他路時,路線仍會經過那裡。它們不表示道路的難度;路線可以使用哪些路,由上方的難度上限決定。',
|
||||
'tours.preferences.avoid_steps': '避開階梯',
|
||||
'tours.preferences.avoid_stepsHint': '只在無法繞行時經過階梯。',
|
||||
'tours.preferences.avoid_ferries': '避開渡輪',
|
||||
'tours.preferences.avoid_ferriesHint': '有繞行陸路時走陸路,即使更遠。',
|
||||
'tours.preferences.prefer_paths': '優先步道',
|
||||
'tours.preferences.prefer_pathsHint': '有獨立小路時,優先於道路與人行道。',
|
||||
'tours.preferences.shortest': '更短而非更快',
|
||||
'tours.preferences.shortestHint': '依長度而非用時選擇道路;通常略短,但不一定更快。',
|
||||
'tours.planner.tourType': '路線類型',
|
||||
'tours.paddle.boat': '船隻',
|
||||
'tours.paddle.boat.kayak': '獨木舟(Kayak)',
|
||||
|
||||
@@ -279,6 +279,18 @@ const tours: TranslationStrings = {
|
||||
'tours.placePicker.linked': '行程地点',
|
||||
'tours.placePicker.linkedHint': '此站点代表行程中的一个地点。移除或解除关联不会改变该地点。',
|
||||
'tours.placePicker.unlink': '解除与地点的关联',
|
||||
'tours.preferences.title': '路线偏好',
|
||||
'tours.preferences.infoLabel': '路线偏好的作用',
|
||||
'tours.preferences.info':
|
||||
'这些选项会影响路线规划选择哪条路。没有其他路时,路线仍会经过那里。它们不表示道路的难度;路线可以使用哪些路,由上方的难度上限决定。',
|
||||
'tours.preferences.avoid_steps': '避开台阶',
|
||||
'tours.preferences.avoid_stepsHint': '只在无法绕行时经过台阶。',
|
||||
'tours.preferences.avoid_ferries': '避开渡轮',
|
||||
'tours.preferences.avoid_ferriesHint': '有绕行陆路时走陆路,即使更远。',
|
||||
'tours.preferences.prefer_paths': '优先步道',
|
||||
'tours.preferences.prefer_pathsHint': '有独立小路时,优先于道路和人行道。',
|
||||
'tours.preferences.shortest': '更短而非更快',
|
||||
'tours.preferences.shortestHint': '按长度而非用时选择道路;通常略短,但不一定更快。',
|
||||
'tours.planner.tourType': '路线类型',
|
||||
'tours.paddle.boat': '船只',
|
||||
'tours.paddle.boat.kayak': '皮划艇',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
hikingPreferencesOf,
|
||||
MAX_PADDLE_SPEED_KMH,
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
recommendedPaddleSpeedKmh,
|
||||
@@ -302,3 +303,42 @@ describe('Tours paddle type', () => {
|
||||
expect(tourListItemSchema.parse(cached).boat_type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hiking preferences (MF-08)', () => {
|
||||
const hike = {
|
||||
name: 'Ridge',
|
||||
route_geometry: [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 650],
|
||||
],
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
it('keeps only the preferences switched on, and none as null', () => {
|
||||
expect(hikingPreferencesOf({ avoid_steps: true, shortest: false })).toEqual({ avoid_steps: true });
|
||||
expect(hikingPreferencesOf({ avoid_steps: false })).toBeNull();
|
||||
expect(hikingPreferencesOf(null)).toBeNull();
|
||||
const parsed = tourCreateRequestSchema.parse({
|
||||
...hike,
|
||||
hiking_preferences: { prefer_paths: true, avoid_ferries: false },
|
||||
});
|
||||
expect(hikingPreferencesOf(parsed.hiking_preferences)).toEqual({ prefer_paths: true });
|
||||
expect(tourCreateRequestSchema.parse(hike).hiking_preferences).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a preference it does not know, and any on a paddle tour', () => {
|
||||
expect(tourCreateRequestSchema.safeParse({ ...hike, hiking_preferences: { avoid_hills: true } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
const paddle = { ...hike, tour_type: 'kayak', boat_type: 'kayak', water_context: 'lake' };
|
||||
expect(tourCreateRequestSchema.safeParse({ ...paddle, hiking_preferences: { avoid_steps: true } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(tourCreateRequestSchema.safeParse({ ...paddle, hiking_preferences: { avoid_steps: false } }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,35 @@ export function recommendedPaddleSpeedKmh(boat: PaddleBoatType | null | undefine
|
||||
export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6);
|
||||
export type TourMaxHikingDifficulty = z.infer<typeof tourMaxHikingDifficultySchema>;
|
||||
|
||||
/**
|
||||
* MF-08: what a hiker asks the router for besides the difficulty limit. Only
|
||||
* preferences whose effect on the router was measured are offered; each is a
|
||||
* weighting that gives way where there is no other path, never a ban, and
|
||||
* none says anything about how hard the path is. A hike without any keeps
|
||||
* null, which is what every tour saved before had.
|
||||
*/
|
||||
export const HIKING_PREFERENCE_KEYS = ['avoid_steps', 'avoid_ferries', 'prefer_paths', 'shortest'] as const;
|
||||
export type HikingPreferenceKey = (typeof HIKING_PREFERENCE_KEYS)[number];
|
||||
export const hikingPreferencesSchema = z
|
||||
.object({
|
||||
/** Steps and stairs only where there is no way round. */
|
||||
avoid_steps: z.boolean().optional(),
|
||||
/** A ferry only where there is no way round over land. */
|
||||
avoid_ferries: z.boolean().optional(),
|
||||
/** Footpaths of their own over roads and their pavements, where there are some. */
|
||||
prefer_paths: z.boolean().optional(),
|
||||
/** The way chosen by its length, not by the time it takes. */
|
||||
shortest: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type HikingPreferences = z.infer<typeof hikingPreferencesSchema>;
|
||||
|
||||
/** The preferences a hike keeps: only those switched on, null when none is. */
|
||||
export function hikingPreferencesOf(value: HikingPreferences | null | undefined): HikingPreferences | null {
|
||||
const kept = HIKING_PREFERENCE_KEYS.filter((key) => value?.[key] === true);
|
||||
return kept.length === 0 ? null : Object.fromEntries(kept.map((key) => [key, true]));
|
||||
}
|
||||
|
||||
/** Tours are single-day activities, so a planned total is bounded to 24 hours. */
|
||||
export const MAX_PLANNED_TOUR_DURATION_MINUTES = 24 * 60;
|
||||
export const plannedTourDurationMinutesSchema = z
|
||||
@@ -118,6 +147,8 @@ export const tourSchema = z.object({
|
||||
match_confidence: z.number().min(0).max(1).nullable(),
|
||||
// NULL for a GPX import: the track carries no difficulty. A drawn tour always has one.
|
||||
max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable(),
|
||||
/** MF-08: what a hike asks the router for besides its limit; null for none and for a paddle tour. Absent in a list cached before it existed. */
|
||||
hiking_preferences: hikingPreferencesSchema.nullable().optional(),
|
||||
/** The import (a GPX file, a wanderer snapshot) this tour was planned from, while it is still in the trip. */
|
||||
derived_from_place_id: z.number().int().positive().nullable().optional(),
|
||||
/** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */
|
||||
@@ -294,6 +325,8 @@ export const tourCreateRequestSchema = z
|
||||
waypoints: z.array(tourWaypointSchema).min(2),
|
||||
/** A hike's routing limit (2 when left out); a paddle tour has none. */
|
||||
max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable().optional(),
|
||||
/** MF-08, a hike's only: what it asks the router for besides its limit. */
|
||||
hiking_preferences: hikingPreferencesSchema.nullable().optional(),
|
||||
boat_type: paddleBoatTypeSchema.nullable().optional(),
|
||||
water_context: paddleWaterContextSchema.nullable().optional(),
|
||||
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
|
||||
@@ -373,6 +406,7 @@ export type TourCreateInput = z.input<typeof tourCreateRequestSchema>;
|
||||
function tourTypeIssues(value: {
|
||||
tour_type: PlannableTourType;
|
||||
max_hiking_difficulty?: number | null;
|
||||
hiking_preferences?: HikingPreferences | null;
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
paddle_speed_kmh?: number | null;
|
||||
@@ -387,6 +421,8 @@ function tourTypeIssues(value: {
|
||||
issues.push({ path: ['water_context'], message: 'a paddle tour needs a water context' });
|
||||
if (value.max_hiking_difficulty != null)
|
||||
issues.push({ path: ['max_hiking_difficulty'], message: 'a paddle tour has no hiking difficulty' });
|
||||
if (hikingPreferencesOf(value.hiking_preferences) !== null)
|
||||
issues.push({ path: ['hiking_preferences'], message: 'a paddle tour has no hiking preferences' });
|
||||
return issues;
|
||||
}
|
||||
if (value.max_hiking_difficulty === null)
|
||||
|
||||
@@ -237,7 +237,7 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show
|
||||
|
||||
| Tool | Description | Scope |
|
||||
|---|---|---|
|
||||
| `list_tours` | The saved tours of a trip: name, type (`hike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale, the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
|
||||
| `list_tours` | The saved tours of a trip: name, type (`hike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
|
||||
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` |
|
||||
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
|
||||
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
|
||||
|
||||
Reference in New Issue
Block a user