mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +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,
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export class TourWaypoints {
|
||||
lng!: number;
|
||||
role!: string;
|
||||
sequence!: number;
|
||||
fixed_leg?: string | null;
|
||||
}
|
||||
|
||||
export const TourWaypointsSchema = defineEntity({
|
||||
@@ -25,5 +26,6 @@ export const TourWaypointsSchema = defineEntity({
|
||||
lng: p.double(),
|
||||
role: p.text(),
|
||||
sequence: p.integer(),
|
||||
fixed_leg: p.text().nullable(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JsonColumn } from '../utils/json-column';
|
||||
import { FERRATAS_RESULT_CAP, ferrataSchema, type Ferrata } from '@trek/shared';
|
||||
import { FERRATAS_RESULT_CAP, ferrataSchema, tourFixedLegSchema, type Ferrata, type TourFixedLeg } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -124,3 +124,14 @@ export const FERRATA_TILE_PAYLOAD: JsonColumn<FerrataTilePayload | null> = {
|
||||
schema: z.object({ ferratas: z.array(ferrataSchema).max(FERRATAS_RESULT_CAP), truncated: z.boolean() }).nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
* A tour leg taken as a fixed line (a via ferrata's own line from OSM) rather
|
||||
* than routed. A row that does not decode reads as a routed leg, which the
|
||||
* editor then plans again.
|
||||
*/
|
||||
export const TOUR_WAYPOINT_FIXED_LEG: JsonColumn<TourFixedLeg | null> = {
|
||||
column: 'tour_waypoints.fixed_leg',
|
||||
schema: tourFixedLegSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
@@ -9,4 +9,5 @@ export interface TourWaypointsTable {
|
||||
lng: number;
|
||||
role: string;
|
||||
sequence: number;
|
||||
fixed_leg: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* `tour_waypoints.fixed_leg` holds the leg from a waypoint to the next one
|
||||
* when the tour takes it as a fixed line rather than as a routed one: a via
|
||||
* ferrata put onto the tour from the map, whose own line from OpenStreetMap
|
||||
* the tour follows, since a router walks around a ferrata wherever it can.
|
||||
*
|
||||
* The column is JSON text (kind, the OSM reference, the name and the line) and
|
||||
* NULL for every routed leg, so every existing waypoint keeps its meaning.
|
||||
*/
|
||||
export class Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata extends Migration {
|
||||
override name = 'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata';
|
||||
|
||||
override up(): void {
|
||||
this.addSql('ALTER TABLE tour_waypoints ADD COLUMN fixed_leg TEXT');
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
import type { TourWaypoints } from '../entities/TourWaypoints.entity';
|
||||
import { TOUR_WAYPOINT_FIXED_LEG } from '../json-columns';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { TourFixedLeg } from '@trek/shared';
|
||||
|
||||
/** One saved routing control point of a tour, in route order. */
|
||||
export interface TourWaypointRow {
|
||||
@@ -8,6 +11,23 @@ export interface TourWaypointRow {
|
||||
lng: number;
|
||||
role: 'start' | 'via' | 'end';
|
||||
sequence: number;
|
||||
/** The leg to the next waypoint as a fixed line; absent for a routed leg. */
|
||||
fixed_leg?: TourFixedLeg | null;
|
||||
}
|
||||
|
||||
/** The row as stored, the fixed leg still JSON text. */
|
||||
interface StoredWaypoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
role: TourWaypointRow['role'];
|
||||
sequence: number;
|
||||
fixed_leg: string | null;
|
||||
}
|
||||
|
||||
/** A stored row as callers read it: the fixed leg decoded, and left out entirely for a routed leg. */
|
||||
function readWaypoint({ fixed_leg, ...point }: StoredWaypoint): TourWaypointRow {
|
||||
const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint');
|
||||
return leg ? { ...point, fixed_leg: leg } : point;
|
||||
}
|
||||
|
||||
/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */
|
||||
@@ -30,37 +50,46 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
return this.kysely<TourWaypointsKyselyDB>();
|
||||
}
|
||||
|
||||
/** TW1 — `SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
|
||||
/** TW1 — `SELECT lat, lng, role, sequence, fixed_leg FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
|
||||
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
|
||||
return await this.db()
|
||||
const rows = await this.db()
|
||||
.selectFrom('tour_waypoints')
|
||||
.select(['lat', 'lng', 'role', 'sequence'])
|
||||
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg'])
|
||||
.where('place_id', '=', place_id)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute();
|
||||
return rows.map(readWaypoint);
|
||||
}
|
||||
|
||||
/** TW2 — every waypoint of a trip's tours, for the trip copy to remap by place. */
|
||||
async listForTrip(trip_id: number): Promise<TourWaypointWithPlaceRow[]> {
|
||||
return await this.db()
|
||||
const rows = await this.db()
|
||||
.selectFrom('tour_waypoints as tw')
|
||||
.innerJoin('places as p', 'p.id', 'tw.place_id')
|
||||
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence'])
|
||||
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg'])
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.orderBy('tw.place_id', 'asc')
|
||||
.orderBy('tw.sequence', 'asc')
|
||||
.execute();
|
||||
return rows.map(({ place_id, ...point }) => ({ place_id, ...readWaypoint(point) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence)
|
||||
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg)
|
||||
* VALUES (...)` for each point. The caller owns the transaction: a create
|
||||
* writes the tour first, an edit clears the old points first.
|
||||
*/
|
||||
async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise<void> {
|
||||
if (waypoints.length === 0) return;
|
||||
await this.insertMany(
|
||||
waypoints.map((w) => ({ place: place_id, lat: w.lat, lng: w.lng, role: w.role, sequence: w.sequence })),
|
||||
waypoints.map((w) => ({
|
||||
place: place_id,
|
||||
lat: w.lat,
|
||||
lng: w.lng,
|
||||
role: w.role,
|
||||
sequence: w.sequence,
|
||||
fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('known migrations', () => {
|
||||
expect(known.has('Migration20200101043000_tour_break_additional_duration')).toBe(true);
|
||||
expect(known.has('Migration20200101043100_an_imported_tour_has_no_hiking_difficulty')).toBe(true);
|
||||
expect(known.has('Migration20200101043200_via_ferrata_tiles_outlive_a_restart')).toBe(true);
|
||||
expect(known.has('Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata')).toBe(true);
|
||||
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101043000_tour_break_additional_duration',
|
||||
'Migration20200101043100_an_imported_tour_has_no_hiking_difficulty',
|
||||
'Migration20200101043200_via_ferrata_tiles_outlive_a_restart',
|
||||
'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3
|
||||
* (`insertForPlace`) and TW4 (`deleteForPlace`).
|
||||
* (`insertForPlace`) and TW4 (`deleteForPlace`), with a leg that follows a
|
||||
* via ferrata's own line.
|
||||
*/
|
||||
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
|
||||
import type {
|
||||
@@ -89,4 +90,40 @@ describe('TourWaypointsRepository', () => {
|
||||
]);
|
||||
expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 });
|
||||
});
|
||||
|
||||
it('TWREPO-005: a fixed leg is stored with its waypoint and read back whole; routed legs carry none', async () => {
|
||||
const { trip, place } = tourInTrip();
|
||||
const fixed_leg = {
|
||||
kind: 'ferrata' as const,
|
||||
ref: 'way/23971675',
|
||||
name: 'Alpspitz Ferrata',
|
||||
geometry: [
|
||||
[47.4343, 11.0501],
|
||||
[47.432, 11.0485],
|
||||
[47.4295, 11.0478],
|
||||
] as [number, number][],
|
||||
};
|
||||
await repo.insertForPlace(place.id, [
|
||||
{ lat: 47.4387, lng: 11.0523, role: 'start', sequence: 0 },
|
||||
{ lat: 47.4343, lng: 11.0501, role: 'via', sequence: 1, fixed_leg },
|
||||
{ lat: 47.4295, lng: 11.0478, role: 'end', sequence: 2 },
|
||||
]);
|
||||
|
||||
const points = await repo.listForPlace(place.id);
|
||||
expect(points[1]).toEqual({ lat: 47.4343, lng: 11.0501, role: 'via', sequence: 1, fixed_leg });
|
||||
expect(points[0]).not.toHaveProperty('fixed_leg');
|
||||
expect(points[2]).not.toHaveProperty('fixed_leg');
|
||||
expect((await repo.listForTrip(trip.id))[1]).toEqual({ place_id: place.id, ...points[1] });
|
||||
});
|
||||
|
||||
it('TWREPO-006: a stored fixed leg that no longer decodes reads as a routed leg', async () => {
|
||||
const { place } = tourInTrip();
|
||||
await repo.insertForPlace(place.id, [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 1 },
|
||||
]);
|
||||
await t.em.nativeUpdate(TourWaypoints, { place: place.id, sequence: 0 }, { fixed_leg: '{"kind":"boat"}' });
|
||||
t.clear();
|
||||
expect((await repo.listForPlace(place.id))[0]).toEqual({ lat: 47, lng: 11, role: 'start', sequence: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata`: tour
|
||||
* waypoints gain a nullable `fixed_leg`, and every waypoint already saved
|
||||
* keeps its routed leg.
|
||||
*/
|
||||
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
|
||||
import type { MikroORM } from '@mikro-orm/sqlite';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const TARGET = 'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata';
|
||||
|
||||
async function ormBeforeTarget(): Promise<MikroORM> {
|
||||
const orm = await createMigrationOrm();
|
||||
const names = await pendingNames(orm);
|
||||
const index = names.indexOf(TARGET);
|
||||
expect(index).toBeGreaterThan(0);
|
||||
await migrateTo(orm, names[index - 1]);
|
||||
return orm;
|
||||
}
|
||||
|
||||
const fixedLegColumn = (orm: MikroORM) =>
|
||||
rawQuery<{ name: string; type: string; notnull: number }>(
|
||||
orm,
|
||||
"SELECT name, type, \"notnull\" FROM pragma_table_info('tour_waypoints') WHERE name = 'fixed_leg'",
|
||||
);
|
||||
|
||||
describe('Tour fixed leg migration', () => {
|
||||
it('TOURLEGMIG-001: adds a nullable fixed_leg text column, and existing waypoints keep a routed leg', async () => {
|
||||
const orm = await ormBeforeTarget();
|
||||
try {
|
||||
expect(await fixedLegColumn(orm)).toEqual([]);
|
||||
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@x.y', 'h')");
|
||||
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')");
|
||||
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (1, 1, 'Tour')");
|
||||
await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (1, 'hike')");
|
||||
await rawExec(
|
||||
orm,
|
||||
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (1, 47, 11, 'start', 0)",
|
||||
);
|
||||
|
||||
await migrateTo(orm, TARGET);
|
||||
|
||||
expect(await fixedLegColumn(orm)).toEqual([{ name: 'fixed_leg', type: 'TEXT', notnull: 0 }]);
|
||||
expect(await rawQuery(orm, 'SELECT fixed_leg FROM tour_waypoints')).toEqual([{ fixed_leg: null }]);
|
||||
} finally {
|
||||
await orm.close(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -17,5 +17,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'مسار تسلق حديدي بلا اسم',
|
||||
'ferratas.viewInOsm': 'عرض في OpenStreetMap',
|
||||
'ferratas.attribution': 'البيانات © مساهمو OpenStreetMap',
|
||||
'ferratas.adopt': 'أضف إلى الجولة',
|
||||
'ferratas.onTour': 'جزء من الجولة',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -75,7 +75,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'وقت المشي التقديري',
|
||||
'tours.planner.walkingEstimatedShort': 'وقت المشي التقريبي',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'كيف يُقدَّر وقت المشي',
|
||||
'tours.planner.walkingEstimatedInfo': 'محسوب من المسافة والصعود والنزول وفق قاعدة زمن المشي المعتادة (DIN 33466)، دون الاستراحات.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'محسوب من المسافة والصعود والنزول وفق قاعدة زمن المشي المعتادة (DIN 33466)، دون الاستراحات.',
|
||||
'tours.planner.waypoints': 'نقاط المرور',
|
||||
'tours.planner.waypointLabel': 'نقطة المرور {n}',
|
||||
'tours.durationMinutes': '{count} د',
|
||||
@@ -174,5 +175,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.preloading': 'جارٍ تحميل المشهد: {percent} %',
|
||||
'tours.terrain.placeOff': 'فعّل «تعيين النقاط» لوضع النقاط على التضاريس بالنقر.',
|
||||
'tours.terrain.clickToPlan': 'انقر على التضاريس لإضافة نقاط. بمجرد وجود مسار يمكن التحليق فوقه وتلوينه حسب الانحدار.',
|
||||
'tours.ferrata.badge': 'طريق حديدي {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'طريق حديدي',
|
||||
'tours.ferrata.crossingInfo': 'يمر المسار {length} عبر «{name}». يتطلب ذلك معدات الطريق الحديدي وخبرة.',
|
||||
'tours.ferrata.crossingShort': 'يمر المسار عبر طريق حديدي',
|
||||
'tours.ferrata.waypointAtEnd': 'بداية أو نهاية «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Adsız via ferrata marşrutu',
|
||||
'ferratas.viewInOsm': 'OpenStreetMap-də bax',
|
||||
'ferratas.attribution': 'Məlumatlar © OpenStreetMap iştirakçıları',
|
||||
'ferratas.adopt': 'Tura əlavə et',
|
||||
'ferratas.onTour': 'Turun bir hissəsi',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Təhlükəsizlik haqqında',
|
||||
'tours.planner.addPoint': 'Nöqtə əlavə et',
|
||||
'tours.planner.addPointActive': 'Xəritəyə klikləyin',
|
||||
'tours.planner.emptyHint': '“Nöqtə əlavə et”i aktiv edin və xəritəyə klikləyin. İki nöqtədən sonra TREK marşrutu hesablayır.',
|
||||
'tours.planner.emptyHint':
|
||||
'“Nöqtə əlavə et”i aktiv edin və xəritəyə klikləyin. İki nöqtədən sonra TREK marşrutu hesablayır.',
|
||||
'tours.planner.walkingEstimated': 'Təxmini piyada vaxtı',
|
||||
'tours.planner.walkingEstimatedShort': 'Təxm. piyada vaxtı',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Piyada vaxtı necə təxmin edilir',
|
||||
'tours.planner.walkingEstimatedInfo': 'Məsafə, qalxma və enmədən adi yürüş vaxtı qaydası (DIN 33466) ilə hesablanır, fasilələr daxil deyil.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Məsafə, qalxma və enmədən adi yürüş vaxtı qaydası (DIN 33466) ilə hesablanır, fasilələr daxil deyil.',
|
||||
'tours.planner.waypoints': 'Yol nöqtələri',
|
||||
'tours.planner.waypointLabel': 'Yol nöqtəsi {n}',
|
||||
'tours.durationMinutes': '{count} dəq.',
|
||||
@@ -170,5 +172,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Relyefə klikləyib nöqtə qoymaq üçün «Nöqtə qoy» alətini aç.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Nöqtə qoymaq üçün əraziyə klikləyin. Marşrut yarandıqdan sonra onun üzərindən uçmaq və meyilə görə rəngləmək olar.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Marşrut {length} boyunca “{name}” üzərindən keçir. Bunun üçün via ferrata ləvazimatı və təcrübə lazımdır.',
|
||||
'tours.ferrata.crossingShort': 'Marşrut via ferrata üzərindən keçir',
|
||||
'tours.ferrata.waypointAtEnd': '“{name}” başlanğıcı və ya sonu',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrata sem nome',
|
||||
'ferratas.viewInOsm': 'Ver no OpenStreetMap',
|
||||
'ferratas.attribution': 'Dados © colaboradores do OpenStreetMap',
|
||||
'ferratas.adopt': 'Adicionar ao passeio',
|
||||
'ferratas.onTour': 'Parte do passeio',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -70,11 +70,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Sobre segurança',
|
||||
'tours.planner.addPoint': 'Adicionar ponto',
|
||||
'tours.planner.addPointActive': 'Clique no mapa',
|
||||
'tours.planner.emptyHint': 'Ative “Adicionar ponto” e clique no mapa. A partir de dois pontos, o TREK calcula a rota.',
|
||||
'tours.planner.emptyHint':
|
||||
'Ative “Adicionar ponto” e clique no mapa. A partir de dois pontos, o TREK calcula a rota.',
|
||||
'tours.planner.walkingEstimated': 'Tempo de caminhada estimado',
|
||||
'tours.planner.walkingEstimatedShort': 'Tempo est.',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Como o tempo de caminhada é estimado',
|
||||
'tours.planner.walkingEstimatedInfo': 'Calculado a partir da distância, subida e descida pela regra usual de tempo de caminhada (DIN 33466), sem pausas.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Calculado a partir da distância, subida e descida pela regra usual de tempo de caminhada (DIN 33466), sem pausas.',
|
||||
'tours.planner.waypoints': 'Pontos de passagem',
|
||||
'tours.planner.waypointLabel': 'Ponto de passagem {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -171,5 +173,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Ative Marcar pontos para clicar pontos no terreno.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Clique no terreno para adicionar pontos. Assim que houver uma rota, ela pode ser sobrevoada e colorida pela inclinação.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'A rota passa {length} pela “{name}”. Isso exige equipamento de via ferrata e experiência.',
|
||||
'tours.ferrata.crossingShort': 'A rota passa por uma via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Início ou fim de “{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrada sense nom',
|
||||
'ferratas.viewInOsm': 'Mostra a OpenStreetMap',
|
||||
'ferratas.attribution': "Dades © col·laboradors d'OpenStreetMap",
|
||||
'ferratas.adopt': 'Afegeix a la ruta',
|
||||
'ferratas.onTour': 'Part de la ruta',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Sobre la seguretat',
|
||||
'tours.planner.addPoint': 'Afegeix un punt',
|
||||
'tours.planner.addPointActive': 'Fes clic al mapa',
|
||||
'tours.planner.emptyHint': 'Activa «Afegeix un punt» i fes clic al mapa. A partir de dos punts, TREK calcula la ruta.',
|
||||
'tours.planner.emptyHint':
|
||||
'Activa «Afegeix un punt» i fes clic al mapa. A partir de dos punts, TREK calcula la ruta.',
|
||||
'tours.planner.walkingEstimated': 'Temps a peu estimat',
|
||||
'tours.planner.walkingEstimatedShort': 'Temps est.',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Com s’estima el temps a peu',
|
||||
'tours.planner.walkingEstimatedInfo': 'Calculat a partir de la distància, la pujada i la baixada amb la regla habitual de temps de marxa (DIN 33466), sense pauses.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Calculat a partir de la distància, la pujada i la baixada amb la regla habitual de temps de marxa (DIN 33466), sense pauses.',
|
||||
'tours.planner.waypoints': 'Punts de pas',
|
||||
'tours.planner.waypointLabel': 'Punt de pas {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -170,5 +172,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Activa Posar punts per afegir punts al terreny amb un clic.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Fes clic al terreny per afegir punts. Quan hi hagi una ruta, es podrà sobrevolar i acolorir segons el pendent.',
|
||||
'tours.ferrata.badge': 'Via ferrada {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrada',
|
||||
'tours.ferrata.crossingInfo': 'La ruta fa {length} per «{name}». Cal material de via ferrada i experiència.',
|
||||
'tours.ferrata.crossingShort': 'La ruta passa per una via ferrada',
|
||||
'tours.ferrata.waypointAtEnd': 'Inici o final de «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -14,5 +14,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Ferrata bez názvu',
|
||||
'ferratas.viewInOsm': 'Zobrazit v OpenStreetMap',
|
||||
'ferratas.attribution': 'Data © přispěvatelé OpenStreetMap',
|
||||
'ferratas.adopt': 'Přidat do túry',
|
||||
'ferratas.onTour': 'Součást túry',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Odhadovaný čas chůze',
|
||||
'tours.planner.walkingEstimatedShort': 'Odhad. čas chůze',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Jak se čas chůze odhaduje',
|
||||
'tours.planner.walkingEstimatedInfo': 'Vypočteno ze vzdálenosti, stoupání a klesání podle obvyklého pravidla pro čas chůze (DIN 33466), bez přestávek.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Vypočteno ze vzdálenosti, stoupání a klesání podle obvyklého pravidla pro čas chůze (DIN 33466), bez přestávek.',
|
||||
'tours.planner.waypoints': 'Průjezdní body',
|
||||
'tours.planner.waypointLabel': 'Bod {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -169,5 +170,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Zapni Přidávat body a klikáním je přidávej do terénu.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Kliknutím do terénu přidáte body. Jakmile vznikne trasa, lze ji proletět a obarvit podle sklonu.',
|
||||
'tours.ferrata.badge': 'Ferata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Ferata',
|
||||
'tours.ferrata.crossingInfo': 'Trasa vede {length} po „{name}“. Potřebujete feratové vybavení a zkušenosti.',
|
||||
'tours.ferrata.crossingShort': 'Trasa vede po feratě',
|
||||
'tours.ferrata.waypointAtEnd': 'Začátek nebo konec „{name}“',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Klettersteig ohne Namen',
|
||||
'ferratas.viewInOsm': 'In OpenStreetMap ansehen',
|
||||
'ferratas.attribution': 'Daten © OpenStreetMap-Mitwirkende',
|
||||
'ferratas.adopt': 'In die Tour übernehmen',
|
||||
'ferratas.onTour': 'Teil der Tour',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Hinweis zur Sicherheit',
|
||||
'tours.planner.addPoint': 'Punkt hinzufügen',
|
||||
'tours.planner.addPointActive': 'Auf Karte klicken',
|
||||
'tours.planner.emptyHint': 'Schalte „Punkt hinzufügen“ ein und klicke auf die Karte. Ab zwei Punkten berechnet TREK die Route.',
|
||||
'tours.planner.emptyHint':
|
||||
'Schalte „Punkt hinzufügen“ ein und klicke auf die Karte. Ab zwei Punkten berechnet TREK die Route.',
|
||||
'tours.planner.walkingEstimated': 'Geschätzte Gehzeit',
|
||||
'tours.planner.walkingEstimatedShort': 'Gesch. Gehzeit',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Wie die Gehzeit geschätzt wird',
|
||||
'tours.planner.walkingEstimatedInfo': 'Nach der üblichen Wanderzeit-Regel (DIN 33466) aus Strecke, Auf- und Abstieg berechnet, ohne Pausen.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Nach der üblichen Wanderzeit-Regel (DIN 33466) aus Strecke, Auf- und Abstieg berechnet, ohne Pausen.',
|
||||
'tours.planner.waypoints': 'Wegpunkte',
|
||||
'tours.planner.waypointLabel': 'Wegpunkt {n}',
|
||||
'tours.durationMinutes': '{count} Min.',
|
||||
@@ -171,6 +173,12 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Schalte „Punkte setzen“ ein, um Punkte ins Gelände zu klicken.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Klicke ins Gelände, um Punkte zu setzen. Sobald eine Route steht, lässt sie sich abfliegen und nach Steigung einfärben.',
|
||||
'tours.ferrata.badge': 'Klettersteig {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Klettersteig',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Die Route führt {length} über „{name}“. Dafür brauchst du Klettersteigausrüstung und Erfahrung.',
|
||||
'tours.ferrata.crossingShort': 'Die Route führt über einen Klettersteig',
|
||||
'tours.ferrata.waypointAtEnd': 'Anfang oder Ende von „{name}“',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Unnamed via ferrata',
|
||||
'ferratas.viewInOsm': 'View in OpenStreetMap',
|
||||
'ferratas.attribution': 'Data © OpenStreetMap contributors',
|
||||
'ferratas.adopt': 'Add to tour',
|
||||
'ferratas.onTour': 'Part of the tour',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Estimated walking time',
|
||||
'tours.planner.walkingEstimatedShort': 'Est. walking time',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'How the walking time is estimated',
|
||||
'tours.planner.walkingEstimatedInfo': 'Worked out from distance, climb and descent with the usual hiking time rule (DIN 33466), without breaks.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Worked out from distance, climb and descent with the usual hiking time rule (DIN 33466), without breaks.',
|
||||
'tours.planner.waypoints': 'Waypoints',
|
||||
'tours.planner.waypointLabel': 'Waypoint {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -168,5 +169,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Turn on Set points to click points onto the terrain.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Click the terrain to set points. Once there is a route, it can be flown and coloured by grade.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo': 'The route climbs {length} of “{name}”. That takes via ferrata gear and experience.',
|
||||
'tours.ferrata.crossingShort': 'The route climbs a via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Start or end of “{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Vía ferrata sin nombre',
|
||||
'ferratas.viewInOsm': 'Ver en OpenStreetMap',
|
||||
'ferratas.attribution': 'Datos © colaboradores de OpenStreetMap',
|
||||
'ferratas.adopt': 'Añadir a la ruta',
|
||||
'ferratas.onTour': 'Parte de la ruta',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -69,11 +69,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Sobre la seguridad',
|
||||
'tours.planner.addPoint': 'Añadir punto',
|
||||
'tours.planner.addPointActive': 'Haz clic en el mapa',
|
||||
'tours.planner.emptyHint': 'Activa «Añadir punto» y haz clic en el mapa. A partir de dos puntos, TREK calcula la ruta.',
|
||||
'tours.planner.emptyHint':
|
||||
'Activa «Añadir punto» y haz clic en el mapa. A partir de dos puntos, TREK calcula la ruta.',
|
||||
'tours.planner.walkingEstimated': 'Tiempo a pie estimado',
|
||||
'tours.planner.walkingEstimatedShort': 'Tiempo est.',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Cómo se estima el tiempo a pie',
|
||||
'tours.planner.walkingEstimatedInfo': 'Calculado a partir de la distancia, la subida y la bajada con la regla habitual de tiempo de marcha (DIN 33466), sin pausas.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Calculado a partir de la distancia, la subida y la bajada con la regla habitual de tiempo de marcha (DIN 33466), sin pausas.',
|
||||
'tours.planner.waypoints': 'Puntos de paso',
|
||||
'tours.planner.waypointLabel': 'Punto de paso {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -169,5 +171,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Activa Colocar puntos para añadir puntos en el terreno con un clic.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Haz clic en el terreno para añadir puntos. En cuanto haya una ruta, se podrá sobrevolar y colorear según la pendiente.',
|
||||
'tours.ferrata.badge': 'Vía ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Vía ferrata',
|
||||
'tours.ferrata.crossingInfo': 'La ruta recorre {length} de «{name}». Necesitas equipo de vía ferrata y experiencia.',
|
||||
'tours.ferrata.crossingShort': 'La ruta pasa por una vía ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Inicio o final de «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Nimeta via ferrata',
|
||||
'ferratas.viewInOsm': 'Vaata OpenStreetMapis',
|
||||
'ferratas.attribution': 'Andmed © OpenStreetMapi kaastöötajad',
|
||||
'ferratas.adopt': 'Lisa matkale',
|
||||
'ferratas.onTour': 'Matka osa',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -68,11 +68,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Ohutusest',
|
||||
'tours.planner.addPoint': 'Lisa punkt',
|
||||
'tours.planner.addPointActive': 'Klõpsa kaardil',
|
||||
'tours.planner.emptyHint': 'Lülita sisse „Lisa punkt“ ja klõpsa kaardil. Alates kahest punktist arvutab TREK marsruudi.',
|
||||
'tours.planner.emptyHint':
|
||||
'Lülita sisse „Lisa punkt“ ja klõpsa kaardil. Alates kahest punktist arvutab TREK marsruudi.',
|
||||
'tours.planner.walkingEstimated': 'Hinnanguline kõndimisaeg',
|
||||
'tours.planner.walkingEstimatedShort': 'Hinn. kõndimisaeg',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Kuidas kõndimisaega hinnatakse',
|
||||
'tours.planner.walkingEstimatedInfo': 'Arvutatud vahemaa, tõusu ja laskumise põhjal tavapärase matkaaja reegli (DIN 33466) järgi, ilma pausideta.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Arvutatud vahemaa, tõusu ja laskumise põhjal tavapärase matkaaja reegli (DIN 33466) järgi, ilma pausideta.',
|
||||
'tours.planner.waypoints': 'Teekonnapunktid',
|
||||
'tours.planner.waypointLabel': 'Teekonnapunkt {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -169,5 +171,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Lülita sisse Lisa punkte, et maastikule klõpsates punkte lisada.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Klõpsa maastikul, et punkte lisada. Kui marsruut on olemas, saab selle üle lennata ja kalde järgi värvida.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Marsruut läbib {length} rajal „{name}“. Selleks on vaja via ferrata varustust ja kogemust.',
|
||||
'tours.ferrata.crossingShort': 'Marsruut läbib via ferrata raja',
|
||||
'tours.ferrata.waypointAtEnd': '„{name}“ algus või lõpp',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrata sans nom',
|
||||
'ferratas.viewInOsm': 'Voir dans OpenStreetMap',
|
||||
'ferratas.attribution': 'Données © contributeurs OpenStreetMap',
|
||||
'ferratas.adopt': 'Ajouter à la randonnée',
|
||||
'ferratas.onTour': 'Fait partie de la randonnée',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -71,11 +71,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'À propos de la sécurité',
|
||||
'tours.planner.addPoint': 'Ajouter un point',
|
||||
'tours.planner.addPointActive': 'Cliquez sur la carte',
|
||||
'tours.planner.emptyHint': 'Activez « Ajouter un point » et cliquez sur la carte. Dès deux points, TREK calcule l’itinéraire.',
|
||||
'tours.planner.emptyHint':
|
||||
'Activez « Ajouter un point » et cliquez sur la carte. Dès deux points, TREK calcule l’itinéraire.',
|
||||
'tours.planner.walkingEstimated': 'Temps de marche estimé',
|
||||
'tours.planner.walkingEstimatedShort': 'Temps estimé',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Comment le temps de marche est estimé',
|
||||
'tours.planner.walkingEstimatedInfo': 'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle habituelle de temps de marche (DIN 33466), sans les pauses.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle habituelle de temps de marche (DIN 33466), sans les pauses.',
|
||||
'tours.planner.waypoints': 'Points de passage',
|
||||
'tours.planner.waypointLabel': 'Point de passage {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -174,5 +176,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Activez Placer des points pour cliquer des points sur le terrain.',
|
||||
'tours.terrain.clickToPlan':
|
||||
"Cliquez sur le relief pour placer des points. Dès qu'un itinéraire existe, il peut être survolé et coloré selon la pente.",
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'L’itinéraire passe {length} sur « {name} ». Il faut l’équipement de via ferrata et de l’expérience.',
|
||||
'tours.ferrata.crossingShort': 'L’itinéraire passe par une via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Début ou fin de « {name} »',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Διαδρομή via ferrata χωρίς όνομα',
|
||||
'ferratas.viewInOsm': 'Προβολή στο OpenStreetMap',
|
||||
'ferratas.attribution': 'Δεδομένα © συντελεστές του OpenStreetMap',
|
||||
'ferratas.adopt': 'Προσθήκη στη διαδρομή',
|
||||
'ferratas.onTour': 'Μέρος της διαδρομής',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -71,11 +71,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Σχετικά με την ασφάλεια',
|
||||
'tours.planner.addPoint': 'Προσθήκη σημείου',
|
||||
'tours.planner.addPointActive': 'Κλικ στον χάρτη',
|
||||
'tours.planner.emptyHint': 'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.',
|
||||
'tours.planner.emptyHint':
|
||||
'Ενεργοποιήστε την «Προσθήκη σημείου» και κάντε κλικ στον χάρτη. Από δύο σημεία και πάνω, το TREK υπολογίζει τη διαδρομή.',
|
||||
'tours.planner.walkingEstimated': 'Εκτιμώμενος χρόνος πεζοπορίας',
|
||||
'tours.planner.walkingEstimatedShort': 'Εκτ. χρόνος',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Πώς εκτιμάται ο χρόνος πεζοπορίας',
|
||||
'tours.planner.walkingEstimatedInfo': 'Υπολογίζεται από την απόσταση, την ανάβαση και την κατάβαση με τον συνήθη κανόνα χρόνου πεζοπορίας (DIN 33466), χωρίς διαλείμματα.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Υπολογίζεται από την απόσταση, την ανάβαση και την κατάβαση με τον συνήθη κανόνα χρόνου πεζοπορίας (DIN 33466), χωρίς διαλείμματα.',
|
||||
'tours.planner.waypoints': 'Σημεία διέλευσης',
|
||||
'tours.planner.waypointLabel': 'Σημείο διέλευσης {n}',
|
||||
'tours.durationMinutes': '{count} λεπτά',
|
||||
@@ -178,5 +180,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Ενεργοποίησε την Τοποθέτηση σημείων για να βάζεις σημεία στο έδαφος με κλικ.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Κάνε κλικ στο έδαφος για να ορίσεις σημεία. Μόλις υπάρξει διαδρομή, μπορείς να την πετάξεις και να τη χρωματίσεις κατά κλίση.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Η διαδρομή περνά {length} από το «{name}». Χρειάζεσαι εξοπλισμό via ferrata και εμπειρία.',
|
||||
'tours.ferrata.crossingShort': 'Η διαδρομή περνά από via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Αρχή ή τέλος του «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Névtelen vasalt út',
|
||||
'ferratas.viewInOsm': 'Megtekintés az OpenStreetMapen',
|
||||
'ferratas.attribution': 'Adatok © OpenStreetMap-közreműködők',
|
||||
'ferratas.adopt': 'Hozzáadás a túrához',
|
||||
'ferratas.onTour': 'A túra része',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -68,11 +68,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'A biztonságról',
|
||||
'tours.planner.addPoint': 'Pont hozzáadása',
|
||||
'tours.planner.addPointActive': 'Kattints a térképre',
|
||||
'tours.planner.emptyHint': 'Kapcsold be a „Pont hozzáadása” gombot, és kattints a térképre. Két ponttól a TREK kiszámolja az útvonalat.',
|
||||
'tours.planner.emptyHint':
|
||||
'Kapcsold be a „Pont hozzáadása” gombot, és kattints a térképre. Két ponttól a TREK kiszámolja az útvonalat.',
|
||||
'tours.planner.walkingEstimated': 'Becsült gyaloglási idő',
|
||||
'tours.planner.walkingEstimatedShort': 'Becs. idő',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Hogyan becsüljük a gyaloglási időt',
|
||||
'tours.planner.walkingEstimatedInfo': 'A távolságból, az emelkedésből és a lejtésből számolva a szokásos túraidő-szabály (DIN 33466) szerint, szünetek nélkül.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'A távolságból, az emelkedésből és a lejtésből számolva a szokásos túraidő-szabály (DIN 33466) szerint, szünetek nélkül.',
|
||||
'tours.planner.waypoints': 'Útvonalpontok',
|
||||
'tours.planner.waypointLabel': '{n}. útvonalpont',
|
||||
'tours.durationMinutes': '{count} perc',
|
||||
@@ -172,5 +174,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Kapcsold be a Pontok elhelyezése eszközt, hogy kattintással pontokat tegyél a terepre.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Kattints a terepre pontok elhelyezéséhez. Ha már van útvonal, átrepülheted és meredekség szerint színezheted.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Az útvonal {length} hosszan a(z) „{name}” vasalt úton halad. Ehhez via ferrata felszerelés és gyakorlat kell.',
|
||||
'tours.ferrata.crossingShort': 'Az útvonal vasalt úton halad',
|
||||
'tours.ferrata.waypointAtEnd': '„{name}” eleje vagy vége',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrata tanpa nama',
|
||||
'ferratas.viewInOsm': 'Lihat di OpenStreetMap',
|
||||
'ferratas.attribution': 'Data © kontributor OpenStreetMap',
|
||||
'ferratas.adopt': 'Tambahkan ke tur',
|
||||
'ferratas.onTour': 'Bagian dari tur',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Perkiraan waktu jalan',
|
||||
'tours.planner.walkingEstimatedShort': 'Perk. waktu jalan',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Cara waktu jalan diperkirakan',
|
||||
'tours.planner.walkingEstimatedInfo': 'Dihitung dari jarak, tanjakan, dan turunan dengan aturan waktu mendaki yang umum (DIN 33466), tanpa istirahat.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Dihitung dari jarak, tanjakan, dan turunan dengan aturan waktu mendaki yang umum (DIN 33466), tanpa istirahat.',
|
||||
'tours.planner.waypoints': 'Titik lintasan',
|
||||
'tours.planner.waypointLabel': 'Titik lintasan {n}',
|
||||
'tours.durationMinutes': '{count} menit',
|
||||
@@ -169,5 +170,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Aktifkan Tempatkan titik untuk menaruh titik di medan dengan klik.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Klik medan untuk menambahkan titik. Setelah ada rute, rute itu bisa diterbangi dan diwarnai menurut kemiringan.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Rute melewati {length} di “{name}”. Ini butuh perlengkapan via ferrata dan pengalaman.',
|
||||
'tours.ferrata.crossingShort': 'Rute melewati via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Awal atau akhir “{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrata senza nome',
|
||||
'ferratas.viewInOsm': 'Vedi su OpenStreetMap',
|
||||
'ferratas.attribution': 'Dati © contributori di OpenStreetMap',
|
||||
'ferratas.adopt': 'Aggiungi al giro',
|
||||
'ferratas.onTour': 'Parte del giro',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -70,11 +70,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'Sulla sicurezza',
|
||||
'tours.planner.addPoint': 'Aggiungi punto',
|
||||
'tours.planner.addPointActive': 'Clicca sulla mappa',
|
||||
'tours.planner.emptyHint': 'Attiva «Aggiungi punto» e fai clic sulla mappa. Da due punti in poi TREK calcola il percorso.',
|
||||
'tours.planner.emptyHint':
|
||||
'Attiva «Aggiungi punto» e fai clic sulla mappa. Da due punti in poi TREK calcola il percorso.',
|
||||
'tours.planner.walkingEstimated': 'Tempo di cammino stimato',
|
||||
'tours.planner.walkingEstimatedShort': 'Tempo stim.',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Come viene stimato il tempo di cammino',
|
||||
'tours.planner.walkingEstimatedInfo': 'Calcolato da distanza, salita e discesa con la consueta regola dei tempi di cammino (DIN 33466), senza pause.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Calcolato da distanza, salita e discesa con la consueta regola dei tempi di cammino (DIN 33466), senza pause.',
|
||||
'tours.planner.waypoints': 'Punti di passaggio',
|
||||
'tours.planner.waypointLabel': 'Punto di passaggio {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -171,5 +173,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Attiva Posiziona punti per aggiungere punti sul terreno con un clic.',
|
||||
'tours.terrain.clickToPlan':
|
||||
"Fai clic sul terreno per aggiungere punti. Appena c'è un percorso, puoi sorvolarlo e colorarlo in base alla pendenza.",
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Il percorso sale per {length} sulla «{name}». Servono attrezzatura da ferrata ed esperienza.',
|
||||
'tours.ferrata.crossingShort': 'Il percorso passa per una via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Inizio o fine di «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': '名称未設定のヴィア・フェラータ',
|
||||
'ferratas.viewInOsm': 'OpenStreetMap で表示',
|
||||
'ferratas.attribution': 'データ © OpenStreetMap 貢献者',
|
||||
'ferratas.adopt': 'ツアーに追加',
|
||||
'ferratas.onTour': 'ツアーの一部',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -70,7 +70,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': '推定歩行時間',
|
||||
'tours.planner.walkingEstimatedShort': '推定時間',
|
||||
'tours.planner.walkingEstimatedInfoLabel': '歩行時間の推定方法',
|
||||
'tours.planner.walkingEstimatedInfo': '一般的な歩行時間の規則(DIN 33466)で、距離と登り・下りから計算しています。休憩は含みません。',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'一般的な歩行時間の規則(DIN 33466)で、距離と登り・下りから計算しています。休憩は含みません。',
|
||||
'tours.planner.waypoints': 'ルート上の地点',
|
||||
'tours.planner.waypointLabel': '地点{n}',
|
||||
'tours.durationMinutes': '{count}分',
|
||||
@@ -164,5 +165,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.preloading': '地形を読み込み中: {percent} %',
|
||||
'tours.terrain.placeOff': '地形をクリックしてポイントを置くには「ポイントを配置」をオンにしてください。',
|
||||
'tours.terrain.clickToPlan': '地形をクリックしてポイントを置きます。ルートができると、飛行して勾配で色分けできます。',
|
||||
'tours.ferrata.badge': 'ヴィア・フェラータ {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'ヴィア・フェラータ',
|
||||
'tours.ferrata.crossingInfo': 'ルートは「{name}」を {length} 通ります。ヴィア・フェラータ用の装備と経験が必要です。',
|
||||
'tours.ferrata.crossingShort': 'ルートがヴィア・フェラータを通ります',
|
||||
'tours.ferrata.waypointAtEnd': '「{name}」の始点または終点',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': '이름 없는 비아 페라타',
|
||||
'ferratas.viewInOsm': 'OpenStreetMap에서 보기',
|
||||
'ferratas.attribution': '데이터 © OpenStreetMap 기여자',
|
||||
'ferratas.adopt': '투어에 추가',
|
||||
'ferratas.onTour': '투어의 일부',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -71,7 +71,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': '예상 보행 시간',
|
||||
'tours.planner.walkingEstimatedShort': '예상 시간',
|
||||
'tours.planner.walkingEstimatedInfoLabel': '보행 시간을 추정하는 방법',
|
||||
'tours.planner.walkingEstimatedInfo': '일반적인 산행 시간 규칙(DIN 33466)에 따라 거리, 오르막, 내리막으로 계산하며 휴식은 포함하지 않습니다.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'일반적인 산행 시간 규칙(DIN 33466)에 따라 거리, 오르막, 내리막으로 계산하며 휴식은 포함하지 않습니다.',
|
||||
'tours.planner.waypoints': '경로 지점',
|
||||
'tours.planner.waypointLabel': '지점 {n}',
|
||||
'tours.durationMinutes': '{count}분',
|
||||
@@ -166,5 +167,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': '지형을 클릭해 지점을 찍으려면 지점 찍기를 켜세요.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'지형을 클릭해 지점을 추가하세요. 경로가 생기면 비행하고 경사별로 색을 입힐 수 있습니다.',
|
||||
'tours.ferrata.badge': '비아 페라타 {grade}',
|
||||
'tours.ferrata.badgeNoGrade': '비아 페라타',
|
||||
'tours.ferrata.crossingInfo': '경로가 “{name}”을(를) {length} 지납니다. 비아 페라타 장비와 경험이 필요합니다.',
|
||||
'tours.ferrata.crossingShort': '경로가 비아 페라타를 지납니다',
|
||||
'tours.ferrata.waypointAtEnd': '“{name}”의 시작 또는 끝',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrata zonder naam',
|
||||
'ferratas.viewInOsm': 'Bekijken in OpenStreetMap',
|
||||
'ferratas.attribution': 'Gegevens © OpenStreetMap-bijdragers',
|
||||
'ferratas.adopt': 'Aan tocht toevoegen',
|
||||
'ferratas.onTour': 'Deel van de tocht',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -73,7 +73,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Geschatte looptijd',
|
||||
'tours.planner.walkingEstimatedShort': 'Geschatte tijd',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Hoe de looptijd wordt geschat',
|
||||
'tours.planner.walkingEstimatedInfo': 'Berekend uit afstand, stijging en daling met de gangbare wandeltijdregel (DIN 33466), zonder pauzes.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Berekend uit afstand, stijging en daling met de gangbare wandeltijdregel (DIN 33466), zonder pauzes.',
|
||||
'tours.planner.waypoints': 'Routepunten',
|
||||
'tours.planner.waypointLabel': 'Routepunt {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -170,5 +171,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Zet Punten plaatsen aan om punten op het terrein te klikken.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Klik in het terrein om punten te zetten. Zodra er een route is, kun je eroverheen vliegen en hem op helling kleuren.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'De route loopt {length} over „{name}”. Daarvoor heb je via-ferratamateriaal en ervaring nodig.',
|
||||
'tours.ferrata.crossingShort': 'De route loopt over een via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Begin of einde van „{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -15,5 +15,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Ferrata bez nazwy',
|
||||
'ferratas.viewInOsm': 'Pokaż w OpenStreetMap',
|
||||
'ferratas.attribution': 'Dane © współtwórcy OpenStreetMap',
|
||||
'ferratas.adopt': 'Dodaj do wycieczki',
|
||||
'ferratas.onTour': 'Część wycieczki',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -75,7 +75,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Szacowany czas marszu',
|
||||
'tours.planner.walkingEstimatedShort': 'Szac. czas marszu',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Jak szacowany jest czas marszu',
|
||||
'tours.planner.walkingEstimatedInfo': 'Obliczony z odległości, podejścia i zejścia według zwykłej reguły czasu marszu (DIN 33466), bez przerw.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Obliczony z odległości, podejścia i zejścia według zwykłej reguły czasu marszu (DIN 33466), bez przerw.',
|
||||
'tours.planner.waypoints': 'Punkty trasy',
|
||||
'tours.planner.waypointLabel': 'Punkt trasy {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -174,5 +175,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Włącz Ustawiaj punkty, aby klikaniem dodawać punkty w terenie.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Kliknij teren, aby dodać punkty. Gdy powstanie trasa, można nad nią przelecieć i pokolorować ją według nachylenia.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Trasa prowadzi {length} przez „{name}”. Potrzebny jest sprzęt do via ferraty i doświadczenie.',
|
||||
'tours.ferrata.crossingShort': 'Trasa prowadzi przez via ferratę',
|
||||
'tours.ferrata.waypointAtEnd': 'Początek lub koniec „{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -15,5 +15,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Виа феррата без названия',
|
||||
'ferratas.viewInOsm': 'Открыть в OpenStreetMap',
|
||||
'ferratas.attribution': 'Данные © участники OpenStreetMap',
|
||||
'ferratas.adopt': 'Добавить в маршрут',
|
||||
'ferratas.onTour': 'Часть маршрута',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -71,11 +71,13 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.safetyInfoLabel': 'О безопасности',
|
||||
'tours.planner.addPoint': 'Добавить точку',
|
||||
'tours.planner.addPointActive': 'Нажмите на карту',
|
||||
'tours.planner.emptyHint': 'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.',
|
||||
'tours.planner.emptyHint':
|
||||
'Включите «Добавить точку» и нажмите на карту. Начиная с двух точек TREK рассчитает маршрут.',
|
||||
'tours.planner.walkingEstimated': 'Примерное время в пути',
|
||||
'tours.planner.walkingEstimatedShort': 'Прим. время',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Как оценивается время в пути',
|
||||
'tours.planner.walkingEstimatedInfo': 'Рассчитано по расстоянию, подъёму и спуску по обычному правилу времени в пути (DIN 33466), без перерывов.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Рассчитано по расстоянию, подъёму и спуску по обычному правилу времени в пути (DIN 33466), без перерывов.',
|
||||
'tours.planner.waypoints': 'Путевые точки',
|
||||
'tours.planner.waypointLabel': 'Путевая точка {n}',
|
||||
'tours.durationMinutes': '{count} мин',
|
||||
@@ -174,5 +176,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Включите «Ставить точки», чтобы ставить точки на рельефе щелчком.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Нажмите на рельеф, чтобы поставить точки. Когда появится маршрут, его можно облететь и раскрасить по уклону.',
|
||||
'tours.ferrata.badge': 'Виа феррата {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Виа феррата',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Маршрут проходит {length} по «{name}». Для этого нужны снаряжение для виа ферраты и опыт.',
|
||||
'tours.ferrata.crossingShort': 'Маршрут проходит по виа феррате',
|
||||
'tours.ferrata.waypointAtEnd': 'Начало или конец «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -14,5 +14,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Ferrata bez názvu',
|
||||
'ferratas.viewInOsm': 'Zobraziť v OpenStreetMap',
|
||||
'ferratas.attribution': 'Údaje © prispievatelia OpenStreetMap',
|
||||
'ferratas.adopt': 'Pridať do túry',
|
||||
'ferratas.onTour': 'Súčasť túry',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Odhadovaný čas chôdze',
|
||||
'tours.planner.walkingEstimatedShort': 'Odhad. čas chôdze',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Ako sa čas chôdze odhaduje',
|
||||
'tours.planner.walkingEstimatedInfo': 'Vypočítané zo vzdialenosti, stúpania a klesania podľa bežného pravidla pre čas chôdze (DIN 33466), bez prestávok.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Vypočítané zo vzdialenosti, stúpania a klesania podľa bežného pravidla pre čas chôdze (DIN 33466), bez prestávok.',
|
||||
'tours.planner.waypoints': 'Body trasy',
|
||||
'tours.planner.waypointLabel': 'Bod trasy {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -169,5 +170,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Zapni Pridávať body a klikaním ich pridávaj do terénu.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Kliknutím do terénu pridáte body. Keď vznikne trasa, dá sa preletieť a zafarbiť podľa sklonu.',
|
||||
'tours.ferrata.badge': 'Ferata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Ferata',
|
||||
'tours.ferrata.crossingInfo': 'Trasa vedie {length} po „{name}“. Potrebujete feratovú výstroj a skúsenosti.',
|
||||
'tours.ferrata.crossingShort': 'Trasa vedie po ferate',
|
||||
'tours.ferrata.waypointAtEnd': 'Začiatok alebo koniec „{name}“',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Namnlös via ferrata',
|
||||
'ferratas.viewInOsm': 'Visa i OpenStreetMap',
|
||||
'ferratas.attribution': 'Data © OpenStreetMap-bidragsgivare',
|
||||
'ferratas.adopt': 'Lägg till i turen',
|
||||
'ferratas.onTour': 'Del av turen',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Uppskattad gångtid',
|
||||
'tours.planner.walkingEstimatedShort': 'Uppsk. gångtid',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Hur gångtiden uppskattas',
|
||||
'tours.planner.walkingEstimatedInfo': 'Beräknad utifrån sträcka, stigning och nedstigning enligt den vanliga vandringstidsregeln (DIN 33466), utan pauser.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Beräknad utifrån sträcka, stigning och nedstigning enligt den vanliga vandringstidsregeln (DIN 33466), utan pauser.',
|
||||
'tours.planner.waypoints': 'Ruttpunkter',
|
||||
'tours.planner.waypointLabel': 'Ruttpunkt {n}',
|
||||
'tours.durationMinutes': '{count} min',
|
||||
@@ -168,5 +169,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Slå på Sätt punkter för att klicka ut punkter i terrängen.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Klicka i terrängen för att sätta ut punkter. När det finns en rutt kan du flyga över den och färga den efter lutning.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo': 'Rutten går {length} längs ”{name}”. Det kräver via ferrata-utrustning och erfarenhet.',
|
||||
'tours.ferrata.crossingShort': 'Rutten går längs en via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Början eller slutet av ”{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'เส้นทางเวียเฟอร์ราตาไม่มีชื่อ',
|
||||
'ferratas.viewInOsm': 'ดูใน OpenStreetMap',
|
||||
'ferratas.attribution': 'ข้อมูล © ผู้ร่วมให้ข้อมูล OpenStreetMap',
|
||||
'ferratas.adopt': 'เพิ่มลงในทัวร์',
|
||||
'ferratas.onTour': 'เป็นส่วนหนึ่งของทัวร์',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -71,7 +71,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'เวลาเดินโดยประมาณ',
|
||||
'tours.planner.walkingEstimatedShort': 'เวลาโดยประมาณ',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'วิธีประมาณเวลาเดิน',
|
||||
'tours.planner.walkingEstimatedInfo': 'คำนวณจากระยะทาง ทางขึ้น และทางลง ตามกฎเวลาเดินป่าทั่วไป (DIN 33466) ไม่รวมเวลาพัก',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'คำนวณจากระยะทาง ทางขึ้น และทางลง ตามกฎเวลาเดินป่าทั่วไป (DIN 33466) ไม่รวมเวลาพัก',
|
||||
'tours.planner.waypoints': 'จุดแวะ',
|
||||
'tours.planner.waypointLabel': 'จุดแวะ {n}',
|
||||
'tours.durationMinutes': '{count} นาที',
|
||||
@@ -164,5 +165,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.preloading': 'กำลังโหลดภูมิทัศน์: {percent} %',
|
||||
'tours.terrain.placeOff': 'เปิด วางจุด เพื่อคลิกวางจุดบนภูมิประเทศ',
|
||||
'tours.terrain.clickToPlan': 'คลิกบนภูมิประเทศเพื่อวางจุด เมื่อมีเส้นทางแล้ว จะบินผ่านและระบายสีตามความชันได้',
|
||||
'tours.ferrata.badge': 'เวียเฟอร์ราตา {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'เวียเฟอร์ราตา',
|
||||
'tours.ferrata.crossingInfo': 'เส้นทางผ่าน “{name}” เป็นระยะ {length} ต้องใช้อุปกรณ์เวียเฟอร์ราตาและประสบการณ์',
|
||||
'tours.ferrata.crossingShort': 'เส้นทางผ่านเวียเฟอร์ราตา',
|
||||
'tours.ferrata.waypointAtEnd': 'จุดเริ่มหรือจุดสิ้นสุดของ “{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -13,5 +13,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Adsız via ferrata',
|
||||
'ferratas.viewInOsm': "OpenStreetMap'te görüntüle",
|
||||
'ferratas.attribution': 'Veriler © OpenStreetMap katkıcıları',
|
||||
'ferratas.adopt': 'Tura ekle',
|
||||
'ferratas.onTour': 'Turun parçası',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Tahmini yürüyüş süresi',
|
||||
'tours.planner.walkingEstimatedShort': 'Tahm. süre',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Yürüyüş süresi nasıl tahmin edilir',
|
||||
'tours.planner.walkingEstimatedInfo': 'Mesafe, çıkış ve inişten olağan yürüyüş süresi kuralıyla (DIN 33466) hesaplanır, molalar dahil değildir.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Mesafe, çıkış ve inişten olağan yürüyüş süresi kuralıyla (DIN 33466) hesaplanır, molalar dahil değildir.',
|
||||
'tours.planner.waypoints': 'Rota noktaları',
|
||||
'tours.planner.waypointLabel': 'Rota noktası {n}',
|
||||
'tours.durationMinutes': '{count} dk',
|
||||
@@ -169,5 +170,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Araziye tıklayarak nokta koymak için Nokta koy aracını aç.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Nokta eklemek için araziye tıklayın. Bir rota oluştuğunda üzerinden uçabilir ve eğime göre renklendirebilirsiniz.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Rota {length} boyunca “{name}” üzerinden geçiyor. Bunun için via ferrata ekipmanı ve deneyim gerekir.',
|
||||
'tours.ferrata.crossingShort': 'Rota bir via ferrata üzerinden geçiyor',
|
||||
'tours.ferrata.waypointAtEnd': '“{name}” başlangıcı veya sonu',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -15,5 +15,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Віа ферата без назви',
|
||||
'ferratas.viewInOsm': 'Відкрити в OpenStreetMap',
|
||||
'ferratas.attribution': 'Дані © учасники OpenStreetMap',
|
||||
'ferratas.adopt': 'Додати до туру',
|
||||
'ferratas.onTour': 'Частина туру',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -74,7 +74,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Орієнтовний час у дорозі',
|
||||
'tours.planner.walkingEstimatedShort': 'Орієнт. час',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Як оцінюється час у дорозі',
|
||||
'tours.planner.walkingEstimatedInfo': 'Розраховано за відстанню, підйомом і спуском за звичайним правилом часу ходьби (DIN 33466), без перерв.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Розраховано за відстанню, підйомом і спуском за звичайним правилом часу ходьби (DIN 33466), без перерв.',
|
||||
'tours.planner.waypoints': 'Маршрутні точки',
|
||||
'tours.planner.waypointLabel': 'Маршрутна точка {n}',
|
||||
'tours.durationMinutes': '{count} хв',
|
||||
@@ -173,5 +174,11 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Увімкніть «Ставити точки», щоб ставити точки на рельєфі клацанням.',
|
||||
'tours.terrain.clickToPlan':
|
||||
"Натисніть на рельєф, щоб поставити точки. Коли з'явиться маршрут, його можна облетіти й розфарбувати за ухилом.",
|
||||
'tours.ferrata.badge': 'Віа ферата {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Віа ферата',
|
||||
'tours.ferrata.crossingInfo':
|
||||
'Маршрут проходить {length} по «{name}». Для цього потрібні спорядження для віа ферати й досвід.',
|
||||
'tours.ferrata.crossingShort': 'Маршрут проходить по віа фераті',
|
||||
'tours.ferrata.waypointAtEnd': 'Початок або кінець «{name}»',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': 'Via ferrata chưa có tên',
|
||||
'ferratas.viewInOsm': 'Xem trên OpenStreetMap',
|
||||
'ferratas.attribution': 'Dữ liệu © những người đóng góp OpenStreetMap',
|
||||
'ferratas.adopt': 'Thêm vào tour',
|
||||
'ferratas.onTour': 'Một phần của tour',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -72,7 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.walkingEstimated': 'Thời gian đi bộ ước tính',
|
||||
'tours.planner.walkingEstimatedShort': 'TG ước tính',
|
||||
'tours.planner.walkingEstimatedInfoLabel': 'Cách ước tính thời gian đi bộ',
|
||||
'tours.planner.walkingEstimatedInfo': 'Tính từ quãng đường, đoạn lên và đoạn xuống theo quy tắc thời gian đi bộ thông thường (DIN 33466), không tính nghỉ.',
|
||||
'tours.planner.walkingEstimatedInfo':
|
||||
'Tính từ quãng đường, đoạn lên và đoạn xuống theo quy tắc thời gian đi bộ thông thường (DIN 33466), không tính nghỉ.',
|
||||
'tours.planner.waypoints': 'Các điểm trên tuyến',
|
||||
'tours.planner.waypointLabel': 'Điểm {n}',
|
||||
'tours.durationMinutes': '{count} phút',
|
||||
@@ -169,5 +170,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.placeOff': 'Bật Đặt điểm để nhấp đặt điểm trên địa hình.',
|
||||
'tours.terrain.clickToPlan':
|
||||
'Nhấp vào địa hình để đặt điểm. Khi đã có tuyến đường, bạn có thể bay qua và tô màu theo độ dốc.',
|
||||
'tours.ferrata.badge': 'Via ferrata {grade}',
|
||||
'tours.ferrata.badgeNoGrade': 'Via ferrata',
|
||||
'tours.ferrata.crossingInfo': 'Tuyến đi qua “{name}” dài {length}. Cần trang bị via ferrata và kinh nghiệm.',
|
||||
'tours.ferrata.crossingShort': 'Tuyến đi qua một via ferrata',
|
||||
'tours.ferrata.waypointAtEnd': 'Điểm đầu hoặc cuối của “{name}”',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': '未命名的飛拉達',
|
||||
'ferratas.viewInOsm': '在 OpenStreetMap 中檢視',
|
||||
'ferratas.attribution': '資料 © OpenStreetMap 貢獻者',
|
||||
'ferratas.adopt': '加入行程',
|
||||
'ferratas.onTour': '行程的一部分',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -161,5 +161,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.preloading': '正在載入地形:{percent} %',
|
||||
'tours.terrain.placeOff': '開啟「放置點」後,點擊地形即可放置點。',
|
||||
'tours.terrain.clickToPlan': '點擊地形來設定點。有了路線後,就可以飛越並依坡度著色。',
|
||||
'tours.ferrata.badge': '岩釘路線 {grade}',
|
||||
'tours.ferrata.badgeNoGrade': '岩釘路線',
|
||||
'tours.ferrata.crossingInfo': '路線經過「{name}」{length}。需要岩釘路線裝備與經驗。',
|
||||
'tours.ferrata.crossingShort': '路線經過岩釘路線',
|
||||
'tours.ferrata.waypointAtEnd': '「{name}」的起點或終點',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -12,5 +12,7 @@ const ferratas: TranslationStrings = {
|
||||
'ferratas.unnamed': '未命名飞拉达',
|
||||
'ferratas.viewInOsm': '在 OpenStreetMap 中查看',
|
||||
'ferratas.attribution': '数据 © OpenStreetMap 贡献者',
|
||||
'ferratas.adopt': '加入行程',
|
||||
'ferratas.onTour': '行程的一部分',
|
||||
};
|
||||
export default ferratas;
|
||||
|
||||
@@ -161,5 +161,10 @@ const tours: TranslationStrings = {
|
||||
'tours.terrain.preloading': '正在加载地形:{percent} %',
|
||||
'tours.terrain.placeOff': '开启“放置点”后,点击地形即可放置点。',
|
||||
'tours.terrain.clickToPlan': '点击地形来设置点。有了路线后,就可以飞越并按坡度着色。',
|
||||
'tours.ferrata.badge': '飞拉达 {grade}',
|
||||
'tours.ferrata.badgeNoGrade': '飞拉达',
|
||||
'tours.ferrata.crossingInfo': '路线经过“{name}”{length}。需要飞拉达装备和经验。',
|
||||
'tours.ferrata.crossingShort': '路线经过飞拉达',
|
||||
'tours.ferrata.waypointAtEnd': '“{name}”的起点或终点',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -147,3 +147,42 @@ describe('Tour informational website', () => {
|
||||
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: value })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tours fixed legs', () => {
|
||||
const leg = (from: [number, number], to: [number, number]) => ({
|
||||
kind: 'ferrata' as const,
|
||||
ref: 'way/1',
|
||||
name: 'Via A',
|
||||
geometry: [from, [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] as [number, number], to],
|
||||
});
|
||||
|
||||
it('accepts a fixed leg that runs from its waypoint to the next one', () => {
|
||||
const request = {
|
||||
...baseRequest,
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: leg([48, 11], [48.01, 11.02]) },
|
||||
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
|
||||
],
|
||||
};
|
||||
expect(tourCreateRequestSchema.parse(request).waypoints[0]?.fixed_leg?.ref).toBe('way/1');
|
||||
});
|
||||
|
||||
it('refuses a fixed leg that does not join its waypoints, or hangs off the last one', () => {
|
||||
const elsewhere = {
|
||||
...baseRequest,
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0, fixed_leg: leg([47, 10], [48.01, 11.02]) },
|
||||
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1 },
|
||||
],
|
||||
};
|
||||
expect(() => tourCreateRequestSchema.parse(elsewhere)).toThrow(/fixed leg/);
|
||||
const dangling = {
|
||||
...baseRequest,
|
||||
waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
|
||||
{ lat: 48.01, lng: 11.02, role: 'end' as const, sequence: 1, fixed_leg: leg([48.01, 11.02], [48.02, 11.03]) },
|
||||
],
|
||||
};
|
||||
expect(() => tourCreateRequestSchema.parse(dangling)).toThrow(/fixed leg/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,14 +113,52 @@ export type TourListResponse = z.infer<typeof tourListResponseSchema>;
|
||||
export const tourWaypointRoleSchema = z.enum(['start', 'via', 'end']);
|
||||
export type TourWaypointRole = z.infer<typeof tourWaypointRoleSchema>;
|
||||
|
||||
/** The most points a fixed leg may carry: a long via ferrata is a few hundred. */
|
||||
export const TOUR_FIXED_LEG_MAX_POINTS = 5000;
|
||||
/** How far a fixed leg's ends may lie from the waypoints it joins, in metres. */
|
||||
export const TOUR_FIXED_LEG_END_TOLERANCE_M = 60;
|
||||
|
||||
/**
|
||||
* A leg the router does not plan: the line from this waypoint to the next is
|
||||
* taken as it stands. Today that is a via ferrata put onto the tour from the
|
||||
* map, whose own line from OpenStreetMap the tour follows, since a router
|
||||
* walks around a ferrata wherever it can.
|
||||
*/
|
||||
export const tourFixedLegSchema = z.object({
|
||||
kind: z.literal('ferrata'),
|
||||
/** The OSM way or group the line came from, `way/<id>` style. */
|
||||
ref: z.string().trim().min(1).max(64),
|
||||
name: z.string().trim().max(255).nullable(),
|
||||
geometry: z
|
||||
.array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)]))
|
||||
.min(2)
|
||||
.max(TOUR_FIXED_LEG_MAX_POINTS),
|
||||
});
|
||||
export type TourFixedLeg = z.infer<typeof tourFixedLegSchema>;
|
||||
|
||||
export const tourWaypointSchema = z.object({
|
||||
lat: z.number().finite().min(-90).max(90),
|
||||
lng: z.number().finite().min(-180).max(180),
|
||||
role: tourWaypointRoleSchema,
|
||||
sequence: z.number().int().nonnegative(),
|
||||
/** The leg to the next waypoint as a fixed line; absent or null for a routed one. */
|
||||
fixed_leg: tourFixedLegSchema.nullable().optional(),
|
||||
});
|
||||
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
|
||||
|
||||
/**
|
||||
* Metres between two `[lat, lng]` points, close enough at the length of a
|
||||
* leg's end gap. Read as plain arrays: a workspace compiled without strict
|
||||
* mode sees the schema's tuples as "maybe two numbers".
|
||||
*/
|
||||
function fixedLegGapM(a: readonly unknown[], b: readonly unknown[]): number {
|
||||
const [aLat, aLng, bLat, bLng] = [Number(a[0]), Number(a[1]), Number(b[0]), Number(b[1])];
|
||||
const rad = Math.PI / 180;
|
||||
const x = (bLng - aLng) * rad * Math.cos(((aLat + bLat) / 2) * rad);
|
||||
const y = (bLat - aLat) * rad;
|
||||
return Math.hypot(x, y) * 6_371_000;
|
||||
}
|
||||
|
||||
const enrichedRoutePointSchema = z.tuple([
|
||||
z.number().finite().min(-90).max(90),
|
||||
z.number().finite().min(-180).max(180),
|
||||
@@ -154,6 +192,25 @@ export const tourCreateRequestSchema = z
|
||||
});
|
||||
}
|
||||
value.waypoints.forEach((point, index) => {
|
||||
const leg = point.fixed_leg;
|
||||
const next = value.waypoints[index + 1];
|
||||
if (leg) {
|
||||
const first = leg.geometry[0];
|
||||
const last = leg.geometry[leg.geometry.length - 1];
|
||||
const joins =
|
||||
!!next &&
|
||||
!!first &&
|
||||
!!last &&
|
||||
fixedLegGapM(first, [point.lat, point.lng]) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
|
||||
fixedLegGapM(last, [next.lat, next.lng]) <= TOUR_FIXED_LEG_END_TOLERANCE_M;
|
||||
if (!joins) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['waypoints', index, 'fixed_leg'],
|
||||
message: `Waypoint ${index}'s fixed leg must run from it to the next waypoint`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const expectedRole: TourWaypointRole =
|
||||
index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via';
|
||||
if (point.sequence !== index || point.role !== expectedRole) {
|
||||
|
||||
Reference in New Issue
Block a user