feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge

This commit is contained in:
Maurice
2026-10-14 16:21:30 +02:00
parent a9882a6415
commit 160bc89e78
126 changed files with 5003 additions and 1153 deletions
@@ -1,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
);
}