mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
/**
|
|
* 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 {
|
|
/** `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,
|
|
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;
|
|
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';
|
|
// The map's own stylesheet sets Helvetica/Arial on its container; the label speaks in the app's face.
|
|
element.style.fontFamily = 'var(--font-system)';
|
|
container.appendChild(element);
|
|
return element;
|
|
};
|
|
|
|
return {
|
|
show({ name, detail }, { x, y }, owner) {
|
|
const label = ensure();
|
|
shownOwner = owner ?? null;
|
|
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%)`;
|
|
// 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() {
|
|
shownOwner = null;
|
|
if (element) element.style.display = 'none';
|
|
},
|
|
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. 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,
|
|
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 }, 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);
|
|
};
|
|
}
|