mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user