diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs index a955a3275..b991a9c00 100644 --- a/client/scripts/i18n-keys.mjs +++ b/client/scripts/i18n-keys.mjs @@ -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_:-]+)+$/; diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index 4715a6851..b183cf908 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -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, diff --git a/client/src/components/Ferratas/ferrataHoverLabel.ts b/client/src/components/Ferratas/ferrataHoverLabel.ts deleted file mode 100644 index 1239e5131..000000000 --- a/client/src/components/Ferratas/ferrataHoverLabel.ts +++ /dev/null @@ -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; - }, - }; -} diff --git a/client/src/components/Ferratas/useFerrataLayerGL.ts b/client/src/components/Ferratas/useFerrataLayerGL.ts index 5668bdf0c..4cc358a00 100644 --- a/client/src/components/Ferratas/useFerrataLayerGL.ts +++ b/client/src/components/Ferratas/useFerrataLayerGL.ts @@ -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'; diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index 586f66d60..81633ba56 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -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( + {}} 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( {}} 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') + }) }) diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index fa9d961d9..2c1ae620a 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -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 ( + + + + ) +} + /** * 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({ ) })} - {(ferrataLayer || onToggleTerrain) && } + {(ferrataLayer || onToggleTerrain || placeTool) && } + {placeTool && } {ferrataLayer && } {onToggleTerrain && ( + + ); +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index d645c6451..55a66c108 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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({ ) : null} - {(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => ( + {plannerWaypoints.map((point, index) => ( } {hasViewBaseLayer ? + onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} /> : } diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 380965f6e..efc6c5ce1 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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 && } {hasViewBaseLayer ? + onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} /> : } {/* Hover tooltip — cursor-following name/category/address card, identical to diff --git a/client/src/components/Map/PlannerWaypointMarker.tsx b/client/src/components/Map/PlannerWaypointMarker.tsx index 922dd5af5..cf1ac429a 100644 --- a/client/src/components/Map/PlannerWaypointMarker.tsx +++ b/client/src/components/Map/PlannerWaypointMarker.tsx @@ -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} > - - {index + 1} + + {point.name ?? index + 1} + {point.detail && {point.detail}} ); diff --git a/client/src/components/Map/mapHoverLabel.ts b/client/src/components/Map/mapHoverLabel.ts new file mode 100644 index 000000000..bc4f6aa96 --- /dev/null +++ b/client/src/components/Map/mapHoverLabel.ts @@ -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); + }; +} diff --git a/client/src/components/Map/mapPress.test.ts b/client/src/components/Map/mapPress.test.ts new file mode 100644 index 000000000..32f6a41bb --- /dev/null +++ b/client/src/components/Map/mapPress.test.ts @@ -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(); + }); +}); diff --git a/client/src/components/Map/mapPress.ts b/client/src/components/Map/mapPress.ts new file mode 100644 index 000000000..542254e8d --- /dev/null +++ b/client/src/components/Map/mapPress.ts @@ -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; + 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); + }, + }; +} diff --git a/client/src/components/Map/mapViewContract.ts b/client/src/components/Map/mapViewContract.ts index 1b471c0e8..d13c2045d 100644 --- a/client/src/components/Map/mapViewContract.ts +++ b/client/src/components/Map/mapViewContract.ts @@ -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 diff --git a/client/src/components/Map/usePlannerWaypointMarkersGL.ts b/client/src/components/Map/usePlannerWaypointMarkersGL.ts index 741a1287e..84b91b43b 100644 --- a/client/src/components/Map/usePlannerWaypointMarkersGL.ts +++ b/client/src/components/Map/usePlannerWaypointMarkersGL.ts @@ -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({ 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({ 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({ 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({ return () => { dragCleanups.forEach((cleanup) => cleanup()); + hoverLabel.remove(); pinsRef.current.forEach((pin) => pin.remove()); pinsRef.current = []; }; diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index 30c072175..b5e34dc56 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -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, }} >
- +
{tour.name} diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 2358c2025..6ca1c2d5a 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -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') diff --git a/client/src/components/Tours/planner/TourEditorFields.tsx b/client/src/components/Tours/planner/TourEditorFields.tsx new file mode 100644 index 000000000..9a2dd3d44 --- /dev/null +++ b/client/src/components/Tours/planner/TourEditorFields.tsx @@ -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 ( + + + + ); +} + +/** 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 ( +
+ + {aside} +
+ ); +} + +function FieldError({ id, children }: { id: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +export function TourNameField({ planner, locked }: TourFieldProps) { + const { t } = useTranslation(); + return ( +
+ + { + if (!locked) planner.setName(event.target.value); + }} + maxLength={255} + placeholder={t('tours.planner.namePlaceholder')} + className={INPUT} + /> +
+ ); +} + +/** + * 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(null); + const [focused, setFocused] = useState(false); + const showDraft = draft !== null && (focused || (value !== null && Number.isNaN(value))); + return ( + { + 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 ( +
+
+ + } + > + {t('tours.planner.breaksShort')} + + + {planner.breakAdditionalInvalid && ( + {t('tours.planner.timeValueInvalid')} + )} +
+
+ + + {manual && ( + + {t('tours.planner.plannedTotalManual')} + + )} + + } + > + {t('tours.planner.plannedShort')} + + + {planner.plannedDurationInvalid && ( + {t('tours.planner.timeValueInvalid')} + )} +
+
+ ); +} + +/** 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(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 ( +
+ +