mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +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,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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user