import './assistantGhost.css'; import { categoryIconSvg } from './categoryIconSvg'; import type { AssistantGhost } from './mapViewContract'; /** * The assistant's suggested places on the map, built once for both renderers. * * A ghost is a plain element: the Leaflet map hands it to a divIcon, the GL * map positions it like its other hand-placed pins. Nothing from the ghost's * data goes in as markup. The name is an attribute and the renderer's own * label shows it as text, the category colour is a CSS variable set through * the style API, and the icon is looked up in the app's fixed icon table. */ /** The ghost's box in CSS pixels, for the anchor of a Leaflet divIcon. */ export const GHOST_SIZE = 30; /** On a ghost whose suggestion is under the pointer, here or in the chat. */ export const GHOST_HOT_CLASS = 'is-hot'; /** On a real pin that just arrived from an applied suggestion. */ export const PLACE_POP_CLASS = 'trek-place-pop'; /** On a real pin the hovered suggestion is about. */ export const PLACE_PULSE_CLASS = 'trek-place-pulse'; const iconMarkup = new Map(); /** The category's icon in the ghost's colour, rendered once per icon name. */ function ghostIcon(name: string | undefined): string { const key = name ?? ''; let markup = iconMarkup.get(key); if (markup === undefined) { markup = categoryIconSvg(name, 14, 'currentColor'); iconMarkup.set(key, markup); } return markup; } /** * A ghost's element: a dashed pin with its category's icon, or for a spot a * card marks (`point`) a small dashed dot without one. */ export function buildGhostElement(ghost: AssistantGhost): HTMLDivElement { const el = document.createElement('div'); el.className = ghost.variant === 'point' ? 'trek-ghost trek-ghost--point' : 'trek-ghost'; el.dataset.ghostId = ghost.id; el.dataset.ghostRef = ghost.ref ?? ghost.id; el.setAttribute('role', 'button'); el.setAttribute('aria-label', ghost.name); if (ghost.color) el.style.setProperty('--ghost-color', ghost.color); const face = document.createElement('div'); face.className = 'trek-ghost-face'; // A fixed glyph from the icon table, never anything the ghost carries. if (ghost.variant !== 'point') face.innerHTML = ghostIcon(ghost.icon); el.appendChild(face); return el; } export interface GhostHandlers { onHover?: (id: string | null) => void; onClick?: (id: string) => void; } /** What a renderer gives back for a ghost it put on its map. */ interface GhostPin { remove: () => void; } interface MountedGhosts { elements: Map; dispose: () => void; } /** * Puts every ghost on the map through `place` and wires the pointer: entering * and leaving report the hover, a click (or Enter) reports the ghost without * reaching the map underneath. The handlers are read through a ref, so a * parent passing fresh callbacks does not rebuild the pins. */ export function mountGhosts( ghosts: readonly AssistantGhost[], place: (el: HTMLElement, ghost: AssistantGhost) => GhostPin, handlers: { readonly current: GhostHandlers } ): MountedGhosts { const elements = new Map(); const cleanups: (() => void)[] = []; for (const ghost of ghosts) { const el = buildGhostElement(ghost); const ref = ghost.ref ?? ghost.id; const enter = () => handlers.current.onHover?.(ref); const leave = () => handlers.current.onHover?.(null); const click = (event: Event) => { event.stopPropagation(); handlers.current.onClick?.(ref); }; const key = (event: KeyboardEvent) => { if (event.key === 'Enter') click(event); }; el.addEventListener('pointerenter', enter); el.addEventListener('pointerleave', leave); el.addEventListener('click', click); el.addEventListener('keydown', key); const pin = place(el, ghost); elements.set(ghost.id, el); cleanups.push(() => { el.removeEventListener('pointerenter', enter); el.removeEventListener('pointerleave', leave); el.removeEventListener('click', click); el.removeEventListener('keydown', key); pin.remove(); }); } return { elements, dispose: () => { cleanups.forEach((cleanup) => cleanup()); elements.clear(); }, }; } /** Marks the hovered ghosts (every one of a change lit by its call), and only them, without rebuilding any pin. */ export function setGhostHot(elements: ReadonlyMap, hoveredId: string | null | undefined): void { elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, (el.dataset.ghostRef ?? id) === hoveredId)); }