import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; import ApexCharts from 'apexcharts'; /** * Resolves a CSS custom property (which may be a `light-dark()` value) to its * currently active value for the given scheme. Reading the custom property * directly (e.g. via getComputedStyle on an element that just has the var set) * returns the raw `light-dark(...)` text, not the resolved color — resolution * only happens when the property is used as an actual style value, hence the * throwaway probe element. */ function resolveColor(varName: string): string { const probe = document.createElement('span'); probe.style.color = `var(${varName})`; probe.style.display = 'none'; document.documentElement.appendChild(probe); const resolved = getComputedStyle(probe).color; probe.remove(); return resolved; } function sameJson(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } function isDarkScheme(): boolean { return getComputedStyle(document.documentElement).colorScheme.includes('dark'); } /** Overlays theme-varying option keys with values resolved from the current * --dialect-* CSS tokens, so charts follow the light/dark toggle without a * server round-trip. */ function applyThemeOverlay(options: any): any { const borderColor = resolveColor('--dialect-border'); const inkColor = resolveColor('--dialect-ink'); const dark = isDarkScheme(); options.grid = { ...options.grid, borderColor }; options.legend = { ...options.legend, labels: { ...options.legend?.labels, colors: inkColor }, }; options.tooltip = { ...options.tooltip, theme: dark ? 'dark' : 'light' }; if (options.xaxis) { options.xaxis = { ...options.xaxis, labels: { ...options.xaxis.labels, style: { ...options.xaxis.labels?.style, colors: inkColor } }, }; } if (options.yaxis) { options.yaxis = { ...options.yaxis, labels: { ...options.yaxis.labels, style: { ...options.yaxis.labels?.style, colors: inkColor } }, }; } return options; } @customElement('apex-chart') export class ApexChart extends LitElement { private chart?: ApexCharts; private lastOptions?: any; private lastOptionsJson?: string; private destroyTimer?: ReturnType; private readonly onThemeChange = () => { if (!this.chart || !this.lastOptions) return; this.chart.updateOptions(applyThemeOverlay(this.lastOptions)); }; protected createRenderRoot() { return this; } connectedCallback() { super.connectedCallback(); clearTimeout(this.destroyTimer); this.destroyTimer = undefined; window.addEventListener('dialect-theme-change', this.onThemeChange); // Back after a real teardown (Flow detach, cached view): rebuild, since // the server only pushes options on setData(), not on re-attach. if (!this.chart && this.lastOptionsJson) { void this.renderChart(this.lastOptionsJson); } } render() { return html`
`; } async renderChart(optionsJson: string) { await this.updateComplete; this.lastOptionsJson = optionsJson; const options = JSON.parse(optionsJson); options.chart = { ...options.chart, events: { dataPointSelection: (_e: unknown, _ctx: unknown, cfg: any) => { (this as any).$server.onPointClick(cfg.seriesIndex, cfg.dataPointIndex); }, }, }; this.lastOptions = options; const themed = applyThemeOverlay(options); const container = this.querySelector('#container') as HTMLElement; if (this.chart) { await this.chart.updateOptions(themed); } else { this.chart = new ApexCharts(container, themed); await this.chart.render(); } } /** * Data-only update: patches the live chart via ApexCharts' own * updateSeries/updateOptions instead of rebuilding it from a full option * set, so the SVG animates from its previous values and zoom/selection * state survives. The patch carries `series` plus, depending on the chart * type, `categories` (axis charts) or `labels` (pie). */ async updateData(patchJson: string) { await this.updateComplete; const patch = JSON.parse(patchJson); // Nothing rendered yet — there is no option set to patch onto. The // server only calls this after a full render, so this is a no-op guard. if (!this.lastOptions) return; const options = this.lastOptions; // Categories/labels are structural: they only reach the chart through // updateOptions, which redraws. Skip it when they are unchanged, which // is the common case for a pure data refresh. const structural: any = {}; if (patch.categories && !sameJson(patch.categories, options.xaxis?.categories)) { structural.xaxis = { ...options.xaxis, categories: patch.categories }; } if (patch.labels && !sameJson(patch.labels, options.labels)) { structural.labels = patch.labels; } options.series = patch.series; if (patch.categories) options.xaxis = { ...options.xaxis, categories: patch.categories }; if (patch.labels) options.labels = patch.labels; // Re-resolve the theme-varying keys on the merged options, so a rebuild // after a detach (see connectedCallback) starts from the patched data. const themed = applyThemeOverlay(options); this.lastOptionsJson = JSON.stringify(themed); if (!this.chart) return; if (Object.keys(structural).length > 0) { await this.chart.updateOptions(structural, false, true); } await this.chart.updateSeries(themed.series, true); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('dialect-theme-change', this.onThemeChange); // gridstack's _sortDom() re-appends item elements after every // move/resize, which disconnects and immediately reconnects this // element within the same task — destroying the chart synchronously // would blank it with nothing to trigger a re-render. Defer past the // current task and bail out if we came back. this.destroyTimer = setTimeout(() => { if (this.isConnected) return; this.chart?.destroy(); this.chart = undefined; }, 0); } }