fix(chart): refine ECharts series styling and legend color handling across bar, donut, and radar charts

This commit is contained in:
2026-04-04 12:29:39 +00:00
parent 54a87a5cc0
commit e2d03107df
9 changed files with 126 additions and 64 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-04-04 - 3.55.5 - fix(chart)
refine ECharts series styling and legend color handling across bar, donut, and radar charts
- switch chart series palettes to hex colors and add rgba conversion to prevent black flashes during ECharts hover and emphasis animations
- explicitly provide legend item colors and solid tooltip markers so translucent fills render consistently across chart types
- deep-merge legend theme options in the shared ECharts base component to preserve nested legend text styling
- adjust donut chart spacing and shared chart container styling for improved layout
## 2026-04-04 - 3.55.4 - fix(chart) ## 2026-04-04 - 3.55.4 - fix(chart)
align ECharts components with theme tokens and load the full ECharts ESM bundle align ECharts components with theme tokens and load the full ECharts ESM bundle

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog', name: '@design.estate/dees-catalog',
version: '3.55.4', version: '3.55.5',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
} }

View File

@@ -8,7 +8,7 @@ import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js';
import { demoFunc } from './demo.js'; import { demoFunc } from './demo.js';
import { barStyles } from './styles.js'; import { barStyles } from './styles.js';
import { renderChartBar } from './template.js'; import { renderChartBar } from './template.js';
import { getEchartsSeriesColors, getThemeColors } from '../dees-chart-echarts-theme.js'; import { getEchartsSeriesColors, getThemeColors, hexToRgba } from '../dees-chart-echarts-theme.js';
export interface IBarSeriesItem { export interface IBarSeriesItem {
name: string; name: string;
@@ -87,28 +87,42 @@ export class DeesChartBar extends DeesChartEchartsBase {
splitLine: { lineStyle: { color: colors.borderSubtle } }, splitLine: { lineStyle: { color: colors.borderSubtle } },
}; };
const seriesData = this.series.map((s, index) => ({ const fillAlpha = this.goBright ? 0.15 : 0.25;
const borderRadius = this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0];
const noBorderRadius = [0, 0, 0, 0];
const legendData: Array<{ name: string; itemStyle: { color: string } }> = [];
const seriesData = this.series.map((s, index) => {
const color = s.color || seriesColors[index % seriesColors.length];
legendData.push({ name: s.name, itemStyle: { color } });
return {
name: s.name, name: s.name,
type: 'bar' as const, type: 'bar' as const,
data: s.data, data: s.data,
stack: this.stacked ? 'total' : undefined, stack: this.stacked ? 'total' : undefined,
itemStyle: { itemStyle: {
color: s.color || seriesColors[index % seriesColors.length], color: hexToRgba(color, fillAlpha),
borderRadius: this.stacked ? [0, 0, 0, 0] : this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0], borderColor: color,
borderWidth: 1,
borderRadius: this.stacked ? noBorderRadius : borderRadius,
}, },
barMaxWidth: 40, barMaxWidth: 40,
barGap: '20%',
emphasis: { emphasis: {
itemStyle: { itemStyle: {
shadowBlur: 6, color: hexToRgba(color, fillAlpha + 0.15),
shadowColor: 'rgba(0, 0, 0, 0.15)', borderColor: color,
borderWidth: 1.5,
}, },
}, },
})); };
});
// For stacked bars, round the top corners of the last visible series // For stacked bars, round the top corners of the last visible series
if (this.stacked && seriesData.length > 0) { if (this.stacked && seriesData.length > 0) {
const last = seriesData[seriesData.length - 1]; const last = seriesData[seriesData.length - 1];
last.itemStyle.borderRadius = this.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]; last.itemStyle.borderRadius = borderRadius;
} }
return { return {
@@ -119,13 +133,15 @@ export class DeesChartBar extends DeesChartEchartsBase {
const items = Array.isArray(params) ? params : [params]; const items = Array.isArray(params) ? params : [params];
let result = `<strong>${items[0].axisValueLabel}</strong><br/>`; let result = `<strong>${items[0].axisValueLabel}</strong><br/>`;
for (const p of items) { for (const p of items) {
result += `${p.marker} ${p.seriesName}: <strong>${formatter(p.value)}</strong><br/>`; const solidColor = p.borderColor || p.color;
const marker = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${solidColor};"></span>`;
result += `${marker}${p.seriesName}: <strong>${formatter(p.value)}</strong><br/>`;
} }
return result; return result;
}, },
}, },
legend: this.showLegend && this.series.length > 1 legend: this.showLegend && this.series.length > 1
? { bottom: 8, itemWidth: 10, itemHeight: 10 } ? { bottom: 8, itemWidth: 10, itemHeight: 10, data: legendData }
: { show: false }, : { show: false },
grid: { grid: {
left: 16, left: 16,

View File

@@ -8,7 +8,7 @@ import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js';
import { demoFunc } from './demo.js'; import { demoFunc } from './demo.js';
import { donutStyles } from './styles.js'; import { donutStyles } from './styles.js';
import { renderChartDonut } from './template.js'; import { renderChartDonut } from './template.js';
import { getEchartsSeriesColors, getThemeColors } from '../dees-chart-echarts-theme.js'; import { getEchartsSeriesColors, getThemeColors, hexToRgba } from '../dees-chart-echarts-theme.js';
export interface IDonutDataItem { export interface IDonutDataItem {
name: string; name: string;
@@ -62,13 +62,32 @@ export class DeesChartDonut extends DeesChartEchartsBase {
} }
protected buildOption(): Record<string, any> { protected buildOption(): Record<string, any> {
const colors = getThemeColors(this.goBright); const themeColors = getThemeColors(this.goBright);
const seriesColors = getEchartsSeriesColors(this.goBright); const seriesColors = getEchartsSeriesColors(this.goBright);
const data = this.data.map((item, index) => ({ const fillAlpha = this.goBright ? 0.15 : 0.2;
const legendData: Array<{ name: string; itemStyle: { color: string } }> = [];
const data = this.data.map((item, index) => {
const color = item.color || seriesColors[index % seriesColors.length];
legendData.push({ name: item.name, itemStyle: { color } });
return {
name: item.name, name: item.name,
value: item.value, value: item.value,
itemStyle: item.color ? { color: item.color } : { color: seriesColors[index % seriesColors.length] }, itemStyle: {
})); color: hexToRgba(color, fillAlpha),
borderColor: color,
borderWidth: 1,
},
emphasis: {
itemStyle: {
color: hexToRgba(color, fillAlpha + 0.15),
borderColor: color,
borderWidth: 1.5,
},
},
};
});
const formatter = this.valueFormatter; const formatter = this.valueFormatter;
@@ -76,7 +95,9 @@ export class DeesChartDonut extends DeesChartEchartsBase {
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: (params: any) => { formatter: (params: any) => {
return `${params.marker} ${params.name}: <strong>${formatter(params.value)}</strong> (${params.percent}%)`; const solidColor = params.data?.itemStyle?.borderColor || params.color;
const marker = `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${solidColor};"></span>`;
return `${marker}${params.name}: <strong>${formatter(params.value)}</strong> (${params.percent}%)`;
}, },
}, },
legend: this.showLegend legend: this.showLegend
@@ -87,6 +108,7 @@ export class DeesChartDonut extends DeesChartEchartsBase {
itemWidth: 10, itemWidth: 10,
itemHeight: 10, itemHeight: 10,
itemGap: 12, itemGap: 12,
data: legendData,
formatter: (name: string) => { formatter: (name: string) => {
const item = this.data.find((d) => d.name === name); const item = this.data.find((d) => d.name === name);
return item ? `${name} ${formatter(item.value)}` : name; return item ? `${name} ${formatter(item.value)}` : name;
@@ -99,31 +121,19 @@ export class DeesChartDonut extends DeesChartEchartsBase {
radius: [this.innerRadiusPercent, '85%'], radius: [this.innerRadiusPercent, '85%'],
center: this.showLegend ? ['35%', '50%'] : ['50%', '50%'], center: this.showLegend ? ['35%', '50%'] : ['50%', '50%'],
avoidLabelOverlap: true, avoidLabelOverlap: true,
padAngle: 2,
itemStyle: { itemStyle: {
borderRadius: 4, borderRadius: 4,
borderColor: 'transparent',
borderWidth: 2,
}, },
label: this.showLabels label: this.showLabels
? { ? {
show: true, show: true,
formatter: '{b}: {d}%', formatter: '{b}: {d}%',
fontSize: 11, fontSize: 11,
color: colors.textSecondary, color: themeColors.textSecondary,
textBorderColor: 'transparent', textBorderColor: 'transparent',
} }
: { show: false }, : { show: false },
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.2)',
},
label: {
show: true,
fontWeight: 'bold',
},
},
data, data,
}, },
], ],

View File

@@ -7,5 +7,8 @@ export const donutStyles = [
:host { :host {
height: 360px; height: 360px;
} }
.chartContainer {
inset: 12px 0;
}
`, `,
]; ];

