apexcharts
Version:
A JavaScript Chart Library
407 lines (374 loc) • 15 kB
JavaScript
// @ts-check
/**
* Trellis (#22): the shared chrome. One title, one legend, one toolbar for the
* whole grid, plus the per-cell facet headers.
*
* All of it is plain DOM outside the panels' SVGs: headers must survive a
* panel unmount (P2 virtualization shows header + skeleton), and the legend
* repeats identical rows N times if left to the panels. Class names reuse the
* shipped legend vocabulary (`apexcharts-legend-series/-marker/-text`,
* `apexcharts-inactive-legend`) so existing CSS and theme tokens apply, with
* trellis-scoped styles in apexcharts.css as the always-present baseline (the
* legend feature's own stylesheet may not be bundled).
*
* @module modules/trellis/TrellisChrome
*/
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import HeatmapGradientLegend from '../legend/HeatmapGradientLegend.js'
/** Minimal inline icons (original shapes, stroke = currentColor). */
const ICONS = {
zoom: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5"/><path d="M8 10.5h5M10.5 8v5"/></svg>',
pan: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3v18M3 12h18"/><path d="M9 6l3-3 3 3M9 18l3 3 3-3M6 9l-3 3 3 3M18 9l3 3-3 3"/></svg>',
reset:
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 10a8 8 0 1 1 2 6"/><path d="M4 4v6h6"/></svg>',
download:
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 4v11"/><path d="M7 11l5 5 5-5"/><path d="M4 20h16"/></svg>',
}
export default class TrellisChrome {
/**
* @param {import('./Trellis').default} trellis
*/
constructor(trellis) {
this.trellis = trellis
/** @type {HTMLElement|null} */
this.elLegend = null
/** @type {HTMLElement|null} */
this.elToolbar = null
/** @type {HTMLElement|null} */
this.elTitle = null
/** @type {HTMLElement|null} */
this.elBreadcrumb = null
/** @type {HeatmapGradientLegend|null} shared gradient strip (P5, heatmap) */
this.gradientLegend = null
/** @type {(() => void)|null} unsubscribe for a deferred gradient build */
this._gradPending = null
}
/**
* The per-cell facet header. Built with the cell, before the panel mounts,
* so a virtualized cell still names itself.
* @param {HTMLElement} cell
* @param {string} key
* @param {{ index: number, count: number }} meta
*/
buildHeader(cell, key, meta) {
const t = this.trellis
const hcfg = t.cfg.header || {}
if (hcfg.show === false) return
const el = BrowserAPIs.createElement('div')
el.className = 'apexcharts-trellis-header'
let text = key
if (typeof hcfg.formatter === 'function') {
text = hcfg.formatter(key, {
dimension: typeof t.cfg.by === 'string' ? t.cfg.by : undefined,
index: meta.index,
count: meta.count,
})
}
el.textContent = text == null ? '' : String(text)
const style = hcfg.style || {}
if (style.fontSize) el.style.fontSize = style.fontSize
if (style.fontWeight) el.style.fontWeight = String(style.fontWeight)
if (style.color) el.style.color = style.color
// Panel promotion (P3): clicking a header expands that panel to the
// grid's full width; the breadcrumb (or clicking the header again)
// restores the grid. `trellis.promote: false` opts out.
if (t.cfg.promote !== false) {
el.classList.add('apexcharts-trellis-header-clickable')
el.setAttribute('role', 'button')
el.setAttribute('tabindex', '0')
el.setAttribute('title', 'Expand this panel')
const toggle = () => {
if (t._promotedKey === key) t.restorePromotion()
else t.promote(key)
}
el.addEventListener('click', toggle)
el.addEventListener('keydown', (/** @type {any} */ e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle()
}
})
}
cell.appendChild(el)
}
/**
* One 2-D strip label (P4): column labels once across the top, row labels
* once down the left, instead of a header per cell. The header formatter
* applies with the dimension named.
* @param {'row'|'column'} dimension
* @param {string} key
* @param {{ index: number, count: number }} meta
* @returns {HTMLElement}
*/
stripEl(dimension, key, meta) {
const t = this.trellis
const hcfg = t.cfg.header || {}
const el = BrowserAPIs.createElement('div')
el.className = `apexcharts-trellis-strip apexcharts-trellis-strip-${dimension}`
let text = key
if (typeof hcfg.formatter === 'function') {
text = hcfg.formatter(key, {
dimension,
index: meta.index,
count: meta.count,
})
}
el.textContent = text == null ? '' : String(text)
const style = hcfg.style || {}
if (style.fontSize) el.style.fontSize = style.fontSize
if (style.fontWeight) el.style.fontWeight = String(style.fontWeight)
if (style.color) el.style.color = style.color
return el
}
/**
* The promotion breadcrumb: "All panels / KEY", where "All panels" is the
* way back. Lives in the chrome-top strip so the grid's own layout is
* untouched.
* @param {HTMLElement} host
* @param {string} key
* @param {() => void} onBack
*/
buildBreadcrumb(host, key, onBack) {
this.removeBreadcrumb()
const el = BrowserAPIs.createElement('div')
el.className = 'apexcharts-trellis-breadcrumb'
const back = BrowserAPIs.createElement('button')
back.setAttribute('type', 'button')
back.className = 'apexcharts-trellis-breadcrumb-back'
back.textContent = 'All panels'
back.addEventListener('click', onBack)
const sep = BrowserAPIs.createElement('span')
sep.className = 'apexcharts-trellis-breadcrumb-sep'
sep.textContent = '/'
const current = BrowserAPIs.createElement('span')
current.className = 'apexcharts-trellis-breadcrumb-current'
current.textContent = key
el.appendChild(back)
el.appendChild(sep)
el.appendChild(current)
host.appendChild(el)
this.elBreadcrumb = el
}
removeBreadcrumb() {
if (this.elBreadcrumb && this.elBreadcrumb.parentNode) {
this.elBreadcrumb.parentNode.removeChild(this.elBreadcrumb)
}
this.elBreadcrumb = null
}
/**
* The trellis-level title, from the host's own `title` config (panels have
* theirs suppressed).
* @param {HTMLElement} host
*/
buildTitle(host) {
const title = this.trellis.w.config.title
if (!title || !title.text) return
const el = BrowserAPIs.createElement('div')
el.className = 'apexcharts-trellis-title'
el.textContent = title.text
const style = title.style || {}
if (style.fontSize) el.style.fontSize = style.fontSize
if (style.color) el.style.color = style.color
host.appendChild(el)
this.elTitle = el
}
/**
* One legend for the grid. Clicking an item toggles that series name in
* every panel (TrellisSync owns the fan-out and the hidden set).
* @param {HTMLElement} host
*/
buildLegend(host) {
const t = this.trellis
if ((t.cfg.legend || 'shared') !== 'shared') return
// A heatmap's legend IS its color scale (P5): one shared gradient strip
// for the grid instead of a categorical name list.
if (t.w.config.chart.type === 'heatmap') {
this.buildGradientLegend(host)
return
}
const names = t.split ? t.split.seriesNames : []
// A one-name legend restates the header; skip it, like the per-chart
// legend skips single-series charts by default.
if (names.length < 2) return
const wrap = BrowserAPIs.createElement('div')
wrap.className = 'apexcharts-trellis-legend apexcharts-legend'
wrap.setAttribute('role', 'list')
names.forEach((name) => {
const item = BrowserAPIs.createElement('div')
item.className = 'apexcharts-legend-series apexcharts-trellis-legend-item'
item.setAttribute('role', 'listitem')
item.setAttribute('tabindex', '0')
item.setAttribute('data:collapsed', 'false')
const marker = BrowserAPIs.createElement('span')
marker.className = 'apexcharts-legend-marker'
marker.style.background = t.scales ? t.scales.colorOf(name) : '#008FFB'
const text = BrowserAPIs.createElement('span')
text.className = 'apexcharts-legend-text'
text.textContent = name
item.appendChild(marker)
item.appendChild(text)
const toggle = () => {
const hidden = t.sync.toggleSeries(name)
item.classList.toggle('apexcharts-inactive-legend', hidden)
item.setAttribute('data:collapsed', String(hidden))
}
item.addEventListener('click', toggle)
item.addEventListener('keydown', (/** @type {any} */ e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle()
}
})
wrap.appendChild(item)
})
host.appendChild(wrap)
this.elLegend = wrap
}
/**
* One shared gradient strip for a heatmap grid (P5). Every panel carries
* the same pushed colorScale min/max (TrellisFrames), so any ONE mounted
* panel's strip is THE grid's scale; it draws detached into a trellis-owned
* slot under the grid. Hover (the value arrow) is wired through every
* mounted panel's events, so sweeping any panel moves the one arrow.
* On a virtualized grid with nothing mounted yet, the build defers to the
* first panelMounted.
* @param {HTMLElement} host
*/
buildGradientLegend(host) {
const t = this.trellis
const mounted = t.panels.find((p) => p.chart)
if (!mounted) {
if (this._gradPending) return
/** @type {any} */
const once = () => {
this._gradPending = null
t.ctx.events.removeEventListener?.('panelMounted', once)
this.buildGradientLegend(host)
}
this._gradPending = once
t.ctx.events.addEventListener('panelMounted', once)
return
}
const slot = BrowserAPIs.createElement('div')
slot.className = 'apexcharts-trellis-legend apexcharts-trellis-gradient-legend'
host.appendChild(slot)
this.elLegend = slot
const chart = /** @type {any} */ (mounted.chart)
const legend = new HeatmapGradientLegend(chart.w, chart.ctx)
legend.draw(slot)
this.gradientLegend = legend
// The instance's own hover wiring only hears ITS panel; mirror the same
// handlers onto every other mounted panel so the arrow follows the
// cursor anywhere in the grid.
t.panels.forEach((p) => {
if (!p.chart || p === mounted) return
const ev = /** @type {any} */ (p.chart).ctx?.events
if (!ev?.addEventListener) return
ev.addEventListener('dataPointMouseEnter', legend._onCellEnter)
ev.addEventListener('dataPointMouseLeave', legend._onCellLeave)
})
}
/** Tear down the shared gradient strip (P5). */
destroyGradientLegend() {
const t = this.trellis
if (this._gradPending) {
t.ctx.events.removeEventListener?.('panelMounted', this._gradPending)
this._gradPending = null
}
if (this.gradientLegend) {
try {
this.gradientLegend.destroy()
} catch (e) {
// The carrier panel may already be destroyed; the strip DOM goes
// with the trellis wrap either way.
}
this.gradientLegend = null
}
}
/**
* One toolbar for the grid: zoom / pan / reset only (P1). Zoom and pan arm
* the tool by setting every panel's interact flags, which is exactly what
* the per-chart toolbar does for its group; reset restores the trellis's
* own domains via TrellisSync.
* @param {HTMLElement} host
*/
buildToolbar(host) {
const t = this.trellis
if ((t.cfg.toolbar || 'shared') !== 'shared') return
const bar = BrowserAPIs.createElement('div')
bar.className = 'apexcharts-trellis-toolbar'
/** @type {Record<string, HTMLElement>} */
const buttons = {}
/** @param {'zoom'|'pan'} tool */
const arm = (tool) => {
t.panels.forEach((p) => {
if (!p.chart) return
const it = p.chart.w.interact
it.zoomEnabled = tool === 'zoom'
it.panEnabled = tool === 'pan'
it.selectionEnabled = false
})
buttons.zoom.classList.toggle('apexcharts-selected', tool === 'zoom')
buttons.pan.classList.toggle('apexcharts-selected', tool === 'pan')
buttons.zoom.setAttribute('aria-pressed', String(tool === 'zoom'))
buttons.pan.setAttribute('aria-pressed', String(tool === 'pan'))
}
/**
* @param {string} kind
* @param {string} label
* @param {() => void} onClick
*/
const makeButton = (kind, label, onClick) => {
const b = BrowserAPIs.createElement('button')
b.className = `apexcharts-trellis-tool apexcharts-trellis-tool-${kind}`
b.setAttribute('type', 'button')
b.setAttribute('aria-label', label)
b.setAttribute('title', label)
b.innerHTML = /** @type {Record<string, string>} */ (ICONS)[kind]
b.addEventListener('click', onClick)
buttons[kind] = b
bar.appendChild(b)
return b
}
makeButton('zoom', 'Selection zoom', () => arm('zoom'))
makeButton('pan', 'Pan', () => arm('pan'))
makeButton('reset', 'Reset zoom', () => {
t.sync.resetAll()
})
// The download tool (P3): one composed artifact for the whole grid.
// Requires the exports feature on the panels; omitted otherwise.
if (t.ctx.exports) {
const menu = BrowserAPIs.createElement('div')
menu.className = 'apexcharts-trellis-menu'
;/** @type {Array<['png'|'svg'|'csv', string]>} */ ([
['png', 'Download PNG'],
['svg', 'Download SVG'],
['csv', 'Download CSV'],
]).forEach(([kind, label]) => {
const item = BrowserAPIs.createElement('button')
item.setAttribute('type', 'button')
item.className = 'apexcharts-trellis-menu-item'
item.textContent = label
item.addEventListener('click', () => {
menu.classList.remove('apexcharts-trellis-menu-open')
t.exports.download(kind)
})
menu.appendChild(item)
})
makeButton('download', 'Download', () => {
menu.classList.toggle('apexcharts-trellis-menu-open')
})
bar.appendChild(menu)
}
// Default tool mirrors the per-chart default (toolbar.autoSelected 'zoom').
buttons.zoom.classList.add('apexcharts-selected')
buttons.zoom.setAttribute('aria-pressed', 'true')
buttons.pan.setAttribute('aria-pressed', 'false')
host.appendChild(bar)
this.elToolbar = bar
// The toolbar floats at the grid's top-right, so the grid has to start
// BELOW it: at four or more columns the last cell's header runs straight
// under the buttons otherwise.
host.classList.add('apexcharts-trellis-has-toolbar')
}
}