mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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.
This commit is contained in:
@@ -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<ToggleableRasterOverlay> = {}): 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(
|
||||
<TourMapLayerSwitcher active="default" onChange={() => {}} 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(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down', belowMinZoom: true })] }} />)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading.')
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} 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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, OverlayLook> = {
|
||||
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 (
|
||||
<>
|
||||
<Tooltip label={down ? t(look.downKey) : label} placement="top">
|
||||
<Tooltip label={notice ?? label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={overlay.onToggle}
|
||||
@@ -244,7 +258,8 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
aria-pressed={on}
|
||||
data-testid={`map-overlay-${overlay.id}`}
|
||||
data-status={overlay.status}
|
||||
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
data-below-min-zoom={zoomIn ? true : undefined}
|
||||
className={on ? tone : 'text-content-muted'}
|
||||
style={segmentStyle(on)}
|
||||
>
|
||||
<look.Icon size={15} strokeWidth={2} />
|
||||
@@ -252,8 +267,8 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{look.Key && overlay.status === 'on' && <look.Key />}
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
{look.Key && overlay.status === 'on' && <look.Key belowMinZoom={!!zoomIn} />}
|
||||
<span role="status" aria-live="polite" className="sr-only">{notice ?? ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(<MapView onViewportChange={onViewportChange} />)
|
||||
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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(<MapViewGL places={[]} fitKey={1} onViewportChange={onViewportChange} />)
|
||||
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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -180,7 +180,11 @@ export interface MapViewProps {
|
||||
roadtripVias?: Record<number, RoadtripVia[]>;
|
||||
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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, () => 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<MapRasterOverlay, 'id' | 'url' | 'attribution' | 'minZoom' | 'maxZoom'>,
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(<PisteKeyButton belowMinZoom />);
|
||||
await userEvent.click(trigger());
|
||||
expect(within(key() as HTMLElement).getByTestId('piste-key-zoom-in')).toHaveTextContent(
|
||||
'Zoom in to see the pistes'
|
||||
);
|
||||
rerender(<PisteKeyButton />);
|
||||
expect(screen.queryByTestId('piste-key-zoom-in')).toBeNull();
|
||||
expect(screen.getByTestId('piste-key-grades')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-3 p-1.5">
|
||||
@@ -86,6 +90,16 @@ function PisteKeyBody() {
|
||||
</span>
|
||||
<SoftPill>OpenSnowMap</SoftPill>
|
||||
</div>
|
||||
{belowMinZoom && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-soft px-2 py-1.5 font-medium text-accent"
|
||||
style={fs(11)}
|
||||
data-testid="piste-key-zoom-in"
|
||||
>
|
||||
<ZoomIn size={12} strokeWidth={2.2} aria-hidden className="flex-none" />
|
||||
{t('tours.map.pistesZoomIn')}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<span className={LABEL}>{t('tours.map.pisteDownhill')}</span>
|
||||
<ul className="grid grid-cols-2 gap-x-3 gap-y-1.5" data-testid="piste-key-grades">
|
||||
@@ -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() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
<PopoverListPanel list={list} label={label} className="w-[272px]">
|
||||
<PisteKeyBody />
|
||||
<PisteKeyBody belowMinZoom={belowMinZoom} />
|
||||
</PopoverListPanel>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number | null>(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]
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,13 @@ type FerrataLayerState = ReturnType<typeof useFerrataMapLayer>;
|
||||
* 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<TourPlannerController, 'mode' | 'tourType' | 'mapBaseLayer' | 'mapOverlays' | 'toggleMapOverlay'>,
|
||||
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] : [])],
|
||||
|
||||
@@ -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': 'مبتدئ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Αρχάριοι',
|
||||
|
||||
@@ -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ő',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '初心者',
|
||||
|
||||
@@ -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': '입문',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Для новичков',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'มือใหม่',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Для новачків',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '入門',
|
||||
|
||||
@@ -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': '入门',
|
||||
|
||||
Reference in New Issue
Block a user