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,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');
}