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,
});
}