Files
peardata/ui/chart-focus.js
T
Raven Scott 2923678354
CI / test (push) Successful in 58s
Release rolling / release (push) Successful in 7m14s
Update
2026-07-19 15:27:00 -04:00

272 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<FocusOpts>) => 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 = `
<div class="chart-focus-backdrop" data-focus-close="1"></div>
<div class="chart-focus-stage">
<header class="chart-focus-head">
<div class="chart-focus-titles">
<p class="chart-focus-kicker muted" id="chart-focus-sub"></p>
<h2 id="chart-focus-title">Chart</h2>
<p class="chart-focus-meta muted">
<span id="chart-focus-units"></span>
<span id="chart-focus-status"></span>
</p>
</div>
<div class="chart-focus-actions" id="chart-focus-actions"></div>
<button type="button" class="btn btn-ghost chart-focus-close" data-focus-close="1" title="Close (Esc)" aria-label="Close fullscreen">✕</button>
</header>
<div class="chart-focus-body">
<button type="button" class="chart-focus-nav chart-focus-prev" id="chart-focus-prev" title="Previous chart (←)" aria-label="Previous chart"></button>
<div class="chart-focus-canvas-wrap">
<canvas id="chart-focus-canvas"></canvas>
</div>
<button type="button" class="chart-focus-nav chart-focus-next" id="chart-focus-next" title="Next chart (→)" aria-label="Next chart"></button>
</div>
<footer class="chart-focus-foot">
<div id="chart-focus-legend" class="chart-focus-legend"></div>
<p class="chart-focus-hint muted"><kbd>Esc</kbd> close · <kbd>←</kbd><kbd>→</kbd> navigate · <kbd>Space</kbd> pause · <kbd>1</kbd><kbd>5</kbd> window</p>
</footer>
</div>
`
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<FocusOpts>} 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<typeof createChartFocus>|null} */
let sharedFocus = null
/** App-wide singleton so Overview + Charts share one overlay. */
export function getChartFocus() {
if (!sharedFocus) sharedFocus = createChartFocus()
return sharedFocus
}