feat(tours): via ferratas on the tour as fixed legs of their own line, crossing warnings, violet ferrata look, tour list and editor polish

This commit is contained in:
Maurice
2026-10-14 19:10:57 +02:00
parent 77c4785a9d
commit 84d637f5fe
97 changed files with 1891 additions and 283 deletions
@@ -1,4 +1,4 @@
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-007: the card for one picked via ferrata.
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-009: the card for one picked via ferrata.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
@@ -95,4 +95,21 @@ describe('FerrataDetailCard', () => {
expect(heading).toHaveClass('line-clamp-2');
expect(heading).not.toHaveClass('truncate');
});
it('FE-FERRATA-CARD-008: offers "Add to tour" only where the tour can be edited, and hands over the ferrata', () => {
const group = groupOf(LOWER, UPPER);
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
const onAdopt = vi.fn();
rerender(<FerrataDetailCard group={group} segment={UPPER} onAdopt={onAdopt} />);
fireEvent.click(screen.getByTestId('ferrata-adopt'));
expect(onAdopt).toHaveBeenCalledWith(group);
});
it('FE-FERRATA-CARD-009: a ferrata the tour already climbs says so instead of offering it again', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} onAdopt={vi.fn()} onTour />);
expect(screen.getByTestId('ferrata-on-tour')).toHaveTextContent('Part of the tour');
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { ExternalLink, MoveHorizontal, Route, X } from 'lucide-react';
import { Check, ExternalLink, MapPinPlus, MoveHorizontal, Route, X } from 'lucide-react';
import type { Ferrata } from '@trek/shared';
@@ -31,11 +31,17 @@ export default function FerrataDetailCard({
group,
segment,
onClose,
onAdopt,
onTour = false,
width,
}: {
group: FerrataGroup;
segment: Ferrata;
onClose?: () => void;
/** Puts the ferrata onto the tour being planned; absent where the tour cannot be edited. */
onAdopt?: (group: FerrataGroup) => void;
/** The tour on the map already climbs this ferrata: the card says so instead of offering it again. */
onTour?: boolean;
/** Set when the card stands on another control and takes its width. */
width?: number;
}) {
@@ -81,6 +87,26 @@ export default function FerrataDetailCard({
</SoftPill>
)}
</div>
{onTour ? (
<div className="mt-3 flex" data-testid="ferrata-on-tour">
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
{t('ferratas.onTour')}
</SoftPill>
</div>
) : (
onAdopt && (
<button
type="button"
onClick={() => onAdopt(group)}
data-testid="ferrata-adopt"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-full bg-accent px-3 py-1.5 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(12, 'body')}
>
<MapPinPlus size={13} strokeWidth={2.2} aria-hidden="true" />
{t('ferratas.adopt')}
</button>
)
)}
<a
href={ferrataOsmUrl(segment)}
target="_blank"
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import FerrataDetailCard from './FerrataDetailCard';
import type { FerrataGroup } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
/**
@@ -23,6 +24,8 @@ export default function FerrataDetailOverlay({
insetEnd,
bottom,
width,
onAdopt,
onTourIds,
}: {
selection: FerrataSelection | null;
onClose: () => void;
@@ -31,6 +34,9 @@ export default function FerrataDetailOverlay({
bottom: number;
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
width?: number;
onAdopt?: (group: FerrataGroup) => void;
/** The ferratas the tour on the map already climbs. */
onTourIds?: ReadonlySet<string>;
}) {
const ref = useRef<HTMLDivElement>(null);
const open = !!selection;
@@ -62,7 +68,14 @@ export default function FerrataDetailOverlay({
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="ferrata-detail-overlay"
>
<FerrataDetailCard group={selection.group} segment={selection.segment} onClose={onClose} width={width} />
<FerrataDetailCard
group={selection.group}
segment={selection.segment}
onClose={onClose}
onAdopt={onAdopt}
onTour={onTourIds?.has(selection.group.id) ?? false}
width={width}
/>
</div>
);
}
@@ -0,0 +1,42 @@
/**
* The via ferrata mark as a React element: the ladder on a disc that the map
* draws at a ferrata's midpoint, in the same `--map-ferrata` tokens, for the
* places beside the map that point at one (a waypoint at its foot or top).
*/
export function FerrataGlyph({ size = 14, className = '' }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 22 22" aria-hidden="true" className={className}>
<circle cx="11" cy="11" r="9.75" fill="var(--map-ferrata-casing)" stroke="var(--map-ferrata)" strokeWidth="1.5" />
<path
d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6"
fill="none"
stroke="var(--map-ferrata)"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/**
* The same ladder as a line icon, drawn the way lucide draws its icons (24 grid,
* round caps, the current colour), for controls that sit among lucide icons:
* the via ferrata switch in the map's layer pill.
*/
export function FerrataLineIcon({ size = 15, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M8 3v18M16 3v18M8 7h8M8 12h8M8 17h8" />
</svg>
);
}
@@ -121,8 +121,8 @@ describe('FerrataLayerLeaflet', () => {
it('FE-FERRATA-LEAFLET-002: a light casing, the dark dashed line, then the invisible hit line, in [lat, lng]', () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--bg-primary', 'white');
root.style.setProperty('--map-ferrata', 'black');
root.style.setProperty('--map-ferrata-casing', 'white');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines().map((line) => line.dataset.weight)).toEqual(['6', '2.5', '16']);
@@ -134,8 +134,8 @@ describe('FerrataLayerLeaflet', () => {
[46.5, 11.7],
[46.51, 11.71],
]);
root.style.removeProperty('--text-primary');
root.style.removeProperty('--bg-primary');
root.style.removeProperty('--map-ferrata');
root.style.removeProperty('--map-ferrata-casing');
});
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
@@ -178,9 +178,9 @@ describe('FerrataLayerLeaflet', () => {
expect(tip.querySelector('span')?.textContent).toBe('K4');
});
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the accent, and so does the open card', () => {
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the deeper violet, and so does the open card', () => {
const root = document.documentElement;
root.style.setProperty('--accent', 'navy');
root.style.setProperty('--map-ferrata-lit', 'navy');
const groups = groupFerratas([
ferrata('way/1', 'Via A', 2),
ferrata('way/2', 'Via A', 3, { lat: 46.51 }),
@@ -203,7 +203,7 @@ describe('FerrataLayerLeaflet', () => {
rerender(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[1]?.id ?? null} />);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([false, false, true]);
root.style.removeProperty('--accent');
root.style.removeProperty('--map-ferrata-lit');
});
it('FE-FERRATA-LEAFLET-008: shows a route icon halfway along each ferrata only once zoomed in, and it opens the card', () => {
@@ -226,17 +226,17 @@ describe('FerrataLayerLeaflet', () => {
it('FE-FERRATA-LEAFLET-009: a theme change recolours the lines without a refetch', async () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--map-ferrata', 'black');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[1]?.dataset.color).toBe('black');
await act(async () => {
root.style.setProperty('--text-primary', 'ivory');
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(lines()[1]?.dataset.color).toBe('ivory'));
root.style.removeProperty('--text-primary');
root.style.removeProperty('--map-ferrata');
});
it('FE-FERRATA-LEAFLET-010: a lit ferrata is drawn solid and wider on a wider casing, the hit line and tooltip carry their classes', () => {
const groups = groupFerratas([ferrata('way/1', 'Via A', 2)]);
@@ -19,7 +19,7 @@ import { useFerrataPaint } from './useFerrataPaint';
const FERRATA_HIT_CLASS = 'trek-ferrata-hit';
const ICON = L.divIcon({
html: ferrataIconSvg({ line: 'var(--text-primary)', casing: 'var(--bg-primary)' }),
html: ferrataIconSvg({ line: 'var(--map-ferrata)', casing: 'var(--map-ferrata-casing)' }),
className: 'trek-ferrata-icon',
iconSize: [FERRATA_ICON_SIZE, FERRATA_ICON_SIZE],
iconAnchor: [FERRATA_ICON_SIZE / 2, FERRATA_ICON_SIZE / 2],
@@ -0,0 +1,123 @@
// FE-FERRATA-CROSS-001 to FE-FERRATA-CROSS-009: where a tour's line climbs a via ferrata, and the tiles along it.
import { describe, expect, it } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { crossingLegs, ferrataEnds, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
import { groupFerratas } from './ferrataGroups';
/** About 11 m of latitude. */
const STEP = 0.0001;
function ferrata(id: number, from: [number, number], steps: number): Ferrata {
return {
id: `way/${id}`,
name: 'Via Test',
routeName: null,
grade: { raw: '3', level: 3, plus: null },
geometry: Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]] as [number, number]),
osmUrl: `https://www.openstreetmap.org/way/${id}`,
};
}
/** A line north from a point, one point every STEP. */
function northFrom(from: [number, number], steps: number): [number, number][] {
return Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]]);
}
describe('routeFerrataCrossings', () => {
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 20)]);
it('FE-FERRATA-CROSS-001: a route climbing along the ferrata gives one stretch, measured along the route', () => {
const route = [...northFrom([47.399, 11.0], 9), ...northFrom([47.4, 11.0], 20)];
const [stretch, ...rest] = routeFerrataCrossings(route, groups);
expect(rest).toEqual([]);
expect(stretch.group.id).toBe(groups[0].id);
expect(stretch.fromM).toBeGreaterThan(95);
expect(stretch.toM - stretch.fromM).toBeGreaterThan(200);
});
it('FE-FERRATA-CROSS-002: a trail that only crosses the ferrata is no stretch', () => {
const across: [number, number][] = Array.from({ length: 21 }, (_, i) => [47.401, 10.999 + i * STEP]);
expect(routeFerrataCrossings(across, groups)).toEqual([]);
});
it('FE-FERRATA-CROSS-003: a route nowhere near a ferrata, or no route, gives nothing', () => {
expect(routeFerrataCrossings(northFrom([46.0, 10.0], 20), groups)).toEqual([]);
expect(routeFerrataCrossings(null, groups)).toEqual([]);
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 20), [])).toEqual([]);
});
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
const route = northFrom([47.4, 11.0], 20);
// The route steps 25 m aside for two points, still within the join gap.
route[10] = [route[10][0], 11.0004];
route[11] = [route[11][0], 11.0004];
expect(routeFerrataCrossings(route, groups)).toHaveLength(1);
});
});
describe('routeFerrataCrossings at the end of a ferrata', () => {
it('FE-FERRATA-CROSS-007: a route that only runs beside the end of a long ferrata is no stretch', () => {
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 40)]);
// Ten points (about 100 m) along the ferrata's top, then away.
const route = [...northFrom([47.4033, 11.0], 9), ...northFrom([47.405, 11.0], 10)];
expect(routeFerrataCrossings(route, groups)).toEqual([]);
});
});
describe('routeFerrataCrossings beside a second ferrata', () => {
it('FE-FERRATA-CROSS-008: where two ferratas run a few metres apart, the route belongs to the nearer one', () => {
const near = ferrata(1, [47.4, 11.0], 30);
// A second ferrata about 15 m to the east, along the same stretch.
const beside = { ...ferrata(2, [47.4, 11.0002], 30), name: 'Via Beside' };
const groups = groupFerratas([beside, near]);
const [stretch, ...rest] = routeFerrataCrossings(northFrom([47.4, 11.0], 30), groups);
expect(rest).toEqual([]);
expect(stretch.group.name).toBe('Via Test');
});
});
describe('crossingLegs', () => {
it('FE-FERRATA-CROSS-009: places waypoints in order along a route that passes the same spot twice, and a stretch by its middle', () => {
// Out 300 m north and back: the turn point is waypoint b, and a and c stand at the same spot.
const samples = [0, 100, 200, 300, 400, 500, 600].map((distanceMeters, i) => ({
distanceMeters,
lat: 47.4 + (i <= 3 ? i : 6 - i) * 0.0009,
lng: 11,
}));
const waypoints = [
{ id: 'a', lat: 47.4, lng: 11 },
{ id: 'b', lat: 47.4027, lng: 11 },
{ id: 'c', lat: 47.4, lng: 11 },
];
expect(
crossingLegs(waypoints, samples, [
{ fromM: 50, toM: 250 },
{ fromM: 280, toM: 520 },
])
).toEqual(['a', 'b']);
expect(crossingLegs([], samples, [{ fromM: 50, toM: 80 }])).toEqual([null]);
});
});
describe('ferrataTilesAlong', () => {
it('FE-FERRATA-CROSS-005: names each grid tile the route passes once, and gives up past the cap', () => {
const tiles = ferrataTilesAlong(northFrom([47.4, 11.0], 20));
expect(tiles).toHaveLength(1);
const long: [number, number][] = Array.from({ length: 400 }, (_, i) => [40 + i * 0.06, 11]);
expect(ferrataTilesAlong(long)).toBeNull();
expect(ferrataTilesAlong(null)).toEqual([]);
});
});
describe('ferrataEnds', () => {
it('FE-FERRATA-CROSS-006: the two ends farthest apart, across the segments of one ferrata', () => {
const [group] = groupFerratas([ferrata(1, [47.4, 11.0], 5), ferrata(2, [47.4005, 11.0], 5)]);
const ends = ferrataEnds(group);
expect(ends).not.toBeNull();
const lats = ends ? ends.map(([lat]) => lat).sort() : [];
expect(lats[0]).toBeCloseTo(47.4, 6);
expect(lats[1]).toBeCloseTo(47.401, 6);
});
});
@@ -0,0 +1,270 @@
import { ferrataTileAt, ferrataTileKey, type FerrataTile } from '@trek/shared';
import type { FerrataGroup } from './ferrataGroups';
import { ferrataPositions } from './ferrataModel';
/**
* Where a tour's line runs along a via ferrata. The router walks a ferrata
* like any trail once the difficulty cap allows alpine routes (T4 and up), so
* a tour can climb one without anyone having picked it; this finds those
* stretches on the line the planner shows, for the warning badge and the band
* on the elevation profile.
*/
export interface FerrataCrossing {
group: FerrataGroup;
/** Where the stretch starts and ends, in metres along the route, as the elevation profile counts them. */
fromM: number;
toM: number;
}
/** How close a route point has to come to a ferrata to count as on it. */
const ON_FERRATA_M = 20;
/**
* A shorter run is a trail crossing the ferrata or meeting it at an end, not a
* climb along it. A ferrata shorter than that counts once most of it is walked.
*/
const MIN_STRETCH_M = 150;
const MIN_SHARE_OF_SHORT_FERRATA = 0.6;
/** Two stretches on the same ferrata this close together are one: a gap in the data, not a way off it. */
const JOIN_GAP_M = 30;
/** The most grid tiles a route asks for, about a thousand kilometres of line. */
const MAX_ROUTE_TILES = 200;
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function distanceM([lat1, lng1]: readonly [number, number], [lat2, lng2]: readonly [number, number]): number {
const dLat = (lat2 - lat1) * RAD;
const dLng = (lng2 - lng1) * RAD;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
/** The distance from a point to a polyline, in metres, on a flat projection around the point (fine at these scales). */
function distanceToLineM(point: readonly [number, number], line: readonly [number, number][]): number {
const metresPerLat = EARTH_RADIUS_M * RAD;
const metresPerLng = metresPerLat * Math.cos(point[0] * RAD);
const project = ([lat, lng]: readonly [number, number]) => [
(lng - point[1]) * metresPerLng,
(lat - point[0]) * metresPerLat,
];
let best = Number.POSITIVE_INFINITY;
for (let i = 1; i < line.length; i += 1) {
const [ax, ay] = project(line[i - 1]);
const [bx, by] = project(line[i]);
const dx = bx - ax;
const dy = by - ay;
const span = dx * dx + dy * dy;
const t = span === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / span));
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
}
if (line.length === 1) {
const [x, y] = project(line[0]);
best = Math.hypot(x, y);
}
return best;
}
/** Whether a point lies inside a group's box widened by the reach. */
function nearBox(point: readonly [number, number], group: FerrataGroup): boolean {
const padLat = ON_FERRATA_M / (EARTH_RADIUS_M * RAD);
const padLng = padLat / Math.max(0.1, Math.cos(point[0] * RAD));
const { south, north, west, east } = group.bbox;
return (
point[0] >= south - padLat && point[0] <= north + padLat && point[1] >= west - padLng && point[1] <= east + padLng
);
}
/** The ferrata a route point is on, if any. */
function ferrataAt(
point: readonly [number, number],
groups: readonly FerrataGroup[],
lines: ReadonlyMap<string, [number, number][][]>
): FerrataGroup | null {
// The nearest one within reach: two ferratas often start a few metres apart at the same foot.
let nearest: FerrataGroup | null = null;
let nearestM = ON_FERRATA_M;
for (const group of groups) {
if (!nearBox(point, group)) continue;
for (const line of lines.get(group.id) ?? []) {
const gap = distanceToLineM(point, line);
if (gap <= nearestM) {
nearestM = gap;
nearest = group;
}
}
}
return nearest;
}
/**
* The stretches of the route that run along a ferrata, in route order. A
* stretch is measured between the route points that lie on the ferrata, so a
* trail that only crosses one or meets its foot stays out.
*/
export function routeFerrataCrossings(
route: readonly (readonly [number, number])[] | null | undefined,
groups: readonly FerrataGroup[]
): FerrataCrossing[] {
if (!route || route.length < 2 || groups.length === 0) return [];
const lines = new Map(groups.map((group) => [group.id, group.segments.map(ferrataPositions)]));
const stretches: FerrataCrossing[] = [];
let along = 0;
let open: FerrataCrossing | null = null;
for (let i = 0; i < route.length; i += 1) {
if (i > 0) along += distanceM(route[i - 1], route[i]);
const group = ferrataAt(route[i], groups, lines);
if (open && group === open.group) {
open.toM = along;
continue;
}
if (open) stretches.push(open);
open = group ? { group, fromM: along, toM: along } : null;
}
if (open) stretches.push(open);
const joined: FerrataCrossing[] = [];
for (const stretch of stretches) {
const last = joined[joined.length - 1];
if (last && last.group === stretch.group && stretch.fromM - last.toM <= JOIN_GAP_M) last.toM = stretch.toM;
else joined.push({ ...stretch });
}
return joined.filter(
(stretch) =>
stretch.toM - stretch.fromM >= Math.min(MIN_STRETCH_M, stretch.group.lengthM * MIN_SHARE_OF_SHORT_FERRATA)
);
}
/** The grid tiles the route runs through, so the ferratas along it can be loaded with the layer off; null past the cap. */
export function ferrataTilesAlong(
route: readonly (readonly [number, number])[] | null | undefined
): FerrataTile[] | null {
if (!route || route.length < 2) return [];
const tiles = new Map<string, FerrataTile>();
for (const [lat, lng] of route) {
const tile = ferrataTileAt(lat, lng);
tiles.set(ferrataTileKey(tile), tile);
if (tiles.size > MAX_ROUTE_TILES) return null;
}
return [...tiles.values()];
}
/** The two ends of a ferrata that lie farthest apart, the start and the top of the climb in either order. */
export function ferrataEnds(group: FerrataGroup): [[number, number], [number, number]] | null {
const ends = group.segments.flatMap((segment) => {
const line = ferrataPositions(segment);
const first = line[0];
const last = line[line.length - 1];
return first && last ? [first, last] : [];
});
let best: [[number, number], [number, number]] | null = null;
let bestM = -1;
for (let i = 0; i < ends.length; i += 1) {
for (let j = i + 1; j < ends.length; j += 1) {
const span = distanceM(ends[i], ends[j]);
if (span > bestM) {
bestM = span;
best = [ends[i], ends[j]];
}
}
}
return best;
}
/** The widest gap two pieces of one ferrata may leave between them and still be walked as one line. */
const CHAIN_GAP_M = 80;
/**
* The ferrata as one line from `from` (one of its ends) to its other end: its
* OSM pieces, which arrive in any order and either direction, put end to end,
* each turned the way the walk goes. Null when the pieces do not join up.
*/
export function ferrataLine(group: FerrataGroup, from: [number, number]): [number, number][] | null {
const pieces = group.segments.map((segment) => ferrataPositions(segment)).filter((line) => line.length >= 2);
if (pieces.length === 0) return null;
const line: [number, number][] = [];
let tail = from;
while (pieces.length > 0) {
let bestIndex = -1;
let bestReversed = false;
let bestGap = Number.POSITIVE_INFINITY;
pieces.forEach((piece, index) => {
const startGap = distanceM(tail, piece[0]);
const endGap = distanceM(tail, piece[piece.length - 1]);
if (startGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, false, startGap];
if (endGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, true, endGap];
});
if (bestGap > CHAIN_GAP_M) return null;
const [piece] = pieces.splice(bestIndex, 1);
const oriented = bestReversed ? [...piece].reverse() : piece;
line.push(...(line.length > 0 ? oriented.slice(1) : oriented));
tail = oriented[oriented.length - 1];
}
return line;
}
/** How close a waypoint has to stand to a ferrata's end to count as at its foot or top. */
const AT_END_M = 40;
/**
* The ferrata each waypoint stands at an end of, by waypoint id, among the
* ferratas the route climbs: the ones "Add to tour" set, or any placed there.
*/
export function waypointsAtFerrataEnds<T>(
waypoints: readonly { id: string; lat: number; lng: number }[],
crossings: readonly FerrataCrossing[],
describe: (group: FerrataGroup) => T
): Record<string, T> {
const marks: Record<string, T> = {};
const groups = [...new Map(crossings.map(({ group }) => [group.id, group])).values()];
for (const point of waypoints) {
const at = groups.find((group) =>
group.segments.some((segment) => {
const line = ferrataPositions(segment);
const first = line[0];
const last = line[line.length - 1];
return [first, last].some((end) => !!end && distanceM([point.lat, point.lng], end) <= AT_END_M);
})
);
if (at) marks[point.id] = describe(at);
}
return marks;
}
/**
* For each stretch, the waypoint it follows on the route: each waypoint is
* placed at the nearest point of the line after the waypoint before it
* (`samples`, metres along it as the profile counts them), and a stretch
* belongs to the leg its middle lies on. Null
* before the first waypoint or without any.
*/
export function crossingLegs(
waypoints: readonly { id: string; lat: number; lng: number }[],
samples: readonly { distanceMeters: number; lat?: number; lng?: number }[],
crossings: readonly Pick<FerrataCrossing, 'fromM' | 'toM'>[]
): Array<string | null> {
// In order along the line: each waypoint is looked for from where the one
// before it stands, so a route that passes a spot twice keeps its legs apart.
let from = 0;
const along = waypoints.map((point) => {
let best = Number.POSITIVE_INFINITY;
let at = from;
for (const sample of samples) {
if (sample.distanceMeters < from || sample.lat == null || sample.lng == null) continue;
const gap = distanceM([point.lat, point.lng], [sample.lat, sample.lng]);
if (gap < best) {
best = gap;
at = sample.distanceMeters;
}
}
from = at;
return { id: point.id, at };
});
return crossings.map((crossing) => {
// By its middle: the trail to a ferrata's foot often runs beside it for a few metres first.
const middle = (crossing.fromM + crossing.toM) / 2;
let after: string | null = null;
for (const point of along) if (point.at <= middle) after = point.id;
return after;
});
}
@@ -1,21 +1,28 @@
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-004: the line colours come from the content, surface and accent tokens.
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-003: the line colours come from the --map-ferrata tokens.
import { afterEach, describe, expect, it } from 'vitest';
import { ferrataPaint, isGreyColour, sameFerrataPaint } from './ferrataPaint';
import { ferrataPaint, sameFerrataPaint } from './ferrataPaint';
const root = document.documentElement;
afterEach(() => {
for (const name of ['--text-primary', '--bg-primary', '--accent', '--info', '--success', '--warning', '--danger'])
for (const name of [
'--map-ferrata',
'--map-ferrata-casing',
'--map-ferrata-lit',
'--success',
'--warning',
'--danger',
])
root.style.removeProperty(name);
});
describe('ferrataPaint', () => {
it('FE-FERRATA-PAINT-001: draws the line in the content colour on a surface casing, the highlight in the accent', () => {
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--bg-primary', 'white');
root.style.setProperty('--accent', 'navy');
expect(ferrataPaint()).toEqual({ line: 'black', casing: 'white', highlight: 'navy' });
it('FE-FERRATA-PAINT-001: draws the line, its halo and the lit line in the via ferrata tokens', () => {
root.style.setProperty('--map-ferrata', 'violet');
root.style.setProperty('--map-ferrata-casing', 'white');
root.style.setProperty('--map-ferrata-lit', 'indigo');
expect(ferrataPaint()).toEqual({ line: 'violet', casing: 'white', highlight: 'indigo' });
});
it('FE-FERRATA-PAINT-002: never takes the grade colours of the tour line', () => {
@@ -34,19 +41,4 @@ describe('ferrataPaint', () => {
expect(sameFerrataPaint(paint, { ...paint })).toBe(true);
expect(sameFerrataPaint(paint, { ...paint, highlight: 'red' })).toBe(false);
});
it('FE-FERRATA-PAINT-004: a grey accent, the neutral default, hands the highlight to the info colour', () => {
root.style.setProperty('--text-primary', '#111827');
root.style.setProperty('--accent', '#111827');
root.style.setProperty('--info', '#2563eb');
expect(ferrataPaint().highlight).toBe('#2563eb');
root.style.setProperty('--accent', '#4f46e5');
expect(ferrataPaint().highlight).toBe('#4f46e5');
expect(isGreyColour('#e4e4e7')).toBe(true);
expect(isGreyColour('#fff')).toBe(true);
expect(isGreyColour('rgb(228, 228, 231)')).toBe(true);
expect(isGreyColour('#d97706')).toBe(false);
expect(isGreyColour('navy')).toBe(false);
});
});
+13 -42
View File
@@ -1,22 +1,18 @@
/**
* The colours the via ferrata lines are drawn in.
*
* Ferratas have a look of their own, apart from the green, yellow and red the
* tour line takes by gradient: a dark dashed line in the content colour on a
* light casing in the surface colour, and the accent for the ferrata under the
* pointer or open in the card. The grade is a badge on the card, never a line
* colour.
*
* The default accent is neutral, close to the content colour itself, so a lit
* ferrata in it would only lose its dashes. A grey accent therefore hands the
* highlight to the info colour, and the lit line is drawn solid and wider, on
* a wider casing, so it stands out by its shape as well as its colour.
* Ferratas have a look of their own: a dashed violet line on a white halo.
* Violet is the one colour nothing else on the planner map uses (the route is
* blue, its grades run green to red, the waypoints are blue), and the halo
* keeps it readable over a satellite tile or dark hillshade. The ferrata under
* the pointer or open in the card is drawn solid, wider and in a deeper violet.
* The grade is a badge on the card, never a line colour.
*
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
* tokens are read off the document when the layer draws (the precedent is the
* hazard overlay in useHazardLayerGL), which keeps the lines on the user's
* scheme without a colour literal of their own.
* `--map-ferrata*` tokens are read off the document when the layer draws (the
* precedent is the hazard overlay in useHazardLayerGL), which keeps light and
* dark mode apart without a colour literal in the layer.
*/
export interface FerrataPaint {
/** The dashed line itself. */
@@ -28,35 +24,11 @@ export interface FerrataPaint {
}
const TOKENS = {
line: '--text-primary',
casing: '--bg-primary',
highlight: '--accent',
/** The highlight when the accent is a grey, which would not stand out from the line. */
highlightFallback: '--info',
line: '--map-ferrata',
casing: '--map-ferrata-casing',
highlight: '--map-ferrata-lit',
} as const;
/** How far apart the channels of a colour may lie and it still reads as a grey. */
const GREY_SPREAD = 24;
/** The red, green and blue of a hex or `rgb()` colour, or null for anything else (a name, `currentColor`). */
function channels(colour: string): [number, number, number] | null {
const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(colour);
if (hex?.[1]) {
const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d) : (hex[1].match(/../g) ?? []);
const [r = 0, g = 0, b = 0] = digits.map((pair) => parseInt(pair, 16));
return [r, g, b];
}
const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(colour);
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
return null;
}
/** Whether a resolved colour is a grey (the neutral default accent, light or dark). */
export function isGreyColour(colour: string): boolean {
const rgb = channels(colour);
return !!rgb && Math.max(...rgb) - Math.min(...rgb) <= GREY_SPREAD;
}
/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */
function token(name: string, style: CSSStyleDeclaration | null): string {
const value = style?.getPropertyValue(name).trim();
@@ -65,11 +37,10 @@ function token(name: string, style: CSSStyleDeclaration | null): string {
export function ferrataPaint(): FerrataPaint {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
const accent = token(TOKENS.highlight, style);
return {
line: token(TOKENS.line, style),
casing: token(TOKENS.casing, style),
highlight: isGreyColour(accent) ? token(TOKENS.highlightFallback, style) : accent,
highlight: token(TOKENS.highlight, style),
};
}
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import { type FerrataCrossing, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
import {
type FerrataGroup,
ferrataGroupOf,
@@ -58,7 +59,9 @@ export function ferrataAreaOf(lines: ReadonlyArray<ReadonlyArray<readonly [numbe
*/
export function useFerrataMapLayer(
tripId: number | null | undefined,
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = []
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [],
/** The tour's line as the elevation profile measures it; the ferratas along it load with the layer off too. */
routeLine: readonly (readonly [number, number])[] | null = null
) {
const [active, setActive] = useState(false);
const [bbox, setBbox] = useState<FerrataBbox | null>(null);
@@ -66,8 +69,10 @@ export function useFerrataMapLayer(
const lastArea = useRef<FerrataBbox | null>(null);
const activeRef = useRef(false);
const area = ferrataAreaOf(areaLines);
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area);
const routeTiles = useMemo(() => ferrataTilesAlong(routeLine), [routeLine]);
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area, routeTiles);
const groups = useMemo(() => groupFerratas(ferratas), [ferratas]);
const crossings = useMemo<FerrataCrossing[]>(() => routeFerrataCrossings(routeLine, groups), [routeLine, groups]);
const count = active ? ferrataGroupsInView(groups, bbox) : 0;
const onViewportChange = useCallback((next: FerrataBbox) => {
@@ -99,5 +104,5 @@ export function useFerrataMapLayer(
[active, onToggle, status, count, groups, selectedId, onSelect]
);
return { layer, onViewportChange, selected, close };
return { layer, onViewportChange, selected, close, crossings };
}
@@ -7,25 +7,25 @@ import { useFerrataPaint } from './useFerrataPaint';
const root = document.documentElement;
afterEach(() => {
root.style.removeProperty('--text-primary');
root.style.removeProperty('--map-ferrata');
root.classList.remove('dark', 'unrelated');
root.removeAttribute('data-scheme');
});
describe('useFerrataPaint', () => {
it('FE-FERRATA-USEPAINT-001: starts with the colours the tokens resolve to', () => {
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
expect(result.current.line).toBe('black');
});
it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => {
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
await act(async () => {
root.setAttribute('data-scheme', 'forest');
root.style.setProperty('--text-primary', 'ivory');
root.style.setProperty('--map-ferrata', 'ivory');
await Promise.resolve();
});
@@ -33,7 +33,7 @@ describe('useFerrataPaint', () => {
});
it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => {
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--map-ferrata', 'black');
const { result } = renderHook(() => useFerrataPaint());
const first = result.current;
@@ -6,8 +6,10 @@ import {
ferrataTileRange,
ferrataTilesOf,
growFerrataTileRange,
parseFerrataTileKey,
type Ferrata,
type FerrataBbox,
type FerrataTile,
type FerrataTileRange,
} from '@trek/shared';
@@ -99,7 +101,8 @@ export function useTripFerratas(
tripId: number | null | undefined,
view: FerrataBbox | null,
enabled: boolean,
area: FerrataBbox | null = null
area: FerrataBbox | null = null,
routeTiles: readonly FerrataTile[] | null = null
): { ferratas: readonly Ferrata[]; status: FerrataStatus; truncated: boolean } {
const { offline } = useNetworkMode();
const store = useTileStore(tripId);
@@ -107,7 +110,11 @@ export function useTripFerratas(
store ? store.subscribe : subscribeNothing,
store ? store.getSnapshot : emptySnapshot
);
const active = enabled && !!store;
// The tiles a tour's line runs through load with the layer off too, for the warning
// that a route climbs a ferrata; only the layer's own state decides what it shows.
const routeKey = routeTiles && routeTiles.length > 0 ? routeTiles.map(ferrataTileKey).sort().join('|') : null;
const layerOn = enabled && !!store;
const active = layerOn || (!!routeKey && !!store);
const fits = !!view && ferrataBboxFits(view);
const viewKey = view && fits ? rangeKeyOf(ferrataTileRange(view)) : null;
const areaRange = area && ferrataBboxFitsArea(area) ? ferrataTileRange(area) : null;
@@ -131,27 +138,36 @@ export function useTripFerratas(
}, [active, store, tripId]);
useEffect(() => {
if (!active || !store || offline || !viewKey) return;
if (!layerOn || !store || offline || !viewKey) return;
const timer = window.setTimeout(() => {
const range = rangeOfKey(viewKey);
store.want(ferrataTilesOf(range), 'visible');
store.want(ferrataTilesOf(growFerrataTileRange(range, PREFETCH_RINGS)), 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, store, offline, viewKey]);
}, [layerOn, store, offline, viewKey]);
// Declared after the view's effect, so with both due its timer fires second.
useEffect(() => {
if (!active || !store || offline || !areaKey) return;
if (!layerOn || !store || offline || !areaKey) return;
const timer = window.setTimeout(() => {
store.want(ferrataTilesOf(rangeOfKey(areaKey)), 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, store, offline, areaKey]);
}, [layerOn, store, offline, areaKey]);
useEffect(() => {
if (!active || !store || offline || !routeKey) return;
const timer = window.setTimeout(() => {
const tiles = routeKey.split('|').flatMap((key) => parseFerrataTileKey(key) ?? []);
store.want(tiles, 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, store, offline, routeKey]);
const missing = visibleKeys.some((key) => !snapshot.loaded.has(key));
const status = statusOf(active, offline, fits, missing, snapshot);
return { ferratas: snapshot.ferratas, status, truncated: active && snapshot.truncated };
const status = statusOf(layerOn, offline, fits, missing, snapshot);
return { ferratas: snapshot.ferratas, status, truncated: layerOn && snapshot.truncated };
}
/** A trip's area is any real box; only its tile count limits the prefetch. */
@@ -1,6 +1,7 @@
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import type { CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
import type { FerrataStatus } from '../Ferratas/useTripFerratas'
import { fs } from '../shared/DialogShell'
@@ -125,7 +126,7 @@ function FerrataToggle({ layer, compact = false }: { layer: FerrataMapLayer; com
className={active ? 'bg-accent text-accent-text' : 'text-content-muted'}
style={segmentStyle(active)}
>
<MountainSnow size={15} strokeWidth={2} />
<FerrataLineIcon size={15} strokeWidth={2} />
{!compact && <span>{t('ferratas.layer')}</span>}
{live && (
<span
+4 -5
View File
@@ -55,14 +55,13 @@ export default function TourListRow({ tour, selected = false, disabled = false,
</span>
</div>
)}
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'} />
{website && (
<div className="mt-1 flex min-w-0">
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'}>
{website && (
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
</SoftPill>
</div>
)}
)}
</TourFactPills>
{children}
</li>
)
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../../store/settingsStore'
import { analyzeRouteGeometry } from '../../../utils/routeGeometry'
import type { TourPlannerController } from './useTourPlanner'
import type { Day } from '../../../types'
import { publishTourFerrataCrossings } from '../../../store/tourFerrataCrossingStore'
import { TourPlannerRail, TourPlannerToursRail } from './TourPlannerPanels'
vi.mock('../../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
@@ -147,6 +148,19 @@ describe('TourPlannerToursRail', () => {
expect(screen.queryByText('Routing is planning assistance, not a safety guarantee.')).not.toBeInTheDocument()
})
it('TOUR-PLANNER-RAIL-020: warns over the overview when the route climbs a via ferrata, with the stretch in the tooltip', () => {
publishTourFerrataCrossings([{ id: 'group/way/1', name: 'Alpspitz Ferrata', grade: 'K2', fromM: 1000, toM: 1600, afterWaypoint: null }])
try {
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
const badges = screen.getByTestId('tour-ferrata-crossings')
expect(badges).toHaveTextContent('Via ferrata K2')
expect(within(badges).getByLabelText(/climbs 600 m of “Alpspitz Ferrata”/)).toBeInTheDocument()
expect(document.querySelectorAll('[data-profile-band]').length).toBeGreaterThan(0)
} finally {
publishTourFerrataCrossings([])
}
})
it('exposes permanent deletion in the visible TOUR-PLANNER Tours rail', () => {
const onDeleteTour = vi.fn()
render(<TourPlannerToursRail planner={planner()} tours={[tour]} {...railProps} canEdit onDeleteTour={onDeleteTour} />)
@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Route, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
import { formatDistance, formatElevation } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
@@ -58,6 +59,41 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
)
}
/**
* The via ferratas the tour climbs, one warning badge each over the overview,
* the stretch and what it asks of a hiker in its tooltip. The router takes a
* ferrata like any trail once the difficulty allows alpine routes, so this is
* the one place that says so.
*/
function FerrataCrossingBadges() {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const crossings = useTourFerrataCrossingStore(state => state.crossings)
if (crossings.length === 0) return null
const byFerrata = new Map<string, { name: string; grade: string | null; metres: number }>()
for (const crossing of crossings) {
const entry = byFerrata.get(crossing.id) ?? { name: crossing.name, grade: crossing.grade, metres: 0 }
entry.metres += crossing.toM - crossing.fromM
byFerrata.set(crossing.id, entry)
}
return (
<div data-testid="tour-ferrata-crossings" className="mb-2.5 flex min-w-0 flex-wrap gap-1">
{[...byFerrata].map(([id, ferrata]) => {
const info = t('tours.ferrata.crossingInfo', { length: formatDistance(ferrata.metres / 1000, unit ?? 'metric'), name: ferrata.name })
return (
<Tooltip key={id} label={info}>
<span aria-label={info} className="inline-flex min-w-0">
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} className="flex-none" />}>
<span className="min-w-0 truncate">{ferrata.grade ? t('tours.ferrata.badge', { grade: ferrata.grade }) : t('tours.ferrata.badgeNoGrade')}</span>
</SoftPill>
</span>
</Tooltip>
)
})}
</div>
)
}
/** A status as a small round icon beside the title, its words in the tooltip and for a screen reader. */
function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' | 'info' | 'warning'; children: ReactNode }) {
const look = tone === 'warning' ? 'text-warning' : tone === 'info' ? 'text-info' : 'text-content-muted'
@@ -79,9 +115,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation()
const statusKey = planner.isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const restored = planner.draftRestored && planner.hasUnsavedChanges
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
const failed = planner.error === 'save'
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
if (!restored && !statusKey && !failed) return null
if (!restored && !statusKey && !failed && !onFerrata) return null
const warning = planner.error != null || canRetry
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
@@ -92,6 +129,11 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
<RotateCcw size={11} strokeWidth={2.2} />
</StatusDot>
)}
{onFerrata && (
<StatusDot label={t('tours.ferrata.crossingShort')} tone="warning">
<Route size={11} strokeWidth={2.2} />
</StatusDot>
)}
{statusLabel && (
<StatusDot label={statusLabel} tone={warning ? 'warning' : 'neutral'}>
{warning
@@ -266,6 +308,8 @@ function ElevationSection({ id, samples, gradientId, planner }: {
}) {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
// The stretches that climb a via ferrata, banded behind the line in the ferrata violet.
const crossings = useTourFerrataCrossingStore(state => state.crossings)
if (samples.length < 2) {
return (
<TourSection label={t('tours.planner.inspector.elevation')}>
@@ -288,6 +332,7 @@ function ElevationSection({ id, samples, gradientId, planner }: {
focus={planner.routeProfileFocus}
onFocusChange={planner.setRouteProfileFocus}
formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)}
bands={crossings}
/>}
</div>
</TourSection>
@@ -396,6 +441,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
{hasRoute && (
<div data-testid="tour-planner-overview" className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<TourSection label={t('tours.planner.inspector.title')}>
<FerrataCrossingBadges />
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
</TourSection>
{routeAnalysis && (
@@ -1,7 +1,15 @@
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Fragment, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import {
useTourFerrataCrossingStore,
type TourFerrataCrossing,
type TourWaypointFerrata,
} from '../../../store/tourFerrataCrossingStore';
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
import { formatDistance } from '../../../utils/units';
import { FerrataGlyph } from '../../Ferratas/FerrataGlyph';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
@@ -65,6 +73,78 @@ function WaypointEmptyState() {
);
}
/**
* A via ferrata the route climbs between two waypoints, as a badge on that leg:
* the ladder, its name, and its grade and length as chips inside, in the
* ferrata violet the map draws it in.
*/
function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit);
const length = formatDistance((crossing.toM - crossing.fromM) / 1000, unit ?? 'metric');
return (
<li
data-testid="tour-planner-ferrata-leg"
aria-label={t('tours.ferrata.crossingInfo', { length, name: crossing.name })}
className="flex min-w-0 py-0.5 ps-[34px]"
>
<span className="flex h-[26px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1 ps-1 shadow-sm ring-1 ring-inset ring-[color:var(--map-ferrata)]">
<FerrataGlyph size={18} className="flex-none" />
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
{crossing.name}
</span>
{crossing.grade && (
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-semibold leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{crossing.grade}
</span>
)}
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist tabular-nums leading-[18px] text-content-secondary"
style={fs(10.5)}
>
{length}
</span>
</span>
</li>
);
}
/**
* A waypoint's number, as the map pins show it. At the foot or the top of a via
* ferrata it takes a violet ring and the ferrata's ladder at its corner, named
* by the tooltip.
*/
function WaypointNumber({
index,
selected,
ferrata,
}: {
index: number;
selected: boolean;
ferrata: TourWaypointFerrata | null;
}) {
const { t } = useTranslation();
const label = t('tours.planner.waypointLabel', { n: index + 1 });
const number = (
<span
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
data-waypoint-number={index + 1}
data-ferrata-end={ferrata ? 'true' : undefined}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'} ${ferrata ? 'ring-2 ring-[color:var(--map-ferrata)]' : ''}`}
style={fs(11.5)}
>
{index + 1}
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
</span>
);
if (!ferrata) return number;
const grade = ferrata.grade ? ` (${ferrata.grade})` : '';
return <Tooltip label={`${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}${grade}`}>{number}</Tooltip>;
}
/**
* The row under the last waypoint that turns on "set points" for the map the
* planner shows, the same switch as the one in the map's layer pill; while it
@@ -107,6 +187,13 @@ export default function TourWaypointList({
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const placeNames = useWaypointPlaceNames(planner.waypoints);
const ferrataEnds = useTourFerrataCrossingStore((state) => state.waypoints);
const crossings = useTourFerrataCrossingStore((state) => state.crossings);
const legCrossings = new Map<string, TourFerrataCrossing[]>();
for (const crossing of crossings) {
if (!crossing.afterWaypoint) continue;
legCrossings.set(crossing.afterWaypoint, [...(legCrossings.get(crossing.afterWaypoint) ?? []), crossing]);
}
const lastIndex = planner.waypoints.length - 1;
const movable = canEdit && !isSaving;
const endDrag = () => {
@@ -132,121 +219,118 @@ export default function TourWaypointList({
const placeName = placeNames[point.id];
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!movable) {
<Fragment key={point.id}>
<li
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!movable) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
endDrag();
}}
onDragEnd={endDrag}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={movable}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<span
data-testid="tour-planner-waypoint-name"
className={`block min-w-0 truncate leading-tight ${placeName ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
style={fs(12.5, 'body')}
>
{placeName ?? coordinates}
</span>
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
endDrag();
}}
onDragEnd={endDrag}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={movable}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
{placeName && (
<WaypointNumber index={index} selected={selected} ferrata={ferrataEnds[point.id] ?? null} />
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<span
data-testid="tour-planner-waypoint-name"
className={`block min-w-0 truncate leading-tight ${placeName ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
style={fs(12.5, 'body')}
>
{placeName ?? coordinates}
</span>
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
</span>
{placeName && (
<span
data-testid="tour-planner-waypoint-coordinates"
className="block min-w-0 truncate font-geist tabular-nums leading-tight text-content-faint"
style={fs(10.5)}
>
{coordinates}
</span>
)}
</button>
{canEdit && (
<span
data-testid="tour-planner-waypoint-coordinates"
className="block min-w-0 truncate font-geist tabular-nums leading-tight text-content-faint"
style={fs(10.5)}
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
{coordinates}
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
</div>
</li>
{(legCrossings.get(point.id) ?? []).map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
</Fragment>
);
})}
</ol>
@@ -0,0 +1,43 @@
// FE-TOURS-FIXEDLEG-001 to -002: a fixed leg holds only while it joins its two waypoints.
import { describe, expect, it } from 'vitest';
import { keepJoinedFixedLegs, lineLengthM } from './tourFixedLegs';
const leg = {
kind: 'ferrata' as const,
ref: 'way/1',
name: 'Via A',
geometry: [
[47.4, 11.0],
[47.401, 11.0],
] as [number, number][],
};
describe('keepJoinedFixedLegs', () => {
it('FE-TOURS-FIXEDLEG-001: keeps a leg that joins its waypoints and drops one that a move, a removal or a bad row broke', () => {
const joined = [
{ lat: 47.4, lng: 11.0, fixedLeg: leg },
{ lat: 47.401, lng: 11.0 },
];
expect(keepJoinedFixedLegs(joined)[0]?.fixedLeg).toBe(leg);
const moved = [
{ lat: 47.4, lng: 11.0, fixedLeg: leg },
{ lat: 47.41, lng: 11.0 },
];
expect(keepJoinedFixedLegs(moved)[0]).not.toHaveProperty('fixedLeg');
const last = [{ lat: 47.401, lng: 11.0, fixedLeg: leg }];
expect(keepJoinedFixedLegs(last)[0]).not.toHaveProperty('fixedLeg');
const broken = [
{ lat: 47.4, lng: 11.0, fixedLeg: { ...leg, kind: 'boat' } as never },
{ lat: 47.401, lng: 11.0 },
];
expect(keepJoinedFixedLegs(broken)[0]).not.toHaveProperty('fixedLeg');
});
it('FE-TOURS-FIXEDLEG-002: measures a line in metres', () => {
expect(lineLengthM(leg.geometry)).toBeCloseTo(111.2, 0);
expect(lineLengthM([])).toBe(0);
});
});
@@ -0,0 +1,52 @@
import { TOUR_FIXED_LEG_END_TOLERANCE_M, tourFixedLegSchema, type TourFixedLeg } from '@trek/shared';
import { haversineKm } from '../../../utils/geo';
/**
* Legs the router does not plan: a via ferrata put onto the tour, whose own
* line from OpenStreetMap the tour follows from one waypoint to the next. A
* fixed leg only holds while it still joins its two waypoints, so moving,
* removing or reordering either of them turns the leg back into a routed one.
*/
export interface FixedLegPoint {
lat: number;
lng: number;
fixedLeg?: TourFixedLeg | null;
}
/** Read as a plain array: this workspace compiles without strict mode, where the schema's tuples are "maybe two numbers". */
function gapM(a: { lat: number; lng: number }, end: readonly unknown[]): number {
return haversineKm(a, { lat: Number(end[0]), lng: Number(end[1]) }) * 1000;
}
/** Whether a leg still runs from this waypoint to the next one. */
export function fixedLegJoins(point: FixedLegPoint, next: FixedLegPoint | undefined, leg: TourFixedLeg): boolean {
const first = leg.geometry[0];
const last = leg.geometry[leg.geometry.length - 1];
return (
!!next &&
!!first &&
!!last &&
gapM(point, first) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
gapM(next, last) <= TOUR_FIXED_LEG_END_TOLERANCE_M
);
}
/** The points with every leg that no longer joins its waypoints (or does not parse) dropped. */
export function keepJoinedFixedLegs<T extends FixedLegPoint>(points: readonly T[]): T[] {
return points.map((point, index) => {
if (!point.fixedLeg) return point;
const parsed = tourFixedLegSchema.safeParse(point.fixedLeg);
if (parsed.success && fixedLegJoins(point, points[index + 1], parsed.data)) return point;
const { fixedLeg: _dropped, ...rest } = point;
return rest as T;
});
}
/** The length of a line, in metres. */
export function lineLengthM(line: readonly (readonly [number, number])[]): number {
let total = 0;
for (let i = 1; i < line.length; i += 1) {
total += haversineKm({ lat: line[i - 1][0], lng: line[i - 1][1] }, { lat: line[i][0], lng: line[i][1] }) * 1000;
}
return total;
}
@@ -35,6 +35,41 @@ describe('routeWalkingTour', () => {
})
})
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,
ref: 'way/1',
name: 'Via A',
geometry: [[47.39, 11.01], [47.391, 11.011], [47.392, 11.012]] as [number, number][],
}
valhallaRun
.mockResolvedValueOnce({ total: { coordinates: [[47.38, 11.0], [47.39, 11.01]], distance: 1500, duration: 1200 } })
.mockResolvedValueOnce({ total: { coordinates: [[47.392, 11.012], [47.4, 11.02]], distance: 1000, duration: 800 } })
const routed = await routeWalkingTour([
{ lat: 47.38, lng: 11.0 },
{ lat: 47.39, lng: 11.01, fixedLeg: leg },
{ lat: 47.392, lng: 11.012 },
{ lat: 47.4, lng: 11.02 },
])
expect(valhallaRun).toHaveBeenCalledTimes(2)
expect(valhallaRun.mock.calls[0]?.[0]).toEqual([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01 }])
expect(valhallaRun.mock.calls[1]?.[0]).toEqual([{ lat: 47.392, lng: 11.012 }, { lat: 47.4, lng: 11.02 }])
expect(routed?.coordinates).toEqual([[47.38, 11.0], [47.39, 11.01], [47.391, 11.011], [47.392, 11.012], [47.4, 11.02]])
expect(routed?.distanceMeters).toBeGreaterThan(2500)
})
it('TOUR-ROUTING-021: a tour that is all fixed leg asks the router nothing, and a failed run fails the whole route', async () => {
const leg = { kind: 'ferrata' as const, ref: 'way/1', name: null, geometry: [[47.39, 11.01], [47.392, 11.012]] as [number, number][] }
const fixedOnly = await routeWalkingTour([{ lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])
expect(valhallaRun).not.toHaveBeenCalled()
expect(fixedOnly?.coordinates).toEqual([[47.39, 11.01], [47.392, 11.012]])
valhallaRun.mockResolvedValueOnce(null)
expect(await routeWalkingTour([{ lat: 47.38, lng: 11.0 }, { lat: 47.39, lng: 11.01, fixedLeg: leg }, { lat: 47.392, lng: 11.012 }])).toBeNull()
})
it.each([1, 2, 3, 4, 5, 6] as const)('TOUR-ROUTING-002: forwards T%i unchanged to Valhalla', async difficulty => {
await routeWalkingTour(waypoints, undefined, difficulty)
@@ -1,5 +1,7 @@
import type { TourFixedLeg } from '@trek/shared'
import type { Waypoint } from '../../../types'
import { valhallaBase, valhallaRun, valhallaTurn } from '../../Map/valhallaRoute'
import { lineLengthM } from './tourFixedLegs'
export interface TourRouteResult {
coordinates: [number, number][]
@@ -13,21 +15,55 @@ export interface TourRouteResult {
*/
const TOURS_MAX_HIKING_DIFFICULTY = 2
/** Tours deliberately bypasses the OSRM-first RouteCalculator. */
/** How fast a fixed leg is reckoned until the heights are in (a via ferrata climbs slowly). */
const FIXED_LEG_KM_PER_HOUR = 1.5
/**
* 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
* leg; the router only plans the runs between fixed legs, one request each.
*/
export async function routeWalkingTour(
waypoints: Pick<Waypoint, 'lat' | 'lng'>[],
waypoints: Array<Pick<Waypoint, 'lat' | 'lng'> & { fixedLeg?: TourFixedLeg | null }>,
signal?: AbortSignal,
maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY,
): Promise<TourRouteResult | null> {
const run = await valhallaRun(waypoints, 'walking', [], signal, {
max_hiking_difficulty: maxHikingDifficulty,
})
if (!run) return null
return {
coordinates: run.total.coordinates,
distanceMeters: run.total.distance,
durationSeconds: run.total.duration,
const lines: [number, number][][] = []
let distanceMeters = 0
let durationSeconds = 0
const routeRun = async (run: Array<Pick<Waypoint, 'lat' | 'lng'>>) => {
if (run.length < 2) return true
const routed = await valhallaRun(run.map(({ lat, lng }) => ({ lat, lng })), 'walking', [], signal, {
max_hiking_difficulty: maxHikingDifficulty,
})
if (!routed) return false
lines.push(routed.total.coordinates)
distanceMeters += routed.total.distance
durationSeconds += routed.total.duration
return true
}
let run: Array<Pick<Waypoint, 'lat' | 'lng'>> = waypoints.slice(0, 1)
for (let i = 0; i < waypoints.length - 1; i += 1) {
const leg = waypoints[i].fixedLeg
if (!leg) {
run.push(waypoints[i + 1])
continue
}
if (!(await routeRun(run))) return null
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng])
const length = lineLengthM(line)
lines.push(line)
distanceMeters += length
durationSeconds += (length / 1000 / FIXED_LEG_KM_PER_HOUR) * 3600
run = [waypoints[i + 1]]
}
if (!(await routeRun(run))) return null
if (lines.length === 0) return null
// One line: each part after the first starts where the one before it ended, so its first point goes.
const coordinates = lines.flatMap((line, index) => (index === 0 ? line : line.slice(1)))
return { coordinates, distanceMeters, durationSeconds }
}
const HEIGHT_CHUNK_SIZE = 500
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { MAX_PLANNED_TOUR_DURATION_MINUTES, tourWebsiteSchema, type TourCreateResponse, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
import { MAX_PLANNED_TOUR_DURATION_MINUTES, tourWebsiteSchema, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty } from '@trek/shared'
import { tourRepo } from '../../../repo/tourRepo'
import { randomId } from '../../../utils/randomId'
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint } from './tourRouting'
import { keepJoinedFixedLegs } from './tourFixedLegs'
import { hikingTimeMinutes } from '../hikingTime'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -13,6 +14,8 @@ export interface TourPlannerWaypoint {
lat: number
lng: number
role: TourWaypointRole
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
fixedLeg?: TourFixedLeg | null
}
export type TourPlannerStatus =
@@ -51,14 +54,14 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
}
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.map((point, index) => ({
return keepJoinedFixedLegs(points).map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}))
}
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}`).join('|')
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${point.fixedLeg ? `:${point.fixedLeg.ref}` : ''}`).join('|')
}
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
@@ -282,6 +285,25 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setSelectedWaypointId(id)
}, [commitWaypoints])
/**
* A fixed leg onto the end of the tour, as one step of the history: a
* waypoint at the line's start that carries it, and one at its end.
*/
const appendFixedLeg = useCallback((leg: TourFixedLeg) => {
if (!editPermissionRef.current) return
const first = leg.geometry[0]
const last = leg.geometry[leg.geometry.length - 1]
if (!first || !last) return
const startId = randomId()
const endId = randomId()
commitWaypoints(current => [
...current,
{ id: startId, lat: Number(first[0]), lng: Number(first[1]), role: 'via', fixedLeg: leg },
{ id: endId, lat: Number(last[0]), lng: Number(last[1]), role: 'end' },
])
setSelectedWaypointId(endId)
}, [commitWaypoints])
const setWaypointPosition = useCallback((id: string, lat: number, lng: number) => {
if (!editPermissionRef.current || saveInFlightRef.current || !Number.isFinite(lat) || !Number.isFinite(lng)) return false
const current = waypointsRef.current.find(point => point.id === id)
@@ -562,6 +584,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
lat: point.lat,
lng: point.lng,
role: point.role,
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
})))
localStorage.removeItem(storageKey)
contextGenerationRef.current += 1
@@ -716,6 +739,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
lng: point.lng,
role: point.role,
sequence,
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
})),
max_hiking_difficulty: maxHikingDifficulty,
duration_seconds: walkingDurationMinutes === null ? durationSeconds : walkingDurationMinutes * 60,
@@ -770,7 +794,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
breakAdditionalMinutes, setBreakAdditionalMinutes, breakAdditionalInvalid,
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
waypoints, selectedWaypointId, setSelectedWaypointId,
addWaypoint, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
status, error, retry, isSaving,
route, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
+2 -1
View File
@@ -68,7 +68,7 @@ function TourMetricPill({ icon: Icon, label, value, prefix }: { icon: LucideIcon
}
/** The facts of a tour as white pills, kept to one short row: how far, up and down, how long, how hard, and where it came from when that was not TREK. */
export function TourFactPills({ tour, className = '' }: { tour: TourListItem; className?: string }) {
export function TourFactPills({ tour, className = '', children }: { tour: TourListItem; className?: string; children?: ReactNode }) {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const times = tourPlannedTimes(tour)
@@ -99,6 +99,7 @@ export function TourFactPills({ tour, className = '' }: { tour: TourListItem; cl
</span>
</Tooltip>
)}
{children}
</div>
)
}
@@ -154,4 +154,14 @@ describe('ElevationProfile', () => {
expect(prepared).toHaveBeenCalledTimes(2)
unmount()
})
it('bands the stretches it is given behind the line, placed by distance along the route', () => {
const { container } = render(
<ElevationProfile samples={samples} color="currentColor" gradientId="banded" ariaLabel="Elevation profile" bands={[{ fromM: 150, toM: 300 }]} />,
)
const band = container.querySelector('[data-profile-band]')
expect(band).toHaveAttribute('x', '220')
expect(band).toHaveAttribute('width', '220')
expect(band).toHaveAttribute('fill', 'var(--map-ferrata)')
})
})
@@ -11,10 +11,12 @@ interface ElevationProfileProps {
focus?: RouteProfileFocus | null
onFocusChange?: (focus: RouteProfileFocus | null) => void
formatFocus?: (focus: RouteProfileFocus) => string
/** Stretches to mark behind the line, in metres along the route (the via ferratas a tour climbs). */
bands?: ReadonlyArray<{ fromM: number; toM: number }>
}
/** A route profile whose horizontal axis represents travelled distance. */
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus }: ElevationProfileProps) {
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands }: ElevationProfileProps) {
const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('')
const pointerFrameRef = useRef<number | null>(null)
const pendingPointerDistanceRef = useRef<number | null>(null)
@@ -117,6 +119,11 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
{bands?.map(band => (
<rect key={`${band.fromM}-${band.toM}`} data-profile-band="true" x={(band.fromM / chart.maxDistance) * width} y={0}
width={Math.max(1, ((band.toM - band.fromM) / chart.maxDistance) * width)} height={height}
fill="var(--map-ferrata)" fillOpacity={0.16} />
))}
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
<path d={chart.linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
{focusX != null && (
+7
View File
@@ -788,6 +788,10 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--journal-card: #FFFFFF;
--journal-border: #E4E4E7;
--journal-accent: #6366F1;
/* Via ferrata lines on the map: a violet nothing else on the map uses, on a white halo. */
--map-ferrata: #7c3aed;
--map-ferrata-lit: #5b21b6;
--map-ferrata-casing: #ffffff;
--journal-text: #09090B;
--journal-muted: #71717A;
--journal-faint: #A1A1AA;
@@ -885,6 +889,9 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--journal-card: #18181B;
--journal-border: #27272A;
--journal-accent: #818CF8;
--map-ferrata: #a78bfa;
--map-ferrata-lit: #c4b5fd;
--map-ferrata-casing: #ffffff;
--journal-text: #FAFAFA;
--journal-muted: #A1A1AA;
--journal-faint: #52525B;
@@ -1,5 +1,7 @@
import { lazy, Suspense, useMemo, useState, type ReactElement } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState, type ReactElement } from 'react';
import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay';
import { crossingLegs, waypointsAtFerrataEnds } from '../../components/Ferratas/ferrataCrossings';
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
@@ -9,6 +11,8 @@ import LocationSelect from '../../components/Planner/LocationSelect';
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
import { isRtlLanguage, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
import { useFerrataAdopt } from './useFerrataAdopt';
import { useTourMapPoints } from './useTourMapPoints';
import type { useTripPlanner } from './useTripPlanner';
import type { useTripPlannerPage } from './useTripPlannerPage';
@@ -26,7 +30,22 @@ const FERRATA_CARD_BOTTOM = CONTROL_GAP + MAP_ROUND_CONTROL_SIZE + CONTROL_GAP;
const PLACING_CURSOR =
'[&_.leaflet-container]:!cursor-crosshair [&_.maplibregl-canvas-container]:!cursor-crosshair [&_.mapboxgl-canvas-container]:!cursor-crosshair';
/**
* Where a via ferrata's card sits. On the flat map nothing is under its corner,
* so it rests at the edge gap; in 3D it stands on the dock row, as wide as the
* grade legend it sits over, and on the legend's bands while they are open.
*/
function ferrataCardPlacement(
terrain3d: boolean,
bottomRowTop: number,
legend: TerrainLegendLayout | null
): { bottom: number; width?: number } {
if (!terrain3d) return { bottom: CONTROL_GAP };
return { bottom: bottomRowTop + (legend?.raisedBy ?? 0), width: legend?.width };
}
type Planner = ReturnType<typeof useTripPlanner>;
const NO_POINTS: readonly { id: string; lat: number; lng: number }[] = [];
type PageState = ReturnType<typeof useTripPlannerPage>;
export interface TourPlannerMapStageProps
@@ -66,12 +85,34 @@ export function TourPlannerMapStage({
() => [tourMap.focusPoints, ...(tourMap.route ?? [])],
[tourMap.focusPoints, tourMap.route]
);
const ferrata = useFerrataMapLayer(tripId, ferrataArea);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
const ferrata = useFerrataMapLayer(tripId, ferrataArea, analysis?.routeCoordinates ?? null);
// The rails beside the map warn about the stretches that climb a ferrata, and band them on the profile.
const { t: translate, language } = useTranslation();
const tourWaypoints = tourPlanner.mode.type === 'view-gpx' ? NO_POINTS : tourPlanner.waypoints;
useEffect(() => {
const describe = (group: FerrataGroup) => {
const label = ferrataLabel(group, translate);
return { name: label.name, grade: label.grade };
};
const legs = crossingLegs(tourWaypoints, analysis?.distanceIndexedProfileSamples ?? [], ferrata.crossings);
publishTourFerrataCrossings(
ferrata.crossings.map(({ group, fromM, toM }, i) => ({
id: group.id,
...describe(group),
fromM,
toM,
afterWaypoint: legs[i] ?? null,
})),
waypointsAtFerrataEnds(tourWaypoints, ferrata.crossings, describe)
);
}, [analysis, ferrata.crossings, tourWaypoints, translate]);
useEffect(() => () => publishTourFerrataCrossings([]), []);
// The click that closes a ferrata's card must not also add a waypoint under it.
const closingPress = useOutsidePressGuard();
const { language } = useTranslation();
const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close);
const ferrataOnTour = useMemo(() => new Set(ferrata.crossings.map(({ group }) => group.id)), [ferrata.crossings]);
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
// The "set points" tool, per mode; only a real click places, never the end of a drag.
const { waypoints, placeTool, placeOn, attachSurface, placedHint } = useTourMapPoints({
tourPlanner,
@@ -154,10 +195,9 @@ export function TourPlannerMapStage({
onClose={ferrata.close}
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
// On the flat map nothing sits under the card's corner, so it rests at the edge gap; in 3D it
// stands on the dock row, and on the grade bands while they are open.
bottom={terrain3d ? bottomRowTop + (terrainLegend?.raisedBy ?? 0) : CONTROL_GAP}
width={terrain3d ? terrainLegend?.width : undefined}
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
onAdopt={adoptFerrata}
onTourIds={ferrataOnTour}
/>
{/* "Point set", with the way back, above the bottom row and between the panels. */}
{placedHint.shown && (
@@ -0,0 +1,95 @@
// FE-HOOK-FERRATAADOPT-001 to FE-HOOK-FERRATAADOPT-004: a via ferrata goes onto the tour as a fixed leg of its own line.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { renderHook } from '../../../tests/helpers/render';
import { groupFerratas } from '../../components/Ferratas/ferrataGroups';
import { useFerrataAdopt } from './useFerrataAdopt';
const ferrata = (id: number, geometry: [number, number][]): Ferrata => ({
id: `way/${id}`,
name: 'Via Test',
routeName: null,
grade: { raw: '2', level: 2, plus: null },
geometry,
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
// Two pieces that join head to head: the second runs backwards.
const [group] = groupFerratas([
ferrata(1, [
[47.43, 11.05],
[47.432, 11.05],
]),
ferrata(2, [
[47.434, 11.05],
[47.432, 11.05],
]),
]);
function planner(overrides: Record<string, unknown> = {}) {
return {
mode: { type: 'new-draft' },
isSaving: false,
waypoints: [] as Array<{ id: string; lat: number; lng: number; role: string }>,
addWaypoint: vi.fn(),
appendFixedLeg: vi.fn(),
startNewTour: vi.fn(),
...overrides,
};
}
describe('useFerrataAdopt', () => {
it('FE-HOOK-FERRATAADOPT-001: hands the ferrata over as one fixed line, its pieces joined in walking order', () => {
const p = planner();
const done = vi.fn();
const { result } = renderHook(() => useFerrataAdopt(p as never, true, done));
result.current?.(group);
expect(p.appendFixedLeg).toHaveBeenCalledWith({
kind: 'ferrata',
ref: group.id.replace(/^group\//, ''),
name: 'Via Test',
geometry: [
[47.43, 11.05],
[47.432, 11.05],
[47.434, 11.05],
],
});
expect(p.addWaypoint).not.toHaveBeenCalled();
expect(done).toHaveBeenCalledOnce();
});
it('FE-HOOK-FERRATAADOPT-002: starts at the end nearer to the last waypoint', () => {
const p = planner({ waypoints: [{ id: 'a', lat: 47.44, lng: 11.05, role: 'start' }] });
const { result } = renderHook(() => useFerrataAdopt(p as never, true, vi.fn()));
result.current?.(group);
const leg = p.appendFixedLeg.mock.calls[0]?.[0] as { geometry: [number, number][] };
expect(leg.geometry[0]).toEqual([47.434, 11.05]);
expect(leg.geometry[leg.geometry.length - 1]).toEqual([47.43, 11.05]);
});
it('FE-HOOK-FERRATAADOPT-003: from a GPX view it starts a new tour first; pieces that do not join go on as two plain ends', () => {
const p = planner({ mode: { type: 'view-gpx', placeId: 9 } });
const [apart] = groupFerratas([
ferrata(3, [
[47.43, 11.05],
[47.431, 11.05],
]),
ferrata(4, [
[47.433, 11.05],
[47.434, 11.05],
]),
]);
const { result } = renderHook(() => useFerrataAdopt(p as never, true, vi.fn()));
result.current?.(apart);
expect(p.startNewTour).toHaveBeenCalledOnce();
expect(p.appendFixedLeg).not.toHaveBeenCalled();
expect(p.addWaypoint).toHaveBeenCalledTimes(2);
});
it('FE-HOOK-FERRATAADOPT-004: offers nothing while the tour cannot be edited', () => {
const { result } = renderHook(() => useFerrataAdopt(planner() as never, false, vi.fn()));
expect(result.current).toBeUndefined();
});
});
@@ -0,0 +1,56 @@
import { useCallback } from 'react';
import { ferrataEnds, ferrataLine } from '../../components/Ferratas/ferrataCrossings';
import { type FerrataGroup, ferrataLabel } from '../../components/Ferratas/ferrataGroups';
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
import { useTranslation } from '../../i18n';
type AdoptPlanner = Pick<
TourPlannerController,
'mode' | 'isSaving' | 'waypoints' | 'addWaypoint' | 'appendFixedLeg' | 'startNewTour'
>;
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function roughDistance([lat1, lng1]: [number, number], [lat2, lng2]: [number, number]): number {
const x = (lng2 - lng1) * RAD * Math.cos(((lat1 + lat2) / 2) * RAD);
const y = (lat2 - lat1) * RAD;
return Math.hypot(x, y) * EARTH_RADIUS_M;
}
/**
* "Add to tour" on a via ferrata's card. The ferrata goes onto the tour as a
* fixed leg: a waypoint at its nearer end carries the ferrata's own line from
* OpenStreetMap to a waypoint at its other end, so the router only plans the
* way there and on from the top, and never walks around the climb. Where the
* ferrata's pieces do not join into one line, its two ends go on as plain
* waypoints instead. Undefined while the tour cannot be edited.
*/
export function useFerrataAdopt(planner: AdoptPlanner, canEdit: boolean, onDone: () => void) {
const { t } = useTranslation();
const adopt = useCallback(
(group: FerrataGroup) => {
const ends = ferrataEnds(group);
if (!ends || planner.isSaving) return;
if (planner.mode.type === 'view-gpx') planner.startNewTour();
const last = planner.mode.type === 'view-gpx' ? undefined : planner.waypoints[planner.waypoints.length - 1];
const [a, b] = ends;
const [first, second] =
last && roughDistance([last.lat, last.lng], b) < roughDistance([last.lat, last.lng], a) ? [b, a] : [a, b];
const line = ferrataLine(group, first);
if (line) {
planner.appendFixedLeg({
kind: 'ferrata',
ref: group.id.replace(/^group\//, '').slice(0, 64),
name: ferrataLabel(group, t).name.slice(0, 255),
geometry: line,
});
} else {
planner.addWaypoint(first[0], first[1]);
planner.addWaypoint(second[0], second[1]);
}
onDone();
},
[planner, onDone, t]
);
return canEdit ? adopt : undefined;
}
@@ -0,0 +1,20 @@
// FE-STORE-FERRATACROSS-001: the stretches a tour climbs a via ferrata, published once per change.
import { describe, expect, it } from 'vitest';
import { publishTourFerrataCrossings, useTourFerrataCrossingStore } from './tourFerrataCrossingStore';
describe('tourFerrataCrossingStore', () => {
it('FE-STORE-FERRATACROSS-001: keeps the same list when the stretches did not change, and empties to one shared list', () => {
const crossing = { id: 'group/way/1', name: 'Via A', grade: 'K2', fromM: 100, toM: 400, afterWaypoint: null };
publishTourFerrataCrossings([crossing]);
const first = useTourFerrataCrossingStore.getState().crossings;
publishTourFerrataCrossings([{ ...crossing }]);
expect(useTourFerrataCrossingStore.getState().crossings).toBe(first);
publishTourFerrataCrossings([{ ...crossing, toM: 500 }]);
expect(useTourFerrataCrossingStore.getState().crossings).not.toBe(first);
publishTourFerrataCrossings([]);
const empty = useTourFerrataCrossingStore.getState().crossings;
publishTourFerrataCrossings([]);
expect(useTourFerrataCrossingStore.getState().crossings).toBe(empty);
expect(empty).toEqual([]);
});
});
@@ -0,0 +1,78 @@
import { create } from 'zustand';
/** A stretch of the tour on screen that runs along a via ferrata, as the rails show it. */
export interface TourFerrataCrossing {
/** The ferrata's group id, so its stretches can be told apart and counted once. */
id: string;
name: string;
/** The grade as the card says it ("K2", "K2 bis K4"), or null when none is tagged. */
grade: string | null;
/** Metres along the route, as the elevation profile counts them. */
fromM: number;
toM: number;
/** The waypoint whose leg the stretch lies on, or null before the first one. */
afterWaypoint: string | null;
}
/**
* Where the tour on the planner map climbs a via ferrata. The map stage owns
* the ferrata data and finds the stretches; the two rails beside it show the
* warning and the band on the profile.
*/
/** The via ferrata a waypoint stands at the foot or the top of, for the mark on its number. */
export interface TourWaypointFerrata {
name: string;
grade: string | null;
}
export const useTourFerrataCrossingStore = create<{
crossings: readonly TourFerrataCrossing[];
/** By waypoint id, the ferratas whose ends the waypoints stand at. */
waypoints: Readonly<Record<string, TourWaypointFerrata>>;
}>(() => ({ crossings: [], waypoints: {} }));
const NONE: readonly TourFerrataCrossing[] = [];
const NO_WAYPOINTS: Readonly<Record<string, TourWaypointFerrata>> = {};
function sameCrossings(a: readonly TourFerrataCrossing[], b: readonly TourFerrataCrossing[]): boolean {
return (
a.length === b.length &&
a.every((crossing, i) => {
const other = b[i];
return (
crossing.id === other.id &&
crossing.afterWaypoint === other.afterWaypoint &&
crossing.fromM === other.fromM &&
crossing.toM === other.toM &&
crossing.grade === other.grade
);
})
);
}
function sameWaypoints(
a: Readonly<Record<string, TourWaypointFerrata>>,
b: Readonly<Record<string, TourWaypointFerrata>>
): boolean {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => b[key]?.name === a[key].name && b[key]?.grade === a[key].grade)
);
}
export function publishTourFerrataCrossings(
crossings: readonly TourFerrataCrossing[],
waypoints: Readonly<Record<string, TourWaypointFerrata>> = NO_WAYPOINTS
): void {
const state = useTourFerrataCrossingStore.getState();
const next = crossings.length === 0 ? NONE : crossings;
const nextWaypoints = Object.keys(waypoints).length === 0 ? NO_WAYPOINTS : waypoints;
const crossingsSame = sameCrossings(state.crossings, next);
const waypointsSame = sameWaypoints(state.waypoints, nextWaypoints);
if (crossingsSame && waypointsSame) return;
useTourFerrataCrossingStore.setState({
crossings: crossingsSame ? state.crossings : next,
waypoints: waypointsSame ? state.waypoints : nextWaypoints,
});
}
@@ -11,6 +11,7 @@ export class TourWaypoints {
lng!: number;
role!: string;
sequence!: number;
fixed_leg?: string | null;
}
export const TourWaypointsSchema = defineEntity({
@@ -25,5 +26,6 @@ export const TourWaypointsSchema = defineEntity({
lng: p.double(),
role: p.text(),
sequence: p.integer(),
fixed_leg: p.text().nullable(),
},
});
+12 -1
View File
@@ -1,5 +1,5 @@
import type { JsonColumn } from '../utils/json-column';
import { FERRATAS_RESULT_CAP, ferrataSchema, type Ferrata } from '@trek/shared';
import { FERRATAS_RESULT_CAP, ferrataSchema, tourFixedLegSchema, type Ferrata, type TourFixedLeg } from '@trek/shared';
import { z } from 'zod';
@@ -124,3 +124,14 @@ export const FERRATA_TILE_PAYLOAD: JsonColumn<FerrataTilePayload | null> = {
schema: z.object({ ferratas: z.array(ferrataSchema).max(FERRATAS_RESULT_CAP), truncated: z.boolean() }).nullable(),
fallback: () => null,
};
/**
* A tour leg taken as a fixed line (a via ferrata's own line from OSM) rather
* than routed. A row that does not decode reads as a routed leg, which the
* editor then plans again.
*/
export const TOUR_WAYPOINT_FIXED_LEG: JsonColumn<TourFixedLeg | null> = {
column: 'tour_waypoints.fixed_leg',
schema: tourFixedLegSchema.nullable(),
fallback: () => null,
};
@@ -9,4 +9,5 @@ export interface TourWaypointsTable {
lng: number;
role: string;
sequence: number;
fixed_leg: InsertOptional<string | null>;
}
@@ -0,0 +1,18 @@
import { Migration } from '@mikro-orm/migrations';
/**
* `tour_waypoints.fixed_leg` holds the leg from a waypoint to the next one
* when the tour takes it as a fixed line rather than as a routed one: a via
* ferrata put onto the tour from the map, whose own line from OpenStreetMap
* the tour follows, since a router walks around a ferrata wherever it can.
*
* The column is JSON text (kind, the OSM reference, the name and the line) and
* NULL for every routed leg, so every existing waypoint keeps its meaning.
*/
export class Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata extends Migration {
override name = 'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata';
override up(): void {
this.addSql('ALTER TABLE tour_waypoints ADD COLUMN fixed_leg TEXT');
}
}
@@ -1,6 +1,9 @@
import { decodeJson, encodeJson } from '../../utils/json-column';
import type { TourWaypoints } from '../entities/TourWaypoints.entity';
import { TOUR_WAYPOINT_FIXED_LEG } from '../json-columns';
import type { DB } from '../kysely/db';
import { TrekRepository } from './_shared/trek-repository';
import type { TourFixedLeg } from '@trek/shared';
/** One saved routing control point of a tour, in route order. */
export interface TourWaypointRow {
@@ -8,6 +11,23 @@ export interface TourWaypointRow {
lng: number;
role: 'start' | 'via' | 'end';
sequence: number;
/** The leg to the next waypoint as a fixed line; absent for a routed leg. */
fixed_leg?: TourFixedLeg | null;
}
/** The row as stored, the fixed leg still JSON text. */
interface StoredWaypoint {
lat: number;
lng: number;
role: TourWaypointRow['role'];
sequence: number;
fixed_leg: string | null;
}
/** A stored row as callers read it: the fixed leg decoded, and left out entirely for a routed leg. */
function readWaypoint({ fixed_leg, ...point }: StoredWaypoint): TourWaypointRow {
const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint');
return leg ? { ...point, fixed_leg: leg } : point;
}
/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */
@@ -30,37 +50,46 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
return this.kysely<TourWaypointsKyselyDB>();
}
/** TW1 — `SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
/** TW1 — `SELECT lat, lng, role, sequence, fixed_leg FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
return await this.db()
const rows = await this.db()
.selectFrom('tour_waypoints')
.select(['lat', 'lng', 'role', 'sequence'])
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg'])
.where('place_id', '=', place_id)
.orderBy('sequence', 'asc')
.execute();
return rows.map(readWaypoint);
}
/** TW2 — every waypoint of a trip's tours, for the trip copy to remap by place. */
async listForTrip(trip_id: number): Promise<TourWaypointWithPlaceRow[]> {
return await this.db()
const rows = await this.db()
.selectFrom('tour_waypoints as tw')
.innerJoin('places as p', 'p.id', 'tw.place_id')
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence'])
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg'])
.where('p.trip_id', '=', trip_id)
.orderBy('tw.place_id', 'asc')
.orderBy('tw.sequence', 'asc')
.execute();
return rows.map(({ place_id, ...point }) => ({ place_id, ...readWaypoint(point) }));
}
/**
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg)
* VALUES (...)` for each point. The caller owns the transaction: a create
* writes the tour first, an edit clears the old points first.
*/
async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise<void> {
if (waypoints.length === 0) return;
await this.insertMany(
waypoints.map((w) => ({ place: place_id, lat: w.lat, lng: w.lng, role: w.role, sequence: w.sequence })),
waypoints.map((w) => ({
place: place_id,
lat: w.lat,
lng: w.lng,
role: w.role,
sequence: w.sequence,
fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null,
})),
);
}
@@ -15,6 +15,7 @@ describe('known migrations', () => {
expect(known.has('Migration20200101043000_tour_break_additional_duration')).toBe(true);
expect(known.has('Migration20200101043100_an_imported_tour_has_no_hiking_difficulty')).toBe(true);
expect(known.has('Migration20200101043200_via_ferrata_tiles_outlive_a_restart')).toBe(true);
expect(known.has('Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata')).toBe(true);
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
});
@@ -44,6 +44,7 @@ const UNNUMBERED = [
'Migration20200101043000_tour_break_additional_duration',
'Migration20200101043100_an_imported_tour_has_no_hiking_difficulty',
'Migration20200101043200_via_ferrata_tiles_outlive_a_restart',
'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -1,6 +1,7 @@
/**
* `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3
* (`insertForPlace`) and TW4 (`deleteForPlace`).
* (`insertForPlace`) and TW4 (`deleteForPlace`), with a leg that follows a
* via ferrata's own line.
*/
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
import type {
@@ -89,4 +90,40 @@ describe('TourWaypointsRepository', () => {
]);
expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 });
});
it('TWREPO-005: a fixed leg is stored with its waypoint and read back whole; routed legs carry none', async () => {
const { trip, place } = tourInTrip();
const fixed_leg = {
kind: 'ferrata' as const,
ref: 'way/23971675',
name: 'Alpspitz Ferrata',
geometry: [
[47.4343, 11.0501],
[47.432, 11.0485],
[47.4295, 11.0478],
] as [number, number][],
};
await repo.insertForPlace(place.id, [
{ lat: 47.4387, lng: 11.0523, role: 'start', sequence: 0 },
{ lat: 47.4343, lng: 11.0501, role: 'via', sequence: 1, fixed_leg },
{ lat: 47.4295, lng: 11.0478, role: 'end', sequence: 2 },
]);
const points = await repo.listForPlace(place.id);
expect(points[1]).toEqual({ lat: 47.4343, lng: 11.0501, role: 'via', sequence: 1, fixed_leg });
expect(points[0]).not.toHaveProperty('fixed_leg');
expect(points[2]).not.toHaveProperty('fixed_leg');
expect((await repo.listForTrip(trip.id))[1]).toEqual({ place_id: place.id, ...points[1] });
});
it('TWREPO-006: a stored fixed leg that no longer decodes reads as a routed leg', async () => {
const { place } = tourInTrip();
await repo.insertForPlace(place.id, [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 1 },
]);
await t.em.nativeUpdate(TourWaypoints, { place: place.id, sequence: 0 }, { fixed_leg: '{"kind":"boat"}' });
t.clear();
expect((await repo.listForPlace(place.id))[0]).toEqual({ lat: 47, lng: 11, role: 'start', sequence: 0 });
});
});
@@ -0,0 +1,50 @@
/**
* `Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata`: tour
* waypoints gain a nullable `fixed_leg`, and every waypoint already saved
* keeps its routed leg.
*/
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const index = names.indexOf(TARGET);
expect(index).toBeGreaterThan(0);
await migrateTo(orm, names[index - 1]);
return orm;
}
const fixedLegColumn = (orm: MikroORM) =>
rawQuery<{ name: string; type: string; notnull: number }>(
orm,
"SELECT name, type, \"notnull\" FROM pragma_table_info('tour_waypoints') WHERE name = 'fixed_leg'",
);
describe('Tour fixed leg migration', () => {
it('TOURLEGMIG-001: adds a nullable fixed_leg text column, and existing waypoints keep a routed leg', async () => {
const orm = await ormBeforeTarget();
try {
expect(await fixedLegColumn(orm)).toEqual([]);
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@x.y', 'h')");
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')");
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (1, 1, 'Tour')");
await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (1, 'hike')");
await rawExec(
orm,
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (1, 47, 11, 'start', 0)",
);
await migrateTo(orm, TARGET);
expect(await fixedLegColumn(orm)).toEqual([{ name: 'fixed_leg', type: 'TEXT', notnull: 0 }]);
expect(await rawQuery(orm, 'SELECT fixed_leg FROM tour_waypoints')).toEqual([{ fixed_leg: null }]);
} finally {
await orm.close(true);
}
}, 30000);
});
+2
View File
@@ -17,5 +17,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'مسار تسلق حديدي بلا اسم',
'ferratas.viewInOsm': 'عرض في OpenStreetMap',
'ferratas.attribution': 'البيانات © مساهمو OpenStreetMap',
'ferratas.adopt': 'أضف إلى الجولة',
'ferratas.onTour': 'جزء من الجولة',
};
export default ferratas;
+7 -1
View File
@@ -75,7 +75,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'وقت المشي التقديري',
'tours.planner.walkingEstimatedShort': 'وقت المشي التقريبي',
'tours.planner.walkingEstimatedInfoLabel': 'كيف يُقدَّر وقت المشي',
'tours.planner.walkingEstimatedInfo': 'محسوب من المسافة والصعود والنزول وفق قاعدة زمن المشي المعتادة (DIN 33466)، دون الاستراحات.',
'tours.planner.walkingEstimatedInfo':
'محسوب من المسافة والصعود والنزول وفق قاعدة زمن المشي المعتادة (DIN 33466)، دون الاستراحات.',
'tours.planner.waypoints': 'نقاط المرور',
'tours.planner.waypointLabel': 'نقطة المرور {n}',
'tours.durationMinutes': '{count} د',
@@ -174,5 +175,10 @@ const tours: TranslationStrings = {
'tours.terrain.preloading': 'جارٍ تحميل المشهد: {percent} %',
'tours.terrain.placeOff': 'فعّل «تعيين النقاط» لوضع النقاط على التضاريس بالنقر.',
'tours.terrain.clickToPlan': 'انقر على التضاريس لإضافة نقاط. بمجرد وجود مسار يمكن التحليق فوقه وتلوينه حسب الانحدار.',
'tours.ferrata.badge': 'طريق حديدي {grade}',
'tours.ferrata.badgeNoGrade': 'طريق حديدي',
'tours.ferrata.crossingInfo': 'يمر المسار {length} عبر «{name}». يتطلب ذلك معدات الطريق الحديدي وخبرة.',
'tours.ferrata.crossingShort': 'يمر المسار عبر طريق حديدي',
'tours.ferrata.waypointAtEnd': 'بداية أو نهاية «{name}»',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Adsız via ferrata marşrutu',
'ferratas.viewInOsm': 'OpenStreetMap-də bax',
'ferratas.attribution': 'Məlumatlar © OpenStreetMap iştirakçıları',
'ferratas.adopt': 'Tura əlavə et',
'ferratas.onTour': 'Turun bir hissəsi',
};
export default ferratas;
+10 -2
View File
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Təhlükəsizlik haqqında',
'tours.planner.addPoint': 'Nöqtə əlavə et',
'tours.planner.addPointActive': 'Xəritəyə klikləyin',
'tours.planner.emptyHint': '“Nöqtə əlavə et”i aktiv edin və xəritəyə klikləyin. İki nöqtədən sonra TREK marşrutu hesablayır.',
'tours.planner.emptyHint':
'“Nöqtə əlavə et”i aktiv edin və xəritəyə klikləyin. İki nöqtədən sonra TREK marşrutu hesablayır.',
'tours.planner.walkingEstimated': 'Təxmini piyada vaxtı',
'tours.planner.walkingEstimatedShort': 'Təxm. piyada vaxtı',
'tours.planner.walkingEstimatedInfoLabel': 'Piyada vaxtı necə təxmin edilir',
'tours.planner.walkingEstimatedInfo': 'Məsafə, qalxma və enmədən adi yürüş vaxtı qaydası (DIN 33466) ilə hesablanır, fasilələr daxil deyil.',
'tours.planner.walkingEstimatedInfo':
'Məsafə, qalxma və enmədən adi yürüş vaxtı qaydası (DIN 33466) ilə hesablanır, fasilələr daxil deyil.',
'tours.planner.waypoints': 'Yol nöqtələri',
'tours.planner.waypointLabel': 'Yol nöqtəsi {n}',
'tours.durationMinutes': '{count} dəq.',
@@ -170,5 +172,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Relyefə klikləyib nöqtə qoymaq üçün «Nöqtə qoy» alətini aç.',
'tours.terrain.clickToPlan':
'Nöqtə qoymaq üçün əraziyə klikləyin. Marşrut yarandıqdan sonra onun üzərindən uçmaq və meyilə görə rəngləmək olar.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Marşrut {length} boyunca “{name}” üzərindən keçir. Bunun üçün via ferrata ləvazimatı və təcrübə lazımdır.',
'tours.ferrata.crossingShort': 'Marşrut via ferrata üzərindən keçir',
'tours.ferrata.waypointAtEnd': '“{name}” başlanğıcı və ya sonu',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrata sem nome',
'ferratas.viewInOsm': 'Ver no OpenStreetMap',
'ferratas.attribution': 'Dados © colaboradores do OpenStreetMap',
'ferratas.adopt': 'Adicionar ao passeio',
'ferratas.onTour': 'Parte do passeio',
};
export default ferratas;
+10 -2
View File
@@ -70,11 +70,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Sobre segurança',
'tours.planner.addPoint': 'Adicionar ponto',
'tours.planner.addPointActive': 'Clique no mapa',
'tours.planner.emptyHint': 'Ative “Adicionar ponto” e clique no mapa. A partir de dois pontos, o TREK calcula a rota.',
'tours.planner.emptyHint':
'Ative “Adicionar ponto” e clique no mapa. A partir de dois pontos, o TREK calcula a rota.',
'tours.planner.walkingEstimated': 'Tempo de caminhada estimado',
'tours.planner.walkingEstimatedShort': 'Tempo est.',
'tours.planner.walkingEstimatedInfoLabel': 'Como o tempo de caminhada é estimado',
'tours.planner.walkingEstimatedInfo': 'Calculado a partir da distância, subida e descida pela regra usual de tempo de caminhada (DIN 33466), sem pausas.',
'tours.planner.walkingEstimatedInfo':
'Calculado a partir da distância, subida e descida pela regra usual de tempo de caminhada (DIN 33466), sem pausas.',
'tours.planner.waypoints': 'Pontos de passagem',
'tours.planner.waypointLabel': 'Ponto de passagem {n}',
'tours.durationMinutes': '{count} min',
@@ -171,5 +173,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Ative Marcar pontos para clicar pontos no terreno.',
'tours.terrain.clickToPlan':
'Clique no terreno para adicionar pontos. Assim que houver uma rota, ela pode ser sobrevoada e colorida pela inclinação.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'A rota passa {length} pela “{name}”. Isso exige equipamento de via ferrata e experiência.',
'tours.ferrata.crossingShort': 'A rota passa por uma via ferrata',
'tours.ferrata.waypointAtEnd': 'Início ou fim de “{name}”',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrada sense nom',
'ferratas.viewInOsm': 'Mostra a OpenStreetMap',
'ferratas.attribution': "Dades © col·laboradors d'OpenStreetMap",
'ferratas.adopt': 'Afegeix a la ruta',
'ferratas.onTour': 'Part de la ruta',
};
export default ferratas;
+9 -2
View File
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Sobre la seguretat',
'tours.planner.addPoint': 'Afegeix un punt',
'tours.planner.addPointActive': 'Fes clic al mapa',
'tours.planner.emptyHint': 'Activa «Afegeix un punt» i fes clic al mapa. A partir de dos punts, TREK calcula la ruta.',
'tours.planner.emptyHint':
'Activa «Afegeix un punt» i fes clic al mapa. A partir de dos punts, TREK calcula la ruta.',
'tours.planner.walkingEstimated': 'Temps a peu estimat',
'tours.planner.walkingEstimatedShort': 'Temps est.',
'tours.planner.walkingEstimatedInfoLabel': 'Com s’estima el temps a peu',
'tours.planner.walkingEstimatedInfo': 'Calculat a partir de la distància, la pujada i la baixada amb la regla habitual de temps de marxa (DIN 33466), sense pauses.',
'tours.planner.walkingEstimatedInfo':
'Calculat a partir de la distància, la pujada i la baixada amb la regla habitual de temps de marxa (DIN 33466), sense pauses.',
'tours.planner.waypoints': 'Punts de pas',
'tours.planner.waypointLabel': 'Punt de pas {n}',
'tours.durationMinutes': '{count} min',
@@ -170,5 +172,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Activa Posar punts per afegir punts al terreny amb un clic.',
'tours.terrain.clickToPlan':
'Fes clic al terreny per afegir punts. Quan hi hagi una ruta, es podrà sobrevolar i acolorir segons el pendent.',
'tours.ferrata.badge': 'Via ferrada {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrada',
'tours.ferrata.crossingInfo': 'La ruta fa {length} per «{name}». Cal material de via ferrada i experiència.',
'tours.ferrata.crossingShort': 'La ruta passa per una via ferrada',
'tours.ferrata.waypointAtEnd': 'Inici o final de «{name}»',
};
export default tours;
+2
View File
@@ -14,5 +14,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Ferrata bez názvu',
'ferratas.viewInOsm': 'Zobrazit v OpenStreetMap',
'ferratas.attribution': 'Data © přispěvatelé OpenStreetMap',
'ferratas.adopt': 'Přidat do túry',
'ferratas.onTour': 'Součást túry',
};
export default ferratas;
+7 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Odhadovaný čas chůze',
'tours.planner.walkingEstimatedShort': 'Odhad. čas chůze',
'tours.planner.walkingEstimatedInfoLabel': 'Jak se čas chůze odhaduje',
'tours.planner.walkingEstimatedInfo': 'Vypočteno ze vzdálenosti, stoupání a klesání podle obvyklého pravidla pro čas chůze (DIN 33466), bez přestávek.',
'tours.planner.walkingEstimatedInfo':
'Vypočteno ze vzdálenosti, stoupání a klesání podle obvyklého pravidla pro čas chůze (DIN 33466), bez přestávek.',
'tours.planner.waypoints': 'Průjezdní body',
'tours.planner.waypointLabel': 'Bod {n}',
'tours.durationMinutes': '{count} min',
@@ -169,5 +170,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Zapni Přidávat body a klikáním je přidávej do terénu.',
'tours.terrain.clickToPlan':
'Kliknutím do terénu přidáte body. Jakmile vznikne trasa, lze ji proletět a obarvit podle sklonu.',
'tours.ferrata.badge': 'Ferata {grade}',
'tours.ferrata.badgeNoGrade': 'Ferata',
'tours.ferrata.crossingInfo': 'Trasa vede {length} po „{name}“. Potřebujete feratové vybavení a zkušenosti.',
'tours.ferrata.crossingShort': 'Trasa vede po feratě',
'tours.ferrata.waypointAtEnd': 'Začátek nebo konec „{name}“',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Klettersteig ohne Namen',
'ferratas.viewInOsm': 'In OpenStreetMap ansehen',
'ferratas.attribution': 'Daten © OpenStreetMap-Mitwirkende',
'ferratas.adopt': 'In die Tour übernehmen',
'ferratas.onTour': 'Teil der Tour',
};
export default ferratas;
+10 -2
View File
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Hinweis zur Sicherheit',
'tours.planner.addPoint': 'Punkt hinzufügen',
'tours.planner.addPointActive': 'Auf Karte klicken',
'tours.planner.emptyHint': 'Schalte „Punkt hinzufügen“ ein und klicke auf die Karte. Ab zwei Punkten berechnet TREK die Route.',
'tours.planner.emptyHint':
'Schalte „Punkt hinzufügen“ ein und klicke auf die Karte. Ab zwei Punkten berechnet TREK die Route.',
'tours.planner.walkingEstimated': 'Geschätzte Gehzeit',
'tours.planner.walkingEstimatedShort': 'Gesch. Gehzeit',
'tours.planner.walkingEstimatedInfoLabel': 'Wie die Gehzeit geschätzt wird',
'tours.planner.walkingEstimatedInfo': 'Nach der üblichen Wanderzeit-Regel (DIN 33466) aus Strecke, Auf- und Abstieg berechnet, ohne Pausen.',
'tours.planner.walkingEstimatedInfo':
'Nach der üblichen Wanderzeit-Regel (DIN 33466) aus Strecke, Auf- und Abstieg berechnet, ohne Pausen.',
'tours.planner.waypoints': 'Wegpunkte',
'tours.planner.waypointLabel': 'Wegpunkt {n}',
'tours.durationMinutes': '{count} Min.',
@@ -171,6 +173,12 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Schalte „Punkte setzen“ ein, um Punkte ins Gelände zu klicken.',
'tours.terrain.clickToPlan':
'Klicke ins Gelände, um Punkte zu setzen. Sobald eine Route steht, lässt sie sich abfliegen und nach Steigung einfärben.',
'tours.ferrata.badge': 'Klettersteig {grade}',
'tours.ferrata.badgeNoGrade': 'Klettersteig',
'tours.ferrata.crossingInfo':
'Die Route führt {length} über „{name}“. Dafür brauchst du Klettersteigausrüstung und Erfahrung.',
'tours.ferrata.crossingShort': 'Die Route führt über einen Klettersteig',
'tours.ferrata.waypointAtEnd': 'Anfang oder Ende von „{name}“',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Unnamed via ferrata',
'ferratas.viewInOsm': 'View in OpenStreetMap',
'ferratas.attribution': 'Data © OpenStreetMap contributors',
'ferratas.adopt': 'Add to tour',
'ferratas.onTour': 'Part of the tour',
};
export default ferratas;
+7 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Estimated walking time',
'tours.planner.walkingEstimatedShort': 'Est. walking time',
'tours.planner.walkingEstimatedInfoLabel': 'How the walking time is estimated',
'tours.planner.walkingEstimatedInfo': 'Worked out from distance, climb and descent with the usual hiking time rule (DIN 33466), without breaks.',
'tours.planner.walkingEstimatedInfo':
'Worked out from distance, climb and descent with the usual hiking time rule (DIN 33466), without breaks.',
'tours.planner.waypoints': 'Waypoints',
'tours.planner.waypointLabel': 'Waypoint {n}',
'tours.durationMinutes': '{count} min',
@@ -168,5 +169,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Turn on Set points to click points onto the terrain.',
'tours.terrain.clickToPlan':
'Click the terrain to set points. Once there is a route, it can be flown and coloured by grade.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo': 'The route climbs {length} of “{name}”. That takes via ferrata gear and experience.',
'tours.ferrata.crossingShort': 'The route climbs a via ferrata',
'tours.ferrata.waypointAtEnd': 'Start or end of “{name}”',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Vía ferrata sin nombre',
'ferratas.viewInOsm': 'Ver en OpenStreetMap',
'ferratas.attribution': 'Datos © colaboradores de OpenStreetMap',
'ferratas.adopt': 'Añadir a la ruta',
'ferratas.onTour': 'Parte de la ruta',
};
export default ferratas;
+9 -2
View File
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Sobre la seguridad',
'tours.planner.addPoint': 'Añadir punto',
'tours.planner.addPointActive': 'Haz clic en el mapa',
'tours.planner.emptyHint': 'Activa «Añadir punto» y haz clic en el mapa. A partir de dos puntos, TREK calcula la ruta.',
'tours.planner.emptyHint':
'Activa «Añadir punto» y haz clic en el mapa. A partir de dos puntos, TREK calcula la ruta.',
'tours.planner.walkingEstimated': 'Tiempo a pie estimado',
'tours.planner.walkingEstimatedShort': 'Tiempo est.',
'tours.planner.walkingEstimatedInfoLabel': 'Cómo se estima el tiempo a pie',
'tours.planner.walkingEstimatedInfo': 'Calculado a partir de la distancia, la subida y la bajada con la regla habitual de tiempo de marcha (DIN 33466), sin pausas.',
'tours.planner.walkingEstimatedInfo':
'Calculado a partir de la distancia, la subida y la bajada con la regla habitual de tiempo de marcha (DIN 33466), sin pausas.',
'tours.planner.waypoints': 'Puntos de paso',
'tours.planner.waypointLabel': 'Punto de paso {n}',
'tours.durationMinutes': '{count} min',
@@ -169,5 +171,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Activa Colocar puntos para añadir puntos en el terreno con un clic.',
'tours.terrain.clickToPlan':
'Haz clic en el terreno para añadir puntos. En cuanto haya una ruta, se podrá sobrevolar y colorear según la pendiente.',
'tours.ferrata.badge': 'Vía ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Vía ferrata',
'tours.ferrata.crossingInfo': 'La ruta recorre {length} de «{name}». Necesitas equipo de vía ferrata y experiencia.',
'tours.ferrata.crossingShort': 'La ruta pasa por una vía ferrata',
'tours.ferrata.waypointAtEnd': 'Inicio o final de «{name}»',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Nimeta via ferrata',
'ferratas.viewInOsm': 'Vaata OpenStreetMapis',
'ferratas.attribution': 'Andmed © OpenStreetMapi kaastöötajad',
'ferratas.adopt': 'Lisa matkale',
'ferratas.onTour': 'Matka osa',
};
export default ferratas;
+10 -2
View File
@@ -68,11 +68,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Ohutusest',
'tours.planner.addPoint': 'Lisa punkt',
'tours.planner.addPointActive': 'Klõpsa kaardil',
'tours.planner.emptyHint': 'Lülita sisse „Lisa punkt“ ja klõpsa kaardil. Alates kahest punktist arvutab TREK marsruudi.',
'tours.planner.emptyHint':
'Lülita sisse „Lisa punkt“ ja klõpsa kaardil. Alates kahest punktist arvutab TREK marsruudi.',
'tours.planner.walkingEstimated': 'Hinnanguline kõndimisaeg',
'tours.planner.walkingEstimatedShort': 'Hinn. kõndimisaeg',
'tours.planner.walkingEstimatedInfoLabel': 'Kuidas kõndimisaega hinnatakse',
'tours.planner.walkingEstimatedInfo': 'Arvutatud vahemaa, tõusu ja laskumise põhjal tavapärase matkaaja reegli (DIN 33466) järgi, ilma pausideta.',
'tours.planner.walkingEstimatedInfo':
'Arvutatud vahemaa, tõusu ja laskumise põhjal tavapärase matkaaja reegli (DIN 33466) järgi, ilma pausideta.',
'tours.planner.waypoints': 'Teekonnapunktid',
'tours.planner.waypointLabel': 'Teekonnapunkt {n}',
'tours.durationMinutes': '{count} min',
@@ -169,5 +171,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Lülita sisse Lisa punkte, et maastikule klõpsates punkte lisada.',
'tours.terrain.clickToPlan':
'Klõpsa maastikul, et punkte lisada. Kui marsruut on olemas, saab selle üle lennata ja kalde järgi värvida.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Marsruut läbib {length} rajal „{name}“. Selleks on vaja via ferrata varustust ja kogemust.',
'tours.ferrata.crossingShort': 'Marsruut läbib via ferrata raja',
'tours.ferrata.waypointAtEnd': '„{name}“ algus või lõpp',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrata sans nom',
'ferratas.viewInOsm': 'Voir dans OpenStreetMap',
'ferratas.attribution': 'Données © contributeurs OpenStreetMap',
'ferratas.adopt': 'Ajouter à la randonnée',
'ferratas.onTour': 'Fait partie de la randonnée',
};
export default ferratas;
+10 -2
View File
@@ -71,11 +71,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'À propos de la sécurité',
'tours.planner.addPoint': 'Ajouter un point',
'tours.planner.addPointActive': 'Cliquez sur la carte',
'tours.planner.emptyHint': 'Activez « Ajouter un point » et cliquez sur la carte. Dès deux points, TREK calcule l’itinéraire.',
'tours.planner.emptyHint':
'Activez « Ajouter un point » et cliquez sur la carte. Dès deux points, TREK calcule l’itinéraire.',
'tours.planner.walkingEstimated': 'Temps de marche estimé',
'tours.planner.walkingEstimatedShort': 'Temps estimé',
'tours.planner.walkingEstimatedInfoLabel': 'Comment le temps de marche est estimé',
'tours.planner.walkingEstimatedInfo': 'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle habituelle de temps de marche (DIN 33466), sans les pauses.',
'tours.planner.walkingEstimatedInfo':
'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle habituelle de temps de marche (DIN 33466), sans les pauses.',
'tours.planner.waypoints': 'Points de passage',
'tours.planner.waypointLabel': 'Point de passage {n}',
'tours.durationMinutes': '{count} min',
@@ -174,5 +176,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Activez Placer des points pour cliquer des points sur le terrain.',
'tours.terrain.clickToPlan':
"Cliquez sur le relief pour placer des points. Dès qu'un itinéraire existe, il peut être survolé et coloré selon la pente.",
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'L’itinéraire passe {length} sur « {name} ». Il faut l’équipement de via ferrata et de l’expérience.',
'tours.ferrata.crossingShort': 'L’itinéraire passe par une via ferrata',
'tours.ferrata.waypointAtEnd': 'Début ou fin de « {name} »',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Διαδρομή via ferrata χωρίς όνομα',
'ferratas.viewInOsm': 'Προβολή στο OpenStreetMap',
'ferratas.attribution': 'Δεδομένα © συντελεστές του OpenStreetMap',
'ferratas.adopt': 'Προσθήκη στη διαδρομή',
'ferratas.onTour': 'Μέρος της διαδρομής',
};
export default ferratas;
+10 -2
View File
@@ -71,11 +71,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Σχετικά με την ασφάλεια',
'tours.planner.addPoint': 'Προσθήκη σημείου',
'tours.planner.addPointActive': 'Κλικ στον χάρτη',
'tours.planner.emptyHint': 'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.',
'tours.planner.emptyHint':
'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.',
'tours.planner.walkingEstimated': 'Εκτιμώμενος χρόνος πεζοπορίας',
'tours.planner.walkingEstimatedShort': 'Εκτ. χρόνος',
'tours.planner.walkingEstimatedInfoLabel': 'Πώς εκτιμάται ο χρόνος πεζοπορίας',
'tours.planner.walkingEstimatedInfo': 'Υπολογίζεται από την απόσταση, την ανάβαση και την κατάβαση με τον συνήθη κανόνα χρόνου πεζοπορίας (DIN 33466), χωρίς διαλείμματα.',
'tours.planner.walkingEstimatedInfo':
'Υπολογίζεται από την απόσταση, την ανάβαση και την κατάβαση με τον συνήθη κανόνα χρόνου πεζοπορίας (DIN 33466), χωρίς διαλείμματα.',
'tours.planner.waypoints': 'Σημεία διέλευσης',
'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}',
'tours.durationMinutes': '{count} λεπτά',
@@ -178,5 +180,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Ενεργοποίησε την Τοποθέτηση σημείων για να βάζεις σημεία στο έδαφος με κλικ.',
'tours.terrain.clickToPlan':
'Κάνε κλικ στο έδαφος για να ορίσεις σημεία. Μόλις υπάρξει διαδρομή, μπορείς να την πετάξεις και να τη χρωματίσεις κατά κλίση.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Η διαδρομή περνά {length} από το «{name}». Χρειάζεσαι εξοπλισμό via ferrata και εμπειρία.',
'tours.ferrata.crossingShort': 'Η διαδρομή περνά από via ferrata',
'tours.ferrata.waypointAtEnd': 'Αρχή ή τέλος του «{name}»',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Névtelen vasalt út',
'ferratas.viewInOsm': 'Megtekintés az OpenStreetMapen',
'ferratas.attribution': 'Adatok © OpenStreetMap-közreműködők',
'ferratas.adopt': 'Hozzáadás a túrához',
'ferratas.onTour': 'A túra része',
};
export default ferratas;
+10 -2
View File
@@ -68,11 +68,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'A biztonságról',
'tours.planner.addPoint': 'Pont hozzáadása',
'tours.planner.addPointActive': 'Kattints a térképre',
'tours.planner.emptyHint': 'Kapcsold be a „Pont hozzáadása” gombot, és kattints a térképre. Két ponttól a TREK kiszámolja az útvonalat.',
'tours.planner.emptyHint':
'Kapcsold be a „Pont hozzáadása” gombot, és kattints a térképre. Két ponttól a TREK kiszámolja az útvonalat.',
'tours.planner.walkingEstimated': 'Becsült gyaloglási idő',
'tours.planner.walkingEstimatedShort': 'Becs. idő',
'tours.planner.walkingEstimatedInfoLabel': 'Hogyan becsüljük a gyaloglási időt',
'tours.planner.walkingEstimatedInfo': 'A távolságból, az emelkedésből és a lejtésből számolva a szokásos túraidő-szabály (DIN 33466) szerint, szünetek nélkül.',
'tours.planner.walkingEstimatedInfo':
'A távolságból, az emelkedésből és a lejtésből számolva a szokásos túraidő-szabály (DIN 33466) szerint, szünetek nélkül.',
'tours.planner.waypoints': 'Útvonalpontok',
'tours.planner.waypointLabel': '{n}. útvonalpont',
'tours.durationMinutes': '{count} perc',
@@ -172,5 +174,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Kapcsold be a Pontok elhelyezése eszközt, hogy kattintással pontokat tegyél a terepre.',
'tours.terrain.clickToPlan':
'Kattints a terepre pontok elhelyezéséhez. Ha már van útvonal, átrepülheted és meredekség szerint színezheted.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Az útvonal {length} hosszan a(z) „{name}” vasalt úton halad. Ehhez via ferrata felszerelés és gyakorlat kell.',
'tours.ferrata.crossingShort': 'Az útvonal vasalt úton halad',
'tours.ferrata.waypointAtEnd': '„{name}” eleje vagy vége',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrata tanpa nama',
'ferratas.viewInOsm': 'Lihat di OpenStreetMap',
'ferratas.attribution': 'Data © kontributor OpenStreetMap',
'ferratas.adopt': 'Tambahkan ke tur',
'ferratas.onTour': 'Bagian dari tur',
};
export default ferratas;
+8 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Perkiraan waktu jalan',
'tours.planner.walkingEstimatedShort': 'Perk. waktu jalan',
'tours.planner.walkingEstimatedInfoLabel': 'Cara waktu jalan diperkirakan',
'tours.planner.walkingEstimatedInfo': 'Dihitung dari jarak, tanjakan, dan turunan dengan aturan waktu mendaki yang umum (DIN 33466), tanpa istirahat.',
'tours.planner.walkingEstimatedInfo':
'Dihitung dari jarak, tanjakan, dan turunan dengan aturan waktu mendaki yang umum (DIN 33466), tanpa istirahat.',
'tours.planner.waypoints': 'Titik lintasan',
'tours.planner.waypointLabel': 'Titik lintasan {n}',
'tours.durationMinutes': '{count} menit',
@@ -169,5 +170,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Aktifkan Tempatkan titik untuk menaruh titik di medan dengan klik.',
'tours.terrain.clickToPlan':
'Klik medan untuk menambahkan titik. Setelah ada rute, rute itu bisa diterbangi dan diwarnai menurut kemiringan.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Rute melewati {length} di “{name}”. Ini butuh perlengkapan via ferrata dan pengalaman.',
'tours.ferrata.crossingShort': 'Rute melewati via ferrata',
'tours.ferrata.waypointAtEnd': 'Awal atau akhir “{name}”',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrata senza nome',
'ferratas.viewInOsm': 'Vedi su OpenStreetMap',
'ferratas.attribution': 'Dati © contributori di OpenStreetMap',
'ferratas.adopt': 'Aggiungi al giro',
'ferratas.onTour': 'Parte del giro',
};
export default ferratas;
+10 -2
View File
@@ -70,11 +70,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'Sulla sicurezza',
'tours.planner.addPoint': 'Aggiungi punto',
'tours.planner.addPointActive': 'Clicca sulla mappa',
'tours.planner.emptyHint': 'Attiva «Aggiungi punto» e fai clic sulla mappa. Da due punti in poi TREK calcola il percorso.',
'tours.planner.emptyHint':
'Attiva «Aggiungi punto» e fai clic sulla mappa. Da due punti in poi TREK calcola il percorso.',
'tours.planner.walkingEstimated': 'Tempo di cammino stimato',
'tours.planner.walkingEstimatedShort': 'Tempo stim.',
'tours.planner.walkingEstimatedInfoLabel': 'Come viene stimato il tempo di cammino',
'tours.planner.walkingEstimatedInfo': 'Calcolato da distanza, salita e discesa con la consueta regola dei tempi di cammino (DIN 33466), senza pause.',
'tours.planner.walkingEstimatedInfo':
'Calcolato da distanza, salita e discesa con la consueta regola dei tempi di cammino (DIN 33466), senza pause.',
'tours.planner.waypoints': 'Punti di passaggio',
'tours.planner.waypointLabel': 'Punto di passaggio {n}',
'tours.durationMinutes': '{count} min',
@@ -171,5 +173,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Attiva Posiziona punti per aggiungere punti sul terreno con un clic.',
'tours.terrain.clickToPlan':
"Fai clic sul terreno per aggiungere punti. Appena c'è un percorso, puoi sorvolarlo e colorarlo in base alla pendenza.",
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Il percorso sale per {length} sulla «{name}». Servono attrezzatura da ferrata ed esperienza.',
'tours.ferrata.crossingShort': 'Il percorso passa per una via ferrata',
'tours.ferrata.waypointAtEnd': 'Inizio o fine di «{name}»',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': '名称未設定のヴィア・フェラータ',
'ferratas.viewInOsm': 'OpenStreetMap で表示',
'ferratas.attribution': 'データ © OpenStreetMap 貢献者',
'ferratas.adopt': 'ツアーに追加',
'ferratas.onTour': 'ツアーの一部',
};
export default ferratas;
+7 -1
View File
@@ -70,7 +70,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': '推定歩行時間',
'tours.planner.walkingEstimatedShort': '推定時間',
'tours.planner.walkingEstimatedInfoLabel': '歩行時間の推定方法',
'tours.planner.walkingEstimatedInfo': '一般的な歩行時間の規則(DIN 33466)で、距離と登り・下りから計算しています。休憩は含みません。',
'tours.planner.walkingEstimatedInfo':
'一般的な歩行時間の規則(DIN 33466)で、距離と登り・下りから計算しています。休憩は含みません。',
'tours.planner.waypoints': 'ルート上の地点',
'tours.planner.waypointLabel': '地点{n}',
'tours.durationMinutes': '{count}分',
@@ -164,5 +165,10 @@ const tours: TranslationStrings = {
'tours.terrain.preloading': '地形を読み込み中: {percent} %',
'tours.terrain.placeOff': '地形をクリックしてポイントを置くには「ポイントを配置」をオンにしてください。',
'tours.terrain.clickToPlan': '地形をクリックしてポイントを置きます。ルートができると、飛行して勾配で色分けできます。',
'tours.ferrata.badge': 'ヴィア・フェラータ {grade}',
'tours.ferrata.badgeNoGrade': 'ヴィア・フェラータ',
'tours.ferrata.crossingInfo': 'ルートは「{name}」を {length} 通ります。ヴィア・フェラータ用の装備と経験が必要です。',
'tours.ferrata.crossingShort': 'ルートがヴィア・フェラータを通ります',
'tours.ferrata.waypointAtEnd': '「{name}」の始点または終点',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': '이름 없는 비아 페라타',
'ferratas.viewInOsm': 'OpenStreetMap에서 보기',
'ferratas.attribution': '데이터 © OpenStreetMap 기여자',
'ferratas.adopt': '투어에 추가',
'ferratas.onTour': '투어의 일부',
};
export default ferratas;
+7 -1
View File
@@ -71,7 +71,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': '예상 보행 시간',
'tours.planner.walkingEstimatedShort': '예상 시간',
'tours.planner.walkingEstimatedInfoLabel': '보행 시간을 추정하는 방법',
'tours.planner.walkingEstimatedInfo': '일반적인 산행 시간 규칙(DIN 33466)에 따라 거리, 오르막, 내리막으로 계산하며 휴식은 포함하지 않습니다.',
'tours.planner.walkingEstimatedInfo':
'일반적인 산행 시간 규칙(DIN 33466)에 따라 거리, 오르막, 내리막으로 계산하며 휴식은 포함하지 않습니다.',
'tours.planner.waypoints': '경로 지점',
'tours.planner.waypointLabel': '지점 {n}',
'tours.durationMinutes': '{count}분',
@@ -166,5 +167,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': '지형을 클릭해 지점을 찍으려면 지점 찍기를 켜세요.',
'tours.terrain.clickToPlan':
'지형을 클릭해 지점을 추가하세요. 경로가 생기면 비행하고 경사별로 색을 입힐 수 있습니다.',
'tours.ferrata.badge': '비아 페라타 {grade}',
'tours.ferrata.badgeNoGrade': '비아 페라타',
'tours.ferrata.crossingInfo': '경로가 “{name}”을(를) {length} 지납니다. 비아 페라타 장비와 경험이 필요합니다.',
'tours.ferrata.crossingShort': '경로가 비아 페라타를 지납니다',
'tours.ferrata.waypointAtEnd': '“{name}”의 시작 또는 끝',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrata zonder naam',
'ferratas.viewInOsm': 'Bekijken in OpenStreetMap',
'ferratas.attribution': 'Gegevens © OpenStreetMap-bijdragers',
'ferratas.adopt': 'Aan tocht toevoegen',
'ferratas.onTour': 'Deel van de tocht',
};
export default ferratas;
+8 -1
View File
@@ -73,7 +73,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Geschatte looptijd',
'tours.planner.walkingEstimatedShort': 'Geschatte tijd',
'tours.planner.walkingEstimatedInfoLabel': 'Hoe de looptijd wordt geschat',
'tours.planner.walkingEstimatedInfo': 'Berekend uit afstand, stijging en daling met de gangbare wandeltijdregel (DIN 33466), zonder pauzes.',
'tours.planner.walkingEstimatedInfo':
'Berekend uit afstand, stijging en daling met de gangbare wandeltijdregel (DIN 33466), zonder pauzes.',
'tours.planner.waypoints': 'Routepunten',
'tours.planner.waypointLabel': 'Routepunt {n}',
'tours.durationMinutes': '{count} min',
@@ -170,5 +171,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Zet Punten plaatsen aan om punten op het terrein te klikken.',
'tours.terrain.clickToPlan':
'Klik in het terrein om punten te zetten. Zodra er een route is, kun je eroverheen vliegen en hem op helling kleuren.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'De route loopt {length} over „{name}”. Daarvoor heb je via-ferratamateriaal en ervaring nodig.',
'tours.ferrata.crossingShort': 'De route loopt over een via ferrata',
'tours.ferrata.waypointAtEnd': 'Begin of einde van „{name}”',
};
export default tours;
+2
View File
@@ -15,5 +15,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Ferrata bez nazwy',
'ferratas.viewInOsm': 'Pokaż w OpenStreetMap',
'ferratas.attribution': 'Dane © współtwórcy OpenStreetMap',
'ferratas.adopt': 'Dodaj do wycieczki',
'ferratas.onTour': 'Część wycieczki',
};
export default ferratas;
+8 -1
View File
@@ -75,7 +75,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Szacowany czas marszu',
'tours.planner.walkingEstimatedShort': 'Szac. czas marszu',
'tours.planner.walkingEstimatedInfoLabel': 'Jak szacowany jest czas marszu',
'tours.planner.walkingEstimatedInfo': 'Obliczony z odległości, podejścia i zejścia według zwykłej reguły czasu marszu (DIN 33466), bez przerw.',
'tours.planner.walkingEstimatedInfo':
'Obliczony z odległości, podejścia i zejścia według zwykłej reguły czasu marszu (DIN 33466), bez przerw.',
'tours.planner.waypoints': 'Punkty trasy',
'tours.planner.waypointLabel': 'Punkt trasy {n}',
'tours.durationMinutes': '{count} min',
@@ -174,5 +175,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Włącz Ustawiaj punkty, aby klikaniem dodawać punkty w terenie.',
'tours.terrain.clickToPlan':
'Kliknij teren, aby dodać punkty. Gdy powstanie trasa, można nad nią przelecieć i pokolorować ją według nachylenia.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Trasa prowadzi {length} przez „{name}”. Potrzebny jest sprzęt do via ferraty i doświadczenie.',
'tours.ferrata.crossingShort': 'Trasa prowadzi przez via ferratę',
'tours.ferrata.waypointAtEnd': 'Początek lub koniec „{name}”',
};
export default tours;
+2
View File
@@ -15,5 +15,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Виа феррата без названия',
'ferratas.viewInOsm': 'Открыть в OpenStreetMap',
'ferratas.attribution': 'Данные © участники OpenStreetMap',
'ferratas.adopt': 'Добавить в маршрут',
'ferratas.onTour': 'Часть маршрута',
};
export default ferratas;
+10 -2
View File
@@ -71,11 +71,13 @@ const tours: TranslationStrings = {
'tours.planner.safetyInfoLabel': 'О безопасности',
'tours.planner.addPoint': 'Добавить точку',
'tours.planner.addPointActive': 'Нажмите на карту',
'tours.planner.emptyHint': 'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.',
'tours.planner.emptyHint':
'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.',
'tours.planner.walkingEstimated': 'Примерное время в пути',
'tours.planner.walkingEstimatedShort': 'Прим. время',
'tours.planner.walkingEstimatedInfoLabel': 'Как оценивается время в пути',
'tours.planner.walkingEstimatedInfo': 'Рассчитано по расстоянию, подъёму и спуску по обычному правилу времени в пути (DIN 33466), без перерывов.',
'tours.planner.walkingEstimatedInfo':
'Рассчитано по расстоянию, подъёму и спуску по обычному правилу времени в пути (DIN 33466), без перерывов.',
'tours.planner.waypoints': 'Путевые точки',
'tours.planner.waypointLabel': 'Путевая точка {n}',
'tours.durationMinutes': '{count} мин',
@@ -174,5 +176,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Включите «Ставить точки», чтобы ставить точки на рельефе щелчком.',
'tours.terrain.clickToPlan':
'Нажмите на рельеф, чтобы поставить точки. Когда появится маршрут, его можно облететь и раскрасить по уклону.',
'tours.ferrata.badge': 'Виа феррата {grade}',
'tours.ferrata.badgeNoGrade': 'Виа феррата',
'tours.ferrata.crossingInfo':
'Маршрут проходит {length} по «{name}». Для этого нужны снаряжение для виа ферраты и опыт.',
'tours.ferrata.crossingShort': 'Маршрут проходит по виа феррате',
'tours.ferrata.waypointAtEnd': 'Начало или конец «{name}»',
};
export default tours;
+2
View File
@@ -14,5 +14,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Ferrata bez názvu',
'ferratas.viewInOsm': 'Zobraziť v OpenStreetMap',
'ferratas.attribution': 'Údaje © prispievatelia OpenStreetMap',
'ferratas.adopt': 'Pridať do túry',
'ferratas.onTour': 'Súčasť túry',
};
export default ferratas;
+7 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Odhadovaný čas chôdze',
'tours.planner.walkingEstimatedShort': 'Odhad. čas chôdze',
'tours.planner.walkingEstimatedInfoLabel': 'Ako sa čas chôdze odhaduje',
'tours.planner.walkingEstimatedInfo': 'Vypočítané zo vzdialenosti, stúpania a klesania podľa bežného pravidla pre čas chôdze (DIN 33466), bez prestávok.',
'tours.planner.walkingEstimatedInfo':
'Vypočítané zo vzdialenosti, stúpania a klesania podľa bežného pravidla pre čas chôdze (DIN 33466), bez prestávok.',
'tours.planner.waypoints': 'Body trasy',
'tours.planner.waypointLabel': 'Bod trasy {n}',
'tours.durationMinutes': '{count} min',
@@ -169,5 +170,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Zapni Pridávať body a klikaním ich pridávaj do terénu.',
'tours.terrain.clickToPlan':
'Kliknutím do terénu pridáte body. Keď vznikne trasa, dá sa preletieť a zafarbiť podľa sklonu.',
'tours.ferrata.badge': 'Ferata {grade}',
'tours.ferrata.badgeNoGrade': 'Ferata',
'tours.ferrata.crossingInfo': 'Trasa vedie {length} po „{name}“. Potrebujete feratovú výstroj a skúsenosti.',
'tours.ferrata.crossingShort': 'Trasa vedie po ferate',
'tours.ferrata.waypointAtEnd': 'Začiatok alebo koniec „{name}“',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Namnlös via ferrata',
'ferratas.viewInOsm': 'Visa i OpenStreetMap',
'ferratas.attribution': 'Data © OpenStreetMap-bidragsgivare',
'ferratas.adopt': 'Lägg till i turen',
'ferratas.onTour': 'Del av turen',
};
export default ferratas;
+7 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Uppskattad gångtid',
'tours.planner.walkingEstimatedShort': 'Uppsk. gångtid',
'tours.planner.walkingEstimatedInfoLabel': 'Hur gångtiden uppskattas',
'tours.planner.walkingEstimatedInfo': 'Beräknad utifrån sträcka, stigning och nedstigning enligt den vanliga vandringstidsregeln (DIN 33466), utan pauser.',
'tours.planner.walkingEstimatedInfo':
'Beräknad utifrån sträcka, stigning och nedstigning enligt den vanliga vandringstidsregeln (DIN 33466), utan pauser.',
'tours.planner.waypoints': 'Ruttpunkter',
'tours.planner.waypointLabel': 'Ruttpunkt {n}',
'tours.durationMinutes': '{count} min',
@@ -168,5 +169,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Slå på Sätt punkter för att klicka ut punkter i terrängen.',
'tours.terrain.clickToPlan':
'Klicka i terrängen för att sätta ut punkter. När det finns en rutt kan du flyga över den och färga den efter lutning.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo': 'Rutten går {length} längs ”{name}”. Det kräver via ferrata-utrustning och erfarenhet.',
'tours.ferrata.crossingShort': 'Rutten går längs en via ferrata',
'tours.ferrata.waypointAtEnd': 'Början eller slutet av ”{name}”',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'เส้นทางเวียเฟอร์ราตาไม่มีชื่อ',
'ferratas.viewInOsm': 'ดูใน OpenStreetMap',
'ferratas.attribution': 'ข้อมูล © ผู้ร่วมให้ข้อมูล OpenStreetMap',
'ferratas.adopt': 'เพิ่มลงในทัวร์',
'ferratas.onTour': 'เป็นส่วนหนึ่งของทัวร์',
};
export default ferratas;
+7 -1
View File
@@ -71,7 +71,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'เวลาเดินโดยประมาณ',
'tours.planner.walkingEstimatedShort': 'เวลาโดยประมาณ',
'tours.planner.walkingEstimatedInfoLabel': 'วิธีประมาณเวลาเดิน',
'tours.planner.walkingEstimatedInfo': 'คำนวณจากระยะทาง ทางขึ้น และทางลง ตามกฎเวลาเดินป่าทั่วไป (DIN 33466) ไม่รวมเวลาพัก',
'tours.planner.walkingEstimatedInfo':
'คำนวณจากระยะทาง ทางขึ้น และทางลง ตามกฎเวลาเดินป่าทั่วไป (DIN 33466) ไม่รวมเวลาพัก',
'tours.planner.waypoints': 'จุดแวะ',
'tours.planner.waypointLabel': 'จุดแวะ {n}',
'tours.durationMinutes': '{count} นาที',
@@ -164,5 +165,10 @@ const tours: TranslationStrings = {
'tours.terrain.preloading': 'กำลังโหลดภูมิทัศน์: {percent} %',
'tours.terrain.placeOff': 'เปิด วางจุด เพื่อคลิกวางจุดบนภูมิประเทศ',
'tours.terrain.clickToPlan': 'คลิกบนภูมิประเทศเพื่อวางจุด เมื่อมีเส้นทางแล้ว จะบินผ่านและระบายสีตามความชันได้',
'tours.ferrata.badge': 'เวียเฟอร์ราตา {grade}',
'tours.ferrata.badgeNoGrade': 'เวียเฟอร์ราตา',
'tours.ferrata.crossingInfo': 'เส้นทางผ่าน “{name}” เป็นระยะ {length} ต้องใช้อุปกรณ์เวียเฟอร์ราตาและประสบการณ์',
'tours.ferrata.crossingShort': 'เส้นทางผ่านเวียเฟอร์ราตา',
'tours.ferrata.waypointAtEnd': 'จุดเริ่มหรือจุดสิ้นสุดของ “{name}”',
};
export default tours;
+2
View File
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Adsız via ferrata',
'ferratas.viewInOsm': "OpenStreetMap'te görüntüle",
'ferratas.attribution': 'Veriler © OpenStreetMap katkıcıları',
'ferratas.adopt': 'Tura ekle',
'ferratas.onTour': 'Turun parçası',
};
export default ferratas;
+8 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Tahmini yürüyüş süresi',
'tours.planner.walkingEstimatedShort': 'Tahm. süre',
'tours.planner.walkingEstimatedInfoLabel': 'Yürüyüş süresi nasıl tahmin edilir',
'tours.planner.walkingEstimatedInfo': 'Mesafe, çıkış ve inişten olağan yürüyüş süresi kuralıyla (DIN 33466) hesaplanır, molalar dahil değildir.',
'tours.planner.walkingEstimatedInfo':
'Mesafe, çıkış ve inişten olağan yürüyüş süresi kuralıyla (DIN 33466) hesaplanır, molalar dahil değildir.',
'tours.planner.waypoints': 'Rota noktaları',
'tours.planner.waypointLabel': 'Rota noktası {n}',
'tours.durationMinutes': '{count} dk',
@@ -169,5 +170,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Araziye tıklayarak nokta koymak için Nokta koy aracını aç.',
'tours.terrain.clickToPlan':
'Nokta eklemek için araziye tıklayın. Bir rota oluştuğunda üzerinden uçabilir ve eğime göre renklendirebilirsiniz.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo':
'Rota {length} boyunca “{name}” üzerinden geçiyor. Bunun için via ferrata ekipmanı ve deneyim gerekir.',
'tours.ferrata.crossingShort': 'Rota bir via ferrata üzerinden geçiyor',
'tours.ferrata.waypointAtEnd': '“{name}” başlangıcı veya sonu',
};
export default tours;
+2
View File
@@ -15,5 +15,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Віа ферата без назви',
'ferratas.viewInOsm': 'Відкрити в OpenStreetMap',
'ferratas.attribution': 'Дані © учасники OpenStreetMap',
'ferratas.adopt': 'Додати до туру',
'ferratas.onTour': 'Частина туру',
};
export default ferratas;
+8 -1
View File
@@ -74,7 +74,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Орієнтовний час у дорозі',
'tours.planner.walkingEstimatedShort': 'Орієнт. час',
'tours.planner.walkingEstimatedInfoLabel': 'Як оцінюється час у дорозі',
'tours.planner.walkingEstimatedInfo': 'Розраховано за відстанню, підйомом і спуском за звичайним правилом часу ходьби (DIN 33466), без перерв.',
'tours.planner.walkingEstimatedInfo':
'Розраховано за відстанню, підйомом і спуском за звичайним правилом часу ходьби (DIN 33466), без перерв.',
'tours.planner.waypoints': 'Маршрутні точки',
'tours.planner.waypointLabel': 'Маршрутна точка {n}',
'tours.durationMinutes': '{count} хв',
@@ -173,5 +174,11 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Увімкніть «Ставити точки», щоб ставити точки на рельєфі клацанням.',
'tours.terrain.clickToPlan':
"Натисніть на рельєф, щоб поставити точки. Коли з'явиться маршрут, його можна облетіти й розфарбувати за ухилом.",
'tours.ferrata.badge': 'Віа ферата {grade}',
'tours.ferrata.badgeNoGrade': 'Віа ферата',
'tours.ferrata.crossingInfo':
'Маршрут проходить {length} по «{name}». Для цього потрібні спорядження для віа ферати й досвід.',
'tours.ferrata.crossingShort': 'Маршрут проходить по віа фераті',
'tours.ferrata.waypointAtEnd': 'Початок або кінець «{name}»',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': 'Via ferrata chưa có tên',
'ferratas.viewInOsm': 'Xem trên OpenStreetMap',
'ferratas.attribution': 'Dữ liệu © những người đóng góp OpenStreetMap',
'ferratas.adopt': 'Thêm vào tour',
'ferratas.onTour': 'Một phần của tour',
};
export default ferratas;
+7 -1
View File
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
'tours.planner.walkingEstimated': 'Thời gian đi bộ ước tính',
'tours.planner.walkingEstimatedShort': 'TG ước tính',
'tours.planner.walkingEstimatedInfoLabel': 'Cách ước tính thời gian đi bộ',
'tours.planner.walkingEstimatedInfo': 'Tính từ quãng đường, đoạn lên và đoạn xuống theo quy tắc thời gian đi bộ thông thường (DIN 33466), không tính nghỉ.',
'tours.planner.walkingEstimatedInfo':
'Tính từ quãng đường, đoạn lên và đoạn xuống theo quy tắc thời gian đi bộ thông thường (DIN 33466), không tính nghỉ.',
'tours.planner.waypoints': 'Các điểm trên tuyến',
'tours.planner.waypointLabel': 'Điểm {n}',
'tours.durationMinutes': '{count} phút',
@@ -169,5 +170,10 @@ const tours: TranslationStrings = {
'tours.terrain.placeOff': 'Bật Đặt điểm để nhấp đặt điểm trên địa hình.',
'tours.terrain.clickToPlan':
'Nhấp vào địa hình để đặt điểm. Khi đã có tuyến đường, bạn có thể bay qua và tô màu theo độ dốc.',
'tours.ferrata.badge': 'Via ferrata {grade}',
'tours.ferrata.badgeNoGrade': 'Via ferrata',
'tours.ferrata.crossingInfo': 'Tuyến đi qua “{name}” dài {length}. Cần trang bị via ferrata và kinh nghiệm.',
'tours.ferrata.crossingShort': 'Tuyến đi qua một via ferrata',
'tours.ferrata.waypointAtEnd': 'Điểm đầu hoặc cuối của “{name}”',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': '未命名的飛拉達',
'ferratas.viewInOsm': '在 OpenStreetMap 中檢視',
'ferratas.attribution': '資料 © OpenStreetMap 貢獻者',
'ferratas.adopt': '加入行程',
'ferratas.onTour': '行程的一部分',
};
export default ferratas;
+5
View File
@@ -161,5 +161,10 @@ const tours: TranslationStrings = {
'tours.terrain.preloading': '正在載入地形:{percent} %',
'tours.terrain.placeOff': '開啟「放置點」後,點擊地形即可放置點。',
'tours.terrain.clickToPlan': '點擊地形來設定點。有了路線後,就可以飛越並依坡度著色。',
'tours.ferrata.badge': '岩釘路線 {grade}',
'tours.ferrata.badgeNoGrade': '岩釘路線',
'tours.ferrata.crossingInfo': '路線經過「{name}」{length}。需要岩釘路線裝備與經驗。',
'tours.ferrata.crossingShort': '路線經過岩釘路線',
'tours.ferrata.waypointAtEnd': '「{name}」的起點或終點',
};
export default tours;
+2
View File
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
'ferratas.unnamed': '未命名飞拉达',
'ferratas.viewInOsm': '在 OpenStreetMap 中查看',
'ferratas.attribution': '数据 © OpenStreetMap 贡献者',
'ferratas.adopt': '加入行程',
'ferratas.onTour': '行程的一部分',
};
export default ferratas;
+5
View File
@@ -161,5 +161,10 @@ const tours: TranslationStrings = {
'tours.terrain.preloading': '正在加载地形:{percent} %',
'tours.terrain.placeOff': '开启“放置点”后,点击地形即可放置点。',
'tours.terrain.clickToPlan': '点击地形来设置点。有了路线后,就可以飞越并按坡度着色。',
'tours.ferrata.badge': '飞拉达 {grade}',
'tours.ferrata.badgeNoGrade': '飞拉达',
'tours.ferrata.crossingInfo': '路线经过“{name}”{length}。需要飞拉达装备和经验。',
'tours.ferrata.crossingShort': '路线经过飞拉达',
'tours.ferrata.waypointAtEnd': '“{name}”的起点或终点',
};
export default tours;
+39
View File
@@ -147,3 +147,42 @@ describe('Tour informational website', () => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: value })).toThrow();
});
});
describe('Tours fixed legs', () => {
const leg = (from: [number, number], to: [number, number]) => ({
kind: 'ferrata' as const,
ref: 'way/1',
name: 'Via A',
geometry: [from, [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as [number, number], to],
});
it('accepts a fixed leg that runs from its waypoint to the next one', () => {
const request = {
...baseRequest,
waypoints: [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: leg([48, 11], [48.01, 11.02]) },
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
],
};
expect(tourCreateRequestSchema.parse(request).waypoints[0]?.fixed_leg?.ref).toBe('way/1');
});
it('refuses a fixed leg that does not join its waypoints, or hangs off the last one', () => {
const elsewhere = {
...baseRequest,
waypoints: [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: leg([47, 10], [48.01, 11.02]) },
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
],
};
expect(() => tourCreateRequestSchema.parse(elsewhere)).toThrow(/fixed leg/);
const dangling = {
...baseRequest,
waypoints: [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1, fixed_leg: leg([48.01, 11.02], [48.02, 11.03]) },
],
};
expect(() => tourCreateRequestSchema.parse(dangling)).toThrow(/fixed leg/);
});
});
+57
View File
@@ -113,14 +113,52 @@ export type TourListResponse = z.infer<typeof tourListResponseSchema>;
export const tourWaypointRoleSchema = z.enum(['start', 'via', 'end']);
export type TourWaypointRole = z.infer<typeof tourWaypointRoleSchema>;
/** The most points a fixed leg may carry: a long via ferrata is a few hundred. */
export const TOUR_FIXED_LEG_MAX_POINTS = 5000;
/** How far a fixed leg's ends may lie from the waypoints it joins, in metres. */
export const TOUR_FIXED_LEG_END_TOLERANCE_M = 60;
/**
* A leg the router does not plan: the line from this waypoint to the next is
* taken as it stands. Today that is a via ferrata put onto the tour from the
* map, whose own line from OpenStreetMap the tour follows, since a router
* walks around a ferrata wherever it can.
*/
export const tourFixedLegSchema = z.object({
kind: z.literal('ferrata'),
/** The OSM way or group the line came from, `way/<id>` style. */
ref: z.string().trim().min(1).max(64),
name: z.string().trim().max(255).nullable(),
geometry: z
.array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)]))
.min(2)
.max(TOUR_FIXED_LEG_MAX_POINTS),
});
export type TourFixedLeg = z.infer<typeof tourFixedLegSchema>;
export const tourWaypointSchema = z.object({
lat: z.number().finite().min(-90).max(90),
lng: z.number().finite().min(-180).max(180),
role: tourWaypointRoleSchema,
sequence: z.number().int().nonnegative(),
/** The leg to the next waypoint as a fixed line; absent or null for a routed one. */
fixed_leg: tourFixedLegSchema.nullable().optional(),
});
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
/**
* Metres between two `[lat, lng]` points, close enough at the length of a
* leg's end gap. Read as plain arrays: a workspace compiled without strict
* mode sees the schema's tuples as "maybe two numbers".
*/
function fixedLegGapM(a: readonly unknown[], b: readonly unknown[]): number {
const [aLat, aLng, bLat, bLng] = [Number(a[0]), Number(a[1]), Number(b[0]), Number(b[1])];
const rad = Math.PI / 180;
const x = (bLng - aLng) * rad * Math.cos(((aLat + bLat) / 2) * rad);
const y = (bLat - aLat) * rad;
return Math.hypot(x, y) * 6_371_000;
}
const enrichedRoutePointSchema = z.tuple([
z.number().finite().min(-90).max(90),
z.number().finite().min(-180).max(180),
@@ -154,6 +192,25 @@ export const tourCreateRequestSchema = z
});
}
value.waypoints.forEach((point, index) => {
const leg = point.fixed_leg;
const next = value.waypoints[index + 1];
if (leg) {
const first = leg.geometry[0];
const last = leg.geometry[leg.geometry.length - 1];
const joins =
!!next &&
!!first &&
!!last &&
fixedLegGapM(first, [point.lat, point.lng]) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
fixedLegGapM(last, [next.lat, next.lng]) <= TOUR_FIXED_LEG_END_TOLERANCE_M;
if (!joins) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['waypoints', index, 'fixed_leg'],
message: `Waypoint ${index}'s fixed leg must run from it to the next waypoint`,
});
}
}
const expectedRole: TourWaypointRole =
index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via';
if (point.sequence !== index || point.role !== expectedRole) {