diff --git a/client/scripts/rtl-disable-baseline.json b/client/scripts/rtl-disable-baseline.json index c8cee7a9c..912ffcce2 100644 --- a/client/scripts/rtl-disable-baseline.json +++ b/client/scripts/rtl-disable-baseline.json @@ -1,4 +1,5 @@ { "pages/tripPlanner/PlanMapStage.tsx": 4, - "mobile/components/trek3d/MascotClip.tsx": 1 + "mobile/components/trek3d/MascotClip.tsx": 1, + "components/Ferratas/FerrataDetailOverlay.tsx": 1 } diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index f1a0e2a70..44907fd51 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -5,7 +5,7 @@ "src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Budget/CostsPanel.test.tsx": 2764, "src/components/Budget/CostsPanel.tsx": 1816, - "src/components/Map/MapView.tsx": 1453, + "src/components/Map/MapView.tsx": 1450, "src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.tsx": 2414, "src/components/Map/RouteCalculator.ts": 1195, diff --git a/client/src/api/ferratas.test.ts b/client/src/api/ferratas.test.ts new file mode 100644 index 000000000..2e4ab8955 --- /dev/null +++ b/client/src/api/ferratas.test.ts @@ -0,0 +1,33 @@ +// FE-API-FERRATAS-001 to FE-API-FERRATAS-002 +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server } from '../../tests/helpers/msw/server'; +import { ferratasApi } from './ferratas'; + +const reply = { ferratas: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' }; + +describe('ferratasApi.list', () => { + it('FE-API-FERRATAS-001: asks GET /api/trips/:tripId/ferratas with the bbox and returns the body', async () => { + let url = ''; + server.use( + http.get('/api/trips/7/ferratas', ({ request }) => { + url = request.url; + return HttpResponse.json(reply); + }) + ); + + await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).resolves.toEqual(reply); + expect(new URL(url).searchParams.get('bbox')).toBe('46.500,11.700,46.600,11.900'); + }); + + it('FE-API-FERRATAS-002: an unreachable Overpass (502) rejects', async () => { + server.use( + http.get('/api/trips/7/ferratas', () => + HttpResponse.json({ error: 'Overpass unavailable', code: 'ferratas_unavailable' }, { status: 502 }) + ) + ); + + await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).rejects.toBeTruthy(); + }); +}); diff --git a/client/src/api/ferratas.ts b/client/src/api/ferratas.ts new file mode 100644 index 000000000..79c8a3b17 --- /dev/null +++ b/client/src/api/ferratas.ts @@ -0,0 +1,25 @@ +import apiClient from './client'; + +/** + * How long the browser waits for GET /api/trips/:tripId/ferratas. + * + * The server races the configured Overpass mirrors under one OVERPASS_TIMEOUT_MS + * (25 s by default) and answers 502 when none of them made it. The shared 8 s + * on `apiClient` would give up on a slow but healthy mirror and show an error + * the server was about to resolve, so this sits just above the server's own + * ceiling. + */ +const FERRATAS_TIMEOUT_MS = 30_000; + +/** + * GET /api/trips/:tripId/ferratas: the via ferratas OpenStreetMap knows inside + * one map area. In its own file rather than in the `client.ts` god module. The + * answer comes back unparsed; repo/ferrataRepo.ts checks it against the shared + * contract before anything draws it. + */ +export const ferratasApi = { + list: (tripId: number, bbox: string, signal?: AbortSignal): Promise => + apiClient + .get(`/trips/${tripId}/ferratas`, { params: { bbox }, signal, timeout: FERRATAS_TIMEOUT_MS }) + .then((response) => response.data as unknown), +}; diff --git a/client/src/components/Ferratas/FerrataDetailCard.test.tsx b/client/src/components/Ferratas/FerrataDetailCard.test.tsx new file mode 100644 index 000000000..586bcfe40 --- /dev/null +++ b/client/src/components/Ferratas/FerrataDetailCard.test.tsx @@ -0,0 +1,71 @@ +// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-005: the card for one picked via ferrata. +import { describe, expect, it, vi } from 'vitest'; + +import type { Ferrata } from '@trek/shared'; + +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import FerrataDetailCard from './FerrataDetailCard'; + +const FERRATA: Ferrata = { + id: 'way/77', + name: 'Via ferrata Pisciadù', + grade: { raw: '5', level: 5, plus: '+' }, + routeName: 'Sella group', + geometry: [ + [46.53, 11.81], + [46.54, 11.82], + ], + osmUrl: 'https://www.openstreetmap.org/way/77', +}; + +describe('FerrataDetailCard', () => { + it('FE-FERRATA-CARD-001: shows the name, the route, the grade badge and the attribution', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument(); + expect(screen.getByText('Sella group')).toBeInTheDocument(); + expect(screen.getByText('K5+')).toHaveClass('text-danger'); + expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument(); + }); + + it('FE-FERRATA-CARD-002: links to the way on openstreetmap.org in a new tab', () => { + render(); + const link = screen.getByRole('link', { name: /View in OpenStreetMap/ }); + + expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/77'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('FE-FERRATA-CARD-003: an unnamed way takes its route name as title and does not repeat it', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument(); + expect(screen.getAllByText('Sella group')).toHaveLength(1); + }); + + it('FE-FERRATA-CARD-004: a letter scale keeps its raw grade, no grade at all reads unknown, both neutral', () => { + const { rerender } = render( + + ); + expect(screen.getByText('C/D')).toHaveClass('text-content-secondary'); + + rerender( + + ); + expect(screen.getByText('Grade unknown')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument(); + }); + + it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => { + const onClose = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/Ferratas/FerrataDetailCard.tsx b/client/src/components/Ferratas/FerrataDetailCard.tsx new file mode 100644 index 000000000..c610b63a4 --- /dev/null +++ b/client/src/components/Ferratas/FerrataDetailCard.tsx @@ -0,0 +1,68 @@ +import { ExternalLink, Mountain, X } from 'lucide-react'; + +import type { Ferrata } from '@trek/shared'; + +import { useTranslation } from '../../i18n'; +import { type PillTone, SoftPill } from '../Planner/planParts'; +import { type FerrataTone, ferrataGradeLabel, ferrataGradeTone, ferrataOsmUrl } from './ferrataModel'; + +/** The badge tone per difficulty band; the same bands colour the line on the map. */ +const PILL_TONE: Record = { + easy: 'success', + medium: 'warning', + hard: 'danger', + unknown: 'neutral', +}; + +/** + * One via ferrata, picked on the map: its name, the grade as a badge, the + * route it belongs to and the way back to the source. OpenStreetMap's data is + * ODbL, which asks for the attribution next to anything shown from it, so the + * card carries it rather than relying on the map's own corner. + */ +export default function FerrataDetailCard({ ferrata, onClose }: { ferrata: Ferrata; onClose?: () => void }) { + const { t } = useTranslation(); + const name = ferrata.name ?? ferrata.routeName ?? t('ferratas.unnamed'); + // The route line only adds something when it is not already the title. + const route = ferrata.routeName && ferrata.routeName !== name ? ferrata.routeName : null; + // A letter scale ("C/D") the contract keeps as `raw` still says more than "unknown". + const grade = ferrataGradeLabel(ferrata.grade) ?? ferrata.grade.raw ?? t('ferratas.unknownGrade'); + + return ( + + ); +} diff --git a/client/src/components/Ferratas/FerrataDetailOverlay.test.tsx b/client/src/components/Ferratas/FerrataDetailOverlay.test.tsx new file mode 100644 index 000000000..6b2693b22 --- /dev/null +++ b/client/src/components/Ferratas/FerrataDetailOverlay.test.tsx @@ -0,0 +1,59 @@ +// FE-FERRATA-OVERLAY-001 to FE-FERRATA-OVERLAY-004: where the picked ferrata's card sits over the map, and how it closes. +import { describe, expect, it, vi } from 'vitest'; + +import type { Ferrata } from '@trek/shared'; + +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import FerrataDetailOverlay from './FerrataDetailOverlay'; + +const FERRATA: Ferrata = { + id: 'way/42', + name: 'Via delle Trincee', + grade: { raw: '4', level: 4, plus: null }, + routeName: null, + geometry: [ + [46.52, 11.8], + [46.53, 11.81], + ], + osmUrl: 'https://www.openstreetmap.org/way/42', +}; + +describe('FerrataDetailOverlay', () => { + it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => { + render(); + expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull(); + }); + + it('FE-FERRATA-OVERLAY-002: anchors the card bottom right, clear of the right panel and the control row', () => { + render(); + const overlay = screen.getByTestId('ferrata-detail-overlay'); + expect(overlay.style.right).toBe('400px'); + expect(overlay.style.bottom).toBe('66px'); + expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card')); + }); + + it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside', () => { + const onClose = vi.fn(); + render( +
+ + +
+ ); + fireEvent.pointerDown(screen.getByText('Via delle Trincee')); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.pointerDown(screen.getByRole('button', { name: 'map' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Ferratas/FerrataDetailOverlay.tsx b/client/src/components/Ferratas/FerrataDetailOverlay.tsx new file mode 100644 index 000000000..4389ddf4c --- /dev/null +++ b/client/src/components/Ferratas/FerrataDetailOverlay.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react'; + +import type { Ferrata } from '@trek/shared'; + +import FerrataDetailCard from './FerrataDetailCard'; + +/** + * The picked ferrata's card over the map, anchored bottom right above the row + * of map controls. `insetRight` is what the right panel covers plus the gap the + * layer pill keeps to the left one, so the card mirrors the pill and never + * slides under the panel; `bottom` clears the control row (the layer pill on + * the flat map, the dock and the grade legend in 3D). + * + * It closes on a press anywhere outside it and on Escape. A press on another + * ferrata closes this card first and the click then opens that one's. + */ +export default function FerrataDetailOverlay({ + ferrata, + onClose, + insetRight, + bottom, +}: { + ferrata: Ferrata | null; + onClose: () => void; + insetRight: number; + bottom: number; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!ferrata) return; + const onDown = (event: PointerEvent) => { + const card = ref.current; + if (card && event.target instanceof Node && card.contains(event.target)) return; + onClose(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.defaultPrevented) onClose(); + }; + document.addEventListener('pointerdown', onDown, true); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('pointerdown', onDown, true); + document.removeEventListener('keydown', onKey); + }; + }, [ferrata, onClose]); + + if (!ferrata) return null; + return ( +
+ +
+ ); +} diff --git a/client/src/components/Ferratas/FerrataLayerGL.tsx b/client/src/components/Ferratas/FerrataLayerGL.tsx new file mode 100644 index 000000000..e8253eb54 --- /dev/null +++ b/client/src/components/Ferratas/FerrataLayerGL.tsx @@ -0,0 +1,23 @@ +import type { Map } from 'mapbox-gl'; + +import type { Ferrata } from '@trek/shared'; + +import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL'; + +/** + * The via ferrata layer as a mountable piece for a GL map view: it renders + * nothing itself, the lines live on `map`. See useFerrataLayerGL for the + * layering and the click handling. + */ +export default function FerrataLayerGL({ + map, + ferratas, + onSelect, +}: { + map: Map | null; + ferratas: readonly Ferrata[]; + onSelect: FerrataSelectHandler; +}) { + useFerrataLayerGL(map, ferratas, onSelect); + return null; +} diff --git a/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx new file mode 100644 index 000000000..4ae9ae3ef --- /dev/null +++ b/client/src/components/Ferratas/FerrataLayerLeaflet.test.tsx @@ -0,0 +1,143 @@ +/** + * FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-007: the via ferrata layer on + * the Leaflet renderer. + * + * Leaflet itself is not under test: the mock reflects each polyline's props + * back as data attributes, so the assertions read which lines are drawn, in + * which order and with what options. Casing and line stay non-interactive so + * they cannot swallow clicks meant for places; only the invisible hit line on + * top carries the tooltip and the click. + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Ferrata } from '@trek/shared'; + +import FerrataLayerLeaflet from './FerrataLayerLeaflet'; + +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + +vi.mock('react-leaflet', () => ({ + Polyline: ({ + positions, + pathOptions, + interactive, + bubblingMouseEvents, + eventHandlers, + children, + }: { + positions: unknown; + pathOptions: { color: string; weight: number; opacity: number; dashArray?: string }; + interactive?: boolean; + bubblingMouseEvents?: boolean; + eventHandlers?: { click?: (event: { latlng: { lat: number; lng: number } }) => void }; + children?: ReactNode; + }) => ( +
eventHandlers?.click?.({ latlng: { lat: 46.505, lng: 11.705 } })} + > + {children} +
+ ), + Tooltip: ({ children }: { children: ReactNode }) => {children}, +})); + +const ferrata = (id: string, name: string | null, level: number | null, routeName: string | null = null): Ferrata => ({ + id, + name, + grade: { raw: level === null ? null : String(level), level, plus: level === 4 ? '+' : null }, + routeName, + geometry: [ + [46.5, 11.7], + [46.51, 11.71], + ], + osmUrl: `https://www.openstreetmap.org/${id}`, +}); + +describe('FerrataLayerLeaflet', () => { + it('FE-FERRATA-LEAFLET-001: draws nothing for no ferratas', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-FERRATA-LEAFLET-002: casing, dashed line, then the invisible hit line, in [lat, lng]', () => { + render(); + const lines = screen.getAllByTestId('polyline'); + + expect(lines.map((line) => line.dataset.weight)).toEqual(['6', '3', '16']); + expect(lines.map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']); + expect(lines[1]?.dataset.dash).toBe('6 5'); + expect(lines[2]?.dataset.opacity).toBe('0'); + expect(JSON.parse(lines[0]?.dataset.positions ?? '[]')).toEqual([ + [46.5, 11.7], + [46.51, 11.71], + ]); + }); + + it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade, or falls back to the route and unnamed', () => { + render( + + ); + expect(screen.getAllByTestId('tooltip').map((tip) => tip.textContent)).toEqual([ + 'PisciadùK4+', + 'Sentiero attrezzato', + 'ferratas.unnamed', + ]); + }); + + it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata and the point to onSelect', () => { + const onSelect = vi.fn(); + const target = ferrata('way/9', 'Tridentina', 2); + render(); + + const [, , hit] = screen.getAllByTestId('polyline'); + if (!hit) throw new Error('no hit line'); + fireEvent.click(hit); + expect(onSelect).toHaveBeenCalledWith(target, { lng: 11.705, lat: 46.505 }); + }); + + it('FE-FERRATA-LEAFLET-006: the hit line keeps its click from reaching the map, so no control point lands under it', () => { + render(); + const [, , hit] = screen.getAllByTestId('polyline'); + expect(hit?.dataset.bubbles).toBe('false'); + }); + + it('FE-FERRATA-LEAFLET-007: the grade sits beside the name as its own label, without a separator', () => { + render(); + const tip = screen.getByTestId('tooltip'); + expect(tip.textContent).not.toContain('·'); + expect(tip.querySelector('span')?.textContent).toBe('K4+'); + }); + + it('FE-FERRATA-LEAFLET-005: a theme change recolours the lines without a refetch', async () => { + const root = document.documentElement; + root.style.setProperty('--warning', 'orange'); + const list = [ferrata('way/1', 'Pisciadù', 3)]; + render(); + expect(screen.getAllByTestId('polyline')[1]?.dataset.color).toBe('orange'); + + await act(async () => { + root.style.setProperty('--warning', 'gold'); + await Promise.resolve(); + }); + + await waitFor(() => expect(screen.getAllByTestId('polyline')[1]?.dataset.color).toBe('gold')); + root.style.removeProperty('--warning'); + }); +}); diff --git a/client/src/components/Ferratas/FerrataLayerLeaflet.tsx b/client/src/components/Ferratas/FerrataLayerLeaflet.tsx new file mode 100644 index 000000000..e41e00593 --- /dev/null +++ b/client/src/components/Ferratas/FerrataLayerLeaflet.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { Polyline, Tooltip } from 'react-leaflet'; + +import type { Ferrata } from '@trek/shared'; + +import { useTranslation } from '../../i18n'; +import { ferrataGradeLabel, ferrataGradeTone, ferrataPositions } from './ferrataModel'; +import type { FerrataSelectHandler } from './useFerrataLayerGL'; +import type { FerrataMapLayer } from './useFerrataMapLayer'; +import { useFerrataPaint } from './useFerrataPaint'; + +/** + * The via ferrata layer on the Leaflet renderer: per ferrata a casing and the + * dashed line coloured by difficulty band, the same construction as the GL + * layer, with a sticky tooltip naming the route and its grade. A click hands + * the ferrata to `onSelect`, like a click on the GL layer does. + */ +export default function FerrataLayerLeaflet({ + ferratas, + onSelect, +}: { + ferratas: readonly Ferrata[]; + onSelect: FerrataSelectHandler; +}) { + const { t } = useTranslation(); + const paint = useFerrataPaint(); + // Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes. + const lines = useMemo( + () => + ferratas.map((ferrata) => ({ + ferrata, + positions: ferrataPositions(ferrata), + casing: { color: paint.casing, weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' } as const, + line: { + color: paint.tone[ferrataGradeTone(ferrata.grade)], + weight: 3, + opacity: 0.95, + dashArray: '6 5', + lineCap: 'round', + lineJoin: 'round', + } as const, + hit: { color: paint.casing, weight: 16, opacity: 0 } as const, + })), + [ferratas, paint] + ); + + if (lines.length === 0) return null; + return ( + <> + {lines.map(({ ferrata, positions, casing }) => ( + + ))} + {lines.map(({ ferrata, positions, line }) => ( + + ))} + {/* A wide, invisible line on top carries the tooltip and the click, so a thin + dashed route is still easy to hit with a finger. The click stops here: it + opens the ferrata's card and must not also drop a control point under it. */} + {lines.map(({ ferrata, positions, hit }) => { + const name = ferrata.name ?? ferrata.routeName ?? t('ferratas.unnamed'); + const grade = ferrataGradeLabel(ferrata.grade); + return ( + onSelect(ferrata, { lng: event.latlng.lng, lat: event.latlng.lat }) }} + > + + {name} + {grade && {grade}} + + + ); + })} + + ); +} + +/** The layer as the Leaflet map view mounts it from a `FerrataMapLayer`: drawn only while it is switched on. */ +export function FerrataMapLayerLeaflet({ layer }: { layer: FerrataMapLayer | undefined }) { + if (!layer?.active) return null; + return ; +} diff --git a/client/src/components/Ferratas/FerrataStatusPill.test.tsx b/client/src/components/Ferratas/FerrataStatusPill.test.tsx new file mode 100644 index 000000000..c89aa1e02 --- /dev/null +++ b/client/src/components/Ferratas/FerrataStatusPill.test.tsx @@ -0,0 +1,39 @@ +// FE-FERRATA-PILL-001 to FE-FERRATA-PILL-004: what the via ferrata layer says about itself. +import { describe, expect, it } from 'vitest'; + +import { render, screen } from '../../../tests/helpers/render'; +import FerrataStatusPill from './FerrataStatusPill'; + +describe('FerrataStatusPill', () => { + it('FE-FERRATA-PILL-001: says nothing while the layer is off', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-FERRATA-PILL-002: counts what it found, in the plural form the count takes', () => { + const { rerender } = render(); + expect(screen.getByRole('status')).toHaveTextContent('12 via ferrata routes'); + + rerender(); + expect(screen.getByRole('status')).toHaveTextContent('1 via ferrata route'); + expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Via ferratas: 1 via ferrata route'); + }); + + it('FE-FERRATA-PILL-003: shows loading and the two reasons a request was not made', () => { + const { rerender } = render(); + expect(screen.getByRole('status')).toHaveTextContent('Loading via ferratas…'); + + rerender(); + expect(screen.getByRole('status')).toHaveTextContent('Zoom in to load via ferratas'); + expect(screen.getByRole('status')).toHaveClass('text-content-secondary'); + + rerender(); + expect(screen.getByRole('status')).toHaveTextContent('Via ferratas need a connection'); + }); + + it('FE-FERRATA-PILL-004: an unreachable Overpass reads as a problem', () => { + render(); + expect(screen.getByRole('status')).toHaveTextContent('Overpass unreachable'); + expect(screen.getByRole('status')).toHaveClass('text-danger'); + }); +}); diff --git a/client/src/components/Ferratas/FerrataStatusPill.tsx b/client/src/components/Ferratas/FerrataStatusPill.tsx new file mode 100644 index 000000000..81886658e --- /dev/null +++ b/client/src/components/Ferratas/FerrataStatusPill.tsx @@ -0,0 +1,65 @@ +import { Loader2, Mountain, TriangleAlert } from 'lucide-react'; + +import { useTranslation } from '../../i18n'; +import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow'; +import type { FerrataStatus } from './useTripFerratas'; + +/** The map controls' frosted glass, so the pill reads as part of the layer pill it sits beside. */ +const GLASS = { + background: 'var(--sidebar-bg)', + backdropFilter: 'blur(20px) saturate(180%)', + WebkitBackdropFilter: 'blur(20px) saturate(180%)', + boxShadow: MAP_CONTROL_SHADOW, +}; + +/** + * What the via ferrata layer is doing, as one small pill over the map, in the + * row of the layer pill and at its height: + * loading, how many it found, or why there are none (Overpass unreachable, the + * area too large to ask for, the device offline). Nothing while the layer is + * off. Polite live region, so a screen reader hears the count arrive without + * being interrupted. + */ +export default function FerrataStatusPill({ status, count }: { status: FerrataStatus; count: number }) { + const { t } = useTranslation(); + if (status === 'idle') return null; + + const problem = status === 'error' || status === 'offline'; + const label = labelOf(status, count, t); + const Icon = status === 'loading' ? Loader2 : problem ? TriangleAlert : Mountain; + + return ( +
+
+ ); +} + +function labelOf( + status: Exclude, + count: number, + t: (key: string, params?: Record) => string +) { + switch (status) { + case 'loading': + return t('ferratas.loading'); + case 'error': + return t('ferratas.error'); + case 'offline': + return t('ferratas.offline'); + case 'tooLarge': + return t('ferratas.zoomIn'); + case 'ready': + return t('ferratas.count', { count }); + } +} diff --git a/client/src/components/Ferratas/ferrataModel.test.ts b/client/src/components/Ferratas/ferrataModel.test.ts new file mode 100644 index 000000000..b3d657c42 --- /dev/null +++ b/client/src/components/Ferratas/ferrataModel.test.ts @@ -0,0 +1,116 @@ +// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-008: the pure half of the via ferrata layer. +import { describe, expect, it } from 'vitest'; + +import { FERRATA_BBOX_MAX_SPAN_DEG, type Ferrata } from '@trek/shared'; + +import { + ferrataBboxFits, + ferrataBboxParam, + ferrataFeatureCollection, + ferrataGradeLabel, + ferrataGradeTone, + ferrataOsmUrl, + ferrataPositions, + roundFerrataBbox, +} from './ferrataModel'; + +const grade = (level: number | null, plus: '+' | '-' | null = null, raw: string | null = null) => ({ + level, + plus, + raw, +}); + +const FERRATA: Ferrata = { + id: 'way/123', + name: 'Pisciadù', + grade: grade(3, '+', '3+'), + routeName: 'Brigata Tridentina', + geometry: [ + [46.53, 11.81], + [46.54, 11.82], + ], + osmUrl: 'https://www.openstreetmap.org/way/123', +}; + +describe('ferrataModel', () => { + it('FE-FERRATA-MODEL-001: labels a numeric grade on the K scale with its plus or minus', () => { + expect(ferrataGradeLabel(grade(3))).toBe('K3'); + expect(ferrataGradeLabel(grade(4, '+'))).toBe('K4+'); + expect(ferrataGradeLabel(grade(2, '-'))).toBe('K2-'); + expect(ferrataGradeLabel(grade(0))).toBe('K0'); + }); + + it('FE-FERRATA-MODEL-002: has no label for a way without a numeric level, letter scales included', () => { + expect(ferrataGradeLabel(grade(null))).toBeNull(); + expect(ferrataGradeLabel(grade(null, null, 'C/D'))).toBeNull(); + }); + + it('FE-FERRATA-MODEL-003: bands levels 0 to 2 easy, 3 and 4 medium, 5 and 6 hard, none unknown', () => { + expect([0, 1, 2, 3, 4, 5, 6].map((level) => ferrataGradeTone(grade(level)))).toEqual([ + 'easy', + 'easy', + 'easy', + 'medium', + 'medium', + 'hard', + 'hard', + ]); + expect(ferrataGradeTone(grade(null, null, 'C'))).toBe('unknown'); + }); + + it('FE-FERRATA-MODEL-004: rounds the map area outwards to three decimals, inside the world', () => { + expect(roundFerrataBbox({ south: 46.12345, west: 11.98761, north: 46.20001, east: 12.1 })).toEqual({ + south: 46.123, + west: 11.987, + north: 46.201, + east: 12.1, + }); + expect(roundFerrataBbox({ south: -90.5, west: -181, north: 91, east: 180.4 })).toEqual({ + south: -90, + west: -180, + north: 90, + east: 180, + }); + }); + + it('FE-FERRATA-MODEL-005: takes an area only up to the server limit and only a real box', () => { + const max = FERRATA_BBOX_MAX_SPAN_DEG; + expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max, east: 11 + max })).toBe(true); + expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max + 0.01, east: 11.2 })).toBe(false); + expect(ferrataBboxFits({ south: 46, west: 11, north: 46.2, east: 11 + max + 0.01 })).toBe(false); + expect(ferrataBboxFits({ south: 46.2, west: 11, north: 46.2, east: 11.2 })).toBe(false); + // The server's own rule: a rounded 0.500 span that reads 0.5000000000000071 still goes out. + expect(ferrataBboxFits({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true); + }); + + it('FE-FERRATA-MODEL-006: writes the bbox parameter in south,west,north,east order', () => { + expect(ferrataBboxParam({ south: 46.1, west: 11.25, north: 46.3, east: 11.5 })).toBe('46.100,11.250,46.300,11.500'); + }); + + it('FE-FERRATA-MODEL-007: builds GeoJSON with [lng, lat] coordinates and only the id and tone', () => { + const collection = ferrataFeatureCollection([FERRATA]); + expect(collection).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { id: 'way/123', tone: 'medium' }, + geometry: { + type: 'LineString', + coordinates: [ + [11.81, 46.53], + [11.82, 46.54], + ], + }, + }, + ], + }); + expect(ferrataPositions(FERRATA)).toEqual(FERRATA.geometry); + expect(ferrataFeatureCollection([]).features).toEqual([]); + }); + + it('FE-FERRATA-MODEL-008: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => { + expect(ferrataOsmUrl(FERRATA)).toBe('https://www.openstreetmap.org/way/123'); + expect(ferrataOsmUrl({ id: 'javascript:alert(1)' })).toBe('https://www.openstreetmap.org/'); + }); +}); diff --git a/client/src/components/Ferratas/ferrataModel.ts b/client/src/components/Ferratas/ferrataModel.ts new file mode 100644 index 000000000..1671641a0 --- /dev/null +++ b/client/src/components/Ferratas/ferrataModel.ts @@ -0,0 +1,117 @@ +import { type Ferrata, type FerrataBbox, type FerrataGrade, ferrataBboxSpanOk } from '@trek/shared'; + +/** + * The pure half of the via ferrata layer: grade wording, the difficulty band a + * badge and a line are coloured by, the map area a request asks for, and the + * GeoJSON the GL renderer draws. React-free, so both renderers and the detail + * card read the same answers. + */ + +/** The difficulty band a grade falls into; `unknown` when OSM gave no 0 to 6 level. */ +export type FerrataTone = 'easy' | 'medium' | 'hard' | 'unknown'; + +/** + * The grade as the K scale reads it: `K3`, `K4+`, `K2-`. Null when the way has + * no numeric level (untagged, or a letter scale such as "C/D" the server keeps + * as `raw` and does not convert). + */ +export function ferrataGradeLabel(grade: FerrataGrade): string | null { + if (grade.level === null || grade.level === undefined) return null; + return `K${grade.level}${grade.plus ?? ''}`; +} + +/** Levels 0 to 2 are easy, 3 and 4 medium, 5 and 6 hard. */ +export function ferrataGradeTone(grade: FerrataGrade): FerrataTone { + const level = grade.level; + if (level === null || level === undefined) return 'unknown'; + if (level <= 2) return 'easy'; + if (level <= 4) return 'medium'; + return 'hard'; +} + +/** + * The line as `[lat, lng]` pairs Leaflet takes. Zod's tuple reads as "maybe two + * numbers, maybe more" where the client compiles without strict mode, so the + * pairs are narrowed here rather than cast; the schema has already guaranteed + * both are numbers. + */ +export function ferrataPositions(ferrata: Pick): Array<[number, number]> { + return ferrata.geometry.flatMap((pair): Array<[number, number]> => + typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : [] + ); +} + +const OSM_ORIGIN = 'https://www.openstreetmap.org/'; + +/** + * The way's page on openstreetmap.org. Built from the id the contract pins to + * `way/` rather than taken from `osmUrl` as sent, so the link can only + * ever point at OpenStreetMap whatever an Overpass mirror put in the answer. + */ +export function ferrataOsmUrl(ferrata: Pick): string { + return /^way\/\d+$/.test(ferrata.id) ? `${OSM_ORIGIN}${ferrata.id}` : OSM_ORIGIN; +} + +/** Three decimals is about 100 m: small pans land on the same area and do not refetch. */ +const STEP = 1000; +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +/** + * The map area rounded outwards to the request grid, so the box asked for + * always covers the box on screen. + */ +export function roundFerrataBbox(bbox: FerrataBbox): FerrataBbox { + return { + south: clamp(Math.floor(bbox.south * STEP) / STEP, -90, 90), + west: clamp(Math.floor(bbox.west * STEP) / STEP, -180, 180), + north: clamp(Math.ceil(bbox.north * STEP) / STEP, -90, 90), + east: clamp(Math.ceil(bbox.east * STEP) / STEP, -180, 180), + }; +} + +/** + * Whether the server takes this area: a real box no wider than + * FERRATA_BBOX_MAX_SPAN_DEG per side. A wider one is refused with a 400, so + * the client asks the user to zoom in instead of sending it. + */ +export function ferrataBboxFits(bbox: FerrataBbox): boolean { + // The span check is the server's own, so a box the client sends is never refused as too large. + return bbox.north > bbox.south && bbox.east > bbox.west && ferrataBboxSpanOk(bbox); +} + +/** The `bbox` query parameter, in the south,west,north,east order the contract reads. */ +export function ferrataBboxParam(bbox: FerrataBbox): string { + return [bbox.south, bbox.west, bbox.north, bbox.east].map((edge) => edge.toFixed(3)).join(','); +} + +interface FerrataFeature { + type: 'Feature'; + properties: { id: string; tone: FerrataTone }; + geometry: { type: 'LineString'; coordinates: Array<[number, number]> }; +} + +interface FerrataFeatureCollection { + type: 'FeatureCollection'; + features: FerrataFeature[]; +} + +/** + * The ferratas as a GeoJSON FeatureCollection for MapLibre or Mapbox GL. The + * contract carries [lat, lng] like the rest of TREK; GeoJSON wants [lng, lat], + * and this is the one place the layer flips it. Only the id and the tone ride + * along: a click looks the ferrata up by id, so names never pass through the + * map's own feature state. + */ +export function ferrataFeatureCollection(ferratas: readonly Ferrata[]): FerrataFeatureCollection { + return { + type: 'FeatureCollection', + features: ferratas.map((ferrata) => ({ + type: 'Feature' as const, + properties: { id: ferrata.id, tone: ferrataGradeTone(ferrata.grade) }, + geometry: { + type: 'LineString' as const, + coordinates: ferrataPositions(ferrata).map(([lat, lng]): [number, number] => [lng, lat]), + }, + })), + }; +} diff --git a/client/src/components/Ferratas/ferrataPaint.test.ts b/client/src/components/Ferratas/ferrataPaint.test.ts new file mode 100644 index 000000000..146be5ec1 --- /dev/null +++ b/client/src/components/Ferratas/ferrataPaint.test.ts @@ -0,0 +1,33 @@ +// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-002: the line colours come from the status tokens. +import { afterEach, describe, expect, it } from 'vitest'; + +import { ferrataPaint } from './ferrataPaint'; + +const root = document.documentElement; + +afterEach(() => { + for (const name of ['--success', '--warning', '--danger', '--text-muted', '--bg-primary']) + root.style.removeProperty(name); +}); + +describe('ferrataPaint', () => { + it('FE-FERRATA-PAINT-001: resolves each difficulty band and the casing from the tokens on the root', () => { + root.style.setProperty('--success', 'green'); + root.style.setProperty('--warning', 'orange'); + root.style.setProperty('--danger', 'crimson'); + root.style.setProperty('--text-muted', 'gray'); + root.style.setProperty('--bg-primary', 'white'); + + expect(ferrataPaint()).toEqual({ + tone: { easy: 'green', medium: 'orange', hard: 'crimson', unknown: 'gray' }, + casing: 'white', + }); + }); + + it('FE-FERRATA-PAINT-002: falls back to currentColor where a token is not defined', () => { + expect(ferrataPaint()).toEqual({ + tone: { easy: 'currentColor', medium: 'currentColor', hard: 'currentColor', unknown: 'currentColor' }, + casing: 'currentColor', + }); + }); +}); diff --git a/client/src/components/Ferratas/ferrataPaint.ts b/client/src/components/Ferratas/ferrataPaint.ts new file mode 100644 index 000000000..806e0fe6a --- /dev/null +++ b/client/src/components/Ferratas/ferrataPaint.ts @@ -0,0 +1,44 @@ +import type { FerrataTone } from './ferrataModel'; + +/** + * The colours the via ferrata lines are drawn in, per difficulty band. + * + * 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 + * status 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. Easy, medium and hard + * follow the same success, warning and danger tones the grade badge uses, so + * the line and the card agree. + */ +interface FerrataPaint { + tone: Record; + /** The halo under the line, so it stays readable over a satellite or hillshade tile. */ + casing: string; +} + +const TONE_TOKENS: Record = { + easy: '--success', + medium: '--warning', + hard: '--danger', + unknown: '--text-muted', +}; + +/** 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(); + return value || 'currentColor'; +} + +export function ferrataPaint(): FerrataPaint { + const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement); + return { + tone: { + easy: token(TONE_TOKENS.easy, style), + medium: token(TONE_TOKENS.medium, style), + hard: token(TONE_TOKENS.hard, style), + unknown: token(TONE_TOKENS.unknown, style), + }, + casing: token('--bg-primary', style), + }; +} diff --git a/client/src/components/Ferratas/useFerrataLayerGL.test.tsx b/client/src/components/Ferratas/useFerrataLayerGL.test.tsx new file mode 100644 index 000000000..bb8b8ae94 --- /dev/null +++ b/client/src/components/Ferratas/useFerrataLayerGL.test.tsx @@ -0,0 +1,203 @@ +/** + * FE-FERRATA-GL-001 to FE-FERRATA-GL-008: the via ferrata layer on a + * MapLibre or Mapbox GL map. + * + * Pinned: the layers go in once per map (and wait for `idle` when tiles are + * still loading, the failure the hazard overlay had), new data replaces the + * source's contents instead of rebuilding the layers, a click on the hit line + * hands the ferrata to `onSelect`, a theme change repaints the installed + * layers in place, and an unmount leaves nothing behind. + */ +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import type { Map as MapboxMap, MapMouseEvent } from 'mapbox-gl'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Ferrata } from '@trek/shared'; + +import FerrataLayerGL from './FerrataLayerGL'; +import { useFerrataLayerGL } from './useFerrataLayerGL'; + +const SOURCE = 'trek-ferratas'; +const LAYERS = ['trek-ferratas-casing', 'trek-ferratas-line', 'trek-ferratas-hit']; + +const ferrata = (id: string, level: number | null = 2): Ferrata => ({ + id, + name: `Ferrata ${id}`, + grade: { raw: level === null ? null : String(level), level, plus: null }, + routeName: null, + geometry: [ + [46.5, 11.7], + [46.51, 11.71], + ], + osmUrl: `https://www.openstreetmap.org/${id}`, +}); + +type Handler = (event?: unknown) => void; + +function fakeGlMap(styleLoaded = true) { + const sources = new Map }>(); + const layers = new Set(); + const handlers = new Map>(); + const once = new Map>(); + const state = { styleLoaded, hit: null as null | { properties: { id: string } } }; + const canvas = document.createElement('canvas'); + const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type); + const map = { + isStyleLoaded: vi.fn(() => state.styleLoaded), + getSource: vi.fn((id: string) => sources.get(id)), + addSource: vi.fn((id: string) => { + sources.set(id, { setData: vi.fn() }); + }), + removeSource: vi.fn((id: string) => { + sources.delete(id); + }), + getLayer: vi.fn((id: string) => (layers.has(id) ? {} : undefined)), + addLayer: vi.fn((spec: { id: string }) => { + layers.add(spec.id); + }), + removeLayer: vi.fn((id: string) => { + layers.delete(id); + }), + setPaintProperty: vi.fn(), + queryRenderedFeatures: vi.fn(() => (state.hit ? [state.hit] : [])), + getCanvas: vi.fn(() => canvas), + on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => { + const key = keyOf(type, layerOrFn); + const set = handlers.get(key) ?? new Set(); + set.add((fn ?? layerOrFn) as Handler); + handlers.set(key, set); + }), + once: vi.fn((type: string, fn: Handler) => { + const set = once.get(type) ?? new Set(); + set.add(fn); + once.set(type, set); + }), + off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => { + const target = (fn ?? layerOrFn) as Handler; + handlers.get(keyOf(type, layerOrFn))?.delete(target); + once.get(type)?.delete(target); + }), + }; + const emit = (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event)); + return { + map: map as unknown as MapboxMap, + spies: map, + state, + sources, + layers, + canvas, + emit, + listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0), + emitIdle: () => { + const pending = [...(once.get('idle') ?? [])]; + once.delete('idle'); + pending.forEach((fn) => fn()); + }, + }; +} + +const click = { point: { x: 4, y: 4 }, lngLat: { lng: 11.705, lat: 46.505 } } as unknown as MapMouseEvent; + +describe('useFerrataLayerGL', () => { + it('FE-FERRATA-GL-001: adds one source and the casing, line and hit layers, in that order', () => { + const gl = fakeGlMap(); + renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn())); + + expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' })); + expect(gl.spies.addLayer.mock.calls.map(([spec]) => spec.id)).toEqual(LAYERS); + }); + + it('FE-FERRATA-GL-002: colours the line by difficulty band through a match on the tone', () => { + const gl = fakeGlMap(); + renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn())); + + const line = gl.spies.addLayer.mock.calls + .map(([spec]) => spec) + .find((spec) => spec.id === LAYERS[1]) as unknown as { + paint: { 'line-color': unknown[] }; + }; + expect(line.paint['line-color'].slice(0, 2)).toEqual(['match', ['get', 'tone']]); + }); + + it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => { + const gl = fakeGlMap(false); + renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn())); + expect(gl.spies.addSource).not.toHaveBeenCalled(); + + gl.state.styleLoaded = true; + gl.emitIdle(); + expect(gl.spies.addLayer).toHaveBeenCalledTimes(3); + }); + + it('FE-FERRATA-GL-004: new ferratas replace the source data without rebuilding the layers', () => { + const gl = fakeGlMap(); + const { rerender } = renderHook(({ list }) => useFerrataLayerGL(gl.map, list, vi.fn()), { + initialProps: { list: [ferrata('way/1')] }, + }); + rerender({ list: [ferrata('way/1'), ferrata('way/2', 5)] }); + + expect(gl.spies.addLayer).toHaveBeenCalledTimes(3); + const setData = gl.sources.get(SOURCE)?.setData; + expect(setData).toHaveBeenLastCalledWith( + expect.objectContaining({ + features: [expect.anything(), expect.objectContaining({ properties: { id: 'way/2', tone: 'hard' } })], + }) + ); + }); + + it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata and the point to onSelect', () => { + const gl = fakeGlMap(); + const onSelect = vi.fn(); + const target = ferrata('way/2'); + renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1'), target], onSelect)); + + gl.state.hit = { properties: { id: 'way/2' } }; + gl.emit('click', click); + expect(onSelect).toHaveBeenCalledWith(target, { lng: 11.705, lat: 46.505 }); + + onSelect.mockClear(); + gl.state.hit = null; + gl.emit('click', click); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('FE-FERRATA-GL-006: the pointer turns into a hand over the hit line', () => { + const gl = fakeGlMap(); + renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn())); + + gl.emit(`mouseenter:${LAYERS[2]}`); + expect(gl.canvas.style.cursor).toBe('pointer'); + gl.emit(`mouseleave:${LAYERS[2]}`); + expect(gl.canvas.style.cursor).toBe(''); + }); + + it('FE-FERRATA-GL-007: unmounting removes the layers, the source and every listener; no map, no work', () => { + const gl = fakeGlMap(); + const { unmount } = render(); + unmount(); + + expect(gl.layers.size).toBe(0); + expect(gl.sources.size).toBe(0); + expect(gl.listeners()).toBe(0); + expect(() => renderHook(() => useFerrataLayerGL(null, [ferrata('way/1')], vi.fn()))).not.toThrow(); + }); + + it('FE-FERRATA-GL-008: a theme change repaints the installed layers without rebuilding them', async () => { + const root = document.documentElement; + root.style.setProperty('--success', 'green'); + const gl = fakeGlMap(); + renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn())); + expect(gl.spies.setPaintProperty).not.toHaveBeenCalled(); + + await act(async () => { + root.style.setProperty('--success', 'lime'); + await Promise.resolve(); + }); + + await waitFor(() => + expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LAYERS[1], 'line-color', expect.arrayContaining(['lime'])) + ); + expect(gl.spies.addLayer).toHaveBeenCalledTimes(3); + root.style.removeProperty('--success'); + }); +}); diff --git a/client/src/components/Ferratas/useFerrataLayerGL.ts b/client/src/components/Ferratas/useFerrataLayerGL.ts new file mode 100644 index 000000000..832345665 --- /dev/null +++ b/client/src/components/Ferratas/useFerrataLayerGL.ts @@ -0,0 +1,188 @@ +import type { ExpressionSpecification, GeoJSONSource, Map, MapMouseEvent } from 'mapbox-gl'; +import { useEffect, useMemo, useRef } from 'react'; + +import type { Ferrata } from '@trek/shared'; + +import { ferrataFeatureCollection } from './ferrataModel'; +import { useFerrataPaint } from './useFerrataPaint'; + +const SOURCE = 'trek-ferratas'; +const CASING = `${SOURCE}-casing`; +const LINE = `${SOURCE}-line`; +/** A wide, invisible line on top, so a thin dashed route is still easy to tap. */ +const HIT = `${SOURCE}-hit`; +const LAYERS = [CASING, LINE, HIT]; + +/** Where on the map a ferrata was clicked, for a popup or card anchored there. */ +interface FerrataClickPoint { + lng: number; + lat: number; +} + +export type FerrataSelectHandler = (ferrata: Ferrata, at: FerrataClickPoint) => void; + +type Paint = ReturnType; + +/** The line colour per difficulty band, as a GL `match` on the feature's `tone`. */ +function toneColor(paint: Paint): ExpressionSpecification { + return [ + 'match', + ['get', 'tone'], + 'easy', + paint.tone.easy, + 'medium', + paint.tone.medium, + 'hard', + paint.tone.hard, + paint.tone.unknown, + ]; +} + +/** + * The via ferrata layer on a MapLibre or Mapbox GL map: one GeoJSON source, + * a casing, the dashed line coloured by difficulty band, and a hit line that + * turns a click into `onSelect(ferrata, point)`. + * + * The layers are installed once per map and again after a style swap (a + * basemap or theme change drops every custom layer); new ferratas only replace + * the source's data, so a refetch after a pan does not rebuild the layers. Like + * the hazard overlay, a draw that finds the style busy loading tiles waits for + * `idle` rather than for a `style.load` that may never come. A theme change + * repaints the installed layers in place instead of rebuilding them. + */ +export function useFerrataLayerGL(map: Map | null, ferratas: readonly Ferrata[], onSelect: FerrataSelectHandler) { + const data = useMemo(() => ferrataFeatureCollection(ferratas), [ferratas]); + const paint = useFerrataPaint(); + // The handlers read the newest props without reinstalling the layers on every render. + const latest = useRef({ ferratas, data, onSelect, paint }); + // The paint the installed layers were last drawn in, so a mount does not repaint what it just drew. + const painted = useRef(null); + useEffect(() => { + latest.current = { ferratas, data, onSelect, paint }; + }); + + useEffect(() => { + if (!map) return; + let waiting = false; + const draw = () => { + if (map.getSource(SOURCE)) return; + if (!map.isStyleLoaded()) { + if (!waiting) { + waiting = true; + map.once('idle', retry); + } + return; + } + const paint = latest.current.paint; + painted.current = paint; + const lineLayout = { 'line-cap': 'round', 'line-join': 'round' } as const; + map.addSource(SOURCE, { type: 'geojson', data: latest.current.data }); + map.addLayer({ + id: CASING, + type: 'line', + source: SOURCE, + layout: lineLayout, + paint: { 'line-color': paint.casing, 'line-width': 6, 'line-opacity': 0.75 }, + }); + map.addLayer({ + id: LINE, + type: 'line', + source: SOURCE, + layout: lineLayout, + paint: { + 'line-color': toneColor(paint), + 'line-width': 3, + 'line-dasharray': [2, 1.5], + }, + }); + map.addLayer({ + id: HIT, + type: 'line', + source: SOURCE, + layout: lineLayout, + paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 }, + }); + }; + const retry = () => { + waiting = false; + draw(); + }; + const click = (event: MapMouseEvent) => { + if (!map.getLayer(HIT)) return; + const hit = map.queryRenderedFeatures(event.point, { layers: [HIT] })[0]; + const id: unknown = hit?.properties?.id; + const ferrata = latest.current.ferratas.find((candidate) => candidate.id === id); + if (ferrata) latest.current.onSelect(ferrata, { lng: event.lngLat.lng, lat: event.lngLat.lat }); + }; + const enter = () => { + map.getCanvas().style.cursor = 'pointer'; + }; + const leave = () => { + map.getCanvas().style.cursor = ''; + }; + + draw(); + map.on('style.load', draw); + map.on('click', click); + map.on('mouseenter', HIT, enter); + map.on('mouseleave', HIT, leave); + return () => { + map.off('style.load', draw); + map.off('idle', retry); + map.off('click', click); + map.off('mouseenter', HIT, enter); + map.off('mouseleave', HIT, leave); + for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer); + if (map.getSource(SOURCE)) map.removeSource(SOURCE); + }; + }, [map]); + + useEffect(() => { + const source = map?.getSource(SOURCE) as GeoJSONSource | undefined; + source?.setData(data); + }, [map, data]); + + useEffect(() => { + if (!map || painted.current === paint || !map.getLayer(LINE)) return; + painted.current = paint; + if (map.getLayer(CASING)) map.setPaintProperty(CASING, 'line-color', paint.casing); + if (map.getLayer(LINE)) map.setPaintProperty(LINE, 'line-color', toneColor(paint)); + }, [map, paint]); +} + +/** The GL map the layer draws on. MapLibre's map answers the same calls the layer makes. */ +export type FerrataGlMap = Map; + +/** + * Just the two calls a hit test needs, so the Mapbox and the MapLibre map both + * fit, each with the point type of its own click events. Optional because a stand-in map (a test double, a map torn down mid + * click) may lack them, and a hit test must then answer no rather than throw. + */ +interface HitTestMap { + getLayer?(id: string): unknown; + queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[]; +} + +/** + * Whether a click at `point` landed on a drawn ferrata. A map's own click + * handler asks this first, so picking a ferrata opens its card instead of also + * dropping a control point or a place under it. + */ +export function isFerrataHit(map: HitTestMap, point: Point): boolean { + if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false; + return !!map.getLayer(HIT) && map.queryRenderedFeatures(point, { layers: [HIT] }).length > 0; +} + +const NO_FERRATAS: readonly Ferrata[] = []; +const IGNORE_SELECT: FerrataSelectHandler = () => {}; + +/** + * The layer as a map view mounts it from a `FerrataMapLayer`: drawn only while + * it is switched on, so a map with the layer off carries no source or layers. + */ +export function useFerrataMapLayerGL( + map: Map | null, + layer: { active: boolean; ferratas: readonly Ferrata[]; onSelect: FerrataSelectHandler } | undefined +) { + useFerrataLayerGL(layer?.active ? map : null, layer?.ferratas ?? NO_FERRATAS, layer?.onSelect ?? IGNORE_SELECT); +} diff --git a/client/src/components/Ferratas/useFerrataMapLayer.test.ts b/client/src/components/Ferratas/useFerrataMapLayer.test.ts new file mode 100644 index 000000000..fa58d721b --- /dev/null +++ b/client/src/components/Ferratas/useFerrataMapLayer.test.ts @@ -0,0 +1,101 @@ +/** + * FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-005: the via ferrata layer as + * one map surface holds it: off by default, the area the map reports, the card + * a click opens. + * + * useTripFerratas is mocked: what it asks the server is its own suite's + * subject. This one checks what the layer hands it (the area, and whether it + * is on at all) and what it hands the map back. + */ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Ferrata, FerrataBbox } from '@trek/shared'; + +import { useFerrataMapLayer } from './useFerrataMapLayer'; +import { useTripFerratas } from './useTripFerratas'; + +vi.mock('./useTripFerratas', () => ({ useTripFerratas: vi.fn() })); + +const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 }; +const OTHER: FerrataBbox = { south: 46.4, west: 11.6, north: 46.5, east: 11.8 }; + +const FERRATA: Ferrata = { + id: 'way/42', + name: 'Via delle Trincee', + grade: { raw: '4', level: 4, plus: null }, + routeName: null, + geometry: [ + [46.52, 11.8], + [46.53, 11.81], + ], + osmUrl: 'https://www.openstreetmap.org/way/42', +}; + +const lastCall = () => vi.mocked(useTripFerratas).mock.lastCall; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useTripFerratas).mockReturnValue({ + ferratas: [FERRATA], + status: 'ready', + truncated: false, + attribution: null, + }); +}); + +describe('useFerrataMapLayer', () => { + it('FE-FERRATA-MAPLAYER-001: starts off, so nothing is asked for', () => { + const { result } = renderHook(() => useFerrataMapLayer(7)); + expect(result.current.layer.active).toBe(false); + expect(result.current.selected).toBeNull(); + expect(lastCall()).toEqual([7, null, false]); + }); + + it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => { + const { result } = renderHook(() => useFerrataMapLayer(7)); + const renders = vi.mocked(useTripFerratas).mock.calls.length; + + act(() => result.current.onViewportChange(BOX)); + expect(vi.mocked(useTripFerratas).mock.calls.length).toBe(renders); + + act(() => result.current.layer.onToggle()); + expect(result.current.layer.active).toBe(true); + expect(lastCall()).toEqual([7, BOX, true]); + }); + + it('FE-FERRATA-MAPLAYER-003: while on, every new area reaches the request', () => { + const { result } = renderHook(() => useFerrataMapLayer(7)); + act(() => result.current.layer.onToggle()); + act(() => result.current.onViewportChange(OTHER)); + expect(lastCall()).toEqual([7, OTHER, true]); + }); + + it('FE-FERRATA-MAPLAYER-004: a click opens the card, close shuts it, and the map gets what the request found', () => { + const { result } = renderHook(() => useFerrataMapLayer(7)); + act(() => result.current.layer.onToggle()); + expect(result.current.layer.ferratas).toEqual([FERRATA]); + expect(result.current.layer.status).toBe('ready'); + + act(() => result.current.layer.onSelect(FERRATA, { lat: 46.52, lng: 11.8 })); + expect(result.current.selected).toBe(FERRATA); + + act(() => result.current.close()); + expect(result.current.selected).toBeNull(); + }); + + it('FE-FERRATA-MAPLAYER-005: switching the layer off closes the card and stops asking', () => { + const { result } = renderHook(() => useFerrataMapLayer(7)); + act(() => result.current.layer.onToggle()); + act(() => result.current.layer.onSelect(FERRATA, { lat: 46.52, lng: 11.8 })); + + act(() => result.current.layer.onToggle()); + expect(result.current.layer.active).toBe(false); + expect(result.current.selected).toBeNull(); + expect(lastCall()?.[2]).toBe(false); + + // On again, the card stays closed until the next click. + act(() => result.current.layer.onToggle()); + expect(result.current.selected).toBeNull(); + }); +}); diff --git a/client/src/components/Ferratas/useFerrataMapLayer.ts b/client/src/components/Ferratas/useFerrataMapLayer.ts new file mode 100644 index 000000000..0c5e452cf --- /dev/null +++ b/client/src/components/Ferratas/useFerrataMapLayer.ts @@ -0,0 +1,63 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; + +import type { Ferrata, FerrataBbox } from '@trek/shared'; + +import type { FerrataSelectHandler } from './useFerrataLayerGL'; +import { type FerrataStatus, useTripFerratas } from './useTripFerratas'; + +/** + * The via ferrata layer as a map hands it around: whether it is on, the switch + * the layer pill shows, what the status pill says, the lines to draw and what + * a click on one of them does. Both flat renderers and the 3D terrain map take + * this one object, so the three cannot drift apart on what the layer is. + */ +export interface FerrataMapLayer { + active: boolean; + onToggle: () => void; + status: FerrataStatus; + ferratas: readonly Ferrata[]; + onSelect: FerrataSelectHandler; +} + +/** + * The via ferrata layer of one map surface: off by default, and nothing is + * asked for while it is off. + * + * `onViewportChange` takes the visible area from whichever map is on screen + * (the flat planner map or the 3D terrain). While the layer is off the area is + * only remembered, not put into state, so panning a map with the layer off + * re-renders nothing; switching it on starts from the last area the map + * reported instead of waiting for the next pan. `selected` is the ferrata + * whose card is open, closed again when the layer is switched off. + */ +export function useFerrataMapLayer(tripId: number | null | undefined) { + const [active, setActive] = useState(false); + const [bbox, setBbox] = useState(null); + const [selected, setSelected] = useState(null); + const lastArea = useRef(null); + const activeRef = useRef(false); + const { ferratas, status } = useTripFerratas(tripId, bbox, active); + + const onViewportChange = useCallback((area: FerrataBbox) => { + lastArea.current = area; + if (activeRef.current) setBbox(area); + }, []); + + const onToggle = useCallback(() => { + const next = !activeRef.current; + activeRef.current = next; + setActive(next); + setSelected(null); + if (next) setBbox(lastArea.current); + }, []); + + const close = useCallback(() => setSelected(null), []); + const onSelect = useCallback((ferrata) => setSelected(ferrata), []); + + const layer = useMemo( + () => ({ active, onToggle, status, ferratas, onSelect }), + [active, onToggle, status, ferratas, onSelect] + ); + + return { layer, onViewportChange, selected: active ? selected : null, close }; +} diff --git a/client/src/components/Ferratas/useFerrataPaint.test.ts b/client/src/components/Ferratas/useFerrataPaint.test.ts new file mode 100644 index 000000000..b937435f5 --- /dev/null +++ b/client/src/components/Ferratas/useFerrataPaint.test.ts @@ -0,0 +1,47 @@ +// FE-FERRATA-USEPAINT-001 to FE-FERRATA-USEPAINT-003: the line colours follow the theme on . +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { useFerrataPaint } from './useFerrataPaint'; + +const root = document.documentElement; + +afterEach(() => { + root.style.removeProperty('--success'); + 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('--success', 'green'); + const { result } = renderHook(() => useFerrataPaint()); + expect(result.current.tone.easy).toBe('green'); + }); + + it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => { + root.style.setProperty('--success', 'green'); + const { result } = renderHook(() => useFerrataPaint()); + + await act(async () => { + root.setAttribute('data-scheme', 'forest'); + root.style.setProperty('--success', 'teal'); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.tone.easy).toBe('teal')); + }); + + it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => { + root.style.setProperty('--success', 'green'); + const { result } = renderHook(() => useFerrataPaint()); + const first = result.current; + + await act(async () => { + root.classList.add('unrelated'); + await Promise.resolve(); + }); + + expect(result.current).toBe(first); + }); +}); diff --git a/client/src/components/Ferratas/useFerrataPaint.ts b/client/src/components/Ferratas/useFerrataPaint.ts new file mode 100644 index 000000000..c0b8fa4a1 --- /dev/null +++ b/client/src/components/Ferratas/useFerrataPaint.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +import { ferrataPaint } from './ferrataPaint'; + +/** The attributes `applyAppearance()` writes on `` that change what the status tokens resolve to. */ +const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme']; + +type Paint = ReturnType; + +function samePaint(a: Paint, b: Paint): boolean { + return ( + a.casing === b.casing && + a.tone.easy === b.tone.easy && + a.tone.medium === b.tone.medium && + a.tone.hard === b.tone.hard && + a.tone.unknown === b.tone.unknown + ); +} + +/** + * The line colours, resolved again whenever the light/dark class, the colour + * scheme or a custom accent on `` changes. Map paint holds resolved + * strings rather than `var(--token)`, so without this a theme toggle would leave + * the lines in the old scheme's colours until the next refetch. Same observer + * approach as `useIsDark`, which only watches the class. The reference only + * changes when a colour does, so a memo keyed on it does not churn. + */ +export function useFerrataPaint(): Paint { + const [paint, setPaint] = useState(ferrataPaint); + + useEffect(() => { + if (typeof document === 'undefined') return; + const observer = new MutationObserver(() => { + const next = ferrataPaint(); + setPaint((current) => (samePaint(current, next) ? current : next)); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES }); + return () => observer.disconnect(); + }, []); + + return paint; +} diff --git a/client/src/components/Ferratas/useTripFerratas.test.ts b/client/src/components/Ferratas/useTripFerratas.test.ts new file mode 100644 index 000000000..c6899c8e5 --- /dev/null +++ b/client/src/components/Ferratas/useTripFerratas.test.ts @@ -0,0 +1,195 @@ +/** + * FE-FERRATA-HOOK-001 to FE-FERRATA-HOOK-010: when the via ferrata layer asks + * the server, and what it reports while it does. + * + * The request goes to a public Overpass mirror behind the server, so the hook + * has to be frugal: nothing while the layer is off, one request per settled + * map area, the old one aborted when the area moves on, and none at all for an + * area the server would refuse or a device that is offline. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared'; + +import { useNetworkMode } from '../../hooks/useNetworkMode'; +import { ferrataRepo } from '../../repo/ferrataRepo'; +import { useTripFerratas } from './useTripFerratas'; + +vi.mock('../../repo/ferrataRepo', () => ({ ferrataRepo: { list: vi.fn() } })); +vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: vi.fn() })); + +const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 }; +const OTHER: FerrataBbox = { south: 46.4, west: 11.6, north: 46.5, east: 11.8 }; + +const FERRATA: Ferrata = { + id: 'way/42', + name: 'Via delle Trincee', + grade: { raw: '4', level: 4, plus: null }, + routeName: null, + geometry: [ + [46.52, 11.8], + [46.53, 11.81], + ], + osmUrl: 'https://www.openstreetmap.org/way/42', +}; + +const reply = (ferratas: Ferrata[] = [FERRATA], truncated = false): FerratasResponse => ({ + ferratas, + truncated, + source: 'openstreetmap', + attribution: '© OpenStreetMap contributors', +}); + +const network = (offline: boolean) => + vi.mocked(useNetworkMode).mockReturnValue({ offline, forced: false, setForced: vi.fn() }); + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + network(false); + vi.mocked(ferrataRepo.list).mockResolvedValue(reply()); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/** Lets the debounce run out and the repo's promise settle. */ +async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); +} + +describe('useTripFerratas', () => { + it('FE-FERRATA-HOOK-001: asks for nothing while the layer is off', async () => { + const { result } = renderHook(() => useTripFerratas(7, BOX, false)); + await settle(); + + expect(ferrataRepo.list).not.toHaveBeenCalled(); + expect(result.current).toEqual({ ferratas: [], status: 'idle', truncated: false, attribution: null }); + }); + + it('FE-FERRATA-HOOK-002: waits for the area to settle, then asks once for the rounded box', async () => { + const { result } = renderHook(() => useTripFerratas(7, BOX, true)); + expect(result.current.status).toBe('loading'); + expect(ferrataRepo.list).not.toHaveBeenCalled(); + + await settle(); + + expect(ferrataRepo.list).toHaveBeenCalledTimes(1); + expect(ferrataRepo.list).toHaveBeenCalledWith(7, '46.500,11.700,46.600,11.900', expect.any(AbortSignal)); + expect(result.current).toEqual({ + ferratas: [FERRATA], + status: 'ready', + truncated: false, + attribution: '© OpenStreetMap contributors', + }); + }); + + it('FE-FERRATA-HOOK-003: a pan before the debounce runs out sends only the last area', async () => { + const { rerender } = renderHook(({ box }) => useTripFerratas(7, box, true), { initialProps: { box: BOX } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + rerender({ box: OTHER }); + await settle(); + + expect(ferrataRepo.list).toHaveBeenCalledTimes(1); + expect(vi.mocked(ferrataRepo.list).mock.calls[0]?.[1]).toBe('46.400,11.600,46.500,11.800'); + }); + + it('FE-FERRATA-HOOK-004: a new area aborts the request in flight and keeps the old lines while it loads', async () => { + let first: AbortSignal | undefined; + vi.mocked(ferrataRepo.list).mockImplementationOnce((_trip, _bbox, signal) => { + first = signal; + return Promise.resolve(reply()); + }); + const { result, rerender } = renderHook(({ box }) => useTripFerratas(7, box, true), { + initialProps: { box: BOX }, + }); + await settle(); + expect(result.current.status).toBe('ready'); + + vi.mocked(ferrataRepo.list).mockReturnValueOnce(new Promise(() => {})); + rerender({ box: OTHER }); + expect(first?.aborted).toBe(true); + expect(result.current.status).toBe('loading'); + expect(result.current.ferratas).toEqual([FERRATA]); + }); + + it('FE-FERRATA-HOOK-005: a failed request reports an error and draws nothing', async () => { + vi.mocked(ferrataRepo.list).mockRejectedValue(Object.assign(new Error('502'), { response: { status: 502 } })); + const { result } = renderHook(() => useTripFerratas(7, BOX, true)); + await settle(); + + expect(result.current.status).toBe('error'); + expect(result.current.ferratas).toEqual([]); + }); + + it('FE-FERRATA-HOOK-006: an area wider than the server takes asks the user to zoom in, without a request', async () => { + const { result } = renderHook(() => useTripFerratas(7, { south: 46, west: 11, north: 47, east: 12 }, true)); + await settle(); + + expect(result.current.status).toBe('tooLarge'); + expect(ferrataRepo.list).not.toHaveBeenCalled(); + }); + + it('FE-FERRATA-HOOK-007: offline it says so and sends nothing', async () => { + network(true); + const { result } = renderHook(() => useTripFerratas(7, BOX, true)); + await settle(); + + expect(result.current.status).toBe('offline'); + expect(ferrataRepo.list).not.toHaveBeenCalled(); + }); + + it('FE-FERRATA-HOOK-008: switching the layer off aborts and clears, and a truncated answer is passed on', async () => { + vi.mocked(ferrataRepo.list).mockResolvedValue(reply([FERRATA], true)); + const { result, rerender } = renderHook(({ on }) => useTripFerratas(7, BOX, on), { initialProps: { on: true } }); + await settle(); + expect(result.current.truncated).toBe(true); + + rerender({ on: false }); + expect(result.current).toEqual({ ferratas: [], status: 'idle', truncated: false, attribution: null }); + expect(renderHook(() => useTripFerratas(undefined, BOX, true)).result.current.status).toBe('idle'); + expect(renderHook(() => useTripFerratas(7, null, true)).result.current.status).toBe('idle'); + }); + + it('FE-FERRATA-HOOK-009: a 400 reads as "zoom in" and is not asked again', async () => { + vi.mocked(ferrataRepo.list).mockRejectedValue(Object.assign(new Error('400'), { response: { status: 400 } })); + const { result } = renderHook(() => useTripFerratas(7, BOX, true)); + await settle(); + expect(result.current.status).toBe('tooLarge'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(ferrataRepo.list).toHaveBeenCalledTimes(1); + }); + + it('FE-FERRATA-HOOK-010: an outage asks again for the same area after a backoff and recovers', async () => { + vi.mocked(ferrataRepo.list) + .mockRejectedValueOnce(Object.assign(new Error('502'), { response: { status: 502 } })) + .mockResolvedValueOnce(reply()); + const { result } = renderHook(() => useTripFerratas(7, BOX, true)); + await settle(); + expect(result.current.status).toBe('error'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(4_000); + }); + expect(ferrataRepo.list).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('error'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + await settle(); + expect(ferrataRepo.list).toHaveBeenCalledTimes(2); + expect(vi.mocked(ferrataRepo.list).mock.calls[1]?.[1]).toBe('46.500,11.700,46.600,11.900'); + expect(result.current.status).toBe('ready'); + expect(result.current.ferratas).toEqual([FERRATA]); + }); +}); diff --git a/client/src/components/Ferratas/useTripFerratas.ts b/client/src/components/Ferratas/useTripFerratas.ts new file mode 100644 index 000000000..c2061cbcc --- /dev/null +++ b/client/src/components/Ferratas/useTripFerratas.ts @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react'; + +import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared'; + +import { useNetworkMode } from '../../hooks/useNetworkMode'; +import { ferrataRepo } from '../../repo/ferrataRepo'; +import { ferrataBboxFits, ferrataBboxParam, roundFerrataBbox } from './ferrataModel'; + +/** + * Where the layer stands. `tooLarge` and `offline` are answers to "why are + * there no lines" that need no request at all: the server refuses a box wider + * than its limit, and an offline device should not fire one. + */ +export type FerrataStatus = 'idle' | 'loading' | 'ready' | 'error' | 'tooLarge' | 'offline'; + +/** Long enough that a pan or a zoom gesture settles before the request goes out. */ +const DEBOUNCE_MS = 400; + +/** + * How long the layer waits before asking again for the same area after the + * mirrors failed it. Past the last step it stays on the error until the map + * moves or the layer is switched off and on. + */ +const RETRY_DELAYS_MS = [5_000, 15_000, 45_000]; + +const NO_FERRATAS: Ferrata[] = []; + +/** + * What came back: the answer, a 400 (`refused`: the server would not take the + * box, which reads as "zoom in" rather than as an outage) or anything else + * (`unavailable`: the Overpass mirrors, worth another try). + */ +type Outcome = FerratasResponse | 'refused' | 'unavailable'; + +interface Answer { + key: string; + outcome: Outcome; +} + +function failureOf(error: unknown): 'refused' | 'unavailable' { + const status = (error as { response?: { status?: unknown } } | null)?.response?.status; + return status === 400 ? 'refused' : 'unavailable'; +} + +/** `current` is the outcome for the area on screen, or null while that one is still on its way. */ +function statusOf(active: boolean, offline: boolean, fits: boolean, current: Outcome | null): FerrataStatus { + if (!active) return 'idle'; + if (offline) return 'offline'; + if (!fits || current === 'refused') return 'tooLarge'; + if (!current) return 'loading'; + return current === 'unavailable' ? 'error' : 'ready'; +} + +/** The area on screen as the request grid sees it, and the request it makes (null: none). */ +function requestOf(tripId: number | null | undefined, bbox: FerrataBbox | null, enabled: boolean, offline: boolean) { + const area = bbox ? roundFerrataBbox(bbox) : null; + const fits = !!area && ferrataBboxFits(area); + const active = enabled && !!tripId && !!area; + const param = active && fits && !offline && area ? ferrataBboxParam(area) : null; + return { fits, active, param, key: param ? `${tripId}:${param}` : null }; +} + +/** The answer that speaks for this attempt; while a retry is in flight, the failure it follows keeps the error up. */ +function answerFor(answer: Answer | null, key: string | null, attempt: number): Answer | null { + if (!answer || !key) return null; + if (answer.key === `${key}#${attempt}`) return answer; + return attempt > 0 && answer.key === `${key}#${attempt - 1}` ? answer : null; +} + +function view(answer: Answer | null, status: FerrataStatus) { + // The last good answer stays up while the next area loads; any other state draws nothing. + const last = answer && typeof answer.outcome === 'object' ? answer.outcome : null; + const shown = status === 'ready' || status === 'loading' ? last : null; + return { + ferratas: shown?.ferratas ?? NO_FERRATAS, + status, + truncated: shown?.truncated ?? false, + attribution: shown?.attribution ?? null, + }; +} + +/** + * The via ferratas inside the visible map area of a trip, for the map layer. + * + * Online only (see repo/ferrataRepo.ts). Nothing is requested while the layer is + * off. A new area waits DEBOUNCE_MS before it is asked for, and a newer area, + * a closed layer or an unmount aborts the request in flight, so a fast pan + * across the Alps ends in one request for where the map stopped. While the + * next area loads, the lines of the last one stay drawn instead of blinking out. + * When the mirrors fail an area, it is asked for again after each of + * RETRY_DELAYS_MS; a 400 is not retried, it reads as "zoom in". + */ +export function useTripFerratas(tripId: number | null | undefined, bbox: FerrataBbox | null, enabled: boolean) { + const { offline } = useNetworkMode(); + const { fits, active, param, key } = requestOf(tripId, bbox, enabled, offline); + const [answer, setAnswer] = useState(null); + // Retries count per area: a new area starts again at zero without a reset. + const [retry, setRetry] = useState<{ key: string | null; attempt: number }>({ key: null, attempt: 0 }); + const attempt = retry.key === key ? retry.attempt : 0; + const requestKey = key ? `${key}#${attempt}` : null; + + useEffect(() => { + if (!tripId || !param || !requestKey) return; + const areaKey = `${tripId}:${param}`; + const controller = new AbortController(); + let retryTimer: number | undefined; + const timer = window.setTimeout(() => { + ferrataRepo.list(tripId, param, controller.signal).then( + (reply) => { + if (!controller.signal.aborted) setAnswer({ key: requestKey, outcome: reply }); + }, + (error: unknown) => { + if (controller.signal.aborted) return; + // The pill reports it ("Overpass unreachable" or "zoom in"); the error + // itself carries nothing more the user could act on. + const failure = failureOf(error); + setAnswer({ key: requestKey, outcome: failure }); + const delay = RETRY_DELAYS_MS[attempt]; + if (failure === 'unavailable' && delay !== undefined) { + retryTimer = window.setTimeout(() => setRetry({ key: areaKey, attempt: attempt + 1 }), delay); + } + } + ); + }, DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + window.clearTimeout(retryTimer); + controller.abort(); + }; + }, [tripId, param, requestKey, attempt]); + + const status = statusOf(active, offline, fits, answerFor(answer, key, attempt)?.outcome ?? null); + return view(answer, status); +} diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index 8355f6fc5..a34933ca7 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -1,8 +1,9 @@ -// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-010 +// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-014 import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '../../../tests/helpers/render' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher' +import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer' describe('MapLayerSwitcher', () => { it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => { @@ -97,3 +98,61 @@ describe('TourMapLayerSwitcher 3D switch', () => { expect(screen.getByRole('button', { name: 'Topo' })).toHaveAttribute('aria-pressed', 'true') }) }) + +const ferrataLayer = (overrides: Partial = {}): FerrataMapLayer => ({ + active: false, + onToggle: vi.fn(), + status: 'idle', + ferratas: [], + onSelect: vi.fn(), + ...overrides, +}) + +describe('TourMapLayerSwitcher via ferrata switch', () => { + it('FE-COMP-MAPLAYER-011: offers no via ferrata switch without a layer, and no status row either', () => { + const { container } = render( {}} />) + expect(screen.queryByRole('button', { name: 'Via ferratas' })).toBeNull() + expect(container.firstElementChild).toHaveAttribute('role', 'group') + }) + + it('FE-COMP-MAPLAYER-012: the switch toggles the layer, pressed and in the accent while it is on', () => { + const layer = ferrataLayer() + const { rerender } = render( {}} ferrataLayer={layer} />) + const off = screen.getByRole('button', { name: 'Via ferratas' }) + expect(off).toHaveAttribute('aria-pressed', 'false') + expect(off).toHaveClass('text-content-muted') + fireEvent.click(off) + expect(layer.onToggle).toHaveBeenCalledTimes(1) + + rerender( {}} ferrataLayer={ferrataLayer({ active: true, status: 'loading' })} />) + const on = screen.getByRole('button', { name: 'Via ferratas' }) + expect(on).toHaveAttribute('aria-pressed', 'true') + expect(on).toHaveClass('bg-accent', 'text-accent-text') + }) + + it('FE-COMP-MAPLAYER-013: the status pill sits in the same row, right of the pill, and says nothing while the layer is off', () => { + const { rerender } = render( {}} ferrataLayer={ferrataLayer()} />) + expect(screen.queryByTestId('ferrata-status-pill')).toBeNull() + + rerender( {}} ferrataLayer={ferrataLayer({ active: true, status: 'tooLarge' })} />) + const pill = screen.getByTestId('ferrata-status-pill') + expect(pill).toHaveTextContent('Zoom in to load via ferratas') + const row = pill.parentElement as HTMLElement + expect(row.style.display).toBe('flex') + expect(row.firstElementChild).toHaveAttribute('role', 'group') + expect(row.lastElementChild).toBe(pill) + }) + + it('FE-COMP-MAPLAYER-014: sits beside the 3D switch behind one divider, and counts what the layer found', () => { + const found = [{ id: 'way/1' }, { id: 'way/2' }] as unknown as FerrataMapLayer['ferratas'] + render( + {}} onToggleTerrain={() => {}} + ferrataLayer={ferrataLayer({ active: true, status: 'ready', ferratas: found })} /> + ) + const group = screen.getByRole('group') + expect(group.querySelectorAll('span[aria-hidden]')).toHaveLength(1) + const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.textContent) + expect(buttons.slice(-2)).toEqual(['Via ferratas', '3D']) + expect(screen.getByTestId('ferrata-status-pill')).toHaveTextContent('2 via ferrata routes') + }) +}) diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 8a467124c..e1f0ad860 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,6 +1,8 @@ -import { Map as MapIcon, Mountain, Rotate3d, Satellite } from 'lucide-react' +import { Map as MapIcon, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react' import type { CSSProperties } from 'react' import { useTranslation } from '../../i18n' +import FerrataStatusPill from '../Ferratas/FerrataStatusPill' +import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer' import { Tooltip } from '../shared/Tooltip' import { MAP_CONTROL_SHADOW } from './mapControlShadow' @@ -86,16 +88,22 @@ export function TourMapLayerSwitcher({ onChange, onToggleTerrain, terrainActive = false, + ferrataLayer, }: { active: TourBaseLayer onChange: (layer: TourBaseLayer) => void /** Offers the 3D terrain switch beside the base layers. */ onToggleTerrain?: () => void terrainActive?: boolean + /** + * Offers the via ferrata switch beside the base layers, and puts the layer's + * status pill in the same row, right of this one. + */ + ferrataLayer?: FerrataMapLayer }) { const { t } = useTranslation() - return ( + const pill = (
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => { const selected = active === id @@ -114,21 +122,40 @@ export function TourMapLayerSwitcher({ ) })} + {(ferrataLayer || onToggleTerrain) && } + {ferrataLayer && ( + + )} {onToggleTerrain && ( - <> - - - + )}
) + + if (!ferrataLayer) return pill + // The status pill keeps the column gap the lock pill keeps above, and says nothing while the layer is off. + return ( +
+ {pill} + +
+ ) } diff --git a/client/src/components/Map/MapView.leaflet.test.tsx b/client/src/components/Map/MapView.leaflet.test.tsx index f142464bd..d84aae998 100644 --- a/client/src/components/Map/MapView.leaflet.test.tsx +++ b/client/src/components/Map/MapView.leaflet.test.tsx @@ -23,6 +23,13 @@ vi.mock('../../hooks/useGeolocation', () => ({ useGeolocation: () => ({ position: null, mode: 'off', error: null, errorCode: null, cycleMode: vi.fn(), setMode: vi.fn() }), })) +// What the via ferrata layer draws is FerrataLayerLeaflet's own suite; here only what the +// renderer hands it. jsdom has no SVG renderer for Leaflet to draw real lines into. +vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ + FerrataMapLayerLeaflet: ({ layer }: { layer?: { active: boolean; ferratas: readonly unknown[] } }) => + layer?.active ?
: null, +})) + vi.mock('../../services/photoService', () => ({ getCached: vi.fn(() => null), isLoading: vi.fn(() => false), @@ -448,4 +455,27 @@ describe('MapView on a real Leaflet map', () => { panMap(container) expect(map.getCenter()).not.toEqual(before) }) + + it('RC-10: the tour layer pill offers the via ferrata switch, and the layer is drawn only while it is on', async () => { + const ferrata = { id: 'way/42', name: 'Via delle Trincee', grade: { raw: '4', level: 4, plus: null }, routeName: null, + geometry: [[46.52, 11.8], [46.53, 11.81]], osmUrl: 'https://www.openstreetmap.org/way/42' } + const layer = { active: false, onToggle: vi.fn(), status: 'idle' as const, ferratas: [ferrata], onSelect: vi.fn() } + const view = render( + , + ) + const toggle = await view.findByRole('button', { name: 'Via ferratas' }) + expect(toggle).toHaveAttribute('aria-pressed', 'false') + expect(view.queryByTestId('ferrata-leaflet-layer')).toBeNull() + act(() => toggle.click()) + expect(layer.onToggle).toHaveBeenCalledOnce() + + view.rerender( + , + ) + await waitFor(() => expect(view.getByTestId('ferrata-leaflet-layer')).toHaveAttribute('data-count', '1')) + expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true') + expect(view.getByTestId('ferrata-status-pill')).toHaveTextContent('1 via ferrata route') + }) }) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 47060d0bf..d645c6451 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -8,6 +8,8 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL import RoadtripViaMarkers from './RoadtripViaMarkers' import PlannerWaypointMarker from './PlannerWaypointMarker' import HazardLayers from './HazardLayers' +import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet' +import { formatViaDwell } from './routeViaDwell' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays' @@ -109,12 +111,6 @@ function routeViaIcon(tone: string): L.DivIcon { return icon } -function formatViaDwell(seconds: number): string { - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return h > 0 ? `${h} h ${m} min` : `${m} min` -} - function nightPauseIcon(via: RouteVia): L.DivIcon { const key = `night:${via.nightPause!.day}:${via.nightPause!.atPlace}` const cached = viaIconCache.get(key) @@ -834,7 +830,7 @@ export const MapView = memo(function MapView({ onPlannerWaypointClick, onPlannerWaypointMove, routeProfileFocus = null, - viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, + viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, }: MapViewProps) { const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' const updateSetting = useSettingsStore(s => s.updateSetting) @@ -1337,6 +1333,7 @@ export const MapView = memo(function MapView({ /> {hazards?.length > 0 && } + {/* The route as it was actually recorded (#2279). Drawn in the casing pane's sibling order so it sits under the planned route rather than @@ -1384,7 +1381,7 @@ export const MapView = memo(function MapView({ {onToggleFollow && } {hasViewBaseLayer ? + onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} /> : }
diff --git a/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx b/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx index 91f89b511..059067d4e 100644 --- a/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx +++ b/client/src/components/Map/MapViewGL.plannerWaypoints.test.tsx @@ -7,8 +7,8 @@ import { resetAllStores } from '../../../tests/helpers/store'; import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'; import { useSettingsStore } from '../../store/settingsStore'; -// The planner-waypoint and location-search camera cases of MapViewGL, with the same -// map doubles as MapViewGL.test.tsx. +// The planner-waypoint and location-search camera cases of MapViewGL, and the tour +// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx. // Stable fake map so fitBounds call counts survive re-renders. The canvas // container is a single element so listeners registered by the component are @@ -477,4 +477,91 @@ describe('MapViewGL', () => { expect(onSelect).toHaveBeenCalledWith(waypoint.id); expect(onMapClick).not.toHaveBeenCalled(); }); + + const FERRATA = { + id: 'way/42', + name: 'Via delle Trincee', + grade: { raw: '4', level: 4, plus: null }, + routeName: null, + geometry: [ + [46.52, 11.8], + [46.53, 11.81], + ] as [number, number][], + osmUrl: 'https://www.openstreetmap.org/way/42', + }; + const ferrataLayer = (active: boolean) => ({ + active, + onToggle: vi.fn(), + status: active ? ('ready' as const) : ('idle' as const), + ferratas: [FERRATA], + onSelect: vi.fn(), + }); + const addedFerratas = () => glMap.addSource.mock.calls.filter(([id]) => id === 'trek-ferratas'); + + it('RC-10: the via ferrata layer is drawn on the ready map only while it is on, its switch in the tour pill', async () => { + loadOnAttach(); + const off = ferrataLayer(false); + const view = render( + + ); + await act(async () => {}); + expect(addedFerratas()).toHaveLength(0); + const toggle = view.getByRole('button', { name: 'Via ferratas' }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + act(() => toggle.click()); + expect(off.onToggle).toHaveBeenCalledOnce(); + + view.rerender( + + ); + await act(async () => {}); + expect(addedFerratas()).toHaveLength(1); + expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true'); + expect(view.getByTestId('ferrata-status-pill')).toHaveTextContent('1 via ferrata route'); + }); + + it('RC-10: a click on a drawn ferrata opens it and is not a map click, so no control point lands under it', async () => { + loadOnAttach(); + glMap.getLayer.mockImplementation((id: string) => (id === 'trek-ferratas-hit' ? { id } : null)); + glMap.queryRenderedFeatures.mockImplementation((_point: unknown, options?: { layers?: string[] }) => + options?.layers?.includes('trek-ferratas-hit') ? [{ properties: { id: FERRATA.id } }] : [] + ); + const layer = ferrataLayer(true); + const onMapClick = vi.fn(); + render( + + ); + await act(async () => {}); + const mapClicks = glMap.on.mock.calls + .filter(([event, handler]) => event === 'click' && typeof handler === 'function') + .map(([, handler]) => handler as (event: unknown) => void); + expect(mapClicks.length).toBeGreaterThan(0); + const click = { + point: { x: 5, y: 5 }, + lngLat: { lat: 46.52, lng: 11.8 }, + originalEvent: { target: document.createElement('div') }, + }; + act(() => mapClicks.forEach((handler) => handler(click))); + expect(onMapClick).not.toHaveBeenCalled(); + expect(layer.onSelect).toHaveBeenCalledWith(FERRATA, { lat: 46.52, lng: 11.8 }); + }); }); diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 88ffef375..380965f6e 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -32,6 +32,8 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters' import { groupCoincidentPlaces } from './coincidentPlaces' import { useHazardLayerGL } from './useHazardLayerGL' +import { type FerrataGlMap, isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL' +import { formatViaDwell } from './routeViaDwell' import { useDawarichTrailGL } from './useDawarichTrailGL' import { bindDayBoundaryDrag } from './dayBoundaryDrag' import NightPauseTooltip from './NightPauseTooltip' @@ -419,12 +421,6 @@ function buildPluginLayerData(layers: PluginMapLayer[]) { return { type: 'FeatureCollection' as const, features } } -function formatViaDwellGl(seconds: number): string { - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return h > 0 ? `${h} h ${m} min` : `${m} min` -} - /** * A place pin on the map, whatever is carrying it. * @@ -630,7 +626,7 @@ export function MapViewGL({ onPlannerWaypointClick, onPlannerWaypointMove, routeProfileFocus = null, - viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, + viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, roadtripVias, onMoveVia, onRemoveVia, @@ -662,6 +658,7 @@ export function MapViewGL({ const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle) const enableMapbox3d = !isMapLibre && mapbox3d const [mapReady, setMapReady] = useState(false) + const [readyMap, setReadyMap] = useState(null) // the ready map, read without touching mapRef // Hover tooltip — a cursor-following name/category/address card, matching the // Leaflet map's overlay exactly (no anchored popup, no photo thumbnail). const [hoverPlace, setHoverPlace] = useState(null) @@ -688,6 +685,7 @@ export function MapViewGL({ const mapRef = useRef(null) const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl]) useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory) + useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer) // Beneath the planned route's casing, the GL twin of the Leaflet pane order. useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1487,6 +1485,7 @@ export function MapViewGL({ // safely add their own sources. Style rebuilds reset this via the // cleanup below. setMapReady(true) + setReadyMap(map) }) // Layer for the hand-positioned place pins, inside the canvas container so it @@ -1535,6 +1534,7 @@ export function MapViewGL({ && typeof map.queryRenderedFeatures === 'function' && map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0 ) return + if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } }) }) // Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore @@ -1984,7 +1984,7 @@ export function MapViewGL({ el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) }) } if (v.label || v.dwellSeconds != null) { - const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ') + const text = [v.label, v.dwellSeconds != null ? formatViaDwell(v.dwellSeconds) : null].filter(Boolean).join(' · ') el.addEventListener('click', (ev) => { ev.stopPropagation() popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map) @@ -2354,7 +2354,7 @@ export function MapViewGL({ {onToggleFollow && } {hasViewBaseLayer ? + onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} /> : } {/* Hover tooltip — cursor-following name/category/address card, identical to diff --git a/client/src/components/Map/mapViewContract.ts b/client/src/components/Map/mapViewContract.ts index ebeafac75..63cef75a5 100644 --- a/client/src/components/Map/mapViewContract.ts +++ b/client/src/components/Map/mapViewContract.ts @@ -2,6 +2,7 @@ import type { DawarichTrack, RoadtripHazard, RoadtripVia } from '@trek/shared'; import type { Day, Place, Reservation, RouteVia } from '../../types'; import type { ViewportPadding } from '../../utils/mapViewport'; import type { RouteProfileFocus } from '../../utils/routeGeometry'; +import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'; import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'; import type { DayBoundaryControls } from './dayBoundaryDrag'; import type { CompassMap } from './MapCompassPill'; @@ -118,6 +119,12 @@ export interface MapViewProps { onViewBaseLayerChange?: (layer: TourBaseLayer) => void; /** Offers the 3D terrain switch in the tour layer pill. */ onToggleTerrain3d?: () => void; + /** + * The via ferrata layer (RC-10): its switch and status pill join the tour layer + * pill, and while it is on its lines are drawn and a click on one hands the + * ferrata to `onSelect`. Both renderers mount it through the Ferratas modules. + */ + ferrataLayer?: FerrataMapLayer; /** * An explicit stretch of map to frame, independent of the day being shown. * diff --git a/client/src/components/Map/routeViaDwell.test.ts b/client/src/components/Map/routeViaDwell.test.ts new file mode 100644 index 000000000..beb490665 --- /dev/null +++ b/client/src/components/Map/routeViaDwell.test.ts @@ -0,0 +1,17 @@ +// FE-COMP-VIADWELL-001 to FE-COMP-VIADWELL-002: the dwell label both map renderers put on a route via. +import { describe, expect, it } from 'vitest'; + +import { formatViaDwell } from './routeViaDwell'; + +describe('formatViaDwell', () => { + it('FE-COMP-VIADWELL-001: under an hour reads as minutes, rounded', () => { + expect(formatViaDwell(0)).toBe('0 min'); + expect(formatViaDwell(25 * 60 + 29)).toBe('25 min'); + expect(formatViaDwell(25 * 60 + 31)).toBe('26 min'); + }); + + it('FE-COMP-VIADWELL-002: an hour or more splits the hours off', () => { + expect(formatViaDwell(3600)).toBe('1 h 0 min'); + expect(formatViaDwell(2 * 3600 + 15 * 60)).toBe('2 h 15 min'); + }); +}); diff --git a/client/src/components/Map/routeViaDwell.ts b/client/src/components/Map/routeViaDwell.ts new file mode 100644 index 000000000..820085912 --- /dev/null +++ b/client/src/components/Map/routeViaDwell.ts @@ -0,0 +1,11 @@ +/** + * How long a route via (a charging stop, a rest area a plugin route places on + * the drawn day) holds the drive, as both map renderers label it: whole + * minutes, with the hours split off once there are any. One function for both, + * so the Leaflet tooltip and the GL label cannot disagree on the wording. + */ +export function formatViaDwell(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.round((seconds % 3600) / 60); + return h > 0 ? `${h} h ${m} min` : `${m} min`; +} diff --git a/client/src/components/Tours/terrain/TourTerrainMap.tsx b/client/src/components/Tours/terrain/TourTerrainMap.tsx index fe18bdc88..f2512fe0b 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.tsx @@ -1,9 +1,11 @@ +import type { FerrataBbox } from '@trek/shared'; import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react'; import { useMemo, useState, type ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; import type { DistanceUnit } from '../../../types'; import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'; import { formatElevation } from '../../../utils/units'; +import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer'; import { MAP_LAYER_SWITCHER_INSET, TourMapLayerSwitcher, type TourBaseLayer } from '../../Map/MapLayerSwitcher'; import { MAP_CONTROL_SHADOW } from '../../Map/mapControlShadow'; import { SoftPill } from '../../Planner/planParts'; @@ -28,11 +30,13 @@ export interface TourTerrainMapProps { rightWidth: number; waypoints: TerrainWaypoints; framePoints: [number, number][]; + /** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */ + ferrataLayer?: FerrataMapLayer; + /** The visible area, for the via ferrata layer to ask for. */ + onViewportChange?: (bbox: FerrataBbox) => void; } const SPEEDS = [1, 2, 4] as const; -/** Width the layer pill takes with its 3D switch, so the dock starts beside it. */ -const LAYER_PILL_WIDTH = 330; // The planner's map-control glass, the same shell as the layer pill. const GLASS = { @@ -57,31 +61,40 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { {/* MapLibre makes its container position: relative, so it is sized, not inset. */}
- {/* The flat map's layer pill, at the same spot, with 3D switched on. */} -
- -
- - {/* The flyover controls and the grade legend share the bottom edge with the pill, short of the right panel. */} + {/* One row along the bottom edge, between the panels: the flat map's layer pill at + the same spot with 3D switched on (and the via ferrata status beside it), then the + flyover controls and the grade legend. The pill takes the width it needs, so a + longer pill moves the dock along instead of running under it. */}
- - {terrain.canReplay ? ( - - ) : ( -
- -
- )} -
{terrain.canReplay && }
+
+ +
+
+ + {terrain.canReplay ? ( + + ) : ( +
+ +
+ )} +
{terrain.canReplay && }
+
); diff --git a/client/src/components/Tours/terrain/useTerrainFerratas.ts b/client/src/components/Tours/terrain/useTerrainFerratas.ts new file mode 100644 index 000000000..2825a8848 --- /dev/null +++ b/client/src/components/Tours/terrain/useTerrainFerratas.ts @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; + +import type { FerrataBbox } from '@trek/shared'; + +import { type FerrataGlMap, useFerrataMapLayerGL } from '../../Ferratas/useFerrataLayerGL'; +import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer'; +import type { TerrainGl } from './useTerrainMap'; + +/** + * The via ferrata layer on the 3D terrain map, the same layer the flat map + * draws: the visible area goes out through `onViewportChange` once the map + * has loaded and after every move, and the lines are drawn by the shared GL + * hook. MapLibre's map answers every call that hook makes, so it is handed + * over as the GL map type the hook is written against. + */ +export function useTerrainFerratas( + gl: TerrainGl | null, + layer: FerrataMapLayer | undefined, + onViewportChange: ((bbox: FerrataBbox) => void) | undefined +) { + useEffect(() => { + if (!gl || !onViewportChange) return; + const map = gl.map; + const emit = () => { + const bounds = map.getBounds(); + onViewportChange({ + south: bounds.getSouth(), + west: bounds.getWest(), + north: bounds.getNorth(), + east: bounds.getEast(), + }); + }; + map.on('moveend', emit); + if (map.loaded()) emit(); + else map.on('load', emit); + return () => { + map.off('moveend', emit); + map.off('load', emit); + }; + }, [gl, onViewportChange]); + + useFerrataMapLayerGL(gl ? (gl.map as unknown as FerrataGlMap) : null, layer); +} diff --git a/client/src/components/Tours/terrain/useTerrainMap.ts b/client/src/components/Tours/terrain/useTerrainMap.ts index 05c768fb9..70d9abe7e 100644 --- a/client/src/components/Tours/terrain/useTerrainMap.ts +++ b/client/src/components/Tours/terrain/useTerrainMap.ts @@ -1,5 +1,6 @@ import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl'; import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; +import { isFerrataHit } from '../../Ferratas/useFerrataLayerGL'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; import { addTerrainLayers, @@ -67,8 +68,12 @@ function createTerrainMap( maxTileCacheSize: 2000, attributionControl: { compact: true }, }); - // Clicking the terrain adds a control point, the same as on the flat planner map. - map.on('click', (event) => scene.current.onAdd?.(event.lngLat.lat, event.lngLat.lng)); + // Clicking the terrain adds a control point, the same as on the flat planner map; + // a click on a via ferrata opens its card instead. + map.on('click', (event) => { + if (isFerrataHit(map, event.point)) return; + scene.current.onAdd?.(event.lngLat.lat, event.lngLat.lng); + }); map.on('style.load', () => { addTerrainLayers(map, scene.current.data, scene.current.exaggeration); handlers.onReady(); diff --git a/client/src/components/Tours/terrain/useTourTerrain.ts b/client/src/components/Tours/terrain/useTourTerrain.ts index d45ba76c2..d1040a489 100644 --- a/client/src/components/Tours/terrain/useTourTerrain.ts +++ b/client/src/components/Tours/terrain/useTourTerrain.ts @@ -1,7 +1,10 @@ +import type { FerrataBbox } from '@trek/shared'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'; +import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer'; import type { TourBaseLayer } from '../../Map/MapLayerSwitcher'; import { trackData, type PanelWidths, type TrackColorMode } from './terrainLayers'; +import { useTerrainFerratas } from './useTerrainFerratas'; import { frameCamera, useTerrainMap, type TerrainScene } from './useTerrainMap'; import { useTerrainPins, type TerrainWaypoints } from './useTerrainPins'; import { useTerrainPrefetch } from './useTerrainPrefetch'; @@ -20,6 +23,10 @@ interface Options { waypoints: TerrainWaypoints; /** What the flat map frames, [lat, lng]; the 3D view starts there while there is no route. */ framePoints: [number, number][]; + /** The via ferrata layer of the planner map, drawn here too while it is on. */ + ferrataLayer?: FerrataMapLayer; + /** The visible area, for the via ferrata layer to ask for. */ + onViewportChange?: (bbox: FerrataBbox) => void; } /** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */ @@ -53,6 +60,7 @@ export function useTourTerrain(options: Options) { const { setContainer, gl, ready, styleGeneration } = useTerrainMap({ scene, baseLayer, data, exaggeration }); const preloaded = useTerrainPrefetch(gl?.map ?? null, styleGeneration, samples); useTerrainPins(gl, ready, waypoints, handlers); + useTerrainFerratas(gl, options.ferrataLayer, options.onViewportChange); const replay = useTerrainReplay({ gl, ready, samples, focus, onFocusChange, scene, preloaded }); const { stop } = replay; diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 10628ad07..db7775bc1 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -1,6 +1,8 @@ -// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-005 +// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-009 +import type { Ferrata } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; +import { useTripFerratas } from '../../components/Ferratas/useTripFerratas'; import type { MapViewProps } from '../../components/Map/mapViewContract'; import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage'; @@ -14,6 +16,14 @@ const stage = vi.hoisted(() => { return map; }), once: vi.fn(() => map), + off: vi.fn(() => map), + loaded: vi.fn(() => false), + getBounds: vi.fn(() => ({ + getSouth: () => 46.5, + getWest: () => 11.7, + getNorth: () => 46.6, + getEast: () => 11.9, + })), remove: vi.fn(), fitBounds: vi.fn(), setTerrain: vi.fn(), @@ -47,6 +57,20 @@ vi.mock('../../components/Map/MapViewAuto', () => ({ }, })); vi.mock('../../components/Planner/LocationSelect', () => ({ default: () => })); +// What the layer asks the server is useTripFerratas' own suite; here it answers from memory. +vi.mock('../../components/Ferratas/useTripFerratas', () => ({ useTripFerratas: vi.fn() })); + +const FERRATA: Ferrata = { + id: 'way/42', + name: 'Via delle Trincee', + grade: { raw: '4', level: 4, plus: null }, + routeName: null, + geometry: [ + [46.52, 11.8], + [46.53, 11.81], + ], + osmUrl: 'https://www.openstreetmap.org/way/42', +}; type Planner = TourPlannerMapStageProps['tourPlanner']; @@ -99,6 +123,12 @@ beforeEach(() => { stage.listeners.clear(); stage.flatMap.props = null; vi.clearAllMocks(); + vi.mocked(useTripFerratas).mockImplementation((_tripId, _bbox, enabled) => ({ + ferratas: enabled ? [FERRATA] : [], + status: enabled ? 'ready' : 'idle', + truncated: false, + attribution: null, + })); vi.stubGlobal( 'fetch', vi.fn(() => new Promise(() => {})) @@ -165,4 +195,62 @@ describe('TourPlannerMapStage', () => { expect(screen.getByRole('progressbar', { name: 'Loading the landscape: 0 %' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Grade mix' })).toBeInTheDocument(); }); + + it('FE-PAGE-TOURSTAGE-006: the flat map gets the via ferrata layer switched off, and nothing is asked for', () => { + renderStage(planner('view-saved')); + const props = stage.flatMap.props; + expect(props?.ferrataLayer?.active).toBe(false); + expect(props?.ferrataLayer?.status).toBe('idle'); + expect(typeof props?.onViewportChange).toBe('function'); + expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([7, null, false]); + }); + + it('FE-PAGE-TOURSTAGE-007: switched on, the layer asks for the area the flat map reported and draws what it found', () => { + renderStage(planner('view-saved')); + const area = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 }; + act(() => stage.flatMap.props?.onViewportChange?.(area)); + act(() => stage.flatMap.props?.ferrataLayer?.onToggle()); + + expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([7, area, true]); + expect(stage.flatMap.props?.ferrataLayer?.active).toBe(true); + expect(stage.flatMap.props?.ferrataLayer?.ferratas).toEqual([FERRATA]); + }); + + it('FE-PAGE-TOURSTAGE-008: a picked ferrata opens its card bottom right, clear of the right panel, and Escape closes it', () => { + renderStage(planner('view-saved')); + act(() => stage.flatMap.props?.ferrataLayer?.onToggle()); + expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull(); + + act(() => stage.flatMap.props?.ferrataLayer?.onSelect(FERRATA, { lat: 46.52, lng: 11.8 })); + const overlay = screen.getByTestId('ferrata-detail-overlay'); + // The right panel's 380px plus the 20px the layer pill keeps to the left one. + expect(overlay.style.right).toBe('400px'); + // Above the bottom row: its 12px gap, the 42px pill, the same gap again. + expect(overlay.style.bottom).toBe('66px'); + expect(overlay).toHaveTextContent('Via delle Trincee'); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull(); + }); + + it('FE-PAGE-TOURSTAGE-009: in 3D the switch sits in the terrain layer pill, the layer stays on and the terrain reports its area', async () => { + renderStage(planner('view-saved')); + act(() => stage.flatMap.props?.ferrataLayer?.onToggle()); + await openTerrain(); + + const toggle = screen.getByRole('button', { name: 'Via ferratas' }); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('ferrata-status-pill')).toHaveTextContent('1 via ferrata route'); + + act(() => stage.fire('load')); + expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([ + 7, + { south: 46.5, west: 11.7, north: 46.6, east: 11.9 }, + true, + ]); + + fireEvent.click(toggle); + expect(screen.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.queryByTestId('ferrata-status-pill')).toBeNull(); + }); }); diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 16e6f2146..5f9dc3baa 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -1,4 +1,7 @@ import { lazy, Suspense, useState, type ReactElement } from 'react'; +import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay'; +import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer'; +import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher'; import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto'; import LocationSelect from '../../components/Planner/LocationSelect'; import { useSettingsStore } from '../../store/settingsStore'; @@ -10,6 +13,10 @@ const TourTerrainMap = lazy(() => import('../../components/Tours/terrain/TourTer /** The route blue the planner draws a tour in. */ const TOUR_TRACK_COLOR = '#0a84ff'; +/** The gap both maps keep between the bottom edge, their row of controls and what floats above it. */ +const CONTROL_GAP = 12; +/** A via ferrata's card floats above the bottom row of controls, at the gap the row keeps to the edge. */ +const FERRATA_CARD_BOTTOM = CONTROL_GAP + MAP_ROUND_CONTROL_SIZE + CONTROL_GAP; type Planner = ReturnType; type PageState = ReturnType; @@ -42,6 +49,8 @@ export function TourPlannerMapStage({ rightPanelPx, }: Readonly): ReactElement { const [terrain3d, setTerrain3d] = useState(false); + // One via ferrata layer for both modes: it stays on, and keeps its card, across 2D and 3D. + const ferrata = useFerrataMapLayer(tripId); const distanceUnit = useSettingsStore((s) => s.settings.distance_unit); const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis; const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'; @@ -74,6 +83,8 @@ export function TourPlannerMapStage({ rightWidth={rightInset} waypoints={waypoints} framePoints={tourMap.focusPoints} + ferrataLayer={ferrata.layer} + onViewportChange={ferrata.onViewportChange} /> ) : ( @@ -101,8 +112,16 @@ export function TourPlannerMapStage({ viewBaseLayer={tourPlanner.mapBaseLayer} onViewBaseLayerChange={tourPlanner.setMapBaseLayer} onToggleTerrain3d={() => setTerrain3d(true)} + ferrataLayer={ferrata.layer} + onViewportChange={ferrata.onViewportChange} /> )} + {/* Centred over the map between the two panels, which sit on fixed physical sides. */}
({ isEffectivelyOffline: vi.fn(() => false) })); +vi.mock('../api/ferratas', () => ({ ferratasApi: { list: vi.fn() } })); + +const BBOX = '46.500,11.700,46.600,11.900'; +const reply = { + ferratas: [ + { + id: 'way/42', + name: 'Via delle Trincee', + grade: { raw: '4', level: 4, plus: null }, + routeName: null, + geometry: [ + [46.52, 11.8], + [46.53, 11.81], + ], + osmUrl: 'https://www.openstreetmap.org/way/42', + }, + ], + truncated: false, + source: 'openstreetmap', + attribution: '© OpenStreetMap contributors', +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isEffectivelyOffline).mockReturnValue(false); + vi.mocked(ferratasApi.list).mockResolvedValue(reply); +}); + +describe('ferrataRepo.list', () => { + it('FE-REPO-FERRATA-001: asks the api for the trip and box with the signal and returns the parsed answer', async () => { + const signal = new AbortController().signal; + await expect(ferrataRepo.list(7, BBOX, signal)).resolves.toEqual(reply); + expect(ferratasApi.list).toHaveBeenCalledWith(7, BBOX, signal); + }); + + it('FE-REPO-FERRATA-002: offline it refuses without making the request', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + await expect(ferrataRepo.list(7, BBOX, new AbortController().signal)).rejects.toThrow('connection'); + expect(ferratasApi.list).not.toHaveBeenCalled(); + }); + + it('FE-REPO-FERRATA-003: an answer off the contract is rejected, not drawn', async () => { + vi.mocked(ferratasApi.list).mockResolvedValue({ ...reply, ferratas: [{ ...reply.ferratas[0], id: 'node/1' }] }); + await expect(ferrataRepo.list(7, BBOX, new AbortController().signal)).rejects.toThrow(); + vi.mocked(ferratasApi.list).mockResolvedValue({ + ...reply, + ferratas: [{ ...reply.ferratas[0], geometry: [[46.5, 11.7]] }], + }); + await expect(ferrataRepo.list(7, BBOX, new AbortController().signal)).rejects.toThrow(); + }); +}); diff --git a/client/src/repo/ferrataRepo.ts b/client/src/repo/ferrataRepo.ts new file mode 100644 index 000000000..1b31dc6b5 --- /dev/null +++ b/client/src/repo/ferrataRepo.ts @@ -0,0 +1,24 @@ +import { type FerratasResponse, ferratasResponseSchema } from '@trek/shared'; + +import { ferratasApi } from '../api/ferratas'; +import { isEffectivelyOffline } from '../sync/networkMode'; + +/** + * Via ferratas from OpenStreetMap, for the map layer. + * + * **Deliberately online-only, and no Dexie table.** The answer is a live read + * of OpenStreetMap through Overpass for whatever area the map shows; a cached + * copy would be stale data for an area the user has long panned away from, and + * the server keeps nothing either. Same shape and reason as + * `roadtripHazardsRepo` and `pluginPoiRepo`. + * + * Offline (by circumstance or by the manual switch) the request is not made at + * all. The answer is parsed against the shared contract rather than cast, + * because its names and grades go on to be drawn on the map. + */ +export const ferrataRepo = { + async list(tripId: number, bbox: string, signal: AbortSignal): Promise { + if (isEffectivelyOffline()) throw new Error('Via ferratas need a connection'); + return ferratasResponseSchema.parse(await ferratasApi.list(tripId, bbox, signal)); + }, +}; diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index accbcb033..46b2b342e 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -42,6 +42,7 @@ import { DayNotesModule } from './day-notes/day-notes.module'; import { DaysModule } from './days/days.module'; import { DocSyncModule } from './doc-sync/doc-sync.module'; import { FeedsModule } from './feeds/feeds.module'; +import { FerratasModule } from './ferratas/ferratas.module'; import { FilesModule } from './files/files.module'; import { GeoModule } from './geo/geo.module'; import { GoogleQuotaModule } from './google-quota/google-quota.module'; @@ -151,6 +152,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; PlacesModule, RoadtripModule, ToursModule, + FerratasModule, TripsModule, CollabModule, FilesModule, diff --git a/server/src/nest/ferratas/ferratas-overpass.client.ts b/server/src/nest/ferratas/ferratas-overpass.client.ts new file mode 100644 index 000000000..0eb7f78d1 --- /dev/null +++ b/server/src/nest/ferratas/ferratas-overpass.client.ts @@ -0,0 +1,71 @@ +import { getAppUrl } from '../../app-config'; +import { readCappedJson } from '../../utils/cappedFetch'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { logWarn } from '../audit/audit-log.logger'; +import { buildUserAgent, parseOverpassEndpoints } from '../maps/overpass.constants'; +import { overpassFerrataAnswerSchema } from './ferratas.helpers'; +import { Injectable } from '@nestjs/common'; + +/** + * The outbound half of the via ferrata lookup: one Overpass query raced across + * the mirrors the operator configured. + * + * It reads the same settings the maps domain's Overpass client reads + * (OVERPASS_URL, a comma-separated list that replaces the public mirrors, and + * OVERPASS_TIMEOUT_MS), and takes the mirror list and the User-Agent from the + * maps domain's public overpass.constants, so a firewalled install that points + * TREK at its own Overpass is never asked to reach a public one from here + * either. The query code itself is not shared: maps keeps its fetch private. + */ + +// A box of half a degree in the Alps answers in tens of kilobytes; eight +// megabytes means the mirror is misbehaving, not that the mountains got bigger. +const MAX_ANSWER_BYTES = 8 * 1024 * 1024; + +/** Why every mirror failed, one reason per mirror, for the log line. */ +function reasons(err: unknown): string { + const all = err instanceof AggregateError ? err.errors : [err]; + return all.map((e) => (e instanceof Error ? e.message : String(e))).join(' | '); +} + +@Injectable() +export class FerratasOverpassClient { + constructor(private readonly env: RuntimeEnvService) {} + + /** + * The raw elements of the first mirror that answers with a well-formed OSM + * document. A `remark` (Overpass' own timeout or runtime error, sent with a + * 200) counts as a failure, so a fast but empty answer cannot win the race. + * Throws once every mirror failed; the caller decides what the client sees. + */ + async query(query: string): Promise { + const { overpassUrl, overpassTimeoutMs } = this.env.env().integrations; + const endpoints = parseOverpassEndpoints(overpassUrl); + const body = `data=${encodeURIComponent(query)}`; + const headers = { 'User-Agent': buildUserAgent(getAppUrl()), 'Content-Type': 'application/x-www-form-urlencoded' }; + const race = new AbortController(); + + const attempt = async (url: string): Promise => { + const signal = AbortSignal.any([race.signal, AbortSignal.timeout(overpassTimeoutMs)]); + const res = await fetch(url, { method: 'POST', headers, body, signal }); + if (!res.ok) { + await res.body?.cancel().catch(() => {}); + throw new Error(`Overpass ${res.status} @ ${url}`); + } + const answer = overpassFerrataAnswerSchema.safeParse(await readCappedJson(res, MAX_ANSWER_BYTES)); + if (!answer.success) throw new Error(`Overpass non-OSM or oversized body @ ${url}`); + if (answer.data.remark) throw new Error(`Overpass remark @ ${url}: ${answer.data.remark}`); + return answer.data.elements; + }; + + try { + return await Promise.any(endpoints.map(attempt)); + } catch (err) { + logWarn(`[Ferratas] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`); + throw new Error('Could not reach any Overpass endpoint', { cause: err }); + } finally { + // The losers are cancelled once one mirror answered (or all gave up). + race.abort(); + } + } +} diff --git a/server/src/nest/ferratas/ferratas.controller.ts b/server/src/nest/ferratas/ferratas.controller.ts new file mode 100644 index 000000000..505f99a5b --- /dev/null +++ b/server/src/nest/ferratas/ferratas.controller.ts @@ -0,0 +1,46 @@ +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { badRequest } from '../common/domain-error'; +import { ResponseContract } from '../common/response-contract'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { FerratasService } from './ferratas.service'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + FERRATA_BBOX_INVALID_MESSAGE, + FERRATA_BBOX_TOO_LARGE_MESSAGE, + ferratasQuerySchema, + ferratasResponseSchema, + type FerratasQuery, + type FerratasResponse, +} from '@trek/shared'; + +/** + * The query through the shared contract, answered with one of two messages: + * a box that is too large is a different fix for the client (zoom in) than + * one that is malformed. + */ +export function parseFerratasQuery(raw: Record): FerratasQuery { + const parsed = ferratasQuerySchema.safeParse(raw); + if (parsed.success) return parsed.data; + const tooLarge = parsed.error.issues.some((issue) => issue.message === FERRATA_BBOX_TOO_LARGE_MESSAGE); + throw badRequest(tooLarge ? FERRATA_BBOX_TOO_LARGE_MESSAGE : FERRATA_BBOX_INVALID_MESSAGE); +} + +/** + * GET /api/trips/:tripId/ferratas?bbox=south,west,north,east: the via ferratas + * OpenStreetMap knows inside the box, for the trip's map. + * + * Trip-scoped like the other trip reads: TripAccessGuard answers 404 for a trip + * the caller cannot reach, and reading needs no permission beyond access. The + * box is the caller's viewport; nothing about the trip is sent to Overpass. + */ +@Controller('api/trips/:tripId/ferratas') +@UseGuards(JwtAuthGuard, TripAccessGuard) +export class FerratasController { + constructor(private readonly ferratas: FerratasService) {} + + @Get() + @ResponseContract(ferratasResponseSchema) + async list(@Query() raw: Record): Promise { + return await this.ferratas.list(parseFerratasQuery(raw).bbox); + } +} diff --git a/server/src/nest/ferratas/ferratas.helpers.ts b/server/src/nest/ferratas/ferratas.helpers.ts new file mode 100644 index 000000000..00971dd7c --- /dev/null +++ b/server/src/nest/ferratas/ferratas.helpers.ts @@ -0,0 +1,172 @@ +import { FERRATAS_RESULT_CAP, type Ferrata, type FerrataBbox, type FerrataGrade } from '@trek/shared'; + +import { z } from 'zod'; + +/** + * Pure pieces of the via ferrata lookup: the Overpass query, the cache key, the + * grade parser and the mapping from Overpass elements to the wire rows. Kept + * free of I/O so the service and its tests share them. + */ + +/** How long Overpass itself may work on one query, in seconds (`[timeout:N]`). */ +export const FERRATA_QUERY_TIMEOUT_S = 15; + +/** The grid the box is snapped outwards to, so nearby requests share one cache entry. */ +const GRID_DEG = 0.05; + +const GRADE_RE = /^([0-6])([+-])?$/; +const RAW_GRADE_MAX = 64; +const NAME_MAX = 500; + +/** + * `via_ferrata_scale` as tagged. Only the OSM 0 to 6 scale, with an optional + * `+` or `-`, gets a level; a letter scale ("C/D") or free text keeps its raw + * value and reads as unknown. Letters are deliberately not converted. + */ +export function parseFerrataGrade(raw: string | undefined): FerrataGrade { + const value = raw?.trim() ?? ''; + if (value === '') return { raw: null, level: null, plus: null }; + const match = GRADE_RE.exec(value); + if (!match) return { raw: value.slice(0, RAW_GRADE_MAX), level: null, plus: null }; + const sign = match[2]; + return { raw: value, level: Number(match[1]), plus: sign === '+' || sign === '-' ? sign : null }; +} + +const snapDown = (v: number) => Math.floor(v / GRID_DEG + 1e-9) * GRID_DEG; +const snapUp = (v: number) => Math.ceil(v / GRID_DEG - 1e-9) * GRID_DEG; +const fixed = (v: number) => Number(v.toFixed(2)); + +/** + * The box grown outwards to the 0.05 degree grid and clamped to the globe. + * The query asks for the snapped box, so a cache entry always covers every + * request that maps onto it. + */ +export function snapFerrataBbox(bbox: FerrataBbox): FerrataBbox { + return { + south: fixed(Math.max(-90, snapDown(bbox.south))), + west: fixed(Math.max(-180, snapDown(bbox.west))), + north: fixed(Math.min(90, snapUp(bbox.north))), + east: fixed(Math.min(180, snapUp(bbox.east))), + }; +} + +export function ferrataCacheKey(snapped: FerrataBbox): string { + return `${snapped.south},${snapped.west},${snapped.north},${snapped.east}`; +} + +/** + * Ways tagged as a via ferrata, paths and footways carrying a + * `via_ferrata_scale`, and the member ways of `route=via_ferrata` relations, + * all inside the box, with their geometry. The relations go out with their + * members so a way can be given its route's name. One more way than the cap + * is asked for, which is how the answer knows it was cut. + */ +export function buildFerrataQuery(bbox: FerrataBbox): string { + const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`; + return [ + `[out:json][timeout:${FERRATA_QUERY_TIMEOUT_S}];`, + `rel["route"="via_ferrata"]${box}->.routes;`, + '(', + ` way["highway"="via_ferrata"]${box};`, + ` way["highway"~"^(path|footway)$"]["via_ferrata_scale"]${box};`, + ` way(r.routes)${box};`, + ')->.ways;', + `.ways out geom qt ${FERRATAS_RESULT_CAP + 1};`, + '.routes out body qt;', + ].join('\n'); +} + +const osmId = z.number().int().positive(); +const tags = z.record(z.string(), z.string()).optional(); +const point = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) }); + +const wayElementSchema = z.object({ + type: z.literal('way'), + id: osmId, + tags, + // A point Overpass could not place comes back as null; it is dropped below. + geometry: z.array(point.nullable()).max(20000).optional(), +}); +const relationElementSchema = z.object({ + type: z.literal('relation'), + id: osmId, + tags, + members: z + .array(z.object({ type: z.string(), ref: z.number().int() })) + .max(10000) + .optional(), +}); +const elementSchema = z.discriminatedUnion('type', [wayElementSchema, relationElementSchema]); +type WayElement = z.infer; +type RelationElement = z.infer; + +/** What an Overpass mirror answers with, at the boundary. */ +export const overpassFerrataAnswerSchema = z.object({ + elements: z.array(z.unknown()).max(50000), + remark: z.string().optional(), +}); + +function cleanName(value: string | undefined): string | null { + const name = value?.trim() ?? ''; + return name === '' ? null : name.slice(0, NAME_MAX); +} + +/** way id -> the name of the first named via ferrata route that holds it. */ +function routeNamesByWay(relations: RelationElement[]): Map { + const names = new Map(); + for (const relation of relations) { + const name = cleanName(relation.tags?.name); + if (!name) continue; + for (const member of relation.members ?? []) { + if (member.type === 'way' && !names.has(member.ref)) names.set(member.ref, name); + } + } + return names; +} + +function toFerrata(way: WayElement, routeName: string | null): Ferrata | null { + const geometry = (way.geometry ?? []) + .filter((p): p is z.infer => p !== null) + .map((p): [number, number] => [p.lat, p.lon]); + if (geometry.length < 2) return null; + return { + id: `way/${way.id}`, + name: cleanName(way.tags?.name), + grade: parseFerrataGrade(way.tags?.via_ferrata_scale), + routeName, + geometry, + osmUrl: `https://www.openstreetmap.org/way/${way.id}`, + }; +} + +/** + * The wire rows out of the raw elements. An element that does not have the + * shape asked for is left out rather than failing the whole answer, and so is + * a way with fewer than two placed points: neither can be drawn. + * + * `truncated` counts the way elements Overpass sent, before any of that + * filtering: the query asks for one past the cap, so a box that hit Overpass's + * limit stays marked as cut even when a dropped way brings the rows under it. + */ +export function toFerratas(elements: unknown[]): { ferratas: Ferrata[]; truncated: boolean } { + const ways: WayElement[] = []; + const relations: RelationElement[] = []; + let rawWays = 0; + for (const raw of elements) { + if ((raw as { type?: unknown } | null)?.type === 'way') rawWays += 1; + const parsed = elementSchema.safeParse(raw); + if (!parsed.success) continue; + if (parsed.data.type === 'way') ways.push(parsed.data); + else relations.push(parsed.data); + } + const routeNames = routeNamesByWay(relations); + const seen = new Set(); + const ferratas: Ferrata[] = []; + for (const way of ways) { + if (seen.has(way.id)) continue; + seen.add(way.id); + const ferrata = toFerrata(way, routeNames.get(way.id) ?? null); + if (ferrata) ferratas.push(ferrata); + } + return { ferratas: ferratas.slice(0, FERRATAS_RESULT_CAP), truncated: rawWays > FERRATAS_RESULT_CAP }; +} diff --git a/server/src/nest/ferratas/ferratas.module.ts b/server/src/nest/ferratas/ferratas.module.ts new file mode 100644 index 000000000..72dcd4a1b --- /dev/null +++ b/server/src/nest/ferratas/ferratas.module.ts @@ -0,0 +1,17 @@ +import { PermissionsModule } from '../permissions/permissions.module'; +import { FerratasOverpassClient } from './ferratas-overpass.client'; +import { FerratasController } from './ferratas.controller'; +import { FerratasService } from './ferratas.service'; +import { Module } from '@nestjs/common'; + +/** + * Via ferratas around a trip, read from OpenStreetMap through Overpass. + * PermissionsModule brings TripAccessGuard; RuntimeEnvService (the Overpass + * endpoints and deadline) comes from the global AppConfigModule. + */ +@Module({ + imports: [PermissionsModule], + controllers: [FerratasController], + providers: [FerratasService, FerratasOverpassClient], +}) +export class FerratasModule {} diff --git a/server/src/nest/ferratas/ferratas.service.ts b/server/src/nest/ferratas/ferratas.service.ts new file mode 100644 index 000000000..edf20498b --- /dev/null +++ b/server/src/nest/ferratas/ferratas.service.ts @@ -0,0 +1,99 @@ +import { DomainError } from '../common/domain-error'; +import { FerratasOverpassClient } from './ferratas-overpass.client'; +import { buildFerrataQuery, ferrataCacheKey, snapFerrataBbox, toFerratas } from './ferratas.helpers'; +import { Injectable } from '@nestjs/common'; +import { FERRATAS_ATTRIBUTION, FERRATAS_UNAVAILABLE_CODE, type FerrataBbox, type FerratasResponse } from '@trek/shared'; + +/** Via ferratas change on the scale of seasons; a day keeps Overpass out of every map pan. */ +export const FERRATA_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** The most boxes held at once; the oldest goes first, so roaming the globe cannot grow the map without bound. */ +export const FERRATA_CACHE_MAX = 300; + +export const FERRATAS_UNAVAILABLE_MESSAGE = 'Via ferrata data is unavailable right now'; + +interface CacheEntry { + at: number; + value: FerratasResponse; +} + +/** + * The via ferratas inside a box, from OpenStreetMap. + * + * Read-only and per process: answers are cached by the box snapped to a 0.05 + * degree grid, and concurrent requests for the same snapped box share one + * Overpass call. A failure is never cached, so the next request tries again; + * it reaches the client as a 502 `{ error, code }` it can show, never as a + * 500 with a stack. + */ +@Injectable() +export class FerratasService { + private readonly cache = new Map(); + private readonly pending = new Map>(); + + constructor(private readonly overpass: FerratasOverpassClient) {} + + /** The via ferratas that reach into `bbox`; the cache holds the wider snapped box. */ + async list(bbox: FerrataBbox, now: number = Date.now()): Promise { + return withinBbox(await this.listSnapped(bbox, now), bbox); + } + + private async listSnapped(bbox: FerrataBbox, now: number): Promise { + const snapped = snapFerrataBbox(bbox); + const key = ferrataCacheKey(snapped); + const hit = this.cache.get(key); + if (hit && now - hit.at < FERRATA_CACHE_TTL_MS) return hit.value; + if (hit) this.cache.delete(key); + + const inFlight = this.pending.get(key); + if (inFlight !== undefined) return inFlight; + // Memoised in-flight load, not a missing await: concurrent callers join the + // promise above, and this frame awaits it below. + const load = this.load(snapped); + this.pending.set(key, load); + try { + const value = await load; + this.remember(key, { at: now, value }); + return value; + } finally { + this.pending.delete(key); + } + } + + private async load(snapped: FerrataBbox): Promise { + let elements: unknown[]; + try { + elements = await this.overpass.query(buildFerrataQuery(snapped)); + } catch { + // The client already logged why each mirror failed. + throw new DomainError(502, FERRATAS_UNAVAILABLE_MESSAGE, { + code: FERRATAS_UNAVAILABLE_CODE, + details: { code: FERRATAS_UNAVAILABLE_CODE }, + }); + } + const { ferratas, truncated } = toFerratas(elements); + return { ferratas, truncated, source: 'openstreetmap', attribution: FERRATAS_ATTRIBUTION }; + } + + private remember(key: string, entry: CacheEntry): void { + this.cache.delete(key); + while (this.cache.size >= FERRATA_CACHE_MAX) { + const oldest = this.cache.keys().next(); + if (oldest.done) break; + this.cache.delete(oldest.value); + } + this.cache.set(key, entry); + } +} + +/** A ferrata belongs to the box when any point of its line lies inside it. */ +function withinBbox(response: FerratasResponse, bbox: FerrataBbox): FerratasResponse { + const inside = ([lat, lng]: readonly unknown[]) => + typeof lat === 'number' && + typeof lng === 'number' && + lat >= bbox.south && + lat <= bbox.north && + lng >= bbox.west && + lng <= bbox.east; + return { ...response, ferratas: response.ferratas.filter((ferrata) => ferrata.geometry.some(inside)) }; +} diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index b3d6367d9..62e3850d3 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -1,6 +1,7 @@ import { readEnv, getAppUrl } from '../../app-config'; import { haversineMetres } from '../common/geo'; import { stripHtmlTags } from '../common/stripHtmlTags'; +import { buildUserAgent, parseOverpassEndpoints } from './overpass.constants'; import { normalizePlaceWebsite } from '@trek/shared'; import { z } from 'zod'; @@ -15,15 +16,10 @@ import { z } from 'zod'; * parser cases). */ -// Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent that -// uniquely identifies the deploying instance — a shared, generic UA gets rate-limited -// and throttled harder (see #1309). When the instance URL is configured we append it; -// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it. -export function buildUserAgent(instanceUrl: string | undefined): string { - const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)'; - if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`; - return base; -} +// The User-Agent rule (instance URL appended, the localhost fallback dropped) lives in +// overpass.constants.ts, the part of maps other domains may import; it is re-exported +// here for the callers that always read it from this module. +export { buildUserAgent }; // Computed once at load — getAppUrl() reads only env vars, which don't change at runtime. export const UA = buildUserAgent(getAppUrl()); @@ -444,35 +440,11 @@ export function parsePoiCategories(raw: string): string[] { return [...seen]; } -// Public Overpass mirrors, queried in PARALLEL (first valid response wins). -// Reachability and load vary a lot by network/region — the canonical instance is -// frequently overloaded (504s) and some community mirrors are unreachable from -// certain networks. Racing them means whichever mirror is fastest-reachable for -// this user answers, and an overloaded or blocked one never blocks the others. -const DEFAULT_OVERPASS_MIRRORS = [ - 'https://overpass-api.de/api/interpreter', - 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', - 'https://overpass.kumi.systems/api/interpreter', - 'https://overpass.private.coffee/api/interpreter', -]; - -// Operators behind locked-down egress — or running their own Overpass — can point TREK -// at one or more custom endpoints via OVERPASS_URL (comma-separated). When set it -// REPLACES the public mirrors, so a firewalled cluster never reaches out to them and a -// self-hosted instance is used exclusively (see #1309). Non-http(s) entries are dropped. +// OVERPASS_URL as the endpoint list (it REPLACES the public mirrors, see #1309), read +// live by default. The parsing and the mirror list are shared with the via ferrata +// lookup through overpass.constants.ts. export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] { - const custom = (raw ?? '') - .split(',') - .map((s) => s.trim()) - .filter((s) => { - try { - const u = new URL(s); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; - } - }); - return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS; + return parseOverpassEndpoints(raw); } // Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case diff --git a/server/src/nest/maps/overpass.constants.ts b/server/src/nest/maps/overpass.constants.ts new file mode 100644 index 000000000..e824bc5d7 --- /dev/null +++ b/server/src/nest/maps/overpass.constants.ts @@ -0,0 +1,60 @@ +/** + * What every outbound Overpass caller shares: the public mirrors, how + * OVERPASS_URL replaces them, and the User-Agent the mirrors ask for. + * + * The maps domain owns Overpass, but the via ferrata lookup races the same + * mirrors under the same operator settings. This file is the part of maps + * other domains may import (a `.constants.ts` is public surface for + * lint:boundaries), so the list lives once instead of drifting in two copies. + * Pure on purpose: no env read, no Nest, the caller hands in the raw values. + */ + +/** + * Public Overpass mirrors, queried in PARALLEL (first valid response wins). + * Reachability and load vary a lot by network and region: the canonical + * instance is frequently overloaded (504s) and some community mirrors are + * unreachable from certain networks. Racing them means whichever mirror is + * fastest-reachable for this user answers, and an overloaded or blocked one + * never blocks the others. + */ +const DEFAULT_OVERPASS_MIRRORS: readonly string[] = [ + 'https://overpass-api.de/api/interpreter', + 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', + 'https://overpass.kumi.systems/api/interpreter', + 'https://overpass.private.coffee/api/interpreter', +]; + +/** + * OVERPASS_URL as a list of http(s) endpoints, or the public mirrors when it + * names none. Operators behind locked-down egress, or running their own + * Overpass, point TREK at one or more endpoints (comma-separated); when set it + * REPLACES the public mirrors, so a firewalled cluster never reaches out to + * them (see #1309). Entries that are not http(s) URLs are dropped. + */ +export function parseOverpassEndpoints(raw: string | undefined): string[] { + const custom = (raw ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => { + try { + const url = new URL(s); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }); + return custom.length > 0 ? custom : [...DEFAULT_OVERPASS_MIRRORS]; +} + +/** + * Overpass, Nominatim and Wikimedia all ask that requests carry a User-Agent + * that uniquely identifies the deploying instance: a shared, generic UA gets + * rate-limited and throttled harder (see #1309). When the instance URL is + * configured it is appended; getAppUrl()'s bare http://localhost fallback is + * not a useful identifier, so it is dropped. + */ +export function buildUserAgent(instanceUrl: string | undefined): string { + const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)'; + if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`; + return base; +} diff --git a/server/tests/e2e/ferratas.e2e.test.ts b/server/tests/e2e/ferratas.e2e.test.ts new file mode 100644 index 000000000..6dad639cc --- /dev/null +++ b/server/tests/e2e/ferratas.e2e.test.ts @@ -0,0 +1,176 @@ +/** + * Ferratas e2e: GET /api/trips/:tripId/ferratas through the real JwtAuthGuard and + * TripAccessGuard over a migrated snapshot db. Only Overpass is faked, by stubbing + * the global fetch; the response contract is checked on every 200 (NODE_ENV=test). + */ +import { AppConfigModule } from '../../src/nest/app-config/app-config.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { FerratasModule } from '../../src/nest/ferratas/ferratas.module'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { makeTrip } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; +import { Test } from '@nestjs/testing'; +import { + FERRATA_BBOX_INVALID_MESSAGE, + FERRATA_BBOX_TOO_LARGE_MESSAGE, + FERRATAS_ATTRIBUTION, + FERRATAS_UNAVAILABLE_CODE, +} from '@trek/shared'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); + +const overpassAnswer = { + elements: [ + { + type: 'way', + id: 4242, + tags: { highway: 'via_ferrata', name: 'Ferrata Test', via_ferrata_scale: '4-' }, + geometry: [ + { lat: 46.51, lon: 12.06 }, + { lat: 46.52, lon: 12.07 }, + ], + }, + { + type: 'relation', + id: 7, + tags: { route: 'via_ferrata', name: 'Alta Via' }, + members: [{ type: 'way', ref: 4242 }], + }, + ], +}; + +describe('Ferratas e2e (real guards + snapshot SQLite)', () => { + let server: Server; + let app: Awaited>; + let ownerId: number; + let strangerId: number; + let tripId: number; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + AppConfigModule, + FerratasModule, + ], + }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + app = await build(); + server = app.getHttpServer(); + const orm = app.get(MikroORM); + const { user: owner } = await makeUser(orm); + const { user: stranger } = await makeUser(orm); + ownerId = owner.id; + strangerId = stranger.id; + tripId = (await makeTrip(orm, owner.id)).id; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + const url = (id: number = tripId) => `/api/trips/${id}/ferratas`; + + it('401 without a session cookie', async () => { + const res = await request(server).get(url()).query({ bbox: '46.5,12.05,46.6,12.2' }); + expect(res.status).toBe(401); + }); + + it('404 "Trip not found" for a trip the caller cannot reach', async () => { + const res = await request(server) + .get(url()) + .set('Cookie', sessionCookie(strangerId)) + .query({ bbox: '46.5,12.05,46.6,12.2' }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + it('400 for a missing or malformed bbox', async () => { + const missing = await request(server).get(url()).set('Cookie', sessionCookie(ownerId)); + expect(missing.status).toBe(400); + expect(missing.body).toEqual({ error: FERRATA_BBOX_INVALID_MESSAGE }); + + const malformed = await request(server) + .get(url()) + .set('Cookie', sessionCookie(ownerId)) + .query({ bbox: '46.5,abc,46.6,12.2' }); + expect(malformed.body).toEqual({ error: FERRATA_BBOX_INVALID_MESSAGE }); + }); + + it('400 for a box over the span cap, before Overpass is asked', async () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + const res = await request(server).get(url()).set('Cookie', sessionCookie(ownerId)).query({ bbox: '45,11,46,11.3' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: FERRATA_BBOX_TOO_LARGE_MESSAGE }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('200 with the ferratas, their grade, route name and the attribution', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(overpassAnswer), { status: 200 })), + ); + const res = await request(server) + .get(url()) + .set('Cookie', sessionCookie(ownerId)) + .query({ bbox: '46.5,12.05,46.6,12.2' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + ferratas: [ + { + id: 'way/4242', + name: 'Ferrata Test', + grade: { raw: '4-', level: 4, plus: '-' }, + routeName: 'Alta Via', + geometry: [ + [46.51, 12.06], + [46.52, 12.07], + ], + osmUrl: 'https://www.openstreetmap.org/way/4242', + }, + ], + truncated: false, + source: 'openstreetmap', + attribution: FERRATAS_ATTRIBUTION, + }); + }); + + it('502 { error, code } when no Overpass mirror answers', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('busy', { status: 504 })), + ); + const res = await request(server) + .get(url()) + .set('Cookie', sessionCookie(ownerId)) + .query({ bbox: '40.5,10.05,40.6,10.2' }); + expect(res.status).toBe(502); + expect(res.body).toEqual({ error: expect.any(String), code: FERRATAS_UNAVAILABLE_CODE }); + expect(JSON.stringify(res.body)).not.toMatch(/stack|at .*\.ts/); + }); +}); diff --git a/server/tests/unit/nest/ferratas-overpass.client.test.ts b/server/tests/unit/nest/ferratas-overpass.client.test.ts new file mode 100644 index 000000000..ddd3e43ec --- /dev/null +++ b/server/tests/unit/nest/ferratas-overpass.client.test.ts @@ -0,0 +1,106 @@ +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { FerratasOverpassClient } from '../../../src/nest/ferratas/ferratas-overpass.client'; +import { parseOverpassEndpoints } from '../../../src/nest/maps/overpass.constants'; +import { Test } from '@nestjs/testing'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const integrations = { overpassUrl: undefined as string | undefined, overpassTimeoutMs: 5000 }; +let client: FerratasOverpassClient; + +beforeEach(async () => { + integrations.overpassUrl = 'https://a.example/api/interpreter,https://b.example/api/interpreter'; + integrations.overpassTimeoutMs = 5000; + const moduleRef = await Test.createTestingModule({ + providers: [FerratasOverpassClient, { provide: RuntimeEnvService, useValue: { env: () => ({ integrations }) } }], + }).compile(); + client = moduleRef.get(FerratasOverpassClient); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); + +describe('parseOverpassEndpoints', () => { + it('reads a comma-separated list of http(s) endpoints', () => { + expect(parseOverpassEndpoints(' https://a.example/i , http://b.local/i ')).toEqual([ + 'https://a.example/i', + 'http://b.local/i', + ]); + }); + + it('drops what is not an http(s) URL', () => { + expect(parseOverpassEndpoints('ftp://x/i,not a url,https://ok.example/i')).toEqual(['https://ok.example/i']); + }); + + it('falls back to the public mirrors when nothing usable is configured', () => { + for (const raw of [undefined, '', 'nope']) { + const list = parseOverpassEndpoints(raw); + expect(list.length).toBeGreaterThan(1); + expect(list).toContain('https://overpass-api.de/api/interpreter'); + } + }); +}); + +describe('FerratasOverpassClient.query', () => { + it('POSTs the query with a user agent and a deadline, and answers the elements', async () => { + const fetcher = vi.fn(async (_url: string, _init: RequestInit) => json({ elements: [{ type: 'way', id: 1 }] })); + vi.stubGlobal('fetch', fetcher); + integrations.overpassUrl = 'https://a.example/api/interpreter'; + const elements = await client.query('[out:json];way(1);out;'); + expect(elements).toEqual([{ type: 'way', id: 1 }]); + const [url, init] = fetcher.mock.calls[0] ?? []; + expect(url).toBe('https://a.example/api/interpreter'); + expect(init?.method).toBe('POST'); + expect(init?.body).toBe(`data=${encodeURIComponent('[out:json];way(1);out;')}`); + expect((init?.headers as Record)['User-Agent']).toMatch(/^TREK Travel Planner/); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + it('takes the first mirror that answers well and cancels the others', async () => { + const signals: AbortSignal[] = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit) => { + if (init.signal) signals.push(init.signal); + if (url.startsWith('https://a.')) return json({ elements: [], remark: 'runtime error: timeout' }); + return json({ elements: [{ type: 'way', id: 2 }] }); + }), + ); + expect(await client.query('q')).toEqual([{ type: 'way', id: 2 }]); + expect(signals.every((s) => s.aborted)).toBe(true); + }); + + it('throws once every mirror failed: an HTTP error, a remark, a body that is not OSM', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => (url.startsWith('https://a.') ? json({}, 504) : json({ elements: 'nope' }))), + ); + await expect(client.query('q')).rejects.toThrow('Could not reach any Overpass endpoint'); + }); + + it('throws when a mirror cannot be reached at all', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('fetch failed'); + }), + ); + await expect(client.query('q')).rejects.toThrow('Could not reach any Overpass endpoint'); + }); + + it('refuses an answer larger than the cap', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response('{"elements":[]}', { status: 200, headers: { 'content-length': String(9 * 1024 * 1024) } }), + ), + ); + await expect(client.query('q')).rejects.toThrow('Could not reach any Overpass endpoint'); + }); +}); diff --git a/server/tests/unit/nest/ferratas.controller.test.ts b/server/tests/unit/nest/ferratas.controller.test.ts new file mode 100644 index 000000000..5a21a348e --- /dev/null +++ b/server/tests/unit/nest/ferratas.controller.test.ts @@ -0,0 +1,94 @@ +import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard'; +import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract'; +import { FerratasController, parseFerratasQuery } from '../../../src/nest/ferratas/ferratas.controller'; +import type { FerratasService } from '../../../src/nest/ferratas/ferratas.service'; +import { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard'; +import { HttpException } from '@nestjs/common'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { + FERRATA_BBOX_INVALID_MESSAGE, + FERRATA_BBOX_TOO_LARGE_MESSAGE, + FERRATAS_ATTRIBUTION, + ferratasResponseSchema, + type FerratasResponse, +} from '@trek/shared'; + +import { describe, expect, it, vi } from 'vitest'; + +function thrown(fn: () => unknown): { status: number; body: unknown } { + try { + fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected a throw'); +} + +const answer: FerratasResponse = { + ferratas: [], + truncated: false, + source: 'openstreetmap', + attribution: FERRATAS_ATTRIBUTION, +}; + +describe('parseFerratasQuery', () => { + it('answers the named edges', () => { + expect(parseFerratasQuery({ bbox: '47.1,11.2,47.4,11.6' }).bbox).toEqual({ + south: 47.1, + west: 11.2, + north: 47.4, + east: 11.6, + }); + }); + + it.each([{}, { bbox: '' }, { bbox: '1,2,3' }, { bbox: '47.4,11.2,47.1,11.6' }, { bbox: ['1', '2'] }])( + '400 with the invalid-bbox message for %j', + (raw) => { + expect(thrown(() => parseFerratasQuery(raw))).toEqual({ + status: 400, + body: { error: FERRATA_BBOX_INVALID_MESSAGE }, + }); + }, + ); + + it('400 with its own message for a box that is too large', () => { + expect(thrown(() => parseFerratasQuery({ bbox: '46,11,47,11.4' }))).toEqual({ + status: 400, + body: { error: FERRATA_BBOX_TOO_LARGE_MESSAGE }, + }); + }); +}); + +describe('FerratasController', () => { + it('hands the parsed box to the service and answers what it returns', async () => { + const list = vi.fn().mockResolvedValue(answer); + const controller = new FerratasController({ list } as unknown as FerratasService); + await expect(controller.list({ bbox: '47.1,11.2,47.4,11.6' })).resolves.toBe(answer); + expect(list).toHaveBeenCalledWith({ south: 47.1, west: 11.2, north: 47.4, east: 11.6 }); + }); + + it('refuses a bad box before asking the service', async () => { + const list = vi.fn(); + const controller = new FerratasController({ list } as unknown as FerratasService); + await expect(controller.list({ bbox: 'x' })).rejects.toBeInstanceOf(HttpException); + expect(list).not.toHaveBeenCalled(); + }); + + it('passes a service refusal through unchanged', async () => { + const refusal = new HttpException({ error: 'down', code: 'ferratas_unavailable' }, 502); + const controller = new FerratasController({ + list: vi.fn().mockRejectedValue(refusal), + } as unknown as FerratasService); + await expect(controller.list({ bbox: '47.1,11.2,47.4,11.6' })).rejects.toBe(refusal); + }); + + it('is trip-scoped behind the auth and trip-access guards', () => { + expect(Reflect.getMetadata(GUARDS_METADATA, FerratasController)).toEqual([JwtAuthGuard, TripAccessGuard]); + }); + + it('declares the shared response contract', () => { + expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, FerratasController.prototype.list)).toBe(ferratasResponseSchema); + }); +}); diff --git a/server/tests/unit/nest/ferratas.helpers.test.ts b/server/tests/unit/nest/ferratas.helpers.test.ts new file mode 100644 index 000000000..7b5bf8283 --- /dev/null +++ b/server/tests/unit/nest/ferratas.helpers.test.ts @@ -0,0 +1,196 @@ +import { + FERRATA_QUERY_TIMEOUT_S, + buildFerrataQuery, + ferrataCacheKey, + overpassFerrataAnswerSchema, + parseFerrataGrade, + snapFerrataBbox, + toFerratas, +} from '../../../src/nest/ferratas/ferratas.helpers'; +import { FERRATAS_RESULT_CAP } from '@trek/shared'; + +import { describe, expect, it } from 'vitest'; + +const line = [ + { lat: 47.1, lon: 11.2 }, + { lat: 47.11, lon: 11.21 }, +]; +const way = (id: number, tags: Record = {}, geometry: unknown = line) => ({ + type: 'way', + id, + tags: { highway: 'via_ferrata', ...tags }, + geometry, +}); + +describe('parseFerrataGrade', () => { + it.each([ + ['0', 0, null], + ['3', 3, null], + ['6', 6, null], + ['2+', 2, '+'], + ['4-', 4, '-'], + [' 5 ', 5, null], + ])('reads %j as level %s', (raw, level, plus) => { + expect(parseFerrataGrade(raw)).toEqual({ raw: raw.trim(), level, plus }); + }); + + it.each(['7', '-1', 'C', 'C/D', 'K3', '2++', '3 +', 'difficult', '2.5'])('keeps %j as unknown', (raw) => { + expect(parseFerrataGrade(raw)).toEqual({ raw, level: null, plus: null }); + }); + + it('answers all null for a missing or blank tag', () => { + expect(parseFerrataGrade(undefined)).toEqual({ raw: null, level: null, plus: null }); + expect(parseFerrataGrade(' ')).toEqual({ raw: null, level: null, plus: null }); + }); + + it('cuts an overlong raw value to what the contract holds', () => { + expect(parseFerrataGrade('x'.repeat(100)).raw).toHaveLength(64); + }); +}); + +describe('snapFerrataBbox and ferrataCacheKey', () => { + it('grows the box outwards to the 0.05 degree grid', () => { + expect(snapFerrataBbox({ south: 47.12, west: 11.21, north: 47.38, east: 11.57 })).toEqual({ + south: 47.1, + west: 11.2, + north: 47.4, + east: 11.6, + }); + }); + + it('leaves a box already on the grid as it is', () => { + const onGrid = { south: 47.1, west: 11.2, north: 47.4, east: 11.6 }; + expect(snapFerrataBbox(onGrid)).toEqual(onGrid); + }); + + it('handles negative coordinates and clamps to the globe', () => { + expect(snapFerrataBbox({ south: -89.99, west: -179.99, north: -89.9, east: -179.9 })).toEqual({ + south: -90, + west: -180, + north: -89.9, + east: -179.9, + }); + }); + + it('gives two nearby requests the same key', () => { + const a = ferrataCacheKey(snapFerrataBbox({ south: 47.11, west: 11.21, north: 47.39, east: 11.59 })); + const b = ferrataCacheKey(snapFerrataBbox({ south: 47.12, west: 11.22, north: 47.38, east: 11.58 })); + expect(a).toBe(b); + expect(a).toBe('47.1,11.2,47.4,11.6'); + }); +}); + +describe('buildFerrataQuery', () => { + const query = buildFerrataQuery({ south: 47.1, west: 11.2, north: 47.4, east: 11.6 }); + + it('carries the server-side timeout and JSON output', () => { + expect(query.startsWith(`[out:json][timeout:${FERRATA_QUERY_TIMEOUT_S}];`)).toBe(true); + }); + + it('asks for the tagged ways, the scaled paths and the route members inside the box', () => { + const box = '(47.1,11.2,47.4,11.6)'; + expect(query).toContain(`way["highway"="via_ferrata"]${box};`); + expect(query).toContain(`way["highway"~"^(path|footway)$"]["via_ferrata_scale"]${box};`); + expect(query).toContain(`rel["route"="via_ferrata"]${box}->.routes;`); + expect(query).toContain(`way(r.routes)${box};`); + }); + + it('asks for one way more than the cap, with geometry, and the relations with their members', () => { + expect(query).toContain(`.ways out geom qt ${FERRATAS_RESULT_CAP + 1};`); + expect(query).toContain('.routes out body qt;'); + }); +}); + +describe('overpassFerrataAnswerSchema', () => { + it('accepts an element list with or without a remark', () => { + expect(overpassFerrataAnswerSchema.safeParse({ elements: [] }).success).toBe(true); + expect(overpassFerrataAnswerSchema.safeParse({ elements: [], remark: 'runtime error' }).success).toBe(true); + }); + + it('refuses a body without elements', () => { + expect(overpassFerrataAnswerSchema.safeParse({ error: 'x' }).success).toBe(false); + expect(overpassFerrataAnswerSchema.safeParse(undefined).success).toBe(false); + }); +}); + +describe('toFerratas', () => { + it('maps a way to the wire row', () => { + const { ferratas, truncated } = toFerratas([way(42, { name: ' Pisciadù ', via_ferrata_scale: '3+' })]); + expect(truncated).toBe(false); + expect(ferratas).toEqual([ + { + id: 'way/42', + name: 'Pisciadù', + grade: { raw: '3+', level: 3, plus: '+' }, + routeName: null, + geometry: [ + [47.1, 11.2], + [47.11, 11.21], + ], + osmUrl: 'https://www.openstreetmap.org/way/42', + }, + ]); + }); + + it('names a way after the first named route relation that holds it', () => { + const { ferratas } = toFerratas([ + way(1), + way(2), + { type: 'relation', id: 9, tags: { route: 'via_ferrata' }, members: [{ type: 'way', ref: 1, role: '' }] }, + { + type: 'relation', + id: 10, + tags: { route: 'via_ferrata', name: 'Via delle Bocchette' }, + members: [ + { type: 'node', ref: 2, role: '' }, + { type: 'way', ref: 1, role: '' }, + ], + }, + { type: 'relation', id: 11, tags: { name: 'Second' }, members: [{ type: 'way', ref: 1, role: '' }] }, + ]); + expect(ferratas.map((f) => [f.id, f.routeName])).toEqual([ + ['way/1', 'Via delle Bocchette'], + ['way/2', null], + ]); + }); + + it('leaves out malformed elements, duplicates and lines that cannot be drawn', () => { + const { ferratas } = toFerratas([ + null, + 'way', + { type: 'node', id: 3, lat: 1, lon: 2 }, + { type: 'way', id: -1, geometry: line }, + way(5, {}, [{ lat: 47.1, lon: 11.2 }, null]), + way(6, {}, undefined), + way(7, {}, [{ lat: 470, lon: 11.2 }, line[1]]), + way(8), + way(8), + way(9, {}, [line[0], null, line[1]]), + ]); + expect(ferratas.map((f) => f.id)).toEqual(['way/8', 'way/9']); + expect(ferratas[1]?.geometry).toHaveLength(2); + }); + + it('keeps a blank name as null', () => { + expect(toFerratas([way(1, { name: ' ' })]).ferratas[0]?.name).toBeNull(); + }); + + it('cuts at the cap and says so', () => { + const many = Array.from({ length: FERRATAS_RESULT_CAP + 1 }, (_, i) => way(i + 1)); + const { ferratas, truncated } = toFerratas(many); + expect(truncated).toBe(true); + expect(ferratas).toHaveLength(FERRATAS_RESULT_CAP); + }); + + it('stays truncated when Overpass hit the cap but a dropped way brings the rows under it', () => { + const many = Array.from({ length: FERRATAS_RESULT_CAP }, (_, i) => way(i + 1)); + const { ferratas, truncated } = toFerratas([...many, way(FERRATAS_RESULT_CAP + 1, {}, [{ lat: 46.5, lon: 11.7 }])]); + expect(ferratas).toHaveLength(FERRATAS_RESULT_CAP); + expect(truncated).toBe(true); + }); + + it('is not truncated at exactly the cap', () => { + const many = Array.from({ length: FERRATAS_RESULT_CAP }, (_, i) => way(i + 1)); + expect(toFerratas(many).truncated).toBe(false); + }); +}); diff --git a/server/tests/unit/nest/ferratas.service.test.ts b/server/tests/unit/nest/ferratas.service.test.ts new file mode 100644 index 000000000..7da34b99b --- /dev/null +++ b/server/tests/unit/nest/ferratas.service.test.ts @@ -0,0 +1,117 @@ +import { DomainError } from '../../../src/nest/common/domain-error'; +import { FerratasOverpassClient } from '../../../src/nest/ferratas/ferratas-overpass.client'; +import { + FERRATA_CACHE_MAX, + FERRATA_CACHE_TTL_MS, + FERRATAS_UNAVAILABLE_MESSAGE, + FerratasService, +} from '../../../src/nest/ferratas/ferratas.service'; +import { Test } from '@nestjs/testing'; +import { FERRATAS_ATTRIBUTION, FERRATAS_UNAVAILABLE_CODE, ferratasResponseSchema } from '@trek/shared'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const bbox = { south: 47.12, west: 11.21, north: 47.38, east: 11.57 }; +const element = { + type: 'way', + id: 42, + tags: { highway: 'via_ferrata', name: 'Klettersteig', via_ferrata_scale: '2+' }, + geometry: [ + { lat: 47.2, lon: 11.3 }, + { lat: 47.21, lon: 11.31 }, + ], +}; + +const query = vi.fn<(q: string) => Promise>(); +let service: FerratasService; + +beforeEach(async () => { + query.mockReset(); + query.mockResolvedValue([element]); + const moduleRef = await Test.createTestingModule({ + providers: [FerratasService, { provide: FerratasOverpassClient, useValue: { query } }], + }).compile(); + service = moduleRef.get(FerratasService); +}); + +describe('FerratasService.list', () => { + it('answers the ferratas with source and attribution, inside the response contract', async () => { + const res = await service.list(bbox); + expect(res).toMatchObject({ truncated: false, source: 'openstreetmap', attribution: FERRATAS_ATTRIBUTION }); + expect(res.ferratas.map((f) => f.id)).toEqual(['way/42']); + expect(ferratasResponseSchema.safeParse(res).success).toBe(true); + }); + + it('answers only the ferratas that reach into the asked box, not the whole snapped box', async () => { + const outside = { + ...element, + id: 43, + geometry: [ + { lat: 47.1, lon: 11.3 }, + { lat: 47.11, lon: 11.31 }, + ], + }; + query.mockResolvedValue([element, outside]); + + const res = await service.list(bbox); + + expect(res.ferratas.map((ferrata) => ferrata.id)).toEqual(['way/42']); + }); + + it('asks Overpass for the box snapped to the grid', async () => { + await service.list(bbox); + expect(query).toHaveBeenCalledTimes(1); + expect(query.mock.calls[0]?.[0]).toContain('(47.1,11.2,47.4,11.6)'); + }); + + it('serves a nearby request from the cache inside the TTL', async () => { + await service.list(bbox, 1_000); + await service.list({ south: 47.11, west: 11.22, north: 47.39, east: 11.59 }, 1_000 + FERRATA_CACHE_TTL_MS - 1); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('asks again once the entry is older than the TTL', async () => { + await service.list(bbox, 1_000); + await service.list(bbox, 1_000 + FERRATA_CACHE_TTL_MS); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('shares one Overpass call between concurrent identical requests', async () => { + let release: (v: unknown[]) => void = () => {}; + query.mockReturnValueOnce(new Promise((resolve) => (release = resolve))); + const both = Promise.all([service.list(bbox), service.list(bbox)]); + release([element]); + const [a, b] = await both; + expect(a).toBe(b); + expect(query).toHaveBeenCalledTimes(1); + }); + + it('turns an Overpass failure into a 502 with a code, and does not cache it', async () => { + query.mockRejectedValueOnce(new Error('Could not reach any Overpass endpoint')); + const err = await service.list(bbox).catch((e: unknown) => e); + expect(err).toBeInstanceOf(DomainError); + const domain = err as DomainError; + expect(domain.getStatus()).toBe(502); + expect(domain.code).toBe(FERRATAS_UNAVAILABLE_CODE); + expect(domain.getResponse()).toEqual({ error: FERRATAS_UNAVAILABLE_MESSAGE, code: FERRATAS_UNAVAILABLE_CODE }); + + const retry = await service.list(bbox); + expect(retry.ferratas).toHaveLength(1); + expect(query).toHaveBeenCalledTimes(2); + }); + + it('evicts the oldest box once the cache is full', async () => { + const boxAt = (i: number) => ({ south: 10 + i * 0.1, west: 10, north: 10.04 + i * 0.1, east: 10.04 }); + // One after another, so the insertion order is the call order. + await Array.from({ length: FERRATA_CACHE_MAX + 1 }, (_, i) => i).reduce>(async (previous, i) => { + await previous; + return service.list(boxAt(i), 1_000); + }, Promise.resolve()); + expect(query).toHaveBeenCalledTimes(FERRATA_CACHE_MAX + 1); + + await service.list(boxAt(FERRATA_CACHE_MAX), 1_000); + expect(query).toHaveBeenCalledTimes(FERRATA_CACHE_MAX + 1); + await service.list(boxAt(0), 1_000); + expect(query).toHaveBeenCalledTimes(FERRATA_CACHE_MAX + 2); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 3f9e0f5ee..c78ff1dbb 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -107,6 +107,10 @@ export default defineConfig({ 'src/nest/days/**/*.ts': { statements: 92, branches: 82, functions: 98, lines: 97 }, 'src/nest/doc-sync/**/*.ts': { statements: 91, branches: 79, functions: 96, lines: 95 }, 'src/nest/feeds/**/*.ts': { statements: 91, branches: 83, functions: 83, lines: 91 }, + // New domain in this change, unmeasured: pinned at the 80 floor so it + // has its own entry rather than hiding in the catch-all. Regenerate with + // scripts/coverage-thresholds.mjs after the first full run. + 'src/nest/ferratas/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, 'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 }, 'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 }, // google-quota, managed, mcp-shared, mcp-transport, public-api, diff --git a/shared/src/ferratas/ferratas.schema.spec.ts b/shared/src/ferratas/ferratas.schema.spec.ts new file mode 100644 index 000000000..03b09ec4f --- /dev/null +++ b/shared/src/ferratas/ferratas.schema.spec.ts @@ -0,0 +1,117 @@ +import { + FERRATA_BBOX_INVALID_MESSAGE, + FERRATA_BBOX_TOO_LARGE_MESSAGE, + FERRATAS_ATTRIBUTION, + ferrataBboxSpanOk, + ferrataGradeSchema, + ferratasQuerySchema, + ferratasResponseSchema, +} from './ferratas.schema'; + +import { describe, expect, it } from 'vitest'; + +function messages(bbox: unknown): string[] { + const parsed = ferratasQuerySchema.safeParse({ bbox }); + return parsed.success ? [] : parsed.error.issues.map((issue) => issue.message); +} + +describe('ferratasQuerySchema', () => { + it('reads south,west,north,east into named edges', () => { + expect(ferratasQuerySchema.parse({ bbox: '47.1,11.2,47.4,11.6' }).bbox).toEqual({ + south: 47.1, + west: 11.2, + north: 47.4, + east: 11.6, + }); + }); + + it('tolerates spaces around the edges', () => { + expect(ferratasQuerySchema.parse({ bbox: ' 47.1 , 11.2,47.4 ,11.6 ' }).bbox.north).toBe(47.4); + }); + + it.each([ + ['too few edges', '47.1,11.2,47.4'], + ['too many edges', '47.1,11.2,47.4,11.6,1'], + ['an empty edge', '47.1,,47.4,11.6'], + ['a word', '47.1,abc,47.4,11.6'], + ['an empty string', ''], + ['Infinity', '47.1,11.2,Infinity,11.6'], + ])('refuses %s', (_label, bbox) => { + expect(messages(bbox)).toEqual([FERRATA_BBOX_INVALID_MESSAGE]); + }); + + it('refuses a missing bbox and a non-string one', () => { + expect(ferratasQuerySchema.safeParse({}).success).toBe(false); + expect(ferratasQuerySchema.safeParse({ bbox: ['47', '11', '48', '12'] }).success).toBe(false); + }); + + it('refuses edges in the wrong order', () => { + expect(messages('47.4,11.2,47.1,11.6')).toContain(FERRATA_BBOX_INVALID_MESSAGE); + expect(messages('47.1,11.6,47.4,11.2')).toContain(FERRATA_BBOX_INVALID_MESSAGE); + }); + + it('refuses coordinates off the globe', () => { + expect(messages('-91,11,-90.5,11.2').length).toBeGreaterThan(0); + expect(messages('47,180.1,47.2,180.3').length).toBeGreaterThan(0); + }); + + it('caps the span per side', () => { + expect(messages('47,11,47.5,11.5')).toEqual([]); + expect(messages('47,11,47.6,11.2')).toEqual([FERRATA_BBOX_TOO_LARGE_MESSAGE]); + expect(messages('47,11,47.2,11.6')).toEqual([FERRATA_BBOX_TOO_LARGE_MESSAGE]); + }); + + it('takes a 0.500 span whose float difference lands a hair above 0.5', () => { + // 64.001 - 63.501 is 0.5000000000000071 in doubles. + expect(64.001 - 63.501).toBeGreaterThan(0.5); + expect(messages('63.501,11.000,64.001,11.200')).toEqual([]); + expect(ferrataBboxSpanOk({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true); + expect(ferrataBboxSpanOk({ south: 63.5, west: 11, north: 64.001, east: 11.2 })).toBe(false); + }); + + it('refuses an oversized query string before splitting it', () => { + expect(ferratasQuerySchema.safeParse({ bbox: '1,'.repeat(150) }).success).toBe(false); + }); +}); + +describe('ferrataGradeSchema', () => { + it('accepts a parsed grade and an unknown one', () => { + expect(ferrataGradeSchema.safeParse({ raw: '3+', level: 3, plus: '+' }).success).toBe(true); + expect(ferrataGradeSchema.safeParse({ raw: 'C/D', level: null, plus: null }).success).toBe(true); + expect(ferrataGradeSchema.safeParse({ raw: null, level: null, plus: null }).success).toBe(true); + }); + + it('refuses a level outside 0 to 6', () => { + expect(ferrataGradeSchema.safeParse({ raw: '7', level: 7, plus: null }).success).toBe(false); + }); +}); + +describe('ferratasResponseSchema', () => { + const ferrata = { + id: 'way/123', + name: 'Klettersteig', + grade: { raw: '2', level: 2, plus: null }, + routeName: null, + geometry: [ + [47.1, 11.2], + [47.2, 11.3], + ], + osmUrl: 'https://www.openstreetmap.org/way/123', + }; + const base = { truncated: false, source: 'openstreetmap', attribution: FERRATAS_ATTRIBUTION }; + + it('accepts a well-formed answer', () => { + expect(ferratasResponseSchema.safeParse({ ...base, ferratas: [ferrata] }).success).toBe(true); + }); + + it('refuses an id that is not a way, and a line with one point', () => { + expect(ferratasResponseSchema.safeParse({ ...base, ferratas: [{ ...ferrata, id: 'node/1' }] }).success).toBe(false); + expect( + ferratasResponseSchema.safeParse({ ...base, ferratas: [{ ...ferrata, geometry: [[47.1, 11.2]] }] }).success, + ).toBe(false); + }); + + it('pins the attribution', () => { + expect(ferratasResponseSchema.safeParse({ ...base, attribution: 'someone', ferratas: [] }).success).toBe(false); + }); +}); diff --git a/shared/src/ferratas/ferratas.schema.ts b/shared/src/ferratas/ferratas.schema.ts new file mode 100644 index 000000000..b7204ea63 --- /dev/null +++ b/shared/src/ferratas/ferratas.schema.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +/** + * Via ferratas around a trip, read from OpenStreetMap through Overpass + * (GET /api/trips/:tripId/ferratas?bbox=south,west,north,east). + * + * Read-only and keyless: the server asks the Overpass mirrors the operator + * configured (OVERPASS_URL) and answers with the ways it found, each with its + * grade as OpenStreetMap tags it. Nothing is stored. + */ + +/** The widest box one request may ask for, per side, in degrees (about 55 km north to south). */ +export const FERRATA_BBOX_MAX_SPAN_DEG = 0.5; + +/** The most ferratas one answer carries; past it the answer says `truncated`. */ +export const FERRATAS_RESULT_CAP = 400; + +/** ODbL asks for this next to anything drawn from the data. */ +export const FERRATAS_ATTRIBUTION = '© OpenStreetMap contributors'; + +/** The `code` beside `error` when no Overpass mirror answered, so the client can say "try again later". */ +export const FERRATAS_UNAVAILABLE_CODE = 'ferratas_unavailable'; + +export const FERRATA_BBOX_INVALID_MESSAGE = 'A valid bbox (south,west,north,east) is required'; +export const FERRATA_BBOX_TOO_LARGE_MESSAGE = `The bbox may span at most ${FERRATA_BBOX_MAX_SPAN_DEG} degrees per side`; + +/** + * Room for float noise at the span limit: edges rounded to three decimals can + * put a 0.500 span at 0.5000000000000071 (63.501 to 64.001). + */ +const SPAN_EPSILON = 1e-9; + +/** + * Whether neither side of the box is wider than FERRATA_BBOX_MAX_SPAN_DEG. The + * one rule for both ends: the server refuses past it and the client asks the + * user to zoom in instead of sending the box. + */ +export function ferrataBboxSpanOk(b: { south: number; west: number; north: number; east: number }): boolean { + const max = FERRATA_BBOX_MAX_SPAN_DEG + SPAN_EPSILON; + return b.north - b.south <= max && b.east - b.west <= max; +} + +const latitude = z.number().min(-90).max(90); +const longitude = z.number().min(-180).max(180); + +const ferrataBboxSchema = z + .object({ south: latitude, west: longitude, north: latitude, east: longitude }) + .refine((b) => b.south < b.north && b.west < b.east, { message: FERRATA_BBOX_INVALID_MESSAGE }) + .refine(ferrataBboxSpanOk, { message: FERRATA_BBOX_TOO_LARGE_MESSAGE }); +export type FerrataBbox = z.infer; + +/** + * The query: one `bbox` of four comma-separated numbers in south, west, north, + * east order (the order Overpass takes them in). A box that crosses the + * antimeridian is refused rather than split; no via ferrata sits on it. + */ +export const ferratasQuerySchema = z.object({ + bbox: z + .string() + .max(200) + .transform((raw, ctx) => { + // Number('') is 0, so an empty edge would quietly become the equator; refuse it first. + const edges = raw.split(',').map((part) => (part.trim() === '' ? Number.NaN : Number(part))); + if (edges.length !== 4 || !edges.every(Number.isFinite)) { + ctx.addIssue({ code: 'custom', message: FERRATA_BBOX_INVALID_MESSAGE }); + return z.NEVER; + } + const [south, west, north, east] = edges as [number, number, number, number]; + return { south, west, north, east }; + }) + .pipe(ferrataBboxSchema), +}); +export type FerratasQuery = z.output; + +/** + * The difficulty as tagged. `via_ferrata_scale` is 0 to 6, sometimes with a + * `+` or `-`; `level` and `plus` are filled only for that form. Anything else + * (a letter scale such as "C/D", free text) keeps its `raw` value and reads as + * unknown: letter scales are not converted, because the mapping between the + * Hüsler, Schall and OSM scales is not one to one. + */ +export const ferrataGradeSchema = z.object({ + raw: z.string().max(64).nullable(), + level: z.number().int().min(0).max(6).nullable(), + plus: z.enum(['+', '-']).nullable(), +}); +export type FerrataGrade = z.infer; + +export const ferrataSchema = z.object({ + /** The OSM way, as `way/`. */ + id: z.string().regex(/^way\/\d+$/), + name: z.string().max(500).nullable(), + grade: ferrataGradeSchema, + /** The name of a `route=via_ferrata` relation the way belongs to, when it has one. */ + routeName: z.string().max(500).nullable(), + /** The line as [lat, lng] pairs, in way order. */ + geometry: z + .array(z.tuple([latitude, longitude])) + .min(2) + .max(20000), + osmUrl: z.string().url(), +}); +export type Ferrata = z.infer; + +export const ferratasResponseSchema = z.object({ + ferratas: z.array(ferrataSchema).max(FERRATAS_RESULT_CAP), + /** True when the box held more than FERRATAS_RESULT_CAP ways and the rest were left out. */ + truncated: z.boolean(), + source: z.literal('openstreetmap'), + attribution: z.literal(FERRATAS_ATTRIBUTION), +}); +export type FerratasResponse = z.infer; diff --git a/shared/src/i18n/ar/ferratas.ts b/shared/src/i18n/ar/ferratas.ts new file mode 100644 index 000000000..21e9c0da6 --- /dev/null +++ b/shared/src/i18n/ar/ferratas.ts @@ -0,0 +1,20 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'مسارات التسلق الحديدية', + 'ferratas.count': '{count} مسار تسلق حديدي', + 'ferratas.count.zero': 'لا توجد مسارات تسلق حديدية', + 'ferratas.count.one': 'مسار تسلق حديدي واحد', + 'ferratas.count.two': 'مساران للتسلق الحديدي', + 'ferratas.count.few': '{count} مسارات تسلق حديدية', + 'ferratas.count.many': '{count} مسارًا للتسلق الحديدي', + 'ferratas.loading': 'جارٍ تحميل مسارات التسلق الحديدية…', + 'ferratas.error': 'تعذّر الوصول إلى Overpass', + 'ferratas.zoomIn': 'قرّب الخريطة لتحميل مسارات التسلق الحديدية', + 'ferratas.offline': 'تحتاج مسارات التسلق الحديدية إلى اتصال', + 'ferratas.unknownGrade': 'درجة الصعوبة غير معروفة', + 'ferratas.unnamed': 'مسار تسلق حديدي بلا اسم', + 'ferratas.viewInOsm': 'عرض في OpenStreetMap', + 'ferratas.attribution': 'البيانات © مساهمو OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/az/ferratas.ts b/shared/src/i18n/az/ferratas.ts new file mode 100644 index 000000000..b1c130519 --- /dev/null +++ b/shared/src/i18n/az/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Via ferrata marşrutları', + 'ferratas.count': '{count} via ferrata marşrutu', + 'ferratas.count.one': '{count} via ferrata marşrutu', + 'ferratas.loading': 'Via ferrata marşrutları yüklənir…', + 'ferratas.error': 'Overpass əlçatan deyil', + 'ferratas.zoomIn': 'Via ferrata marşrutlarını yükləmək üçün yaxınlaşdırın', + 'ferratas.offline': 'Via ferrata marşrutları üçün bağlantı lazımdır', + 'ferratas.unknownGrade': 'Çətinlik naməlumdur', + 'ferratas.unnamed': 'Adsız via ferrata marşrutu', + 'ferratas.viewInOsm': 'OpenStreetMap-də bax', + 'ferratas.attribution': 'Məlumatlar © OpenStreetMap iştirakçıları', +}; +export default ferratas; diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index cbe27b3cf..49ed7a462 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/br/ferratas.ts b/shared/src/i18n/br/ferratas.ts new file mode 100644 index 000000000..1195454d5 --- /dev/null +++ b/shared/src/i18n/br/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Vias ferratas', + 'ferratas.count': '{count} vias ferratas', + 'ferratas.count.one': '{count} via ferrata', + 'ferratas.loading': 'Carregando vias ferratas…', + 'ferratas.error': 'Overpass indisponível', + 'ferratas.zoomIn': 'Aproxime o mapa para carregar vias ferratas', + 'ferratas.offline': 'As vias ferratas precisam de conexão', + 'ferratas.unknownGrade': 'Dificuldade desconhecida', + 'ferratas.unnamed': 'Via ferrata sem nome', + 'ferratas.viewInOsm': 'Ver no OpenStreetMap', + 'ferratas.attribution': 'Dados © colaboradores do OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 126153ad8..29eb22621 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/ca/ferratas.ts b/shared/src/i18n/ca/ferratas.ts new file mode 100644 index 000000000..0a7fd51e9 --- /dev/null +++ b/shared/src/i18n/ca/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Vies ferrades', + 'ferratas.count': '{count} vies ferrades', + 'ferratas.count.one': '{count} via ferrada', + 'ferratas.loading': "S'estan carregant les vies ferrades…", + 'ferratas.error': 'Overpass no està disponible', + 'ferratas.zoomIn': 'Apropa el mapa per carregar vies ferrades', + 'ferratas.offline': 'Les vies ferrades necessiten connexió', + 'ferratas.unknownGrade': 'Dificultat desconeguda', + 'ferratas.unnamed': 'Via ferrada sense nom', + 'ferratas.viewInOsm': 'Mostra a OpenStreetMap', + 'ferratas.attribution': "Dades © col·laboradors d'OpenStreetMap", +}; +export default ferratas; diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index ce56796ca..f1e33c665 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/cs/ferratas.ts b/shared/src/i18n/cs/ferratas.ts new file mode 100644 index 000000000..a6501e649 --- /dev/null +++ b/shared/src/i18n/cs/ferratas.ts @@ -0,0 +1,17 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Ferraty', + 'ferratas.count': '{count} ferrat', + 'ferratas.count.one': '{count} ferrata', + 'ferratas.count.few': '{count} ferraty', + 'ferratas.loading': 'Načítání ferrat…', + 'ferratas.error': 'Overpass není dostupný', + 'ferratas.zoomIn': 'Přibližte mapu pro načtení ferrat', + 'ferratas.offline': 'Ferraty vyžadují připojení', + 'ferratas.unknownGrade': 'Obtížnost neznámá', + 'ferratas.unnamed': 'Ferrata bez názvu', + 'ferratas.viewInOsm': 'Zobrazit v OpenStreetMap', + 'ferratas.attribution': 'Data © přispěvatelé OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/de/ferratas.ts b/shared/src/i18n/de/ferratas.ts new file mode 100644 index 000000000..ba9b7848b --- /dev/null +++ b/shared/src/i18n/de/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Klettersteige', + 'ferratas.count': '{count} Klettersteige', + 'ferratas.count.one': '{count} Klettersteig', + 'ferratas.loading': 'Klettersteige werden geladen…', + 'ferratas.error': 'Overpass nicht erreichbar', + 'ferratas.zoomIn': 'Für Klettersteige näher heranzoomen', + 'ferratas.offline': 'Klettersteige brauchen eine Verbindung', + 'ferratas.unknownGrade': 'Schwierigkeit unbekannt', + 'ferratas.unnamed': 'Klettersteig ohne Namen', + 'ferratas.viewInOsm': 'In OpenStreetMap ansehen', + 'ferratas.attribution': 'Daten © OpenStreetMap-Mitwirkende', +}; +export default ferratas; diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/en/ferratas.ts b/shared/src/i18n/en/ferratas.ts new file mode 100644 index 000000000..96004bcba --- /dev/null +++ b/shared/src/i18n/en/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Via ferratas', + 'ferratas.count': '{count} via ferrata routes', + 'ferratas.count.one': '{count} via ferrata route', + 'ferratas.loading': 'Loading via ferratas…', + 'ferratas.error': 'Overpass unreachable', + 'ferratas.zoomIn': 'Zoom in to load via ferratas', + 'ferratas.offline': 'Via ferratas need a connection', + 'ferratas.unknownGrade': 'Grade unknown', + 'ferratas.unnamed': 'Unnamed via ferrata', + 'ferratas.viewInOsm': 'View in OpenStreetMap', + 'ferratas.attribution': 'Data © OpenStreetMap contributors', +}; +export default ferratas; diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/es/ferratas.ts b/shared/src/i18n/es/ferratas.ts new file mode 100644 index 000000000..6aaa24fd1 --- /dev/null +++ b/shared/src/i18n/es/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Vías ferratas', + 'ferratas.count': '{count} vías ferratas', + 'ferratas.count.one': '{count} vía ferrata', + 'ferratas.loading': 'Cargando vías ferratas…', + 'ferratas.error': 'Overpass no disponible', + 'ferratas.zoomIn': 'Acerca el mapa para cargar vías ferratas', + 'ferratas.offline': 'Las vías ferratas necesitan conexión', + 'ferratas.unknownGrade': 'Dificultad desconocida', + 'ferratas.unnamed': 'Vía ferrata sin nombre', + 'ferratas.viewInOsm': 'Ver en OpenStreetMap', + 'ferratas.attribution': 'Datos © colaboradores de OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index f3de150d5..f7d357419 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/et/ferratas.ts b/shared/src/i18n/et/ferratas.ts new file mode 100644 index 000000000..077e207f9 --- /dev/null +++ b/shared/src/i18n/et/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Via ferrata rajad', + 'ferratas.count': '{count} via ferrata rada', + 'ferratas.count.one': '{count} via ferrata rada', + 'ferratas.loading': 'Via ferrata radade laadimine…', + 'ferratas.error': 'Overpass pole kättesaadav', + 'ferratas.zoomIn': 'Via ferrata radade laadimiseks suumi lähemale', + 'ferratas.offline': 'Via ferrata rajad vajavad ühendust', + 'ferratas.unknownGrade': 'Raskusaste teadmata', + 'ferratas.unnamed': 'Nimeta via ferrata', + 'ferratas.viewInOsm': 'Vaata OpenStreetMapis', + 'ferratas.attribution': 'Andmed © OpenStreetMapi kaastöötajad', +}; +export default ferratas; diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index cbe27b3cf..49ed7a462 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/fr/ferratas.ts b/shared/src/i18n/fr/ferratas.ts new file mode 100644 index 000000000..2b33fdf60 --- /dev/null +++ b/shared/src/i18n/fr/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Via ferrata', + 'ferratas.count': '{count} via ferrata', + 'ferratas.count.one': '{count} via ferrata', + 'ferratas.loading': 'Chargement des via ferrata…', + 'ferratas.error': 'Overpass injoignable', + 'ferratas.zoomIn': 'Zoomez pour charger les via ferrata', + 'ferratas.offline': 'Les via ferrata nécessitent une connexion', + 'ferratas.unknownGrade': 'Difficulté inconnue', + 'ferratas.unnamed': 'Via ferrata sans nom', + 'ferratas.viewInOsm': 'Voir dans OpenStreetMap', + 'ferratas.attribution': 'Données © contributeurs OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/gr/ferratas.ts b/shared/src/i18n/gr/ferratas.ts new file mode 100644 index 000000000..0f6b4bca3 --- /dev/null +++ b/shared/src/i18n/gr/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Διαδρομές via ferrata', + 'ferratas.count': '{count} διαδρομές via ferrata', + 'ferratas.count.one': '{count} διαδρομή via ferrata', + 'ferratas.loading': 'Φόρτωση διαδρομών via ferrata…', + 'ferratas.error': 'Το Overpass δεν είναι διαθέσιμο', + 'ferratas.zoomIn': 'Μεγεθύνετε για να φορτωθούν διαδρομές via ferrata', + 'ferratas.offline': 'Οι διαδρομές via ferrata χρειάζονται σύνδεση', + 'ferratas.unknownGrade': 'Άγνωστη δυσκολία', + 'ferratas.unnamed': 'Διαδρομή via ferrata χωρίς όνομα', + 'ferratas.viewInOsm': 'Προβολή στο OpenStreetMap', + 'ferratas.attribution': 'Δεδομένα © συντελεστές του OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/hu/ferratas.ts b/shared/src/i18n/hu/ferratas.ts new file mode 100644 index 000000000..f4abe55dd --- /dev/null +++ b/shared/src/i18n/hu/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Vasalt utak', + 'ferratas.count': '{count} vasalt út', + 'ferratas.count.one': '{count} vasalt út', + 'ferratas.loading': 'Vasalt utak betöltése…', + 'ferratas.error': 'Az Overpass nem érhető el', + 'ferratas.zoomIn': 'Nagyíts rá a vasalt utak betöltéséhez', + 'ferratas.offline': 'A vasalt utakhoz kapcsolat szükséges', + 'ferratas.unknownGrade': 'Ismeretlen nehézség', + 'ferratas.unnamed': 'Névtelen vasalt út', + 'ferratas.viewInOsm': 'Megtekintés az OpenStreetMapen', + 'ferratas.attribution': 'Adatok © OpenStreetMap-közreműködők', +}; +export default ferratas; diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 126153ad8..29eb22621 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/id/ferratas.ts b/shared/src/i18n/id/ferratas.ts new file mode 100644 index 000000000..74686e7d7 --- /dev/null +++ b/shared/src/i18n/id/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Jalur via ferrata', + 'ferratas.count': '{count} jalur via ferrata', + 'ferratas.loading': 'Memuat jalur via ferrata…', + 'ferratas.error': 'Overpass tidak dapat dijangkau', + 'ferratas.zoomIn': 'Perbesar peta untuk memuat jalur via ferrata', + 'ferratas.offline': 'Jalur via ferrata memerlukan koneksi', + 'ferratas.unknownGrade': 'Tingkat kesulitan tidak diketahui', + 'ferratas.unnamed': 'Via ferrata tanpa nama', + 'ferratas.viewInOsm': 'Lihat di OpenStreetMap', + 'ferratas.attribution': 'Data © kontributor OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/it/ferratas.ts b/shared/src/i18n/it/ferratas.ts new file mode 100644 index 000000000..1f972dfa4 --- /dev/null +++ b/shared/src/i18n/it/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Vie ferrate', + 'ferratas.count': '{count} vie ferrate', + 'ferratas.count.one': '{count} via ferrata', + 'ferratas.loading': 'Caricamento vie ferrate…', + 'ferratas.error': 'Overpass non raggiungibile', + 'ferratas.zoomIn': 'Ingrandisci per caricare le vie ferrate', + 'ferratas.offline': 'Le vie ferrate richiedono una connessione', + 'ferratas.unknownGrade': 'Difficoltà sconosciuta', + 'ferratas.unnamed': 'Via ferrata senza nome', + 'ferratas.viewInOsm': 'Vedi su OpenStreetMap', + 'ferratas.attribution': 'Dati © contributori di OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/ja/ferratas.ts b/shared/src/i18n/ja/ferratas.ts new file mode 100644 index 000000000..a2eaca10b --- /dev/null +++ b/shared/src/i18n/ja/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'ヴィア・フェラータ', + 'ferratas.count': '{count} 件のヴィア・フェラータ', + 'ferratas.loading': 'ヴィア・フェラータを読み込み中…', + 'ferratas.error': 'Overpass に接続できません', + 'ferratas.zoomIn': 'ヴィア・フェラータを読み込むには拡大してください', + 'ferratas.offline': 'ヴィア・フェラータの表示には接続が必要です', + 'ferratas.unknownGrade': '難易度不明', + 'ferratas.unnamed': '名称未設定のヴィア・フェラータ', + 'ferratas.viewInOsm': 'OpenStreetMap で表示', + 'ferratas.attribution': 'データ © OpenStreetMap 貢献者', +}; +export default ferratas; diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/ko/ferratas.ts b/shared/src/i18n/ko/ferratas.ts new file mode 100644 index 000000000..8905b7270 --- /dev/null +++ b/shared/src/i18n/ko/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': '비아 페라타', + 'ferratas.count': '비아 페라타 {count}곳', + 'ferratas.loading': '비아 페라타 불러오는 중…', + 'ferratas.error': 'Overpass에 연결할 수 없음', + 'ferratas.zoomIn': '비아 페라타를 불러오려면 확대하세요', + 'ferratas.offline': '비아 페라타를 보려면 연결이 필요합니다', + 'ferratas.unknownGrade': '난이도 알 수 없음', + 'ferratas.unnamed': '이름 없는 비아 페라타', + 'ferratas.viewInOsm': 'OpenStreetMap에서 보기', + 'ferratas.attribution': '데이터 © OpenStreetMap 기여자', +}; +export default ferratas; diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/nl/ferratas.ts b/shared/src/i18n/nl/ferratas.ts new file mode 100644 index 000000000..5bcb833bc --- /dev/null +++ b/shared/src/i18n/nl/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': "Via ferrata's", + 'ferratas.count': "{count} via ferrata's", + 'ferratas.count.one': '{count} via ferrata', + 'ferratas.loading': "Via ferrata's laden…", + 'ferratas.error': 'Overpass niet bereikbaar', + 'ferratas.zoomIn': "Zoom in om via ferrata's te laden", + 'ferratas.offline': "Via ferrata's hebben een verbinding nodig", + 'ferratas.unknownGrade': 'Moeilijkheid onbekend', + 'ferratas.unnamed': 'Via ferrata zonder naam', + 'ferratas.viewInOsm': 'Bekijken in OpenStreetMap', + 'ferratas.attribution': 'Gegevens © OpenStreetMap-bijdragers', +}; +export default ferratas; diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/pl/ferratas.ts b/shared/src/i18n/pl/ferratas.ts new file mode 100644 index 000000000..10f0002ab --- /dev/null +++ b/shared/src/i18n/pl/ferratas.ts @@ -0,0 +1,18 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Ferraty', + 'ferratas.count': '{count} ferraty', + 'ferratas.count.one': '{count} ferrata', + 'ferratas.count.few': '{count} ferraty', + 'ferratas.count.many': '{count} ferrat', + 'ferratas.loading': 'Ładowanie ferrat…', + 'ferratas.error': 'Overpass jest niedostępny', + 'ferratas.zoomIn': 'Przybliż mapę, aby wczytać ferraty', + 'ferratas.offline': 'Ferraty wymagają połączenia', + 'ferratas.unknownGrade': 'Nieznana trudność', + 'ferratas.unnamed': 'Ferrata bez nazwy', + 'ferratas.viewInOsm': 'Pokaż w OpenStreetMap', + 'ferratas.attribution': 'Dane © współtwórcy OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/ru/ferratas.ts b/shared/src/i18n/ru/ferratas.ts new file mode 100644 index 000000000..f12d3d647 --- /dev/null +++ b/shared/src/i18n/ru/ferratas.ts @@ -0,0 +1,18 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Виа ферраты', + 'ferratas.count': '{count} виа ферраты', + 'ferratas.count.one': '{count} виа феррата', + 'ferratas.count.few': '{count} виа ферраты', + 'ferratas.count.many': '{count} виа феррат', + 'ferratas.loading': 'Загрузка виа феррат…', + 'ferratas.error': 'Overpass недоступен', + 'ferratas.zoomIn': 'Приблизьте карту, чтобы загрузить виа ферраты', + 'ferratas.offline': 'Для виа феррат нужно подключение', + 'ferratas.unknownGrade': 'Сложность неизвестна', + 'ferratas.unnamed': 'Виа феррата без названия', + 'ferratas.viewInOsm': 'Открыть в OpenStreetMap', + 'ferratas.attribution': 'Данные © участники OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/sk/ferratas.ts b/shared/src/i18n/sk/ferratas.ts new file mode 100644 index 000000000..fec76dbda --- /dev/null +++ b/shared/src/i18n/sk/ferratas.ts @@ -0,0 +1,17 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Ferraty', + 'ferratas.count': '{count} ferrát', + 'ferratas.count.one': '{count} ferrata', + 'ferratas.count.few': '{count} ferraty', + 'ferratas.loading': 'Načítavajú sa ferraty…', + 'ferratas.error': 'Overpass nie je dostupný', + 'ferratas.zoomIn': 'Priblížte mapu na načítanie ferrát', + 'ferratas.offline': 'Ferraty vyžadujú pripojenie', + 'ferratas.unknownGrade': 'Neznáma obťažnosť', + 'ferratas.unnamed': 'Ferrata bez názvu', + 'ferratas.viewInOsm': 'Zobraziť v OpenStreetMap', + 'ferratas.attribution': 'Údaje © prispievatelia OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index cbe27b3cf..49ed7a462 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/sv/ferratas.ts b/shared/src/i18n/sv/ferratas.ts new file mode 100644 index 000000000..7139c25e1 --- /dev/null +++ b/shared/src/i18n/sv/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Via ferrata-leder', + 'ferratas.count': '{count} via ferrata-leder', + 'ferratas.count.one': '{count} via ferrata-led', + 'ferratas.loading': 'Läser in via ferrata-leder…', + 'ferratas.error': 'Overpass går inte att nå', + 'ferratas.zoomIn': 'Zooma in för att läsa in via ferrata-leder', + 'ferratas.offline': 'Via ferrata-leder kräver en anslutning', + 'ferratas.unknownGrade': 'Okänd svårighetsgrad', + 'ferratas.unnamed': 'Namnlös via ferrata', + 'ferratas.viewInOsm': 'Visa i OpenStreetMap', + 'ferratas.attribution': 'Data © OpenStreetMap-bidragsgivare', +}; +export default ferratas; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/th/ferratas.ts b/shared/src/i18n/th/ferratas.ts new file mode 100644 index 000000000..debc99f44 --- /dev/null +++ b/shared/src/i18n/th/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'เส้นทางเวียเฟอร์ราตา', + 'ferratas.count': '{count} เส้นทางเวียเฟอร์ราตา', + 'ferratas.loading': 'กำลังโหลดเส้นทางเวียเฟอร์ราตา…', + 'ferratas.error': 'ไม่สามารถเชื่อมต่อ Overpass ได้', + 'ferratas.zoomIn': 'ซูมเข้าเพื่อโหลดเส้นทางเวียเฟอร์ราตา', + 'ferratas.offline': 'เส้นทางเวียเฟอร์ราตาต้องใช้การเชื่อมต่อ', + 'ferratas.unknownGrade': 'ไม่ทราบระดับความยาก', + 'ferratas.unnamed': 'เส้นทางเวียเฟอร์ราตาไม่มีชื่อ', + 'ferratas.viewInOsm': 'ดูใน OpenStreetMap', + 'ferratas.attribution': 'ข้อมูล © ผู้ร่วมให้ข้อมูล OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index cbe27b3cf..49ed7a462 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/tr/ferratas.ts b/shared/src/i18n/tr/ferratas.ts new file mode 100644 index 000000000..486bfc4d9 --- /dev/null +++ b/shared/src/i18n/tr/ferratas.ts @@ -0,0 +1,16 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Via ferrata rotaları', + 'ferratas.count': '{count} via ferrata rotası', + 'ferratas.count.one': '{count} via ferrata rotası', + 'ferratas.loading': 'Via ferrata rotaları yükleniyor…', + 'ferratas.error': "Overpass'a ulaşılamıyor", + 'ferratas.zoomIn': 'Via ferrata rotalarını yüklemek için yakınlaştırın', + 'ferratas.offline': 'Via ferrata rotaları için bağlantı gerekiyor', + 'ferratas.unknownGrade': 'Zorluk bilinmiyor', + 'ferratas.unnamed': 'Adsız via ferrata', + 'ferratas.viewInOsm': "OpenStreetMap'te görüntüle", + 'ferratas.attribution': 'Veriler © OpenStreetMap katkıcıları', +}; +export default ferratas; diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/uk/ferratas.ts b/shared/src/i18n/uk/ferratas.ts new file mode 100644 index 000000000..2271b2d81 --- /dev/null +++ b/shared/src/i18n/uk/ferratas.ts @@ -0,0 +1,18 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Віа ферати', + 'ferratas.count': '{count} віа ферати', + 'ferratas.count.one': '{count} віа ферата', + 'ferratas.count.few': '{count} віа ферати', + 'ferratas.count.many': '{count} віа ферат', + 'ferratas.loading': 'Завантаження віа ферат…', + 'ferratas.error': 'Overpass недоступний', + 'ferratas.zoomIn': 'Наблизьте мапу, щоб завантажити віа ферати', + 'ferratas.offline': 'Для віа ферат потрібне з’єднання', + 'ferratas.unknownGrade': 'Складність невідома', + 'ferratas.unnamed': 'Віа ферата без назви', + 'ferratas.viewInOsm': 'Відкрити в OpenStreetMap', + 'ferratas.attribution': 'Дані © учасники OpenStreetMap', +}; +export default ferratas; diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/vi/ferratas.ts b/shared/src/i18n/vi/ferratas.ts new file mode 100644 index 000000000..b8ec85187 --- /dev/null +++ b/shared/src/i18n/vi/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': 'Tuyến via ferrata', + 'ferratas.count': '{count} tuyến via ferrata', + 'ferratas.loading': 'Đang tải các tuyến via ferrata…', + 'ferratas.error': 'Không thể kết nối Overpass', + 'ferratas.zoomIn': 'Phóng to để tải các tuyến via ferrata', + 'ferratas.offline': 'Các tuyến via ferrata cần kết nối mạng', + 'ferratas.unknownGrade': 'Không rõ độ khó', + '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', +}; +export default ferratas; diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/zh-TW/ferratas.ts b/shared/src/i18n/zh-TW/ferratas.ts new file mode 100644 index 000000000..8cbee546e --- /dev/null +++ b/shared/src/i18n/zh-TW/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': '飛拉達', + 'ferratas.count': '{count} 條飛拉達', + 'ferratas.loading': '正在載入飛拉達…', + 'ferratas.error': '無法連線至 Overpass', + 'ferratas.zoomIn': '放大地圖以載入飛拉達', + 'ferratas.offline': '飛拉達需要網路連線', + 'ferratas.unknownGrade': '難度不明', + 'ferratas.unnamed': '未命名的飛拉達', + 'ferratas.viewInOsm': '在 OpenStreetMap 中檢視', + 'ferratas.attribution': '資料 © OpenStreetMap 貢獻者', +}; +export default ferratas; diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index f2bfefa47..0ba02c123 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/i18n/zh/ferratas.ts b/shared/src/i18n/zh/ferratas.ts new file mode 100644 index 000000000..53718a9d5 --- /dev/null +++ b/shared/src/i18n/zh/ferratas.ts @@ -0,0 +1,15 @@ +import type { TranslationStrings } from '../types'; + +const ferratas: TranslationStrings = { + 'ferratas.layer': '飞拉达', + 'ferratas.count': '{count} 条飞拉达', + 'ferratas.loading': '正在加载飞拉达…', + 'ferratas.error': '无法连接 Overpass', + 'ferratas.zoomIn': '放大地图以加载飞拉达', + 'ferratas.offline': '飞拉达需要网络连接', + 'ferratas.unknownGrade': '难度未知', + 'ferratas.unnamed': '未命名飞拉达', + 'ferratas.viewInOsm': '在 OpenStreetMap 中查看', + 'ferratas.attribution': '数据 © OpenStreetMap 贡献者', +}; +export default ferratas; diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index fc4d989cd..e2faa8e69 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -12,6 +12,7 @@ import dawarich from './dawarich'; import day from './day'; import dayplan from './dayplan'; import docsync from './docsync'; +import ferratas from './ferratas'; import files from './files'; import help from './help'; import inspector from './inspector'; @@ -106,5 +107,6 @@ const locale = { ...storage, ...dawarich, ...docsync, + ...ferratas, }; export default locale; diff --git a/shared/src/index.ts b/shared/src/index.ts index 25359ce53..02cbcc826 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -115,3 +115,6 @@ export * from './money/money'; // Trip permission catalog (keys, levels, defaults) and the rule that decides a check. export * from './permissions/permissions'; + +// Via ferratas around a trip, from OpenStreetMap through Overpass (GET /api/trips/:tripId/ferratas). +export * from './ferratas/ferratas.schema';