mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +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,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%' }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user