diff --git a/client/src/components/Ferratas/FerrataDetailCard.test.tsx b/client/src/components/Ferratas/FerrataDetailCard.test.tsx index e51da9f5e..3a7c3edb5 100644 --- a/client/src/components/Ferratas/FerrataDetailCard.test.tsx +++ b/client/src/components/Ferratas/FerrataDetailCard.test.tsx @@ -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(); + expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument(); + + const onAdopt = vi.fn(); + rerender(); + 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(); + expect(screen.getByTestId('ferrata-on-tour')).toHaveTextContent('Part of the tour'); + expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Ferratas/FerrataDetailCard.tsx b/client/src/components/Ferratas/FerrataDetailCard.tsx index 96b949d72..2c948fdf3 100644 --- a/client/src/components/Ferratas/FerrataDetailCard.tsx +++ b/client/src/components/Ferratas/FerrataDetailCard.tsx @@ -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({ )} + {onTour ? ( +
+ }> + {t('ferratas.onTour')} + +
+ ) : ( + onAdopt && ( + + ) + )} 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; }) { const ref = useRef(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" > - + ); } diff --git a/client/src/components/Ferratas/FerrataGlyph.tsx b/client/src/components/Ferratas/FerrataGlyph.tsx new file mode 100644 index 000000000..b40fbbf27 --- /dev/null +++ b/client/src/components/Ferratas/FerrataGlyph.tsx @@ -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 ( + + ); +} + +/** + * 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 ( + + ); +} diff --git a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx index d139667ee..c95695f50 100644 --- a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx +++ b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx @@ -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(); 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(); 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(); 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)]); diff --git a/client/src/components/Ferratas/FerrataLayerLeaflet.tsx b/client/src/components/Ferratas/FerrataLayerLeaflet.tsx index f129559f9..b062db7be 100644 --- a/client/src/components/Ferratas/FerrataLayerLeaflet.tsx +++ b/client/src/components/Ferratas/FerrataLayerLeaflet.tsx @@ -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], diff --git a/client/src/components/Ferratas/ferrataCrossings.test.ts b/client/src/components/Ferratas/ferrataCrossings.test.ts new file mode 100644 index 000000000..ed7d364d3 --- /dev/null +++ b/client/src/components/Ferratas/ferrataCrossings.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Ferratas/ferrataCrossings.ts b/client/src/components/Ferratas/ferrataCrossings.ts new file mode 100644 index 000000000..000b9905e --- /dev/null +++ b/client/src/components/Ferratas/ferrataCrossings.ts @@ -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 +): 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(); + 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( + waypoints: readonly { id: string; lat: number; lng: number }[], + crossings: readonly FerrataCrossing[], + describe: (group: FerrataGroup) => T +): Record { + const marks: Record = {}; + 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[] +): Array { + // 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; + }); +} diff --git a/client/src/components/Ferratas/ferrataPaint.test.ts b/client/src/components/Ferratas/ferrataPaint.test.ts index 86d607329..5898dccfc 100644 --- a/client/src/components/Ferratas/ferrataPaint.test.ts +++ b/client/src/components/Ferratas/ferrataPaint.test.ts @@ -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); - }); }); diff --git a/client/src/components/Ferratas/ferrataPaint.ts b/client/src/components/Ferratas/ferrataPaint.ts index e93b97023..6d2002051 100644 --- a/client/src/components/Ferratas/ferrataPaint.ts +++ b/client/src/components/Ferratas/ferrataPaint.ts @@ -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), }; } diff --git a/client/src/components/Ferratas/useFerrataMapLayer.ts b/client/src/components/Ferratas/useFerrataMapLayer.ts index 99aeec1dc..67993cb99 100644 --- a/client/src/components/Ferratas/useFerrataMapLayer.ts +++ b/client/src/components/Ferratas/useFerrataMapLayer.ts @@ -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> = [] + areaLines: ReadonlyArray> = [], + /** 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(null); @@ -66,8 +69,10 @@ export function useFerrataMapLayer( const lastArea = useRef(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(() => 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 }; } diff --git a/client/src/components/Ferratas/useFerrataPaint.test.ts b/client/src/components/Ferratas/useFerrataPaint.test.ts index 7ba6b5122..c2ad9bf34 100644 --- a/client/src/components/Ferratas/useFerrataPaint.test.ts +++ b/client/src/components/Ferratas/useFerrataPaint.test.ts @@ -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; diff --git a/client/src/components/Ferratas/useTripFerratas.ts b/client/src/components/Ferratas/useTripFerratas.ts index 7b0eadd28..29dbe437d 100644 --- a/client/src/components/Ferratas/useTripFerratas.ts +++ b/client/src/components/Ferratas/useTripFerratas.ts @@ -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. */ diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 2c1ae620a..b6e6689f6 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -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)} > - + {!compact && {t('ferratas.layer')}} {live && ( )} - - {website && ( -
+ + {website && ( }> {website.provider ?? website.domain} -
- )} + )} +
{children} ) diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 99aecd116..8dceacdc5 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -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() + 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() diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 869de1523..b3256c5af 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -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() + 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 ( +
+ {[...byFerrata].map(([id, ferrata]) => { + const info = t('tours.ferrata.crossingInfo', { length: formatDistance(ferrata.metres / 1000, unit ?? 'metric'), name: ferrata.name }) + return ( + + + }> + {ferrata.grade ? t('tours.ferrata.badge', { grade: ferrata.grade }) : t('tours.ferrata.badgeNoGrade')} + + + + ) + })} +
+ ) +} + /** 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 }) { )} + {onFerrata && ( + + + + )} {statusLabel && ( {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 ( @@ -288,6 +332,7 @@ function ElevationSection({ id, samples, gradientId, planner }: { focus={planner.routeProfileFocus} onFocusChange={planner.setRouteProfileFocus} formatFocus={focus => profileFocusLabel(focus, t, distanceUnit)} + bands={crossings} />} @@ -396,6 +441,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo {hasRoute && (
+ {routeAnalysis && ( diff --git a/client/src/components/Tours/planner/TourWaypointList.tsx b/client/src/components/Tours/planner/TourWaypointList.tsx index d3319b77d..92c991582 100644 --- a/client/src/components/Tours/planner/TourWaypointList.tsx +++ b/client/src/components/Tours/planner/TourWaypointList.tsx @@ -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 ( +
  • + + + + {crossing.name} + + {crossing.grade && ( + + {crossing.grade} + + )} + + {length} + + +
  • + ); +} + +/** + * 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 = ( + + {index + 1} + {ferrata && } + + ); + if (!ferrata) return number; + const grade = ferrata.grade ? ` (${ferrata.grade})` : ''; + return {number}; +} + /** * 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(null); const [dropTarget, setDropTarget] = useState(null); const placeNames = useWaypointPlaceNames(planner.waypoints); + const ferrataEnds = useTourFerrataCrossingStore((state) => state.waypoints); + const crossings = useTourFerrataCrossingStore((state) => state.crossings); + const legCrossings = new Map(); + 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 ( -
  • { - if (!movable) { + +
  • { + 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 && ( -
  • +
    + + {(legCrossings.get(point.id) ?? []).map((crossing) => ( + + ))} + ); })} diff --git a/client/src/components/Tours/planner/tourFixedLegs.test.ts b/client/src/components/Tours/planner/tourFixedLegs.test.ts new file mode 100644 index 000000000..752f5e91e --- /dev/null +++ b/client/src/components/Tours/planner/tourFixedLegs.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Tours/planner/tourFixedLegs.ts b/client/src/components/Tours/planner/tourFixedLegs.ts new file mode 100644 index 000000000..e72948f6e --- /dev/null +++ b/client/src/components/Tours/planner/tourFixedLegs.ts @@ -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(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; +} diff --git a/client/src/components/Tours/planner/tourRouting.test.ts b/client/src/components/Tours/planner/tourRouting.test.ts index d829d966f..d9ec45602 100644 --- a/client/src/components/Tours/planner/tourRouting.test.ts +++ b/client/src/components/Tours/planner/tourRouting.test.ts @@ -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) diff --git a/client/src/components/Tours/planner/tourRouting.ts b/client/src/components/Tours/planner/tourRouting.ts index 975af0f5a..2a46211ba 100644 --- a/client/src/components/Tours/planner/tourRouting.ts +++ b/client/src/components/Tours/planner/tourRouting.ts @@ -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[], + waypoints: Array & { fixedLeg?: TourFixedLeg | null }>, signal?: AbortSignal, maxHikingDifficulty = TOURS_MAX_HIKING_DIFFICULTY, ): Promise { - 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>) => { + 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> = 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 diff --git a/client/src/components/Tours/planner/useTourPlanner.ts b/client/src/components/Tours/planner/useTourPlanner.ts index b936dea3c..e80f1fcd4 100644 --- a/client/src/components/Tours/planner/useTourPlanner.ts +++ b/client/src/components/Tours/planner/useTourPlanner.ts @@ -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, diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index d115760fa..13e34a246 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -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
    )} + {children} ) } diff --git a/client/src/components/shared/ElevationProfile.test.tsx b/client/src/components/shared/ElevationProfile.test.tsx index 97f5c5275..d30165d8a 100644 --- a/client/src/components/shared/ElevationProfile.test.tsx +++ b/client/src/components/shared/ElevationProfile.test.tsx @@ -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( + , + ) + const band = container.querySelector('[data-profile-band]') + expect(band).toHaveAttribute('x', '220') + expect(band).toHaveAttribute('width', '220') + expect(band).toHaveAttribute('fill', 'var(--map-ferrata)') + }) }) diff --git a/client/src/components/shared/ElevationProfile.tsx b/client/src/components/shared/ElevationProfile.tsx index 63e272136..1845894ea 100644 --- a/client/src/components/shared/ElevationProfile.tsx +++ b/client/src/components/shared/ElevationProfile.tsx @@ -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(null) const pendingPointerDistanceRef = useRef(null) @@ -117,6 +119,11 @@ export default function ElevationProfile({ samples, color, height = 100, gradien + {bands?.map(band => ( + + ))} {focusX != null && ( diff --git a/client/src/index.css b/client/src/index.css index f86998fc2..1aa8c6f42 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index dbfd6d610..6486e8039 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -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; +const NO_POINTS: readonly { id: string; lat: number; lng: number }[] = []; type PageState = ReturnType; 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 && ( diff --git a/client/src/pages/tripPlanner/useFerrataAdopt.test.tsx b/client/src/pages/tripPlanner/useFerrataAdopt.test.tsx new file mode 100644 index 000000000..578cd3480 --- /dev/null +++ b/client/src/pages/tripPlanner/useFerrataAdopt.test.tsx @@ -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 = {}) { + 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(); + }); +}); diff --git a/client/src/pages/tripPlanner/useFerrataAdopt.ts b/client/src/pages/tripPlanner/useFerrataAdopt.ts new file mode 100644 index 000000000..a785dfc19 --- /dev/null +++ b/client/src/pages/tripPlanner/useFerrataAdopt.ts @@ -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; +} diff --git a/client/src/store/tourFerrataCrossingStore.test.ts b/client/src/store/tourFerrataCrossingStore.test.ts new file mode 100644 index 000000000..30c40d286 --- /dev/null +++ b/client/src/store/tourFerrataCrossingStore.test.ts @@ -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([]); + }); +}); diff --git a/client/src/store/tourFerrataCrossingStore.ts b/client/src/store/tourFerrataCrossingStore.ts new file mode 100644 index 000000000..f6f092c4d --- /dev/null +++ b/client/src/store/tourFerrataCrossingStore.ts @@ -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>; +}>(() => ({ crossings: [], waypoints: {} })); + +const NONE: readonly TourFerrataCrossing[] = []; +const NO_WAYPOINTS: Readonly> = {}; + +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>, + b: Readonly> +): 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> = 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, + }); +} diff --git a/server/src/db/entities/TourWaypoints.entity.ts b/server/src/db/entities/TourWaypoints.entity.ts index 0f6a6ab80..0d9f53e2d 100644 --- a/server/src/db/entities/TourWaypoints.entity.ts +++ b/server/src/db/entities/TourWaypoints.entity.ts @@ -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(), }, }); diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index 1fc21897f..4f41354cf 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -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 = { 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 = { + column: 'tour_waypoints.fixed_leg', + schema: tourFixedLegSchema.nullable(), + fallback: () => null, +}; diff --git a/server/src/db/kysely/TourWaypoints.table.ts b/server/src/db/kysely/TourWaypoints.table.ts index dad4ec6d1..a6e06ecbb 100644 --- a/server/src/db/kysely/TourWaypoints.table.ts +++ b/server/src/db/kysely/TourWaypoints.table.ts @@ -9,4 +9,5 @@ export interface TourWaypointsTable { lng: number; role: string; sequence: number; + fixed_leg: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata.ts b/server/src/db/migrations/Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata.ts new file mode 100644 index 000000000..611d32105 --- /dev/null +++ b/server/src/db/migrations/Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata.ts @@ -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'); + } +} diff --git a/server/src/db/repositories/TourWaypoints.repository.ts b/server/src/db/repositories/TourWaypoints.repository.ts index 10e4bb7d7..a7adee458 100644 --- a/server/src/db/repositories/TourWaypoints.repository.ts +++ b/server/src/db/repositories/TourWaypoints.repository.ts @@ -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 { return this.kysely(); } - /** 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 { - 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 { - 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 { 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, + })), ); } diff --git a/server/tests/unit/db/known-migrations.test.ts b/server/tests/unit/db/known-migrations.test.ts index b2b6a947c..761822334 100644 --- a/server/tests/unit/db/known-migrations.test.ts +++ b/server/tests/unit/db/known-migrations.test.ts @@ -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); }); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index dd765c723..345097e53 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -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. diff --git a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts index abffa8214..4d24524aa 100644 --- a/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts +++ b/server/tests/unit/db/repositories/TourWaypoints.repository.test.ts @@ -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 }); + }); }); diff --git a/server/tests/unit/db/tour-fixed-leg-migration.test.ts b/server/tests/unit/db/tour-fixed-leg-migration.test.ts new file mode 100644 index 000000000..abb71cc4b --- /dev/null +++ b/server/tests/unit/db/tour-fixed-leg-migration.test.ts @@ -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 { + 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); +}); diff --git a/shared/src/i18n/ar/ferratas.ts b/shared/src/i18n/ar/ferratas.ts index f799f2171..64d7a2a8c 100644 --- a/shared/src/i18n/ar/ferratas.ts +++ b/shared/src/i18n/ar/ferratas.ts @@ -17,5 +17,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': 'مسار تسلق حديدي بلا اسم', 'ferratas.viewInOsm': 'عرض في OpenStreetMap', 'ferratas.attribution': 'البيانات © مساهمو OpenStreetMap', + 'ferratas.adopt': 'أضف إلى الجولة', + 'ferratas.onTour': 'جزء من الجولة', }; export default ferratas; diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index da5955a62..ea74bb891 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -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; diff --git a/shared/src/i18n/az/ferratas.ts b/shared/src/i18n/az/ferratas.ts index 41241274d..8e4ae46bf 100644 --- a/shared/src/i18n/az/ferratas.ts +++ b/shared/src/i18n/az/ferratas.ts @@ -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; diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index a76139d13..0cb689ee0 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -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; diff --git a/shared/src/i18n/br/ferratas.ts b/shared/src/i18n/br/ferratas.ts index 0908bec85..0714a2672 100644 --- a/shared/src/i18n/br/ferratas.ts +++ b/shared/src/i18n/br/ferratas.ts @@ -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; diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 49d078030..1b8f8e0e3 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -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; diff --git a/shared/src/i18n/ca/ferratas.ts b/shared/src/i18n/ca/ferratas.ts index 38cdd8e36..cfa61a9e3 100644 --- a/shared/src/i18n/ca/ferratas.ts +++ b/shared/src/i18n/ca/ferratas.ts @@ -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; diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 1135fc795..c5737059c 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -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; diff --git a/shared/src/i18n/cs/ferratas.ts b/shared/src/i18n/cs/ferratas.ts index f3f8861ca..30db87c3d 100644 --- a/shared/src/i18n/cs/ferratas.ts +++ b/shared/src/i18n/cs/ferratas.ts @@ -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; diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index eb01448d7..d0afa3472 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -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; diff --git a/shared/src/i18n/de/ferratas.ts b/shared/src/i18n/de/ferratas.ts index d0999923b..614f2e50f 100644 --- a/shared/src/i18n/de/ferratas.ts +++ b/shared/src/i18n/de/ferratas.ts @@ -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; diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index b28fb0a88..10c3f7f60 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -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; diff --git a/shared/src/i18n/en/ferratas.ts b/shared/src/i18n/en/ferratas.ts index 7a797e4dd..7dfab9773 100644 --- a/shared/src/i18n/en/ferratas.ts +++ b/shared/src/i18n/en/ferratas.ts @@ -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; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index fd08b62af..79f84ab93 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -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; diff --git a/shared/src/i18n/es/ferratas.ts b/shared/src/i18n/es/ferratas.ts index 433b62ae3..62668d364 100644 --- a/shared/src/i18n/es/ferratas.ts +++ b/shared/src/i18n/es/ferratas.ts @@ -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; diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index b7b5b9930..01b5a6ace 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -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; diff --git a/shared/src/i18n/et/ferratas.ts b/shared/src/i18n/et/ferratas.ts index 3c2041580..612c657a1 100644 --- a/shared/src/i18n/et/ferratas.ts +++ b/shared/src/i18n/et/ferratas.ts @@ -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; diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 73b72da33..6641d3b15 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -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; diff --git a/shared/src/i18n/fr/ferratas.ts b/shared/src/i18n/fr/ferratas.ts index 797fb5720..b4e871b4d 100644 --- a/shared/src/i18n/fr/ferratas.ts +++ b/shared/src/i18n/fr/ferratas.ts @@ -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; diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index daef4c40e..cfa3468ab 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -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; diff --git a/shared/src/i18n/gr/ferratas.ts b/shared/src/i18n/gr/ferratas.ts index e9a403fc0..fc389ee6c 100644 --- a/shared/src/i18n/gr/ferratas.ts +++ b/shared/src/i18n/gr/ferratas.ts @@ -13,5 +13,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': 'Διαδρομή via ferrata χωρίς όνομα', 'ferratas.viewInOsm': 'Προβολή στο OpenStreetMap', 'ferratas.attribution': 'Δεδομένα © συντελεστές του OpenStreetMap', + 'ferratas.adopt': 'Προσθήκη στη διαδρομή', + 'ferratas.onTour': 'Μέρος της διαδρομής', }; export default ferratas; diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 32ddbd82c..ecb2ee1b4 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -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; diff --git a/shared/src/i18n/hu/ferratas.ts b/shared/src/i18n/hu/ferratas.ts index 73f6c765c..af52915a3 100644 --- a/shared/src/i18n/hu/ferratas.ts +++ b/shared/src/i18n/hu/ferratas.ts @@ -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; diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 2f9ea7f3e..2288c123b 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -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; diff --git a/shared/src/i18n/id/ferratas.ts b/shared/src/i18n/id/ferratas.ts index 85190ff70..69b3c23e7 100644 --- a/shared/src/i18n/id/ferratas.ts +++ b/shared/src/i18n/id/ferratas.ts @@ -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; diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 521b58187..ea8715985 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -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; diff --git a/shared/src/i18n/it/ferratas.ts b/shared/src/i18n/it/ferratas.ts index ec6a20191..5e545eb4d 100644 --- a/shared/src/i18n/it/ferratas.ts +++ b/shared/src/i18n/it/ferratas.ts @@ -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; diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 3b75dc510..7e6c2a772 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -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; diff --git a/shared/src/i18n/ja/ferratas.ts b/shared/src/i18n/ja/ferratas.ts index 8a34edc15..fc8f81002 100644 --- a/shared/src/i18n/ja/ferratas.ts +++ b/shared/src/i18n/ja/ferratas.ts @@ -12,5 +12,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': '名称未設定のヴィア・フェラータ', 'ferratas.viewInOsm': 'OpenStreetMap で表示', 'ferratas.attribution': 'データ © OpenStreetMap 貢献者', + 'ferratas.adopt': 'ツアーに追加', + 'ferratas.onTour': 'ツアーの一部', }; export default ferratas; diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 8d326e49c..b9eb735da 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -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; diff --git a/shared/src/i18n/ko/ferratas.ts b/shared/src/i18n/ko/ferratas.ts index 49d44c034..793030821 100644 --- a/shared/src/i18n/ko/ferratas.ts +++ b/shared/src/i18n/ko/ferratas.ts @@ -12,5 +12,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': '이름 없는 비아 페라타', 'ferratas.viewInOsm': 'OpenStreetMap에서 보기', 'ferratas.attribution': '데이터 © OpenStreetMap 기여자', + 'ferratas.adopt': '투어에 추가', + 'ferratas.onTour': '투어의 일부', }; export default ferratas; diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index fee78855a..d1d643a35 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -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; diff --git a/shared/src/i18n/nl/ferratas.ts b/shared/src/i18n/nl/ferratas.ts index 49e8cd0eb..47f07a870 100644 --- a/shared/src/i18n/nl/ferratas.ts +++ b/shared/src/i18n/nl/ferratas.ts @@ -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; diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index f58d6f017..1445700b8 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -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; diff --git a/shared/src/i18n/pl/ferratas.ts b/shared/src/i18n/pl/ferratas.ts index 0f7245a17..af68958e7 100644 --- a/shared/src/i18n/pl/ferratas.ts +++ b/shared/src/i18n/pl/ferratas.ts @@ -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; diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index 42e10dc0a..a4dee442c 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -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; diff --git a/shared/src/i18n/ru/ferratas.ts b/shared/src/i18n/ru/ferratas.ts index ec10bb58b..f4940812b 100644 --- a/shared/src/i18n/ru/ferratas.ts +++ b/shared/src/i18n/ru/ferratas.ts @@ -15,5 +15,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': 'Виа феррата без названия', 'ferratas.viewInOsm': 'Открыть в OpenStreetMap', 'ferratas.attribution': 'Данные © участники OpenStreetMap', + 'ferratas.adopt': 'Добавить в маршрут', + 'ferratas.onTour': 'Часть маршрута', }; export default ferratas; diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index a9675e260..b9cd37f0c 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -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; diff --git a/shared/src/i18n/sk/ferratas.ts b/shared/src/i18n/sk/ferratas.ts index 093a0f9a0..3ce18d426 100644 --- a/shared/src/i18n/sk/ferratas.ts +++ b/shared/src/i18n/sk/ferratas.ts @@ -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; diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index b8a49d40b..fdc9589aa 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -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; diff --git a/shared/src/i18n/sv/ferratas.ts b/shared/src/i18n/sv/ferratas.ts index 97f236600..449bfe2d4 100644 --- a/shared/src/i18n/sv/ferratas.ts +++ b/shared/src/i18n/sv/ferratas.ts @@ -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; diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 9d15dcd4a..1d6d3d43f 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -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; diff --git a/shared/src/i18n/th/ferratas.ts b/shared/src/i18n/th/ferratas.ts index 27042c64d..61b33c326 100644 --- a/shared/src/i18n/th/ferratas.ts +++ b/shared/src/i18n/th/ferratas.ts @@ -12,5 +12,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': 'เส้นทางเวียเฟอร์ราตาไม่มีชื่อ', 'ferratas.viewInOsm': 'ดูใน OpenStreetMap', 'ferratas.attribution': 'ข้อมูล © ผู้ร่วมให้ข้อมูล OpenStreetMap', + 'ferratas.adopt': 'เพิ่มลงในทัวร์', + 'ferratas.onTour': 'เป็นส่วนหนึ่งของทัวร์', }; export default ferratas; diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 11f18d102..9c5091342 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -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; diff --git a/shared/src/i18n/tr/ferratas.ts b/shared/src/i18n/tr/ferratas.ts index 0fd0bf961..577395721 100644 --- a/shared/src/i18n/tr/ferratas.ts +++ b/shared/src/i18n/tr/ferratas.ts @@ -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; diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 43eff280a..4f539c8a8 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -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; diff --git a/shared/src/i18n/uk/ferratas.ts b/shared/src/i18n/uk/ferratas.ts index 43bed58c3..340d2e8df 100644 --- a/shared/src/i18n/uk/ferratas.ts +++ b/shared/src/i18n/uk/ferratas.ts @@ -15,5 +15,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': 'Віа ферата без назви', 'ferratas.viewInOsm': 'Відкрити в OpenStreetMap', 'ferratas.attribution': 'Дані © учасники OpenStreetMap', + 'ferratas.adopt': 'Додати до туру', + 'ferratas.onTour': 'Частина туру', }; export default ferratas; diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 8b754795b..fab002e9f 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -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; diff --git a/shared/src/i18n/vi/ferratas.ts b/shared/src/i18n/vi/ferratas.ts index 142899e08..80ad599c3 100644 --- a/shared/src/i18n/vi/ferratas.ts +++ b/shared/src/i18n/vi/ferratas.ts @@ -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; diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 745e10abc..091dad6be 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/ferratas.ts b/shared/src/i18n/zh-TW/ferratas.ts index fcf58d358..753b252a7 100644 --- a/shared/src/i18n/zh-TW/ferratas.ts +++ b/shared/src/i18n/zh-TW/ferratas.ts @@ -12,5 +12,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': '未命名的飛拉達', 'ferratas.viewInOsm': '在 OpenStreetMap 中檢視', 'ferratas.attribution': '資料 © OpenStreetMap 貢獻者', + 'ferratas.adopt': '加入行程', + 'ferratas.onTour': '行程的一部分', }; export default ferratas; diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index f9710e0c2..356f22426 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -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; diff --git a/shared/src/i18n/zh/ferratas.ts b/shared/src/i18n/zh/ferratas.ts index 715758210..ffe6d421e 100644 --- a/shared/src/i18n/zh/ferratas.ts +++ b/shared/src/i18n/zh/ferratas.ts @@ -12,5 +12,7 @@ const ferratas: TranslationStrings = { 'ferratas.unnamed': '未命名飞拉达', 'ferratas.viewInOsm': '在 OpenStreetMap 中查看', 'ferratas.attribution': '数据 © OpenStreetMap 贡献者', + 'ferratas.adopt': '加入行程', + 'ferratas.onTour': '行程的一部分', }; export default ferratas; diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 53b52f000..9f4a0d96f 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -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; diff --git a/shared/src/tours/tours.schema.test.ts b/shared/src/tours/tours.schema.test.ts index e63bafe1b..1728f36e9 100644 --- a/shared/src/tours/tours.schema.test.ts +++ b/shared/src/tours/tours.schema.test.ts @@ -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/); + }); +}); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 26570c992..1a975066d 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -113,14 +113,52 @@ export type TourListResponse = z.infer; export const tourWaypointRoleSchema = z.enum(['start', 'via', 'end']); export type TourWaypointRole = z.infer; +/** 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/` 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; + 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; +/** + * 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) {