mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +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,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