View File

@@ -87,12 +87,17 @@ export abstract class DeesChartEchartsBase extends DeesElement {
if (!this.chartInstance) return; if (!this.chartInstance) return;
const themeOptions = getEchartsThemeOptions(this.goBright); const themeOptions = getEchartsThemeOptions(this.goBright);
const chartOption = this.buildOption(); const chartOption = this.buildOption();
// Merge theme defaults with chart-specific options // Deep-merge theme defaults with chart-specific options for nested objects
const merged = { const merged = {
...themeOptions, ...themeOptions,
...chartOption, ...chartOption,
textStyle: { ...themeOptions.textStyle, ...(chartOption.textStyle || {}) }, textStyle: { ...themeOptions.textStyle, ...(chartOption.textStyle || {}) },
tooltip: { ...themeOptions.tooltip, ...(chartOption.tooltip || {}) }, tooltip: { ...themeOptions.tooltip, ...(chartOption.tooltip || {}) },
legend: {
...themeOptions.legend,
...(chartOption.legend || {}),
textStyle: { ...(themeOptions.legend?.textStyle || {}), ...(chartOption.legend?.textStyle || {}) },
},
}; };
this.chartInstance.setOption(merged, true); this.chartInstance.setOption(merged, true);
} }

View File

@@ -31,7 +31,6 @@ export const echartsBaseStyles = [
.chartContainer { .chartContainer {
position: absolute; position: absolute;
inset: 0; inset: 0;
will-change: transform;
} }
`, `,
]; ];

