Files
TREK/client/src/components/Map/mapRasterOverlay.ts
T
Maurice 73237ffedb feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)
The tour type is a dropdown; a bike tour picks its profile, routes for it,
says when a point lies off every way, and shows what it runs on: surfaces,
kinds of way and, for a mountain bike, the trail grades, each pointed at on
the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour)
is offered while a tour of that type is open, with an admin switch each.

From the planned cards: a hike avoids steep climbs, steps or ferries, nothing
else; a saved tour can be shared where the platform takes GPX files; and the
tours rail adds a tour from one menu: plan new, import GPX, or take one over
from another trip.
2026-10-19 21:33:49 +02:00

159 lines
6.1 KiB
TypeScript

import type { Map } from 'mapbox-gl';
import { useEffect } from 'react';
import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL';
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers';
import type { BaseMapHealth, TileHealth } from './tourRasterBases';
/**
* A transparent raster layer laid over the basemap and under the route, such
* as OpenSeaMap's seamarks over a paddle tour. Only the tiles in view are
* fetched while it is on: nothing is prefetched or stored for offline use, and
* the browser's own cache follows the answer's headers.
*/
export interface MapRasterOverlay {
id: string;
url: string;
attribution: string;
minZoom: number;
maxZoom: number;
active: boolean;
/** A tile failed to load; the layer's owner decides when that means the source is down. */
onTileError: () => void;
/** A tile arrived; the source answers again. */
onTileLoad: () => void;
}
type RasterOverlayStatus = 'off' | 'on' | 'down';
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
export interface ToggleableRasterOverlay extends MapRasterOverlay {
status: RasterOverlayStatus;
onToggle: () => void;
}
/** One map type for both engines, as the ferrata layer does: MapLibre answers every call made here. */
export type RasterOverlayGlMap = Map;
/**
* The layer id. It carries TREK's own prefix, so a basemap raster added later
* (satellite, topo) is put under it, and it goes in under the first other own
* layer, so the route and its markers stay on top.
*/
function overlayLayerId(id: string): string {
return `trek-overlay-${id}`;
}
function applyOverlay(map: RasterOverlayGlMap, overlay: MapRasterOverlay): void {
const layerId = overlayLayerId(overlay.id);
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', overlay.active ? 'visible' : 'none');
return;
}
if (!overlay.active) return;
if (!map.getSource(layerId)) {
map.addSource(layerId, {
type: 'raster',
tiles: [overlay.url],
tileSize: 256,
minzoom: overlay.minZoom,
maxzoom: overlay.maxZoom,
attribution: overlay.attribution,
});
}
const firstOwn = (map.getStyle()?.layers ?? []).find(
(layer) =>
OWN_LAYER_PREFIXES.some((prefix) => layer.id.startsWith(prefix)) && !layer.id.startsWith('trek-overlay-')
);
map.addLayer({ id: layerId, type: 'raster', source: layerId, paint: { 'raster-opacity': 1 } }, firstOwn?.id);
} catch {
// A style that refuses the layer keeps the plain map; the switch reports nothing loaded.
}
}
/**
* The overlays on a GL map, kept through a basemap change (which rebuilds the
* style and drops them) and reporting their tiles' arrivals and failures.
*/
export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: readonly MapRasterOverlay[] | undefined) {
useEffect(() => {
if (!map || !overlays?.length) return;
const apply = () => overlays.forEach((overlay) => applyOverlay(map, overlay));
const sourceOf = (event: { sourceId?: string }) =>
overlays.find((overlay) => overlayLayerId(overlay.id) === event.sourceId);
// A tile's failure carries the source it came from; other errors carry none and are not ours.
const failed = (event: { error?: Error; sourceId?: string }) => {
const overlay = sourceOf(event);
if (overlay?.active) overlay.onTileError();
};
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
const overlay = sourceOf(event);
if (overlay?.active && event.tile) overlay.onTileLoad();
};
apply();
map.on('styledata', apply);
map.on('error', failed);
map.on('sourcedata', loaded);
return () => {
map.off('styledata', apply);
map.off('error', failed);
map.off('sourcedata', loaded);
};
}, [map, overlays]);
}
/**
* Reports the tiles of one GL source into its health while `health` is given:
* a failure carries the source it came from, an arrival a tile. The base layer
* itself is drawn by applyBaseLayer.
*/
export function useSourceTileHealthGL(
map: RasterOverlayGlMap | null,
sourceId: string,
health: TileHealth | undefined
) {
const { onTileError, onTileLoad } = health ?? {};
useEffect(() => {
if (!map || !onTileError || !onTileLoad) return;
const failed = (event: { error?: Error; sourceId?: string }) => {
if (event.sourceId === sourceId) onTileError();
};
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
if (event.sourceId === sourceId && event.tile) onTileLoad();
};
map.on('error', failed);
map.on('sourcedata', loaded);
return () => {
map.off('error', failed);
map.off('sourcedata', loaded);
};
}, [map, sourceId, onTileError, onTileLoad]);
}
/**
* The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers,
* the paddle obstacles, and whether the map on terms (OpenHikingMap, CyclOSM) answers while it is the base map.
*/
export function useTourMapLayersGL(
map: RasterOverlayGlMap | null,
ferrataLayer: FerrataMapLayer | undefined,
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
) {
useFerrataMapLayerGL(map, ferrataLayer);
useRasterOverlaysGL(map, layers?.overlays);
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
}
/**
* Whether a click at `point` landed on an object of the tour layers, a via
* ferrata or a paddle obstacle. The maps ask this before their own click
* handler, so picking one opens its card instead of also setting a waypoint.
*/
export function isTourLayerHit<Point>(map: Parameters<typeof isFerrataHit<Point>>[0], point: Point): boolean {
return isFerrataHit(map, point) || isPaddleHit(map, point);
}