mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(tours): tidy the tour editor and tour list, set points only on purpose, name waypoints on the map
This commit is contained in:
@@ -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_:-]+)+$/;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user