mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-014
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-016
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -103,13 +103,15 @@ const ferrataLayer = (overrides: Partial<FerrataMapLayer> = {}): FerrataMapLayer
|
||||
active: false,
|
||||
onToggle: vi.fn(),
|
||||
status: 'idle',
|
||||
ferratas: [],
|
||||
count: 0,
|
||||
groups: [],
|
||||
selectedId: null,
|
||||
onSelect: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TourMapLayerSwitcher via ferrata switch', () => {
|
||||
it('FE-COMP-MAPLAYER-011: offers no via ferrata switch without a layer, and no status row either', () => {
|
||||
it('FE-COMP-MAPLAYER-011: offers no via ferrata switch without a layer', () => {
|
||||
const { container } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} />)
|
||||
expect(screen.queryByRole('button', { name: 'Via ferratas' })).toBeNull()
|
||||
expect(container.firstElementChild).toHaveAttribute('role', 'group')
|
||||
@@ -130,29 +132,51 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
|
||||
expect(on).toHaveClass('bg-accent', 'text-accent-text')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-013: the status pill sits in the same row, right of the pill, and says nothing while the layer is off', () => {
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer()} />)
|
||||
it('FE-COMP-MAPLAYER-013: the count rides in the switch as a badge, nothing beside the pill, and none while off', () => {
|
||||
const { container, rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ count: 4 })} />)
|
||||
expect(screen.queryByTestId('ferrata-count-badge')).toBeNull()
|
||||
expect(container.firstElementChild).toHaveAttribute('role', 'group')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'ready', count: 4 })} />)
|
||||
const badge = screen.getByTestId('ferrata-count-badge')
|
||||
expect(badge).toHaveTextContent('4')
|
||||
expect(badge).toHaveAttribute('data-tone', 'default')
|
||||
expect(screen.getByRole('button', { name: 'Via ferratas' })).toContainElement(badge)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('4 via ferrata routes')
|
||||
expect(screen.queryByTestId('ferrata-status-pill')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-014: a tiny spinner in the badge while tiles load', () => {
|
||||
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'loading', count: 2 })} />)
|
||||
const badge = screen.getByTestId('ferrata-count-badge')
|
||||
expect(badge).toContainElement(screen.getByTestId('ferrata-count-spinner'))
|
||||
expect(badge).not.toHaveTextContent('2')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading via ferratas…')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-015: offline, the mirrors down or the view too wide: a warning tone, the reason in the tooltip', async () => {
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'offline', count: 1 })} />)
|
||||
expect(screen.getByTestId('ferrata-count-badge')).toHaveAttribute('data-tone', 'warning')
|
||||
expect(screen.getByTestId('ferrata-count-badge')).toHaveClass('bg-warning-soft', 'text-warning')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Via ferratas need a connection')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'tooLarge' })} />)
|
||||
const pill = screen.getByTestId('ferrata-status-pill')
|
||||
expect(pill).toHaveTextContent('Zoom in to load via ferratas')
|
||||
const row = pill.parentElement as HTMLElement
|
||||
expect(row.style.display).toBe('flex')
|
||||
expect(row.firstElementChild).toHaveAttribute('role', 'group')
|
||||
expect(row.lastElementChild).toBe(pill)
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: 'Via ferratas' }))
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to load via ferratas')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer({ active: true, status: 'error' })} />)
|
||||
expect(screen.getByTestId('ferrata-count-badge')).toHaveAttribute('data-tone', 'warning')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Overpass unreachable')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-014: sits beside the 3D switch behind one divider, and counts what the layer found', () => {
|
||||
const found = [{ id: 'way/1' }, { id: 'way/2' }] as unknown as FerrataMapLayer['ferratas']
|
||||
it('FE-COMP-MAPLAYER-016: sits beside the 3D switch behind one divider', () => {
|
||||
render(
|
||||
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
|
||||
ferrataLayer={ferrataLayer({ active: true, status: 'ready', ferratas: found })} />
|
||||
ferrataLayer={ferrataLayer({ active: true, status: 'ready', count: 2 })} />
|
||||
)
|
||||
const group = screen.getByRole('group')
|
||||
expect(group.querySelectorAll('span[aria-hidden]')).toHaveLength(1)
|
||||
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.textContent)
|
||||
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
|
||||
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
|
||||
expect(buttons.slice(-2)).toEqual(['Via ferratas', '3D'])
|
||||
expect(screen.getByTestId('ferrata-status-pill')).toHaveTextContent('2 via ferrata routes')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Map as MapIcon, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Loader2, Map as MapIcon, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import FerrataStatusPill from '../Ferratas/FerrataStatusPill'
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||
import type { FerrataStatus } from '../Ferratas/useTripFerratas'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
|
||||
@@ -82,59 +83,121 @@ const segmentStyle = (filled: boolean): CSSProperties => ({
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
})
|
||||
|
||||
/** Per-view Tours control. It never reads or writes the global map setting. */
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/** What the via ferrata layer is doing, in words, for the toggle's tooltip and the screen reader. */
|
||||
function ferrataStatusLabel(status: Exclude<FerrataStatus, 'idle'>, count: number, t: Translate): string {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return t('ferratas.loading')
|
||||
case 'error':
|
||||
return t('ferratas.error')
|
||||
case 'offline':
|
||||
return t('ferratas.offline')
|
||||
case 'tooLarge':
|
||||
return t('ferratas.zoomIn')
|
||||
case 'ready':
|
||||
return t('ferratas.count', { count })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata switch with its count as a badge: how many ferratas reach
|
||||
* into the view, a small spinner while tiles load, and the warning tone when
|
||||
* there is a reason it cannot load (offline, the mirrors down, the view too
|
||||
* wide), which the tooltip names. Nothing beside the switch, so the bottom row
|
||||
* stays one pill wide. `compact` drops the word and keeps the icon and the
|
||||
* badge; the tooltip and the accessible name still say what it is.
|
||||
*/
|
||||
function FerrataToggle({ layer, compact = false }: { layer: FerrataMapLayer; compact?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { active, status, count } = layer
|
||||
const live = active && status !== 'idle' ? ferrataStatusLabel(status, count, t) : null
|
||||
const problem = status === 'error' || status === 'offline' || status === 'tooLarge'
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={live ?? t('ferratas.layer')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={layer.onToggle}
|
||||
aria-label={t('ferratas.layer')}
|
||||
aria-pressed={active}
|
||||
className={active ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(active)}
|
||||
>
|
||||
<MountainSnow size={15} strokeWidth={2} />
|
||||
{!compact && <span>{t('ferratas.layer')}</span>}
|
||||
{live && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="ferrata-count-badge"
|
||||
data-tone={problem ? 'warning' : 'default'}
|
||||
className={`inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 font-semibold tabular-nums leading-none ${
|
||||
problem ? 'bg-warning-soft text-warning' : 'bg-accent-text text-accent'
|
||||
}`}
|
||||
style={fs(10)}
|
||||
>
|
||||
{status === 'loading'
|
||||
? <Loader2 size={10} strokeWidth={2.5} className="motion-safe:animate-spin" data-testid="ferrata-count-spinner" />
|
||||
: count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* Polite, so a screen reader hears the count arrive without being interrupted. */}
|
||||
<span role="status" aria-live="polite" className="sr-only">{live ?? ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
* alone, each named by its tooltip, for a bottom row that has to share its
|
||||
* width (the 3D map's, beside the flyover dock).
|
||||
*/
|
||||
export function TourMapLayerSwitcher({
|
||||
active,
|
||||
onChange,
|
||||
onToggleTerrain,
|
||||
terrainActive = false,
|
||||
ferrataLayer,
|
||||
compact = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
/** Offers the 3D terrain switch beside the base layers. */
|
||||
onToggleTerrain?: () => void
|
||||
terrainActive?: boolean
|
||||
/**
|
||||
* Offers the via ferrata switch beside the base layers, and puts the layer's
|
||||
* status pill in the same row, right of this one.
|
||||
*/
|
||||
/** Offers the via ferrata switch, with its count badge, beside the base layers. */
|
||||
ferrataLayer?: FerrataMapLayer
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const pill = (
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
|
||||
style={segmentStyle(selected)}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-pressed={selected}
|
||||
aria-label={compact ? label : undefined}
|
||||
className={selected ? 'bg-accent-soft text-accent' : 'text-content-muted'}
|
||||
style={segmentStyle(selected)}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{(ferrataLayer || onToggleTerrain) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{ferrataLayer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={ferrataLayer.onToggle}
|
||||
aria-pressed={ferrataLayer.active}
|
||||
className={ferrataLayer.active ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(ferrataLayer.active)}
|
||||
>
|
||||
<MountainSnow size={15} strokeWidth={2} />
|
||||
<span>{t('ferratas.layer')}</span>
|
||||
</button>
|
||||
)}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{onToggleTerrain && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -149,13 +212,4 @@ export function TourMapLayerSwitcher({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!ferrataLayer) return pill
|
||||
// The status pill keeps the column gap the lock pill keeps above, and says nothing while the layer is off.
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{pill}
|
||||
<FerrataStatusPill status={ferrataLayer.status} count={ferrataLayer.ferratas.length} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ vi.mock('../../hooks/useGeolocation', () => ({
|
||||
// What the via ferrata layer draws is FerrataLayerLeaflet's own suite; here only what the
|
||||
// renderer hands it. jsdom has no SVG renderer for Leaflet to draw real lines into.
|
||||
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({
|
||||
FerrataMapLayerLeaflet: ({ layer }: { layer?: { active: boolean; ferratas: readonly unknown[] } }) =>
|
||||
layer?.active ? <div data-testid="ferrata-leaflet-layer" data-count={layer.ferratas.length} /> : null,
|
||||
FerrataMapLayerLeaflet: ({ layer }: { layer?: { active: boolean; groups: readonly unknown[] } }) =>
|
||||
layer?.active ? <div data-testid="ferrata-leaflet-layer" data-count={layer.groups.length} /> : null,
|
||||
}))
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
@@ -41,6 +41,8 @@ vi.mock('../../services/photoService', () => ({
|
||||
import L from 'leaflet'
|
||||
import { MapView } from './MapView'
|
||||
import { ContextMenu } from '../shared/ContextMenu'
|
||||
import type { Ferrata } from '@trek/shared'
|
||||
import { groupFerratas } from '../Ferratas/ferrataGroups'
|
||||
|
||||
// Every map this file builds, newest last, for the tests that reach past the component.
|
||||
const maps: L.Map[] = []
|
||||
@@ -457,9 +459,12 @@ describe('MapView on a real Leaflet map', () => {
|
||||
})
|
||||
|
||||
it('RC-10: the tour layer pill offers the via ferrata switch, and the layer is drawn only while it is on', async () => {
|
||||
const ferrata = { id: 'way/42', name: 'Via delle Trincee', grade: { raw: '4', level: 4, plus: null }, routeName: null,
|
||||
const ferrata: Ferrata = { id: 'way/42', name: 'Via delle Trincee', grade: { raw: '4', level: 4, plus: null }, routeName: null,
|
||||
geometry: [[46.52, 11.8], [46.53, 11.81]], osmUrl: 'https://www.openstreetmap.org/way/42' }
|
||||
const layer = { active: false, onToggle: vi.fn(), status: 'idle' as const, ferratas: [ferrata], onSelect: vi.fn() }
|
||||
const layer = {
|
||||
active: false, onToggle: vi.fn(), status: 'idle' as const, count: 0, groups: groupFerratas([ferrata]),
|
||||
selectedId: null, onSelect: vi.fn(),
|
||||
}
|
||||
const view = render(
|
||||
<MapView places={[]} center={[46.52, 11.8]} zoom={13} tileUrl={RASTER}
|
||||
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={layer} />,
|
||||
@@ -472,10 +477,10 @@ describe('MapView on a real Leaflet map', () => {
|
||||
|
||||
view.rerender(
|
||||
<MapView places={[]} center={[46.52, 11.8]} zoom={13} tileUrl={RASTER}
|
||||
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={{ ...layer, active: true, status: 'ready' }} />,
|
||||
viewBaseLayer="topo" onViewBaseLayerChange={vi.fn()} ferrataLayer={{ ...layer, active: true, status: 'ready', count: 1 }} />,
|
||||
)
|
||||
await waitFor(() => expect(view.getByTestId('ferrata-leaflet-layer')).toHaveAttribute('data-count', '1'))
|
||||
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(view.getByTestId('ferrata-status-pill')).toHaveTextContent('1 via ferrata route')
|
||||
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,10 @@ const glMap = vi.hoisted(() => ({
|
||||
removeLayer: vi.fn(),
|
||||
removeSource: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
setFilter: vi.fn(),
|
||||
hasImage: vi.fn().mockReturnValue(false),
|
||||
addImage: vi.fn(),
|
||||
removeImage: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => glCanvasContainer),
|
||||
@@ -227,6 +231,7 @@ vi.mock('../../services/photoService', () => ({
|
||||
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import * as photoService from '../../services/photoService';
|
||||
import { groupFerratas } from '../Ferratas/ferrataGroups';
|
||||
import { MapViewGL as MapViewGLWithEngine } from './MapViewGL';
|
||||
import * as mapboxSetup from './mapboxSetup';
|
||||
|
||||
@@ -489,11 +494,14 @@ describe('MapViewGL', () => {
|
||||
] as [number, number][],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/42',
|
||||
};
|
||||
const FERRATA_GROUPS = groupFerratas([FERRATA]);
|
||||
const ferrataLayer = (active: boolean) => ({
|
||||
active,
|
||||
onToggle: vi.fn(),
|
||||
status: active ? ('ready' as const) : ('idle' as const),
|
||||
ferratas: [FERRATA],
|
||||
count: active ? 1 : 0,
|
||||
groups: FERRATA_GROUPS,
|
||||
selectedId: null,
|
||||
onSelect: vi.fn(),
|
||||
});
|
||||
const addedFerratas = () => glMap.addSource.mock.calls.filter(([id]) => id === 'trek-ferratas');
|
||||
@@ -529,7 +537,7 @@ describe('MapViewGL', () => {
|
||||
await act(async () => {});
|
||||
expect(addedFerratas()).toHaveLength(1);
|
||||
expect(view.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(view.getByTestId('ferrata-status-pill')).toHaveTextContent('1 via ferrata route');
|
||||
expect(view.getByTestId('ferrata-count-badge')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('RC-10: a click on a drawn ferrata opens it and is not a map click, so no control point lands under it', async () => {
|
||||
@@ -562,6 +570,6 @@ describe('MapViewGL', () => {
|
||||
};
|
||||
act(() => mapClicks.forEach((handler) => handler(click)));
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
expect(layer.onSelect).toHaveBeenCalledWith(FERRATA, { lat: 46.52, lng: 11.8 });
|
||||
expect(layer.onSelect).toHaveBeenCalledWith(FERRATA_GROUPS[0], FERRATA, { lat: 46.52, lng: 11.8 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,9 +120,10 @@ export interface MapViewProps {
|
||||
/** Offers the 3D terrain switch in the tour layer pill. */
|
||||
onToggleTerrain3d?: () => void;
|
||||
/**
|
||||
* The via ferrata layer (RC-10): its switch and status pill join the tour layer
|
||||
* pill, and while it is on its lines are drawn and a click on one hands the
|
||||
* ferrata to `onSelect`. Both renderers mount it through the Ferratas modules.
|
||||
* The via ferrata layer (RC-10): its switch, with the count as a badge, joins
|
||||
* the tour layer pill, and while it is on its lines are drawn and a click on
|
||||
* one hands the ferrata to `onSelect`. Both renderers mount it through the
|
||||
* Ferratas modules.
|
||||
*/
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user