diff --git a/client/src/components/Ferratas/useFerrataLayerGL.test.tsx b/client/src/components/Ferratas/useFerrataLayerGL.test.tsx index 1d4e13ada..12180b63f 100644 --- a/client/src/components/Ferratas/useFerrataLayerGL.test.tsx +++ b/client/src/components/Ferratas/useFerrataLayerGL.test.tsx @@ -4,7 +4,7 @@ * * Pinned: the layers go in once per map (and wait for `idle` when tiles are * still loading, the failure the hazard overlay had), the line is the dark - * dashed content colour and never a grade colour, new data replaces the + * dashed via ferrata violet and never a grade colour, new data replaces the * sources' contents instead of rebuilding the layers, a click on the hit line * hands the ferrata and the way to `onSelect`, hover and selection highlight * the whole ferrata (solid, on a wider casing) and name it above the pointer, @@ -156,10 +156,10 @@ describe('useFerrataLayerGL', () => { expect(addedIds(gl)).toEqual(LAYERS); }); - it('FE-FERRATA-GL-002: draws a dark dashed line in the content colour on a light casing, never by grade', () => { + it('FE-FERRATA-GL-002: draws a dashed violet line on a white casing, never by grade', () => { const root = document.documentElement; - root.style.setProperty('--text-primary', 'black'); - root.style.setProperty('--bg-primary', 'white'); + root.style.setProperty('--map-ferrata', 'black'); + root.style.setProperty('--map-ferrata-casing', 'white'); const gl = fakeGlMap(); renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn())); @@ -167,8 +167,8 @@ describe('useFerrataLayerGL', () => { expect(line.paint['line-color']).toBe('black'); expect(line.paint['line-dasharray']).toEqual([2, 1.5]); expect(spec(gl, CASING).paint['line-color']).toBe('white'); - root.style.removeProperty('--text-primary'); - root.style.removeProperty('--bg-primary'); + root.style.removeProperty('--map-ferrata'); + root.style.removeProperty('--map-ferrata-casing'); }); it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => { @@ -269,19 +269,19 @@ describe('useFerrataLayerGL', () => { it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => { const root = document.documentElement; - root.style.setProperty('--text-primary', 'black'); + root.style.setProperty('--map-ferrata', 'black'); const gl = fakeGlMap(); renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn())); expect(gl.spies.setPaintProperty).not.toHaveBeenCalled(); await act(async () => { - root.style.setProperty('--text-primary', 'ivory'); + root.style.setProperty('--map-ferrata', 'ivory'); await Promise.resolve(); }); await waitFor(() => expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LINE, 'line-color', 'ivory')); expect(addedIds(gl).filter((id) => LAYERS.includes(id))).toEqual(LAYERS); - root.style.removeProperty('--text-primary'); + root.style.removeProperty('--map-ferrata'); }); it('FE-FERRATA-GL-011: names the ferrata under the pointer with its grade, above the pointer, and hides it on leave', () => { const gl = fakeGlMap(); @@ -290,14 +290,14 @@ describe('useFerrataLayerGL', () => { act(() => gl.emit(`mousemove:${HIT}`, click)); const label = gl.container.querySelector('[data-testid="ferrata-hover-label"]'); - expect(label?.hidden).toBe(false); + expect(label?.style.display).toBe(''); expect(label?.textContent).toBe('Via AK2'); expect(label?.querySelector('span')?.textContent).toBe('K2'); expect(label?.style.transform).toContain('translate(4px, -8px)'); expect(label).toHaveClass('bg-surface-card', 'text-content', 'border-edge-faint'); act(() => gl.emit(`mouseleave:${HIT}`)); - expect(label?.hidden).toBe(true); + expect(label?.style.display).toBe('none'); unmount(); expect(gl.container.querySelector('[data-testid="ferrata-hover-label"]')).toBeNull(); }); diff --git a/client/src/components/Map/mapHoverLabel.ts b/client/src/components/Map/mapHoverLabel.ts index c049c57d6..39a1b3156 100644 --- a/client/src/components/Map/mapHoverLabel.ts +++ b/client/src/components/Map/mapHoverLabel.ts @@ -26,17 +26,26 @@ interface LabelHost { } export interface MapHoverLabel { - show(text: { name: string; detail: string | null }, point: { x: number; y: number }): void; + /** `owner` is what the label is shown for, so a stale hide from another pin leaves it alone. */ + show(text: { name: string; detail: string | null }, point: { x: number; y: number }, owner?: object): void; + /** Whether the label is up for this owner. */ + shownFor(owner: object): boolean; hide(): void; remove(): void; } -const NO_LABEL: MapHoverLabel = { show: () => undefined, hide: () => undefined, remove: () => undefined }; +const NO_LABEL: MapHoverLabel = { + show: () => undefined, + shownFor: () => false, + 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; + let shownOwner: object | null = null; const ensure = () => { if (element) return element; @@ -53,8 +62,9 @@ export function mapHoverLabel(map: LabelHost, testId = 'map-hover-label'): MapHo }; return { - show({ name, detail }, { x, y }) { + show({ name, detail }, { x, y }, owner) { const label = ensure(); + shownOwner = owner ?? null; label.replaceChildren(document.createTextNode(name)); if (detail) { const badge = document.createElement('span'); @@ -63,10 +73,15 @@ export function mapHoverLabel(map: LabelHost, testId = 'map-hover-label'): MapHo label.appendChild(badge); } label.style.transform = `translate(${x}px, ${y - POINTER_GAP}px) translate(-50%, -100%)`; - label.hidden = false; + // Not the hidden attribute: the label's `flex` class would win over it and keep it on screen. + label.style.display = ''; + }, + shownFor(owner) { + return !!element && element.style.display !== 'none' && shownOwner === owner; }, hide() { - if (element) element.hidden = true; + shownOwner = null; + if (element) element.style.display = 'none'; }, remove() { element?.remove(); @@ -83,7 +98,10 @@ export interface PinHoverText { /** * 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. + * moment the pin is pressed, so it never trails a drag. A map that moves its + * markers every frame (the 3D terrain) can leave a pin without a leave event, + * so a move anywhere else on the map, or off it, hides the label too, as long + * as it is still this pin's. Returns the unwiring. */ export function bindPinHoverLabel( pin: HTMLElement, @@ -94,15 +112,23 @@ export function bindPinHoverLabel( 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 }); + label.show(text, { x: box.left + box.width / 2 - frame.left, y: box.top - frame.top }, pin); }; const hide = () => label.hide(); + const away = (event: Event) => { + if (!label.shownFor(pin)) return; + if (event.type === 'pointerleave' || !(event.target instanceof Node && pin.contains(event.target))) label.hide(); + }; pin.addEventListener('pointerenter', show); pin.addEventListener('pointerleave', hide); pin.addEventListener('pointerdown', hide); + container.addEventListener('pointermove', away); + container.addEventListener('pointerleave', away); return () => { pin.removeEventListener('pointerenter', show); pin.removeEventListener('pointerleave', hide); pin.removeEventListener('pointerdown', hide); + container.removeEventListener('pointermove', away); + container.removeEventListener('pointerleave', away); }; } diff --git a/client/src/components/Map/usePlannerWaypointMarkersGL.ts b/client/src/components/Map/usePlannerWaypointMarkersGL.ts index 84b91b43b..534de8a55 100644 --- a/client/src/components/Map/usePlannerWaypointMarkersGL.ts +++ b/client/src/components/Map/usePlannerWaypointMarkersGL.ts @@ -8,6 +8,9 @@ import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; interface PlannerWaypointMap { getContainer: () => HTMLElement; unproject: (point: [number, number]) => { lat: number; lng: number }; + /** Optional so a stand-in map without events still takes the pins. */ + on?: (type: 'movestart', listener: () => void) => unknown; + off?: (type: 'movestart', listener: () => void) => unknown; } /** The slice of a hand-positioned pin the waypoint markers drive. */ @@ -61,6 +64,10 @@ export function usePlannerWaypointMarkersGL({ pinsRef.current = []; const dragCleanups: (() => void)[] = []; const hoverLabel = mapHoverLabel(map, 'planner-waypoint-label'); + // A label left behind by a pan or a zoom would float over the wrong spot. + const hideOnMove = () => hoverLabel.hide(); + map.on?.('movestart', hideOnMove); + dragCleanups.push(() => map.off?.('movestart', hideOnMove)); for (const [index, point] of plannerWaypoints.entries()) { const selected = point.id === selectedPlannerWaypointId; diff --git a/client/src/components/Tours/terrain/useTerrainPins.ts b/client/src/components/Tours/terrain/useTerrainPins.ts index 4d80cfc9f..7d22f885e 100644 --- a/client/src/components/Tours/terrain/useTerrainPins.ts +++ b/client/src/components/Tours/terrain/useTerrainPins.ts @@ -33,7 +33,10 @@ export function useTerrainPins( if (!gl || !ready) return; pinsRef.current.forEach((pin) => pin.remove()); const hoverLabel = mapHoverLabel(gl.map, 'planner-waypoint-label'); - const unbind: (() => void)[] = []; + // A label left behind by a turn or a tilt would float over the wrong spot. + const hideOnMove = () => hoverLabel.hide(); + gl.map.on('movestart', hideOnMove); + const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)]; pinsRef.current = points.map((point, index) => { const el = waypointPinElement(index, point.id === selectedId, editable); const name = point.name ?? String(index + 1);