640616c60a
CI / build-and-test (pull_request) Successful in 2m21s
Every data change went through renderChart, which rebuilt the chart from a full option set: animations restarted and zoom/selection state was lost. Add an updateData path that patches the live chart via ApexCharts' updateSeries (and updateOptions only when categories/labels actually change), exposed as AxisChart.updateData / PieChart.updateData. Before the first render there is nothing to patch, so those fall back to setData. The theme overlay is re-applied to the merged options, so a rebuild after a detach starts from the patched data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
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<typeof setTimeout>;
|
|
|
|
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`<div id="container" style="width:100%;height:100%"></div>`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
} |