View File

@@ -3,8 +3,12 @@
* Uses the centralized themeDefaults tokens so chart colors stay in sync * Uses the centralized themeDefaults tokens so chart colors stay in sync
* with the rest of the dees-catalog design system. * with the rest of the dees-catalog design system.
* *
* ECharts renders on <canvas> and cannot read CSS custom properties, * ECharts renders on <svg> and cannot read CSS custom properties,
* so we reference the TypeScript source-of-truth (themeDefaults) directly. * so we reference the TypeScript source-of-truth (themeDefaults) directly.
*
* IMPORTANT: All colors passed to ECharts for data series must be hex or rgb/rgba.
* ECharts cannot interpolate HSL strings during hover/emphasis animations,
* causing them to flash black.
*/ */
import { themeDefaults } from '../00theme.js'; import { themeDefaults } from '../00theme.js';
@@ -12,22 +16,27 @@ import { themeDefaults } from '../00theme.js';
const light = themeDefaults.colors.light; const light = themeDefaults.colors.light;
const dark = themeDefaults.colors.dark; const dark = themeDefaults.colors.dark;
/**
* Series color palette for ECharts charts.
* Aligned with the Tailwind/shadcn-inspired palette used throughout the codebase.
* All values are hex — ECharts requires this for animation interpolation.
*/
const SERIES_COLORS = { const SERIES_COLORS = {
dark: [ dark: [
dark.accentPrimary, // blue '#60a5fa', // blue-400 — softer in dark mode
'hsl(173.4 80.4% 40%)', // teal (no token yet) '#2dd4bf', // teal-400
'hsl(280.3 87.4% 66.7%)', // purple (no token yet) '#a78bfa', // violet-400
dark.accentWarning, // orange/amber '#fbbf24', // amber-400
dark.accentSuccess, // green '#34d399', // emerald-400
dark.accentError, // rose/red '#fb7185', // rose-400
], ],
light: [ light: [
light.accentPrimary, '#3b82f6', // blue-500
'hsl(142.1 76.2% 36.3%)', // teal (no token yet) '#14b8a6', // teal-500
'hsl(280.3 47.7% 50.2%)', // purple (no token yet) '#8b5cf6', // violet-500
light.accentWarning, '#f59e0b', // amber-500
light.accentSuccess, '#10b981', // emerald-500
light.accentError, '#f43f5e', // rose-500
], ],
}; };
@@ -35,6 +44,16 @@ export function getEchartsSeriesColors(goBright: boolean): string[] {
return goBright ? SERIES_COLORS.light : SERIES_COLORS.dark; return goBright ? SERIES_COLORS.light : SERIES_COLORS.dark;
} }
/**
* Convert a hex color to an rgba string with the given alpha.
*/
export function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export function getEchartsThemeOptions(goBright: boolean): Record<string, any> { export function getEchartsThemeOptions(goBright: boolean): Record<string, any> {
const colors = goBright ? light : dark; const colors = goBright ? light : dark;
return { return {
@@ -44,7 +63,8 @@ export function getEchartsThemeOptions(goBright: boolean): Record<string, any> {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSize: 12, fontSize: 12,
}, },
color: goBright ? SERIES_COLORS.light : SERIES_COLORS.dark, // No global `color` array — each component sets per-item/per-series
// colors explicitly to avoid conflicts during emphasis animations.
tooltip: { tooltip: {
backgroundColor: colors.bgPrimary, backgroundColor: colors.bgPrimary,
borderColor: colors.borderDefault, borderColor: colors.borderDefault,
@@ -65,7 +85,6 @@ export function getEchartsThemeOptions(goBright: boolean): Record<string, any> {
/** /**
* Helper to get the resolved theme colors object for use in buildOption(). * Helper to get the resolved theme colors object for use in buildOption().
* Components can use this instead of hardcoding dark/light color values.
*/ */
export function getThemeColors(goBright: boolean) { export function getThemeColors(goBright: boolean) {
return goBright ? light : dark; return goBright ? light : dark;

View File

@@ -8,7 +8,7 @@ import { DeesChartEchartsBase } from '../dees-chart-echarts-base.js';
import { demoFunc } from './demo.js'; import { demoFunc } from './demo.js';
import { radarStyles } from './styles.js'; import { radarStyles } from './styles.js';
import { renderChartRadar } from './template.js'; import { renderChartRadar } from './template.js';
import { getEchartsSeriesColors, getThemeColors } from '../dees-chart-echarts-theme.js'; import { getEchartsSeriesColors, getThemeColors, hexToRgba } from '../dees-chart-echarts-theme.js';
export interface IRadarIndicator { export interface IRadarIndicator {
name: string; name: string;
@@ -67,16 +67,18 @@ export class DeesChartRadar extends DeesChartEchartsBase {
const colors = getThemeColors(this.goBright); const colors = getThemeColors(this.goBright);
const seriesColors = getEchartsSeriesColors(this.goBright); const seriesColors = getEchartsSeriesColors(this.goBright);
const fillAlpha = this.goBright ? 0.1 : 0.15;
const seriesData = this.series.map((s, index) => { const seriesData = this.series.map((s, index) => {
const color = s.color || seriesColors[index % seriesColors.length]; const color = s.color || seriesColors[index % seriesColors.length];
return { return {
name: s.name, name: s.name,
value: s.values, value: s.values,
itemStyle: { color }, itemStyle: { color, borderColor: color, borderWidth: 1 },
lineStyle: { color, width: 2 }, lineStyle: { color, width: 1.5 },
areaStyle: this.fillArea ? { color, opacity: 0.15 } : undefined, areaStyle: this.fillArea ? { color: hexToRgba(color, fillAlpha) } : undefined,
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 5,
}; };
}); });