From 008aaeb4088b2021ce019a522b6910497779e719 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 14:09:54 +0200 Subject: [PATCH] fix(tours): say to zoom in when the pistes are on further out than they draw The pistes overlay has no tiles below zoom 9, so switching it on at a region or country view left the map empty while the switch looked on, which read as broken. Both maps now report their zoom with the viewport, on the 256 px tile scale raster sources count in (a GL map's own zoom is one step lower there), and the planner hands it to its overlays. While the pistes are on and the map is further out than their first tiles, the switch stays pressed in the soft accent, its tooltip and the key's first line say to zoom in, and the screen reader hears it once. An unknown zoom claims nothing. --- .../components/Map/MapLayerSwitcher.test.tsx | 28 +++++++++++- .../src/components/Map/MapLayerSwitcher.tsx | 33 ++++++++++---- client/src/components/Map/MapView.test.tsx | 5 ++- client/src/components/Map/MapView.tsx | 6 +-- client/src/components/Map/MapViewGL.test.tsx | 6 ++- client/src/components/Map/MapViewGL.tsx | 9 ++-- .../src/components/Map/glRasterLayers.test.ts | 9 +++- client/src/components/Map/glRasterLayers.ts | 10 +++++ client/src/components/Map/mapRasterOverlay.ts | 2 + client/src/components/Map/mapViewContract.ts | 6 ++- .../Tours/terrain/TourTerrainMap.tsx | 4 +- .../Tours/terrain/useTerrainFerratas.test.ts | 43 +++++++++++++++++++ .../Tours/terrain/useTerrainFerratas.ts | 22 ++++++---- .../Tours/terrain/useTourTerrain.ts | 4 +- .../Tours/useTourRasterOverlay.test.ts | 16 +++++++ .../components/Tours/useTourRasterOverlay.ts | 10 ++++- .../components/Tours/winter/PisteKey.test.tsx | 13 +++++- .../src/components/Tours/winter/PisteKey.tsx | 24 ++++++++--- .../tripPlanner/TourPlannerMapStage.test.tsx | 14 +++++- .../pages/tripPlanner/TourPlannerMapStage.tsx | 8 +++- .../tripPlanner/useTourStageLayers.test.ts | 13 +++++- .../pages/tripPlanner/useTourStageLayers.ts | 11 +++-- shared/src/i18n/ar/tours.ts | 1 + shared/src/i18n/az/tours.ts | 1 + shared/src/i18n/br/tours.ts | 1 + shared/src/i18n/ca/tours.ts | 1 + shared/src/i18n/cs/tours.ts | 1 + shared/src/i18n/de/tours.ts | 1 + shared/src/i18n/en/tours.ts | 1 + shared/src/i18n/es/tours.ts | 1 + shared/src/i18n/et/tours.ts | 1 + shared/src/i18n/fr/tours.ts | 1 + shared/src/i18n/gr/tours.ts | 1 + shared/src/i18n/hu/tours.ts | 1 + shared/src/i18n/id/tours.ts | 1 + shared/src/i18n/it/tours.ts | 1 + shared/src/i18n/ja/tours.ts | 1 + shared/src/i18n/ko/tours.ts | 1 + shared/src/i18n/nl/tours.ts | 1 + shared/src/i18n/pl/tours.ts | 1 + shared/src/i18n/ru/tours.ts | 1 + shared/src/i18n/sk/tours.ts | 1 + shared/src/i18n/sv/tours.ts | 1 + shared/src/i18n/th/tours.ts | 1 + shared/src/i18n/tours-locales.spec.ts | 2 +- shared/src/i18n/tr/tours.ts | 1 + shared/src/i18n/uk/tours.ts | 1 + shared/src/i18n/vi/tours.ts | 1 + shared/src/i18n/zh-TW/tours.ts | 1 + shared/src/i18n/zh/tours.ts | 1 + 50 files changed, 274 insertions(+), 51 deletions(-) create mode 100644 client/src/components/Tours/terrain/useTerrainFerratas.test.ts diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index 61e4b80fc..edc006142 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-023 +// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-024 import React from 'react' import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '../../../tests/helpers/render' @@ -245,6 +245,7 @@ const pisteOverlay = (overrides: Partial = {}): Togglea maxZoom: 18, active: false, status: 'off', + belowMinZoom: false, onToggle: vi.fn(), onTileError: vi.fn(), onTileLoad: vi.fn(), @@ -285,4 +286,29 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => { fireEvent.mouseEnter(toggle) expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes are not loading.') }) + + it('FE-COMP-MAPLAYER-024: on further out than the pistes reach, the switch stays pressed in the soft accent and says to zoom in', async () => { + const { rerender } = render( + {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on', belowMinZoom: true })] }} /> + ) + const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' }) + expect(toggle).toHaveAttribute('aria-pressed', 'true') + expect(toggle).toHaveAttribute('data-below-min-zoom', 'true') + expect(toggle).toHaveClass('bg-accent-soft', 'text-accent') + expect(screen.getByRole('status')).toHaveTextContent('Zoom in to see the pistes') + fireEvent.mouseEnter(toggle) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to see the pistes') + // The key stays at hand and opens with the same line on top. + fireEvent.click(screen.getByTestId('piste-key-button')) + expect(await screen.findByTestId('piste-key-zoom-in')).toHaveTextContent('Zoom in to see the pistes') + + // A source that stopped answering says so instead, and close enough the switch is plainly on. + rerender( {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down', belowMinZoom: true })] }} />) + expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading.') + rerender( {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />) + const plain = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' }) + expect(plain).not.toHaveAttribute('data-below-min-zoom') + expect(plain).toHaveClass('bg-accent', 'text-accent-text') + expect(screen.getByRole('status')).toHaveTextContent('') + }) }) diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index b2de1da97..810fb62e8 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -201,14 +201,23 @@ interface OverlayLook { downKey: string /** On its icon alone, named by its tooltip, so the row stays short beside a type's other switches. */ iconOnly?: boolean - /** The overlay's key, beside its switch while the layer shows. */ - Key?: ComponentType + /** What the switch says while the map is further out than the overlay's first tiles. */ + zoomInKey?: string + /** The overlay's key, beside its switch while the layer is on; told when nothing of it is drawn yet. */ + Key?: ComponentType<{ belowMinZoom: boolean }> } const OVERLAY_LOOK: Record = { seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' }, // SKI-01: a hike's row already holds four maps, the ferratas and 3D, so the pistes go by their icon. - pistes: { Icon: MountainSnow, labelKey: 'tours.map.pistes', downKey: 'tours.map.pistesDown', iconOnly: true, Key: PisteKeyButton }, + pistes: { + Icon: MountainSnow, + labelKey: 'tours.map.pistes', + downKey: 'tours.map.pistesDown', + zoomInKey: 'tours.map.pistesZoomIn', + iconOnly: true, + Key: PisteKeyButton, + }, } /** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */ @@ -224,8 +233,10 @@ function DownBadge() { * The switch for a transparent overlay (the seamarks, the pistes), the way the via * ferrata switch looks: on in the accent, and in the warning tone with a badge * once its source stopped answering, which the tooltip names. The map and the - * planning go on without it. An overlay with a key shows the key's button - * beside the switch while its tiles arrive. + * planning go on without it. Switched on further out than its first tiles, it + * stays pressed in the soft accent and its tooltip says to zoom in, so an empty + * map does not read as broken. An overlay with a key shows the key's button + * beside the switch while it is on and answering. */ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) { const { t } = useTranslation() @@ -234,9 +245,12 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; const label = t(look.labelKey) const down = overlay.status === 'down' const on = overlay.status !== 'off' + const zoomIn = !down && overlay.belowMinZoom && look.zoomInKey ? t(look.zoomInKey) : null + const notice = down ? t(look.downKey) : zoomIn + const tone = zoomIn ? 'bg-accent-soft text-accent' : 'bg-accent text-accent-text' return ( <> - + - {look.Key && overlay.status === 'on' && } - {down ? t(look.downKey) : ''} + {look.Key && overlay.status === 'on' && } + {notice ?? ''} ) } diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index a0112db1c..5f8d6b6b8 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -1141,10 +1141,11 @@ describe('MapView plugin route vias', () => { }) describe('MapView map event wiring', () => { - it('FE-COMP-MAPVIEW-043: reports the viewport bbox once the map is ready and again on every move', () => { + it('FE-COMP-MAPVIEW-043: reports the viewport bbox and its zoom once the map is ready and again on every move', () => { const onViewportChange = vi.fn() + mapMock.getZoom.mockReturnValue(10) render() - expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 }) + expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 }, 10) const moveend = handlerFor('moveend') const zoomend = handlerFor('zoomend') diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index e3a51f8c3..37aacd90b 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -340,14 +340,14 @@ function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string } // Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch -// OSM places for the visible area. -function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) { +// OSM places for the visible area, with the zoom an overlay's minimum is read against. +function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) { const map = useMap() useEffect(() => { if (!onViewportChange) return const emit = () => { const b = map.getBounds() - onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }) + onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom()) } map.whenReady(emit) // ensure the first bbox is captured once the map is laid out map.on('moveend', emit) diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index d05411e1c..db80c2909 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -2215,13 +2215,15 @@ describe('MapViewGL', () => { expect((window as any).__trek_map).toBeUndefined() }) - it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => { + it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill, with the zoom on the raster tile scale', async () => { const onViewportChange = vi.fn() + vi.mocked(glMap.getZoom).mockReturnValue(10) render() await act(async () => {}) act(() => { mapHandler('moveend')() }) - expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 }) + // The GL engine counts on 512 px tiles; a raster tile's zoom is one step further in. + expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 }, 11) }) it('FE-COMP-MAPVIEWGL-060: a new POI or via set replaces the previous overlay markers', async () => { diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 062cbe634..a4121a45b 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -8,7 +8,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n/TranslationContext' -import { applyBaseLayer } from './glRasterLayers' +import { applyBaseLayer, rasterTileZoom } from './glRasterLayers' import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' @@ -1433,10 +1433,13 @@ export function MapViewGL({ 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 - // pill can fetch OSM places for the visible area. + // pill can fetch OSM places for the visible area, with the zoom on the raster tile scale. const emitViewport = () => { const b = map.getBounds() - onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }) + onViewportChangeRef.current?.( + { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, + rasterTileZoom(map.getZoom()), + ) } map.on('moveend', emitViewport) map.once('idle', emitViewport) diff --git a/client/src/components/Map/glRasterLayers.test.ts b/client/src/components/Map/glRasterLayers.test.ts index 434ee261e..8723826dc 100644 --- a/client/src/components/Map/glRasterLayers.test.ts +++ b/client/src/components/Map/glRasterLayers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers'; +import { applyBaseLayer, rasterTileZoom, tourRasterSourceId } from './glRasterLayers'; /** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */ function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) { @@ -53,3 +53,10 @@ describe('applyBaseLayer (MAP-01)', () => { expect(() => applyBaseLayer(map, 'hiking')).not.toThrow(); }); }); + +describe('rasterTileZoom', () => { + it('puts a GL zoom on the 256 px tile scale one step further in, where a raster minimum is read', () => { + expect(rasterTileZoom(8)).toBe(9); + expect(rasterTileZoom(7.5)).toBe(8.5); + }); +}); diff --git a/client/src/components/Map/glRasterLayers.ts b/client/src/components/Map/glRasterLayers.ts index 57e288875..7774b5a2b 100644 --- a/client/src/components/Map/glRasterLayers.ts +++ b/client/src/components/Map/glRasterLayers.ts @@ -27,6 +27,16 @@ const SATELLITE_LAYER_ID = 'trip-satellite-raster'; // `trip-` layer, and anchoring the imagery there painted right over them. export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']; +/** + * A GL map's zoom on the 256 px tile scale that raster tile URLs, their + * min and max zoom, and Leaflet count in. The GL engines count on 512 px + * tiles, so the same view is one step further in on that scale, and a 256 px + * raster source is drawn from its tiles one level up. + */ +export function rasterTileZoom(glZoom: number): number { + return glZoom + 1; +} + /** * Puts the imagery on the map, or takes it off again. * diff --git a/client/src/components/Map/mapRasterOverlay.ts b/client/src/components/Map/mapRasterOverlay.ts index 413bd7fd1..8c40d792d 100644 --- a/client/src/components/Map/mapRasterOverlay.ts +++ b/client/src/components/Map/mapRasterOverlay.ts @@ -31,6 +31,8 @@ type RasterOverlayStatus = 'off' | 'on' | 'down'; /** An overlay with its switch: whether it is on, and whether its source stopped answering. */ export interface ToggleableRasterOverlay extends MapRasterOverlay { status: RasterOverlayStatus; + /** On, but the map is further out than its first tiles, so nothing of it is drawn yet. */ + belowMinZoom: boolean; onToggle: () => void; } diff --git a/client/src/components/Map/mapViewContract.ts b/client/src/components/Map/mapViewContract.ts index 2ce46717a..f8ebe9e1a 100644 --- a/client/src/components/Map/mapViewContract.ts +++ b/client/src/components/Map/mapViewContract.ts @@ -180,7 +180,11 @@ export interface MapViewProps { roadtripVias?: Record; onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void; onRemoveVia?: (dayId: number, id: number) => void; - onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void; + /** + * The visible area after every move, and the zoom on the 256 px tile scale + * raster tile URLs count in (a GL map's own zoom is one step lower there). + */ + onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }, zoom: number) => void; /** * The raster tile URL template. Only the Leaflet renderer draws raster tiles; * the GL renderer draws the style of the chosen provider and ignores it. diff --git a/client/src/components/Tours/terrain/TourTerrainMap.tsx b/client/src/components/Tours/terrain/TourTerrainMap.tsx index c24692f79..6a37c5a55 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.tsx @@ -44,8 +44,8 @@ export interface TourTerrainMapProps { ferrataLayer?: FerrataMapLayer; /** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */ tourLayers?: TourLayerOffer; - /** The visible area, for the via ferrata layer to ask for. */ - onViewportChange?: (bbox: FerrataBbox) => void; + /** The visible area, for the via ferrata layer to ask for, and the zoom an overlay's minimum is read against. */ + onViewportChange?: (bbox: FerrataBbox, zoom: number) => void; /** How high the controls along the bottom edge reach, every row of them, for what floats above them. */ onControlsHeightChange?: (px: number) => void; /** The "set points" switch in the layer pill, while a tour is being edited. */ diff --git a/client/src/components/Tours/terrain/useTerrainFerratas.test.ts b/client/src/components/Tours/terrain/useTerrainFerratas.test.ts new file mode 100644 index 000000000..6717e24bc --- /dev/null +++ b/client/src/components/Tours/terrain/useTerrainFerratas.test.ts @@ -0,0 +1,43 @@ +// FE-TOUR-TERRAIN-FERRATAS-001 to FE-TOUR-TERRAIN-FERRATAS-002 +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useTerrainFerratas } from './useTerrainFerratas'; +import type { TerrainGl } from './useTerrainMap'; + +vi.mock('../../Ferratas/useFerrataLayerGL', () => ({ useFerrataMapLayerGL: vi.fn() })); + +/** Enough of a MapLibre map for the viewport: its bounds, its zoom, and the events it is told about. */ +function fakeGl(loaded: boolean) { + const handlers = new Map void>(); + const map = { + getBounds: () => ({ getSouth: () => 46, getWest: () => 7, getNorth: () => 47, getEast: () => 8 }), + getZoom: vi.fn(() => 8), + loaded: () => loaded, + on: vi.fn((event: string, handler: () => void) => handlers.set(event, handler)), + off: vi.fn((event: string) => handlers.delete(event)), + }; + return { gl: { map } as unknown as TerrainGl, handlers, map }; +} + +describe('useTerrainFerratas', () => { + it('FE-TOUR-TERRAIN-FERRATAS-001: reports the visible area at once on a loaded map, with the zoom on the raster tile scale, and after every move', () => { + const { gl, handlers, map } = fakeGl(true); + const onViewportChange = vi.fn(); + renderHook(() => useTerrainFerratas(gl, undefined, onViewportChange)); + expect(onViewportChange).toHaveBeenCalledWith({ south: 46, west: 7, north: 47, east: 8 }, 9); + map.getZoom.mockReturnValue(10); + handlers.get('moveend')?.(); + expect(onViewportChange).toHaveBeenLastCalledWith({ south: 46, west: 7, north: 47, east: 8 }, 11); + }); + + it('FE-TOUR-TERRAIN-FERRATAS-002: waits for a map still loading, and lets go of its events when done', () => { + const { gl, handlers } = fakeGl(false); + const onViewportChange = vi.fn(); + const { unmount } = renderHook(() => useTerrainFerratas(gl, undefined, onViewportChange)); + expect(onViewportChange).not.toHaveBeenCalled(); + handlers.get('load')?.(); + expect(onViewportChange).toHaveBeenCalledTimes(1); + unmount(); + expect(handlers.size).toBe(0); + }); +}); diff --git a/client/src/components/Tours/terrain/useTerrainFerratas.ts b/client/src/components/Tours/terrain/useTerrainFerratas.ts index b33d3a70e..a27c6dda1 100644 --- a/client/src/components/Tours/terrain/useTerrainFerratas.ts +++ b/client/src/components/Tours/terrain/useTerrainFerratas.ts @@ -4,31 +4,35 @@ import type { FerrataBbox } from '@trek/shared'; import { type FerrataGlMap, useFerrataMapLayerGL } from '../../Ferratas/useFerrataLayerGL'; import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer'; +import { rasterTileZoom } from '../../Map/glRasterLayers'; 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 + * draws: the visible area, with the zoom on the raster tile scale, 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 + onViewportChange: ((bbox: FerrataBbox, zoom: number) => 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(), - }); + onViewportChange( + { + south: bounds.getSouth(), + west: bounds.getWest(), + north: bounds.getNorth(), + east: bounds.getEast(), + }, + rasterTileZoom(map.getZoom()) + ); }; map.on('moveend', emit); if (map.loaded()) emit(); diff --git a/client/src/components/Tours/terrain/useTourTerrain.ts b/client/src/components/Tours/terrain/useTourTerrain.ts index 17f5fedd0..e7bd0e070 100644 --- a/client/src/components/Tours/terrain/useTourTerrain.ts +++ b/client/src/components/Tours/terrain/useTourTerrain.ts @@ -28,8 +28,8 @@ interface Options { /** The via ferrata layer of the planner map, drawn here too while it is on. */ ferrataLayer?: FerrataMapLayer; tourLayers?: TourLayerOffer; - /** The visible area, for the via ferrata layer to ask for. */ - onViewportChange?: (bbox: FerrataBbox) => void; + /** The visible area, for the via ferrata layer to ask for, and the zoom an overlay's minimum is read against. */ + onViewportChange?: (bbox: FerrataBbox, zoom: number) => void; /** A paddle tour: the route is never coloured by grade. */ water?: boolean; } diff --git a/client/src/components/Tours/useTourRasterOverlay.test.ts b/client/src/components/Tours/useTourRasterOverlay.test.ts index 5d1db1abc..4549bae5f 100644 --- a/client/src/components/Tours/useTourRasterOverlay.test.ts +++ b/client/src/components/Tours/useTourRasterOverlay.test.ts @@ -66,4 +66,20 @@ describe('useTourRasterOverlay', () => { expect(toggle).toHaveBeenCalledTimes(1); expect(result.current.status).toBe('on'); }); + + it('marks an overlay that is on while the map is further out than its first tiles, and nothing at an unknown zoom', () => { + const { result, rerender } = renderHook( + ({ active, zoom }: { active: boolean; zoom: number | null }) => + useTourRasterOverlay(SEAMARKS, true, active, vi.fn(), zoom), + { initialProps: { active: true, zoom: null as number | null } } + ); + expect(result.current.belowMinZoom).toBe(false); + rerender({ active: true, zoom: SEAMARKS.minZoom - 0.5 }); + expect(result.current).toMatchObject({ status: 'on', belowMinZoom: true }); + rerender({ active: true, zoom: SEAMARKS.minZoom }); + expect(result.current.belowMinZoom).toBe(false); + // Off, there is nothing to zoom in for. + rerender({ active: false, zoom: 2 }); + expect(result.current).toMatchObject({ status: 'off', belowMinZoom: false }); + }); }); diff --git a/client/src/components/Tours/useTourRasterOverlay.ts b/client/src/components/Tours/useTourRasterOverlay.ts index 9c7a40302..6bf6c5fb5 100644 --- a/client/src/components/Tours/useTourRasterOverlay.ts +++ b/client/src/components/Tours/useTourRasterOverlay.ts @@ -45,15 +45,20 @@ export function useBaseMapHealth(baseLayer: string): BaseMapHealth | undefined { * An overlay the planner offers, off until switched on; whether it is on is * the planner's (`active`, `onToggle`), so it outlasts the map. Its tile * health shows on the switch, and switching it off and on again tries afresh. + * While it is on and the map (`zoom`, on the raster tile scale) is further out + * than its first tiles, it is marked so: nothing is drawn there, and the switch + * says to zoom in. An unknown zoom marks nothing. */ export function useTourRasterOverlay( definition: Pick, offered: boolean, active: boolean, - toggle: () => void + toggle: () => void, + zoom: number | null = null ): ToggleableRasterOverlay { const { down, onTileError, onTileLoad, reset } = useTileHealth(); const on = active && offered; + const belowMinZoom = on && zoom !== null && zoom < definition.minZoom; const onToggle = useCallback(() => { reset(); @@ -65,10 +70,11 @@ export function useTourRasterOverlay( ...definition, active: on, status: on ? (down ? 'down' : 'on') : 'off', + belowMinZoom, onToggle, onTileError, onTileLoad, }), - [definition, down, on, onTileError, onTileLoad, onToggle] + [definition, down, on, belowMinZoom, onTileError, onTileLoad, onToggle] ); } diff --git a/client/src/components/Tours/winter/PisteKey.test.tsx b/client/src/components/Tours/winter/PisteKey.test.tsx index 9ce50a875..6bda0acb4 100644 --- a/client/src/components/Tours/winter/PisteKey.test.tsx +++ b/client/src/components/Tours/winter/PisteKey.test.tsx @@ -1,4 +1,4 @@ -// FE-TOUR-PISTE-KEY-001 to FE-TOUR-PISTE-KEY-005 +// FE-TOUR-PISTE-KEY-001 to FE-TOUR-PISTE-KEY-006 import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; import { fireEvent, render, screen, within } from '../../../../tests/helpers/render'; @@ -77,4 +77,15 @@ describe('PisteKeyButton (SKI-01)', () => { fireEvent.mouseDown(document.body); expect(key()).toBeNull(); }); + + it('FE-TOUR-PISTE-KEY-006: opened further out than the pistes reach, the key says first to zoom in, and not once close', async () => { + const { rerender } = render(); + await userEvent.click(trigger()); + expect(within(key() as HTMLElement).getByTestId('piste-key-zoom-in')).toHaveTextContent( + 'Zoom in to see the pistes' + ); + rerender(); + expect(screen.queryByTestId('piste-key-zoom-in')).toBeNull(); + expect(screen.getByTestId('piste-key-grades')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Tours/winter/PisteKey.tsx b/client/src/components/Tours/winter/PisteKey.tsx index 862c3daf5..7b7dddcce 100644 --- a/client/src/components/Tours/winter/PisteKey.tsx +++ b/client/src/components/Tours/winter/PisteKey.tsx @@ -1,4 +1,4 @@ -import { CableCar, ExternalLink, Info } from 'lucide-react'; +import { CableCar, ExternalLink, Info, ZoomIn } from 'lucide-react'; import { useEffect } from 'react'; import { useTranslation } from '../../../i18n'; import { SoftPill } from '../../Planner/planParts'; @@ -75,8 +75,12 @@ function WayLine({ colour, dotted = false }: { colour: string; dotted?: boolean const ROW = 'flex min-w-0 items-center gap-2 text-content-secondary'; -/** The key's content: the downhill grades, the other ways, what the colours do not say, and where the full key is. */ -function PisteKeyBody() { +/** + * The key's content: the downhill grades, the other ways, what the colours do + * not say, and where the full key is. Opened further out than the layer's first + * tiles, it says first that the map has to come closer before anything shows. + */ +function PisteKeyBody({ belowMinZoom }: { belowMinZoom: boolean }) { const { t } = useTranslation(); return (
@@ -86,6 +90,16 @@ function PisteKeyBody() { OpenSnowMap
+ {belowMinZoom && ( +

+ + {t('tours.map.pistesZoomIn')} +

+ )}
{t('tours.map.pisteDownhill')}
    @@ -132,7 +146,7 @@ function PisteKeyBody() { * it reads as the key at a glance; the key itself opens above the map in the * popover look and closes on Escape or a click beside it, the map untouched. */ -export function PisteKeyButton() { +export function PisteKeyButton({ belowMinZoom = false }: { belowMinZoom?: boolean }) { const { t } = useTranslation(); const list = usePopoverList(-1, { estimatedHeight: 380 }); const { open, box, toggle, triggerRef, menuRef, listId } = list; @@ -166,7 +180,7 @@ export function PisteKeyButton() { - + ); diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx index 519c1b2aa..f325d96b4 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.test.tsx @@ -1,4 +1,4 @@ -// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-023 +// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-024 import type { Ferrata, PaddleFeature } from '@trek/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'; @@ -700,5 +700,17 @@ describe('TourPlannerMapStage', () => { other.unmount(); } }); + + it('FE-PAGE-TOURSTAGE-024: the pistes know the zoom the map reported, so switched on far out they say to zoom in (SKI-01)', () => { + renderStage(planner('neutral', { mapOverlays: new Set(['pistes']) })); + const pistes = () => flat().tourLayers?.overlays?.find((overlay) => overlay.id === 'pistes'); + const area = { south: 46, west: 7, north: 48, east: 12 }; + // No zoom reported yet: nothing is claimed. + expect(pistes()).toMatchObject({ status: 'on', belowMinZoom: false }); + act(() => flat().onViewportChange?.(area, 6)); + expect(pistes()?.belowMinZoom).toBe(true); + act(() => flat().onViewportChange?.(area, 12)); + expect(pistes()?.belowMinZoom).toBe(false); + }); }); }); diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 5ee811732..78e235770 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -103,15 +103,19 @@ export function TourPlannerMapStage({ ); const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis; const ferrata = useFerrataMapLayer(tripId, ferrataArea, analysis?.routeCoordinates ?? null); + // The zoom either map last reported, on the raster tile scale; unknown until the first one did. + const [mapZoom, setMapZoom] = useState(null); // The maps this tour's type offers: its base layers, its overlays, and the ferratas only where they belong. const { baseLayer, tourLayers, ferrataLayer, ferrataCrossings, ferrataSelected } = useTourStageLayers( tourPlanner, - ferrata + ferrata, + mapZoom ); const ferrataViewportChange = ferrata.onViewportChange; const viewportChanged = useCallback( - (bounds: FerrataBbox) => { + (bounds: FerrataBbox, zoom: number) => { ferrataViewportChange(bounds); + setMapZoom(zoom); onViewportChange?.(bounds); }, [ferrataViewportChange, onViewportChange] diff --git a/client/src/pages/tripPlanner/useTourStageLayers.test.ts b/client/src/pages/tripPlanner/useTourStageLayers.test.ts index 7ae482158..2b428eff7 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.test.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.test.ts @@ -18,7 +18,8 @@ function planner( return { mode, tourType, mapBaseLayer, mapOverlays: new Set(), toggleMapOverlay: () => {} }; } -const basesOf = (p: Planner) => renderHook(() => useTourStageLayers(p, ferrata)).result.current; +const basesOf = (p: Planner, zoom: number | null = null) => + renderHook(() => useTourStageLayers(p, ferrata, zoom)).result.current; describe('useTourStageLayers: OpenHikingMap (MAP-01)', () => { beforeEach(() => useAddonStore.setState({ toursHikingMap: true, toursCycleMap: true })); @@ -88,4 +89,14 @@ describe('useTourStageLayers: the pistes (SKI-01)', () => { ['pistes', 'on'], ]); }); + + it('marks them on but out of reach while the map is further out than their first tiles, and not once it is close', () => { + const p = { ...planner({ type: 'neutral' }, 'hike', 'default'), mapOverlays: new Set(['pistes']) }; + const pistesAt = (zoom: number | null) => + basesOf(p, zoom).tourLayers.overlays.find((overlay) => overlay.id === 'pistes'); + expect(pistesAt(6)).toMatchObject({ status: 'on', belowMinZoom: true }); + expect(pistesAt(9)).toMatchObject({ status: 'on', belowMinZoom: false }); + // Until the map reported a zoom, nothing is claimed. + expect(pistesAt(null)?.belowMinZoom).toBe(false); + }); }); diff --git a/client/src/pages/tripPlanner/useTourStageLayers.ts b/client/src/pages/tripPlanner/useTourStageLayers.ts index a87617f00..4c778c492 100644 --- a/client/src/pages/tripPlanner/useTourStageLayers.ts +++ b/client/src/pages/tripPlanner/useTourStageLayers.ts @@ -21,10 +21,13 @@ type FerrataLayerState = ReturnType; * does not offer falls back to its default without changing the choice kept, * which is also what an install whose admin withdrew OpenHikingMap sees. * The pistes (SKI-01) are offered with no tour open too, whatever type was chosen last. + * `mapZoom` is the map's zoom on the raster tile scale, null until it reported one, so an + * overlay switched on further out than its tiles reach can say so instead of looking broken. */ export function useTourStageLayers( tourPlanner: Pick, - ferrata: FerrataLayerState + ferrata: FerrataLayerState, + mapZoom: number | null = null ) { const { mode } = tourPlanner; // MAP-01, BIK-06: a type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour) while a @@ -48,7 +51,8 @@ export function useTourStageLayers( SEAMARK_OVERLAY, offersSeamarks, tourPlanner.mapOverlays.has(SEAMARK_OVERLAY.id), - toggleSeamarks + toggleSeamarks, + mapZoom ); // SKI-01: the pistes for the types they serve, and with no tour open, so a ski area can be // looked at without any route. Switching them never touches the tour: the switch is the map view's. @@ -58,7 +62,8 @@ export function useTourStageLayers( PISTE_OVERLAY, offersPistes, tourPlanner.mapOverlays.has(PISTE_OVERLAY.id), - togglePistes + togglePistes, + mapZoom ); const overlays = useMemo( () => [...(offersSeamarks ? [seamarks] : []), ...(offersPistes ? [pistes] : [])], diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index ba584ee21..44feb8d30 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -699,6 +699,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'لا يتم تحميل العلامات البحرية. الخريطة ومسارك يعملان كالمعتاد.', 'tours.map.pistes': 'المنحدرات ومسارات التزلج الريفي والمصاعد', 'tours.map.pistesDown': 'لا يتم تحميل المنحدرات. الخريطة ومسارك يعملان كالمعتاد.', + 'tours.map.pistesZoomIn': 'كبّر الخريطة لرؤية المنحدرات', 'tours.map.pisteKey': 'مفتاح المنحدرات', 'tours.map.pisteDownhill': 'منحدرات الهبوط حسب الصعوبة', 'tours.map.pisteGrade.novice': 'مبتدئ', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index c3fe118fd..458789531 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -702,6 +702,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Dəniz nişanları yüklənmir. Xəritə və marşrutun işləməyə davam edir.', 'tours.map.pistes': 'Trasslar, xizək qaçışı yolları və liftlər', 'tours.map.pistesDown': 'Trasslar yüklənmir. Xəritə və marşrutun işləməyə davam edir.', + 'tours.map.pistesZoomIn': 'Trassları görmək üçün yaxınlaşdırın', 'tours.map.pisteKey': 'Trass legendası', 'tours.map.pisteDownhill': 'Enmə trassları çətinliyə görə', 'tours.map.pisteGrade.novice': 'Başlanğıc', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index c119e8206..164588de9 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -707,6 +707,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Os sinais náuticos não estão carregando. O mapa e sua rota continuam funcionando.', 'tours.map.pistes': 'Pistas, trilhas de esqui de fundo e teleféricos', 'tours.map.pistesDown': 'As pistas não estão carregando. O mapa e sua rota continuam funcionando.', + 'tours.map.pistesZoomIn': 'Aproxime o mapa para ver as pistas', 'tours.map.pisteKey': 'Legenda das pistas', 'tours.map.pisteDownhill': 'Pistas de descida por dificuldade', 'tours.map.pisteGrade.novice': 'Iniciante', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index 5bcdd47ff..a6f5d3a7d 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -708,6 +708,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Els senyals nàutics no es carreguen. El mapa i la ruta continuen funcionant.', 'tours.map.pistes': 'Pistes, circuits de fons i remuntadors', 'tours.map.pistesDown': 'Les pistes no es carreguen. El mapa i la ruta continuen funcionant.', + 'tours.map.pistesZoomIn': 'Apropa el mapa per veure les pistes', 'tours.map.pisteKey': 'Llegenda de pistes', 'tours.map.pisteDownhill': 'Pistes de descens per dificultat', 'tours.map.pisteGrade.novice': 'Principiant', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 860046dc7..7bf6d552c 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -695,6 +695,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Plavební znaky se nenačítají. Mapa i vaše trasa dál fungují.', 'tours.map.pistes': 'Sjezdovky, běžkařské tratě a vleky', 'tours.map.pistesDown': 'Sjezdovky se nenačítají. Mapa i vaše trasa dál fungují.', + 'tours.map.pistesZoomIn': 'Přibližte mapu, abyste viděli sjezdovky', 'tours.map.pisteKey': 'Legenda sjezdovek', 'tours.map.pisteDownhill': 'Sjezdovky podle obtížnosti', 'tours.map.pisteGrade.novice': 'Začátečník', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 9125f83c1..dc839c14e 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -712,6 +712,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Seezeichen laden gerade nicht. Karte und Route funktionieren weiter.', 'tours.map.pistes': 'Pisten, Loipen und Lifte', 'tours.map.pistesDown': 'Pisten laden gerade nicht. Karte und Route funktionieren weiter.', + 'tours.map.pistesZoomIn': 'Hineinzoomen, um die Pisten zu sehen', 'tours.map.pisteKey': 'Pistenlegende', 'tours.map.pisteDownhill': 'Abfahrten nach Schwierigkeit', 'tours.map.pisteGrade.novice': 'Anfänger', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index ac23d116e..cf5e4dedf 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -695,6 +695,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Seamarks are not loading. The map and your route still work.', 'tours.map.pistes': 'Pistes, cross-country trails and lifts', 'tours.map.pistesDown': 'Pistes are not loading. The map and your route still work.', + 'tours.map.pistesZoomIn': 'Zoom in to see the pistes', 'tours.map.pisteKey': 'Piste key', 'tours.map.pisteDownhill': 'Downhill runs by difficulty', 'tours.map.pisteGrade.novice': 'Novice', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 5cb0f134c..f9f76ef49 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -707,6 +707,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Las señales náuticas no cargan. El mapa y tu ruta siguen funcionando.', 'tours.map.pistes': 'Pistas, circuitos de fondo y remontes', 'tours.map.pistesDown': 'Las pistas no cargan. El mapa y tu ruta siguen funcionando.', + 'tours.map.pistesZoomIn': 'Acerca el mapa para ver las pistas', 'tours.map.pisteKey': 'Leyenda de pistas', 'tours.map.pisteDownhill': 'Pistas de descenso por dificultad', 'tours.map.pisteGrade.novice': 'Principiante', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index acc6f3f77..b3129ad65 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -698,6 +698,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Navigatsioonimärgid ei laadi. Kaart ja sinu marsruut töötavad edasi.', 'tours.map.pistes': 'Nõlvad, suusarajad ja tõstukid', 'tours.map.pistesDown': 'Nõlvad ei laadi. Kaart ja sinu marsruut töötavad edasi.', + 'tours.map.pistesZoomIn': 'Suumi lähemale, et nõlvu näha', 'tours.map.pisteKey': 'Nõlvade legend', 'tours.map.pisteDownhill': 'Laskumisnõlvad raskuse järgi', 'tours.map.pisteGrade.novice': 'Algaja', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 15c9f2c9d..15beb05c2 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -717,6 +717,7 @@ const tours: TranslationStrings = { 'Le balisage maritime ne se charge pas. La carte et votre itinéraire fonctionnent toujours.', 'tours.map.pistes': 'Pistes, pistes de fond et remontées', 'tours.map.pistesDown': 'Les pistes ne se chargent pas. La carte et votre itinéraire fonctionnent toujours.', + 'tours.map.pistesZoomIn': 'Zoomez pour voir les pistes', 'tours.map.pisteKey': 'Légende des pistes', 'tours.map.pisteDownhill': 'Pistes de descente par difficulté', 'tours.map.pisteGrade.novice': 'Débutant', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index bb3e1ca2c..9132b7ef2 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -719,6 +719,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Τα ναυτικά σήματα δεν φορτώνουν. Ο χάρτης και η διαδρομή σας λειτουργούν κανονικά.', 'tours.map.pistes': 'Πίστες, διαδρομές αντοχής και αναβατήρες', 'tours.map.pistesDown': 'Οι πίστες δεν φορτώνουν. Ο χάρτης και η διαδρομή σας λειτουργούν κανονικά.', + 'tours.map.pistesZoomIn': 'Μεγεθύνετε για να δείτε τις πίστες', 'tours.map.pisteKey': 'Υπόμνημα πιστών', 'tours.map.pisteDownhill': 'Πίστες κατάβασης ανά δυσκολία', 'tours.map.pisteGrade.novice': 'Αρχάριοι', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index de7cfd19a..69e880ba8 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -709,6 +709,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'A hajózási jelek nem töltődnek be. A térkép és az útvonalad továbbra is működik.', 'tours.map.pistes': 'Pályák, sífutópályák és felvonók', 'tours.map.pistesDown': 'A pályák nem töltődnek be. A térkép és az útvonalad továbbra is működik.', + 'tours.map.pistesZoomIn': 'Nagyíts rá a pályák megjelenítéséhez', 'tours.map.pisteKey': 'Pályajelmagyarázat', 'tours.map.pisteDownhill': 'Lesiklópályák nehézség szerint', 'tours.map.pisteGrade.novice': 'Kezdő', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index 5226c5971..b1c40d6d1 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -700,6 +700,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Rambu laut tidak termuat. Peta dan rutemu tetap berfungsi.', 'tours.map.pistes': 'Lintasan ski, jalur ski lintas alam, dan lift', 'tours.map.pistesDown': 'Lintasan ski tidak termuat. Peta dan rutemu tetap berfungsi.', + 'tours.map.pistesZoomIn': 'Perbesar peta untuk melihat lintasan ski', 'tours.map.pisteKey': 'Keterangan lintasan', 'tours.map.pisteDownhill': 'Lintasan turun menurut tingkat kesulitan', 'tours.map.pisteGrade.novice': 'Pemula', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 3d809583b..c4cf2c7fb 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -709,6 +709,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'I segnali nautici non si caricano. La mappa e il percorso funzionano ancora.', 'tours.map.pistes': 'Piste, anelli di fondo e impianti', 'tours.map.pistesDown': 'Le piste non si caricano. La mappa e il percorso funzionano ancora.', + 'tours.map.pistesZoomIn': 'Ingrandisci la mappa per vedere le piste', 'tours.map.pisteKey': 'Legenda delle piste', 'tours.map.pisteDownhill': 'Piste di discesa per difficoltà', 'tours.map.pisteGrade.novice': 'Principiante', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 356067eea..73344e758 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -683,6 +683,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': '航路標識を読み込めません。地図とルートはそのまま使えます。', 'tours.map.pistes': 'ゲレンデ、クロスカントリーコース、リフト', 'tours.map.pistesDown': 'ゲレンデを読み込めません。地図とルートはそのまま使えます。', + 'tours.map.pistesZoomIn': 'ゲレンデを表示するには拡大してください', 'tours.map.pisteKey': 'ゲレンデの凡例', 'tours.map.pisteDownhill': '難易度別のゲレンデ', 'tours.map.pisteGrade.novice': '初心者', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index d8d38033b..ac22a20a7 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -681,6 +681,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': '항로 표지를 불러오지 못했습니다. 지도와 경로는 계속 사용할 수 있습니다.', 'tours.map.pistes': '슬로프, 크로스컨트리 코스, 리프트', 'tours.map.pistesDown': '슬로프를 불러오지 못했습니다. 지도와 경로는 계속 사용할 수 있습니다.', + 'tours.map.pistesZoomIn': '슬로프를 보려면 확대하세요', 'tours.map.pisteKey': '슬로프 범례', 'tours.map.pisteDownhill': '난이도별 활강 슬로프', 'tours.map.pisteGrade.novice': '입문', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 8c3ce7fb7..f8200b86b 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -709,6 +709,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Zeemarkeringen laden niet. De kaart en je route werken nog.', 'tours.map.pistes': 'Pistes, langlaufloipes en liften', 'tours.map.pistesDown': 'Pistes laden niet. De kaart en je route werken nog.', + 'tours.map.pistesZoomIn': 'Zoom in om de pistes te zien', 'tours.map.pisteKey': 'Pistelegenda', 'tours.map.pisteDownhill': 'Afdalingen naar moeilijkheid', 'tours.map.pisteGrade.novice': 'Beginner', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index d227edd8e..a8cb550f6 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -717,6 +717,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Znaki nawigacyjne się nie wczytują. Mapa i Twoja trasa nadal działają.', 'tours.map.pistes': 'Trasy zjazdowe, biegowe i wyciągi', 'tours.map.pistesDown': 'Trasy narciarskie się nie wczytują. Mapa i Twoja trasa nadal działają.', + 'tours.map.pistesZoomIn': 'Przybliż mapę, aby zobaczyć trasy narciarskie', 'tours.map.pisteKey': 'Legenda tras', 'tours.map.pisteDownhill': 'Trasy zjazdowe według trudności', 'tours.map.pisteGrade.novice': 'Początkujący', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 1e38d93d7..8f2d708fd 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -720,6 +720,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Навигационные знаки не загружаются. Карта и ваш маршрут работают дальше.', 'tours.map.pistes': 'Трассы, лыжни и подъёмники', 'tours.map.pistesDown': 'Трассы не загружаются. Карта и ваш маршрут работают дальше.', + 'tours.map.pistesZoomIn': 'Приблизьте карту, чтобы увидеть трассы', 'tours.map.pisteKey': 'Легенда трасс', 'tours.map.pisteDownhill': 'Горнолыжные трассы по сложности', 'tours.map.pisteGrade.novice': 'Для новичков', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 962c76306..45fe9a577 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -697,6 +697,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Plavebné znaky sa nenačítavajú. Mapa aj vaša trasa ďalej fungujú.', 'tours.map.pistes': 'Zjazdovky, bežecké trate a vleky', 'tours.map.pistesDown': 'Zjazdovky sa nenačítavajú. Mapa aj vaša trasa ďalej fungujú.', + 'tours.map.pistesZoomIn': 'Priblížte mapu, aby ste videli zjazdovky', 'tours.map.pisteKey': 'Legenda zjazdoviek', 'tours.map.pisteDownhill': 'Zjazdovky podľa náročnosti', 'tours.map.pisteGrade.novice': 'Začiatočník', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 99ee4b4eb..b452f6999 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -695,6 +695,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Sjömärkena laddas inte. Kartan och din rutt fungerar ändå.', 'tours.map.pistes': 'Pister, längdspår och liftar', 'tours.map.pistesDown': 'Pisterna laddas inte. Kartan och din rutt fungerar ändå.', + 'tours.map.pistesZoomIn': 'Zooma in för att se pisterna', 'tours.map.pisteKey': 'Pistförklaring', 'tours.map.pisteDownhill': 'Nedfarter efter svårighet', 'tours.map.pisteGrade.novice': 'Nybörjare', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 80b72f08f..6ffd9a034 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -668,6 +668,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'โหลดเครื่องหมายทางน้ำไม่ได้ แผนที่และเส้นทางของคุณยังใช้งานได้', 'tours.map.pistes': 'ลานสกี เส้นทางสกีทางไกล และลิฟต์', 'tours.map.pistesDown': 'โหลดลานสกีไม่ได้ แผนที่และเส้นทางของคุณยังใช้งานได้', + 'tours.map.pistesZoomIn': 'ซูมเข้าเพื่อดูลานสกี', 'tours.map.pisteKey': 'คำอธิบายลานสกี', 'tours.map.pisteDownhill': 'ลานสกีลงเขาตามระดับความยาก', 'tours.map.pisteGrade.novice': 'มือใหม่', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index cd7c819c4..668af8844 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match) // placeholder spec; here only the general keys are. const groups = pluralGroups(Object.keys(english)); const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups)); -const GENERAL_KEY_COUNT = 897; +const GENERAL_KEY_COUNT = 898; function literalKeys(source: string): string[] { const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index e1d8f7c87..6b1304984 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -703,6 +703,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Deniz işaretleri yüklenmiyor. Harita ve rotan çalışmaya devam ediyor.', 'tours.map.pistes': 'Pistler, kayaklı koşu parkurları ve teleferikler', 'tours.map.pistesDown': 'Pistler yüklenmiyor. Harita ve rotan çalışmaya devam ediyor.', + 'tours.map.pistesZoomIn': 'Pistleri görmek için yakınlaştır', 'tours.map.pisteKey': 'Pist açıklaması', 'tours.map.pisteDownhill': 'Zorluğa göre iniş pistleri', 'tours.map.pisteGrade.novice': 'Acemi', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 7e12e98c5..ae674c1ce 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -713,6 +713,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Навігаційні знаки не завантажуються. Мапа й ваш маршрут працюють далі.', 'tours.map.pistes': 'Траси, лижні та підйомники', 'tours.map.pistesDown': 'Траси не завантажуються. Мапа й ваш маршрут працюють далі.', + 'tours.map.pistesZoomIn': 'Наблизьте мапу, щоб побачити траси', 'tours.map.pisteKey': 'Легенда трас', 'tours.map.pisteDownhill': 'Гірськолижні траси за складністю', 'tours.map.pisteGrade.novice': 'Для новачків', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 75aa63d27..f8c352329 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -695,6 +695,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': 'Báo hiệu hàng hải không tải được. Bản đồ và tuyến của bạn vẫn hoạt động.', 'tours.map.pistes': 'Đường trượt, đường trượt băng đồng và cáp treo', 'tours.map.pistesDown': 'Đường trượt không tải được. Bản đồ và tuyến của bạn vẫn hoạt động.', + 'tours.map.pistesZoomIn': 'Phóng to để xem đường trượt', 'tours.map.pisteKey': 'Chú giải đường trượt', 'tours.map.pisteDownhill': 'Đường trượt dốc theo độ khó', 'tours.map.pisteGrade.novice': 'Mới bắt đầu', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index ef3f81523..3d02fc9f7 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -641,6 +641,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': '航標無法載入。地圖和路線仍可正常使用。', 'tours.map.pistes': '雪道、越野滑雪道與纜車', 'tours.map.pistesDown': '雪道無法載入。地圖和路線仍可正常使用。', + 'tours.map.pistesZoomIn': '放大地圖以查看雪道', 'tours.map.pisteKey': '雪道圖例', 'tours.map.pisteDownhill': '依難度區分的滑降雪道', 'tours.map.pisteGrade.novice': '入門', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 6cca7e6fc..fdcaa8d21 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -642,6 +642,7 @@ const tours: TranslationStrings = { 'tours.map.seamarksDown': '航标无法加载。地图和路线仍可正常使用。', 'tours.map.pistes': '雪道、越野滑雪道和缆车', 'tours.map.pistesDown': '雪道无法加载。地图和路线仍可正常使用。', + 'tours.map.pistesZoomIn': '放大地图以查看雪道', 'tours.map.pisteKey': '雪道图例', 'tours.map.pisteDownhill': '按难度划分的滑降雪道', 'tours.map.pisteGrade.novice': '入门',