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:
Maurice
2026-10-14 16:21:30 +02:00
parent a9882a6415
commit 160bc89e78
126 changed files with 5003 additions and 1153 deletions
@@ -1,9 +1,10 @@
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-009
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-012
import type { Ferrata } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
import type { MapViewProps } from '../../components/Map/mapViewContract';
import { useSettingsStore } from '../../store/settingsStore';
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
type Handler = (event?: unknown) => void;
@@ -42,7 +43,14 @@ const stage = vi.hoisted(() => {
}),
};
const fire = (type: string, event?: unknown) => (listeners.get(type) ?? []).forEach((fn) => fn(event));
return { map, engine, listeners, fire, flatMap: { props: null as MapViewProps | null } };
return {
map,
engine,
listeners,
fire,
flatMap: { props: null as MapViewProps | null },
search: { props: null as Record<string, unknown> | null },
};
});
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
@@ -56,7 +64,12 @@ vi.mock('../../components/Map/MapViewAuto', () => ({
);
},
}));
vi.mock('../../components/Planner/LocationSelect', () => ({ default: () => <input aria-label="location search" /> }));
vi.mock('../../components/Planner/LocationSelect', () => ({
default: (props: Record<string, unknown>) => {
stage.search.props = props;
return <input aria-label="location search" />;
},
}));
// What the layer asks the server is useTripFerratas' own suite; here it answers from memory.
vi.mock('../../components/Ferratas/useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
@@ -122,12 +135,12 @@ async function openTerrain() {
beforeEach(() => {
stage.listeners.clear();
stage.flatMap.props = null;
stage.search.props = null;
vi.clearAllMocks();
vi.mocked(useTripFerratas).mockImplementation((_tripId, _bbox, enabled) => ({
ferratas: enabled ? [FERRATA] : [],
status: enabled ? 'ready' : 'idle',
truncated: false,
attribution: null,
}));
vi.stubGlobal(
'fetch',
@@ -202,55 +215,106 @@ describe('TourPlannerMapStage', () => {
expect(props?.ferrataLayer?.active).toBe(false);
expect(props?.ferrataLayer?.status).toBe('idle');
expect(typeof props?.onViewportChange).toBe('function');
expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([7, null, false]);
// Off: no view, and the trip's area (here its one framed point) only rides along.
expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([
7,
null,
false,
{ south: 46, west: 7, north: 46, east: 7 },
]);
});
it('FE-PAGE-TOURSTAGE-007: switched on, the layer asks for the area the flat map reported and draws what it found', () => {
it('FE-PAGE-TOURSTAGE-007: switched on, the layer asks for the area the flat map reported and draws what it found, grouped', () => {
renderStage(planner('view-saved'));
const area = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
act(() => stage.flatMap.props?.onViewportChange?.(area));
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([7, area, true]);
expect(vi.mocked(useTripFerratas).mock.lastCall?.slice(0, 3)).toEqual([7, area, true]);
expect(stage.flatMap.props?.ferrataLayer?.active).toBe(true);
expect(stage.flatMap.props?.ferrataLayer?.ferratas).toEqual([FERRATA]);
expect(stage.flatMap.props?.ferrataLayer?.groups[0]?.segments).toEqual([FERRATA]);
expect(stage.flatMap.props?.ferrataLayer?.count).toBe(1);
});
it('FE-PAGE-TOURSTAGE-008: a picked ferrata opens its card bottom right, clear of the right panel, and Escape closes it', () => {
it('FE-PAGE-TOURSTAGE-008: a picked ferrata opens its card at the inline end, clear of the right panel, and Escape closes it', () => {
renderStage(planner('view-saved'));
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
act(() => stage.flatMap.props?.ferrataLayer?.onSelect(FERRATA, { lat: 46.52, lng: 11.8 }));
const group = stage.flatMap.props?.ferrataLayer?.groups[0];
if (!group) throw new Error('no group');
act(() => stage.flatMap.props?.ferrataLayer?.onSelect(group, FERRATA, { lat: 46.52, lng: 11.8 }));
const overlay = screen.getByTestId('ferrata-detail-overlay');
// The right panel's 380px plus the 20px the layer pill keeps to the left one.
expect(overlay.style.right).toBe('400px');
expect(overlay.style.insetInlineEnd).toBe('400px');
// Above the bottom row: its 12px gap, the 42px pill, the same gap again.
expect(overlay.style.bottom).toBe('66px');
expect(overlay).toHaveTextContent('Via delle Trincee');
expect(stage.flatMap.props?.ferrataLayer?.selectedId).toBe(group.id);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
});
it('FE-PAGE-TOURSTAGE-009: in 3D the switch sits in the terrain layer pill, the layer stays on and the terrain reports its area', async () => {
it('FE-PAGE-TOURSTAGE-009: in 3D the switch sits in the terrain layer pill with its count, the layer stays on and the terrain reports its area', async () => {
renderStage(planner('view-saved'));
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
await openTerrain();
const toggle = screen.getByRole('button', { name: 'Via ferratas' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('ferrata-status-pill')).toHaveTextContent('1 via ferrata route');
expect(screen.queryByTestId('ferrata-status-pill')).toBeNull();
act(() => stage.fire('load'));
expect(vi.mocked(useTripFerratas).mock.lastCall).toEqual([
expect(vi.mocked(useTripFerratas).mock.lastCall?.slice(0, 3)).toEqual([
7,
{ south: 46.5, west: 11.7, north: 46.6, east: 11.9 },
true,
]);
expect(screen.getByTestId('ferrata-count-badge')).toHaveTextContent('1');
fireEvent.click(toggle);
expect(screen.getByRole('button', { name: 'Via ferratas' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.queryByTestId('ferrata-status-pill')).toBeNull();
expect(screen.queryByTestId('ferrata-count-badge')).toBeNull();
});
it('FE-PAGE-TOURSTAGE-010: the map search is the floating variant, with the short placeholder', () => {
renderStage(planner('view-saved'));
expect(stage.search.props).toMatchObject({
floating: true,
placeholder: 'tours.planner.mapSearch',
ariaLabel: 'tours.planner.mapSearch',
});
});
it('FE-PAGE-TOURSTAGE-011: right to left, the card sits at the inline end over the left panel', () => {
useSettingsStore.setState((state) => ({ settings: { ...state.settings, language: 'ar' } }));
try {
renderStage(planner('view-saved'));
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
const group = stage.flatMap.props?.ferrataLayer?.groups[0];
if (!group) throw new Error('no group');
act(() => stage.flatMap.props?.ferrataLayer?.onSelect(group, FERRATA, { lat: 46.52, lng: 11.8 }));
// The left panel's 320px plus the pill's 20px.
expect(screen.getByTestId('ferrata-detail-overlay').style.insetInlineEnd).toBe('340px');
} finally {
useSettingsStore.setState((state) => ({ settings: { ...state.settings, language: 'en' } }));
}
});
it('FE-PAGE-TOURSTAGE-012: the press that closes a ferrata card does not also add a waypoint, the next click does', () => {
const tourPlanner = planner('new-draft');
renderStage(tourPlanner);
act(() => stage.flatMap.props?.ferrataLayer?.onToggle());
const group = stage.flatMap.props?.ferrataLayer?.groups[0];
if (!group) throw new Error('no group');
act(() => stage.flatMap.props?.ferrataLayer?.onSelect(group, FERRATA, { lat: 46.52, lng: 11.8 }));
fireEvent.pointerDown(screen.getByRole('button', { name: 'flat map' }));
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.3, lng: 7.3 } } as never));
expect(tourPlanner.addWaypoint).toHaveBeenCalledWith(46.3, 7.3);
});
});
@@ -1,9 +1,11 @@
import { lazy, Suspense, useState, type ReactElement } from 'react';
import { lazy, Suspense, useMemo, useState, type ReactElement } from 'react';
import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay';
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
import { isRtlLanguage, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import type { useTripPlanner } from './useTripPlanner';
import type { useTripPlannerPage } from './useTripPlannerPage';
@@ -49,8 +51,18 @@ export function TourPlannerMapStage({
rightPanelPx,
}: Readonly<TourPlannerMapStageProps>): ReactElement {
const [terrain3d, setTerrain3d] = useState(false);
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
// One via ferrata layer for both modes: it stays on, and keeps its card, across 2D and 3D.
const ferrata = useFerrataMapLayer(tripId);
// The trip's area for the layer to prefetch: the framed points and the tour's line.
const ferrataArea = useMemo(
() => [tourMap.focusPoints, ...(tourMap.route ?? [])],
[tourMap.focusPoints, tourMap.route]
);
const ferrata = useFerrataMapLayer(tripId, ferrataArea);
// The click that closes a ferrata's card must not also add a waypoint under it.
const closingPress = useOutsidePressGuard();
const { language } = useTranslation();
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
@@ -60,7 +72,7 @@ export function TourPlannerMapStage({
selectedId: editing ? tourPlanner.selectedWaypointId : null,
onSelect: tourPlanner.setSelectedWaypointId,
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
onAdd: canEditRoute ? tourPlanner.addWaypoint : undefined,
onAdd: closingPress.guard(canEditRoute ? tourPlanner.addWaypoint : undefined),
};
// What each panel covers of the map, a collapsed one its rail.
const leftInset = leftHidden ? 56 : leftPanelPx;
@@ -85,6 +97,7 @@ export function TourPlannerMapStage({
framePoints={tourMap.focusPoints}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onControlsHeightChange={setTerrainControlsPx}
/>
</Suspense>
) : (
@@ -93,12 +106,12 @@ export function TourPlannerMapStage({
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={
onMapClick={closingPress.guard(
canEditRoute
? ({ latlng }: { latlng: { lat: number; lng: number } }) =>
tourPlanner.addWaypoint(latlng.lat, latlng.lng)
: undefined
}
)}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
@@ -116,11 +129,14 @@ export function TourPlannerMapStage({
onViewportChange={ferrata.onViewportChange}
/>
)}
{/* The card sits at the inline end; the panels keep their physical sides, so in a
right-to-left language that end is the left panel's. */}
<FerrataDetailOverlay
ferrata={ferrata.selected}
selection={ferrata.selected}
onClose={ferrata.close}
insetRight={rightInset + MAP_LAYER_SWITCHER_INSET}
bottom={FERRATA_CARD_BOTTOM}
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
bottom={terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM}
/>
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
<div
@@ -144,8 +160,9 @@ export function TourPlannerMapStage({
<LocationSelect
value={locationSearch.value}
onChange={locationSearch.onChange}
placeholder={t('reservations.searchLocation')}
ariaLabel={t('reservations.searchLocation')}
placeholder={t('tours.planner.mapSearch')}
ariaLabel={t('tours.planner.mapSearch')}
floating
showSearchStatus
style={{ width: '100%' }}
/>