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