272 lines
8.1 KiB
JavaScript
272 lines
8.1 KiB
JavaScript
/**
|
||
* 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
|
||
}
|