/** * Fullscreen chart focus overlay — shared by Charts wall + Overview sparks. */ /** * @typedef {{ * id?: string, * title?: string, * subtitle?: string, * units?: string, * status?: string, * paint: (canvas: HTMLCanvasElement) => void, * onClose?: () => void, * onPrev?: (() => void)|null, * onNext?: (() => void)|null, * actions?: Array<{ id: string, label: string, title?: string, active?: boolean, onClick: () => void }>, * }} FocusOpts */ /** * @returns {{ * open: (opts: FocusOpts) => void, * close: () => void, * isOpen: () => boolean, * currentId: () => string, * refresh: () => void, * updateChrome: (patch: Partial) => void, * setLegend: (html: string) => void, * el: HTMLElement, * }} */ export function createChartFocus() { const root = document.createElement('div') root.id = 'chart-focus' root.className = 'chart-focus hidden' root.tabIndex = -1 root.setAttribute('role', 'dialog') root.setAttribute('aria-modal', 'true') root.setAttribute('aria-labelledby', 'chart-focus-title') root.innerHTML = `

Chart

Esc close · navigate · Space pause · 15 window

` document.body.appendChild(root) const titleEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-title')) const subEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-sub')) const unitsEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-units')) const statusEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-status')) const actionsEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-actions')) const legendEl = /** @type {HTMLElement} */ (root.querySelector('#chart-focus-legend')) const canvas = /** @type {HTMLCanvasElement} */ (root.querySelector('#chart-focus-canvas')) const prevBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#chart-focus-prev')) const nextBtn = /** @type {HTMLButtonElement} */ (root.querySelector('#chart-focus-next')) /** @type {FocusOpts|null} */ let current = null let resizeObs = /** @type {ResizeObserver|null} */ (null) let openRaf = 0 function paintNow() { if (!current?.paint || !canvas) return try { current.paint(canvas) } catch { // ignore paint errors in focus } } function schedulePaint() { if (openRaf) return openRaf = requestAnimationFrame(() => { openRaf = 0 paintNow() }) } function syncNav() { const hasPrev = Boolean(current?.onPrev) const hasNext = Boolean(current?.onNext) prevBtn.classList.toggle('hidden', !hasPrev) nextBtn.classList.toggle('hidden', !hasNext) prevBtn.disabled = !hasPrev nextBtn.disabled = !hasNext } function syncActions() { const actions = current?.actions || [] actionsEl.innerHTML = '' for (const a of actions) { const btn = document.createElement('button') btn.type = 'button' btn.className = 'btn btn-ghost' + (a.active ? ' is-active' : '') btn.dataset.focusAction = a.id btn.textContent = a.label if (a.title) btn.title = a.title btn.addEventListener('click', (ev) => { ev.stopPropagation() a.onClick() }) actionsEl.appendChild(btn) } } /** * @param {Partial} patch */ function updateChrome(patch = {}) { if (!current) return current = { ...current, ...patch } titleEl.textContent = current.title || current.id || 'Chart' subEl.textContent = current.subtitle || current.id || '' unitsEl.textContent = current.units ? String(current.units) : '' statusEl.textContent = current.status ? String(current.status) : '' syncNav() if (patch.actions) syncActions() } /** * Optional legend HTML from the host (dimension chips). * @param {string} html */ function setLegend(html) { legendEl.innerHTML = html || '' } /** * @param {FocusOpts} opts */ function open(opts) { current = { ...opts } updateChrome(opts) syncActions() root.classList.remove('hidden') requestAnimationFrame(() => root.classList.add('is-open')) document.body.classList.add('chart-focus-open') schedulePaint() if (!resizeObs && typeof ResizeObserver !== 'undefined') { resizeObs = new ResizeObserver(() => schedulePaint()) const wrap = root.querySelector('.chart-focus-canvas-wrap') if (wrap) resizeObs.observe(wrap) } try { root.focus({ preventScroll: true }) } catch { // ignore } } function close() { if (!current) return const onClose = current.onClose current = null root.classList.remove('is-open') const hide = () => { root.classList.add('hidden') legendEl.innerHTML = '' document.body.classList.remove('chart-focus-open') } // Allow exit animation; fall back if reduced motion let done = false const finish = () => { if (done) return done = true hide() } root.addEventListener('transitionend', finish, { once: true }) setTimeout(finish, 220) onClose?.() } function isOpen() { return Boolean(current) && !root.classList.contains('hidden') } function currentId() { return current?.id || '' } function refresh() { if (!isOpen()) return schedulePaint() } root.addEventListener('click', (ev) => { const t = /** @type {HTMLElement} */ (ev.target) if (t?.closest?.('[data-focus-close]')) { ev.preventDefault() close() } }) prevBtn.addEventListener('click', (ev) => { ev.stopPropagation() current?.onPrev?.() }) nextBtn.addEventListener('click', (ev) => { ev.stopPropagation() current?.onNext?.() }) window.addEventListener('keydown', (ev) => { if (!isOpen()) return const tag = (ev.target && /** @type {HTMLElement} */ (ev.target).tagName) || '' if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return if (ev.key === 'Escape') { ev.preventDefault() ev.stopPropagation() close() } else if (ev.key === 'ArrowLeft') { if (current?.onPrev) { ev.preventDefault() current.onPrev() } } else if (ev.key === 'ArrowRight') { if (current?.onNext) { ev.preventDefault() current.onNext() } } }) // Keep canvas sharp on DPR / window resize window.addEventListener('resize', () => { if (isOpen()) schedulePaint() }) return { open, close, isOpen, currentId, refresh, updateChrome, setLegend, el: root, } } /** @type {ReturnType|null} */ let sharedFocus = null /** App-wide singleton so Overview + Charts share one overlay. */ export function getChartFocus() { if (!sharedFocus) sharedFocus = createChartFocus() return sharedFocus }