mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +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:
@@ -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 = [];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user