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 -2
View File
@@ -1,5 +1,4 @@
{
"pages/tripPlanner/PlanMapStage.tsx": 4,
"mobile/components/trek3d/MascotClip.tsx": 1,
"components/Ferratas/FerrataDetailOverlay.tsx": 1
"mobile/components/trek3d/MascotClip.tsx": 1
}
@@ -1,71 +1,98 @@
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-005: the card for one picked via ferrata.
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-007: the card for one picked via ferrata.
import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FerrataDetailCard from './FerrataDetailCard';
import { type FerrataGroup, groupFerratas } from './ferrataGroups';
const FERRATA: Ferrata = {
id: 'way/77',
/** About 222 m of latitude per segment. */
const segment = (id: number, from: number, overrides: Partial<Ferrata> = {}): Ferrata => ({
id: `way/${id}`,
name: 'Via ferrata Pisciadù',
grade: { raw: '5', level: 5, plus: '+' },
routeName: 'Sella group',
geometry: [
[46.53, 11.81],
[46.54, 11.82],
[from, 11.81],
[from + 0.002, 11.81],
],
osmUrl: 'https://www.openstreetmap.org/way/77',
};
osmUrl: `https://www.openstreetmap.org/way/${id}`,
...overrides,
});
const LOWER = segment(77, 46.53, { grade: { raw: '2', level: 2, plus: null } });
const UPPER = segment(78, 46.532);
function groupOf(...segments: Ferrata[]): FerrataGroup {
const [group] = groupFerratas(segments);
if (!group) throw new Error('no group');
return group;
}
describe('FerrataDetailCard', () => {
it('FE-FERRATA-CARD-001: shows the name, the route, the grade badge and the attribution', () => {
render(<FerrataDetailCard ferrata={FERRATA} />);
it('FE-FERRATA-CARD-001: shows the name, the route, the grade range and length as badges, and the attribution', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument();
expect(screen.getByText('Sella group')).toBeInTheDocument();
expect(screen.getByText('K5+')).toHaveClass('text-danger');
// A badge, not a line colour: the grade is neutral, never the tour's green to red.
expect(screen.getByText('K2 to K5+')).toHaveClass('text-content-secondary');
expect(screen.getByText('445 m')).toBeInTheDocument();
expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument();
});
it('FE-FERRATA-CARD-002: links to the way on openstreetmap.org in a new tab', () => {
render(<FerrataDetailCard ferrata={FERRATA} />);
it('FE-FERRATA-CARD-002: links to the way that was picked on openstreetmap.org, in a new tab', () => {
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
const link = screen.getByRole('link', { name: /View in OpenStreetMap/ });
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/77');
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/78');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-FERRATA-CARD-003: an unnamed way takes its route name as title and does not repeat it', () => {
render(<FerrataDetailCard ferrata={{ ...FERRATA, name: null }} />);
it('FE-FERRATA-CARD-003: an unnamed ferrata takes its route name as title and does not repeat it', () => {
const unnamed = segment(77, 46.53, { name: null });
render(<FerrataDetailCard group={groupOf(unnamed)} segment={unnamed} />);
expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument();
expect(screen.getAllByText('Sella group')).toHaveLength(1);
});
it('FE-FERRATA-CARD-004: a letter scale keeps its raw grade, no grade at all reads unknown, both neutral', () => {
const { rerender } = render(
<FerrataDetailCard ferrata={{ ...FERRATA, grade: { raw: 'C/D', level: null, plus: null } }} />
);
it('FE-FERRATA-CARD-004: one grade reads once; a letter scale keeps its raw grade, none at all reads unknown', () => {
const { rerender } = render(<FerrataDetailCard group={groupOf(UPPER)} segment={UPPER} />);
expect(screen.getByText('K5+')).toBeInTheDocument();
const letter = segment(80, 46.53, { grade: { raw: 'C/D', level: null, plus: null } });
rerender(<FerrataDetailCard group={groupOf(letter)} segment={letter} />);
expect(screen.getByText('C/D')).toHaveClass('text-content-secondary');
rerender(
<FerrataDetailCard
ferrata={{ ...FERRATA, name: null, routeName: null, grade: { raw: null, level: null, plus: null } }}
/>
);
const bare = segment(81, 46.53, { name: null, routeName: null, grade: { raw: null, level: null, plus: null } });
rerender(<FerrataDetailCard group={groupOf(bare)} segment={bare} />);
expect(screen.getByText('Grade unknown')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument();
});
it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => {
const onClose = vi.fn();
const { rerender } = render(<FerrataDetailCard ferrata={FERRATA} />);
const group = groupOf(UPPER);
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
rerender(<FerrataDetailCard ferrata={FERRATA} onClose={onClose} />);
rerender(<FerrataDetailCard group={group} segment={UPPER} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-CARD-006: separates nothing with a middle dot', () => {
const { container } = render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={LOWER} />);
expect(container.textContent).not.toContain('·');
});
it('FE-FERRATA-CARD-007: a long name wraps onto a second line instead of being cut to one', () => {
const long = 'Herrmann von Barth Klettersteig über den Wetterstein';
render(<FerrataDetailCard group={groupOf(segment(79, 46.6, { name: long }))} segment={UPPER} />);
const heading = screen.getByRole('heading', { name: long });
expect(heading).toHaveClass('line-clamp-2');
expect(heading).not.toHaveClass('truncate');
});
});
@@ -1,42 +1,51 @@
import { ExternalLink, Mountain, X } from 'lucide-react';
import { ExternalLink, MoveHorizontal, Route, X } from 'lucide-react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { type PillTone, SoftPill } from '../Planner/planParts';
import { type FerrataTone, ferrataGradeLabel, ferrataGradeTone, ferrataOsmUrl } from './ferrataModel';
/** The badge tone per difficulty band; the same bands colour the line on the map. */
const PILL_TONE: Record<FerrataTone, PillTone> = {
easy: 'success',
medium: 'warning',
hard: 'danger',
unknown: 'neutral',
};
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance } from '../../utils/units';
import { SoftPill } from '../Planner/planParts';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataOsmUrl } from './ferrataModel';
/**
* One via ferrata, picked on the map: its name, the grade as a badge, the
* route it belongs to and the way back to the source. OpenStreetMap's data is
* ODbL, which asks for the attribution next to anything shown from it, so the
* card carries it rather than relying on the map's own corner.
* One via ferrata, picked on the map: its name, the route it belongs to, its
* grade range and total length as badges, and the way back to the source.
* `segment` is the OSM way that was clicked, which the source link opens.
* OpenStreetMap's data is ODbL, which asks for the attribution next to
* anything shown from it, so the card carries it rather than relying on the
* map's own corner.
*/
export default function FerrataDetailCard({ ferrata, onClose }: { ferrata: Ferrata; onClose?: () => void }) {
export default function FerrataDetailCard({
group,
segment,
onClose,
}: {
group: FerrataGroup;
segment: Ferrata;
onClose?: () => void;
}) {
const { t } = useTranslation();
const name = ferrata.name ?? ferrata.routeName ?? t('ferratas.unnamed');
const unit = useSettingsStore((s) => s.settings.distance_unit);
const label = ferrataLabel(group, t);
const name = label.name;
// The route line only adds something when it is not already the title.
const route = ferrata.routeName && ferrata.routeName !== name ? ferrata.routeName : null;
// A letter scale ("C/D") the contract keeps as `raw` still says more than "unknown".
const grade = ferrataGradeLabel(ferrata.grade) ?? ferrata.grade.raw ?? t('ferratas.unknownGrade');
const route = group.routeName && group.routeName !== name ? group.routeName : null;
// Without any grade the badge says so, rather than leaving a gap.
const grade = label.grade ?? t('ferratas.unknownGrade');
return (
<article
className="w-72 max-w-full rounded-xl border border-edge bg-surface-card p-3 text-content shadow-lg"
className="w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem] rounded-xl border border-edge bg-surface-card p-3 text-content shadow-lg"
data-testid="ferrata-detail-card"
>
<div className="flex items-start gap-2">
<Mountain size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
<div className="min-w-0 flex-1">
<h3 className="truncate text-subtitle font-semibold">{name}</h3>
<h3 className="line-clamp-2 break-words text-subtitle font-semibold" title={name}>
{name}
</h3>
{route && <p className="truncate text-caption text-content-muted">{route}</p>}
</div>
{onClose && (
@@ -50,11 +59,16 @@ export default function FerrataDetailCard({ ferrata, onClose }: { ferrata: Ferra
</button>
)}
</div>
<div className="mt-2 flex items-center gap-2">
<SoftPill tone={PILL_TONE[ferrataGradeTone(ferrata.grade)]}>{grade}</SoftPill>
<div className="mt-2 flex flex-wrap items-center gap-2">
<SoftPill>{grade}</SoftPill>
{group.lengthM > 0 && (
<SoftPill icon={<MoveHorizontal size={11} aria-hidden="true" />}>
{formatDistance(group.lengthM / 1000, unit ?? 'metric')}
</SoftPill>
)}
</div>
<a
href={ferrataOsmUrl(ferrata)}
href={ferrataOsmUrl(segment)}
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-body font-medium text-accent-on hover:underline"
@@ -5,6 +5,8 @@ import type { Ferrata } from '@trek/shared';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import FerrataDetailOverlay from './FerrataDetailOverlay';
import { groupFerratas } from './ferrataGroups';
import type { FerrataSelection } from './useFerrataMapLayer';
const FERRATA: Ferrata = {
id: 'way/42',
@@ -18,38 +20,54 @@ const FERRATA: Ferrata = {
osmUrl: 'https://www.openstreetmap.org/way/42',
};
const [GROUP] = groupFerratas([FERRATA]);
if (!GROUP) throw new Error('no group');
const SELECTION: FerrataSelection = { group: GROUP, segment: FERRATA };
describe('FerrataDetailOverlay', () => {
it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => {
render(<FerrataDetailOverlay ferrata={null} onClose={vi.fn()} insetRight={400} bottom={66} />);
render(<FerrataDetailOverlay selection={null} onClose={vi.fn()} insetEnd={400} bottom={66} />);
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
});
it('FE-FERRATA-OVERLAY-002: anchors the card bottom right, clear of the right panel and the control row', () => {
render(<FerrataDetailOverlay ferrata={FERRATA} onClose={vi.fn()} insetRight={400} bottom={66} />);
it('FE-FERRATA-OVERLAY-002: anchors the card at the bottom of the inline end, clear of the panel and the control row', () => {
render(<FerrataDetailOverlay selection={SELECTION} onClose={vi.fn()} insetEnd={400} bottom={66} />);
const overlay = screen.getByTestId('ferrata-detail-overlay');
expect(overlay.style.right).toBe('400px');
expect(overlay.style.insetInlineEnd).toBe('400px');
// Logical only: no physical side, so a right-to-left page mirrors it.
expect(overlay.style.right).toBe('');
expect(overlay.style.left).toBe('');
expect(overlay.style.bottom).toBe('66px');
expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card'));
});
it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside', () => {
it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside, and reports the outside press', () => {
const onClose = vi.fn();
const onOutsidePress = vi.fn();
render(
<div>
<button type="button">map</button>
<FerrataDetailOverlay ferrata={FERRATA} onClose={onClose} insetRight={400} bottom={66} />
<FerrataDetailOverlay
selection={SELECTION}
onClose={onClose}
onOutsidePress={onOutsidePress}
insetEnd={400}
bottom={66}
/>
</div>
);
fireEvent.pointerDown(screen.getByText('Via delle Trincee'));
expect(onClose).not.toHaveBeenCalled();
expect(onOutsidePress).not.toHaveBeenCalled();
fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onOutsidePress).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => {
const onClose = vi.fn();
render(<FerrataDetailOverlay ferrata={FERRATA} onClose={onClose} insetRight={400} bottom={66} />);
render(<FerrataDetailOverlay selection={SELECTION} onClose={onClose} insetEnd={400} bottom={66} />);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
@@ -1,37 +1,43 @@
import { useEffect, useRef } from 'react';
import type { Ferrata } from '@trek/shared';
import FerrataDetailCard from './FerrataDetailCard';
import type { FerrataSelection } from './useFerrataMapLayer';
/**
* The picked ferrata's card over the map, anchored bottom right above the row
* of map controls. `insetRight` is what the right panel covers plus the gap the
* layer pill keeps to the left one, so the card mirrors the pill and never
* slides under the panel; `bottom` clears the control row (the layer pill on
* the flat map, the dock and the grade legend in 3D).
* The picked ferrata's card over the map, anchored at the bottom of the inline
* end, above the row of map controls. `insetEnd` is what the panel on that
* side covers plus the gap the layer pill keeps to the panel on the other, so
* the card mirrors the pill and never slides under the panel; `bottom` clears
* the control row (the layer pill on the flat map, the dock and the grade
* legend in 3D).
*
* It closes on a press anywhere outside it and on Escape. A press on another
* ferrata closes this card first and the click then opens that one's.
* ferrata closes this card first and the click then opens that one's. The
* press outside is handed to `onOutsidePress` as well, so the map under it can
* leave the click that follows alone (see `useOutsidePressGuard`).
*/
export default function FerrataDetailOverlay({
ferrata,
selection,
onClose,
insetRight,
onOutsidePress,
insetEnd,
bottom,
}: {
ferrata: Ferrata | null;
selection: FerrataSelection | null;
onClose: () => void;
insetRight: number;
onOutsidePress?: () => void;
insetEnd: number;
bottom: number;
}) {
const ref = useRef<HTMLDivElement>(null);
const open = !!selection;
useEffect(() => {
if (!ferrata) return;
if (!open) return;
const onDown = (event: PointerEvent) => {
const card = ref.current;
if (card && event.target instanceof Node && card.contains(event.target)) return;
onOutsidePress?.();
onClose();
};
const onKey = (event: KeyboardEvent) => {
@@ -43,21 +49,17 @@ export default function FerrataDetailOverlay({
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey);
};
}, [ferrata, onClose]);
}, [open, onClose, onOutsidePress]);
if (!ferrata) return null;
if (!selection) return null;
return (
<div
ref={ref}
className="absolute z-[20]"
style={{
right: insetRight, // rtl-lint-disable: measured from the right panel, which stays on the physical right
bottom,
maxWidth: `calc(100% - ${insetRight + 32}px)`,
}}
className="absolute z-[20] flex justify-end"
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
data-testid="ferrata-detail-overlay"
>
<FerrataDetailCard ferrata={ferrata} onClose={onClose} />
<FerrataDetailCard group={selection.group} segment={selection.segment} onClose={onClose} />
</div>
);
}
@@ -1,23 +1,24 @@
import type { Map } from 'mapbox-gl';
import type { Ferrata } from '@trek/shared';
import type { FerrataGroup } from './ferrataGroups';
import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL';
/**
* The via ferrata layer as a mountable piece for a GL map view: it renders
* nothing itself, the lines live on `map`. See useFerrataLayerGL for the
* layering and the click handling.
* layering, the highlight and the click handling.
*/
export default function FerrataLayerGL({
map,
ferratas,
groups,
onSelect,
selectedId = null,
}: {
map: Map | null;
ferratas: readonly Ferrata[];
groups: readonly FerrataGroup[];
onSelect: FerrataSelectHandler;
selectedId?: string | null;
}) {
useFerrataLayerGL(map, ferratas, onSelect);
useFerrataLayerGL(map, groups, onSelect, selectedId);
return null;
}
@@ -1,22 +1,34 @@
/**
* FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-007: the via ferrata layer on
* FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-011: the via ferrata layer on
* the Leaflet renderer.
*
* Leaflet itself is not under test: the mock reflects each polyline's props
* back as data attributes, so the assertions read which lines are drawn, in
* which order and with what options. Casing and line stay non-interactive so
* they cannot swallow clicks meant for places; only the invisible hit line on
* top carries the tooltip and the click.
* Leaflet itself is not under test: the mock reflects each polyline's and
* marker's props back as data attributes, so the assertions read which lines
* are drawn, in which order and with what options. Casing and line stay
* non-interactive so they cannot swallow clicks meant for places; only the
* invisible hit line on top carries the tooltip, the hover and the click.
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import FerrataLayerLeaflet from './FerrataLayerLeaflet';
import { groupFerratas } from './ferrataGroups';
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, string | number>) =>
params ? `${key}(${Object.values(params).join(',')})` : key,
}),
}));
const zoom = { value: 12 };
const zoomEnd: { handler: (() => void) | null } = { handler: null };
type Handlers = Record<string, ((event: { latlng: { lat: number; lng: number } }) => void) | undefined>;
const at = { latlng: { lat: 46.505, lng: 11.705 } };
vi.mock('react-leaflet', () => ({
Polyline: ({
@@ -28,10 +40,10 @@ vi.mock('react-leaflet', () => ({
children,
}: {
positions: unknown;
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string };
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; className?: string };
interactive?: boolean;
bubblingMouseEvents?: boolean;
eventHandlers?: { click?: (event: { latlng: { lat: number; lng: number } }) => void };
eventHandlers?: Handlers;
children?: ReactNode;
}) => (
<div
@@ -41,103 +53,215 @@ vi.mock('react-leaflet', () => ({
data-weight={pathOptions.weight}
data-opacity={pathOptions.opacity}
data-dash={pathOptions.dashArray ?? ''}
data-class={pathOptions.className ?? ''}
data-interactive={interactive === false ? 'false' : 'true'}
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
onClick={() => eventHandlers?.click?.({ latlng: { lat: 46.505, lng: 11.705 } })}
onClick={() => eventHandlers?.click?.(at)}
onMouseOver={() => eventHandlers?.mouseover?.(at)}
onMouseOut={() => eventHandlers?.mouseout?.(at)}
>
{children}
</div>
),
Tooltip: ({ children }: { children: ReactNode }) => <span data-testid="tooltip">{children}</span>,
Marker: ({ position, eventHandlers }: { position: [number, number]; eventHandlers?: Handlers }) => (
<button
type="button"
data-testid="ferrata-icon"
data-position={JSON.stringify(position)}
onClick={() => eventHandlers?.click?.(at)}
/>
),
Tooltip: ({ children, className }: { children: ReactNode; className?: string }) => (
<span data-testid="tooltip" data-class={className ?? ''}>
{children}
</span>
),
// A pixel per 0.00001 degree: enough to tell icons apart that sit a few metres from each other.
useMap: () => ({
getZoom: () => zoom.value,
project: ([lat, lng]: [number, number]) => ({ x: lng * 100_000, y: -lat * 100_000 }),
}),
useMapEvents: (handlers: { zoomend?: () => void }) => {
zoomEnd.handler = handlers.zoomend ?? null;
return null;
},
}));
const ferrata = (id: string, name: string | null, level: number | null, routeName: string | null = null): Ferrata => ({
id,
name,
grade: { raw: level === null ? null : String(level), level, plus: level === 4 ? '+' : null },
routeName,
geometry: [
[46.5, 11.7],
[46.51, 11.71],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
const ferrata = (
id: string,
name: string | null,
level: number | null,
options: { routeName?: string | null; lat?: number; plus?: '+' | null } = {}
): Ferrata => {
const lat = options.lat ?? 46.5;
return {
id,
name,
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
routeName: options.routeName ?? null,
geometry: [
[lat, 11.7],
[lat + 0.01, 11.71],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
};
};
const lines = () => screen.getAllByTestId('polyline');
beforeEach(() => {
zoom.value = 12;
});
describe('FerrataLayerLeaflet', () => {
it('FE-FERRATA-LEAFLET-001: draws nothing for no ferratas', () => {
const { container } = render(<FerrataLayerLeaflet ferratas={[]} onSelect={vi.fn()} />);
const { container } = render(<FerrataLayerLeaflet groups={[]} onSelect={vi.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-FERRATA-LEAFLET-002: casing, dashed line, then the invisible hit line, in [lat, lng]', () => {
render(<FerrataLayerLeaflet ferratas={[ferrata('way/1', 'Pisciadù', 3)]} onSelect={vi.fn()} />);
const lines = screen.getAllByTestId('polyline');
it('FE-FERRATA-LEAFLET-002: a light casing, the dark dashed line, then the invisible hit line, in [lat, lng]', () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--bg-primary', 'white');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines.map((line) => line.dataset.weight)).toEqual(['6', '3', '16']);
expect(lines.map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']);
expect(lines[1]?.dataset.dash).toBe('6 5');
expect(lines[2]?.dataset.opacity).toBe('0');
expect(JSON.parse(lines[0]?.dataset.positions ?? '[]')).toEqual([
expect(lines().map((line) => line.dataset.weight)).toEqual(['6', '2.5', '16']);
expect(lines().map((line) => line.dataset.color)).toEqual(['white', 'black', 'white']);
expect(lines().map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']);
expect(lines()[1]?.dataset.dash).toBe('6 5');
expect(lines()[2]?.dataset.opacity).toBe('0');
expect(JSON.parse(lines()[0]?.dataset.positions ?? '[]')).toEqual([
[46.5, 11.7],
[46.51, 11.71],
]);
root.style.removeProperty('--text-primary');
root.style.removeProperty('--bg-primary');
});
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade, or falls back to the route and unnamed', () => {
render(
<FerrataLayerLeaflet
ferratas={[
ferrata('way/1', 'Pisciadù', 4),
ferrata('way/2', null, null, 'Sentiero attrezzato'),
ferrata('way/3', null, null),
]}
onSelect={vi.fn()}
/>
);
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
const groups = groupFerratas([
ferrata('way/1', 'Pisciadù', 2),
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, plus: '+' }),
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
ferrata('way/4', null, null, { lat: 48 }),
]);
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
expect(screen.getAllByTestId('tooltip').map((tip) => tip.textContent)).toEqual([
'PisciadùK4+',
'Sentiero attrezzato',
'Pisciadùferratas.gradeRange(K2,K4+)',
'Pisciadùferratas.gradeRange(K2,K4+)',
'Sentiero attrezzatoK3',
'ferratas.unnamed',
]);
});
it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata and the point to onSelect', () => {
it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
const onSelect = vi.fn();
const target = ferrata('way/9', 'Tridentina', 2);
render(<FerrataLayerLeaflet ferratas={[target]} onSelect={onSelect} />);
const groups = groupFerratas([target]);
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
const [, , hit] = screen.getAllByTestId('polyline');
const hit = lines()[2];
if (!hit) throw new Error('no hit line');
fireEvent.click(hit);
expect(onSelect).toHaveBeenCalledWith(target, { lng: 11.705, lat: 46.505 });
expect(onSelect).toHaveBeenCalledWith(groups[0], target, { lng: 11.705, lat: 46.505 });
});
it('FE-FERRATA-LEAFLET-006: the hit line keeps its click from reaching the map, so no control point lands under it', () => {
render(<FerrataLayerLeaflet ferratas={[ferrata('way/1', 'Pisciadù', 3)]} onSelect={vi.fn()} />);
const [, , hit] = screen.getAllByTestId('polyline');
expect(hit?.dataset.bubbles).toBe('false');
it('FE-FERRATA-LEAFLET-005: the hit line keeps its click from reaching the map, so no control point lands under it', () => {
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[2]?.dataset.bubbles).toBe('false');
});
it('FE-FERRATA-LEAFLET-007: the grade sits beside the name as its own label, without a separator', () => {
render(<FerrataLayerLeaflet ferratas={[ferrata('way/1', 'Pisciadù', 4)]} onSelect={vi.fn()} />);
it('FE-FERRATA-LEAFLET-006: the grade sits beside the name as its own label, without a separator', () => {
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 4)])} onSelect={vi.fn()} />);
const tip = screen.getByTestId('tooltip');
expect(tip.textContent).not.toContain('·');
expect(tip.querySelector('span')?.textContent).toBe('K4+');
expect(tip.querySelector('span')?.textContent).toBe('K4');
});
it('FE-FERRATA-LEAFLET-005: a theme change recolours the lines without a refetch', async () => {
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the accent, and so does the open card', () => {
const root = document.documentElement;
root.style.setProperty('--warning', 'orange');
const list = [ferrata('way/1', 'Pisciadù', 3)];
render(<FerrataLayerLeaflet ferratas={list} onSelect={vi.fn()} />);
expect(screen.getAllByTestId('polyline')[1]?.dataset.color).toBe('orange');
root.style.setProperty('--accent', 'navy');
const groups = groupFerratas([
ferrata('way/1', 'Via A', 2),
ferrata('way/2', 'Via A', 3, { lat: 46.51 }),
ferrata('way/3', 'Via B', 3, { lat: 47 }),
]);
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
// Casings 0 to 2, lines 3 to 5, hit lines 6 to 8.
const lineColours = () =>
lines()
.slice(3, 6)
.map((line) => line.dataset.color);
expect(lineColours()).not.toContain('navy');
const hit = lines()[7];
if (!hit) throw new Error('no hit line');
fireEvent.mouseOver(hit);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([true, true, false]);
fireEvent.mouseOut(hit);
expect(lineColours()).not.toContain('navy');
rerender(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[1]?.id ?? null} />);
expect(lineColours().map((colour) => colour === 'navy')).toEqual([false, false, true]);
root.style.removeProperty('--accent');
});
it('FE-FERRATA-LEAFLET-008: shows a route icon halfway along each ferrata only once zoomed in, and it opens the card', () => {
const onSelect = vi.fn();
const groups = groupFerratas([ferrata('way/1', 'Via A', 2), ferrata('way/2', 'Via B', 3, { lat: 47 })]);
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
expect(screen.queryAllByTestId('ferrata-icon')).toHaveLength(0);
zoom.value = 14;
act(() => zoomEnd.handler?.());
const icons = screen.getAllByTestId('ferrata-icon');
expect(icons).toHaveLength(2);
const [lat, lng] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
expect(lat).toBeCloseTo(46.505, 4);
expect(lng).toBeCloseTo(11.705, 4);
fireEvent.click(icons[1] as HTMLElement);
expect(onSelect).toHaveBeenCalledWith(groups[1], groups[1]?.segments[0], { lng: 11.705, lat: 46.505 });
});
it('FE-FERRATA-LEAFLET-009: a theme change recolours the lines without a refetch', async () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', 'black');
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
expect(lines()[1]?.dataset.color).toBe('black');
await act(async () => {
root.style.setProperty('--warning', 'gold');
root.style.setProperty('--text-primary', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(screen.getAllByTestId('polyline')[1]?.dataset.color).toBe('gold'));
root.style.removeProperty('--warning');
await waitFor(() => expect(lines()[1]?.dataset.color).toBe('ivory'));
root.style.removeProperty('--text-primary');
});
it('FE-FERRATA-LEAFLET-010: a lit ferrata is drawn solid and wider on a wider casing, the hit line and tooltip carry their classes', () => {
const groups = groupFerratas([ferrata('way/1', 'Via A', 2)]);
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[0]?.id ?? null} />);
expect(lines().map((line) => line.dataset.weight)).toEqual(['9', '4', '16']);
expect(lines()[1]?.dataset.dash).toBe('');
expect(lines()[2]?.dataset.class).toBe('trek-ferrata-hit');
expect(screen.getByTestId('tooltip').dataset.class).toBe('map-tooltip');
});
it('FE-FERRATA-LEAFLET-011: of two icons that would touch only the longer ferrata keeps its own', () => {
const short = ferrata('way/1', 'Via A', 2);
const long: Ferrata = {
...ferrata('way/2', 'Via B', 3, { lat: 46.5001 }),
geometry: [
[46.4951, 11.695],
[46.5151, 11.715],
],
};
const groups = groupFerratas([short, long]);
zoom.value = 14;
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
const icons = screen.getAllByTestId('ferrata-icon');
expect(icons).toHaveLength(1);
const [lat] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
expect(lat).toBeCloseTo(46.5051, 4);
});
});
@@ -1,79 +1,179 @@
import { useMemo } from 'react';
import { Polyline, Tooltip } from 'react-leaflet';
import type { Ferrata } from '@trek/shared';
import L from 'leaflet';
import { useMemo, useState } from 'react';
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import { useTranslation } from '../../i18n';
import { ferrataGradeLabel, ferrataGradeTone, ferrataPositions } from './ferrataModel';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
import { ferrataPositions } from './ferrataModel';
import type { FerrataSelectHandler } from './useFerrataLayerGL';
import type { FerrataMapLayer } from './useFerrataMapLayer';
import { useFerrataPaint } from './useFerrataPaint';
/**
* The via ferrata layer on the Leaflet renderer: per ferrata a casing and the
* dashed line coloured by difficulty band, the same construction as the GL
* layer, with a sticky tooltip naming the route and its grade. A click hands
* the ferrata to `onSelect`, like a click on the GL layer does.
* The route icon for Leaflet: the shared SVG in a divIcon, painted straight
* from the tokens, so a theme change restyles it without rebuilding anything.
* Built once; it carries no user content.
*/
export default function FerrataLayerLeaflet({
ferratas,
onSelect,
}: {
ferratas: readonly Ferrata[];
onSelect: FerrataSelectHandler;
}) {
const { t } = useTranslation();
const paint = useFerrataPaint();
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
const lines = useMemo(
() =>
ferratas.map((ferrata) => ({
ferrata,
positions: ferrataPositions(ferrata),
casing: { color: paint.casing, weight: 6, opacity: 0.75, lineCap: 'round', lineJoin: 'round' } as const,
line: {
color: paint.tone[ferrataGradeTone(ferrata.grade)],
weight: 3,
opacity: 0.95,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
} as const,
hit: { color: paint.casing, weight: 16, opacity: 0 } as const,
})),
[ferratas, paint]
);
/** The hit line's class, which index.css keeps free of the focus ring a clicked path draws. */
const FERRATA_HIT_CLASS = 'trek-ferrata-hit';
if (lines.length === 0) return null;
const ICON = L.divIcon({
html: ferrataIconSvg({ line: 'var(--text-primary)', casing: 'var(--bg-primary)' }),
className: 'trek-ferrata-icon',
iconSize: [FERRATA_ICON_SIZE, FERRATA_ICON_SIZE],
iconAnchor: [FERRATA_ICON_SIZE / 2, FERRATA_ICON_SIZE / 2],
});
/**
* The ferratas whose icon shows: none below FERRATA_ICON_MIN_ZOOM, and above
* it only those whose icon touches no other one at this zoom, the longer
* ferrata first. Leaflet has no symbol placement of its own, so this is what
* GL's `icon-allow-overlap: false` does there. Positions are projected at the
* zoom, which a pan does not change, so only a zoom or new ferratas place again.
*/
function useIconGroups(groups: readonly FerrataGroup[]): readonly FerrataGroup[] {
const map = useMap();
const [zoom, setZoom] = useState(() => map.getZoom());
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
return useMemo(() => {
if (zoom < FERRATA_ICON_MIN_ZOOM) return [];
const longestFirst = [...groups].sort((a, b) => b.lengthM - a.lengthM);
const shown = placeFerrataIcons(
longestFirst.map((group) => {
const { x, y } = map.project(group.midpoint, zoom);
return { id: group.id, x, y };
})
);
return groups.filter((group) => shown.has(group.id));
}, [map, groups, zoom]);
}
/** The tooltip's words: the ferrata's name and its grade. */
function FerrataTooltipText({ group }: { group: FerrataGroup }) {
const { t } = useTranslation();
const { name, grade } = ferrataLabel(group, t);
return (
<>
{lines.map(({ ferrata, positions, casing }) => (
<Polyline key={`${ferrata.id}-casing`} positions={positions} pathOptions={casing} interactive={false} />
{name}
{grade && <span className="ms-1.5 font-semibold">{grade}</span>}
</>
);
}
/**
* The via ferrata layer on the Leaflet renderer, in the same construction as
* the GL layer: per OSM way a light casing and the dark dashed line, the whole
* ferrata lit (solid, wider, on a wider casing, in the highlight colour) while
* the pointer is on it or its card is open, a sticky tooltip in the map's
* tooltip look naming it with its grade, and from FERRATA_ICON_MIN_ZOOM a
* small route icon halfway along it where it does not touch another one. A
* click hands the ferrata and the way to `onSelect`, like a click on the GL
* layer does.
*/
export default function FerrataLayerLeaflet({
groups,
onSelect,
selectedId = null,
}: {
groups: readonly FerrataGroup[];
onSelect: FerrataSelectHandler;
selectedId?: string | null;
}) {
const paint = useFerrataPaint();
const iconGroups = useIconGroups(groups);
const [hovered, setHovered] = useState<string | null>(null);
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
const lines = useMemo(() => {
const casing = { color: paint.casing, weight: 6, opacity: 0.9, lineCap: 'round', lineJoin: 'round' } as const;
const litCasing = { ...casing, weight: 9, opacity: 1 } as const;
const line = {
color: paint.line,
weight: 2.5,
opacity: 1,
dashArray: '6 5',
lineCap: 'round',
lineJoin: 'round',
} as const;
// Solid: an empty dash list clears the dashes Leaflet would otherwise keep from the line's options.
const highlight = {
color: paint.highlight,
weight: 4,
opacity: 1,
dashArray: '',
lineCap: 'round',
lineJoin: 'round',
} as const;
// The class lets the stylesheet drop the focus ring a clicked SVG path keeps; the card has its own focus.
const hit = { color: paint.casing, weight: 16, opacity: 0, className: FERRATA_HIT_CLASS } as const;
return groups.flatMap((group) =>
group.segments.map((segment) => ({
group,
segment,
positions: ferrataPositions(segment),
casing,
litCasing,
line,
highlight,
hit,
}))
);
}, [groups, paint]);
if (lines.length === 0) return null;
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
return (
<>
{lines.map(({ group, segment, positions, casing, litCasing }) => (
<Polyline
key={`${segment.id}-casing`}
positions={positions}
pathOptions={lit(group) ? litCasing : casing}
interactive={false}
/>
))}
{lines.map(({ ferrata, positions, line }) => (
<Polyline key={`${ferrata.id}-line`} positions={positions} pathOptions={line} interactive={false} />
{lines.map(({ group, segment, positions, line, highlight }) => (
<Polyline
key={`${segment.id}-line`}
positions={positions}
pathOptions={lit(group) ? highlight : line}
interactive={false}
/>
))}
{/* A wide, invisible line on top carries the tooltip and the click, so a thin
dashed route is still easy to hit with a finger. The click stops here: it
{/* A wide, invisible line on top carries the tooltip, the hover and the click, so a
thin dashed route is still easy to hit with a finger. The click stops here: it
opens the ferrata's card and must not also drop a control point under it. */}
{lines.map(({ ferrata, positions, hit }) => {
const name = ferrata.name ?? ferrata.routeName ?? t('ferratas.unnamed');
const grade = ferrataGradeLabel(ferrata.grade);
return (
<Polyline
key={`${ferrata.id}-hit`}
positions={positions}
pathOptions={hit}
bubblingMouseEvents={false}
eventHandlers={{ click: (event) => onSelect(ferrata, { lng: event.latlng.lng, lat: event.latlng.lat }) }}
>
<Tooltip sticky>
{name}
{grade && <span className="ms-1.5 font-semibold">{grade}</span>}
</Tooltip>
</Polyline>
);
})}
{lines.map(({ group, segment, positions, hit }) => (
<Polyline
key={`${segment.id}-hit`}
positions={positions}
pathOptions={hit}
bubblingMouseEvents={false}
eventHandlers={{
click: (event) => onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat }),
mouseover: () => setHovered(group.id),
mouseout: () => setHovered((current) => (current === group.id ? null : current)),
}}
>
<Tooltip sticky direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
<FerrataTooltipText group={group} />
</Tooltip>
</Polyline>
))}
{iconGroups.map((group) => (
<Marker
key={`${group.id}-icon`}
position={group.midpoint}
icon={ICON}
keyboard={false}
eventHandlers={{
click: (event) => {
const segment = group.segments[0];
if (segment) onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat });
},
}}
/>
))}
</>
);
}
@@ -81,5 +181,5 @@ export default function FerrataLayerLeaflet({
/** The layer as the Leaflet map view mounts it from a `FerrataMapLayer`: drawn only while it is switched on. */
export function FerrataMapLayerLeaflet({ layer }: { layer: FerrataMapLayer | undefined }) {
if (!layer?.active) return null;
return <FerrataLayerLeaflet ferratas={layer.ferratas} onSelect={layer.onSelect} />;
return <FerrataLayerLeaflet groups={layer.groups} onSelect={layer.onSelect} selectedId={layer.selectedId} />;
}
@@ -1,39 +0,0 @@
// FE-FERRATA-PILL-001 to FE-FERRATA-PILL-004: what the via ferrata layer says about itself.
import { describe, expect, it } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import FerrataStatusPill from './FerrataStatusPill';
describe('FerrataStatusPill', () => {
it('FE-FERRATA-PILL-001: says nothing while the layer is off', () => {
const { container } = render(<FerrataStatusPill status="idle" count={0} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-FERRATA-PILL-002: counts what it found, in the plural form the count takes', () => {
const { rerender } = render(<FerrataStatusPill status="ready" count={12} />);
expect(screen.getByRole('status')).toHaveTextContent('12 via ferrata routes');
rerender(<FerrataStatusPill status="ready" count={1} />);
expect(screen.getByRole('status')).toHaveTextContent('1 via ferrata route');
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Via ferratas: 1 via ferrata route');
});
it('FE-FERRATA-PILL-003: shows loading and the two reasons a request was not made', () => {
const { rerender } = render(<FerrataStatusPill status="loading" count={0} />);
expect(screen.getByRole('status')).toHaveTextContent('Loading via ferratas…');
rerender(<FerrataStatusPill status="tooLarge" count={0} />);
expect(screen.getByRole('status')).toHaveTextContent('Zoom in to load via ferratas');
expect(screen.getByRole('status')).toHaveClass('text-content-secondary');
rerender(<FerrataStatusPill status="offline" count={0} />);
expect(screen.getByRole('status')).toHaveTextContent('Via ferratas need a connection');
});
it('FE-FERRATA-PILL-004: an unreachable Overpass reads as a problem', () => {
render(<FerrataStatusPill status="error" count={0} />);
expect(screen.getByRole('status')).toHaveTextContent('Overpass unreachable');
expect(screen.getByRole('status')).toHaveClass('text-danger');
});
});
@@ -1,65 +0,0 @@
import { Loader2, Mountain, TriangleAlert } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
import type { FerrataStatus } from './useTripFerratas';
/** The map controls' frosted glass, so the pill reads as part of the layer pill it sits beside. */
const GLASS = {
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
};
/**
* What the via ferrata layer is doing, as one small pill over the map, in the
* row of the layer pill and at its height:
* loading, how many it found, or why there are none (Overpass unreachable, the
* area too large to ask for, the device offline). Nothing while the layer is
* off. Polite live region, so a screen reader hears the count arrive without
* being interrupted.
*/
export default function FerrataStatusPill({ status, count }: { status: FerrataStatus; count: number }) {
const { t } = useTranslation();
if (status === 'idle') return null;
const problem = status === 'error' || status === 'offline';
const label = labelOf(status, count, t);
const Icon = status === 'loading' ? Loader2 : problem ? TriangleAlert : Mountain;
return (
<div
role="status"
aria-live="polite"
aria-label={`${t('ferratas.layer')}: ${label}`}
data-testid="ferrata-status-pill"
className={`pointer-events-auto inline-flex h-[42px] flex-none items-center gap-1.5 whitespace-nowrap rounded-full px-3.5 text-caption font-semibold ${
problem ? 'text-danger' : 'text-content-secondary'
}`}
style={GLASS}
>
<Icon size={13} aria-hidden="true" className={status === 'loading' ? 'motion-safe:animate-spin' : undefined} />
<span>{label}</span>
</div>
);
}
function labelOf(
status: Exclude<FerrataStatus, 'idle'>,
count: number,
t: (key: string, params?: Record<string, string | number>) => 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 });
}
}
@@ -0,0 +1,149 @@
// FE-FERRATA-GROUP-001 to FE-FERRATA-GROUP-009: OSM's short segments read back as the ferratas a climber means.
import { describe, expect, it } from 'vitest';
import type { Ferrata } from '@trek/shared';
import {
FERRATA_NAME_REACH_M,
ferrataBboxesMeet,
ferrataGradeRange,
ferrataGroupOf,
ferrataGroupsInView,
groupFerratas,
} from './ferrataGroups';
/** About 111 m of latitude. */
const STEP = 0.001;
function segment(
id: number,
from: [number, number],
options: {
name?: string | null;
routeName?: string | null;
level?: number | null;
plus?: '+' | '-' | null;
raw?: string;
} = {}
): Ferrata {
const level = options.level === undefined ? 3 : options.level;
return {
id: `way/${id}`,
name: options.name === undefined ? 'Via X' : options.name,
routeName: options.routeName ?? null,
grade: { raw: options.raw ?? (level === null ? null : String(level)), level, plus: options.plus ?? null },
geometry: [from, [from[0] + STEP, from[1]]],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
};
}
describe('groupFerratas', () => {
it('FE-FERRATA-GROUP-001: joins touching segments of one name into one ferrata, named after them', () => {
const groups = groupFerratas([
segment(2, [46.5, 11.7], { level: 4 }),
segment(1, [46.5 + STEP, 11.7], { level: 2 }),
segment(3, [46.5 + 2 * STEP, 11.7], { level: 3 }),
]);
expect(groups).toHaveLength(1);
const [group] = groups;
expect(group?.id).toBe('group/way/1');
expect(group?.name).toBe('Via X');
expect(group?.segments.map((s) => s.id)).toEqual(['way/2', 'way/1', 'way/3']);
expect(group?.lengthM).toBeGreaterThan(330);
expect(group?.lengthM).toBeLessThan(340);
});
it('FE-FERRATA-GROUP-002: chains segments within the reach, and keeps a homonym far away apart', () => {
const gap = (FERRATA_NAME_REACH_M - 50) / 111_195;
const groups = groupFerratas([
segment(1, [46.5, 11.7]),
segment(2, [46.5 + STEP + gap, 11.7]),
// The same name ten kilometres north is another ferrata.
segment(3, [46.6, 11.7]),
]);
expect(groups.map((g) => g.segments.map((s) => s.id))).toEqual([['way/1', 'way/2'], ['way/3']]);
});
it('FE-FERRATA-GROUP-003: reads names without case or outer spaces, and keeps every unnamed segment alone', () => {
const groups = groupFerratas([
segment(1, [46.5, 11.7], { name: 'Via X' }),
segment(2, [46.5 + STEP, 11.7], { name: ' via x ' }),
segment(3, [46.5, 11.71], { name: null }),
segment(4, [46.5 + STEP, 11.71], { name: null }),
]);
expect(groups.map((g) => g.segments.length)).toEqual([2, 1, 1]);
expect(groups[1]?.name).toBeNull();
});
it('FE-FERRATA-GROUP-004: groups by the route relation first, over a wider reach and whatever the ways are named', () => {
const groups = groupFerratas([
segment(10, [46.5, 11.7], { routeName: 'Brigata Tridentina', name: 'Pitch 1' }),
// 1.5 km on, the same route.
segment(11, [46.5135, 11.7], { routeName: 'Brigata Tridentina', name: null }),
// A way named like the route but outside the relation is its own ferrata.
segment(12, [46.5 + STEP, 11.7], { name: 'Brigata Tridentina' }),
]);
expect(groups).toHaveLength(2);
expect(groups[0]?.segments.map((s) => s.id)).toEqual(['way/10', 'way/11']);
expect(groups[0]?.routeName).toBe('Brigata Tridentina');
expect(groups[0]?.name).toBe('Pitch 1');
});
it('FE-FERRATA-GROUP-005: spans the grades from the easiest to the hardest segment, minus before plus', () => {
const [group] = groupFerratas([
segment(1, [46.5, 11.7], { level: 4, plus: '+' }),
segment(2, [46.5 + STEP, 11.7], { level: 2, plus: '-' }),
segment(3, [46.5 + 2 * STEP, 11.7], { level: 2 }),
segment(4, [46.5 + 3 * STEP, 11.7], { level: null }),
]);
expect(group && ferrataGradeRange(group)).toEqual({ from: 'K2-', to: 'K4+' });
expect(group?.rawGrade).toBeNull();
});
it('FE-FERRATA-GROUP-006: without a numeric grade keeps the tag as written, and has no range', () => {
const [group] = groupFerratas([segment(1, [46.5, 11.7], { level: null, raw: 'C/D' })]);
expect(group && ferrataGradeRange(group)).toBeNull();
expect(group?.rawGrade).toBe('C/D');
const [single] = groupFerratas([segment(2, [46.5, 11.7], { level: 3 })]);
expect(single && ferrataGradeRange(single)).toEqual({ from: 'K3', to: 'K3' });
});
it('FE-FERRATA-GROUP-007: puts the label halfway along the longest segment and spans every segment in its box', () => {
const long: Ferrata = {
...segment(1, [46.5, 11.7]),
geometry: [
[46.5, 11.7],
[46.5 + 4 * STEP, 11.7],
],
};
const [group] = groupFerratas([segment(2, [46.5 + 4 * STEP, 11.7]), long]);
expect(group?.midpoint[0]).toBeCloseTo(46.502, 5);
expect(group?.midpoint[1]).toBeCloseTo(11.7, 5);
expect(group?.bbox.south).toBe(46.5);
expect(group?.bbox.north).toBeCloseTo(46.505, 9);
expect(group?.bbox.west).toBe(11.7);
expect(group?.bbox.east).toBe(11.7);
});
it('FE-FERRATA-GROUP-008: finds the ferrata a way belongs to, and counts the ferratas reaching into a view', () => {
const groups = groupFerratas([
segment(1, [46.5, 11.7]),
segment(2, [46.5 + STEP, 11.7]),
segment(3, [46.6, 11.8], { name: 'Other' }),
]);
expect(ferrataGroupOf(groups, 'way/2')?.id).toBe('group/way/1');
expect(ferrataGroupOf(groups, 'way/99')).toBeNull();
expect(ferrataGroupOf(groups, null)).toBeNull();
expect(ferrataGroupsInView(groups, { south: 46.49, west: 11.69, north: 46.51, east: 11.71 })).toBe(1);
expect(ferrataGroupsInView(groups, { south: 46.4, west: 11.6, north: 46.7, east: 11.9 })).toBe(2);
expect(ferrataGroupsInView(groups, null)).toBe(0);
});
it('FE-FERRATA-GROUP-009: boxes meet on a shared edge, not across a gap; a line under two points is dropped', () => {
const box = { south: 0, west: 0, north: 1, east: 1 };
expect(ferrataBboxesMeet(box, { south: 1, west: 1, north: 2, east: 2 })).toBe(true);
expect(ferrataBboxesMeet(box, { south: 1.1, west: 0, north: 2, east: 1 })).toBe(false);
const point = { ...segment(1, [46.5, 11.7]), geometry: [[46.5, 11.7]] } as Ferrata;
expect(groupFerratas([point])).toEqual([]);
});
});
@@ -0,0 +1,270 @@
import type { Ferrata, FerrataBbox, FerrataGrade } from '@trek/shared';
import { ferrataGradeLabel, ferrataPositions } from './ferrataModel';
/**
* OpenStreetMap maps one via ferrata as many short ways: the approach, each
* pitch, a bridge, the exit. This turns those segments back into the routes a
* climber means, so the layer counts, highlights and describes one ferrata
* rather than a dozen pieces of it. Pure and React-free.
*
* Segments belong together when they share their route relation's name, or,
* with no relation, their own name, and lie close to each other: a chain of
* segments joins as long as each one comes within the reach of the next.
* Unnamed segments stay on their own; nothing says two of them are one route.
*/
/** How close two segments of the same name have to come to read as one ferrata. */
export const FERRATA_NAME_REACH_M = 300;
/**
* The same for a route relation's name. A relation is one route by definition,
* so the reach is wider; it only keeps two distant routes that happen to share
* a name apart.
*/
const FERRATA_ROUTE_REACH_M = 2000;
export interface FerrataGroup {
/** Stable for as long as the group keeps its lowest way: `group/<way id>`. */
id: string;
name: string | null;
routeName: string | null;
/** The OSM ways it is made of, in the order they arrived. */
segments: readonly Ferrata[];
/** The easiest and the hardest numeric grade among the segments, or null when none has one. */
grades: { min: FerrataGrade; max: FerrataGrade } | null;
/** A grade as tagged when no segment has a numeric one (a letter scale such as "C/D"). */
rawGrade: string | null;
/** The summed length of every segment, in metres. */
lengthM: number;
bbox: FerrataBbox;
/** Where its label goes: halfway along its longest segment, as [lat, lng]. */
midpoint: [number, number];
}
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
function distanceM([lat1, lng1]: [number, number], [lat2, lng2]: [number, number]): number {
const dLat = (lat2 - lat1) * RAD;
const dLng = (lng2 - lng1) * RAD;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
function lengthOf(points: readonly [number, number][]): number {
let total = 0;
for (let i = 1; i < points.length; i += 1) total += distanceM(points[i - 1], points[i]);
return total;
}
/**
* The box around the points, the ones that are not finite numbers skipped, or
* null when none is left.
*/
export function ferrataPointsBbox(points: Iterable<readonly [number, number]>): FerrataBbox | null {
let south = Infinity;
let west = Infinity;
let north = -Infinity;
let east = -Infinity;
for (const [lat, lng] of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
south = Math.min(south, lat);
north = Math.max(north, lat);
west = Math.min(west, lng);
east = Math.max(east, lng);
}
return south <= north && west <= east ? { south, west, north, east } : null;
}
/** A segment's box; the schema guarantees it at least two placed points. */
function bboxOf(points: readonly [number, number][]): FerrataBbox {
return ferrataPointsBbox(points) ?? { south: 0, west: 0, north: 0, east: 0 };
}
/** Whether two boxes share any point, edges included. */
export function ferrataBboxesMeet(a: FerrataBbox, b: FerrataBbox): boolean {
return a.south <= b.north && b.south <= a.north && a.west <= b.east && b.west <= a.east;
}
/** The point halfway along a line, by length. */
function halfway(points: readonly [number, number][]): [number, number] {
const half = lengthOf(points) / 2;
let walked = 0;
for (let i = 1; i < points.length; i += 1) {
const step = distanceM(points[i - 1], points[i]);
if (walked + step >= half && step > 0) {
const t = (half - walked) / step;
const [lat1, lng1] = points[i - 1];
const [lat2, lng2] = points[i];
return [lat1 + (lat2 - lat1) * t, lng1 + (lng2 - lng1) * t];
}
walked += step;
}
return points[0] ?? [0, 0];
}
interface Segment {
ferrata: Ferrata;
points: [number, number][];
bbox: FerrataBbox;
lengthM: number;
}
/** Whether two boxes come within `reach` metres of each other, a cheap test before any point is compared. */
function boxesWithin(a: FerrataBbox, b: FerrataBbox, reach: number): boolean {
const latPad = reach / (EARTH_RADIUS_M * RAD);
const lngPad = latPad / Math.max(0.01, Math.cos(((a.south + a.north) / 2) * RAD));
return (
a.south - latPad <= b.north && b.south - latPad <= a.north && a.west - lngPad <= b.east && b.west - lngPad <= a.east
);
}
/** Whether an end of either segment comes within `reach` of a point of the other: they touch or nearly do. */
function segmentsMeet(a: Segment, b: Segment, reach: number): boolean {
if (!boxesWithin(a.bbox, b.bbox, reach)) return false;
const near = (ends: Segment, line: Segment) => {
const tips = [ends.points[0], ends.points[ends.points.length - 1]];
return tips.some((tip) => tip !== undefined && line.points.some((point) => distanceM(tip, point) <= reach));
};
return near(a, b) || near(b, a);
}
/** What segments group by, or null for one that stays alone. Case and outer spaces do not tell two names apart. */
function groupKey(ferrata: Ferrata): { key: string; reach: number } | null {
const route = ferrata.routeName?.trim().toLocaleLowerCase();
if (route) return { key: `route:${route}`, reach: FERRATA_ROUTE_REACH_M };
const name = ferrata.name?.trim().toLocaleLowerCase();
if (name) return { key: `name:${name}`, reach: FERRATA_NAME_REACH_M };
return null;
}
/** Segments of one key joined into chains: any two that meet end up in one set. */
function chains(segments: Segment[], reach: number): Segment[][] {
const parent = segments.map((_, index) => index);
const find = (i: number): number => {
let root = i;
while (parent[root] !== root) root = parent[root];
parent[i] = root;
return root;
};
for (let i = 0; i < segments.length; i += 1) {
for (let j = i + 1; j < segments.length; j += 1) {
if (find(i) !== find(j) && segmentsMeet(segments[i], segments[j], reach)) parent[find(j)] = find(i);
}
}
const sets = new Map<number, Segment[]>();
segments.forEach((segment, index) => {
const root = find(index);
const set = sets.get(root);
if (set) set.push(segment);
else sets.set(root, [segment]);
});
return [...sets.values()];
}
/** -, none, + in that order within a level. */
const gradeRank = (grade: FerrataGrade) =>
(grade.level ?? 0) * 3 + (grade.plus === '-' ? 0 : grade.plus === '+' ? 2 : 1);
function wayNumber(id: string): number {
const n = Number(id.slice(id.indexOf('/') + 1));
return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
}
function toGroup(members: Segment[]): FerrataGroup {
const segments = members.map((member) => member.ferrata);
const graded = segments.map((segment) => segment.grade).filter((grade) => grade.level !== null);
const sorted = [...graded].sort((a, b) => gradeRank(a) - gradeRank(b));
const longest = members.reduce((best, member) => (member.lengthM > best.lengthM ? member : best), members[0]);
// The name of the longest named segment speaks for the group.
const named = [...members].sort((a, b) => b.lengthM - a.lengthM).find((member) => member.ferrata.name);
const lowest = [...segments].sort((a, b) => wayNumber(a.id) - wayNumber(b.id))[0];
return {
id: `group/${lowest.id}`,
name: named?.ferrata.name ?? null,
routeName: segments.find((segment) => segment.routeName)?.routeName ?? null,
segments,
grades: sorted.length ? { min: sorted[0], max: sorted[sorted.length - 1] } : null,
rawGrade: sorted.length ? null : (segments.find((segment) => segment.grade.raw)?.grade.raw ?? null),
lengthM: members.reduce((sum, member) => sum + member.lengthM, 0),
bbox: members.slice(1).reduce(
(box, member) => ({
south: Math.min(box.south, member.bbox.south),
west: Math.min(box.west, member.bbox.west),
north: Math.max(box.north, member.bbox.north),
east: Math.max(box.east, member.bbox.east),
}),
members[0].bbox
),
midpoint: halfway(longest.points),
};
}
/** The segments as ferratas, in the order their first segment arrived. */
export function groupFerratas(ferratas: readonly Ferrata[]): FerrataGroup[] {
const keyed = new Map<string, { reach: number; segments: Segment[] }>();
const ordered: Array<Segment[] | string> = [];
for (const ferrata of ferratas) {
const points = ferrataPositions(ferrata);
if (points.length < 2) continue;
const segment: Segment = { ferrata, points, bbox: bboxOf(points), lengthM: lengthOf(points) };
const key = groupKey(ferrata);
if (!key) {
ordered.push([segment]);
continue;
}
const bucket = keyed.get(key.key);
if (bucket) bucket.segments.push(segment);
else {
keyed.set(key.key, { reach: key.reach, segments: [segment] });
ordered.push(key.key);
}
}
return ordered.flatMap((entry) => {
if (typeof entry !== 'string') return [toGroup(entry)];
const bucket = keyed.get(entry);
return bucket ? chains(bucket.segments, bucket.reach).map(toGroup) : [];
});
}
/** The group a way belongs to, for a click or a selection that names one segment. */
export function ferrataGroupOf(groups: readonly FerrataGroup[], segmentId: string | null): FerrataGroup | null {
if (!segmentId) return null;
return groups.find((group) => group.segments.some((segment) => segment.id === segmentId)) ?? null;
}
/** How many ferratas reach into the area on screen. */
export function ferrataGroupsInView(groups: readonly FerrataGroup[], view: FerrataBbox | null): number {
if (!view) return 0;
return groups.reduce((count, group) => (ferrataBboxesMeet(group.bbox, view) ? count + 1 : count), 0);
}
/**
* The group's grade on the K scale: the easiest and the hardest segment
* (`K2`, `K4`), the same label twice when they agree, null when no segment has
* a numeric grade.
*/
export function ferrataGradeRange(group: Pick<FerrataGroup, 'grades'>): { from: string; to: string } | null {
if (!group.grades) return null;
const from = ferrataGradeLabel(group.grades.min);
const to = ferrataGradeLabel(group.grades.max);
return from && to ? { from, to } : null;
}
type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* What a ferrata is called on the map and on its card: its own name, else its
* route's, else "unnamed"; and its grade, `K3` or `K2 to K4`, or without a
* numeric grade the tag as written (a letter scale such as "C/D" still says
* something), or null when there is none at all.
*/
export function ferrataLabel(
group: Pick<FerrataGroup, 'name' | 'routeName' | 'grades' | 'rawGrade'>,
t: Translate
): { name: string; grade: string | null } {
const name = group.name ?? group.routeName ?? t('ferratas.unnamed');
const range = ferrataGradeRange(group);
if (!range) return { name, grade: group.rawGrade };
return { name, grade: range.from === range.to ? range.from : t('ferratas.gradeRange', range) };
}
@@ -0,0 +1,70 @@
/**
* The name and grade of the via ferrata under the pointer on a GL map, the
* twin of the Leaflet layer's sticky tooltip: the same words, the look of the
* shared Tooltip (card surface, faint edge, caption tier), floating just above
* the pointer.
*
* Built from the DOM rather than React, because it follows every mouse move:
* a React state per move would render the whole map view each time. The words
* go in as text, never as markup. It lives in the map's own container, so it
* scrolls, hides and goes away with the map.
*/
/** How far above the pointer the label floats, in CSS pixels. */
const POINTER_GAP = 12;
const LABEL_CLASS =
'pointer-events-none absolute z-20 whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
interface LabelHost {
getContainer?(): HTMLElement;
}
interface FerrataHoverLabel {
show(text: { name: string; grade: string | null }, point: { x: number; y: number }): void;
hide(): void;
remove(): void;
}
const NO_LABEL: FerrataHoverLabel = { show: () => undefined, hide: () => undefined, remove: () => undefined };
export function ferrataHoverLabel(map: LabelHost): FerrataHoverLabel {
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
const container = map.getContainer();
let element: HTMLDivElement | null = null;
const ensure = () => {
if (element) return element;
element = document.createElement('div');
element.className = LABEL_CLASS;
element.setAttribute('role', 'tooltip');
element.dataset.testid = 'ferrata-hover-label';
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
element.style.inset = '0px auto auto 0px';
container.appendChild(element);
return element;
};
return {
show({ name, grade }, { x, y }) {
const label = ensure();
label.replaceChildren(document.createTextNode(name));
if (grade) {
const badge = document.createElement('span');
badge.className = 'ms-1.5 font-semibold';
badge.textContent = grade;
label.appendChild(badge);
}
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
label.hidden = false;
},
hide() {
if (element) element.hidden = true;
},
remove() {
element?.remove();
element = null;
},
};
}
@@ -0,0 +1,66 @@
// FE-FERRATA-ICON-001 to FE-FERRATA-ICON-004: the route icon halfway along a via ferrata.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
FERRATA_ICON_GAP,
FERRATA_ICON_PIXEL_RATIO,
FERRATA_ICON_SIZE,
ferrataIconSvg,
loadFerrataIcon,
placeFerrataIcons,
} from './ferrataIcon';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('ferrataIcon', () => {
it('FE-FERRATA-ICON-001: draws a ladder in the line colour on a disc of the casing colour', () => {
const svg = ferrataIconSvg({ line: 'var(--text-primary)', casing: 'rgb(255, 255, 255)' });
expect(svg).toContain('fill="rgb(255, 255, 255)"');
expect(svg).toContain('stroke="var(--text-primary)"');
expect(svg).toContain(`width="${FERRATA_ICON_SIZE}"`);
});
it('FE-FERRATA-ICON-002: lets nothing but a colour into the markup', () => {
const svg = ferrataIconSvg({ line: '"/><script>alert(1)</script>', casing: 'white' });
expect(svg).not.toContain('<script>');
expect(svg).toContain('stroke="currentColor"');
});
it('FE-FERRATA-ICON-003: hands GL a sharp image of the same SVG, and rejects when it cannot be decoded', async () => {
const made: Array<{ src: string; width?: number }> = [];
class FakeImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(public width?: number) {
made.push(this as unknown as { src: string; width?: number });
}
set src(value: string) {
(this as unknown as { _src: string })._src = value;
queueMicrotask(() => (value.includes('fail') ? this.onerror?.() : this.onload?.()));
}
get src() {
return (this as unknown as { _src: string })._src;
}
}
vi.stubGlobal('Image', FakeImage);
const image = await loadFerrataIcon({ line: 'black', casing: 'white' });
expect(image).toBe(made[0]);
expect(made[0]?.width).toBe(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
expect(decodeURIComponent(made[0]?.src ?? '')).toContain('stroke="black"');
await expect(loadFerrataIcon({ line: 'fail', casing: 'white' })).rejects.toThrow('could not be drawn');
});
it('FE-FERRATA-ICON-004: leaves out an icon that would touch one already placed, first come first placed', () => {
const placed = placeFerrataIcons([
{ id: 'a', x: 100, y: 100 },
{ id: 'b', x: 100 + FERRATA_ICON_GAP - 1, y: 100 },
{ id: 'c', x: 100, y: 100 + FERRATA_ICON_GAP },
{ id: 'd', x: 400, y: 400 },
]);
expect([...placed]).toEqual(['a', 'c', 'd']);
expect(FERRATA_ICON_GAP).toBeGreaterThan(FERRATA_ICON_SIZE);
});
});
@@ -0,0 +1,79 @@
import type { FerrataPaint } from './ferrataPaint';
/**
* The small route icon a via ferrata carries at its midpoint once the map is
* zoomed in: a ladder in the line colour on a disc of the casing colour, so it
* reads as part of the line it sits on.
*
* One SVG for both renderers: Leaflet puts it in a divIcon, where it can take
* `var(--token)` straight from the page; GL needs an image with the colours
* resolved, which `loadFerrataIcon` makes from the resolved paint.
*/
/** The zoom from which the icons show; below it they would crowd a valley full of ferratas. */
export const FERRATA_ICON_MIN_ZOOM = 13;
/** The icon's size on screen, in CSS pixels. */
export const FERRATA_ICON_SIZE = 22;
/** The name the GL renderer registers the image under. */
export const FERRATA_ICON_IMAGE = 'trek-ferrata-icon';
/**
* A colour as it may go into the markup: a resolved token (a hex, `rgb()` or
* named value) or a `var(--token)`. Anything else, which a token never
* resolves to, falls back to `currentColor` rather than reaching the SVG.
*/
function safeColour(value: string): string {
return /^[#\w\s(),.%-]+$/.test(value) ? value : 'currentColor';
}
export function ferrataIconSvg(paint: Pick<FerrataPaint, 'line' | 'casing'>, size = FERRATA_ICON_SIZE): string {
const line = safeColour(paint.line);
const casing = safeColour(paint.casing);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 22 22" aria-hidden="true">`,
`<circle cx="11" cy="11" r="9.75" fill="${casing}" stroke="${line}" stroke-width="1.5"/>`,
`<path d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6" fill="none" stroke="${line}" stroke-width="1.6" stroke-linecap="round"/>`,
'</svg>',
].join('');
}
/** How sharp the GL image is drawn; the map scales it back to FERRATA_ICON_SIZE. */
export const FERRATA_ICON_PIXEL_RATIO = 2;
/**
* The icon as an image for a GL map, in the colours the tokens resolve to now.
* Rejects when the browser cannot decode it, and the lines then simply go
* without their icons.
*/
export function loadFerrataIcon(paint: Pick<FerrataPaint, 'line' | 'casing'>): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('The via ferrata icon could not be drawn'));
const svg = ferrataIconSvg(paint, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
});
}
/** The room an icon keeps to the next one, in CSS pixels, so two discs never touch. */
export const FERRATA_ICON_GAP = FERRATA_ICON_SIZE + 4;
/**
* Which icons fit on screen without touching one another, for a renderer that
* does not place symbols itself (Leaflet; GL keeps `icon-allow-overlap` off for
* the same effect). `points` are the icons' screen positions in pixels, in the
* order they should win: an icon closer than FERRATA_ICON_GAP to one already
* placed is left out.
*/
export function placeFerrataIcons(points: ReadonlyArray<{ id: string; x: number; y: number }>): Set<string> {
const placed: Array<{ x: number; y: number }> = [];
const ids = new Set<string>();
for (const point of points) {
if (placed.some((other) => Math.hypot(other.x - point.x, other.y - point.y) < FERRATA_ICON_GAP)) continue;
placed.push(point);
ids.add(point.id);
}
return ids;
}
@@ -1,17 +1,17 @@
// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-008: the pure half of the via ferrata layer.
// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-007: the pure half of the via ferrata layer.
import { describe, expect, it } from 'vitest';
import { FERRATA_BBOX_MAX_SPAN_DEG, type Ferrata } from '@trek/shared';
import { groupFerratas } from './ferrataGroups';
import {
ferrataBboxFits,
ferrataBboxParam,
ferrataFeatureCollection,
ferrataGradeLabel,
ferrataGradeTone,
ferrataLabelCollection,
ferrataOsmUrl,
ferrataPositions,
roundFerrataBbox,
} from './ferrataModel';
const grade = (level: number | null, plus: '+' | '-' | null = null, raw: string | null = null) => ({
@@ -45,56 +45,28 @@ describe('ferrataModel', () => {
expect(ferrataGradeLabel(grade(null, null, 'C/D'))).toBeNull();
});
it('FE-FERRATA-MODEL-003: bands levels 0 to 2 easy, 3 and 4 medium, 5 and 6 hard, none unknown', () => {
expect([0, 1, 2, 3, 4, 5, 6].map((level) => ferrataGradeTone(grade(level)))).toEqual([
'easy',
'easy',
'easy',
'medium',
'medium',
'hard',
'hard',
]);
expect(ferrataGradeTone(grade(null, null, 'C'))).toBe('unknown');
});
it('FE-FERRATA-MODEL-004: rounds the map area outwards to three decimals, inside the world', () => {
expect(roundFerrataBbox({ south: 46.12345, west: 11.98761, north: 46.20001, east: 12.1 })).toEqual({
south: 46.123,
west: 11.987,
north: 46.201,
east: 12.1,
});
expect(roundFerrataBbox({ south: -90.5, west: -181, north: 91, east: 180.4 })).toEqual({
south: -90,
west: -180,
north: 90,
east: 180,
});
});
it('FE-FERRATA-MODEL-005: takes an area only up to the server limit and only a real box', () => {
it('FE-FERRATA-MODEL-003: takes an area only up to the server limit and only a real box', () => {
const max = FERRATA_BBOX_MAX_SPAN_DEG;
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max, east: 11 + max })).toBe(true);
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max + 0.01, east: 11.2 })).toBe(false);
expect(ferrataBboxFits({ south: 46, west: 11, north: 46.2, east: 11 + max + 0.01 })).toBe(false);
expect(ferrataBboxFits({ south: 46.2, west: 11, north: 46.2, east: 11.2 })).toBe(false);
// The server's own rule: a rounded 0.500 span that reads 0.5000000000000071 still goes out.
// The server's own rule: a 0.500 span that reads 0.5000000000000071 still goes out.
expect(ferrataBboxFits({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true);
});
it('FE-FERRATA-MODEL-006: writes the bbox parameter in south,west,north,east order', () => {
it('FE-FERRATA-MODEL-004: writes the bbox parameter in south,west,north,east order', () => {
expect(ferrataBboxParam({ south: 46.1, west: 11.25, north: 46.3, east: 11.5 })).toBe('46.100,11.250,46.300,11.500');
});
it('FE-FERRATA-MODEL-007: builds GeoJSON with [lng, lat] coordinates and only the id and tone', () => {
const collection = ferrataFeatureCollection([FERRATA]);
it('FE-FERRATA-MODEL-005: builds one GeoJSON line per way, in [lng, lat], carrying only the way and its group', () => {
const collection = ferrataFeatureCollection(groupFerratas([FERRATA]));
expect(collection).toEqual({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { id: 'way/123', tone: 'medium' },
properties: { id: 'way/123', group: 'group/way/123' },
geometry: {
type: 'LineString',
coordinates: [
@@ -109,7 +81,16 @@ describe('ferrataModel', () => {
expect(ferrataFeatureCollection([]).features).toEqual([]);
});
it('FE-FERRATA-MODEL-008: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => {
it('FE-FERRATA-MODEL-006: puts one label point per ferrata halfway along it, in [lng, lat]', () => {
const [feature] = ferrataLabelCollection(groupFerratas([FERRATA])).features;
expect(feature?.properties).toEqual({ group: 'group/way/123' });
expect(feature?.geometry.type).toBe('Point');
const [lng, lat] = feature?.geometry.coordinates ?? [0, 0];
expect(lng).toBeCloseTo(11.815, 4);
expect(lat).toBeCloseTo(46.535, 4);
});
it('FE-FERRATA-MODEL-007: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => {
expect(ferrataOsmUrl(FERRATA)).toBe('https://www.openstreetmap.org/way/123');
expect(ferrataOsmUrl({ id: 'javascript:alert(1)' })).toBe('https://www.openstreetmap.org/');
});
+45 -53
View File
@@ -1,14 +1,12 @@
import { type Ferrata, type FerrataBbox, type FerrataGrade, ferrataBboxSpanOk } from '@trek/shared';
/**
* The pure half of the via ferrata layer: grade wording, the difficulty band a
* badge and a line are coloured by, the map area a request asks for, and the
* GeoJSON the GL renderer draws. React-free, so both renderers and the detail
* card read the same answers.
*/
import type { FerrataGroup } from './ferrataGroups';
/** The difficulty band a grade falls into; `unknown` when OSM gave no 0 to 6 level. */
export type FerrataTone = 'easy' | 'medium' | 'hard' | 'unknown';
/**
* The pure half of the via ferrata layer: grade wording, the area checks a
* request makes, and the GeoJSON the GL renderer draws. React-free, so both
* renderers and the detail card read the same answers.
*/
/**
* The grade as the K scale reads it: `K3`, `K4+`, `K2-`. Null when the way has
@@ -20,15 +18,6 @@ export function ferrataGradeLabel(grade: FerrataGrade): string | null {
return `K${grade.level}${grade.plus ?? ''}`;
}
/** Levels 0 to 2 are easy, 3 and 4 medium, 5 and 6 hard. */
export function ferrataGradeTone(grade: FerrataGrade): FerrataTone {
const level = grade.level;
if (level === null || level === undefined) return 'unknown';
if (level <= 2) return 'easy';
if (level <= 4) return 'medium';
return 'hard';
}
/**
* The line as `[lat, lng]` pairs Leaflet takes. Zod's tuple reads as "maybe two
* numbers, maybe more" where the client compiles without strict mode, so the
@@ -52,27 +41,10 @@ export function ferrataOsmUrl(ferrata: Pick<Ferrata, 'id'>): string {
return /^way\/\d+$/.test(ferrata.id) ? `${OSM_ORIGIN}${ferrata.id}` : OSM_ORIGIN;
}
/** Three decimals is about 100 m: small pans land on the same area and do not refetch. */
const STEP = 1000;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
/**
* The map area rounded outwards to the request grid, so the box asked for
* always covers the box on screen.
*/
export function roundFerrataBbox(bbox: FerrataBbox): FerrataBbox {
return {
south: clamp(Math.floor(bbox.south * STEP) / STEP, -90, 90),
west: clamp(Math.floor(bbox.west * STEP) / STEP, -180, 180),
north: clamp(Math.ceil(bbox.north * STEP) / STEP, -90, 90),
east: clamp(Math.ceil(bbox.east * STEP) / STEP, -180, 180),
};
}
/**
* Whether the server takes this area: a real box no wider than
* FERRATA_BBOX_MAX_SPAN_DEG per side. A wider one is refused with a 400, so
* the client asks the user to zoom in instead of sending it.
* FERRATA_BBOX_MAX_SPAN_DEG per side. Past it the layer asks the user to zoom
* in instead of loading every tile of the view.
*/
export function ferrataBboxFits(bbox: FerrataBbox): boolean {
// The span check is the server's own, so a box the client sends is never refused as too large.
@@ -84,34 +56,54 @@ export function ferrataBboxParam(bbox: FerrataBbox): string {
return [bbox.south, bbox.west, bbox.north, bbox.east].map((edge) => edge.toFixed(3)).join(',');
}
interface FerrataFeature {
interface FerrataLineFeature {
type: 'Feature';
properties: { id: string; tone: FerrataTone };
properties: { id: string; group: string };
geometry: { type: 'LineString'; coordinates: Array<[number, number]> };
}
interface FerrataFeatureCollection {
type: 'FeatureCollection';
features: FerrataFeature[];
interface FerrataLabelFeature {
type: 'Feature';
properties: { group: string };
geometry: { type: 'Point'; coordinates: [number, number] };
}
interface FeatureCollection<F> {
type: 'FeatureCollection';
features: F[];
}
const lngLat = ([lat, lng]: [number, number]): [number, number] => [lng, lat];
/**
* The ferratas as a GeoJSON FeatureCollection for MapLibre or Mapbox GL. The
* contract carries [lat, lng] like the rest of TREK; GeoJSON wants [lng, lat],
* and this is the one place the layer flips it. Only the id and the tone ride
* along: a click looks the ferrata up by id, so names never pass through the
* map's own feature state.
* The segments as a GeoJSON FeatureCollection for MapLibre or Mapbox GL, one
* line per OSM way. The contract carries [lat, lng] like the rest of TREK;
* GeoJSON wants [lng, lat], and this is the one place the layer flips it. Only
* the way and its group ride along: a click looks the ferrata up by id, so
* names never pass through the map's own feature state, and a highlight
* filters on the group.
*/
export function ferrataFeatureCollection(ferratas: readonly Ferrata[]): FerrataFeatureCollection {
export function ferrataFeatureCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLineFeature> {
return {
type: 'FeatureCollection',
features: ferratas.map((ferrata) => ({
features: groups.flatMap((group) =>
group.segments.map((segment) => ({
type: 'Feature' as const,
properties: { id: segment.id, group: group.id },
geometry: { type: 'LineString' as const, coordinates: ferrataPositions(segment).map(lngLat) },
}))
),
};
}
/** One point per ferrata, halfway along it, where the GL renderer puts the route icon. */
export function ferrataLabelCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLabelFeature> {
return {
type: 'FeatureCollection',
features: groups.map((group) => ({
type: 'Feature' as const,
properties: { id: ferrata.id, tone: ferrataGradeTone(ferrata.grade) },
geometry: {
type: 'LineString' as const,
coordinates: ferrataPositions(ferrata).map(([lat, lng]): [number, number] => [lng, lat]),
},
properties: { group: group.id },
geometry: { type: 'Point' as const, coordinates: lngLat(group.midpoint) },
})),
};
}
@@ -1,33 +1,52 @@
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-002: the line colours come from the status tokens.
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-004: the line colours come from the content, surface and accent tokens.
import { afterEach, describe, expect, it } from 'vitest';
import { ferrataPaint } from './ferrataPaint';
import { ferrataPaint, isGreyColour, sameFerrataPaint } from './ferrataPaint';
const root = document.documentElement;
afterEach(() => {
for (const name of ['--success', '--warning', '--danger', '--text-muted', '--bg-primary'])
for (const name of ['--text-primary', '--bg-primary', '--accent', '--info', '--success', '--warning', '--danger'])
root.style.removeProperty(name);
});
describe('ferrataPaint', () => {
it('FE-FERRATA-PAINT-001: resolves each difficulty band and the casing from the tokens on the root', () => {
it('FE-FERRATA-PAINT-001: draws the line in the content colour on a surface casing, the highlight in the accent', () => {
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--bg-primary', 'white');
root.style.setProperty('--accent', 'navy');
expect(ferrataPaint()).toEqual({ line: 'black', casing: 'white', highlight: 'navy' });
});
it('FE-FERRATA-PAINT-002: never takes the grade colours of the tour line', () => {
root.style.setProperty('--success', 'green');
root.style.setProperty('--warning', 'orange');
root.style.setProperty('--danger', 'crimson');
root.style.setProperty('--text-muted', 'gray');
root.style.setProperty('--bg-primary', 'white');
expect(ferrataPaint()).toEqual({
tone: { easy: 'green', medium: 'orange', hard: 'crimson', unknown: 'gray' },
casing: 'white',
});
const paint = Object.values(ferrataPaint());
expect(paint).not.toContain('green');
expect(paint).not.toContain('orange');
expect(paint).not.toContain('crimson');
});
it('FE-FERRATA-PAINT-002: falls back to currentColor where a token is not defined', () => {
expect(ferrataPaint()).toEqual({
tone: { easy: 'currentColor', medium: 'currentColor', hard: 'currentColor', unknown: 'currentColor' },
casing: 'currentColor',
});
it('FE-FERRATA-PAINT-003: falls back to currentColor where a token is not defined, and compares by colour', () => {
const paint = ferrataPaint();
expect(paint).toEqual({ line: 'currentColor', casing: 'currentColor', highlight: 'currentColor' });
expect(sameFerrataPaint(paint, { ...paint })).toBe(true);
expect(sameFerrataPaint(paint, { ...paint, highlight: 'red' })).toBe(false);
});
it('FE-FERRATA-PAINT-004: a grey accent, the neutral default, hands the highlight to the info colour', () => {
root.style.setProperty('--text-primary', '#111827');
root.style.setProperty('--accent', '#111827');
root.style.setProperty('--info', '#2563eb');
expect(ferrataPaint().highlight).toBe('#2563eb');
root.style.setProperty('--accent', '#4f46e5');
expect(ferrataPaint().highlight).toBe('#4f46e5');
expect(isGreyColour('#e4e4e7')).toBe(true);
expect(isGreyColour('#fff')).toBe(true);
expect(isGreyColour('rgb(228, 228, 231)')).toBe(true);
expect(isGreyColour('#d97706')).toBe(false);
expect(isGreyColour('navy')).toBe(false);
});
});
+57 -23
View File
@@ -1,28 +1,61 @@
import type { FerrataTone } from './ferrataModel';
/**
* The colours the via ferrata lines are drawn in, per difficulty band.
* The colours the via ferrata lines are drawn in.
*
* Ferratas have a look of their own, apart from the green, yellow and red the
* tour line takes by gradient: a dark dashed line in the content colour on a
* light casing in the surface colour, and the accent for the ferrata under the
* pointer or open in the card. The grade is a badge on the card, never a line
* colour.
*
* The default accent is neutral, close to the content colour itself, so a lit
* ferrata in it would only lose its dashes. A grey accent therefore hands the
* highlight to the info colour, and the lit line is drawn solid and wider, on
* a wider casing, so it stands out by its shape as well as its colour.
*
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
* status tokens are read off the document when the layer draws (the precedent
* is the hazard overlay in useHazardLayerGL), which keeps the lines on the
* user's scheme without a colour literal of their own. Easy, medium and hard
* follow the same success, warning and danger tones the grade badge uses, so
* the line and the card agree.
* tokens are read off the document when the layer draws (the precedent is the
* hazard overlay in useHazardLayerGL), which keeps the lines on the user's
* scheme without a colour literal of their own.
*/
interface FerrataPaint {
tone: Record<FerrataTone, string>;
export interface FerrataPaint {
/** The dashed line itself. */
line: string;
/** The halo under the line, so it stays readable over a satellite or hillshade tile. */
casing: string;
/** The ferrata under the pointer or open in the card. */
highlight: string;
}
const TONE_TOKENS: Record<FerrataTone, string> = {
easy: '--success',
medium: '--warning',
hard: '--danger',
unknown: '--text-muted',
};
const TOKENS = {
line: '--text-primary',
casing: '--bg-primary',
highlight: '--accent',
/** The highlight when the accent is a grey, which would not stand out from the line. */
highlightFallback: '--info',
} as const;
/** How far apart the channels of a colour may lie and it still reads as a grey. */
const GREY_SPREAD = 24;
/** The red, green and blue of a hex or `rgb()` colour, or null for anything else (a name, `currentColor`). */
function channels(colour: string): [number, number, number] | null {
const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(colour);
if (hex?.[1]) {
const digits = hex[1].length === 3 ? [...hex[1]].map((d) => d + d) : (hex[1].match(/../g) ?? []);
const [r = 0, g = 0, b = 0] = digits.map((pair) => parseInt(pair, 16));
return [r, g, b];
}
const rgb = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/i.exec(colour);
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
return null;
}
/** Whether a resolved colour is a grey (the neutral default accent, light or dark). */
export function isGreyColour(colour: string): boolean {
const rgb = channels(colour);
return !!rgb && Math.max(...rgb) - Math.min(...rgb) <= GREY_SPREAD;
}
/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */
function token(name: string, style: CSSStyleDeclaration | null): string {
@@ -32,13 +65,14 @@ function token(name: string, style: CSSStyleDeclaration | null): string {
export function ferrataPaint(): FerrataPaint {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
const accent = token(TOKENS.highlight, style);
return {
tone: {
easy: token(TONE_TOKENS.easy, style),
medium: token(TONE_TOKENS.medium, style),
hard: token(TONE_TOKENS.hard, style),
unknown: token(TONE_TOKENS.unknown, style),
},
casing: token('--bg-primary', style),
line: token(TOKENS.line, style),
casing: token(TOKENS.casing, style),
highlight: isGreyColour(accent) ? token(TOKENS.highlightFallback, style) : accent,
};
}
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight;
}
@@ -0,0 +1,295 @@
/**
* FE-FERRATA-TILES-001 to FE-FERRATA-TILES-012: the per-trip tile store behind
* the via ferrata layer.
*
* Pinned: nothing goes out before the device cache was read, a tile is asked
* for once and never dropped, the view goes before any prefetch and runs
* beside one on its way rather than behind it, answers are
* kept per tile, the view's failures are reported and retried, and a stop
* calls off what is on its way but keeps what is loaded.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
import type { FerrataTileRow } from '../../repo/ferrataRepo';
import { FerrataTileStore, ferrataRowsOf } from './ferrataTileStore';
const ferrata = (id: number, lat: number, lng: number, lat2 = lat + 0.01, lng2 = lng): Ferrata => ({
id: `way/${id}`,
name: `Ferrata ${id}`,
grade: { raw: '3', level: 3, plus: null },
routeName: null,
geometry: [
[lat, lng],
[lat2, lng2],
],
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
const reply = (ferratas: Ferrata[], truncated = false): FerratasResponse => ({
ferratas,
truncated,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
});
interface Call {
bbox: FerrataBbox;
signal: AbortSignal;
resolve: (value: FerratasResponse) => void;
reject: (error: unknown) => void;
}
function harness(options: { persist?: boolean } = {}) {
const calls: Call[] = [];
const persisted: FerrataTileRow[][] = [];
const fetch = vi.fn(
(bbox: FerrataBbox, signal: AbortSignal) =>
new Promise<FerratasResponse>((resolve, reject) => calls.push({ bbox, signal, resolve, reject }))
);
const persist = vi.fn((rows: FerrataTileRow[]) => {
persisted.push(rows);
return Promise.resolve();
});
const store = new FerrataTileStore({
fetch,
persist: options.persist === false ? undefined : persist,
now: () => 1_000,
retryDelaysMs: [100, 200],
});
return { store, calls, fetch, persist, persisted };
}
/** Lets a settled fetch's handlers run. */
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
const A = { row: 930, col: 236 }; // 46.50 to 46.55, 11.80 to 11.85
const B = { row: 930, col: 237 };
const FAR = { row: 940, col: 236 };
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('FerrataTileStore', () => {
it('FE-FERRATA-TILES-001: asks for nothing before the cache was read, and nothing the cache already had', () => {
const { store, fetch } = harness();
expect(store.beginHydration()).toBe(true);
expect(store.beginHydration()).toBe(false);
store.want([A, B], 'visible');
expect(fetch).not.toHaveBeenCalled();
expect(store.getSnapshot().waiting.size).toBe(2);
const cached = ferrata(1, 46.52, 11.82);
store.hydrate([
{ tile: '930:236', fetchedAt: 1, ferratas: [cached] },
{ tile: '930:237', fetchedAt: 1, ferratas: [] },
]);
expect(fetch).not.toHaveBeenCalled();
const snapshot = store.getSnapshot();
expect(snapshot.hydrated).toBe(true);
expect([...snapshot.loaded].sort()).toEqual(['930:236', '930:237']);
expect(snapshot.waiting.size).toBe(0);
expect(snapshot.ferratas).toEqual([cached]);
});
it('FE-FERRATA-TILES-002: asks for the missing tiles in one box, keeps them, and never asks for them again', async () => {
const { store, calls, fetch } = harness();
store.hydrate([]);
store.want([A, B], 'visible');
expect(fetch).toHaveBeenCalledTimes(1);
expect(calls[0]?.bbox).toEqual({ south: 46.5, west: 11.8, north: 46.55, east: 11.9 });
const found = ferrata(1, 46.52, 11.82);
calls[0]?.resolve(reply([found]));
await flush();
expect(store.getSnapshot().ferratas).toEqual([found]);
expect(store.getSnapshot().loaded.has('930:237')).toBe(true);
store.want([A, B], 'visible');
store.want([A], 'background');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-TILES-003: keeps every ferrata it ever loaded, a later answer adding to them by id', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'visible');
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
await flush();
store.want([FAR], 'visible');
// The same way again, as a tile across its end returns it, and one more.
calls[1]?.resolve(reply([ferrata(1, 46.52, 11.82), ferrata(2, 47.01, 11.82)]));
await flush();
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/1', 'way/2']);
});
it('FE-FERRATA-TILES-004: the view goes out beside a prefetch on its way, and prefetches go one at a time', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([FAR], 'background');
expect(calls).toHaveLength(1);
// The view does not wait for the prefetch already on its way.
store.want([B], 'visible');
expect(calls).toHaveLength(2);
expect(calls[1]?.bbox).toEqual({ south: 46.5, west: 11.85, north: 46.55, east: 11.9 });
calls[1]?.resolve(reply([]));
await flush();
expect(calls).toHaveLength(2);
calls[0]?.resolve(reply([]));
await flush();
expect(calls[2]?.bbox.south).toBe(47);
});
it('FE-FERRATA-TILES-012: runs two of the view requests at once and queues the rest behind them', async () => {
const { store, calls } = harness();
store.hydrate([]);
// Three blocks of the grid apart: three requests.
store.want([A, { row: 930, col: 240 }, { row: 930, col: 244 }], 'visible');
expect(calls).toHaveLength(2);
calls[0]?.resolve(reply([]));
await flush();
expect(calls).toHaveLength(3);
expect(calls[2]?.bbox.west).toBe(12.2);
});
it('FE-FERRATA-TILES-005: a tile a queued prefetch holds moves up with it when the view wants it', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([FAR], 'background');
store.want([B], 'background');
store.want([B], 'visible');
expect(store.getSnapshot().waiting.has('930:237')).toBe(true);
// Moved up, it takes the view's lane at once instead of waiting behind the other prefetches.
expect(calls).toHaveLength(2);
expect(calls[1]?.bbox.west).toBe(11.85);
});
it('FE-FERRATA-TILES-006: keeps each answer per tile, an empty tile included', async () => {
const { store, calls, persisted } = harness();
store.hydrate([]);
store.want([A, B], 'visible');
const inA = ferrata(1, 46.52, 11.82);
calls[0]?.resolve(reply([inA]));
await flush();
expect(persisted).toEqual([
[
{ tile: '930:236', fetchedAt: 1_000, ferratas: [inA] },
{ tile: '930:237', fetchedAt: 1_000, ferratas: [] },
],
]);
});
it('FE-FERRATA-TILES-007: reports the view failing and asks again after each delay; a 400 reads as zoom in', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'visible');
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBe('unavailable');
expect(store.getSnapshot().waiting.size).toBe(0);
await vi.advanceTimersByTimeAsync(100);
expect(calls).toHaveLength(2);
calls[1]?.resolve(reply([]));
await flush();
expect(store.getSnapshot().failure).toBeNull();
store.want([FAR], 'visible');
calls[2]?.reject({ response: { status: 400 } });
await flush();
expect(store.getSnapshot().failure).toBe('refused');
await vi.advanceTimersByTimeAsync(1_000);
expect(calls).toHaveLength(3);
});
it('FE-FERRATA-TILES-008: a failed prefetch says nothing and is asked for again on the next want', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBeNull();
store.want([A], 'background');
expect(calls).toHaveLength(2);
});
it('FE-FERRATA-TILES-009: a stop aborts what is on its way and drops the queue, and keeps what is loaded', async () => {
const { store, calls } = harness();
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
store.want([A], 'visible');
store.want([B], 'background');
expect(calls).toHaveLength(2);
store.stop();
expect(calls.map((call) => call.signal.aborted)).toEqual([true, true]);
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
await flush();
expect(calls).toHaveLength(2);
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/9']);
expect(store.getSnapshot().waiting.size).toBe(0);
// Wanted again, the tiles go out again.
store.want([A], 'visible');
expect(calls).toHaveLength(3);
});
it('FE-FERRATA-TILES-010: tells its listeners only of a change, and keeps the list while only the tiles change', async () => {
const { store, calls } = harness({ persist: false });
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
const first = store.getSnapshot();
expect(listener).toHaveBeenCalledTimes(1);
store.want([FAR], 'visible');
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getSnapshot()).toBe(first);
store.want([A], 'visible');
calls[0]?.resolve(reply([]));
await flush();
expect(store.getSnapshot()).not.toBe(first);
expect(store.getSnapshot().ferratas).toBe(first.ferratas);
unsubscribe();
});
it('FE-FERRATA-TILES-011: a prefetch on its way that holds a tile of the view reports its failure as the view', async () => {
const { store, calls } = harness();
store.hydrate([]);
store.want([A], 'background');
store.want([A], 'visible');
expect(calls).toHaveLength(1);
expect(store.getSnapshot().waiting.has('930:236')).toBe(true);
calls[0]?.reject(new Error('502'));
await flush();
expect(store.getSnapshot().failure).toBe('unavailable');
expect(store.getSnapshot().waiting.size).toBe(0);
});
});
describe('ferrataRowsOf', () => {
it('gives a ferrata to every tile of the box its own line reaches into', () => {
// From 46.52 to 46.57: across the row line at 46.55.
const across = ferrata(1, 46.52, 11.82, 46.57, 11.82);
const rows = ferrataRowsOf({ minRow: 930, maxRow: 931, minCol: 236, maxCol: 237 }, [across], 5);
expect(rows.map((row) => [row.tile, row.ferratas.length])).toEqual([
['930:236', 1],
['930:237', 0],
['931:236', 1],
['931:237', 0],
]);
expect(rows.every((row) => row.fetchedAt === 5)).toBe(true);
});
});
@@ -0,0 +1,340 @@
import {
type Ferrata,
type FerrataBbox,
type FerratasResponse,
type FerrataTile,
type FerrataTileRange,
ferrataTileKey,
ferrataTileRange,
ferrataTileRequests,
ferrataTilesOf,
} from '@trek/shared';
import type { FerrataTileRow } from '../../repo/ferrataRepo';
import { ferrataPointsBbox } from './ferrataGroups';
import { ferrataPositions } from './ferrataModel';
/**
* The via ferrata layer's tiles for one trip, outside React.
*
* Every ferrata loaded for the trip stays, in a map by OSM id, whatever the
* map does afterwards: a pan or a zoom only asks for the tiles of the shared
* 0.05 degree grid that are not loaded yet, never for ones it has, and never
* drops any. Tiles wanted for the view on screen go first; the ring around it
* and the trip's area load behind them. The two have lanes of their own: the
* view's requests run beside a prefetch on its way rather than behind it,
* VISIBLE_AT_ONCE of them at a time, while prefetches go one at a time, so
* the view is never kept waiting by a prefetch. Answers go to `persist` per tile, and a
* trip's cached tiles come back through `hydrate`, which holds every request
* until the cache has been read, so a cached tile is never asked for again.
*
* Built for `useSyncExternalStore`: `subscribe` and `getSnapshot`, and the
* snapshot is a new object only when something in it changed.
*/
type FerrataTilePriority = 'visible' | 'background';
/**
* Why the last request for the view failed: `refused` (a 400, the server would
* not take the box, read as "zoom in") or `unavailable` (anything else: the
* Overpass mirrors, worth another try).
*/
type FerrataTileFailure = 'refused' | 'unavailable';
export interface FerrataTileSnapshot {
/** Every tile loaded for the trip, cached or fetched. */
loaded: ReadonlySet<string>;
/** Every ferrata those tiles hold, each once. */
ferratas: readonly Ferrata[];
/** Tiles wanted for the view that are queued or on their way. */
waiting: ReadonlySet<string>;
failure: FerrataTileFailure | null;
/** True when an answer for the view was cut at the server's cap. */
truncated: boolean;
/** Whether the cached tiles have been read. */
hydrated: boolean;
}
interface FerrataTileStoreDeps {
fetch: (bbox: FerrataBbox, signal: AbortSignal) => Promise<FerratasResponse>;
persist?: (rows: FerrataTileRow[]) => Promise<void>;
now?: () => number;
/** How long to wait before asking again for the view after the mirrors failed it. */
retryDelaysMs?: readonly number[];
}
interface Job {
bbox: FerrataBbox;
range: FerrataTileRange;
keys: string[];
priority: FerrataTilePriority;
}
const FERRATA_RETRY_DELAYS_MS = [5_000, 15_000, 45_000] as const;
/** Requests for the view on screen that may be on their way at once. */
const VISIBLE_AT_ONCE = 2;
/** Prefetches on their way at once: one, beside the view's own. */
const BACKGROUND_AT_ONCE = 1;
/** A job on its way, and the lane it took: the lane is what it counts against, even once it is moved up. */
interface Flight {
job: Job;
lane: FerrataTilePriority;
controller: AbortController;
}
function failureOf(error: unknown): FerrataTileFailure {
const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
return status === 400 ? 'refused' : 'unavailable';
}
/**
* The ferratas of an answer per tile of the box it was for: each one goes to
* every tile its own box reaches into, and every tile gets a row, an empty one
* included, because "no ferrata here" is an answer worth keeping too.
*/
export function ferrataRowsOf(range: FerrataTileRange, ferratas: readonly Ferrata[], at: number): FerrataTileRow[] {
const rows = new Map(ferrataTilesOf(range).map((tile) => [ferrataTileKey(tile), [] as Ferrata[]]));
for (const ferrata of ferratas) {
const box = ferrataPointsBbox(ferrataPositions(ferrata));
if (!box) continue;
const own = ferrataTileRange(box);
const minRow = Math.max(own.minRow, range.minRow);
const maxRow = Math.min(own.maxRow, range.maxRow);
const minCol = Math.max(own.minCol, range.minCol);
const maxCol = Math.min(own.maxCol, range.maxCol);
if (minRow > maxRow || minCol > maxCol) continue;
for (const tile of ferrataTilesOf({ minRow, maxRow, minCol, maxCol }))
rows.get(ferrataTileKey(tile))?.push(ferrata);
}
return [...rows].map(([tile, list]) => ({ tile, fetchedAt: at, ferratas: list }));
}
export class FerrataTileStore {
private readonly loaded = new Set<string>();
private readonly byId = new Map<string, Ferrata>();
/** Tiles in a queued or in-flight job, so no tile is asked for twice at once. */
private readonly pending = new Set<string>();
private readonly waiting = new Set<string>();
private queue: Job[] = [];
private readonly inFlight = new Set<Flight>();
private failure: FerrataTileFailure | null = null;
private truncated = false;
private hydrated = false;
private hydrating = false;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private retryAttempt = 0;
private readonly listeners = new Set<() => void>();
private snapshot: FerrataTileSnapshot;
/** Writes to the device cache that failed; the tiles still draw, they only load again next time. */
persistFailures = 0;
constructor(private readonly deps: FerrataTileStoreDeps) {
this.snapshot = this.build();
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): FerrataTileSnapshot => this.snapshot;
/** Starts reading the cache; requests wait until `hydrate` hands its rows over. */
beginHydration(): boolean {
if (this.hydrated || this.hydrating) return false;
this.hydrating = true;
return true;
}
/** The cached tiles. Queued jobs whose tiles all came back from the cache are dropped. */
hydrate(rows: readonly FerrataTileRow[]): void {
for (const row of rows) {
this.loaded.add(row.tile);
this.waiting.delete(row.tile);
for (const ferrata of row.ferratas) this.byId.set(ferrata.id, ferrata);
}
this.hydrating = false;
this.hydrated = true;
this.queue = this.queue.filter((job) => {
if (!job.keys.every((key) => this.loaded.has(key))) return true;
for (const key of job.keys) this.pending.delete(key);
return false;
});
this.emit();
this.pump();
}
/**
* Asks for the tiles not loaded or on their way yet. `visible` ones jump the
* queue, and a background job already holding one of them is moved up with
* it; the error of an earlier view is cleared, its retry called off.
*/
want(tiles: readonly FerrataTile[], priority: FerrataTilePriority): void {
const keys = tiles.map(ferrataTileKey);
if (priority === 'visible') {
this.clearRetry();
// The ones on their way too, so their failure is reported and retried like the view's own.
const jobs = [...[...this.inFlight].map((flight) => flight.job), ...this.queue];
for (const job of jobs) {
if (job.priority === 'background' && job.keys.some((key) => keys.includes(key))) job.priority = 'visible';
}
for (const key of keys) if (!this.loaded.has(key)) this.waiting.add(key);
}
const missing = tiles.filter((tile, index) => !this.loaded.has(keys[index]) && !this.pending.has(keys[index]));
for (const request of ferrataTileRequests(missing)) {
const jobKeys = ferrataTilesOf(request.range).map(ferrataTileKey);
for (const key of jobKeys) this.pending.add(key);
this.queue.push({ ...request, keys: jobKeys, priority });
}
this.emit();
this.pump();
}
/** Calls off everything queued, in flight or due for a retry; what is loaded stays. */
stop(): void {
this.clearRetry();
for (const flight of this.inFlight) flight.controller.abort();
this.inFlight.clear();
this.queue = [];
this.pending.clear();
this.waiting.clear();
this.emit();
}
private clearRetry(): void {
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
this.retryTimer = null;
}
/** How many jobs of a lane are on their way. */
private flying(lane: FerrataTilePriority): number {
let count = 0;
for (const flight of this.inFlight) if (flight.lane === lane) count += 1;
return count;
}
/** The next queued job of a priority, taken off the queue, or null for none. */
private take(priority: FerrataTilePriority): Job | null {
const index = this.queue.findIndex((job) => job.priority === priority);
if (index < 0) return null;
const [job] = this.queue.splice(index, 1);
return job ?? null;
}
/** Starts what the lanes have room for: the view's jobs in theirs, the prefetches in theirs. */
private pump(): void {
if (!this.hydrated) return;
for (const [lane, room] of [
['visible', VISIBLE_AT_ONCE],
['background', BACKGROUND_AT_ONCE],
] as const) {
while (this.flying(lane) < room) {
const job = this.take(lane);
if (!job) break;
this.launch(job, lane);
}
}
}
private launch(job: Job, lane: FerrataTilePriority): void {
const controller = new AbortController();
const flight: Flight = { job, lane, controller };
this.inFlight.add(flight);
void this.deps.fetch(job.bbox, controller.signal).then(
(reply) => {
if (controller.signal.aborted) return;
this.inFlight.delete(flight);
this.settle(job, reply);
},
(error: unknown) => {
if (controller.signal.aborted) return;
this.inFlight.delete(flight);
this.fail(job, failureOf(error));
}
);
}
private settle(job: Job, reply: FerratasResponse): void {
const at = (this.deps.now ?? Date.now)();
for (const ferrata of reply.ferratas) this.byId.set(ferrata.id, ferrata);
for (const key of job.keys) {
this.loaded.add(key);
this.pending.delete(key);
this.waiting.delete(key);
}
if (job.priority === 'visible') {
this.failure = null;
this.retryAttempt = 0;
this.truncated = reply.truncated;
}
const persist = this.deps.persist;
if (persist) {
void persist(ferrataRowsOf(job.range, reply.ferratas, at)).catch(() => {
this.persistFailures += 1;
});
}
this.emit();
this.pump();
}
private fail(job: Job, failure: FerrataTileFailure): void {
for (const key of job.keys) this.pending.delete(key);
if (job.priority === 'visible') {
// The view's tiles are no longer on their way: the status reads the failure instead.
for (const key of job.keys) this.waiting.delete(key);
this.failure = failure;
const delay = (this.deps.retryDelaysMs ?? FERRATA_RETRY_DELAYS_MS)[this.retryAttempt];
if (failure === 'unavailable' && delay !== undefined) {
this.retryAttempt += 1;
const tiles = ferrataTilesOf(job.range);
this.clearRetry();
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.want(tiles, 'visible');
}, delay);
}
}
this.emit();
this.pump();
}
private build(): FerrataTileSnapshot {
return {
loaded: new Set(this.loaded),
ferratas: [...this.byId.values()],
waiting: new Set(this.waiting),
failure: this.failure,
truncated: this.truncated,
hydrated: this.hydrated,
};
}
private emit(): void {
const next = this.build();
const prev = this.snapshot;
// An unchanged list keeps its reference, so what is drawn from it is not rebuilt.
const sameFerratas =
prev.ferratas.length === next.ferratas.length &&
prev.ferratas.every((ferrata, index) => ferrata === next.ferratas[index]);
const same =
sameFerratas &&
prev.failure === next.failure &&
prev.truncated === next.truncated &&
prev.hydrated === next.hydrated &&
sameSet(prev.loaded, next.loaded) &&
sameSet(prev.waiting, next.waiting);
if (same) return;
this.snapshot = sameFerratas ? { ...next, ferratas: prev.ferratas } : next;
for (const listener of [...this.listeners]) listener();
}
}
function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) return false;
for (const value of a) if (!b.has(value)) return false;
return true;
}
@@ -1,12 +1,16 @@
/**
* FE-FERRATA-GL-001 to FE-FERRATA-GL-008: the via ferrata layer on a
* FE-FERRATA-GL-001 to FE-FERRATA-GL-012: the via ferrata layer on a
* MapLibre or Mapbox GL map.
*
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
* still loading, the failure the hazard overlay had), new data replaces the
* source's contents instead of rebuilding the layers, a click on the hit line
* hands the ferrata to `onSelect`, a theme change repaints the installed
* layers in place, and an unmount leaves nothing behind.
* still loading, the failure the hazard overlay had), the line is the dark
* dashed content colour and never a grade colour, new data replaces the
* sources' contents instead of rebuilding the layers, a click on the hit line
* hands the ferrata and the way to `onSelect`, hover and selection highlight
* the whole ferrata (solid, on a wider casing) and name it above the pointer,
* the 3D terrain draws wider lines, the route icon joins once its image is drawn, a theme
* change repaints the installed layers in place, and an unmount leaves nothing
* behind.
*/
import { act, render, renderHook, waitFor } from '@testing-library/react';
import type { Map as MapboxMap, MapMouseEvent } from 'mapbox-gl';
@@ -15,32 +19,54 @@ import { describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import FerrataLayerGL from './FerrataLayerGL';
import { groupFerratas } from './ferrataGroups';
import { useFerrataLayerGL } from './useFerrataLayerGL';
const SOURCE = 'trek-ferratas';
const LAYERS = ['trek-ferratas-casing', 'trek-ferratas-line', 'trek-ferratas-hit'];
vi.mock('./ferrataIcon', async (importOriginal) => ({
...(await importOriginal<typeof import('./ferrataIcon')>()),
loadFerrataIcon: vi.fn(() => Promise.resolve({ width: 44, height: 44 })),
}));
const ferrata = (id: string, level: number | null = 2): Ferrata => ({
const SOURCE = 'trek-ferratas';
const LABEL_SOURCE = 'trek-ferratas-labels';
const CASING = 'trek-ferratas-casing';
const LINE = 'trek-ferratas-line';
const HIGHLIGHT_CASING = 'trek-ferratas-highlight-casing';
const HIGHLIGHT = 'trek-ferratas-highlight';
const HIT = 'trek-ferratas-hit';
const LABEL = 'trek-ferratas-label';
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
const ferrata = (id: string, lat: number, name: string): Ferrata => ({
id,
name: `Ferrata ${id}`,
grade: { raw: level === null ? null : String(level), level, plus: null },
name,
grade: { raw: '2', level: 2, plus: null },
routeName: null,
geometry: [
[46.5, 11.7],
[46.51, 11.71],
[lat, 11.7],
[lat + 0.01, 11.71],
],
osmUrl: `https://www.openstreetmap.org/${id}`,
});
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
const GROUPS = groupFerratas([
ferrata('way/1', 46.5, 'Via A'),
ferrata('way/2', 46.51, 'Via A'),
ferrata('way/3', 47, 'Via B'),
]);
type Handler = (event?: unknown) => void;
function fakeGlMap(styleLoaded = true) {
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
const layers = new Set<string>();
const images = new Set<string>();
const handlers = new Map<string, Set<Handler>>();
const once = new Map<string, Set<Handler>>();
const state = { styleLoaded, hit: null as null | { properties: { id: string } } };
const state = { styleLoaded, hit: null as null | { properties: { id: string; group: string } } };
const canvas = document.createElement('canvas');
const container = document.createElement('div');
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
const map = {
isStyleLoaded: vi.fn(() => state.styleLoaded),
@@ -59,8 +85,17 @@ function fakeGlMap(styleLoaded = true) {
layers.delete(id);
}),
setPaintProperty: vi.fn(),
setFilter: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
images.add(id);
}),
removeImage: vi.fn((id: string) => {
images.delete(id);
}),
queryRenderedFeatures: vi.fn(() => (state.hit ? [state.hit] : [])),
getCanvas: vi.fn(() => canvas),
getContainer: vi.fn(() => container),
on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
const key = keyOf(type, layerOrFn);
const set = handlers.get(key) ?? new Set<Handler>();
@@ -85,7 +120,9 @@ function fakeGlMap(styleLoaded = true) {
state,
sources,
layers,
images,
canvas,
container,
emit,
listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0),
emitIdle: () => {
@@ -98,62 +135,82 @@ function fakeGlMap(styleLoaded = true) {
const click = { point: { x: 4, y: 4 }, lngLat: { lng: 11.705, lat: 46.505 } } as unknown as MapMouseEvent;
const addedIds = (gl: ReturnType<typeof fakeGlMap>) => gl.spies.addLayer.mock.calls.map(([layer]) => layer.id);
const spec = (gl: ReturnType<typeof fakeGlMap>, id: string) =>
gl.spies.addLayer.mock.calls.map(([layer]) => layer).find((layer) => layer.id === id) as unknown as {
paint: Record<string, unknown>;
filter?: unknown;
minzoom?: number;
layout?: Record<string, unknown>;
};
const filterOf = (ids: string[]) => ['in', ['get', 'group'], ['literal', ids]];
describe('useFerrataLayerGL', () => {
it('FE-FERRATA-GL-001: adds one source and the casing, line and hit layers, in that order', () => {
it('FE-FERRATA-GL-001: adds the line and label sources and the casing, line, highlight casing, highlight and hit layers, in that order', () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn()));
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(gl.spies.addLayer.mock.calls.map(([spec]) => spec.id)).toEqual(LAYERS);
expect(gl.spies.addSource).toHaveBeenCalledWith(LABEL_SOURCE, expect.objectContaining({ type: 'geojson' }));
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-FERRATA-GL-002: colours the line by difficulty band through a match on the tone', () => {
it('FE-FERRATA-GL-002: draws a dark dashed line in the content colour on a light casing, never by grade', () => {
const root = document.documentElement;
root.style.setProperty('--text-primary', 'black');
root.style.setProperty('--bg-primary', 'white');
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn()));
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
const line = gl.spies.addLayer.mock.calls
.map(([spec]) => spec)
.find((spec) => spec.id === LAYERS[1]) as unknown as {
paint: { 'line-color': unknown[] };
};
expect(line.paint['line-color'].slice(0, 2)).toEqual(['match', ['get', 'tone']]);
const line = spec(gl, LINE);
expect(line.paint['line-color']).toBe('black');
expect(line.paint['line-dasharray']).toEqual([2, 1.5]);
expect(spec(gl, CASING).paint['line-color']).toBe('white');
root.style.removeProperty('--text-primary');
root.style.removeProperty('--bg-primary');
});
it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => {
const gl = fakeGlMap(false);
renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn()));
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.addSource).not.toHaveBeenCalled();
gl.state.styleLoaded = true;
gl.emitIdle();
expect(gl.spies.addLayer).toHaveBeenCalledTimes(3);
expect(addedIds(gl)).toEqual(LAYERS);
});
it('FE-FERRATA-GL-004: new ferratas replace the source data without rebuilding the layers', () => {
it('FE-FERRATA-GL-004: new ferratas replace the sources data without rebuilding the layers', () => {
const gl = fakeGlMap();
const { rerender } = renderHook(({ list }) => useFerrataLayerGL(gl.map, list, vi.fn()), {
initialProps: { list: [ferrata('way/1')] },
const { rerender } = renderHook(({ groups }) => useFerrataLayerGL(gl.map, groups, vi.fn()), {
initialProps: { groups: GROUPS.slice(0, 1) },
});
rerender({ list: [ferrata('way/1'), ferrata('way/2', 5)] });
rerender({ groups: GROUPS });
expect(gl.spies.addLayer).toHaveBeenCalledTimes(3);
const setData = gl.sources.get(SOURCE)?.setData;
expect(setData).toHaveBeenLastCalledWith(
expect(addedIds(gl)).toEqual(LAYERS);
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenLastCalledWith(
expect.objectContaining({
features: [expect.anything(), expect.objectContaining({ properties: { id: 'way/2', tone: 'hard' } })],
features: [
expect.objectContaining({ properties: { id: 'way/1', group: 'group/way/1' } }),
expect.objectContaining({ properties: { id: 'way/2', group: 'group/way/1' } }),
expect.objectContaining({ properties: { id: 'way/3', group: 'group/way/3' } }),
],
})
);
expect(gl.sources.get(LABEL_SOURCE)?.setData).toHaveBeenLastCalledWith(
expect.objectContaining({ features: [expect.anything(), expect.anything()] })
);
});
it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata and the point to onSelect', () => {
it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
const gl = fakeGlMap();
const onSelect = vi.fn();
const target = ferrata('way/2');
renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1'), target], onSelect));
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, onSelect));
gl.state.hit = { properties: { id: 'way/2' } };
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
gl.emit('click', click);
expect(onSelect).toHaveBeenCalledWith(target, { lng: 11.705, lat: 46.505 });
expect(onSelect).toHaveBeenCalledWith(GROUPS[0], GROUPS[0]?.segments[1], { lng: 11.705, lat: 46.505 });
onSelect.mockClear();
gl.state.hit = null;
@@ -161,43 +218,102 @@ describe('useFerrataLayerGL', () => {
expect(onSelect).not.toHaveBeenCalled();
});
it('FE-FERRATA-GL-006: the pointer turns into a hand over the hit line', () => {
it('FE-FERRATA-GL-006: the pointer turns into a hand over a line, and the whole ferrata under it lights up', () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn()));
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(spec(gl, HIGHLIGHT).filter).toEqual(filterOf([]));
gl.emit(`mouseenter:${LAYERS[2]}`);
gl.emit(`mouseenter:${HIT}`);
expect(gl.canvas.style.cursor).toBe('pointer');
gl.emit(`mouseleave:${LAYERS[2]}`);
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
act(() => gl.emit(`mousemove:${HIT}`, click));
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/1']));
act(() => gl.emit(`mouseleave:${HIT}`));
expect(gl.canvas.style.cursor).toBe('');
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf([]));
});
it('FE-FERRATA-GL-007: unmounting removes the layers, the source and every listener; no map, no work', () => {
it('FE-FERRATA-GL-007: the ferrata open in the card stays highlighted', () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} ferratas={[ferrata('way/1')]} onSelect={vi.fn()} />);
const { rerender } = renderHook(({ selected }) => useFerrataLayerGL(gl.map, GROUPS, vi.fn(), selected), {
initialProps: { selected: null as string | null },
});
rerender({ selected: 'group/way/3' });
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/3']));
});
it('FE-FERRATA-GL-008: adds the route icon layer from the zoom it shows at, once its image is drawn', async () => {
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
expect(gl.images.has('trek-ferrata-icon')).toBe(true);
const label = spec(gl, LABEL);
expect(label.minzoom).toBe(13);
expect(label.layout?.['icon-image']).toBe('trek-ferrata-icon');
});
it('FE-FERRATA-GL-009: unmounting removes the layers, the sources, the icon and every listener; no map, no work', async () => {
const gl = fakeGlMap();
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
unmount();
expect(gl.layers.size).toBe(0);
expect(gl.sources.size).toBe(0);
expect(gl.images.size).toBe(0);
expect(gl.listeners()).toBe(0);
expect(() => renderHook(() => useFerrataLayerGL(null, [ferrata('way/1')], vi.fn()))).not.toThrow();
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
});
it('FE-FERRATA-GL-008: a theme change repaints the installed layers without rebuilding them', async () => {
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
const root = document.documentElement;
root.style.setProperty('--success', 'green');
root.style.setProperty('--text-primary', 'black');
const gl = fakeGlMap();
renderHook(() => useFerrataLayerGL(gl.map, [ferrata('way/1')], vi.fn()));
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
expect(gl.spies.setPaintProperty).not.toHaveBeenCalled();
await act(async () => {
root.style.setProperty('--success', 'lime');
root.style.setProperty('--text-primary', 'ivory');
await Promise.resolve();
});
await waitFor(() =>
expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LAYERS[1], 'line-color', expect.arrayContaining(['lime']))
);
expect(gl.spies.addLayer).toHaveBeenCalledTimes(3);
root.style.removeProperty('--success');
await waitFor(() => expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LINE, 'line-color', 'ivory'));
expect(addedIds(gl).filter((id) => LAYERS.includes(id))).toEqual(LAYERS);
root.style.removeProperty('--text-primary');
});
it('FE-FERRATA-GL-011: names the ferrata under the pointer with its grade, above the pointer, and hides it on leave', () => {
const gl = fakeGlMap();
const { unmount } = renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
act(() => gl.emit(`mousemove:${HIT}`, click));
const label = gl.container.querySelector<HTMLElement>('[data-testid="ferrata-hover-label"]');
expect(label?.hidden).toBe(false);
expect(label?.textContent).toBe('Via AK2');
expect(label?.querySelector('span')?.textContent).toBe('K2');
expect(label?.style.transform).toContain('translate(4px, -8px)');
expect(label).toHaveClass('bg-surface-card', 'text-content', 'border-edge-faint');
act(() => gl.emit(`mouseleave:${HIT}`));
expect(label?.hidden).toBe(true);
unmount();
expect(gl.container.querySelector('[data-testid="ferrata-hover-label"]')).toBeNull();
});
it('FE-FERRATA-GL-012: lights a ferrata solid on a wider casing, and draws wider lines on the 3D terrain', () => {
const flat = fakeGlMap();
renderHook(() => useFerrataLayerGL(flat.map, GROUPS, vi.fn()));
expect(spec(flat, CASING).paint['line-width']).toBe(6);
expect(spec(flat, LINE).paint['line-width']).toBe(2.5);
expect(spec(flat, HIGHLIGHT_CASING).paint['line-width']).toBe(9);
expect(spec(flat, HIGHLIGHT).paint['line-width']).toBe(4);
expect(spec(flat, HIGHLIGHT).paint['line-dasharray']).toBeUndefined();
const terrain = fakeGlMap();
renderHook(() => useFerrataLayerGL(terrain.map, GROUPS, vi.fn(), null, 'terrain'));
expect(spec(terrain, CASING).paint['line-width']).toBe(8);
expect(spec(terrain, LINE).paint['line-width']).toBe(3.5);
});
});
@@ -1,17 +1,46 @@
import type { ExpressionSpecification, GeoJSONSource, Map, MapMouseEvent } from 'mapbox-gl';
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Ferrata } from '@trek/shared';
import { ferrataFeatureCollection } from './ferrataModel';
import { useTranslation } from '../../i18n';
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
import { ferrataHoverLabel } from './ferrataHoverLabel';
import { FERRATA_ICON_IMAGE, FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_PIXEL_RATIO, loadFerrataIcon } from './ferrataIcon';
import { ferrataFeatureCollection, ferrataLabelCollection } from './ferrataModel';
import type { FerrataPaint } from './ferrataPaint';
import { useFerrataPaint } from './useFerrataPaint';
const SOURCE = 'trek-ferratas';
const LABEL_SOURCE = `${SOURCE}-labels`;
const CASING = `${SOURCE}-casing`;
const LINE = `${SOURCE}-line`;
/** The wider casing under a lit ferrata, so it stands out by its shape and not only its colour. */
const HIGHLIGHT_CASING = `${SOURCE}-highlight-casing`;
/** The whole ferrata under the pointer or open in the card, drawn solid over its own line. */
const HIGHLIGHT = `${SOURCE}-highlight`;
/** A wide, invisible line on top, so a thin dashed route is still easy to tap. */
const HIT = `${SOURCE}-hit`;
const LAYERS = [CASING, LINE, HIT];
const LABEL = `${SOURCE}-label`;
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT, LABEL];
const LIT_LAYERS = [HIGHLIGHT_CASING, HIGHLIGHT];
/**
* The line widths, in pixels. On the 3D terrain the map is seen at a slant
* and over a hillshade, so the lines there are drawn wider to stay readable.
*/
interface FerrataWidths {
casing: number;
line: number;
litCasing: number;
lit: number;
}
const FLAT_WIDTHS: FerrataWidths = { casing: 6, line: 2.5, litCasing: 9, lit: 4 };
const TERRAIN_WIDTHS: FerrataWidths = { casing: 8, line: 3.5, litCasing: 11, lit: 5 };
/** Where the layer is drawn: the flat map, or the 3D terrain, which draws its lines wider. */
type FerrataSurface = 'flat' | 'terrain';
/** Where on the map a ferrata was clicked, for a popup or card anchored there. */
interface FerrataClickPoint {
@@ -19,51 +48,142 @@ interface FerrataClickPoint {
lat: number;
}
export type FerrataSelectHandler = (ferrata: Ferrata, at: FerrataClickPoint) => void;
/** A click on a line: the ferrata it belongs to, the OSM way under the pointer and where it landed. */
export type FerrataSelectHandler = (group: FerrataGroup, segment: Ferrata, at: FerrataClickPoint) => void;
type Paint = ReturnType<typeof useFerrataPaint>;
/** The groups to highlight, as a GL filter on the features' `group`. */
function highlightFilter(ids: readonly string[]): ExpressionSpecification {
return ['in', ['get', 'group'], ['literal', [...ids]]];
}
/** The line colour per difficulty band, as a GL `match` on the feature's `tone`. */
function toneColor(paint: Paint): ExpressionSpecification {
return [
'match',
['get', 'tone'],
'easy',
paint.tone.easy,
'medium',
paint.tone.medium,
'hard',
paint.tone.hard,
paint.tone.unknown,
];
const LINE_LAYOUT = { 'line-cap': 'round', 'line-join': 'round' } as const;
/**
* The two sources and the five line layers, bottom to top: casing, dashed
* line, the lit ferrata's wider casing and its solid line, hit line.
*/
function addFerrataLayers(
map: Map,
paint: FerrataPaint,
widths: FerrataWidths,
data: GeoJSON.GeoJSON,
labels: GeoJSON.GeoJSON
): void {
map.addSource(SOURCE, { type: 'geojson', data });
map.addSource(LABEL_SOURCE, { type: 'geojson', data: labels });
map.addLayer({
id: CASING,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.casing, 'line-width': widths.casing, 'line-opacity': 0.9 },
});
map.addLayer({
id: LINE,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.line, 'line-width': widths.line, 'line-dasharray': [2, 1.5] },
});
map.addLayer({
id: HIGHLIGHT_CASING,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
filter: highlightFilter([]),
paint: { 'line-color': paint.casing, 'line-width': widths.litCasing },
});
map.addLayer({
id: HIGHLIGHT,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
filter: highlightFilter([]),
paint: { 'line-color': paint.highlight, 'line-width': widths.lit },
});
map.addLayer({
id: HIT,
type: 'line',
source: SOURCE,
layout: LINE_LAYOUT,
paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 },
});
}
/**
* The via ferrata layer on a MapLibre or Mapbox GL map: one GeoJSON source,
* a casing, the dashed line coloured by difficulty band, and a hit line that
* turns a click into `onSelect(ferrata, point)`.
* Draws the route icon in `paint` and, the first time, the symbol layer that
* shows it from FERRATA_ICON_MIN_ZOOM. Done once the image is decoded; a
* layer torn down meanwhile (`gone`) is left alone.
*/
function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): void {
void loadFerrataIcon(paint).then(
(image) => {
if (gone() || !map.getSource(LABEL_SOURCE)) return;
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
map.addImage(FERRATA_ICON_IMAGE, image, { pixelRatio: FERRATA_ICON_PIXEL_RATIO });
if (map.getLayer(LABEL)) return;
map.addLayer({
id: LABEL,
type: 'symbol',
source: LABEL_SOURCE,
minzoom: FERRATA_ICON_MIN_ZOOM,
layout: { 'icon-image': FERRATA_ICON_IMAGE, 'icon-allow-overlap': false, 'icon-ignore-placement': false },
});
},
// Undecodable: the lines simply go without their icons.
() => undefined
);
}
function removeFerrataLayers(map: Map): void {
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
}
/**
* The via ferrata layer on a MapLibre or Mapbox GL map: a source of the OSM
* ways and one of a point per ferrata, a light casing, the dark dashed line,
* the highlight of the ferrata under the pointer or `selectedId` (solid and
* wider, on a wider casing), a hit line that turns a click into
* `onSelect(group, way, point)` and shows the name and grade above the
* pointer, and from FERRATA_ICON_MIN_ZOOM a small route icon halfway along
* each ferrata. `surface` 'terrain' draws the lines wider for the 3D map.
*
* The layers are installed once per map and again after a style swap (a
* basemap or theme change drops every custom layer); new ferratas only replace
* the source's data, so a refetch after a pan does not rebuild the layers. Like
* the hazard overlay, a draw that finds the style busy loading tiles waits for
* `idle` rather than for a `style.load` that may never come. A theme change
* repaints the installed layers in place instead of rebuilding them.
* the sources' data, so tiles loading after a pan do not rebuild the layers.
* Like the hazard overlay, a draw that finds the style busy loading tiles
* waits for `idle` rather than for a `style.load` that may never come. A theme
* change repaints the installed layers in place and redraws the icon.
*/
export function useFerrataLayerGL(map: Map | null, ferratas: readonly Ferrata[], onSelect: FerrataSelectHandler) {
const data = useMemo(() => ferrataFeatureCollection(ferratas), [ferratas]);
export function useFerrataLayerGL(
map: Map | null,
groups: readonly FerrataGroup[],
onSelect: FerrataSelectHandler,
selectedId: string | null = null,
surface: FerrataSurface = 'flat'
) {
const { t } = useTranslation();
const data = useMemo(() => ferrataFeatureCollection(groups), [groups]);
const labels = useMemo(() => ferrataLabelCollection(groups), [groups]);
const paint = useFerrataPaint();
const [hovered, setHovered] = useState<string | null>(null);
// The handlers read the newest props without reinstalling the layers on every render.
const latest = useRef({ ferratas, data, onSelect, paint });
const latest = useRef({ groups, data, labels, onSelect, paint, t, surface });
// The paint the installed layers were last drawn in, so a mount does not repaint what it just drew.
const painted = useRef<Paint | null>(null);
const painted = useRef<FerrataPaint | null>(null);
// Set by the install effect: redraws the icon in the current paint on a theme change.
const iconRepaint = useRef<(() => void) | null>(null);
useEffect(() => {
latest.current = { ferratas, data, onSelect, paint };
latest.current = { groups, data, labels, onSelect, paint, t, surface };
});
useEffect(() => {
if (!map) return;
let waiting = false;
let disposed = false;
const hoverLabel = ferrataHoverLabel(map);
const draw = () => {
if (map.getSource(SOURCE)) return;
if (!map.isStyleLoaded()) {
@@ -73,80 +193,90 @@ export function useFerrataLayerGL(map: Map | null, ferratas: readonly Ferrata[],
}
return;
}
const paint = latest.current.paint;
const { paint, data, labels } = latest.current;
painted.current = paint;
const lineLayout = { 'line-cap': 'round', 'line-join': 'round' } as const;
map.addSource(SOURCE, { type: 'geojson', data: latest.current.data });
map.addLayer({
id: CASING,
type: 'line',
source: SOURCE,
layout: lineLayout,
paint: { 'line-color': paint.casing, 'line-width': 6, 'line-opacity': 0.75 },
});
map.addLayer({
id: LINE,
type: 'line',
source: SOURCE,
layout: lineLayout,
paint: {
'line-color': toneColor(paint),
'line-width': 3,
'line-dasharray': [2, 1.5],
},
});
map.addLayer({
id: HIT,
type: 'line',
source: SOURCE,
layout: lineLayout,
paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 },
});
const widths = latest.current.surface === 'terrain' ? TERRAIN_WIDTHS : FLAT_WIDTHS;
addFerrataLayers(map, paint, widths, data, labels);
addFerrataIcon(map, paint, () => disposed);
};
const retry = () => {
waiting = false;
draw();
};
const click = (event: MapMouseEvent) => {
if (!map.getLayer(HIT)) return;
const featureAt = (event: MapMouseEvent) => {
if (!map.getLayer(HIT)) return null;
const hit = map.queryRenderedFeatures(event.point, { layers: [HIT] })[0];
const id: unknown = hit?.properties?.id;
const ferrata = latest.current.ferratas.find((candidate) => candidate.id === id);
if (ferrata) latest.current.onSelect(ferrata, { lng: event.lngLat.lng, lat: event.lngLat.lat });
return hit?.properties ?? null;
};
const click = (event: MapMouseEvent) => {
const id: unknown = featureAt(event)?.id;
for (const group of latest.current.groups) {
const segment = group.segments.find((candidate) => candidate.id === id);
if (segment) {
latest.current.onSelect(group, segment, { lng: event.lngLat.lng, lat: event.lngLat.lat });
return;
}
}
};
const move = (event: MapMouseEvent) => {
const id: unknown = featureAt(event)?.group;
const group = typeof id === 'string' ? latest.current.groups.find((candidate) => candidate.id === id) : undefined;
setHovered(group ? group.id : null);
if (group) hoverLabel.show(ferrataLabel(group, latest.current.t), event.point);
else hoverLabel.hide();
};
const enter = () => {
map.getCanvas().style.cursor = 'pointer';
};
const leave = () => {
map.getCanvas().style.cursor = '';
setHovered(null);
hoverLabel.hide();
};
const repaintIcon = () => addFerrataIcon(map, latest.current.paint, () => disposed);
draw();
map.on('style.load', draw);
map.on('click', click);
map.on('mousemove', HIT, move);
map.on('mouseenter', HIT, enter);
map.on('mouseleave', HIT, leave);
iconRepaint.current = repaintIcon;
return () => {
disposed = true;
iconRepaint.current = null;
map.off('style.load', draw);
map.off('idle', retry);
map.off('click', click);
map.off('mousemove', HIT, move);
map.off('mouseenter', HIT, enter);
map.off('mouseleave', HIT, leave);
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
if (map.getSource(SOURCE)) map.removeSource(SOURCE);
hoverLabel.remove();
removeFerrataLayers(map);
};
}, [map]);
useEffect(() => {
const source = map?.getSource(SOURCE) as GeoJSONSource | undefined;
source?.setData(data);
}, [map, data]);
(map?.getSource(SOURCE) as GeoJSONSource | undefined)?.setData(data);
(map?.getSource(LABEL_SOURCE) as GeoJSONSource | undefined)?.setData(labels);
}, [map, data, labels]);
const highlighted = useMemo(
() => [...new Set([hovered, selectedId].filter((id): id is string => !!id))],
[hovered, selectedId]
);
useEffect(() => {
for (const layer of LIT_LAYERS) if (map?.getLayer(layer)) map.setFilter(layer, highlightFilter(highlighted));
}, [map, highlighted]);
useEffect(() => {
if (!map || painted.current === paint || !map.getLayer(LINE)) return;
painted.current = paint;
if (map.getLayer(CASING)) map.setPaintProperty(CASING, 'line-color', paint.casing);
if (map.getLayer(LINE)) map.setPaintProperty(LINE, 'line-color', toneColor(paint));
for (const layer of [CASING, HIGHLIGHT_CASING])
if (map.getLayer(layer)) map.setPaintProperty(layer, 'line-color', paint.casing);
map.setPaintProperty(LINE, 'line-color', paint.line);
if (map.getLayer(HIGHLIGHT)) map.setPaintProperty(HIGHLIGHT, 'line-color', paint.highlight);
iconRepaint.current?.();
}, [map, paint]);
}
@@ -173,7 +303,7 @@ export function isFerrataHit<Point>(map: HitTestMap<Point>, point: Point): boole
return !!map.getLayer(HIT) && map.queryRenderedFeatures(point, { layers: [HIT] }).length > 0;
}
const NO_FERRATAS: readonly Ferrata[] = [];
const NO_GROUPS: readonly FerrataGroup[] = [];
const IGNORE_SELECT: FerrataSelectHandler = () => {};
/**
@@ -182,7 +312,16 @@ const IGNORE_SELECT: FerrataSelectHandler = () => {};
*/
export function useFerrataMapLayerGL(
map: Map | null,
layer: { active: boolean; ferratas: readonly Ferrata[]; onSelect: FerrataSelectHandler } | undefined
layer:
| { active: boolean; groups: readonly FerrataGroup[]; onSelect: FerrataSelectHandler; selectedId: string | null }
| undefined,
surface: FerrataSurface = 'flat'
) {
useFerrataLayerGL(layer?.active ? map : null, layer?.ferratas ?? NO_FERRATAS, layer?.onSelect ?? IGNORE_SELECT);
useFerrataLayerGL(
layer?.active ? map : null,
layer?.groups ?? NO_GROUPS,
layer?.onSelect ?? IGNORE_SELECT,
layer?.selectedId ?? null,
surface
);
}
@@ -1,55 +1,55 @@
/**
* FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-005: the via ferrata layer as
* one map surface holds it: off by default, the area the map reports, the card
* a click opens.
* FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-008: the via ferrata layer as
* one map surface holds it: off by default, the area the map reports, the
* trip's area to prefetch, the ferratas grouped from OSM's segments with their
* count in view, and the card a click opens.
*
* useTripFerratas is mocked: what it asks the server is its own suite's
* subject. This one checks what the layer hands it (the area, and whether it
* is on at all) and what it hands the map back.
* subject. This one checks what the layer hands it (the view, whether it is on
* at all, the trip's area) and what it hands the map back.
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import { useFerrataMapLayer } from './useFerrataMapLayer';
import { ferrataAreaOf, useFerrataMapLayer } from './useFerrataMapLayer';
import { useTripFerratas } from './useTripFerratas';
vi.mock('./useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
const OTHER: FerrataBbox = { south: 46.4, west: 11.6, north: 46.5, east: 11.8 };
const OTHER: FerrataBbox = { south: 47.4, west: 11.6, north: 47.5, east: 11.8 };
const FERRATA: Ferrata = {
id: 'way/42',
const segment = (id: number, lat: number): Ferrata => ({
id: `way/${id}`,
name: 'Via delle Trincee',
grade: { raw: '4', level: 4, plus: null },
grade: { raw: String(id), level: id, plus: null },
routeName: null,
geometry: [
[46.52, 11.8],
[46.53, 11.81],
[lat, 11.8],
[lat + 0.001, 11.8],
],
osmUrl: 'https://www.openstreetmap.org/way/42',
};
osmUrl: `https://www.openstreetmap.org/way/${id}`,
});
// Two touching segments of one ferrata.
const LOWER = segment(2, 46.52);
const UPPER = segment(4, 46.521);
const lastCall = () => vi.mocked(useTripFerratas).mock.lastCall;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useTripFerratas).mockReturnValue({
ferratas: [FERRATA],
status: 'ready',
truncated: false,
attribution: null,
});
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER], status: 'ready', truncated: false });
});
describe('useFerrataMapLayer', () => {
it('FE-FERRATA-MAPLAYER-001: starts off, so nothing is asked for', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
expect(result.current.layer.active).toBe(false);
expect(result.current.layer.count).toBe(0);
expect(result.current.selected).toBeNull();
expect(lastCall()).toEqual([7, null, false]);
expect(lastCall()).toEqual([7, null, false, null]);
});
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
@@ -61,33 +61,82 @@ describe('useFerrataMapLayer', () => {
act(() => result.current.layer.onToggle());
expect(result.current.layer.active).toBe(true);
expect(lastCall()).toEqual([7, BOX, true]);
expect(lastCall()?.slice(0, 3)).toEqual([7, BOX, true]);
});
it('FE-FERRATA-MAPLAYER-003: while on, every new area reaches the request', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
act(() => result.current.onViewportChange(OTHER));
expect(lastCall()).toEqual([7, OTHER, true]);
expect(lastCall()?.slice(0, 3)).toEqual([7, OTHER, true]);
});
it('FE-FERRATA-MAPLAYER-004: a click opens the card, close shuts it, and the map gets what the request found', () => {
it("FE-FERRATA-MAPLAYER-004: hands the trip's area around its lines to the loader", () => {
const lines = [
[[46.5, 11.7]],
[
[46.7, 11.6],
[46.6, 11.9],
],
] as Array<Array<[number, number]>>;
renderHook(() => useFerrataMapLayer(7, lines));
expect(lastCall()?.[3]).toEqual({ south: 46.5, west: 11.6, north: 46.7, east: 11.9 });
expect(ferrataAreaOf([])).toBeNull();
expect(ferrataAreaOf([[[Number.NaN, 1]]])).toBeNull();
});
it('FE-FERRATA-MAPLAYER-005: groups the segments into one ferrata and counts the ferratas in view', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.onViewportChange(BOX));
act(() => result.current.layer.onToggle());
expect(result.current.layer.groups).toHaveLength(1);
expect(result.current.layer.groups[0]?.segments).toEqual([LOWER, UPPER]);
expect(result.current.layer.count).toBe(1);
act(() => result.current.onViewportChange(OTHER));
expect(result.current.layer.count).toBe(0);
// Out of view, still drawn.
expect(result.current.layer.groups).toHaveLength(1);
});
it('FE-FERRATA-MAPLAYER-006: a click opens the whole ferrata in the card, highlighted, and close shuts it', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
expect(result.current.layer.ferratas).toEqual([FERRATA]);
expect(result.current.layer.status).toBe('ready');
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(FERRATA, { lat: 46.52, lng: 11.8 }));
expect(result.current.selected).toBe(FERRATA);
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
expect(result.current.selected?.group.id).toBe(group.id);
expect(result.current.selected?.segment).toBe(UPPER);
expect(result.current.layer.selectedId).toBe(group.id);
act(() => result.current.close());
expect(result.current.selected).toBeNull();
expect(result.current.layer.selectedId).toBeNull();
});
it('FE-FERRATA-MAPLAYER-005: switching the layer off closes the card and stops asking', () => {
it('FE-FERRATA-MAPLAYER-007: the card follows its ferrata when more of its segments load', () => {
const { result, rerender } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
// A lower way loads and joins the ferrata; its id is now the group's.
const lowest = segment(1, 46.519);
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER, lowest], status: 'ready', truncated: false });
rerender();
expect(result.current.selected?.group.segments).toHaveLength(3);
expect(result.current.selected?.segment).toBe(UPPER);
expect(result.current.layer.selectedId).toBe('group/way/1');
});
it('FE-FERRATA-MAPLAYER-008: switching the layer off closes the card and stops asking', () => {
const { result } = renderHook(() => useFerrataMapLayer(7));
act(() => result.current.layer.onToggle());
act(() => result.current.layer.onSelect(FERRATA, { lat: 46.52, lng: 11.8 }));
const group = result.current.layer.groups[0];
if (!group) throw new Error('no group');
act(() => result.current.layer.onSelect(group, LOWER, { lat: 46.52, lng: 11.8 }));
act(() => result.current.layer.onToggle());
expect(result.current.layer.active).toBe(false);
@@ -2,62 +2,102 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import type { Ferrata, FerrataBbox } from '@trek/shared';
import {
type FerrataGroup,
ferrataGroupOf,
ferrataGroupsInView,
ferrataPointsBbox,
groupFerratas,
} from './ferrataGroups';
import type { FerrataSelectHandler } from './useFerrataLayerGL';
import { type FerrataStatus, useTripFerratas } from './useTripFerratas';
/**
* The via ferrata layer as a map hands it around: whether it is on, the switch
* the layer pill shows, what the status pill says, the lines to draw and what
* a click on one of them does. Both flat renderers and the 3D terrain map take
* this one object, so the three cannot drift apart on what the layer is.
* the layer pill shows with its count badge, the ferratas to draw (grouped
* from OSM's segments), the one open in the card, and what a click on a line
* does. Both flat renderers and the 3D terrain map take this one object, so
* the three cannot drift apart on what the layer is.
*/
export interface FerrataMapLayer {
active: boolean;
onToggle: () => void;
status: FerrataStatus;
ferratas: readonly Ferrata[];
/** How many ferratas reach into the view on screen. */
count: number;
groups: readonly FerrataGroup[];
/** The ferrata open in the card, drawn highlighted. */
selectedId: string | null;
onSelect: FerrataSelectHandler;
}
/** The ferrata open in the card, and the OSM way that was picked on it. */
export interface FerrataSelection {
group: FerrataGroup;
segment: Ferrata;
}
/** The box around every point of every line the stage hands over, or null for none. */
export function ferrataAreaOf(lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>): FerrataBbox | null {
return ferrataPointsBbox(lines.flat());
}
/**
* The via ferrata layer of one map surface: off by default, and nothing is
* asked for while it is off.
* read or asked for while it is off.
*
* `onViewportChange` takes the visible area from whichever map is on screen
* (the flat planner map or the 3D terrain). While the layer is off the area is
* only remembered, not put into state, so panning a map with the layer off
* re-renders nothing; switching it on starts from the last area the map
* reported instead of waiting for the next pan. `selected` is the ferrata
* whose card is open, closed again when the layer is switched off.
* reported instead of waiting for the next pan. `areaLines` are the lines the
* trip's area is made of (tour routes, waypoints, places), prefetched once
* the layer is on. The selection names the OSM way that was clicked, so it
* follows its ferrata when more segments load and join it, and closes when the
* layer is switched off.
*/
export function useFerrataMapLayer(tripId: number | null | undefined) {
export function useFerrataMapLayer(
tripId: number | null | undefined,
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = []
) {
const [active, setActive] = useState(false);
const [bbox, setBbox] = useState<FerrataBbox | null>(null);
const [selected, setSelected] = useState<Ferrata | null>(null);
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
const lastArea = useRef<FerrataBbox | null>(null);
const activeRef = useRef(false);
const { ferratas, status } = useTripFerratas(tripId, bbox, active);
const area = ferrataAreaOf(areaLines);
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area);
const groups = useMemo(() => groupFerratas(ferratas), [ferratas]);
const count = active ? ferrataGroupsInView(groups, bbox) : 0;
const onViewportChange = useCallback((area: FerrataBbox) => {
lastArea.current = area;
if (activeRef.current) setBbox(area);
const onViewportChange = useCallback((next: FerrataBbox) => {
lastArea.current = next;
if (activeRef.current) setBbox(next);
}, []);
const onToggle = useCallback(() => {
const next = !activeRef.current;
activeRef.current = next;
setActive(next);
setSelected(null);
setSelectedSegment(null);
if (next) setBbox(lastArea.current);
}, []);
const close = useCallback(() => setSelected(null), []);
const onSelect = useCallback<FerrataSelectHandler>((ferrata) => setSelected(ferrata), []);
const close = useCallback(() => setSelectedSegment(null), []);
const onSelect = useCallback<FerrataSelectHandler>((_group, segment) => setSelectedSegment(segment.id), []);
const selected = useMemo<FerrataSelection | null>(() => {
if (!active) return null;
const group = ferrataGroupOf(groups, selectedSegment);
const segment = group?.segments.find((candidate) => candidate.id === selectedSegment);
return group && segment ? { group, segment } : null;
}, [active, groups, selectedSegment]);
const selectedId = selected?.group.id ?? null;
const layer = useMemo<FerrataMapLayer>(
() => ({ active, onToggle, status, ferratas, onSelect }),
[active, onToggle, status, ferratas, onSelect]
() => ({ active, onToggle, status, count, groups, selectedId, onSelect }),
[active, onToggle, status, count, groups, selectedId, onSelect]
);
return { layer, onViewportChange, selected: active ? selected : null, close };
return { layer, onViewportChange, selected, close };
}
@@ -7,33 +7,33 @@ import { useFerrataPaint } from './useFerrataPaint';
const root = document.documentElement;
afterEach(() => {
root.style.removeProperty('--success');
root.style.removeProperty('--text-primary');
root.classList.remove('dark', 'unrelated');
root.removeAttribute('data-scheme');
});
describe('useFerrataPaint', () => {
it('FE-FERRATA-USEPAINT-001: starts with the colours the tokens resolve to', () => {
root.style.setProperty('--success', 'green');
root.style.setProperty('--text-primary', 'black');
const { result } = renderHook(() => useFerrataPaint());
expect(result.current.tone.easy).toBe('green');
expect(result.current.line).toBe('black');
});
it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => {
root.style.setProperty('--success', 'green');
root.style.setProperty('--text-primary', 'black');
const { result } = renderHook(() => useFerrataPaint());
await act(async () => {
root.setAttribute('data-scheme', 'forest');
root.style.setProperty('--success', 'teal');
root.style.setProperty('--text-primary', 'ivory');
await Promise.resolve();
});
await waitFor(() => expect(result.current.tone.easy).toBe('teal'));
await waitFor(() => expect(result.current.line).toBe('ivory'));
});
it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => {
root.style.setProperty('--success', 'green');
root.style.setProperty('--text-primary', 'black');
const { result } = renderHook(() => useFerrataPaint());
const first = result.current;
@@ -1,38 +1,26 @@
import { useEffect, useState } from 'react';
import { ferrataPaint } from './ferrataPaint';
import { type FerrataPaint, ferrataPaint, sameFerrataPaint } from './ferrataPaint';
/** The attributes `applyAppearance()` writes on `<html>` that change what the status tokens resolve to. */
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
type Paint = ReturnType<typeof ferrataPaint>;
function samePaint(a: Paint, b: Paint): boolean {
return (
a.casing === b.casing &&
a.tone.easy === b.tone.easy &&
a.tone.medium === b.tone.medium &&
a.tone.hard === b.tone.hard &&
a.tone.unknown === b.tone.unknown
);
}
/**
* The line colours, resolved again whenever the light/dark class, the colour
* scheme or a custom accent on `<html>` changes. Map paint holds resolved
* strings rather than `var(--token)`, so without this a theme toggle would leave
* the lines in the old scheme's colours until the next refetch. Same observer
* approach as `useIsDark`, which only watches the class. The reference only
* changes when a colour does, so a memo keyed on it does not churn.
* the lines in the old scheme's colours. Same observer approach as `useIsDark`,
* which only watches the class. The reference only changes when a colour does,
* so a memo keyed on it does not churn.
*/
export function useFerrataPaint(): Paint {
export function useFerrataPaint(): FerrataPaint {
const [paint, setPaint] = useState(ferrataPaint);
useEffect(() => {
if (typeof document === 'undefined') return;
const observer = new MutationObserver(() => {
const next = ferrataPaint();
setPaint((current) => (samePaint(current, next) ? current : next));
setPaint((current) => (sameFerrataPaint(current, next) ? current : next));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
return () => observer.disconnect();
@@ -0,0 +1,34 @@
// FE-FERRATA-GUARD-001: the press that closed a ferrata's card is kept from also acting on the map.
import { act, fireEvent, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useOutsidePressGuard } from './useOutsidePressGuard';
describe('useOutsidePressGuard', () => {
it('FE-FERRATA-GUARD-001: the guard drops the one map click after the closing press and is off once that click went by', () => {
vi.useFakeTimers();
try {
const handler = vi.fn();
const { result } = renderHook(() => useOutsidePressGuard());
const guarded = result.current.guard(handler);
expect(result.current.guard(undefined)).toBeUndefined();
act(() => result.current.arm());
guarded?.('first');
expect(handler).not.toHaveBeenCalled();
guarded?.('second');
expect(handler).toHaveBeenCalledWith('second');
// Armed, but the click lands somewhere that is not the map: the guard comes off right after it.
act(() => result.current.arm());
fireEvent.click(document.body);
act(() => {
vi.runAllTimers();
});
guarded?.('third');
expect(handler).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,61 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* Keeps the press that closed the card from also acting on the map.
*
* `arm` goes to the overlay's `onOutsidePress`; `guard` wraps the map's own
* click handlers (adding a waypoint, say). The click that ends the closing
* press is dropped once, and the guard is off again as soon as that click has
* gone by, or at the next press when the first one never became a click (a
* pan that ended off the page). A press on another ferrata keeps working: its
* click opens that ferrata's card, which never runs a guarded handler.
*/
export function useOutsidePressGuard() {
const armed = useRef(false);
const disarm = useRef<(() => void) | null>(null);
useEffect(() => () => disarm.current?.(), []);
const arm = useCallback(() => {
disarm.current?.();
armed.current = true;
let timer: number | undefined;
const off = () => {
if (timer !== undefined) window.clearTimeout(timer);
document.removeEventListener('click', afterClick, true);
document.removeEventListener('pointerdown', nextPress, true);
disarm.current = null;
};
// The map's handler runs during this click's dispatch; the guard comes off right after it.
const afterClick = () => {
document.removeEventListener('click', afterClick, true);
timer = window.setTimeout(() => {
armed.current = false;
off();
}, 0);
};
const nextPress = () => {
armed.current = false;
off();
};
document.addEventListener('click', afterClick, true);
// Listened for from the next task on, so the press being dispatched now does not count.
window.setTimeout(() => {
if (armed.current && disarm.current === off) document.addEventListener('pointerdown', nextPress, true);
}, 0);
disarm.current = off;
}, []);
const guard = useCallback(<A extends unknown[]>(handler: ((...args: A) => void) | undefined) => {
if (!handler) return undefined;
return (...args: A) => {
if (armed.current) {
armed.current = false;
return;
}
handler(...args);
};
}, []);
return { arm, guard };
}
@@ -1,11 +1,12 @@
/**
* FE-FERRATA-HOOK-001 to FE-FERRATA-HOOK-010: when the via ferrata layer asks
* the server, and what it reports while it does.
* the server, what it keeps, and what it reports while it does.
*
* The request goes to a public Overpass mirror behind the server, so the hook
* has to be frugal: nothing while the layer is off, one request per settled
* map area, the old one aborted when the area moves on, and none at all for an
* area the server would refuse or a device that is offline.
* The requests reach a public Overpass mirror behind the server, so the hook
* has to be frugal: nothing while the layer is off, a trip's cached tiles
* first, then only the tiles of the shared grid that are not loaded yet, never
* one twice, and none at all for an area the server would refuse or a device
* that is offline.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -14,13 +15,21 @@ import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { ferrataRepo } from '../../repo/ferrataRepo';
import { useTripFerratas } from './useTripFerratas';
import { FERRATA_AREA_PREFETCH_MAX_TILES, useTripFerratas } from './useTripFerratas';
vi.mock('../../repo/ferrataRepo', () => ({ ferrataRepo: { list: vi.fn() } }));
vi.mock('../../repo/ferrataRepo', () => ({
ferrataRepo: { list: vi.fn(), cachedTiles: vi.fn(), saveTiles: vi.fn(), cacheName: vi.fn(() => 'trek-offline') },
}));
vi.mock('../../hooks/useNetworkMode', () => ({ useNetworkMode: vi.fn() }));
const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
const OTHER: FerrataBbox = { south: 46.4, west: 11.6, north: 46.5, east: 11.8 };
/** Exactly the tile 930:236. */
const TILE: FerrataBbox = { south: 46.5, west: 11.8, north: 46.55, east: 11.85 };
const TILE_PARAM = '46.500,11.800,46.550,11.850';
/** The first block of the ring around it, south west of it: the ring goes out a 0.1 degree block at a time. */
const RING_FIRST_PARAM = '46.450,11.750,46.500,11.800';
/** A view inside the same tile, after a small pan. */
const INSIDE: FerrataBbox = { south: 46.51, west: 11.81, north: 46.54, east: 11.84 };
const WIDE: FerrataBbox = { south: 46, west: 11, north: 47, east: 12 };
const FERRATA: Ferrata = {
id: 'way/42',
@@ -28,15 +37,15 @@ const FERRATA: Ferrata = {
grade: { raw: '4', level: 4, plus: null },
routeName: null,
geometry: [
[46.52, 11.8],
[46.53, 11.81],
[46.52, 11.82],
[46.53, 11.82],
],
osmUrl: 'https://www.openstreetmap.org/way/42',
};
const reply = (ferratas: Ferrata[] = [FERRATA], truncated = false): FerratasResponse => ({
const reply = (ferratas: Ferrata[] = [FERRATA]): FerratasResponse => ({
ferratas,
truncated,
truncated: false,
source: 'openstreetmap',
attribution: '© OpenStreetMap contributors',
});
@@ -44,152 +53,162 @@ const reply = (ferratas: Ferrata[] = [FERRATA], truncated = false): FerratasResp
const network = (offline: boolean) =>
vi.mocked(useNetworkMode).mockReturnValue({ offline, forced: false, setForced: vi.fn() });
const params = () => vi.mocked(ferrataRepo.list).mock.calls.map(([, bbox]) => bbox);
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
network(false);
vi.mocked(ferrataRepo.list).mockResolvedValue(reply());
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([]);
vi.mocked(ferrataRepo.saveTiles).mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
/** Lets the debounce run out and the repo's promise settle. */
async function settle() {
/** Lets the cache read and settled requests run their handlers. */
async function drain() {
await act(async () => {
await vi.advanceTimersByTimeAsync(400);
// Enough turns for the prefetches, which go out one after another, to settle too.
for (let i = 0; i < 40; i += 1) await Promise.resolve();
});
}
/** Lets the debounce run out, then everything it started settle. */
async function settle(ms = 200) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
await drain();
}
describe('useTripFerratas', () => {
it('FE-FERRATA-HOOK-001: asks for nothing while the layer is off', async () => {
const { result } = renderHook(() => useTripFerratas(7, BOX, false));
await settle();
it('FE-FERRATA-HOOK-001: reads and asks for nothing while the layer is off', async () => {
const { result } = renderHook(() => useTripFerratas(7, TILE, false));
await settle(1_000);
expect(ferrataRepo.cachedTiles).not.toHaveBeenCalled();
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current).toEqual({ ferratas: [], status: 'idle', truncated: false, attribution: null });
expect(result.current.status).toBe('idle');
});
it('FE-FERRATA-HOOK-002: waits for the area to settle, then asks once for the rounded box', async () => {
const { result } = renderHook(() => useTripFerratas(7, BOX, true));
it('FE-FERRATA-HOOK-002: switched on, asks for the missing tiles of the view after the debounce, then the ring', async () => {
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
await drain();
expect(ferrataRepo.cachedTiles).toHaveBeenCalledWith(7);
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('loading');
expect(ferrataRepo.list).not.toHaveBeenCalled();
await settle();
expect(ferrataRepo.list).toHaveBeenCalledTimes(1);
expect(ferrataRepo.list).toHaveBeenCalledWith(7, '46.500,11.700,46.600,11.900', expect.any(AbortSignal));
expect(result.current).toEqual({
ferratas: [FERRATA],
status: 'ready',
truncated: false,
attribution: '© OpenStreetMap contributors',
});
});
it('FE-FERRATA-HOOK-003: a pan before the debounce runs out sends only the last area', async () => {
const { rerender } = renderHook(({ box }) => useTripFerratas(7, box, true), { initialProps: { box: BOX } });
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
rerender({ box: OTHER });
await settle();
expect(ferrataRepo.list).toHaveBeenCalledTimes(1);
expect(vi.mocked(ferrataRepo.list).mock.calls[0]?.[1]).toBe('46.400,11.600,46.500,11.800');
});
it('FE-FERRATA-HOOK-004: a new area aborts the request in flight and keeps the old lines while it loads', async () => {
let first: AbortSignal | undefined;
vi.mocked(ferrataRepo.list).mockImplementationOnce((_trip, _bbox, signal) => {
first = signal;
return Promise.resolve(reply());
});
const { result, rerender } = renderHook(({ box }) => useTripFerratas(7, box, true), {
initialProps: { box: BOX },
});
await settle();
expect(params()[0]).toBe(TILE_PARAM);
expect(params().slice(1)).toContain(RING_FIRST_PARAM);
expect(vi.mocked(ferrataRepo.list).mock.calls[0]?.[0]).toBe(7);
expect(result.current.status).toBe('ready');
expect(result.current.ferratas).toEqual([FERRATA]);
expect(ferrataRepo.saveTiles).toHaveBeenCalledWith(7, expect.any(Array), 'trek-offline');
});
vi.mocked(ferrataRepo.list).mockReturnValueOnce(new Promise(() => {}));
rerender({ box: OTHER });
expect(first?.aborted).toBe(true);
expect(result.current.status).toBe('loading');
it('FE-FERRATA-HOOK-003: draws the cached tiles at once and does not ask for them again', async () => {
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([{ tile: '930:236', fetchedAt: 1, ferratas: [FERRATA] }]);
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
await drain();
expect(result.current.status).toBe('ready');
expect(result.current.ferratas).toEqual([FERRATA]);
await settle();
expect(params()).not.toContain(TILE_PARAM);
});
it('FE-FERRATA-HOOK-004: a pan inside loaded tiles asks for nothing, and keeps every line drawn', async () => {
const { result, rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
initialProps: { view: TILE },
});
await settle();
const asked = params().length;
rerender({ view: INSIDE });
await settle();
expect(params()).toHaveLength(asked);
expect(result.current.ferratas).toEqual([FERRATA]);
expect(result.current.status).toBe('ready');
});
it('FE-FERRATA-HOOK-005: a fast pan ends in one request for where the map stopped', async () => {
const { rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
initialProps: { view: { south: 47, west: 11.8, north: 47.05, east: 11.85 } },
});
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
rerender({ view: TILE });
await settle();
expect(params()[0]).toBe(TILE_PARAM);
});
it('FE-FERRATA-HOOK-006: offline it asks for nothing and still draws what the device kept', async () => {
network(true);
vi.mocked(ferrataRepo.cachedTiles).mockResolvedValue([{ tile: '930:236', fetchedAt: 1, ferratas: [FERRATA] }]);
const { result } = renderHook(() => useTripFerratas(7, TILE, true));
await settle(1_000);
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('offline');
expect(result.current.ferratas).toEqual([FERRATA]);
});
it('FE-FERRATA-HOOK-005: a failed request reports an error and draws nothing', async () => {
vi.mocked(ferrataRepo.list).mockRejectedValue(Object.assign(new Error('502'), { response: { status: 502 } }));
const { result } = renderHook(() => useTripFerratas(7, BOX, true));
await settle();
expect(result.current.status).toBe('error');
expect(result.current.ferratas).toEqual([]);
});
it('FE-FERRATA-HOOK-006: an area wider than the server takes asks the user to zoom in, without a request', async () => {
const { result } = renderHook(() => useTripFerratas(7, { south: 46, west: 11, north: 47, east: 12 }, true));
await settle();
it('FE-FERRATA-HOOK-007: a view wider than the server takes reads as zoom in and asks for nothing', async () => {
const { result } = renderHook(() => useTripFerratas(7, WIDE, true));
await settle(1_000);
expect(ferrataRepo.list).not.toHaveBeenCalled();
expect(result.current.status).toBe('tooLarge');
expect(ferrataRepo.list).not.toHaveBeenCalled();
});
it('FE-FERRATA-HOOK-007: offline it says so and sends nothing', async () => {
network(true);
const { result } = renderHook(() => useTripFerratas(7, BOX, true));
it('FE-FERRATA-HOOK-008: reports the mirrors failing as an error and a refused box as zoom in', async () => {
vi.mocked(ferrataRepo.list).mockRejectedValue(new Error('502'));
const { result, rerender } = renderHook(({ view }) => useTripFerratas(7, view, true), {
initialProps: { view: TILE },
});
await settle();
expect(result.current.status).toBe('error');
expect(result.current.status).toBe('offline');
expect(ferrataRepo.list).not.toHaveBeenCalled();
vi.mocked(ferrataRepo.list).mockRejectedValue({ response: { status: 400 } });
rerender({ view: { south: 47, west: 11.8, north: 47.05, east: 11.85 } });
await settle();
expect(result.current.status).toBe('tooLarge');
});
it('FE-FERRATA-HOOK-008: switching the layer off aborts and clears, and a truncated answer is passed on', async () => {
vi.mocked(ferrataRepo.list).mockResolvedValue(reply([FERRATA], true));
const { result, rerender } = renderHook(({ on }) => useTripFerratas(7, BOX, on), { initialProps: { on: true } });
it('FE-FERRATA-HOOK-009: switched off and on again asks for no tile it already has', async () => {
const { result, rerender } = renderHook(({ on }) => useTripFerratas(7, TILE, on), {
initialProps: { on: true },
});
await settle();
expect(result.current.truncated).toBe(true);
const asked = params().length;
rerender({ on: false });
expect(result.current).toEqual({ ferratas: [], status: 'idle', truncated: false, attribution: null });
expect(renderHook(() => useTripFerratas(undefined, BOX, true)).result.current.status).toBe('idle');
expect(renderHook(() => useTripFerratas(7, null, true)).result.current.status).toBe('idle');
});
it('FE-FERRATA-HOOK-009: a 400 reads as "zoom in" and is not asked again', async () => {
vi.mocked(ferrataRepo.list).mockRejectedValue(Object.assign(new Error('400'), { response: { status: 400 } }));
const { result } = renderHook(() => useTripFerratas(7, BOX, true));
expect(result.current.status).toBe('idle');
rerender({ on: true });
await settle();
expect(result.current.status).toBe('tooLarge');
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(ferrataRepo.list).toHaveBeenCalledTimes(1);
});
it('FE-FERRATA-HOOK-010: an outage asks again for the same area after a backoff and recovers', async () => {
vi.mocked(ferrataRepo.list)
.mockRejectedValueOnce(Object.assign(new Error('502'), { response: { status: 502 } }))
.mockResolvedValueOnce(reply());
const { result } = renderHook(() => useTripFerratas(7, BOX, true));
await settle();
expect(result.current.status).toBe('error');
await act(async () => {
await vi.advanceTimersByTimeAsync(4_000);
});
expect(ferrataRepo.list).toHaveBeenCalledTimes(1);
expect(result.current.status).toBe('error');
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
});
await settle();
expect(ferrataRepo.list).toHaveBeenCalledTimes(2);
expect(vi.mocked(ferrataRepo.list).mock.calls[1]?.[1]).toBe('46.500,11.700,46.600,11.900');
expect(result.current.status).toBe('ready');
expect(params()).toHaveLength(asked);
expect(ferrataRepo.cachedTiles).toHaveBeenCalledTimes(1);
expect(result.current.ferratas).toEqual([FERRATA]);
expect(result.current.status).toBe('ready');
});
it("FE-FERRATA-HOOK-010: prefetches the trip's area in the background, but not one wider than the cap", async () => {
const area: FerrataBbox = { south: 46.0, west: 11.0, north: 46.1, east: 11.1 };
const first = renderHook(() => useTripFerratas(7, TILE, true, area));
await settle();
expect(params()).toContain('46.000,11.000,46.100,11.100');
first.unmount();
vi.mocked(ferrataRepo.list).mockClear();
// 2 by 2 degrees is 1600 tiles, past FERRATA_AREA_PREFETCH_MAX_TILES.
expect(FERRATA_AREA_PREFETCH_MAX_TILES).toBeLessThan(1600);
renderHook(() => useTripFerratas(8, null, true, { south: 44, west: 10, north: 46, east: 12 }));
await settle(1_000);
expect(ferrataRepo.list).not.toHaveBeenCalled();
});
});
+129 -103
View File
@@ -1,134 +1,160 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useSyncExternalStore } from 'react';
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
import {
ferrataTileCount,
ferrataTileKey,
ferrataTileRange,
ferrataTilesOf,
growFerrataTileRange,
type Ferrata,
type FerrataBbox,
type FerrataTileRange,
} from '@trek/shared';
import { useNetworkMode } from '../../hooks/useNetworkMode';
import { ferrataRepo } from '../../repo/ferrataRepo';
import { ferrataBboxFits, ferrataBboxParam, roundFerrataBbox } from './ferrataModel';
import { ferrataBboxFits, ferrataBboxParam } from './ferrataModel';
import { FerrataTileStore, type FerrataTileSnapshot } from './ferrataTileStore';
/**
* Where the layer stands. `tooLarge` and `offline` are answers to "why are
* there no lines" that need no request at all: the server refuses a box wider
* than its limit, and an offline device should not fire one.
* Where the layer stands for the view on screen. `tooLarge` and `offline` are
* answers to "why are there no new lines" that need no request at all: the
* server refuses a box wider than its limit, and an offline device should not
* fire one. Whatever was loaded before stays drawn in both.
*/
export type FerrataStatus = 'idle' | 'loading' | 'ready' | 'error' | 'tooLarge' | 'offline';
/** Long enough that a pan or a zoom gesture settles before the request goes out. */
const DEBOUNCE_MS = 400;
/** Long enough that a pan or a zoom gesture settles before the missing tiles are asked for. */
const DEBOUNCE_MS = 200;
/** The ring of tiles around the view that loads behind it, so a short pan finds its lines already there. */
const PREFETCH_RINGS = 1;
/**
* How long the layer waits before asking again for the same area after the
* mirrors failed it. Past the last step it stays on the error until the map
* moves or the layer is switched off and on.
* The trip's area is prefetched up to this many tiles (about 0.4 by 0.4
* degree, at most sixteen requests of 0.1 degree); a trip spread wider loads
* as the map gets there.
*/
const RETRY_DELAYS_MS = [5_000, 15_000, 45_000];
export const FERRATA_AREA_PREFETCH_MAX_TILES = 64;
const NO_FERRATAS: Ferrata[] = [];
const NO_TILES: readonly string[] = [];
/**
* What came back: the answer, a 400 (`refused`: the server would not take the
* box, which reads as "zoom in" rather than as an outage) or anything else
* (`unavailable`: the Overpass mirrors, worth another try).
*/
type Outcome = FerratasResponse | 'refused' | 'unavailable';
const EMPTY_SNAPSHOT: FerrataTileSnapshot = {
loaded: new Set(),
ferratas: [],
waiting: new Set(),
failure: null,
truncated: false,
hydrated: false,
};
const subscribeNothing = () => () => {};
const emptySnapshot = () => EMPTY_SNAPSHOT;
interface Answer {
key: string;
outcome: Outcome;
/** A range as a dependency-friendly string, and back. */
const rangeKeyOf = (range: FerrataTileRange) => `${range.minRow},${range.maxRow},${range.minCol},${range.maxCol}`;
function rangeOfKey(key: string): FerrataTileRange {
const [minRow = 0, maxRow = 0, minCol = 0, maxCol = 0] = key.split(',').map(Number);
return { minRow, maxRow, minCol, maxCol };
}
function failureOf(error: unknown): 'refused' | 'unavailable' {
const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
return status === 400 ? 'refused' : 'unavailable';
}
/** `current` is the outcome for the area on screen, or null while that one is still on its way. */
function statusOf(active: boolean, offline: boolean, fits: boolean, current: Outcome | null): FerrataStatus {
/** `missing`: some tile of the view is not loaded yet. */
function statusOf(
active: boolean,
offline: boolean,
fits: boolean,
missing: boolean,
snapshot: FerrataTileSnapshot
): FerrataStatus {
if (!active) return 'idle';
if (offline) return 'offline';
if (!fits || current === 'refused') return 'tooLarge';
if (!current) return 'loading';
return current === 'unavailable' ? 'error' : 'ready';
if (!fits || (missing && snapshot.failure === 'refused')) return 'tooLarge';
if (!missing) return 'ready';
return snapshot.failure === 'unavailable' ? 'error' : 'loading';
}
/** The area on screen as the request grid sees it, and the request it makes (null: none). */
function requestOf(tripId: number | null | undefined, bbox: FerrataBbox | null, enabled: boolean, offline: boolean) {
const area = bbox ? roundFerrataBbox(bbox) : null;
const fits = !!area && ferrataBboxFits(area);
const active = enabled && !!tripId && !!area;
const param = active && fits && !offline && area ? ferrataBboxParam(area) : null;
return { fits, active, param, key: param ? `${tripId}:${param}` : null };
}
/** The answer that speaks for this attempt; while a retry is in flight, the failure it follows keeps the error up. */
function answerFor(answer: Answer | null, key: string | null, attempt: number): Answer | null {
if (!answer || !key) return null;
if (answer.key === `${key}#${attempt}`) return answer;
return attempt > 0 && answer.key === `${key}#${attempt - 1}` ? answer : null;
}
function view(answer: Answer | null, status: FerrataStatus) {
// The last good answer stays up while the next area loads; any other state draws nothing.
const last = answer && typeof answer.outcome === 'object' ? answer.outcome : null;
const shown = status === 'ready' || status === 'loading' ? last : null;
return {
ferratas: shown?.ferratas ?? NO_FERRATAS,
status,
truncated: shown?.truncated ?? false,
attribution: shown?.attribution ?? null,
};
/** One tile store per trip; it lives as long as the map surface does, so 2D, 3D and a toggle share it. */
function useTileStore(tripId: number | null | undefined): FerrataTileStore | null {
return useMemo(() => {
if (!tripId) return null;
const cacheName = ferrataRepo.cacheName();
return new FerrataTileStore({
fetch: (bbox, signal) => ferrataRepo.list(tripId, ferrataBboxParam(bbox), signal),
persist: (rows) => ferrataRepo.saveTiles(tripId, rows, cacheName),
});
}, [tripId]);
}
/**
* The via ferratas inside the visible map area of a trip, for the map layer.
* The via ferratas of a trip, for the map layer, loaded per tile of the shared
* 0.05 degree grid (see `FerrataTileStore`).
*
* Online only (see repo/ferrataRepo.ts). Nothing is requested while the layer is
* off. A new area waits DEBOUNCE_MS before it is asked for, and a newer area,
* a closed layer or an unmount aborts the request in flight, so a fast pan
* across the Alps ends in one request for where the map stopped. While the
* next area loads, the lines of the last one stay drawn instead of blinking out.
* When the mirrors fail an area, it is asked for again after each of
* RETRY_DELAYS_MS; a 400 is not retried, it reads as "zoom in".
* Nothing is read or requested while the layer is off. Switched on, the trip's
* cached tiles come back from the device first and draw at once, offline too;
* then the view's missing tiles are asked for DEBOUNCE_MS after the map
* settles, the ring around them behind that, and `area` (the trip's own
* extent) in the background once, after the same pause, so the view's tiles
* are always asked for first. Every ferrata loaded stays drawn: a pan, a
* zoom, a switch between 2D and 3D or the layer off and on again never drops
* one, and never asks again for a tile it has.
*/
export function useTripFerratas(tripId: number | null | undefined, bbox: FerrataBbox | null, enabled: boolean) {
export function useTripFerratas(
tripId: number | null | undefined,
view: FerrataBbox | null,
enabled: boolean,
area: FerrataBbox | null = null
): { ferratas: readonly Ferrata[]; status: FerrataStatus; truncated: boolean } {
const { offline } = useNetworkMode();
const { fits, active, param, key } = requestOf(tripId, bbox, enabled, offline);
const [answer, setAnswer] = useState<Answer | null>(null);
// Retries count per area: a new area starts again at zero without a reset.
const [retry, setRetry] = useState<{ key: string | null; attempt: number }>({ key: null, attempt: 0 });
const attempt = retry.key === key ? retry.attempt : 0;
const requestKey = key ? `${key}#${attempt}` : null;
const store = useTileStore(tripId);
const snapshot = useSyncExternalStore(
store ? store.subscribe : subscribeNothing,
store ? store.getSnapshot : emptySnapshot
);
const active = enabled && !!store;
const fits = !!view && ferrataBboxFits(view);
const viewKey = view && fits ? rangeKeyOf(ferrataTileRange(view)) : null;
const areaRange = area && ferrataBboxFitsArea(area) ? ferrataTileRange(area) : null;
const areaKey =
areaRange && ferrataTileCount(areaRange) <= FERRATA_AREA_PREFETCH_MAX_TILES ? rangeKeyOf(areaRange) : null;
const visibleKeys = useMemo(
() => (viewKey ? ferrataTilesOf(rangeOfKey(viewKey)).map(ferrataTileKey) : NO_TILES),
[viewKey]
);
useEffect(() => {
if (!tripId || !param || !requestKey) return;
const areaKey = `${tripId}:${param}`;
const controller = new AbortController();
let retryTimer: number | undefined;
const timer = window.setTimeout(() => {
ferrataRepo.list(tripId, param, controller.signal).then(
(reply) => {
if (!controller.signal.aborted) setAnswer({ key: requestKey, outcome: reply });
},
(error: unknown) => {
if (controller.signal.aborted) return;
// The pill reports it ("Overpass unreachable" or "zoom in"); the error
// itself carries nothing more the user could act on.
const failure = failureOf(error);
setAnswer({ key: requestKey, outcome: failure });
const delay = RETRY_DELAYS_MS[attempt];
if (failure === 'unavailable' && delay !== undefined) {
retryTimer = window.setTimeout(() => setRetry({ key: areaKey, attempt: attempt + 1 }), delay);
}
}
if (!active || !store || !tripId) return;
if (store.beginHydration()) {
void ferrataRepo.cachedTiles(tripId).then(
(rows) => store.hydrate(rows),
// An unreadable cache is an empty one: the tiles load from the network instead.
() => store.hydrate([])
);
}, DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
window.clearTimeout(retryTimer);
controller.abort();
};
}, [tripId, param, requestKey, attempt]);
}
return () => store.stop();
}, [active, store, tripId]);
const status = statusOf(active, offline, fits, answerFor(answer, key, attempt)?.outcome ?? null);
return view(answer, status);
useEffect(() => {
if (!active || !store || offline || !viewKey) return;
const timer = window.setTimeout(() => {
const range = rangeOfKey(viewKey);
store.want(ferrataTilesOf(range), 'visible');
store.want(ferrataTilesOf(growFerrataTileRange(range, PREFETCH_RINGS)), 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, store, offline, viewKey]);
// Declared after the view's effect, so with both due its timer fires second.
useEffect(() => {
if (!active || !store || offline || !areaKey) return;
const timer = window.setTimeout(() => {
store.want(ferrataTilesOf(rangeOfKey(areaKey)), 'background');
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [active, store, offline, areaKey]);
const missing = visibleKeys.some((key) => !snapshot.loaded.has(key));
const status = statusOf(active, offline, fits, missing, snapshot);
return { ferratas: snapshot.ferratas, status, truncated: active && snapshot.truncated };
}
/** A trip's area is any real box; only its tile count limits the prefetch. */
function ferrataBboxFitsArea(bbox: FerrataBbox): boolean {
return bbox.north >= bbox.south && bbox.east >= bbox.west;
}
@@ -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')
})
})
+94 -40
View File
@@ -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 });
});
});
+4 -3
View File
@@ -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;
/**
+1 -1
View File
@@ -100,7 +100,7 @@ export function TimePill({ children, className = '', quiet = false }: { children
return <WhiteBadge className={className} quiet={quiet} icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>{children}</WhiteBadge>
}
export type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
type PillTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
// White badges on whatever tint the row has; the tone only colours the words and the icon.
const PILL_TONES: Record<PillTone, string> = {
@@ -1,6 +1,6 @@
import type { FerrataBbox } from '@trek/shared';
import { ChevronDown, ChevronUp, Gauge, Palette, Pause, Play, RotateCcw, Scan, TrendingUp } from 'lucide-react';
import { useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { DistanceUnit } from '../../../types';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
@@ -12,6 +12,7 @@ import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { GRADE_BANDS, terrainStats, type GradeBandKey, type TerrainStats } from './tourTerrainModel';
import { useControlRowFit } from './useControlRowFit';
import type { TerrainWaypoints } from './useTerrainPins';
import { useTourTerrain } from './useTourTerrain';
@@ -34,6 +35,8 @@ export interface TourTerrainMapProps {
ferrataLayer?: FerrataMapLayer;
/** The visible area, for the via ferrata layer to ask for. */
onViewportChange?: (bbox: FerrataBbox) => void;
/** How high the controls along the bottom edge reach, every row of them, for what floats above them. */
onControlsHeightChange?: (px: number) => void;
}
const SPEEDS = [1, 2, 4] as const;
@@ -53,47 +56,61 @@ const GLASS = {
*/
export default function TourTerrainMap(props: TourTerrainMapProps) {
const { samples, leftWidth, rightWidth, baseLayer, onBaseLayerChange, onExit, unit = 'metric' } = props;
const { onControlsHeightChange } = props;
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
const stats = useMemo(() => terrainStats(samples), [samples]);
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} /> : null;
const legendAbove = fit === 'stacked';
useEffect(() => {
if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight);
}, [controlsHeight, onControlsHeightChange]);
return (
<div className="absolute inset-0">
{/* MapLibre makes its container position: relative, so it is sized, not inset. */}
<div ref={setContainer} className="h-full w-full bg-surface" />
{/* One row along the bottom edge, between the panels: the flat map's layer pill at
the same spot with 3D switched on (and the via ferrata status beside it), then the
flyover controls and the grade legend. The pill takes the width it needs, so a
longer pill moves the dock along instead of running under it. */}
{/* One row along the bottom edge, between the panels and the same inset from both:
the flat map's layer pill at the same spot with 3D switched on (the via ferrata
count rides inside its switch), then the flyover controls and the grade legend.
Where the row does not fit, the pill goes to its icons first, then the legend
moves onto a row of its own above, so nothing runs under a panel. */}
<div
className="pointer-events-none absolute bottom-3 z-[19] flex items-end gap-3"
ref={attachStack}
className="pointer-events-none absolute bottom-3 z-[19] flex flex-col items-end gap-3"
style={{
insetInlineStart: leftWidth + MAP_LAYER_SWITCHER_INSET,
insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET,
}}
>
<div className="flex-none">
<TourMapLayerSwitcher
active={baseLayer}
onChange={onBaseLayerChange}
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
/>
</div>
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
<span />
{terrain.canReplay ? (
<TerrainDock terrain={terrain} />
) : (
<div
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
style={{ ...GLASS, ...fs(12, 'body') }}
>
<PickHint editing={!!props.waypoints.onAdd} />
</div>
)}
<div className="flex justify-end">{terrain.canReplay && <GradeLegend stats={stats} unit={unit} />}</div>
{legendAbove && legend}
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
<div className="flex-none">
<TourMapLayerSwitcher
active={baseLayer}
onChange={onBaseLayerChange}
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
compact={fit !== 'full'}
/>
</div>
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
<span />
{terrain.canReplay ? (
<TerrainDock terrain={terrain} />
) : (
<div
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
style={{ ...GLASS, ...fs(12, 'body') }}
>
<PickHint editing={!!props.waypoints.onAdd} />
</div>
)}
<div className="flex justify-end">{!legendAbove && legend}</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,52 @@
// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-002: the 3D map's bottom row steps down to fit between the panels.
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { type ControlRowFit, useControlRowFit } from './useControlRowFit';
/** What the row's content needs at each step, in pixels. */
const NEEDS: Record<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400 };
function fakeRow(room: { width: number }, fit: () => ControlRowFit): HTMLDivElement {
const row = document.createElement('div');
Object.defineProperty(row, 'clientWidth', { get: () => room.width });
Object.defineProperty(row, 'scrollWidth', { get: () => Math.max(room.width, NEEDS[fit()]) });
return row;
}
describe('useControlRowFit', () => {
it('FE-TERRAIN-FIT-001: keeps every label while the row fits, and steps down only as far as it must', () => {
const room = { width: 800 };
const { result, rerender } = renderHook(() => useControlRowFit());
const row = fakeRow(room, () => result.current.fit);
act(() => result.current.attachRow(row));
expect(result.current.fit).toBe('full');
room.width = 500;
rerender();
expect(result.current.fit).toBe('compact');
room.width = 450;
rerender();
expect(result.current.fit).toBe('stacked');
});
it('FE-TERRAIN-FIT-002: steps back up only once the room holds what the larger step needed', () => {
const room = { width: 450 };
const { result, rerender } = renderHook(() => useControlRowFit());
const row = fakeRow(room, () => result.current.fit);
act(() => result.current.attachRow(row));
rerender();
expect(result.current.fit).toBe('stacked');
room.width = 600;
rerender();
expect(result.current.fit).toBe('compact');
room.width = 699;
rerender();
expect(result.current.fit).toBe('compact');
room.width = 700;
rerender();
expect(result.current.fit).toBe('full');
});
});
@@ -0,0 +1,68 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
/**
* How the 3D map's bottom row fits between the side panels: `full` with every
* label, `compact` with the layer pill on its icons, and `stacked` with the
* grade legend on a row of its own above the dock as well.
*/
export type ControlRowFit = 'full' | 'compact' | 'stacked';
const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked'];
/**
* Steps the row down while its content is wider than the room between the
* panels, and back up once the room has grown to what the larger step
* needed, so it never flips back and forth at one width. `height` is the
* height the controls take from the bottom edge, every row of them, for what
* floats above them (the via ferrata card).
*
* `row` is the one row that must not overflow; `stack` wraps everything the
* fit shows, the legend's own row included.
*/
export function useControlRowFit() {
const [row, setRow] = useState<HTMLDivElement | null>(null);
const [stack, setStack] = useState<HTMLDivElement | null>(null);
const [fit, setFit] = useState<ControlRowFit>('full');
const [height, setHeight] = useState<number | null>(null);
// The width the content needed at each step when it had to step down from it.
const needed = useRef<Partial<Record<ControlRowFit, number>>>({});
const check = useCallback(() => {
if (!row) return;
setFit((current) => {
const index = STEPS.indexOf(current);
const room = row.clientWidth;
const larger = STEPS[index - 1];
const neededLarger = larger ? needed.current[larger] : undefined;
if (larger && neededLarger !== undefined && room >= neededLarger) return larger;
const smaller = STEPS[index + 1];
if (smaller && row.scrollWidth > room + 1) {
needed.current[current] = row.scrollWidth;
return smaller;
}
return current;
});
}, [row]);
useLayoutEffect(() => {
check();
});
useLayoutEffect(() => {
if (!row || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => check());
observer.observe(row);
return () => observer.disconnect();
}, [row, check]);
useLayoutEffect(() => {
if (!stack || typeof ResizeObserver === 'undefined') return;
const measure = () => setHeight(stack.offsetHeight || null);
measure();
const observer = new ResizeObserver(measure);
observer.observe(stack);
return () => observer.disconnect();
}, [stack]);
return { fit, height, attachRow: setRow, attachStack: setStack };
}
@@ -39,5 +39,6 @@ export function useTerrainFerratas(
};
}, [gl, onViewportChange]);
useFerrataMapLayerGL(gl ? (gl.map as unknown as FerrataGlMap) : null, layer);
// Wider lines than the flat map's: seen at a slant over the hillshade, the thin ones get lost.
useFerrataMapLayerGL(gl ? (gl.map as unknown as FerrataGlMap) : null, layer, 'terrain');
}
+22 -1
View File
@@ -1,4 +1,4 @@
import type { RoadtripPreferences, TourListItem, TourWaypoint } from '@trek/shared';
import type { Ferrata, RoadtripPreferences, TourListItem, TourWaypoint } from '@trek/shared';
import Dexie, { type Table } from 'dexie';
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
@@ -28,6 +28,20 @@ export interface CachedBudgetSettlement {
settlement: unknown;
}
/**
* One 0.05 degree tile of the via ferrata layer (see `ferrataGrid` in
* `@trek/shared`) as last loaded for a trip: the ferratas that reach into it,
* possibly none. Read straight from OpenStreetMap, not trip data; kept so the
* layer draws at once and offline, and refreshed after a week.
*/
interface CachedFerrataTile {
tripId: number;
/** The tile's key, `<row>:<col>`. */
tile: string;
fetchedAt: number;
ferratas: Ferrata[];
}
// ── Queue + sync types ────────────────────────────────────────────────────────
// 'conflict' is terminal-until-resolved: the server rejected the replay because
@@ -234,6 +248,7 @@ export class TrekOfflineDb extends Dexie {
areaPlaces!: Table<CachedAreaPlace, [string, number]>;
tours!: Table<CachedTour, number>;
budgetSettlements!: Table<CachedBudgetSettlement, number>;
ferrataTiles!: Table<CachedFerrataTile, [number, string]>;
constructor(name: string = ANON_DB_NAME) {
super(name);
@@ -308,6 +323,10 @@ export class TrekOfflineDb extends Dexie {
// v10: the Costs settlement, one per trip, so the balances read offline.
this.version(10).stores({ budgetSettlements: 'tripId' });
// v11: the via ferrata layer's loaded tiles, per trip, so the lines draw at
// once and offline; `tripId` so they drop with the trip.
this.version(11).stores({ ferrataTiles: '[tripId+tile], tripId, fetchedAt' });
}
}
@@ -590,6 +609,7 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.roadtripPreferences,
offlineDb.tours,
offlineDb.budgetSettlements,
offlineDb.ferrataTiles,
],
async () => {
await offlineDb.roadtripPreferences.delete(tripId);
@@ -611,6 +631,7 @@ export async function clearTripData(tripId: number): Promise<void> {
// answering offline searches — and nothing else ever deleted them, so they
// accumulated for the life of the install.
await offlineDb.areaPlaces.where('tripId').equals(tripId).delete();
await offlineDb.ferrataTiles.where('tripId').equals(tripId).delete();
},
);
// Remove the trip row itself outside the transaction since it's a separate table
+5
View File
@@ -1199,6 +1199,11 @@ body {
.leaflet-tooltip-top.map-tooltip::before {
display: none;
}
/* A clicked via ferrata opens a card with its own focus; the path's focus ring would only linger on the map. */
.leaflet-interactive.trek-ferrata-hit:focus,
.leaflet-interactive.trek-ferrata-hit:focus-visible {
outline: none;
}
/* Scrollbars — styled on desktop, hidden on mobile */
::-webkit-scrollbar {
@@ -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%' }}
/>
+68 -2
View File
@@ -1,9 +1,13 @@
// FE-REPO-FERRATA-001 to FE-REPO-FERRATA-003: the online-only read behind the via ferrata layer.
// FE-REPO-FERRATA-001 to FE-REPO-FERRATA-007: the network read behind the via ferrata layer and its tiles kept on the device.
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Ferrata } from '@trek/shared';
import { ferratasApi } from '../api/ferratas';
import { clearAll, clearTripData, offlineDb } from '../db/offlineDb';
import { isEffectivelyOffline } from '../sync/networkMode';
import { ferrataRepo } from './ferrataRepo';
import { FERRATA_TILE_FRESH_MS, ferrataRepo } from './ferrataRepo';
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
vi.mock('../api/ferratas', () => ({ ferratasApi: { list: vi.fn() } }));
@@ -57,3 +61,65 @@ describe('ferrataRepo.list', () => {
await expect(ferrataRepo.list(7, BBOX, new AbortController().signal)).rejects.toThrow();
});
});
describe('ferrataRepo tiles', () => {
const NOW = 10 * FERRATA_TILE_FRESH_MS;
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',
};
beforeEach(async () => {
await clearAll();
});
it('FE-REPO-FERRATA-004: keeps tiles per trip and hands back the fresh ones', async () => {
await ferrataRepo.saveTiles(7, [
{ tile: '930:236', fetchedAt: NOW - 1_000, ferratas: [FERRATA] },
{ tile: '930:237', fetchedAt: NOW - 2_000, ferratas: [] },
]);
await ferrataRepo.saveTiles(8, [{ tile: '930:236', fetchedAt: NOW, ferratas: [] }]);
const rows = await ferrataRepo.cachedTiles(7, NOW);
expect(rows.map((row) => row.tile).sort()).toEqual(['930:236', '930:237']);
expect(rows.find((row) => row.tile === '930:236')?.ferratas).toEqual([FERRATA]);
expect(await ferrataRepo.cachedTiles(8, NOW)).toHaveLength(1);
});
it('FE-REPO-FERRATA-005: a tile older than a week is dropped instead of drawn', async () => {
await ferrataRepo.saveTiles(7, [
{ tile: '930:236', fetchedAt: NOW - FERRATA_TILE_FRESH_MS - 1, ferratas: [FERRATA] },
{ tile: '930:237', fetchedAt: NOW - FERRATA_TILE_FRESH_MS + 1, ferratas: [] },
]);
expect((await ferrataRepo.cachedTiles(7, NOW)).map((row) => row.tile)).toEqual(['930:237']);
expect(await offlineDb.ferrataTiles.get([7, '930:236'])).toBeUndefined();
});
it('FE-REPO-FERRATA-006: a cached ferrata off the contract, or a row under a bad key, is left out', async () => {
await offlineDb.ferrataTiles.bulkPut([
{ tripId: 7, tile: '930:236', fetchedAt: NOW, ferratas: [FERRATA, { ...FERRATA, id: 'node/1' }] },
{ tripId: 7, tile: 'nonsense', fetchedAt: NOW, ferratas: [] },
]);
const rows = await ferrataRepo.cachedTiles(7, NOW);
expect(rows).toHaveLength(1);
expect(rows[0]?.ferratas).toEqual([FERRATA]);
});
it('FE-REPO-FERRATA-007: skips a save for another account, and the tiles go with their trip', async () => {
await ferrataRepo.saveTiles(7, [{ tile: '930:236', fetchedAt: NOW, ferratas: [] }], 'trek-offline-u999');
expect(await offlineDb.ferrataTiles.count()).toBe(0);
await ferrataRepo.saveTiles(7, [{ tile: '930:236', fetchedAt: NOW, ferratas: [] }], ferrataRepo.cacheName());
expect(await offlineDb.ferrataTiles.count()).toBe(1);
await clearTripData(7);
expect(await offlineDb.ferrataTiles.count()).toBe(0);
});
});
+70 -9
View File
@@ -1,24 +1,85 @@
import { type FerratasResponse, ferratasResponseSchema } from '@trek/shared';
import {
type Ferrata,
type FerratasResponse,
ferrataSchema,
ferratasResponseSchema,
parseFerrataTileKey,
} from '@trek/shared';
import { ferratasApi } from '../api/ferratas';
import { offlineDb } from '../db/offlineDb';
import { isEffectivelyOffline } from '../sync/networkMode';
/** How long a loaded tile stays good on this device; past it the tile is asked for again. */
export const FERRATA_TILE_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
/** One loaded tile of the layer: its key, when it was loaded and the ferratas that reach into it. */
export interface FerrataTileRow {
tile: string;
fetchedAt: number;
ferratas: Ferrata[];
}
/** The cached ferratas that still match the contract; a row an older bundle wrote off it is left out, not drawn. */
function validFerratas(rows: unknown[]): Ferrata[] {
return rows.flatMap((row) => {
const parsed = ferrataSchema.safeParse(row);
return parsed.success ? [parsed.data] : [];
});
}
/**
* Via ferratas from OpenStreetMap, for the map layer.
*
* **Deliberately online-only, and no Dexie table.** The answer is a live read
* of OpenStreetMap through Overpass for whatever area the map shows; a cached
* copy would be stale data for an area the user has long panned away from, and
* the server keeps nothing either. Same shape and reason as
* `roadtripHazardsRepo` and `pluginPoiRepo`.
* Read through the network per tile of the shared 0.05 degree grid, and kept
* on the device per trip in `ferrataTiles` (Dexie), so a trip's lines draw at
* once on the next visit and offline. The copy is read-only map data, not trip
* data, so there is no write queue: a tile is good for FERRATA_TILE_FRESH_MS
* and then loaded again.
*
* Offline (by circumstance or by the manual switch) the request is not made at
* all. The answer is parsed against the shared contract rather than cast,
* because its names and grades go on to be drawn on the map.
* Offline (by circumstance or by the manual switch) no request is made at all.
* Every answer is parsed against the shared contract rather than cast, because
* its names and grades go on to be drawn on the map.
*/
export const ferrataRepo = {
async list(tripId: number, bbox: string, signal: AbortSignal): Promise<FerratasResponse> {
if (isEffectivelyOffline()) throw new Error('Via ferratas need a connection');
return ferratasResponseSchema.parse(await ferratasApi.list(tripId, bbox, signal));
},
/** The trip's tiles loaded within the last FERRATA_TILE_FRESH_MS; older ones are dropped on the way. */
async cachedTiles(tripId: number, now: number = Date.now()): Promise<FerrataTileRow[]> {
const rows = await offlineDb.ferrataTiles.where('tripId').equals(tripId).toArray();
const fresh: FerrataTileRow[] = [];
const stale: Array<[number, string]> = [];
for (const row of rows) {
const current = now - row.fetchedAt < FERRATA_TILE_FRESH_MS && parseFerrataTileKey(row.tile) !== null;
if (!current) stale.push([tripId, row.tile]);
else
fresh.push({
tile: row.tile,
fetchedAt: row.fetchedAt,
ferratas: validFerratas(Array.isArray(row.ferratas) ? row.ferratas : []),
});
}
if (stale.length) await offlineDb.ferrataTiles.bulkDelete(stale);
return fresh;
},
/** Which account's database the cache is in now, to hand back to `saveTiles` once an answer arrives. */
cacheName(): string {
return offlineDb.name;
},
/**
* Keeps loaded tiles for the trip. Skipped when the account changed since
* the answer was asked for, so one user's map data never lands in another's
* database.
*/
async saveTiles(tripId: number, rows: readonly FerrataTileRow[], cacheName: string = offlineDb.name): Promise<void> {
if (rows.length === 0 || offlineDb.name !== cacheName) return;
await offlineDb.ferrataTiles.bulkPut(
rows.map((row) => ({ tripId, tile: row.tile, fetchedAt: row.fetchedAt, ferratas: row.ferratas }))
);
},
};
@@ -53,6 +53,7 @@ const HISTORY: Record<number, StoreSpec> = {
8: { areaPlaces: '[gers+tripId], gers, tripId, searchName' },
9: { tours: 'place_id, trip_id' },
10: { budgetSettlements: 'tripId' },
11: { ferrataTiles: '[tripId+tile], tripId, fetchedAt' },
};
const HISTORY_VERSIONS = Object.keys(HISTORY)
@@ -130,6 +131,7 @@ function rowsAt(version: number): Record<string, Record<string, unknown>> {
roadtripPreferences: { tripId: 1, preferences: { avoid_tolls: true } },
tours: { place_id: 21, trip_id: 1, name: 'Old town loop', distance: 4200 },
budgetSettlements: { tripId: 1, base: 'EUR', settlement: { balances: [], flows: [] } },
ferrataTiles: { tripId: 1, tile: '930:236', fetchedAt: 11, ferratas: [] },
};
const tables = schemaAt(version);
return Object.fromEntries(Object.entries(all).filter(([table]) => table in tables));
@@ -291,6 +293,21 @@ const UPGRADE_CASES: Record<number, UpgradeCase> = {
expect(await db.budgetSettlements.get(3)).toMatchObject({ base: 'USD' });
},
},
11: {
intent: 'adds the via ferrata tiles per trip and keeps the Costs settlement',
check: async (db) => {
expect(await db.budgetSettlements.get(1)).toMatchObject({ base: 'EUR' });
await db.table('ferrataTiles').bulkPut([
{ tripId: 3, tile: '930:236', fetchedAt: 5, ferratas: [] },
{ tripId: 3, tile: '930:237', fetchedAt: 6, ferratas: [] },
// The same tile for another trip is its own row.
{ tripId: 4, tile: '930:236', fetchedAt: 7, ferratas: [] },
]);
expect(await db.ferrataTiles.where('tripId').equals(3).count()).toBe(2);
expect(await db.ferrataTiles.get([4, '930:236'])).toMatchObject({ fetchedAt: 7 });
expect(await db.ferrataTiles.where('fetchedAt').below(6).count()).toBe(1);
},
},
};
describe('offlineDb upgrades', () => {