feat(tours): tidy the tour editor and tour list, set points only on purpose, name waypoints on the map

This commit is contained in:
Maurice
2026-10-14 17:13:24 +02:00
parent 160bc89e78
commit e3983ef857
72 changed files with 2607 additions and 869 deletions
-5
View File
@@ -139,11 +139,6 @@ export const DYNAMIC_ALLOWED = [
'memories.* key in en',
}))
),
{
file: 'components/Tours/planner/TourPlannerPanels.tsx',
template: 'tours.planner.${role}',
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
},
];
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
+1 -1
View File
@@ -89,7 +89,7 @@
"src/components/Todo/todoListModel.ts": 1,
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
"src/components/Tours/planner/useTourPlanner.ts": 2,
"src/components/Tours/tourParts.tsx": 11,
"src/components/Tours/tourParts.tsx": 8,
"src/components/Trips/TripFormModal.tsx": 3,
"src/components/Trips/TripMembersModal.tsx": 1,
"src/components/Vacay/VacayCalendar.tsx": 1,
@@ -1,70 +0,0 @@
/**
* The name and grade of the via ferrata under the pointer on a GL map, the
* twin of the Leaflet layer's sticky tooltip: the same words, the look of the
* shared Tooltip (card surface, faint edge, caption tier), floating just above
* the pointer.
*
* Built from the DOM rather than React, because it follows every mouse move:
* a React state per move would render the whole map view each time. The words
* go in as text, never as markup. It lives in the map's own container, so it
* scrolls, hides and goes away with the map.
*/
/** How far above the pointer the label floats, in CSS pixels. */
const POINTER_GAP = 12;
const LABEL_CLASS =
'pointer-events-none absolute z-20 whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
interface LabelHost {
getContainer?(): HTMLElement;
}
interface FerrataHoverLabel {
show(text: { name: string; grade: string | null }, point: { x: number; y: number }): void;
hide(): void;
remove(): void;
}
const NO_LABEL: FerrataHoverLabel = { show: () => undefined, hide: () => undefined, remove: () => undefined };
export function ferrataHoverLabel(map: LabelHost): FerrataHoverLabel {
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
const container = map.getContainer();
let element: HTMLDivElement | null = null;
const ensure = () => {
if (element) return element;
element = document.createElement('div');
element.className = LABEL_CLASS;
element.setAttribute('role', 'tooltip');
element.dataset.testid = 'ferrata-hover-label';
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
element.style.inset = '0px auto auto 0px';
container.appendChild(element);
return element;
};
return {
show({ name, grade }, { x, y }) {
const label = ensure();
label.replaceChildren(document.createTextNode(name));
if (grade) {
const badge = document.createElement('span');
badge.className = 'ms-1.5 font-semibold';
badge.textContent = grade;
label.appendChild(badge);
}
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
label.hidden = false;
},
hide() {
if (element) element.hidden = true;
},
remove() {
element?.remove();
element = null;
},
};
}
@@ -4,8 +4,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { Ferrata } from '@trek/shared';
import { useTranslation } from '../../i18n';
import { mapHoverLabel } from '../Map/mapHoverLabel';
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';
@@ -183,7 +183,7 @@ export function useFerrataLayerGL(
if (!map) return;
let waiting = false;
let disposed = false;
const hoverLabel = ferrataHoverLabel(map);
const hoverLabel = mapHoverLabel(map, 'ferrata-hover-label');
const draw = () => {
if (map.getSource(SOURCE)) return;
if (!map.isStyleLoaded()) {
@@ -222,8 +222,10 @@ export function useFerrataLayerGL(
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();
if (group) {
const label = ferrataLabel(group, latest.current.t);
hoverLabel.show({ name: label.name, detail: label.grade }, event.point);
} else hoverLabel.hide();
};
const enter = () => {
map.getCanvas().style.cursor = 'pointer';
@@ -1,4 +1,4 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-016
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -179,4 +179,27 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
expect(buttons.slice(-2)).toEqual(['Via ferratas', '3D'])
})
it('FE-COMP-MAPLAYER-017: the "set points" switch leads the tools on its icon, pressed in the accent while on', async () => {
const onToggle = vi.fn()
const { rerender } = render(
<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}}
ferrataLayer={ferrataLayer()} placeTool={{ active: true, onToggle }} />
)
const group = screen.getByRole('group')
expect(group.querySelectorAll('span.bg-edge')).toHaveLength(1)
const buttons = Array.from(group.querySelectorAll('button')).map((button) => button.getAttribute('aria-label') ?? button.textContent)
expect(buttons.slice(-3)).toEqual(['Set points', 'Via ferratas', '3D'])
const tool = screen.getByRole('button', { name: 'Set points' })
expect(tool).toHaveAttribute('aria-pressed', 'true')
expect(tool).toHaveClass('bg-accent', 'text-accent-text')
expect(tool).not.toHaveTextContent('Set points')
fireEvent.click(tool)
expect(onToggle).toHaveBeenCalledTimes(1)
fireEvent.mouseEnter(tool)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Set points')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} placeTool={{ active: false, onToggle }} />)
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
})
})
+33 -2
View File
@@ -1,4 +1,4 @@
import { Loader2, Map as MapIcon, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
import type { CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
@@ -150,6 +150,33 @@ function FerrataToggle({ layer, compact = false }: { layer: FerrataMapLayer; com
)
}
/** The tour map's "set points" tool: while on, a click on the map places a point. */
export interface MapPlaceTool {
active: boolean
onToggle: () => void
}
/** The switch for the "set points" tool, on its icon, named by its tooltip. */
function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
const { t } = useTranslation()
const label = t('tours.planner.placePoints')
return (
<Tooltip label={label} placement="top">
<button
type="button"
onClick={tool.onToggle}
aria-label={label}
aria-pressed={tool.active}
data-testid="map-place-tool"
className={tool.active ? 'bg-accent text-accent-text' : 'text-content-muted'}
style={segmentStyle(tool.active)}
>
<MapPinPlus size={15} strokeWidth={2} />
</button>
</Tooltip>
)
}
/**
* Per-view Tours control. It never reads or writes the global map setting.
* `compact` shows the base layers and the via ferrata switch by their icons
@@ -162,6 +189,7 @@ export function TourMapLayerSwitcher({
onToggleTerrain,
terrainActive = false,
ferrataLayer,
placeTool,
compact = false,
}: {
active: TourBaseLayer
@@ -171,6 +199,8 @@ export function TourMapLayerSwitcher({
terrainActive?: boolean
/** Offers the via ferrata switch, with its count badge, beside the base layers. */
ferrataLayer?: FerrataMapLayer
/** Offers the "set points" switch, while a tour is being edited. */
placeTool?: MapPlaceTool
compact?: boolean
}) {
const { t } = useTranslation()
@@ -196,7 +226,8 @@ export function TourMapLayerSwitcher({
</Tooltip>
)
})}
{(ferrataLayer || onToggleTerrain) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
{onToggleTerrain && (
<button
+51
View File
@@ -0,0 +1,51 @@
import { Check, Undo2 } from 'lucide-react';
import { fs } from '../shared/DialogShell';
import { MAP_CONTROL_SHADOW } from './mapControlShadow';
export interface MapUndoHintProps {
message: string;
undoLabel: string;
onUndo: () => void;
/** True while the pointer rests on the hint, so it does not vanish under it. */
onHold?: (held: boolean) => void;
}
/**
* A short note at the foot of the map that something just happened there, with
* the way back: a glass pill like the map's other controls. The caller places it
* and decides how long it stays.
*/
export function MapUndoHint({ message, undoLabel, onUndo, onHold }: Readonly<MapUndoHintProps>) {
return (
<div
role="status"
aria-live="polite"
data-testid="map-undo-hint"
className="trek-toast-enter pointer-events-auto flex max-w-full items-center gap-2 rounded-full p-1 ps-3 text-content"
style={{
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}
onMouseEnter={() => onHold?.(true)}
onMouseLeave={() => onHold?.(false)}
onFocus={() => onHold?.(true)}
onBlur={() => onHold?.(false)}
>
<Check size={14} strokeWidth={2.4} className="flex-none text-accent" aria-hidden />
<span className="min-w-0 truncate font-semibold" style={fs(12, 'body')}>
{message}
</span>
<button
type="button"
onClick={onUndo}
className="bg-accent-soft flex h-[30px] flex-none items-center gap-1.5 rounded-full px-3 font-semibold text-accent hover:opacity-90"
style={fs(11)}
>
<Undo2 size={13} strokeWidth={2.2} aria-hidden />
{undoLabel}
</button>
</div>
);
}
+3 -3
View File
@@ -830,7 +830,7 @@ export const MapView = memo(function MapView({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
}: MapViewProps) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
@@ -1298,7 +1298,7 @@ export const MapView = memo(function MapView({
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
{plannerWaypoints.map((point, index) => (
<PlannerWaypointMarker
key={`planner-waypoint-${point.id}`}
point={point}
@@ -1381,7 +1381,7 @@ export const MapView = memo(function MapView({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>
+2 -2
View File
@@ -626,7 +626,7 @@ export function MapViewGL({
onPlannerWaypointClick,
onPlannerWaypointMove,
routeProfileFocus = null,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer,
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -2354,7 +2354,7 @@ export function MapViewGL({
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} />
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
</div>
{/* Hover tooltip — cursor-following name/category/address card, identical to
@@ -1,6 +1,7 @@
import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
@@ -24,7 +25,7 @@ export default function PlannerWaypointMarker({
onMove,
pinCss,
}: {
point: { id: string; lat: number; lng: number };
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
index: number;
selected: boolean;
onClick?: (id: string) => void;
@@ -76,8 +77,9 @@ export default function PlannerWaypointMarker({
zIndexOffset={selected ? 1200 : 1100}
eventHandlers={eventHandlers}
>
<Tooltip direction="top" offset={[0, -12]}>
{index + 1}
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
<span className="font-medium">{point.name ?? index + 1}</span>
{point.detail && <span className={MAP_LABEL_BADGE_CLASS}>{point.detail}</span>}
</Tooltip>
</Marker>
);
+106
View File
@@ -0,0 +1,106 @@
/**
* A name and a short detail floating over a GL map (the via ferrata under the
* pointer and its grade, a tour waypoint and its role), the twin of the
* Leaflet `map-tooltip`: the look of the shared Tooltip (card surface, faint
* edge, caption tier), just above the point it is shown for.
*
* Built from the DOM rather than React, because it follows every mouse move:
* a React state per move would render the whole map view each time. The words
* go in as text, never as markup. It lives in the map's own container, so it
* scrolls, hides and goes away with the map.
*/
/** How far above the pointer the label floats, in CSS pixels. */
const POINTER_GAP = 12;
/** The detail beside the name (a role, a grade) as a badge, as the shared pills draw one. */
export const MAP_LABEL_BADGE_CLASS =
'ms-1.5 inline-flex h-[18px] items-center rounded-full bg-surface-secondary px-1.5 font-semibold text-content-secondary ring-1 ring-inset ring-edge-faint';
const LABEL_CLASS =
'pointer-events-none absolute z-20 flex items-center whitespace-nowrap rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-caption font-medium text-content shadow-lg';
/** Just the call the label needs, so the Mapbox and the MapLibre map both fit, and a stand-in without it gets none. */
interface LabelHost {
getContainer?(): HTMLElement;
}
export interface MapHoverLabel {
show(text: { name: string; detail: string | null }, point: { x: number; y: number }): void;
hide(): void;
remove(): void;
}
const NO_LABEL: MapHoverLabel = { show: () => undefined, hide: () => undefined, remove: () => undefined };
export function mapHoverLabel(map: LabelHost, testId = 'map-hover-label'): MapHoverLabel {
if (typeof map.getContainer !== 'function' || typeof document === 'undefined') return NO_LABEL;
const container = map.getContainer();
let element: HTMLDivElement | null = null;
const ensure = () => {
if (element) return element;
element = document.createElement('div');
element.className = LABEL_CLASS;
element.setAttribute('role', 'tooltip');
element.dataset.testid = testId;
// The pointer's pixel on the map, not a side of the text: placed from the corner either way.
element.style.inset = '0px auto auto 0px';
container.appendChild(element);
return element;
};
return {
show({ name, detail }, { x, y }) {
const label = ensure();
label.replaceChildren(document.createTextNode(name));
if (detail) {
const badge = document.createElement('span');
badge.className = MAP_LABEL_BADGE_CLASS;
badge.textContent = detail;
label.appendChild(badge);
}
label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`;
label.hidden = false;
},
hide() {
if (element) element.hidden = true;
},
remove() {
element?.remove();
element = null;
},
};
}
/** What a map pin names on hover: its place and, beside it, its role. */
export interface PinHoverText {
name: string;
detail: string | null;
}
/**
* Shows the label above a pin while the pointer rests on it, and hides it the
* moment the pin is pressed, so it never trails a drag. Returns the unwiring.
*/
export function bindPinHoverLabel(
pin: HTMLElement,
label: MapHoverLabel,
container: HTMLElement,
text: PinHoverText
): () => void {
const show = () => {
const box = pin.getBoundingClientRect();
const frame = container.getBoundingClientRect();
label.show(text, { x: box.left + box.width / 2 - frame.left, y: box.top - frame.top });
};
const hide = () => label.hide();
pin.addEventListener('pointerenter', show);
pin.addEventListener('pointerleave', hide);
pin.addEventListener('pointerdown', hide);
return () => {
pin.removeEventListener('pointerenter', show);
pin.removeEventListener('pointerleave', hide);
pin.removeEventListener('pointerdown', hide);
};
}
+116
View File
@@ -0,0 +1,116 @@
// FE-MAP-PRESS-001 to FE-MAP-PRESS-008: only a real click on the map places a point, never a drag or a long hold.
import { describe, expect, it } from 'vitest';
import { createPressTracker, isPlacementTap, TAP_MAX_DURATION_MS, TAP_MAX_TRAVEL_PX } from './mapPress';
function pointer(type: string, x: number, y: number, pointerId = 1): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, {
clientX: { value: x },
clientY: { value: y },
pointerId: { value: pointerId },
});
return event;
}
/** A tracker on a fresh element, with a clock the test moves. */
function setup() {
const el = document.createElement('div');
let clock = 0;
const tracker = createPressTracker(el, () => clock);
return {
el,
tracker,
at: (ms: number) => {
clock = ms;
},
send: (type: string, x: number, y: number, id = 1) => el.dispatchEvent(pointer(type, x, y, id)),
};
}
describe('isPlacementTap', () => {
it('FE-MAP-PRESS-001: a short press that barely moved places', () => {
expect(isPlacementTap({ travelPx: 0, durationMs: 80, pointers: 1 })).toBe(true);
expect(isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX - 0.5, durationMs: TAP_MAX_DURATION_MS - 1, pointers: 1 })).toBe(
true
);
});
it('FE-MAP-PRESS-002: 4 px of travel, 300 ms of hold or a second finger never place', () => {
expect(isPlacementTap({ travelPx: TAP_MAX_TRAVEL_PX, durationMs: 50, pointers: 1 })).toBe(false);
expect(isPlacementTap({ travelPx: 0, durationMs: TAP_MAX_DURATION_MS, pointers: 1 })).toBe(false);
expect(isPlacementTap({ travelPx: 0, durationMs: 50, pointers: 2 })).toBe(false);
});
it('FE-MAP-PRESS-003: a click no press was seen for (keyboard, synthetic) is the map\'s own call', () => {
expect(isPlacementTap(null)).toBe(true);
});
});
describe('createPressTracker', () => {
it('FE-MAP-PRESS-004: hands out the finished press once, with its travel and length', () => {
const { tracker, at, send } = setup();
at(1000);
send('pointerdown', 10, 10);
at(1100);
send('pointerup', 12, 11);
const press = tracker.take();
expect(press?.durationMs).toBe(100);
expect(press?.travelPx).toBeCloseTo(Math.hypot(2, 1));
expect(press?.pointers).toBe(1);
expect(isPlacementTap(press)).toBe(true);
expect(tracker.take()).toBeNull();
});
it('FE-MAP-PRESS-005: a drag that came back to where it started still counts as a drag', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 100, 100);
send('pointermove', 140, 100);
at(120);
send('pointerup', 100, 100);
const press = tracker.take();
expect(press?.travelPx).toBe(40);
expect(isPlacementTap(press)).toBe(false);
});
it('FE-MAP-PRESS-006: a pinch or a two-finger rotate is no tap, and ends with the last finger', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 10, 10, 1);
send('pointerdown', 50, 50, 2);
at(80);
send('pointerup', 50, 50, 2);
expect(tracker.take()).toBeNull();
send('pointerup', 10, 10, 1);
const press = tracker.take();
expect(press?.pointers).toBe(2);
expect(isPlacementTap(press)).toBe(false);
});
it('FE-MAP-PRESS-007: a cancelled press (the browser took it for a gesture) never places', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 10, 10);
at(50);
send('pointercancel', 10, 10);
expect(isPlacementTap(tracker.take())).toBe(false);
});
it('FE-MAP-PRESS-008: a new press drops the one nobody read, and dispose stops listening', () => {
const { tracker, at, send } = setup();
at(0);
send('pointerdown', 0, 0);
at(500);
send('pointerup', 0, 0);
send('pointerdown', 0, 0);
expect(tracker.take()).toBeNull();
at(550);
send('pointerup', 0, 0);
expect(tracker.take()?.durationMs).toBe(50);
tracker.dispose();
send('pointerdown', 0, 0);
send('pointerup', 0, 0);
expect(tracker.take()).toBeNull();
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* Telling a click that should put something on the map from the end of a pan,
* a rotation or a tilt. Both map engines fire their click after short drags
* and long holds too, which in 3D, where every look around is a drag, kept
* dropping waypoints nobody meant to set.
*/
/** How far the pointer may travel during a press that still counts as a click. */
export const TAP_MAX_TRAVEL_PX = 4;
/** How long a press that still counts as a click may last. */
export const TAP_MAX_DURATION_MS = 300;
/** One finished press on the map. */
export interface MapPress {
/** The farthest the first pointer got from where it went down. */
travelPx: number;
durationMs: number;
/** The most pointers down at once: two fingers pinch or rotate, they never place. */
pointers: number;
}
/**
* Whether a press places a point. A press that was never seen (a keyboard
* activation, a synthetic click) is left to the map, which only fires a click
* for it on purpose.
*/
export function isPlacementTap(press: MapPress | null): boolean {
if (!press) return true;
return press.pointers <= 1 && press.travelPx < TAP_MAX_TRAVEL_PX && press.durationMs < TAP_MAX_DURATION_MS;
}
export interface PressTracker {
/** The press that ended last, handed out once: the click it caused reads it, the next one does not. */
take: () => MapPress | null;
dispose: () => void;
}
interface Active {
id: number;
x: number;
y: number;
at: number;
travelPx: number;
pointers: Set<number>;
most: number;
cancelled: boolean;
}
/**
* Follows the presses on an element in the capture phase, so neither engine
* can swallow them first, and keeps the last finished one for the click that
* follows it.
*/
export function createPressTracker(target: EventTarget, now: () => number = () => performance.now()): PressTracker {
let active: Active | null = null;
let last: MapPress | null = null;
const down = (event: Event) => {
const e = event as PointerEvent;
if (active) {
active.pointers.add(e.pointerId);
active.most = Math.max(active.most, active.pointers.size);
return;
}
last = null;
active = {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
at: now(),
travelPx: 0,
pointers: new Set([e.pointerId]),
most: 1,
cancelled: false,
};
};
const move = (event: Event) => {
const e = event as PointerEvent;
if (!active || e.pointerId !== active.id) return;
active.travelPx = Math.max(active.travelPx, Math.hypot(e.clientX - active.x, e.clientY - active.y));
};
const up = (event: Event) => {
const e = event as PointerEvent;
if (!active) return;
if (e.pointerId === active.id && !active.cancelled) {
move(e);
}
if (event.type === 'pointercancel') active.cancelled = true;
active.pointers.delete(e.pointerId);
if (active.pointers.size > 0) return;
last = {
travelPx: active.cancelled ? Number.POSITIVE_INFINITY : active.travelPx,
durationMs: now() - active.at,
pointers: active.most,
};
active = null;
};
const options = { capture: true, passive: true } as const;
target.addEventListener('pointerdown', down, options);
target.addEventListener('pointermove', move, options);
target.addEventListener('pointerup', up, options);
target.addEventListener('pointercancel', up, options);
return {
take: () => {
const press = last;
last = null;
return press;
},
dispose: () => {
target.removeEventListener('pointerdown', down, options);
target.removeEventListener('pointermove', move, options);
target.removeEventListener('pointerup', up, options);
target.removeEventListener('pointercancel', up, options);
},
};
}
+5 -2
View File
@@ -6,7 +6,7 @@ import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
import type { DayBoundaryControls } from './dayBoundaryDrag';
import type { CompassMap } from './MapCompassPill';
import type { TourBaseLayer } from './MapLayerSwitcher';
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
import type { Poi } from './poiCategories';
/*
@@ -109,7 +109,8 @@ export interface MapViewProps {
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>;
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */
@@ -119,6 +120,8 @@ export interface MapViewProps {
onViewBaseLayerChange?: (layer: TourBaseLayer) => void;
/** Offers the 3D terrain switch in the tour layer pill. */
onToggleTerrain3d?: () => void;
/** Offers the "set points" switch in the tour layer pill, while a tour is being edited. */
placeTool?: MapPlaceTool;
/**
* The via ferrata layer (RC-10): its switch, with the count as a badge, joins
* the tour layer pill, and while it is on its lines are drawn and a click on
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
@@ -59,6 +60,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
const dragCleanups: (() => void)[] = [];
const hoverLabel = mapHoverLabel(map, 'planner-waypoint-label');
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId;
@@ -66,7 +68,8 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
const el = document.createElement('button');
el.type = 'button';
el.textContent = String(index + 1);
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
const swallow = (event: Event) => event.stopPropagation();
const select = (event: Event) => {
@@ -78,6 +81,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
el.addEventListener('click', select);
const pin = attachPin(el, point.lng, point.lat);
pinsRef.current.push(pin);
dragCleanups.push(bindPinHoverLabel(el, hoverLabel, map.getContainer(), { name, detail: point.detail ?? null }));
if (plannerWaypointsDraggable) {
el.style.cursor = 'grab';
dragCleanups.push(
@@ -99,6 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
return () => {
dragCleanups.forEach((cleanup) => cleanup());
hoverLabel.remove();
pinsRef.current.forEach((pin) => pin.remove());
pinsRef.current = [];
};
+2 -2
View File
@@ -2,7 +2,7 @@ import { useId, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { fs } from '../shared/DialogShell'
import { useTranslation } from '../../i18n'
import { TourFactPills, TourTile } from './tourParts'
import { TourFactPills, TourGradeTile } from './tourParts'
import { tourWebsitePresentation } from './tourPresentation'
interface TourListRowProps {
@@ -38,7 +38,7 @@ export default function TourListRow({ tour, selected = false, disabled = false,
}}
>
<div className="flex items-start gap-2.5">
<TourTile />
<TourGradeTile tour={tour} />
<div className="min-w-0 flex-1 pt-px">
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
<TourFactPills tour={tour} className="mt-1.5" />
@@ -102,7 +102,7 @@ describe('ToursSidebar', () => {
expect(labels[0]).toContain('1.9 km')
expect(labels[1]).toContain('13 m')
expect(labels[2]).toContain('11 m')
expect(screen.getByText('TREK')).toBeInTheDocument()
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
@@ -0,0 +1,379 @@
import { AlertTriangle, Info } from 'lucide-react';
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts';
import CustomSelect from '../../shared/CustomSelect';
import { INPUT, LABEL } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { FoldButton, TourNotice, TourSection } from '../tourParts';
import { TOUR_DURATION_INPUT_MAX_LENGTH, formatTourDuration, parseTourDuration } from './tourDurationInput';
import type { TourPlannerController } from './useTourPlanner';
type Difficulty = 1 | 2 | 3 | 4 | 5 | 6;
interface TourFieldProps {
planner: TourPlannerController;
/** True while the viewer may not edit or a save is in flight. */
locked: boolean;
}
/** The tallest the description grows before it scrolls, about ten lines. */
const DESCRIPTION_MAX_HEIGHT = 240;
const DETAILS_OPEN_KEY = 'trek.tours.planner.detailsOpen';
function readDetailsOpen(): boolean {
try {
return localStorage.getItem(DETAILS_OPEN_KEY) !== 'false';
} catch {
// Storage blocked (private window, preview): the section starts open.
return true;
}
}
function writeDetailsOpen(open: boolean) {
try {
localStorage.setItem(DETAILS_OPEN_KEY, String(open));
} catch {
// Storage blocked: the choice lasts as long as the panel does.
}
}
/** The round info dot beside a label; hover, focus and a tap show the explanation as a tooltip. */
export function InfoDot({ description, label }: { description: string; label: string }) {
const id = useId();
return (
<Tooltip label={description}>
<button
type="button"
aria-label={label}
aria-describedby={id}
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Info size={13} strokeWidth={2} aria-hidden="true" />
<span id={id} className="sr-only">
{description}
</span>
</button>
</Tooltip>
);
}
/** A field label with what belongs beside it (an info dot, a badge) on the same line. */
function FieldLabel({ htmlFor, children, aside }: { htmlFor: string; children: ReactNode; aside?: ReactNode }) {
return (
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor={htmlFor}>
{children}
</label>
{aside}
</div>
);
}
function FieldError({ id, children }: { id: string; children: ReactNode }) {
return (
<span id={id} role="alert" className="mt-1 block text-danger" style={fs(11)}>
{children}
</span>
);
}
export function TourNameField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">
{t('tours.planner.name')}
</label>
<input
id="tour-planner-name"
disabled={locked}
value={planner.name}
onChange={(event) => {
if (!locked) planner.setName(event.target.value);
}}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
);
}
/**
* A duration typed as "2:56", "2 h 56" or "176" minutes and shown as "2 h 56 min".
* A readable value reaches the planner while it is typed; text that is no
* duration reaches it as NaN on leaving the field, so the planner marks it
* invalid and holds Save back, and the text stays for the user to fix.
*/
function TourDurationInput({
id,
label,
value,
invalid,
errorId,
locked,
onCommit,
}: {
id: string;
label: string;
value: number | null;
invalid: boolean;
errorId: string;
locked: boolean;
onCommit: (minutes: number | null) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const showDraft = draft !== null && (focused || (value !== null && Number.isNaN(value)));
return (
<input
id={id}
aria-label={label}
type="text"
inputMode="text"
autoComplete="off"
maxLength={TOUR_DURATION_INPUT_MAX_LENGTH}
placeholder="0:00"
disabled={locked}
value={showDraft ? draft : formatTourDuration(value)}
onFocus={() => {
setFocused(true);
setDraft(formatTourDuration(value));
}}
onChange={(event) => {
if (locked) return;
const text = event.target.value;
setDraft(text);
const parsed = parseTourDuration(text);
if (parsed !== 'invalid') onCommit(parsed);
}}
onBlur={() => {
setFocused(false);
if (draft === null) return;
if (!locked && parseTourDuration(draft) === 'invalid') onCommit(Number.NaN);
else setDraft(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? errorId : undefined}
className={`${INPUT} tabular-nums`}
/>
);
}
/** Breaks on top of the walking time, and the planned total that defaults to their sum, side by side. */
export function TourTimeFields({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
const manual = planner.plannedDurationMinutes != null;
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-2 gap-3">
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-breaks"
aside={
<InfoDot
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
}
>
{t('tours.planner.breaksShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-breaks"
label={t('tours.planner.breaksAdditional')}
value={planner.breakAdditionalMinutes}
invalid={planner.breakAdditionalInvalid}
errorId="tour-planner-breaks-error"
locked={locked}
onCommit={planner.setBreakAdditionalMinutes}
/>
{planner.breakAdditionalInvalid && (
<FieldError id="tour-planner-breaks-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-planned-duration"
aside={
<>
<InfoDot
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
{manual && (
<span data-testid="tour-planner-planned-manual" className="ms-auto inline-flex min-w-0">
<SoftPill tone="info">{t('tours.planner.plannedTotalManual')}</SoftPill>
</span>
)}
</>
}
>
{t('tours.planner.plannedShort')}
</FieldLabel>
<TourDurationInput
id="tour-planner-planned-duration"
label={t('tours.planner.plannedTotalDuration')}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
invalid={planner.plannedDurationInvalid}
errorId="tour-planner-planned-duration-error"
locked={locked}
onCommit={planner.setPlannedDurationMinutes}
/>
{planner.plannedDurationInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
</div>
</div>
);
}
/** The description, growing with its text up to about ten lines before it scrolls. */
function TourDescriptionField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
const ref = useRef<HTMLTextAreaElement>(null);
const value = planner.description;
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const borders = element.offsetHeight - element.clientHeight;
element.style.height = 'auto';
const wanted = element.scrollHeight + borders;
element.style.height = `${Math.min(wanted, DESCRIPTION_MAX_HEIGHT)}px`;
element.style.overflowY = wanted > DESCRIPTION_MAX_HEIGHT ? 'auto' : 'hidden';
}, [value]);
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">
{t('places.formDescription')}
</label>
<textarea
ref={ref}
id="tour-planner-description"
data-testid="tour-planner-description"
disabled={locked}
value={value}
onChange={(event) => {
if (!locked) planner.setDescription(event.target.value);
}}
maxLength={2000}
rows={2}
className={`${INPUT} resize-none`}
/>
</div>
);
}
function TourWebsiteField({ planner, locked }: TourFieldProps) {
const { t } = useTranslation();
return (
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">
{t('places.formWebsite')}
</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={locked}
value={planner.website}
onChange={(event) => {
if (!locked) planner.setWebsite(event.target.value);
}}
maxLength={500}
placeholder={t('collab.notes.websitePlaceholder')}
aria-invalid={planner.websiteInvalid || undefined}
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
className={INPUT}
/>
{planner.websiteInvalid && (
<FieldError id="tour-planner-website-error">{t('dawarich.error.invalid_url')}</FieldError>
)}
</div>
);
}
/** The difficulty cap, with the routing safety note behind the info dot beside its label. */
function TourDifficultyField({
planner,
locked,
onDifficultyChange,
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
return (
<>
<div className="min-w-0">
<FieldLabel
htmlFor="tour-planner-difficulty"
aside={<InfoDot label={t('tours.planner.safetyInfoLabel')} description={t('tours.planner.safetyNote')} />}
>
{t('tours.planner.maxDifficulty')}
</FieldLabel>
<CustomSelect
id="tour-planner-difficulty"
disabled={locked}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={(nextValue) => {
if (!locked) onDifficultyChange(Number(nextValue) as Difficulty);
}}
options={[1, 2, 3, 4, 5, 6].map((value) => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.planner.difficulty.t3Warning')}
</TourNotice>
)}
</>
);
}
/**
* Description, website, the difficulty cap and the time, folded away with the
* chevron. The section opens by itself while one of its fields holds a value
* the planner refuses, so the reason Save is held back is never out of sight.
*/
export function TourDetailsSection({
planner,
locked,
onDifficultyChange,
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(readDetailsOpen);
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
const expanded = open || forcedOpen;
const toggle = () => {
const next = !expanded;
setOpen(next);
writeDetailsOpen(next);
};
return (
<TourSection
label={<span className="text-content">{t('tours.planner.details')}</span>}
action={
<FoldButton
expanded={expanded}
onToggle={toggle}
controls="tour-planner-details"
label={t(expanded ? 'tours.planner.collapseDetails' : 'tours.planner.expandDetails')}
/>
}
>
<div id="tour-planner-details" hidden={!expanded} className="flex flex-col gap-3">
{expanded && (
<>
<TourDescriptionField planner={planner} locked={locked} />
<TourWebsiteField planner={planner} locked={locked} />
<TourDifficultyField planner={planner} locked={locked} onDifficultyChange={onDifficultyChange} />
<TourTimeFields planner={planner} locked={locked} />
</>
)}
</div>
</TourSection>
);
}
@@ -119,7 +119,7 @@ describe('TourPlannerToursRail', () => {
expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
})
it('disables saved-tour switching and Plan another while a Save is pending', () => {
it('disables saved-tour switching while a Save is pending', () => {
const controller = planner({
isSaving: true,
saveOutcome: tour,
@@ -134,14 +134,12 @@ describe('TourPlannerToursRail', () => {
expect(row).toHaveAttribute('tabindex', '-1')
fireEvent.click(row)
expect(controller.openTour).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Plan another' })).toBeDisabled()
})
it('TOUR-PLANNER-RAIL-001: shows saved Tours rows and route stats without hiding the safety note in navigation', () => {
render(<TourPlannerToursRail planner={planner({ mode: { type: 'new-draft' } })} tours={[tour]} {...railProps} />)
expect(screen.getByRole('complementary', { name: 'Tours of this trip' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('TREK')
expect(screen.getByRole('option', { name: /Saved ridge walk/i })).toHaveTextContent('4 km')
expect(screen.getByText('Ascent')).toBeInTheDocument()
expect(screen.getByText('50 m')).toBeInTheDocument()
@@ -176,11 +174,9 @@ describe('TourPlannerToursRail', () => {
const onAssignToDay = vi.fn().mockResolvedValue(undefined)
render(<TourPlannerToursRail planner={controller} tours={[tour]} {...railProps} onAssignToDay={onAssignToDay} />)
expect(screen.getByRole('status')).toHaveTextContent('Tour saved')
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan another' })).not.toBeInTheDocument()
expect(onAssignToDay).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Plan another' }))
expect(controller.startNewTour).toHaveBeenCalledOnce()
expect(controller.discard).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Add to day: Saved ridge walk' }))
const dayOption = screen.getByRole('button', { name: /Summit day/ })
@@ -673,8 +669,8 @@ describe('TourPlannerRail', () => {
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' } })} />)
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Click the map to add your starting point.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Add another point to calculate a route.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('Turn on Add point and click the map.')
expect(screen.getByTestId('tour-planner-empty-state')).toHaveTextContent('From two points on, TREK works out the route.')
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, waypoints: [{ id: 'first', lat: 48, lng: 11, role: 'start' }] })} />)
expect(screen.queryByTestId('tour-planner-empty-state')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument()
@@ -817,11 +813,13 @@ describe('TourPlannerRail', () => {
it('TOUR-PLANNER-RAIL-019: restored new and saved drafts show a non-blocking notice', () => {
const { rerender } = render(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
expect(screen.getByRole('status')).toHaveTextContent('Draft')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'New tour' })).toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ draftRestored: true, hasUnsavedChanges: true, editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
expect(screen.getByRole('status')).toHaveTextContent('Unsaved draft restored')
expect(screen.getByRole('status')).toHaveTextContent('Draft')
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
})
})
@@ -1,14 +1,12 @@
import { useId, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, GripVertical, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
import { AlertTriangle, ArrowLeft, Info, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { formatDistance, formatElevation } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import { INPUT, LABEL } from '../../shared/dialogParts'
import EmptyState from '../../shared/EmptyState'
import { useToast } from '../../shared/Toast'
import { Tooltip } from '../../shared/Tooltip'
@@ -18,6 +16,8 @@ import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
import TourWaypointList from './TourWaypointList'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
@@ -28,9 +28,7 @@ const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
'routing-failed': 'tours.planner.status.routingFailed',
'enriching-elevation': 'tours.planner.status.elevation',
'elevation-failed': 'tours.planner.status.elevationFailed',
ready: 'tours.planner.status.ready',
saving: 'tours.planner.status.saving',
saved: 'tours.planner.saved',
}
/** The tinted head band both rails open with, like every panel head in the planner. */
@@ -38,10 +36,6 @@ const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint p
type Translate = (key: string, params?: Record<string, string | number | null>) => string
function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) {
return t(`tours.planner.${role}`)
}
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
@@ -57,26 +51,48 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
<h2 className="m-0 min-w-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(14, 'body')}>{title}</h2>
{aside}
</div>
{sub && <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>}
{typeof sub === 'string'
? <p className="m-0 mt-0.5 leading-snug text-content-faint" style={fs(11.5, 'body')}>{sub}</p>
: sub}
</div>
)
}
function DurationInfo({ description, label }: { description: string; label: string }) {
const id = useId()
// Hover, focus and a tap show the explanation as a tooltip; nothing unfolds in the form.
/**
* What the editor is doing right now, as badges under the head's title: a
* restored draft, routing or saving, and a failure with its retry. Nothing
* when there is nothing to say.
*/
function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation()
const statusKey = planner.isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const restored = planner.draftRestored && planner.hasUnsavedChanges
const failed = planner.error === 'save'
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
if (!restored && !statusKey && !failed) return null
const warning = planner.error != null || canRetry
return (
<Tooltip label={description}>
<button
type="button"
aria-label={label}
aria-describedby={id}
className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Info size={13} strokeWidth={2} aria-hidden="true" />
<span id={id} className="sr-only">{description}</span>
</button>
</Tooltip>
<div role="status" data-testid="tour-planner-status" className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
{restored && (
<Tooltip label={t('tours.planner.restored')}>
<span className="inline-flex" aria-label={t('tours.planner.restored')}>
<SoftPill tone="info" icon={<RotateCcw size={10} strokeWidth={2.2} className="flex-none" />}>{t('tours.planner.restoredShort')}</SoftPill>
</span>
</Tooltip>
)}
{(failed || statusKey) && (
<SoftPill tone={warning ? 'warning' : 'neutral'} icon={warning ? <AlertTriangle size={10} strokeWidth={2.2} className="flex-none" /> : undefined}>
{failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
</SoftPill>
)}
{canRetry && (
<button type="button" onClick={planner.retry}
className="inline-flex h-[20px] items-center rounded-full bg-accent px-2 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(10.5)}>
{t('tours.planner.retry')}
</button>
)}
</div>
)
}
@@ -88,8 +104,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
const alpineAcknowledged = useRef(false)
const isSaving = planner.isSaving
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
const requestClose = () => {
if (isSaving) return
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
@@ -173,7 +187,7 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
<BarButton label={t('tours.planner.backToTours')} onClick={() => requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
<ArrowLeft size={16} strokeWidth={2} />
</BarButton>
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={t('tours.planner.mapHint')} />
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} sub={<RailStatusBadges planner={planner} />} />
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}>
<Undo2 size={15} strokeWidth={2} />
</BarButton>
@@ -183,37 +197,14 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{planner.draftRestored && planner.hasUnsavedChanges && (
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.planner.restored')}</TourNotice>
)}
<TourNameField planner={planner} locked={!canEdit || isSaving} />
<TourDetailsFields planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
<TourWaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
<WaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
{(statusKey || planner.error) && (
<TourNotice
role="status"
tone={planner.error ? 'warning' : 'neutral'}
icon={planner.error ? <AlertTriangle size={13} strokeWidth={2} /> : <Info size={13} strokeWidth={2} className="text-content-faint" />}
>
{planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null}
{canRetry && (
<button type="button" onClick={planner.retry}
className="ms-2 inline-flex items-center rounded-full bg-surface-card px-2 py-[1px] font-semibold text-content shadow-sm hover:bg-surface-secondary"
style={fs(11)}>
{t('tours.planner.retry')}
</button>
)}
</TourNotice>
)}
<TourDetailsSection planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
</div>
<div className="flex flex-none flex-col gap-2.5 border-t border-edge-faint px-3 py-3">
<p className="m-0 flex items-start gap-1.5 leading-snug text-content-faint" style={fs(11)}>
<ShieldAlert size={13} strokeWidth={2} className="mt-px flex-none" />
<span>{t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}</span>
</p>
<div className="flex flex-none flex-col border-t border-edge-faint px-3 py-3">
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}>
{t('common.discard')}
@@ -252,323 +243,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
)
}
interface TourFieldProps {
planner: TourPlannerController
/** True while the viewer may not edit or a save is in flight. */
locked: boolean
}
/** Breaks on top of the moving time, and the planned total that defaults to their sum. */
function TourTimeGrid({ planner, locked }: TourFieldProps) {
const { t } = useTranslation()
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 leading-5`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
<DurationInfo
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
</div>
<input
id="tour-planner-breaks"
aria-label={t('tours.planner.breaksAdditional')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={locked}
value={planner.breakAdditionalMinutes ?? ''}
onChange={event => { if (!locked) planner.setBreakAdditionalMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
aria-invalid={planner.breakAdditionalInvalid || undefined}
aria-describedby={`tour-planner-breaks-hint${planner.breakAdditionalInvalid ? ' tour-planner-breaks-error' : ''}`}
className={INPUT}
/>
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
{t('tours.planner.breaksAdditionalHint')}
</span>
{planner.breakAdditionalInvalid && (
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} !mb-0 leading-5`} htmlFor="tour-planner-planned-duration">
{t('tours.planner.plannedTotalDuration')}
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
</label>
<DurationInfo
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
</div>
<div className="flex min-w-0 items-center gap-2">
<input
id="tour-planner-planned-duration"
aria-label={t('tours.planner.plannedTotalDuration')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={locked}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
onChange={event => { if (!locked) planner.setPlannedDurationMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
aria-invalid={planner.plannedDurationInvalid || undefined}
aria-describedby={planner.plannedDurationInvalid ? 'tour-planner-planned-duration-error' : undefined}
className={`${INPUT} min-w-0 flex-none`}
style={{ width: '6rem' }}
/>
{planner.plannedDurationMinutes == null && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
</div>
{planner.plannedDurationInvalid && (
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
</div>
)
}
/** Name, description, timings, website and the difficulty cap of the tour being planned. */
function TourDetailsFields({ planner, locked, onDifficultyChange }: TourFieldProps & { onDifficultyChange: (value: 1 | 2 | 3 | 4 | 5 | 6) => void }) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3">
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
<input
id="tour-planner-name"
disabled={locked}
value={planner.name}
onChange={event => { if (!locked) planner.setName(event.target.value) }}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
<textarea
id="tour-planner-description"
disabled={locked}
value={planner.description}
onChange={event => { if (!locked) planner.setDescription(event.target.value) }}
maxLength={2000}
rows={3}
className={`${INPUT} min-h-[72px] resize-y`}
/>
</div>
<TourSection label={t('tours.planner.plannedTotalDuration')}>
<TourTimeGrid planner={planner} locked={locked} />
</TourSection>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={locked}
value={planner.website}
onChange={event => { if (!locked) planner.setWebsite(event.target.value) }}
maxLength={500}
placeholder={t('collab.notes.websitePlaceholder')}
aria-invalid={planner.websiteInvalid || undefined}
aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
className={INPUT}
/>
{planner.websiteInvalid && (
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('dawarich.error.invalid_url')}
</span>
)}
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
<CustomSelect
id="tour-planner-difficulty"
disabled={locked}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={nextValue => {
if (!locked) onDifficultyChange(Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6)
}}
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
)}
</div>
)
}
/** The ordered waypoints, reorderable by drag or by the arrow controls on each row. */
function WaypointList({ planner, canEdit, isSaving }: { planner: TourPlannerController; canEdit: boolean; isSaving: boolean }) {
const { t } = useTranslation()
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null);
const lastIndex = planner.waypoints.length - 1
return (
<TourSection label={t('tours.planner.waypoints')}>
{planner.waypoints.length === 0 ? (
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!canEdit || isSaving) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!canEdit || isSaving || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement
? current
: { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (
canEdit &&
!isSaving &&
draggedId &&
draggedId !== point.id &&
dropTarget?.id === point.id
) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
setDraggedWaypointId(null);
setDropTarget(null);
}}
onDragEnd={() => {
setDraggedWaypointId(null);
setDropTarget(null);
}}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={canEdit && !isSaving}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
{roleLabel(point.role, t)}
</span>
<span
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
style={fs(10.5)}
>
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
</span>
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
);
})}
</ol>
)}
</TourSection>
)
}
/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
return (
<Tooltip label={label}>
<button type="button" disabled={disabled}
aria-label={label}
aria-disabled={disabled}
onClick={event => { event.stopPropagation(); if (!disabled) onAct() }}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</button>
</Tooltip>
)
}
/** The elevation profile under its label, folded away with the chevron beside it. */
function ElevationSection({ id, samples, gradientId, planner }: {
id: string
@@ -640,6 +314,12 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null
const routeAnalysis = planner.routeAnalysis
// A finished save is said once, as the app says it everywhere: a toast.
const savedTour = planner.saveOutcome
useEffect(() => {
if (savedTour) toast.success(t('tours.planner.saved'))
}, [savedTour, t, toast])
const openTour = async (tour: TourListItem) => {
const opened = await planner.openTour(tour)
if (!opened) toast.error(t('tours.planner.openFailed'))
@@ -667,11 +347,11 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
: formatPlannedTourDuration(planner.walkingDurationMinutes),
},
{
label: t('tours.planner.breaksAdditional'),
label: t('tours.planner.breaksShort'),
value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
},
{
label: `${t('tours.planner.plannedTotalDuration')}${planner.plannedDurationMinutes != null ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}`,
label: `${t('tours.planner.plannedShort')}${planner.plannedDurationMinutes != null ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`,
value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
},
]
@@ -679,12 +359,21 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
return (
<aside className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.tripTours')}>
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} sub={t('tours.subtitle')} />
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && planner.mode.type !== 'neutral' && (
<Tooltip label={t('tours.planner.newTour')}>
<button type="button" aria-label={t('tours.planner.newTour')} data-testid="tour-rail-new-tour"
disabled={isSaving} onClick={() => { if (!isSaving) planner.startNewTour() }}
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-accent text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40">
<Plus size={14} strokeWidth={2.4} />
</button>
</Tooltip>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
{hasRoute && (
<>
<div data-testid="tour-planner-overview" className="flex flex-col gap-4 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<TourSection label={t('tours.planner.inspector.title')}>
<TourMetricFields analysis={routeAnalysis} unit={distanceUnit} extra={tourTimeFields} columns={2} />
</TourSection>
@@ -696,18 +385,6 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
planner={planner}
/>
)}
</>
)}
{planner.saveOutcome && (
<div className={`${BOX} flex flex-col gap-3 p-3`} role="status">
<div className="flex items-center gap-2 font-semibold text-content" style={fs(13, 'body')}>
<CheckCircle2 size={16} strokeWidth={2} className="flex-none text-success" />
{t('tours.planner.saved')}
</div>
<DialogButton onClick={() => { if (canEdit && !isSaving) planner.startNewTour() }} disabled={!canEdit || isSaving}>
{t('tours.planner.planAnother')}
</DialogButton>
</div>
)}
@@ -0,0 +1,256 @@
import { ArrowDown, ArrowUp, GripVertical, MapPin, MapPinPlus, Trash2 } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { toggleTourPlacing, useTourPlaceToolStore } from '../../../store/tourPlaceToolStore';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import { TourSection } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner';
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
type DropTarget = { id: string; placement: 'before' | 'after' };
const ROLE_KEY = {
start: 'tours.planner.start',
via: 'tours.planner.via',
end: 'tours.planner.end',
} as const;
/** A quiet icon control on a waypoint row; it stops the click so the row is not selected too. */
function WaypointAction({
label,
disabled,
danger = false,
onAct,
children,
}: {
label: string;
disabled: boolean;
danger?: boolean;
onAct: () => void;
children: ReactNode;
}) {
return (
<Tooltip label={label}>
<button
type="button"
disabled={disabled}
aria-label={label}
aria-disabled={disabled}
onClick={(event) => {
event.stopPropagation();
if (!disabled) onAct();
}}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
</button>
</Tooltip>
);
}
/** The quiet hint and first-use guidance before a route exists. */
function WaypointEmptyState() {
const { t } = useTranslation();
return (
<div
data-testid="tour-planner-empty-state"
className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted"
style={fs(12, 'body')}
>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.emptyHint')}</p>
</div>
);
}
/**
* The row under the last waypoint that turns on "set points" for the map the
* planner shows, the same switch as the one in the map's layer pill; while it
* is on, the row says where to click and a second click turns it off again.
*/
function AddPointRow({ disabled }: { disabled: boolean }) {
const { t } = useTranslation();
const active = useTourPlaceToolStore((state) => state.placing[state.mode]);
return (
<button
type="button"
data-testid="tour-planner-add-point"
aria-pressed={active}
disabled={disabled}
onClick={() => toggleTourPlacing()}
className={`mt-1.5 flex w-full items-center justify-center gap-1.5 rounded-[12px] border border-dashed px-3 py-2.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'border-accent bg-surface-card text-content' : 'border-edge text-content-muted enabled:hover:border-content-faint enabled:hover:text-content'}`}
style={fs(12, 'body')}
>
<MapPinPlus size={14} strokeWidth={2} className="flex-none" />
<span className="min-w-0 truncate">{t(active ? 'tours.planner.addPointActive' : 'tours.planner.addPoint')}</span>
</button>
);
}
/**
* The ordered waypoints, each under its place name with its role as a badge,
* reorderable by drag or by the arrow controls on each row. The coordinates
* stand in for the name while it loads, offline, or when the lookup has none.
*/
export default function TourWaypointList({
planner,
canEdit,
isSaving,
}: {
planner: TourPlannerController;
canEdit: boolean;
isSaving: boolean;
}) {
const { t } = useTranslation();
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const placeNames = useWaypointPlaceNames(planner.waypoints);
const lastIndex = planner.waypoints.length - 1;
const movable = canEdit && !isSaving;
const endDrag = () => {
setDraggedWaypointId(null);
setDropTarget(null);
};
if (planner.waypoints.length === 0) {
return (
<TourSection label={t('tours.planner.waypoints')}>
<WaypointEmptyState />
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
);
}
return (
<TourSection label={t('tours.planner.waypoints')}>
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
const placeName = placeNames[point.id];
const coordinates = `${point.lat.toFixed(5)}, ${point.lng.toFixed(5)}`;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
if (!movable) {
event.preventDefault();
return;
}
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', point.id);
}
setDraggedWaypointId(point.id);
setDropTarget(null);
}}
onDragOver={(event) => {
if (!movable || !draggedWaypointId || draggedWaypointId === point.id) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const bounds = event.currentTarget.getBoundingClientRect();
const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
setDropTarget((current) =>
current?.id === point.id && current.placement === placement ? current : { id: point.id, placement }
);
}}
onDrop={(event) => {
event.preventDefault();
const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
if (movable && draggedId && draggedId !== point.id && dropTarget?.id === point.id) {
planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
}
endDrag();
}}
onDragEnd={endDrag}
className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
>
{isDropTarget && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={movable}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${movable ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => planner.setSelectedWaypointId(point.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
aria-current={selected ? 'true' : undefined}
>
<span className="flex w-full min-w-0 items-center gap-1.5">
<span
data-testid="tour-planner-waypoint-name"
className={`block min-w-0 truncate leading-tight ${placeName ? 'font-semibold text-content' : 'font-geist tabular-nums text-content-muted'}`}
style={fs(12.5, 'body')}
>
{placeName ?? coordinates}
</span>
<SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
</span>
{placeName && (
<span
data-testid="tour-planner-waypoint-coordinates"
className="block min-w-0 truncate font-geist tabular-nums leading-tight text-content-faint"
style={fs(10.5)}
>
{coordinates}
</span>
)}
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
);
})}
</ol>
{canEdit && <AddPointRow disabled={isSaving} />}
</TourSection>
);
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { formatTourDuration, parseTourDuration } from './tourDurationInput';
describe('parseTourDuration', () => {
it.each([
['176', 176],
['0', 0],
[' 45 ', 45],
['2:56', 176],
['0:05', 5],
['2:5', 125],
['2 h 56', 176],
['2h56', 176],
['2 h 56 min', 176],
['2H 56MIN', 176],
['2 h', 120],
['3h', 180],
['1,5 h', 90],
['1.5h', 90],
['2 Std 10', 130],
['2 Std. 10 Min.', 130],
['45 min', 45],
['45m', 45],
['2 h 5', 125],
])('reads %j as %d minutes', (text, minutes) => {
expect(parseTourDuration(text)).toBe(minutes);
});
it('clears the value for an empty field', () => {
expect(parseTourDuration('')).toBeNull();
expect(parseTourDuration(' ')).toBeNull();
});
it.each(['h', 'min', 'abc', '2:60', '2:', ':30', '-5', '2 56', '2,5', '1 h 2 h', '12345678901234567'])(
'rejects %j',
(text) => {
expect(parseTourDuration(text)).toBe('invalid');
},
);
it('reads back everything it formats', () => {
for (const minutes of [0, 5, 59, 60, 61, 176, 1440]) {
expect(parseTourDuration(formatTourDuration(minutes))).toBe(minutes);
}
});
});
describe('formatTourDuration', () => {
it('writes hours and minutes the way the planner shows durations', () => {
expect(formatTourDuration(176)).toBe('2 h 56 min');
expect(formatTourDuration(120)).toBe('2 h');
expect(formatTourDuration(45)).toBe('45 min');
expect(formatTourDuration(0)).toBe('0 min');
});
it('writes nothing for an empty or unreadable value', () => {
expect(formatTourDuration(null)).toBe('');
expect(formatTourDuration(Number.NaN)).toBe('');
});
});
@@ -0,0 +1,45 @@
import { formatPlannedTourDuration } from '../tourPresentation';
/** What a typed duration means: nothing (cleared), a number of minutes, or no duration at all. */
export type ParsedTourDuration = number | null | 'invalid';
/** The longest text a duration field takes; nobody types more for a day's walk. */
export const TOUR_DURATION_INPUT_MAX_LENGTH = 16;
const PLAIN_MINUTES = /^\d+$/;
const CLOCK = /^(\d+):(\d{1,2})$/;
// Hours with a unit, then minutes with an optional unit, or minutes alone with a unit:
// "2 h 56", "2h56min", "1,5 h", "45 min". Each part is anchored on its unit, so the
// pattern cannot match one text two ways.
const WITH_UNITS = /^(?:(\d+(?:[.,]\d+)?) ?(?:h|hr|hrs|hours?|std|stunden?)\.?)? ?(?:(\d+) ?(?:m|min|mins|minutes?|minuten?)?\.?)?$/;
/**
* Reads what somebody typed into a duration field as whole minutes.
*
* "176" is minutes, "2:56" is hours and minutes, and "2 h 56", "2h 56 min" or
* "1,5 h" spell the units out. An empty field clears the value. Anything else,
* a clock with 60 minutes or more included, is invalid. The range is the
* planner's to check, not this one's.
*/
export function parseTourDuration(text: string): ParsedTourDuration {
const value = text.trim().toLowerCase().replace(/\s+/g, ' ');
if (value === '') return null;
if (value.length > TOUR_DURATION_INPUT_MAX_LENGTH) return 'invalid';
if (PLAIN_MINUTES.test(value)) return Number(value);
const clock = CLOCK.exec(value);
if (clock) {
const minutes = Number(clock[2]);
return minutes < 60 ? Number(clock[1]) * 60 + minutes : 'invalid';
}
const units = WITH_UNITS.exec(value);
if (!units || (units[1] === undefined && units[2] === undefined)) return 'invalid';
const hours = units[1] === undefined ? 0 : Number(units[1].replace(',', '.'));
const minutes = units[2] === undefined ? 0 : Number(units[2]);
return Math.round(hours * 60 + minutes);
}
/** "2 h 56 min" for a duration in minutes, and nothing for an empty or unreadable one. */
export function formatTourDuration(minutes: number | null): string {
if (minutes === null || !Number.isFinite(minutes)) return '';
return formatPlannedTourDuration(minutes);
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { usePlaceLanguage } from '../../../hooks/usePlaceLanguage';
import { reverseGeocodeRepo } from '../../../repo/reverseGeocodeRepo';
/** How long a point has to rest before it is looked up, so a drag does not ask at every step. */
const SETTLE_MS = 350;
interface WaypointCoordinate {
id: string;
lat: number;
lng: number;
}
const coordinateKey = (point: WaypointCoordinate) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
/**
* The place name of every waypoint by its id, as far as the reverse lookup has
* answered. A waypoint without an entry is still loading, offline, or has no
* name; the caller shows its coordinates then. A name is kept with the
* coordinate it belongs to, so a moved point never shows its old name.
*/
export function useWaypointPlaceNames(waypoints: WaypointCoordinate[]): Record<string, string> {
const lang = usePlaceLanguage();
const [names, setNames] = useState<Record<string, { key: string; name: string }>>({});
const signature = waypoints.map((point) => `${point.id}@${coordinateKey(point)}`).join('|');
useEffect(() => {
if (!signature) return undefined;
let cancelled = false;
const points = signature.split('|').map((entry) => {
const [id, key] = entry.split('@');
const [lat, lng] = key.split(',').map(Number);
return { id, key, lat, lng };
});
const timer = window.setTimeout(() => {
for (const point of points) {
reverseGeocodeRepo.placeName(point.lat, point.lng, lang).then(
(name) => {
if (cancelled || !name) return;
setNames((current) =>
current[point.id]?.key === point.key && current[point.id]?.name === name
? current
: { ...current, [point.id]: { key: point.key, name } },
);
},
() => {
// Offline or a failed lookup: the row keeps showing the coordinates.
},
);
}
}, SETTLE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [lang, signature]);
const result: Record<string, string> = {};
for (const point of waypoints) {
const entry = names[point.id];
if (entry && entry.key === coordinateKey(point)) result[point.id] = entry.name;
}
return result;
}
@@ -1,4 +1,4 @@
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-014
// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-015
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
@@ -372,4 +372,24 @@ describe('TourTerrainMap', () => {
expect(gl.map.remove).toHaveBeenCalledTimes(1);
expect(pin.remove).toHaveBeenCalled();
});
it('FE-COMP-TERRAIN-015: the "set points" switch rides in the layer pill, and while it is off the hint says so', async () => {
const onToggle = vi.fn();
const { rerender } = await renderTerrain({ placeTool: { active: false, onToggle } });
const tool = screen.getByRole('button', { name: 'Set points' });
expect(tool).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('Turn on Set points to click points onto the terrain.')).toBeInTheDocument();
fireEvent.click(tool);
expect(onToggle).toHaveBeenCalledTimes(1);
rerender(
<TourTerrainMap
{...props({
placeTool: { active: true, onToggle },
waypoints: { points: [], selectedId: null, onSelect: vi.fn(), onAdd: vi.fn() },
})}
/>
);
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText(/Click the terrain to set points/)).toBeInTheDocument();
});
});
@@ -6,7 +6,12 @@ import type { DistanceUnit } from '../../../types';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry';
import { formatElevation } from '../../../utils/units';
import type { FerrataMapLayer } from '../../Ferratas/useFerrataMapLayer';
import { MAP_LAYER_SWITCHER_INSET, TourMapLayerSwitcher, type TourBaseLayer } from '../../Map/MapLayerSwitcher';
import {
MAP_LAYER_SWITCHER_INSET,
TourMapLayerSwitcher,
type MapPlaceTool,
type TourBaseLayer,
} from '../../Map/MapLayerSwitcher';
import { MAP_CONTROL_SHADOW } from '../../Map/mapControlShadow';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
@@ -37,6 +42,8 @@ export interface TourTerrainMapProps {
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;
/** The "set points" switch in the layer pill, while a tour is being edited. */
placeTool?: MapPlaceTool;
}
const SPEEDS = [1, 2, 4] as const;
@@ -94,6 +101,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
placeTool={props.placeTool}
compact={fit !== 'full'}
/>
</div>
@@ -106,7 +114,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
style={{ ...GLASS, ...fs(12, 'body') }}
>
<PickHint editing={!!props.waypoints.onAdd} />
<PickHint editing={!!props.waypoints.onAdd} placeOff={props.placeTool?.active === false} />
</div>
)}
<div className="flex justify-end">{!legendAbove && legend}</div>
@@ -117,8 +125,10 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
);
}
function PickHint({ editing }: { editing: boolean }) {
/** What a click on the terrain does now: plan, nothing until the tool is on, or nothing to plan. */
function PickHint({ editing, placeOff }: { editing: boolean; placeOff: boolean }) {
const { t } = useTranslation();
if (placeOff) return <>{t('tours.terrain.placeOff')}</>;
return <>{t(editing ? 'tours.terrain.clickToPlan' : 'tours.terrain.pickTour')}</>;
}
@@ -1,10 +1,12 @@
import type { Marker as MapLibreMarker } from 'maplibre-gl';
import { useEffect, useRef, type RefObject } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from '../../Map/mapHoverLabel';
import { waypointPinElement } from './terrainLayers';
import type { TerrainGl } from './useTerrainMap';
export interface TerrainWaypoints {
points: Array<{ id: string; lat: number; lng: number }>;
/** `name` and `detail` (its role) show on hover. */
points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
selectedId: string | null;
onSelect: (id: string) => void;
/** Absent while the tour cannot be edited; the pins then stay put. */
@@ -30,8 +32,13 @@ export function useTerrainPins(
useEffect(() => {
if (!gl || !ready) return;
pinsRef.current.forEach((pin) => pin.remove());
const hoverLabel = mapHoverLabel(gl.map, 'planner-waypoint-label');
const unbind: (() => void)[] = [];
pinsRef.current = points.map((point, index) => {
const el = waypointPinElement(index, point.id === selectedId, editable);
const name = point.name ?? String(index + 1);
el.setAttribute('aria-label', name);
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
el.addEventListener('click', (event) => {
event.stopPropagation();
handlers.current.onSelect(point.id);
@@ -43,8 +50,13 @@ export function useTerrainPins(
const at = pin.getLngLat();
if (!handlers.current.onMove?.(point.id, at.lat, at.lng)) pin.setLngLat([point.lng, point.lat]);
});
pin.on('dragstart', () => hoverLabel.hide());
return pin;
});
return () => {
unbind.forEach((off) => off());
hoverLabel.remove();
};
}, [gl, ready, points, selectedId, editable, handlers]);
// The pins go with the map, not with each redraw.
+56 -46
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler } from 'lucide-react'
import { AlertTriangle, ArrowDown, ArrowUp, ChevronDown, Clock, Mountain, Plus, Ruler, type LucideIcon } from 'lucide-react'
import type { Day, DistanceUnit } from '../../types'
import type { RouteGeometryAnalysis } from '../../utils/routeGeometry'
import { useTranslation } from '../../i18n'
@@ -13,7 +13,7 @@ import { Tooltip } from '../shared/Tooltip'
import { useTripStore } from '../../store/tripStore'
import { BOX, Eyebrow } from '../Planner/bookings/bookingParts'
import { SoftPill } from '../Planner/planParts'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes } from './tourPresentation'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource } from './tourPresentation'
type Translate = (key: string, params?: Record<string, unknown>) => string
@@ -26,62 +26,72 @@ export function TourTile({ size = 34, color, raised = false }: { size?: number;
)
}
/** The T-scale badge; a GPX import carries no difficulty, so it gets none. */
function TourDifficultyPill({ level, t }: { level: TourListItem['max_hiking_difficulty']; t: Translate }) {
if (level == null) return null
const label = t(`tours.planner.difficulty.t${level}`)
/**
* A tour's tile in a list: its T-scale grade in place of the mountain, named in
* full by its tooltip (a GPX import carries no grade and keeps the mountain),
* and where it came from, when that was not TREK, as a small badge over its foot.
*/
export function TourGradeTile({ tour }: { tour: Pick<TourListItem, 'max_hiking_difficulty' | 'wanderer_ref' | 'has_waypoints'> }) {
const { t } = useTranslation()
const level = tour.max_hiking_difficulty
const source = tourSource(tour)
const label = level == null ? null : t(`tours.planner.difficulty.t${level}`)
return (
<Tooltip label={label}>
<span aria-label={label} className="inline-flex">
<SoftPill>T{level}</SoftPill>
</span>
</Tooltip>
<span className="relative flex-none">
<Tooltip label={label ?? ''} disabled={label == null}>
<span aria-label={label ?? undefined} className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content" style={fs(12, 'body')}>
{level == null ? <Mountain size={15} strokeWidth={1.9} className="text-content-secondary" /> : `T${level}`}
</span>
</Tooltip>
{source !== 'trek' && (
<span className="absolute inset-x-0 -bottom-1.5 flex justify-center">
<Tooltip label={hikeSourceBadgeLabel(tour, t)}>
<span data-testid="tour-source-badge" aria-label={hikeSourceBadgeLabel(tour, t)}
className="rounded-full bg-surface-card px-1 font-geist font-semibold uppercase leading-[14px] tracking-[.04em] text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint"
style={fs(8.5)}>
{t(`tours.badgeShort.${source}`)}
</span>
</Tooltip>
</span>
)}
</span>
)
}
/** The facts of a tour as white pills: how far, how much up and down, how hard, where it came from. */
/** One measured fact of a tour as a white pill: its icon, an optional short word and the value, named in full for a screen reader. */
function TourMetricPill({ icon: Icon, label, value, prefix }: { icon: LucideIcon; label: string; value: string; prefix?: string }) {
return (
<SoftPill className="tabular-nums" icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${label}: ${value}`}>{prefix ? `${prefix} ${value}` : value}</span>
</SoftPill>
)
}
/** The facts of a tour as white pills, kept to one short row: how far, up and down, how long, how hard, and where it came from when that was not TREK. */
export function TourFactPills({ tour, className = '' }: { tour: TourListItem; className?: string }) {
const { t } = useTranslation()
const unit = useSettingsStore(state => state.settings.distance_unit)
const times = tourPlannedTimes(tour)
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes
const timeParts = [
times.walkingMinutes != null ? `${t('tours.planner.walkingShort')} ${formatPlannedTourDuration(times.walkingMinutes)}` : null,
times.breakMinutes != null ? `${t('tours.planner.breaksShort')} ${formatPlannedTourDuration(times.breakMinutes)}` : null,
times.plannedTotalMinutes != null
? `${t('tours.planner.plannedShort')} ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
: null,
].filter((part): part is string => part !== null)
return (
<div data-testid="tour-metrics" className={`flex min-w-0 flex-wrap items-center gap-1 ${className}`}>
<SoftPill className="tabular-nums" icon={<Ruler size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance ?? 0, unit)}`}>{formatDistance(tour.distance ?? 0, unit)}</span>
</SoftPill>
{tour.elevation_gain != null && (
<SoftPill className="tabular-nums" icon={<ArrowUp size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}>{formatElevation(tour.elevation_gain, unit)}</span>
</SoftPill>
)}
{tour.elevation_loss != null && (
<SoftPill className="tabular-nums" icon={<ArrowDown size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.detail.descent')}: ${formatElevation(tour.elevation_loss, unit)}`}>{formatElevation(tour.elevation_loss, unit)}</span>
</SoftPill>
)}
{times.walkingMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`}>
{t('tours.planner.walkingShort')} {formatPlannedTourDuration(times.walkingMinutes)}
<TourMetricPill icon={Ruler} label={t('tours.detail.distance')} value={formatDistance(tour.distance ?? 0, unit)} />
{tour.elevation_gain != null && <TourMetricPill icon={ArrowUp} label={t('tours.detail.ascent')} value={formatElevation(tour.elevation_gain, unit)} />}
{tour.elevation_loss != null && <TourMetricPill icon={ArrowDown} label={t('tours.detail.descent')} value={formatElevation(tour.elevation_loss, unit)} />}
{shownMinutes != null && (
<Tooltip label={timeParts.join(', ')}>
<span className="inline-flex">
<TourMetricPill icon={Clock} label={timeParts.join(', ')} value={formatPlannedTourDuration(shownMinutes)} />
</span>
</SoftPill>
</Tooltip>
)}
{times.breakMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`}>
{t('tours.planner.breaksShort')} {formatPlannedTourDuration(times.breakMinutes)}
</span>
</SoftPill>
)}
{times.plannedTotalMinutes != null && (
<SoftPill className="tabular-nums" icon={<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedShort')} {formatPlannedTourDuration(times.plannedTotalMinutes)}{times.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</SoftPill>
)}
<TourDifficultyPill level={tour.max_hiking_difficulty} t={t} />
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
<span aria-label={t('tours.caution.badge')} className="inline-flex">
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { within } from '@testing-library/react'
import en from '@trek/shared/i18n/en'
import de from '@trek/shared/i18n/de'
import fr from '@trek/shared/i18n/fr'
@@ -68,7 +67,7 @@ describe('Tours English and German copy', () => {
expect(en[`tours.planner.difficulty.t${level}`]).toMatch(new RegExp(`^T${level} `))
expect(de[`tours.planner.difficulty.t${level}`]).toMatch(new RegExp(`^T${level} `))
}
expect(de['tours.planner.safetyNoteDetails']).toMatch(/OSM.*Wetter.*Sperrungen/)
expect(de['tours.planner.safetyNote']).toMatch(/Sicherheit/)
expect(de['tours.planner.difficulty.t3Warning']).toMatch(/Trittsicherheit/)
expect(de['tours.planner.difficulty.alpineBody']).toMatch(/OSM/)
expect(toursText(en, 'tours.planner.breaksAdditionalInfo')).toBe('Optional whole minutes added to calculated walking time.')
@@ -94,10 +93,11 @@ describe('Tours English and German copy', () => {
})} />)
expect(screen.getByRole('heading', { name: newTitle })).toBeInTheDocument()
const strings = language === 'de' ? de : en
expect(screen.getByText(toursText(strings, 'tours.planner.restored'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.restoredShort'))).toBeInTheDocument()
expect(screen.getByLabelText(toursText(strings, 'tours.planner.restored'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.status.routing'))).toBeInTheDocument()
expect(screen.getByText(toursText(strings, 'tours.planner.difficulty.t3Warning'))).toBeInTheDocument()
expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNoteDetails')) === true)).toBeInTheDocument()
expect(screen.getByText((_, element) => element?.tagName === 'SPAN' && element.textContent?.includes(toursText(strings, 'tours.planner.safetyNote')) === true)).toBeInTheDocument()
expect(screen.getByLabelText(language === 'de' ? 'Wegpunkt 1' : 'Waypoint 1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: language === 'de' ? 'Maximale Wegschwierigkeit' : 'Maximum trail difficulty' })).toBeInTheDocument()
assertNoRawToursKeys()
@@ -125,9 +125,7 @@ describe('Tours English and German copy', () => {
await waitFor(() => expect(screen.getByLabelText(toursText(de, 'tours.planner.difficulty.t2'))).toBeInTheDocument())
const onAssignToDay = vi.fn()
rerender(<TourPlannerToursRail planner={planner({ saveOutcome: tour, mode: { type: 'edit-saved', placeId: 42 } })} tours={[tour]} days={[{ id: 7, title: 'Gipfeltag', date: '2026-05-15' } as never]} loading={false} onAssignToDay={onAssignToDay} onViewGpxTour={vi.fn()} />)
const saveBanner = screen.getByText(toursText(de, 'tours.planner.saved')).closest('[role="status"]') as HTMLElement
expect(within(saveBanner).getByRole('button', { name: toursText(de, 'tours.planner.planAnother') })).toBeInTheDocument()
expect(within(saveBanner).queryByRole('button', { name: toursText(de, 'tours.planner.assignToDay') })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: toursText(de, 'tours.planner.assignToDay') })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: `${toursText(de, 'tours.addToDay')}: ${tour.name}` }))
const dayOption = screen.getByRole('button', { name: /Gipfeltag/ })
expect(dayOption).toHaveTextContent(/Tag 1/)
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, renderHook, screen, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
@@ -140,9 +140,7 @@ describe('Tours permissions without database setup', () => {
} as unknown as TourPlannerController
const assign = vi.fn()
render(<><TourPlannerRail planner={planner} canEdit={canEdit} canAssign={canAssign} /><TourPlannerToursRail planner={planner} tours={[tour]} days={days} loading={false} onAssignToDay={assign} onViewGpxTour={vi.fn()} canEdit={canEdit} canAssign={canAssign} /></>)
const saveBanner = screen.getByText('tours.planner.saved').closest('[role="status"]') as HTMLElement
expect(within(saveBanner).getByRole('button', { name: 'tours.planner.planAnother' })).toBeInTheDocument()
expect(within(saveBanner).queryByRole('button', { name: 'tours.planner.assignToDay' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'tours.planner.assignToDay' })).not.toBeInTheDocument()
const save = screen.getByRole('button', { name: 'common.save' }) as HTMLButtonElement
expect(save.disabled).toBe(!canEdit)
fireEvent.click(save)
@@ -80,9 +80,7 @@ describe('CustomSelect consumer contracts', () => {
planner={planner({ saveOutcome: tour, editingPlaceId: 42 })}
tours={[tour]} days={days} loading={false} onAssignToDay={onAssignToDay} onViewGpxTour={vi.fn()}
/></TranslationProvider>)
const saveBanner = screen.getByText('Tour saved').closest('[role="status"]') as HTMLElement
expect(within(saveBanner).getByRole('button', { name: 'Plan another' })).toBeInTheDocument()
expect(within(saveBanner).queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Assign to a day' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add to day: Ridge walk' }))
const dayOption = screen.getByRole('button', { name: /Summit day/ })
expect(dayOption).toHaveTextContent(/Day 1/)
@@ -60,7 +60,7 @@ describe('ElevationProfile', () => {
expect(onFocus).toHaveBeenLastCalledWith({ distanceMeters: 150, elevationMeters: 187.5, lat: 48.015, lng: 11.015, sampleIndex: 1 })
expect(profile.querySelector('[data-profile-focus-line]')).toBeInTheDocument()
expect(profile.querySelector('[data-profile-focus-point]')).toBeInTheDocument()
expect(profile.parentElement?.querySelector('[data-profile-focus-point]')).toBeInTheDocument()
const readout = profile.parentElement?.querySelector('[data-profile-focus-readout]')
expect(readout?.textContent).toBe('150 m · 187.5 m')
expect(readout).toHaveClass('rounded-lg', 'border-edge-faint', 'bg-surface-card', 'text-content', 'shadow-elevated')
@@ -68,7 +68,7 @@ describe('ElevationProfile', () => {
expect(readout).toHaveStyle({ maxWidth: 'min(164px, calc(100% - 8px))' })
expect(profile).toHaveStyle({ fontFamily: 'var(--font-system)' })
expect(profile.querySelector('[data-profile-focus-line]')).toHaveAttribute('stroke', 'var(--text-muted)')
expect(profile.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)')
expect(profile.parentElement?.querySelector('[data-profile-focus-point]')).toHaveAttribute('stroke', 'var(--bg-card)')
expect(profile.parentElement?.querySelector('[aria-live="polite"]')?.textContent).toBe('')
fireEvent.pointerMove(profile, { clientX: -50, pointerType: 'mouse' })
act(() => frames.shift()?.(32))
@@ -122,10 +122,15 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
{focusX != null && (
<g pointerEvents="none" aria-hidden="true">
<line data-profile-focus-line="true" x1={focusX} x2={focusX} y1={4} y2={height - 4} stroke="var(--text-muted)" strokeOpacity="0.85" strokeWidth={1} strokeDasharray="3 3" vectorEffect="non-scaling-stroke" />
{focusY != null && <circle data-profile-focus-point="true" cx={focusX} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />}
</g>
)}
</svg>
{/* The point in an unscaled layer of its own: the chart above stretches to the width, which would squash a circle drawn in it into an ellipse. */}
{focusX != null && focusY != null && (
<svg width="100%" height={height} aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 overflow-visible">
<circle data-profile-focus-point="true" cx={`${(focusX / width) * 100}%`} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} />
</svg>
)}
{interactive && focusX != null && (
<div
data-profile-focus-readout="true"
@@ -331,7 +331,7 @@ describe('TripPlannerPage — shell', () => {
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77)
})
it('preserves the visible TOUR-PLANNER camera when Plan another starts a new Tour', async () => {
it('preserves the visible TOUR-PLANNER camera when a new Tour starts after a save', async () => {
const savedTour = {
place_id: 82,
name: 'Saved Kyoto tour',
@@ -363,7 +363,7 @@ describe('TripPlannerPage — shell', () => {
const mapElement = screen.getByTestId('map-view')
expect(props('map').focusPoints).toEqual([[35.01, 135.76], [35.02, 135.77]])
fireEvent.click(await screen.findByRole('button', { name: 'Plan another' }))
act(() => { plannerActions.startNewTour() })
expect(plannerActions.startNewTour).toHaveBeenCalledOnce()
view.rerender(<TripPlannerPage />)
@@ -1,4 +1,4 @@
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-012
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016
import type { Ferrata } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
@@ -99,6 +99,8 @@ function planner(
setSelectedWaypointId: vi.fn(),
setWaypointPosition: vi.fn(() => true),
addWaypoint: vi.fn(),
undo: vi.fn(),
maxHikingDifficulty: null,
routeAnalysis: null,
readOnlyGpxAnalysis: null,
mapBaseLayer: 'default',
@@ -109,8 +111,8 @@ function planner(
} as unknown as Planner;
}
function renderStage(tourPlanner: Planner, canPlaceEdit = true) {
const props = {
function stageProps(tourPlanner: Planner, canPlaceEdit = true) {
return {
tripId: 7,
mapTileUrl: '',
leftHidden: false,
@@ -123,7 +125,24 @@ function renderStage(tourPlanner: Planner, canPlaceEdit = true) {
leftPanelPx: 320,
rightPanelPx: 380,
} as unknown as TourPlannerMapStageProps;
return render(<TourPlannerMapStage {...props} />);
}
function renderStage(tourPlanner: Planner, canPlaceEdit = true) {
return render(<TourPlannerMapStage {...stageProps(tourPlanner, canPlaceEdit)} />);
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, { clientX: { value: x }, clientY: { value: y }, pointerId: { value: 1 } });
return event;
}
/** A press on the map surface that travels `dx` pixels before it is let go. */
function press(dx: number) {
const surface = screen.getByTestId('tour-map-surface');
surface.dispatchEvent(pointer('pointerdown', 100, 100));
surface.dispatchEvent(pointer('pointermove', 100 + dx, 100));
surface.dispatchEvent(pointer('pointerup', 100 + dx, 100));
}
/** Switches the flat map to 3D and waits for the GL engine to load. */
@@ -133,6 +152,7 @@ async function openTerrain() {
}
beforeEach(() => {
window.sessionStorage.clear();
stage.listeners.clear();
stage.flatMap.props = null;
stage.search.props = null;
@@ -157,7 +177,7 @@ describe('TourPlannerMapStage', () => {
const tourPlanner = planner('new-draft');
renderStage(tourPlanner);
const props = stage.flatMap.props;
expect(props?.plannerWaypoints).toEqual([{ id: 'w1', lat: 46, lng: 7 }]);
expect(props?.plannerWaypoints).toEqual([expect.objectContaining({ id: 'w1', lat: 46, lng: 7 })]);
expect(props?.selectedPlannerWaypointId).toBe('w1');
expect(props?.onPlannerWaypointMove).toBe(tourPlanner.setWaypointPosition);
props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never);
@@ -173,11 +193,21 @@ describe('TourPlannerMapStage', () => {
expect(props?.onPlannerWaypointMove).toBeUndefined();
});
it('FE-PAGE-TOURSTAGE-003: the 3D switch opens the terrain, where a click plans the next point', async () => {
it('FE-PAGE-TOURSTAGE-003: the 3D switch opens the terrain, where a click plans once "set points" is on', async () => {
const tourPlanner = planner('edit-saved');
renderStage(tourPlanner);
await openTerrain();
expect(screen.queryByRole('button', { name: 'flat map' })).toBeNull();
// Off to begin with in 3D, where every look around is a drag.
const tool = screen.getByRole('button', { name: 'Set points' });
expect(tool).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText('Turn on Set points to click points onto the terrain.')).toBeInTheDocument();
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
fireEvent.click(tool);
expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('tour-map-surface')).toHaveAttribute('data-placing', 'true');
expect(screen.getByText(/Click the terrain to set points/)).toBeInTheDocument();
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).toHaveBeenCalledWith(46.3, 7.3);
@@ -317,4 +347,62 @@ describe('TourPlannerMapStage', () => {
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.3, lng: 7.3 } } as never));
expect(tourPlanner.addWaypoint).toHaveBeenCalledWith(46.3, 7.3);
});
it('FE-PAGE-TOURSTAGE-013: on the flat map "set points" starts on, with the crosshair, and off it takes no clicks', () => {
renderStage(planner('new-draft'));
expect(stage.flatMap.props?.placeTool?.active).toBe(true);
expect(screen.getByTestId('tour-map-surface')).toHaveAttribute('data-placing', 'true');
act(() => stage.flatMap.props?.placeTool?.onToggle());
expect(stage.flatMap.props?.placeTool?.active).toBe(false);
expect(stage.flatMap.props?.onMapClick).toBeUndefined();
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
});
it('FE-PAGE-TOURSTAGE-014: the end of a pan or a rotation never places a point, a click does, in 2D and 3D', async () => {
const tourPlanner = planner('new-draft');
renderStage(tourPlanner);
press(12);
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
expect(tourPlanner.addWaypoint).not.toHaveBeenCalled();
press(1);
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
expect(tourPlanner.addWaypoint).toHaveBeenCalledTimes(1);
await openTerrain();
fireEvent.click(screen.getByRole('button', { name: 'Set points' }));
press(30);
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).toHaveBeenCalledTimes(1);
press(0);
act(() => stage.fire('click', { lngLat: { lat: 46.3, lng: 7.3 } }));
expect(tourPlanner.addWaypoint).toHaveBeenLastCalledWith(46.3, 7.3);
});
it('FE-PAGE-TOURSTAGE-015: "point set" shows above the bottom row after a click, and its undo takes the point back', () => {
const tourPlanner = planner('new-draft');
const view = renderStage(tourPlanner);
act(() => stage.flatMap.props?.onMapClick?.({ latlng: { lat: 46.2, lng: 7.2 } } as never));
const placed = planner('new-draft', {
waypoints: [
{ id: 'w1', lat: 46, lng: 7 },
{ id: 'w2', lat: 46.2, lng: 7.2 },
],
undo: tourPlanner.undo,
});
view.rerender(<TourPlannerMapStage {...stageProps(placed)} />);
const hint = screen.getByTestId('map-undo-hint');
expect(hint).toHaveTextContent('tours.planner.pointPlaced');
// Clear of the 2D row: its 12px gap, the 42px pill, the same gap again.
expect(hint.parentElement?.parentElement?.style.bottom).toBe('66px');
fireEvent.click(screen.getByRole('button', { name: 'undo.button' }));
expect(tourPlanner.undo).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId('map-undo-hint')).toBeNull();
});
it('FE-PAGE-TOURSTAGE-016: without edit rights there is no "set points" switch and no crosshair', () => {
renderStage(planner('new-draft'), false);
expect(stage.flatMap.props?.placeTool).toBeUndefined();
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
});
});
@@ -3,10 +3,12 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
import { MapUndoHint } from '../../components/Map/MapUndoHint';
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
import LocationSelect from '../../components/Planner/LocationSelect';
import { isRtlLanguage, useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTourMapPoints } from './useTourMapPoints';
import type { useTripPlanner } from './useTripPlanner';
import type { useTripPlannerPage } from './useTripPlannerPage';
@@ -19,6 +21,9 @@ const TOUR_TRACK_COLOR = '#0a84ff';
const CONTROL_GAP = 12;
/** A via ferrata's card floats above the bottom row of controls, at the gap the row keeps to the edge. */
const FERRATA_CARD_BOTTOM = CONTROL_GAP + MAP_ROUND_CONTROL_SIZE + CONTROL_GAP;
/** The crosshair over either engine's map while a click sets a point (markers keep their own cursor). */
const PLACING_CURSOR =
'[&_.leaflet-container]:!cursor-crosshair [&_.maplibregl-canvas-container]:!cursor-crosshair [&_.mapboxgl-canvas-container]:!cursor-crosshair';
type Planner = ReturnType<typeof useTripPlanner>;
type PageState = ReturnType<typeof useTripPlannerPage>;
@@ -65,70 +70,80 @@ export function TourPlannerMapStage({
const { language } = useTranslation();
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis;
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing;
const waypoints = {
points: editing ? tourPlanner.waypoints : [],
selectedId: editing ? tourPlanner.selectedWaypointId : null,
onSelect: tourPlanner.setSelectedWaypointId,
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
onAdd: closingPress.guard(canEditRoute ? tourPlanner.addWaypoint : undefined),
};
// The "set points" tool, per mode; only a real click places, never the end of a drag.
const { waypoints, placeTool, placeOn, attachSurface, placedHint } = useTourMapPoints({
tourPlanner,
canPlaceEdit,
mode: terrain3d ? '3d' : '2d',
ferrataOpen: !!ferrata.selected,
guard: closingPress.guard,
});
const onAdd = waypoints.onAdd;
// What each panel covers of the map, a collapsed one its rail.
const leftInset = leftHidden ? 56 : leftPanelPx;
const rightInset = rightHidden ? 56 : rightPanelPx;
const bottomRowTop = terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM;
return (
<>
{terrain3d ? (
<Suspense fallback={null}>
<TourTerrainMap
samples={analysis?.distanceIndexedProfileSamples ?? []}
routes={tourMap.route}
trackColor={TOUR_TRACK_COLOR}
unit={distanceUnit}
baseLayer={tourPlanner.mapBaseLayer}
onBaseLayerChange={tourPlanner.setMapBaseLayer}
onExit={() => setTerrain3d(false)}
focus={tourPlanner.routeProfileFocus}
onFocusChange={tourPlanner.setRouteProfileFocus}
leftWidth={leftInset}
rightWidth={rightInset}
waypoints={waypoints}
framePoints={tourMap.focusPoints}
{/* Around either map, to follow its presses and show the crosshair while placing. */}
<div
ref={attachSurface}
className={`absolute inset-0 ${placeOn ? PLACING_CURSOR : ''}`}
data-testid="tour-map-surface"
data-placing={placeOn || undefined}
>
{terrain3d ? (
<Suspense fallback={null}>
<TourTerrainMap
samples={analysis?.distanceIndexedProfileSamples ?? []}
routes={tourMap.route}
trackColor={TOUR_TRACK_COLOR}
unit={distanceUnit}
baseLayer={tourPlanner.mapBaseLayer}
onBaseLayerChange={tourPlanner.setMapBaseLayer}
onExit={() => setTerrain3d(false)}
focus={tourPlanner.routeProfileFocus}
onFocusChange={tourPlanner.setRouteProfileFocus}
leftWidth={leftInset}
rightWidth={rightInset}
waypoints={waypoints}
framePoints={tourMap.focusPoints}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onControlsHeightChange={setTerrainControlsPx}
placeTool={placeTool}
/>
</Suspense>
) : (
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={
onAdd
? ({ latlng }: { latlng: { lat: number; lng: number } }) => onAdd(latlng.lat, latlng.lng)
: undefined
}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
plannerWaypoints={waypoints.points}
selectedPlannerWaypointId={waypoints.selectedId}
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
onPlannerWaypointMove={waypoints.onMove}
routeProfileFocus={tourPlanner.routeProfileFocus}
viewBaseLayer={tourPlanner.mapBaseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
onToggleTerrain3d={() => setTerrain3d(true)}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
onControlsHeightChange={setTerrainControlsPx}
placeTool={placeTool}
/>
</Suspense>
) : (
<MapView
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={closingPress.guard(
canEditRoute
? ({ latlng }: { latlng: { lat: number; lng: number } }) =>
tourPlanner.addWaypoint(latlng.lat, latlng.lng)
: undefined
)}
tileUrl={mapTileUrl}
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
plannerWaypoints={waypoints.points}
selectedPlannerWaypointId={waypoints.selectedId}
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
onPlannerWaypointMove={waypoints.onMove}
routeProfileFocus={tourPlanner.routeProfileFocus}
viewBaseLayer={tourPlanner.mapBaseLayer}
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
onToggleTerrain3d={() => setTerrain3d(true)}
ferrataLayer={ferrata.layer}
onViewportChange={ferrata.onViewportChange}
/>
)}
)}
</div>
{/* The card sits at the inline end; the panels keep their physical sides, so in a
right-to-left language that end is the left panel's. */}
<FerrataDetailOverlay
@@ -136,8 +151,31 @@ export function TourPlannerMapStage({
onClose={ferrata.close}
onOutsidePress={closingPress.arm}
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
bottom={terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM}
bottom={bottomRowTop}
/>
{/* "Point set", with the way back, above the bottom row and between the panels. */}
{placedHint.shown && (
<div
className="pointer-events-none absolute inset-x-0 z-[19] flex justify-center"
style={{ bottom: bottomRowTop }}
>
<div
className="flex justify-center"
style={{
transform: `translateX(${(leftInset - rightInset) / 2}px)`,
maxWidth: `calc(100% - ${leftInset + rightInset + 2 * MAP_LAYER_SWITCHER_INSET}px)`,
}}
>
<MapUndoHint
key={placedHint.key}
message={t('tours.planner.pointPlaced')}
undoLabel={t('undo.button')}
onUndo={placedHint.undoPlacement}
onHold={placedHint.hold}
/>
</div>
</div>
)}
{/* Centred over the map between the two panels, which sit on fixed physical sides. */}
<div
style={{
@@ -0,0 +1,95 @@
// FE-HOOK-PLACEDHINT-001 to FE-HOOK-PLACEDHINT-006: "point set" after a click on the map, and its undo.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PLACED_HINT_MS, usePlacedPointHint } from './usePlacedPointHint';
interface Props {
waypoints: readonly unknown[];
difficulty: unknown;
enabled: boolean;
}
const A = { id: 'a' };
const B = { id: 'b' };
function setup(initial: Partial<Props> = {}) {
const undo = vi.fn();
const view = renderHook((props: Props) => usePlacedPointHint({ ...props, undo }), {
initialProps: { waypoints: [A], difficulty: 'T2', enabled: true, ...initial },
});
/** Notes the click and lands the add in the same update, the way the map does. */
const place = (after: readonly unknown[], rest: Partial<Props> = {}) => {
act(() => view.result.current.notePlacement());
view.rerender({ waypoints: after, difficulty: 'T2', enabled: true, ...rest });
};
return { ...view, undo, place };
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('usePlacedPointHint', () => {
it('FE-HOOK-PLACEDHINT-001: shows once the point is in, and its undo takes back exactly that add', () => {
const { result, undo, place } = setup();
expect(result.current.shown).toBe(false);
place([A, B]);
expect(result.current.shown).toBe(true);
act(() => result.current.undoPlacement());
expect(undo).toHaveBeenCalledTimes(1);
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-002: goes by itself after a few seconds, but not while the pointer rests on it', () => {
const { result, place } = setup();
place([A, B]);
act(() => result.current.hold(true));
act(() => vi.advanceTimersByTime(PLACED_HINT_MS * 2));
expect(result.current.shown).toBe(true);
act(() => result.current.hold(false));
act(() => vi.advanceTimersByTime(PLACED_HINT_MS - 1));
expect(result.current.shown).toBe(true);
act(() => vi.advanceTimersByTime(1));
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-003: any later change to the points ends it, so undo never reverts something else', () => {
const { result, rerender, undo, place } = setup();
place([A, B]);
rerender({ waypoints: [A, { id: 'b', moved: true }], difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(false);
act(() => result.current.undoPlacement());
expect(undo).not.toHaveBeenCalled();
});
it('FE-HOOK-PLACEDHINT-004: so does a new difficulty, which is in the same undo history', () => {
const { result, rerender, place } = setup();
const after = [A, B];
place(after);
rerender({ waypoints: after, difficulty: 'T4', enabled: true });
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-005: a refused add shows nothing, not even at the next change', () => {
const { result, rerender } = setup();
act(() => result.current.notePlacement());
rerender({ waypoints: [A], difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(false);
rerender({ waypoints: [{ id: 'a', moved: true }], difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(false);
});
it('FE-HOOK-PLACEDHINT-006: stays hidden while it must give way, and shows again after', () => {
const { result, rerender, place } = setup();
const after = [A, B];
place(after);
rerender({ waypoints: after, difficulty: 'T2', enabled: false });
expect(result.current.shown).toBe(false);
rerender({ waypoints: after, difficulty: 'T2', enabled: true });
expect(result.current.shown).toBe(true);
});
});
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/** How long the hint stays up after a point was set, unless the pointer rests on it. */
export const PLACED_HINT_MS = 4000;
interface Hint {
id: number;
/** The points before the click, to spot the list the new point produced. */
before: readonly unknown[];
/** The list right after the point went in; any other list means something came after it. */
after: readonly unknown[] | null;
difficulty: unknown;
}
interface Options {
waypoints: readonly unknown[];
/** Part of the editor's undo history too: a change to it after the point makes undo mean that. */
difficulty: unknown;
undo: () => void;
/** False while the hint must not show (not editing, a ferrata card open). */
enabled: boolean;
}
/**
* The "point set" hint after a click on the map, with an undo that takes back
* exactly that point. It goes as soon as anything else changes the tour, so
* the editor's undo, which reverts the latest change, always means the point.
*/
export function usePlacedPointHint({ waypoints, difficulty, undo, enabled }: Options) {
const [hint, setHint] = useState<Hint | null>(null);
const [held, setHeld] = useState(false);
const count = useRef(0);
// Adjusted while rendering: the add lands in the same batch as the click that noted it.
// A list that did not grow by one was no add of this click's point (it was refused).
if (hint && hint.after === null && waypoints !== hint.before) {
setHint(waypoints.length === hint.before.length + 1 ? { ...hint, after: waypoints } : null);
} else if (hint?.after && (waypoints !== hint.after || difficulty !== hint.difficulty)) {
setHint(null);
}
const shown = enabled && !!hint?.after && hint.after === waypoints && hint.difficulty === difficulty;
const hintId = shown ? hint.id : null;
useEffect(() => {
if (hintId === null || held) return;
const timer = window.setTimeout(() => setHint(null), PLACED_HINT_MS);
return () => window.clearTimeout(timer);
}, [hintId, held]);
/** Called right before the point is added. */
const notePlacement = useCallback(() => {
count.current += 1;
setHeld(false);
setHint({ id: count.current, before: waypoints, after: null, difficulty });
}, [waypoints, difficulty]);
const undoPlacement = useCallback(() => {
if (!shown) return;
setHint(null);
undo();
}, [shown, undo]);
return { shown, key: hintId, notePlacement, undoPlacement, hold: setHeld };
}
@@ -0,0 +1,101 @@
// FE-HOOK-TOURPLACING-001 to FE-HOOK-TOURPLACING-004: the tour map's "set points" tool and its click gate.
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing';
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(event, { clientX: { value: x }, clientY: { value: y }, pointerId: { value: 1 } });
return event;
}
beforeEach(() => {
window.sessionStorage.clear();
useTourPlaceToolStore.setState(initialTourPlaceToolState());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useTourMapPlacing', () => {
it('FE-HOOK-TOURPLACING-001: on in 2D and off in 3D to begin with', () => {
const { result, rerender } = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '2d' },
});
expect(result.current.active).toBe(true);
rerender({ mode: '3d' });
expect(result.current.active).toBe(false);
});
it('FE-HOOK-TOURPLACING-002: each mode keeps its own choice, for the rest of the session', () => {
const { result, rerender, unmount } = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '3d' },
});
act(() => result.current.toggle());
expect(result.current.active).toBe(true);
rerender({ mode: '2d' });
act(() => result.current.toggle());
expect(result.current.active).toBe(false);
unmount();
const again = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '3d' },
});
expect(again.result.current.active).toBe(true);
again.rerender({ mode: '2d' });
expect(again.result.current.active).toBe(false);
});
it('FE-HOOK-TOURPLACING-003: the gate lets a click through and drops the end of a drag', () => {
const el = document.createElement('div');
let clock = 0;
vi.spyOn(performance, 'now').mockImplementation(() => clock);
const { result } = renderHook(() => useTourMapPlacing('2d'));
act(() => result.current.attach(el));
const handler = vi.fn();
const gated = result.current.gate(handler);
el.dispatchEvent(pointer('pointerdown', 10, 10));
clock = 90;
el.dispatchEvent(pointer('pointerup', 11, 10));
gated?.(1, 2);
expect(handler).toHaveBeenCalledWith(1, 2);
el.dispatchEvent(pointer('pointerdown', 10, 10));
el.dispatchEvent(pointer('pointermove', 30, 10));
clock = 150;
el.dispatchEvent(pointer('pointerup', 30, 10));
gated?.(3, 4);
expect(handler).toHaveBeenCalledTimes(1);
// A hold that never moved, longer than a click.
clock = 1000;
el.dispatchEvent(pointer('pointerdown', 10, 10));
clock = 1600;
el.dispatchEvent(pointer('pointerup', 10, 10));
gated?.(5, 6);
expect(handler).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-TOURPLACING-004: no handler, no gate; without storage the choice holds while the stage is up', () => {
const { result } = renderHook(() => useTourMapPlacing('2d'));
expect(result.current.gate(undefined)).toBeUndefined();
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('blocked');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked');
});
const blocked = renderHook(({ mode }: { mode: TourMapMode }) => useTourMapPlacing(mode), {
initialProps: { mode: '3d' },
});
expect(blocked.result.current.active).toBe(false);
act(() => blocked.result.current.toggle());
blocked.rerender({ mode: '2d' });
blocked.rerender({ mode: '3d' });
expect(blocked.result.current.active).toBe(true);
});
});
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPressTracker, isPlacementTap, type PressTracker } from '../../components/Map/mapPress';
import {
setTourPlaceToolMode,
toggleTourPlacing,
useTourPlaceToolStore,
type TourMapMode,
} from '../../store/tourPlaceToolStore';
export type { TourMapMode } from '../../store/tourPlaceToolStore';
/**
* The tour map's "set points" tool: whether a click on the map places a point
* in the current mode, remembered per mode for the session, and the gate that
* lets only a real click through, never the end of a pan, a rotation or a tilt.
* `attach` goes on the element around the map, where the presses are followed.
*/
export function useTourMapPlacing(mode: TourMapMode) {
const active = useTourPlaceToolStore((state) => state.placing[mode]);
const [element, attach] = useState<HTMLElement | null>(null);
const tracker = useRef<PressTracker | null>(null);
useEffect(() => {
if (!element) return;
const next = createPressTracker(element);
tracker.current = next;
return () => {
next.dispose();
if (tracker.current === next) tracker.current = null;
};
}, [element]);
// The editor's "add point" row switches the tool for whichever mode the map shows.
useEffect(() => {
setTourPlaceToolMode(mode);
}, [mode]);
/** Lets a handler run only for a click that was a click, not a drag or a long hold. */
const gate = useCallback(<A extends unknown[]>(handler: ((...args: A) => void) | undefined) => {
if (!handler) return undefined;
return (...args: A) => {
if (!isPlacementTap(tracker.current?.take() ?? null)) return;
handler(...args);
};
}, []);
return { active, toggle: toggleTourPlacing, gate, attach };
}
@@ -0,0 +1,86 @@
import type { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
import type { TourPlannerController } from '../../components/Tours/planner/useTourPlanner';
import { useWaypointPlaceNames } from '../../components/Tours/planner/useWaypointPlaceNames';
import { useTranslation } from '../../i18n';
import { usePlacedPointHint } from './usePlacedPointHint';
import { useTourMapPlacing, type TourMapMode } from './useTourMapPlacing';
const ROLE_KEY = {
start: 'tours.planner.start',
via: 'tours.planner.via',
end: 'tours.planner.end',
} as const;
type PointsPlanner = Pick<
TourPlannerController,
| 'mode'
| 'isSaving'
| 'waypoints'
| 'selectedWaypointId'
| 'setSelectedWaypointId'
| 'setWaypointPosition'
| 'addWaypoint'
| 'undo'
| 'maxHikingDifficulty'
>;
interface Options {
tourPlanner: PointsPlanner;
canPlaceEdit: boolean;
mode: TourMapMode;
/** A via ferrata card is open: the "point set" hint gives way to it. */
ferrataOpen: boolean;
/** Drops the click that closed a ferrata card. */
guard: ReturnType<typeof useOutsidePressGuard>['guard'];
}
/**
* The tour's control points on the planner map, for either mode: what the map
* shows and may change, the "set points" tool, and the hint with its undo
* after a point went in. A click places a point only while the tool is on,
* only when it was a click and not a drag, and never when it closed a card.
*/
export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard }: Options) {
const { t } = useTranslation();
const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved';
// The same lookup as the waypoint list, so a pin is named on hover as its row is.
const placeNames = useWaypointPlaceNames(editing ? tourPlanner.waypoints : []);
const canEditRoute = canPlaceEdit && !tourPlanner.isSaving && editing;
const { active, toggle, gate, attach } = useTourMapPlacing(mode);
const placeOn = canEditRoute && active;
const placedHint = usePlacedPointHint({
waypoints: tourPlanner.waypoints,
difficulty: tourPlanner.maxHikingDifficulty,
undo: tourPlanner.undo,
enabled: editing && !ferrataOpen,
});
const { notePlacement } = placedHint;
const { addWaypoint } = tourPlanner;
const addPoint = (lat: number, lng: number) => {
notePlacement();
addWaypoint(lat, lng);
};
const onAdd = guard(gate(placeOn ? addPoint : undefined));
return {
waypoints: {
points: editing
? tourPlanner.waypoints.map((point, index) => ({
...point,
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
detail: t(ROLE_KEY[point.role]),
}))
: [],
selectedId: editing ? tourPlanner.selectedWaypointId : null,
onSelect: tourPlanner.setSelectedWaypointId,
onMove: canEditRoute ? tourPlanner.setWaypointPosition : undefined,
onAdd,
},
/** The switch for the layer pill, there only while the tour can be edited. */
placeTool: canEditRoute ? { active, onToggle: toggle } : undefined,
placeOn,
/** Goes on the element around the map, where the presses are followed. */
attachSurface: attach,
placedHint,
};
}
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reverseGeocodeRepo } from './reverseGeocodeRepo';
import { isEffectivelyOffline } from '../sync/networkMode';
import { mapsApi } from '../api/client';
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
vi.mock('../api/client', () => ({ mapsApi: { reverse: vi.fn() } }));
beforeEach(() => {
vi.clearAllMocks();
reverseGeocodeRepo.clear();
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
vi.mocked(mapsApi.reverse).mockResolvedValue({ name: 'Zugspitze', address: 'Zugspitze, Grainau, Bayern' });
});
describe('reverse geocoded place names', () => {
it('FE-REVERSE-GEOCODE-001: asks the maps route with the coordinate and the place language', async () => {
await expect(reverseGeocodeRepo.placeName(47.42123, 10.98456, 'de')).resolves.toBe('Zugspitze');
expect(mapsApi.reverse).toHaveBeenCalledWith(47.42123, 10.98456, 'de');
});
it('FE-REVERSE-GEOCODE-002: the same point twice is one request, another language or point is another', async () => {
const first = reverseGeocodeRepo.placeName(47.4, 11.1, 'de');
const again = reverseGeocodeRepo.placeName(47.400001, 11.100001, 'de');
expect(await first).toBe(await again);
expect(mapsApi.reverse).toHaveBeenCalledTimes(1);
await reverseGeocodeRepo.placeName(47.4, 11.1, 'en');
await reverseGeocodeRepo.placeName(47.41, 11.1, 'de');
expect(mapsApi.reverse).toHaveBeenCalledTimes(3);
});
it('FE-REVERSE-GEOCODE-003: falls back to the first part of the address and to null', async () => {
vi.mocked(mapsApi.reverse).mockResolvedValueOnce({ name: null, address: 'Höllentalanger, Grainau' });
await expect(reverseGeocodeRepo.placeName(47.1, 11, 'de')).resolves.toBe('Höllentalanger');
vi.mocked(mapsApi.reverse).mockResolvedValueOnce({ name: ' ', address: null });
await expect(reverseGeocodeRepo.placeName(47.2, 11, 'de')).resolves.toBeNull();
});
it('FE-REVERSE-GEOCODE-004: a failed lookup is not cached, so the point is asked again', async () => {
vi.mocked(mapsApi.reverse).mockRejectedValueOnce(new Error('boom'));
await expect(reverseGeocodeRepo.placeName(47.3, 11, 'de')).rejects.toThrow('boom');
await expect(reverseGeocodeRepo.placeName(47.3, 11, 'de')).resolves.toBe('Zugspitze');
expect(mapsApi.reverse).toHaveBeenCalledTimes(2);
});
it('FE-REVERSE-GEOCODE-005: offline never reaches the network', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
await expect(reverseGeocodeRepo.placeName(47.5, 11, 'de')).rejects.toThrow('Offline');
expect(mapsApi.reverse).not.toHaveBeenCalled();
});
it('FE-REVERSE-GEOCODE-006: runs at most two lookups at once and starts the rest as they finish', async () => {
const resolvers: Array<(value: { name: string; address: null }) => void> = [];
vi.mocked(mapsApi.reverse).mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
}),
);
const names = [1, 2, 3].map((n) => reverseGeocodeRepo.placeName(46 + n, 11, 'de'));
expect(mapsApi.reverse).toHaveBeenCalledTimes(2);
resolvers[0]({ name: 'One', address: null });
await names[0];
await vi.waitFor(() => expect(mapsApi.reverse).toHaveBeenCalledTimes(3));
resolvers[1]({ name: 'Two', address: null });
resolvers[2]({ name: 'Three', address: null });
await expect(Promise.all(names)).resolves.toEqual(['One', 'Two', 'Three']);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { mapsApi } from '../api/client';
import { isEffectivelyOffline } from '../sync/networkMode';
/** How many coordinates the cache remembers before it starts over. */
const CACHE_LIMIT = 500;
/** How many lookups may be in flight at once, so a long tour does not flood the geocoder. */
const CONCURRENCY = 2;
const cache = new Map<string, Promise<string | null>>();
const queue: Array<() => void> = [];
let running = 0;
/**
* Five decimals are about a metre: a point nudged by less than that is the same
* place, and a point moved further is asked for again.
*/
function keyOf(lat: number, lng: number, lang: string): string {
return `${lat.toFixed(5)},${lng.toFixed(5)}|${lang}`;
}
function runQueued<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
running += 1;
task()
.then(resolve, reject)
.finally(() => {
running -= 1;
queue.shift()?.();
});
};
if (running < CONCURRENCY) start();
else queue.push(start);
});
}
/** The name a reverse lookup gives, or the first part of its address when it has no name. */
function placeLabel(result: { name: string | null; address: string | null }): string | null {
const name = result.name?.trim();
if (name) return name;
const first = result.address?.split(',')[0]?.trim();
return first || null;
}
export const reverseGeocodeRepo = {
/**
* A short place name for a coordinate, or null when the geocoder knows none.
* Rejects offline and on a failed lookup; a failure is not cached, so the
* same point is asked again the next time it is shown.
*/
placeName(lat: number, lng: number, lang: string): Promise<string | null> {
if (isEffectivelyOffline()) return Promise.reject(new Error('Offline'));
const key = keyOf(lat, lng, lang);
const cached = cache.get(key);
if (cached !== undefined) return cached;
if (cache.size >= CACHE_LIMIT) cache.clear();
const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then(placeLabel);
cache.set(key, value);
void value.catch(() => {
if (cache.get(key) === value) cache.delete(key);
});
return value;
},
/** Forgets every remembered name; for tests. */
clear() {
cache.clear();
},
};
+57
View File
@@ -0,0 +1,57 @@
import { create } from 'zustand';
export type TourMapMode = '2d' | '3d';
/** In 2D a click is how a tour is drawn; in 3D every look around is a drag, so placing starts off. */
const DEFAULT_PLACING: Record<TourMapMode, boolean> = { '2d': true, '3d': false };
const STORAGE_KEY = 'trek.tours.placePoints.';
function readPlacing(mode: TourMapMode): boolean {
try {
const stored = window.sessionStorage.getItem(STORAGE_KEY + mode);
if (stored === '1' || stored === '0') return stored === '1';
} catch {
// Storage blocked: the choice then lasts as long as the page.
}
return DEFAULT_PLACING[mode];
}
function writePlacing(mode: TourMapMode, on: boolean): void {
try {
window.sessionStorage.setItem(STORAGE_KEY + mode, on ? '1' : '0');
} catch {
// Storage blocked: the store still holds it while the page is up.
}
}
interface TourPlaceToolState {
/** Whether a click on the map sets a point, per mode, remembered for the session. */
placing: Record<TourMapMode, boolean>;
/** The mode the tour map shows now, so a control outside the map switches the tool for it. */
mode: TourMapMode;
}
export function initialTourPlaceToolState(): TourPlaceToolState {
return { placing: { '2d': readPlacing('2d'), '3d': readPlacing('3d') }, mode: '2d' };
}
/**
* The tour map's "set points" tool, shared by its switch in the layer pill and
* the "add point" row under the waypoints in the editor.
*/
export const useTourPlaceToolStore = create<TourPlaceToolState>(() => initialTourPlaceToolState());
export function setTourPlaceToolMode(mode: TourMapMode): void {
if (useTourPlaceToolStore.getState().mode !== mode) useTourPlaceToolStore.setState({ mode });
}
export function setTourPlacing(mode: TourMapMode, on: boolean): void {
writePlacing(mode, on);
useTourPlaceToolStore.setState((state) => ({ placing: { ...state.placing, [mode]: on } }));
}
/** Switches the tool for the mode the map shows now. */
export function toggleTourPlacing(): void {
const { mode, placing } = useTourPlaceToolStore.getState();
setTourPlacing(mode, !placing[mode]);
}