Run the CLI
Use the CLI to add chart to your project.
Beautiful charts built with Apache ECharts. Copy and paste into your apps.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-preview',
imports: [ZardCardImports, ZardChartImports],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Bar Chart" />
<z-card-description zDescription="Showing total visitors for the last 6 months" />
</z-card-header>
<z-card-content>
<z-chart
zType="bar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
class="w-full"
>
<z-chart-tooltip zIndicator="dashed" />
<z-chart-legend />
</z-chart>
</z-card-content>
</z-card>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartPreviewComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series = ['desktop', 'mobile'];
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
The chart is built on Apache ECharts, wired into Angular through ngx-echarts. Apache ECharts
Use the CLI to add chart to your project.
npx zard-cli@latest add chartpnpm dlx zard-cli@latest add chartyarn dlx zard-cli@latest add chartbunx zard-cli@latest add chart
import { ApplicationConfig } from '@angular/core';
import { provideZardCharts } from '@/shared/components/chart/chart-echarts.provider';
export const appConfig: ApplicationConfig = {
providers: [
provideZardCharts(),
],
};Install the required dependencies for this component.
npm install ngx-echarts echartspnpm add ngx-echarts echartsyarn add ngx-echarts echartsbun add ngx-echarts echartsCreate the following file in your project.
import { isPlatformBrowser } from '@angular/common';
import {
afterRenderEffect,
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
contentChild,
DestroyRef,
effect,
ElementRef,
forwardRef,
inject,
input,
numberAttribute,
output,
PendingTasks,
PLATFORM_ID,
signal,
untracked,
ViewEncapsulation,
} from '@angular/core';
import { DomSanitizer, type SafeHtml } from '@angular/platform-browser';
import type { ClassValue } from 'clsx';
import type { EChartsOption } from 'echarts';
import type { ECElementEvent, EChartsType } from 'echarts/core';
import { NgxEchartsDirective } from 'ngx-echarts';
import { EDarkModes, ZardDarkMode } from '@/shared/services/dark-mode';
import { mergeClasses } from '@/shared/utils/merge-classes';
import { resolveChartChrome, resolveChartColors, resolveCssColor } from './chart-colors.util';
import { ZARD_CHART, type ZardChartHost } from './chart-context';
import { ZardChartLegendComponent } from './chart-legend.component';
import {
buildChartOption,
buildLegendEntries,
deepMerge,
normalizeSeries,
resolveOptionColors,
type ZardChartBuildContext,
} from './chart-option.builder';
import { renderChartToSvg, type ZardEchartsSsrApi } from './chart-ssr.util';
import { ZardChartTooltipComponent } from './chart-tooltip.component';
import type {
ZardChartConfig,
ZardChartDatum,
ZardChartGrid,
ZardChartLegendEntry,
ZardChartOptionOverride,
ZardChartRadarShape,
ZardChartRadialVariant,
ZardChartRenderer,
ZardChartSeriesInput,
ZardChartStackOffset,
ZardChartType,
} from './chart.types';
import { chartCanvasVariants, chartVariants } from './chart.variants';
/**
* `numberAttribute` answers `NaN` for anything it cannot read — `undefined`, `null`, or a
* valueless attribute — and ECharts draws nothing at all once a `NaN` reaches an angle. An
* unreadable value means "not set", which is what the builder already has a default for.
*/
function optionalNumberAttribute(value: unknown): number | undefined {
const parsed = numberAttribute(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
/** Same guard, for the inputs that must always end up with a number. */
function numberAttributeOr(fallback: number): (value: unknown) => number {
return value => numberAttribute(value, fallback);
}
/** The canvas is measured on every resize; only a real change should redraw the chart. */
function sameSize(a: { width: number; height: number }, b: { width: number; height: number }): boolean {
return a.width === b.width && a.height === b.height;
}
@Component({
selector: 'z-chart',
imports: [NgxEchartsDirective],
template: `
@if (ssrSvg(); as svg) {
<div [class]="canvasClasses()" [attr.role]="hostRole()" [attr.aria-label]="ariaLabel()" [innerHTML]="svg"></div>
} @else if (onScreen()) {
<div
echarts
[class]="canvasClasses()"
[attr.role]="hostRole()"
[attr.aria-label]="ariaLabel()"
[options]="option()"
[initOpts]="initOpts()"
[autoResize]="true"
(chartInit)="onChartInit($event)"
(chartClick)="zChartClick.emit($event)"
(chartLegendSelectChanged)="zLegendSelectChanged.emit($event)"
(chartDataZoom)="zDataZoomChange.emit($event)"
></div>
}
<ng-content />
`,
providers: [{ provide: ZARD_CHART, useExisting: forwardRef(() => ZardChartComponent) }],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: {
'data-slot': 'chart',
ngSkipHydration: 'true',
'[class]': 'classes()',
},
exportAs: 'zChart',
})
export class ZardChartComponent implements ZardChartHost {
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly sanitizer = inject(DomSanitizer);
private readonly darkMode = inject(ZardDarkMode);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly destroyRef = inject(DestroyRef);
private readonly pendingTasks = inject(PendingTasks);
readonly class = input<ClassValue>('');
readonly zConfig = input<ZardChartConfig>({});
readonly zData = input<readonly ZardChartDatum[]>([]);
readonly zType = input<ZardChartType>('bar');
readonly zSeries = input<ZardChartSeriesInput>([]);
readonly zXAxisKey = input<string>();
readonly zNameKey = input<string>();
readonly zStacked = input(false, { transform: booleanAttribute });
readonly zStackOffset = input<ZardChartStackOffset>('none');
readonly zHorizontal = input(false, { transform: booleanAttribute });
readonly zGrid = input<ZardChartGrid>('horizontal');
readonly zXAxis = input(true, { transform: booleanAttribute });
readonly zYAxis = input(false, { transform: booleanAttribute });
readonly zXAxisFormatter = input<(value: string) => string>();
readonly zYAxisFormatter = input<(value: number) => string>();
readonly zInnerRadius = input<string | number>();
readonly zOuterRadius = input<string | number>();
readonly zRadialVariant = input<ZardChartRadialVariant>('bar');
readonly zTrack = input(true, { transform: booleanAttribute });
/** Radial only: writes each category's name along its own ring, like Recharts' `<LabelList>`. */
readonly zRadialLabel = input(false, { transform: booleanAttribute });
/**
* Waits for the chart to scroll into view before drawing it, so its entry animation plays
* where someone can see it. Turn off to draw as soon as the component is created.
*/
readonly zLazyRender = input(true, { transform: booleanAttribute });
readonly zRadarShape = input<ZardChartRadarShape>('polygon');
readonly zRadarRadialLines = input(true, { transform: booleanAttribute });
readonly zStartAngle = input<number | undefined>(undefined, { transform: optionalNumberAttribute });
readonly zEndAngle = input<number | undefined>(undefined, { transform: optionalNumberAttribute });
readonly zPadAngle = input(0, { transform: numberAttributeOr(0) });
readonly zGradient = input(false, { transform: booleanAttribute });
readonly zLabel = input(false, { transform: booleanAttribute });
readonly zCenterValue = input<string>();
readonly zCenterLabel = input<string>();
readonly zAccessibility = input(true, { transform: booleanAttribute });
readonly zAnimation = input(true, { transform: booleanAttribute });
readonly zDataZoom = input(false, { transform: booleanAttribute });
readonly zBrush = input(false, { transform: booleanAttribute });
readonly zToolbox = input(false, { transform: booleanAttribute });
readonly zRenderer = input<ZardChartRenderer>('canvas');
readonly zSsrWidth = input(600, { transform: numberAttributeOr(600) });
readonly zSsrHeight = input(300, { transform: numberAttributeOr(300) });
readonly zOption = input<ZardChartOptionOverride>({});
readonly zChartInit = output<EChartsType>();
readonly zChartClick = output<ECElementEvent>();
readonly zLegendSelectChanged = output<unknown>();
readonly zDataZoomChange = output<unknown>();
private readonly tooltipRef = contentChild(ZardChartTooltipComponent);
private readonly legendRef = contentChild(ZardChartLegendComponent);
private readonly chartInstance = signal<EChartsType | null>(null);
/** Bars growing out of the axis is movement; someone who asked for less of it gets none. */
private readonly reducedMotion = signal(false);
/**
* The drawing surface, watched so anything positioned in pixels follows a resize. Compared by
* value: a new object on every observer callback would rebuild the option, and ngx-echarts
* re-applies it with `notMerge`, which replays the entry animation and clears the legend
* selection the HTML legend is holding.
*/
private readonly canvasSize = signal({ width: 0, height: 0 }, { equal: sameSize });
private readonly coarsePointer = signal(false);
/** Flips once, the first time the chart is scrolled into view. */
private readonly seen = signal(false);
/** Bumped one frame after a theme switch, once the `.dark` class is on the document. */
private readonly colorRevision = signal(0);
readonly hiddenSeries = signal<ReadonlySet<string>>(new Set<string>());
constructor() {
this.watchMotionPreference();
this.watchPointerType();
this.watchCanvasSize();
this.watchVisibility();
this.renderOnServer();
this.restoreHiddenSeries();
if (this.isBrowser) {
effect(() => {
this.darkMode.themeMode();
// The `.dark` class lands on <html> in a separate effect, so the computed colors are
// only trustworthy on the next frame.
this.scheduleColorRefresh();
});
}
}
/** The chart inherits the page's typeface, so the arc labels have to measure with it too. */
private hostFontFamily(): string {
if (!this.isBrowser || typeof globalThis.getComputedStyle !== 'function') {
return 'sans-serif';
}
try {
return globalThis.getComputedStyle(this.elementRef.nativeElement as HTMLElement).fontFamily || 'sans-serif';
} catch {
return 'sans-serif';
}
}
private watchCanvasSize(): void {
if (!this.isBrowser || typeof globalThis.ResizeObserver !== 'function') {
return;
}
const host = this.elementRef.nativeElement as HTMLElement;
const observer = new globalThis.ResizeObserver(() =>
this.canvasSize.set({ width: host.clientWidth, height: host.clientHeight }),
);
observer.observe(host);
this.destroyRef.onDestroy(() => observer.disconnect());
}
// The live chart is a browser affair: on the server the SVG below is all there is, and
// instantiating the ngx-echarts directive there would ask for a ResizeObserver that is absent.
protected readonly onScreen = computed(() => this.isBrowser && (this.seen() || !this.zLazyRender()));
private watchVisibility(): void {
const host = this.elementRef.nativeElement as HTMLElement;
if (!this.isBrowser || typeof globalThis.IntersectionObserver !== 'function') {
this.seen.set(true);
return;
}
const observer = new globalThis.IntersectionObserver(
entries => {
if (!entries.some(entry => entry.isIntersecting)) {
return;
}
this.seen.set(true);
observer.disconnect();
},
// A sliver on screen is enough: the chart should be drawing as it comes up, not after.
{ threshold: 0.1 },
);
observer.observe(host);
this.destroyRef.onDestroy(() => observer.disconnect());
}
private watchPointerType(): void {
this.watchMedia('(pointer: coarse)', matches => this.coarsePointer.set(matches));
}
private watchMotionPreference(): void {
this.watchMedia('(prefers-reduced-motion: reduce)', matches => this.reducedMotion.set(matches));
}
private watchMedia(query: string, apply: (matches: boolean) => void): void {
if (!this.isBrowser || typeof globalThis.matchMedia !== 'function') {
return;
}
const media = globalThis.matchMedia(query);
apply(media.matches);
const listener = (event: MediaQueryListEvent) => apply(event.matches);
media.addEventListener('change', listener);
this.destroyRef.onDestroy(() => media.removeEventListener('change', listener));
}
private scheduleColorRefresh(): void {
const bump = () => this.colorRevision.update(value => value + 1);
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(bump);
return;
}
setTimeout(bump);
}
/** Flattens the declarative `z-chart-tooltip` child into plain data for the builder. */
private readonly tooltipContext = computed<ZardChartBuildContext['tooltip']>(() => {
const tooltip = this.tooltipRef();
if (!tooltip) {
return null;
}
return {
indicator: tooltip.zIndicator(),
trigger: tooltip.zTrigger(),
hideLabel: tooltip.zHideLabel(),
hideIndicator: tooltip.zHideIndicator(),
labelKey: tooltip.zLabelKey(),
nameKey: tooltip.zNameKey(),
labelFormatter: tooltip.zLabelFormatter(),
valueFormatter: tooltip.zValueFormatter(),
class: mergeClasses(tooltip.class()),
labelClass: mergeClasses(tooltip.zLabelClass()),
cursor: tooltip.zCursor(),
};
});
private readonly buildContext = computed<ZardChartBuildContext>(() => {
this.colorRevision();
const host = this.isBrowser ? (this.elementRef.nativeElement as HTMLElement) : null;
const isDark = this.darkMode.themeMode() === EDarkModes.DARK;
const config = this.zConfig();
return {
type: this.zType(),
data: this.zData(),
config,
series: this.zSeries(),
xAxisKey: this.zXAxisKey(),
nameKey: this.zNameKey(),
stacked: this.zStacked(),
stackOffset: this.zStackOffset(),
horizontal: this.zHorizontal(),
grid: this.zGrid(),
xAxis: this.zXAxis(),
yAxis: this.zYAxis(),
xAxisFormatter: this.zXAxisFormatter(),
yAxisFormatter: this.zYAxisFormatter(),
innerRadius: this.zInnerRadius(),
outerRadius: this.zOuterRadius(),
radialVariant: this.zRadialVariant(),
track: this.zTrack(),
radialLabel: this.zRadialLabel(),
size: this.canvasSize(),
fontFamily: this.hostFontFamily(),
coarsePointer: this.coarsePointer(),
radarShape: this.zRadarShape(),
radarRadialLines: this.zRadarRadialLines(),
startAngle: this.zStartAngle(),
endAngle: this.zEndAngle(),
padAngle: this.zPadAngle(),
gradient: this.zGradient(),
label: this.zLabel(),
centerValue: this.zCenterValue(),
centerLabel: this.zCenterLabel(),
accessibility: this.zAccessibility(),
animation: this.zAnimation() && !this.reducedMotion(),
dataZoom: this.zDataZoom(),
brush: this.zBrush(),
toolbox: this.zToolbox(),
colors: resolveChartColors(host, config, isDark),
chrome: resolveChartChrome(host),
hasLegend: !!this.legendRef(),
resolveColor: (value: string) => resolveCssColor(host, value),
tooltip: this.tooltipContext(),
};
});
protected readonly option = computed<EChartsOption>(() => {
const context = this.buildContext();
const merged = deepMerge(buildChartOption(context), this.zOption());
return resolveOptionColors(merged, context.resolveColor);
});
readonly legendEntries = computed<ZardChartLegendEntry[]>(() => buildLegendEntries(this.buildContext()));
protected readonly initOpts = computed(() => ({ renderer: this.zRenderer() }));
/** The static picture the server paints. Always null in the browser. */
protected readonly ssrSvg = signal<SafeHtml | null>(null);
/**
* Paints the server-side SVG.
*
* The engine is imported here, and only here, so that the browser bundle never carries it on
* the component's account — `provideZardCharts()` is what loads it on the client, lazily.
* Angular waits on the pending task before serialising the page.
*/
private renderOnServer(): void {
if (this.isBrowser) {
return;
}
this.pendingTasks.run(async () => {
const { zardEcharts } = await import('./chart-echarts.registry');
const api = zardEcharts as unknown as ZardEchartsSsrApi;
const svg = renderChartToSvg(api, this.option(), this.zSsrWidth(), this.zSsrHeight());
this.ssrSvg.set(svg ? this.sanitizer.bypassSecurityTrustHtml(svg) : null);
});
}
protected readonly hostRole = computed(() => (this.zAccessibility() ? 'img' : null));
protected readonly ariaLabel = computed(() => {
if (!this.zAccessibility()) {
return null;
}
const config = this.zConfig();
const labels = normalizeSeries(this.zSeries())
.map(definition => config[definition.dataKey]?.label ?? definition.dataKey)
.filter(Boolean);
return labels.length > 0 ? `${this.zType()} chart of ${labels.join(', ')}` : `${this.zType()} chart`;
});
protected readonly classes = computed(() => mergeClasses(chartVariants({ zType: this.zType() }), this.class()));
protected readonly canvasClasses = computed(() => mergeClasses(chartCanvasVariants()));
protected onChartInit(instance: EChartsType): void {
this.chartInstance.set(instance);
this.hiddenSeries.set(new Set<string>());
this.showDefaultTooltip(instance);
this.zChartInit.emit(instance);
}
/**
* Puts the toggled-off series back after a fresh option is applied.
*
* `ngx-echarts` hands every `[options]` change to `setOption(option, true)`, and a `notMerge`
* update drops the legend selection — a theme switch would bring a hidden series back on screen
* while its legend entry still reads `aria-pressed="false"`. This runs after the DOM is written,
* so the directive has already applied the option, and it only tracks `option()`: the toggle
* itself is dispatched by `toggleSeries`, and re-running here would be a second, wasted redraw.
*/
private restoreHiddenSeries(): void {
afterRenderEffect(() => {
this.option();
const instance = untracked(() => this.chartInstance());
const hidden = untracked(() => this.hiddenSeries());
if (!instance || hidden.size === 0) {
return;
}
for (const name of hidden) {
instance.dispatchAction({ type: 'legendUnSelect', name });
}
});
}
/** `z-chart-tooltip[zDefaultIndex]` opens the tooltip on that point without a pointer. */
private showDefaultTooltip(instance: EChartsType): void {
const dataIndex = this.tooltipRef()?.zDefaultIndex();
if (dataIndex === undefined || Number.isNaN(dataIndex)) {
return;
}
// Cleared on destroy: the chart can be torn down in the same tick it was created — a route
// change, a category switch — and ECharts throws when an action reaches a disposed instance.
const timer = setTimeout(() => instance.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex }));
this.destroyRef.onDestroy(() => clearTimeout(timer));
}
toggleSeries(name: string): void {
this.chartInstance()?.dispatchAction({ type: 'legendToggleSelect', name });
this.hiddenSeries.update(current => {
const next = new Set(current);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}
}
Create the following file in your project.
import { cva, type VariantProps } from 'class-variance-authority';
// `aspect-video` mirrors shadcn's ChartContainer: without it a chart with no explicit
// height would initialise at zero pixels and draw nothing. Any height class wins over it.
export const chartVariants = cva('flex aspect-video w-full flex-col justify-center text-xs', {
variants: {
zType: {
area: '',
bar: '',
line: '',
pie: '',
radar: '',
radial: '',
},
},
defaultVariants: {
zType: 'bar',
},
});
export type ZardChartTypeVariants = NonNullable<VariantProps<typeof chartVariants>['zType']>;
/** The element the ECharts instance is mounted on. It must always resolve to a real height. */
export const chartCanvasVariants = cva('order-1 min-h-0 w-full flex-1');
export const chartLegendVariants = cva('flex items-center justify-center gap-4 pt-3', {
variants: {
zVerticalAlign: {
bottom: 'order-2',
top: 'order-0 pt-0 pb-3',
},
},
defaultVariants: {
zVerticalAlign: 'bottom',
},
});
export type ZardChartLegendAlignVariants = NonNullable<VariantProps<typeof chartLegendVariants>['zVerticalAlign']>;
export const chartLegendItemVariants = cva(
'flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-opacity',
{
variants: {
zInactive: {
false: '',
true: 'opacity-50',
},
},
defaultVariants: {
zInactive: false,
},
},
);
export const chartLegendSwatchVariants = cva('size-2 shrink-0 rounded-[2px]');
Create the following file in your project.
/**
* Labels that ride a radial bar's ring.
*
* Recharts draws these with an SVG `<textPath>` on a circular arc, so every glyph sits on the
* ring and leans with it. Canvas has no such thing, and ECharts' own polar label turns the whole
* word to face the centre — a straight line of text across a curve, whose ends fall inside the
* band. Placing one glyph at a time along the arc is the canvas equivalent.
*/
/** Where the text starts, past the beginning of the bar. Recharts' `<LabelList offset>`. */
const START_OFFSET_DEGREES = 5;
export interface ZardArcLabelGeometry {
/** Centre of the polar system, in canvas pixels. */
cx: number;
cy: number;
/** Radius of each ring, outermost last, matching the order of `texts`. */
radii: readonly number[];
/** Where the bars begin, in ECharts degrees: 0 is three o'clock, positive counter-clockwise. */
startAngle: number;
/** Whether the bars sweep towards a larger angle. */
ascending: boolean;
font: string;
fontSize: number;
fill: string;
}
type TextElement = Record<string, unknown>;
let ruler: CanvasRenderingContext2D | null | undefined;
function measureContext(): CanvasRenderingContext2D | null {
if (ruler !== undefined) {
return ruler;
}
try {
ruler = globalThis.document?.createElement('canvas').getContext('2d') ?? null;
} catch {
ruler = null;
}
return ruler;
}
/** Lays one label along one ring, a glyph at a time. */
function glyphsOf(text: string, radius: number, geometry: ZardArcLabelGeometry): TextElement[] {
const context = measureContext();
if (!context || radius <= 0) {
return [];
}
context.font = geometry.font;
const sign = geometry.ascending ? 1 : -1;
const characters = [...text];
let travelled = (START_OFFSET_DEGREES * Math.PI * radius) / 180;
return characters.map(character => {
const { width } = context.measureText(character);
const angle = geometry.startAngle + ((sign * (travelled + width / 2)) / radius) * (180 / Math.PI);
const radians = (angle * Math.PI) / 180;
travelled += width;
// The tangent, pointing the way the text reads.
const tangentX = -Math.sin(radians) * sign;
const tangentY = -Math.cos(radians) * sign;
return {
type: 'text',
x: geometry.cx + Math.cos(radians) * radius,
y: geometry.cy - Math.sin(radians) * radius,
rotation: -Math.atan2(tangentY, tangentX),
z: 100,
silent: true,
style: {
text: character,
fill: geometry.fill,
font: geometry.font,
align: 'center',
verticalAlign: 'middle',
},
};
});
}
/** Every glyph of every label, ready to hand to ECharts as `graphic` elements. */
export function buildArcLabels(texts: readonly string[], geometry: ZardArcLabelGeometry): TextElement[] {
return texts.flatMap((text, index) => glyphsOf(text, geometry.radii[index] ?? 0, geometry));
}
/** ECharts sizes a polar radius against half the shorter side; percentages resolve the same way. */
export function resolveRadius(value: string | number | undefined, base: number, fallback: number): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string' && value.endsWith('%')) {
const percent = Number.parseFloat(value);
return Number.isFinite(percent) ? (percent / 100) * base : fallback;
}
return fallback;
}
Create the following file in your project.
import type { ZardChartChromeColors, ZardChartConfig } from './chart.types';
/** How many `--chart-*` tokens the default palette cycles through. */
export const CHART_PALETTE_SIZE = 5;
const VAR_PATTERN = /^var\(\s*(--[^,)\s]+)\s*(?:,\s*([\s\S]+?))?\s*\)$/;
const HEX_PATTERN = /^#([0-9a-f]{3,8})$/i;
const FUNCTION_PATTERN = /^([a-z-]+)\(\s*([\s\S]*?)\s*\)$/i;
const MAX_VAR_DEPTH = 8;
/** CSS color functions that accept the modern `<channels> / <alpha>` syntax. */
const SLASH_ALPHA_FUNCTIONS = new Set(['color', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'rgb', 'rgba']);
/** The two functions that also have a legacy comma syntax, mapped to their alpha-aware twin. */
const LEGACY_ALPHA_FUNCTIONS: Record<string, string> = { hsl: 'hsla', hsla: 'hsla', rgb: 'rgba', rgba: 'rgba' };
/** Canonical name to emit when writing the modern slash syntax. */
const CANONICAL_FUNCTIONS: Record<string, string> = { hsla: 'hsl', rgba: 'rgb' };
/**
* Literal fallbacks used where `getComputedStyle` is unavailable — server-side rendering.
* They mirror the light theme of `apps/web/src/styles.css` as hex, because ECharts' server
* renderer is safer with sRGB values than with `oklch()`.
*/
const SSR_TOKEN_COLORS: Record<string, string> = {
'--background': '#ffffff',
'--border': '#e5e5e5',
'--card': '#ffffff',
'--chart-1': '#8ec5ff',
'--chart-2': '#2b7fff',
'--chart-3': '#155dfc',
'--chart-4': '#1447e6',
'--chart-5': '#193cb8',
'--foreground': '#0a0a0a',
'--muted-foreground': '#737373',
};
let normalizer: CanvasRenderingContext2D | null | undefined;
const normalizedColors = new Map<string, string>();
function normalizerContext(): CanvasRenderingContext2D | null {
if (normalizer !== undefined) {
return normalizer;
}
try {
const canvas = globalThis.document?.createElement('canvas');
if (canvas) {
canvas.width = canvas.height = 1;
}
normalizer = canvas?.getContext('2d', { willReadFrequently: true }) ?? null;
} catch {
normalizer = null;
}
return normalizer;
}
/**
* Rewrites a CSS colour into sRGB.
*
* ECharts parses colours itself to derive the hover and blur shades of a series, and its parser
* predates `oklch()` — which is what every shadcn theme token is written in. It paints the
* untouched chart fine, because the browser resolves the string, but the moment a tooltip
* highlights a point every unparsed colour comes back transparent and the series vanishes.
* Painting one pixel and reading it back converts anything the browser understands.
*/
export function toCanvasColor(value: string): string {
const raw = (value ?? '').trim();
if (!raw || raw.startsWith('#') || raw.startsWith('rgb')) {
return raw;
}
const cached = normalizedColors.get(raw);
if (cached !== undefined) {
return cached;
}
const context = normalizerContext();
if (!context) {
return raw;
}
context.clearRect(0, 0, 1, 1);
// An unparseable colour leaves `fillStyle` on the transparent sentinel, painting nothing.
context.fillStyle = 'rgba(0, 0, 0, 0)';
context.fillStyle = raw;
context.fillRect(0, 0, 1, 1);
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
const resolved =
alpha === 0
? raw
: alpha === 255
? `rgb(${red}, ${green}, ${blue})`
: `rgba(${red}, ${green}, ${blue}, ${alpha / 255})`;
normalizedColors.set(raw, resolved);
return resolved;
}
function readCssVariable(host: HTMLElement | null | undefined, token: string): string {
if (!host || typeof globalThis.getComputedStyle !== 'function') {
return SSR_TOKEN_COLORS[token] ?? '';
}
try {
return globalThis.getComputedStyle(host).getPropertyValue(token).trim();
} catch {
return SSR_TOKEN_COLORS[token] ?? '';
}
}
function readCssColor(host: HTMLElement | null | undefined, value: string, depth: number): string {
const raw = (value ?? '').trim();
if (!raw || depth >= MAX_VAR_DEPTH) {
return raw;
}
const match = VAR_PATTERN.exec(raw);
if (!match) {
return raw;
}
const [, token, fallback] = match;
const computed = readCssVariable(host, token);
if (computed) {
return readCssColor(host, computed, depth + 1);
}
if (fallback) {
return readCssColor(host, fallback, depth + 1);
}
return raw;
}
/**
* Resolves a CSS color that may be a `var(--token)` reference into a literal value
* ECharts can consume. Falls back to the var()'s own fallback, then to the raw input.
*/
export function resolveCssColor(host: HTMLElement | null | undefined, value: string, depth = 0): string {
return toCanvasColor(readCssColor(host, value, depth));
}
function hexWithAlpha(digits: string, alpha: number): string {
const expanded = digits.length <= 4 ? [...digits].map(char => char + char).join('') : digits;
const channel = Math.round(alpha * 255)
.toString(16)
.padStart(2, '0');
return `#${expanded.slice(0, 6)}${channel}`;
}
/** Applies alpha to any CSS color, preferring oklch's native slash syntax. */
export function withAlpha(color: string, alpha: number): string {
return toCanvasColor(applyAlpha(color, alpha));
}
function applyAlpha(color: string, alpha: number): string {
const raw = (color ?? '').trim();
if (!raw) {
return raw;
}
const clamped = Math.min(1, Math.max(0, alpha));
const hex = HEX_PATTERN.exec(raw);
if (hex) {
return hexWithAlpha(hex[1], clamped);
}
const fn = FUNCTION_PATTERN.exec(raw);
if (fn) {
const name = fn[1].toLowerCase();
const body = fn[2];
if (SLASH_ALPHA_FUNCTIONS.has(name)) {
if (!body.includes(',')) {
const channels = body.split('/')[0].trim();
return `${CANONICAL_FUNCTIONS[name] ?? name}(${channels} / ${clamped})`;
}
// Legacy comma syntax only exists for rgb()/hsl(), which have `rgba()`/`hsla()` counterparts.
const legacy = LEGACY_ALPHA_FUNCTIONS[name];
if (legacy) {
const channels = body
.split(',')
.slice(0, 3)
.map(part => part.trim())
.join(', ');
return `${legacy}(${channels}, ${clamped})`;
}
}
}
return `color-mix(in oklab, ${raw} ${Math.round(clamped * 100)}%, transparent)`;
}
/** Default palette: var(--chart-1) … var(--chart-5), cycling. */
export function paletteColor(index: number): string {
const position = ((index % CHART_PALETTE_SIZE) + CHART_PALETTE_SIZE) % CHART_PALETTE_SIZE;
return `var(--chart-${position + 1})`;
}
/** Resolves every color referenced by a ZardChartConfig for the current theme. */
export function resolveChartColors(
host: HTMLElement | null | undefined,
config: ZardChartConfig,
isDark: boolean,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const [key, item] of Object.entries(config ?? {})) {
const themed = isDark ? item?.theme?.dark : item?.theme?.light;
const declared = themed ?? item?.color;
if (!declared) {
continue;
}
resolved[key] = resolveCssColor(host, declared);
}
return resolved;
}
/** Resolves the grid, axis and surface colors the chart chrome is painted with. */
export function resolveChartChrome(host: HTMLElement | null | undefined): ZardChartChromeColors {
return {
background: resolveCssColor(host, 'var(--background)'),
border: resolveCssColor(host, 'var(--border)'),
foreground: resolveCssColor(host, 'var(--foreground)'),
mutedForeground: resolveCssColor(host, 'var(--muted-foreground)'),
};
}
Create the following file in your project.
import { InjectionToken, type Signal } from '@angular/core';
import type { ZardChartLegendEntry } from './chart.types';
/**
* The slice of `z-chart` its declarative children are allowed to touch.
* Declared apart from the component so `z-chart-legend` can inject the parent
* without the two files importing each other.
*/
export interface ZardChartHost {
/** One entry per series — or per slice, for pie and single-series radial charts. */
readonly legendEntries: Signal<ZardChartLegendEntry[]>;
/** Names currently toggled off through the legend. */
readonly hiddenSeries: Signal<ReadonlySet<string>>;
/** Toggles a series on the live ECharts instance. */
toggleSeries(name: string): void;
}
export const ZARD_CHART = new InjectionToken<ZardChartHost>('ZARD_CHART');
Create the following file in your project.
import { provideEchartsCore } from 'ngx-echarts';
/**
* Registers the ECharts modules ZardUI charts need. Add to `app.config.ts` providers.
*
* The engine is imported lazily: `ngx-echarts` accepts a loader function and awaits it the
* first time a chart is created, which keeps roughly a megabyte of ECharts out of the
* application's initial bundle. Edit `chart-echarts.registry.ts` to change what is loaded.
*/
export const provideZardCharts = () =>
provideEchartsCore({ echarts: () => import('./chart-echarts.registry').then(module => module.zardEcharts) });
Create the following file in your project.
import { BarChart, GaugeChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
AriaComponent,
BrushComponent,
DatasetComponent,
DataZoomComponent,
GraphicComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
MarkPointComponent,
PolarComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
/**
* The tree-shaken ECharts build ZardUI charts run on. Drop what you do not use — every
* entry removed here leaves the bundle.
*
* Two must stay:
* - `LegendComponent`, because the option keeps `legend: { show: false }` and the HTML
* legend toggles series through `dispatchAction({ type: 'legendToggleSelect' })`.
* - `SVGRenderer`, because the server renders through `renderToSVGString()`.
*
* This module is kept apart from `provideZardCharts()` so the provider can import it
* lazily and keep ECharts out of the application's initial bundle.
*/
echarts.use([
BarChart,
LineChart,
PieChart,
RadarChart,
GaugeChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
MarkLineComponent,
MarkAreaComponent,
MarkPointComponent,
PolarComponent,
GraphicComponent,
ToolboxComponent,
BrushComponent,
AriaComponent,
DatasetComponent,
TransformComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer,
SVGRenderer,
]);
/** The registered ECharts core. Also what `z-chart` renders with on the server. */
export const zardEcharts = echarts;
Create the following file in your project.
import { ChangeDetectionStrategy, Component, computed, inject, input, ViewEncapsulation } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { ClassValue } from 'clsx';
import { mergeClasses } from '@/shared/utils/merge-classes';
import { ZARD_CHART } from './chart-context';
import type { ZardChartLegendEntry } from './chart.types';
import {
chartLegendItemVariants,
chartLegendSwatchVariants,
chartLegendVariants,
type ZardChartLegendAlignVariants,
} from './chart.variants';
/**
* The only declarative child that renders real DOM. ECharts' own legend cannot reproduce the
* shadcn markup, so the option keeps `legend: { show: false }` and this component draws the
* entries below (or above) the canvas, toggling series through `legendToggleSelect`.
*/
@Component({
selector: 'z-chart-legend',
imports: [NgIcon],
template: `
@for (entry of entries(); track entry.name) {
<button
type="button"
[class]="itemClasses(entry.name)"
[attr.aria-pressed]="!hidden().has(entry.name)"
(click)="toggle(entry.name)"
>
@if (entry.icon) {
<ng-icon [name]="entry.icon" class="size-3" />
} @else {
<span [class]="swatchClasses()" [style.background-color]="entry.color"></span>
}
{{ entry.label }}
</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { 'data-slot': 'chart-legend', '[class]': 'classes()' },
exportAs: 'zChartLegend',
})
export class ZardChartLegendComponent {
private readonly chart = inject(ZARD_CHART, { optional: true });
readonly class = input<ClassValue>('');
readonly zVerticalAlign = input<ZardChartLegendAlignVariants>('bottom');
protected readonly entries = computed<ZardChartLegendEntry[]>(() => this.chart?.legendEntries() ?? []);
protected readonly hidden = computed<ReadonlySet<string>>(() => this.chart?.hiddenSeries() ?? new Set<string>());
protected readonly classes = computed(() =>
mergeClasses(chartLegendVariants({ zVerticalAlign: this.zVerticalAlign() }), this.class()),
);
protected readonly swatchClasses = computed(() => mergeClasses(chartLegendSwatchVariants()));
protected itemClasses(name: string): string {
return mergeClasses(chartLegendItemVariants({ zInactive: this.hidden().has(name) }));
}
protected toggle(name: string): void {
this.chart?.toggleSeries(name);
}
}
Create the following file in your project.
import type { EChartsOption } from 'echarts';
import { buildArcLabels, resolveRadius } from './chart-arc-label.util';
import { paletteColor, withAlpha } from './chart-colors.util';
import { buildTooltipHtml, type ZardChartTooltipContext, type ZardChartTooltipParam } from './chart-tooltip.formatter';
import type {
ZardChartChromeColors,
ZardChartConfig,
ZardChartDatum,
ZardChartGrid,
ZardChartLegendEntry,
ZardChartRadarShape,
ZardChartRadialVariant,
ZardChartSeries,
ZardChartSeriesInput,
ZardChartStackOffset,
ZardChartType,
} from './chart.types';
/** Bars keep shadcn's default corner radius unless a series overrides it. */
const DEFAULT_BAR_RADIUS = 4;
const DEFAULT_SYMBOL_SIZE = 8;
/**
* Horizontal breathing room for the plot area, in pixels. Lines and areas sit flush against the
* axis ends (`boundaryGap: false`), so without it the first and last point are drawn half outside
* the canvas — the same reason every shadcn area/line chart passes `margin={{ left: 12, right: 12 }}`.
*/
const CURVE_GRID_INSET = 12;
/** Keeps the donut's centre reading above anything a chart paints behind its ring. */
const CENTER_TEXT_Z = 100;
const DEFAULT_AREA_OPACITY = 0.4;
const DEFAULT_RADAR_OPACITY = 0.6;
const DEFAULT_RADAR_STROKE = 1;
const DEFAULT_RADAR_RADIUS = '72%';
const IMPLICIT_STACK_ID = 'zard-stack';
/**
* Entry motion. A chart is watched once, when it appears, so it can afford to be explanatory —
* but Recharts' 1.5s `ease` reads as sluggish across a grid of them. This is the same gesture,
* shortened, on a strong ease-out: fast off the mark, settling at the end, where the eye is.
*/
const ENTRY_DURATION = 700;
const ENTRY_EASING = 'quinticOut';
/** Re-drawing after a toggle is movement on screen, not an entrance: quicker, and eased both ends. */
const UPDATE_DURATION = 250;
/** Everything the builder needs. Pure data plus one color resolver, so it stays testable. */
export interface ZardChartBuildContext {
type: ZardChartType;
data: readonly ZardChartDatum[];
config: ZardChartConfig;
series: ZardChartSeriesInput;
xAxisKey?: string;
nameKey?: string;
stacked: boolean;
stackOffset: ZardChartStackOffset;
horizontal: boolean;
grid: ZardChartGrid;
xAxis: boolean;
yAxis: boolean;
xAxisFormatter?: (value: string) => string;
yAxisFormatter?: (value: number) => string;
innerRadius?: string | number;
outerRadius?: string | number;
radialVariant: ZardChartRadialVariant;
/** Radial only: the muted ring drawn behind each bar. */
track: boolean;
/** Radial only: writes each category's name along its own ring. */
radialLabel: boolean;
/** The canvas, in pixels. Zero until the chart has been laid out. */
size: { width: number; height: number };
fontFamily: string;
radarShape: ZardChartRadarShape;
/** Radar only: the spokes running from the centre to each indicator. */
radarRadialLines: boolean;
startAngle?: number;
endAngle?: number;
padAngle: number;
gradient: boolean;
label: boolean;
centerValue?: string;
centerLabel?: string;
accessibility: boolean;
animation: boolean;
dataZoom: boolean;
brush: boolean;
toolbox: boolean;
/** Series colors already resolved from `zConfig` for the active theme, keyed by data key. */
colors: Record<string, string>;
chrome: ZardChartChromeColors;
tooltip: (Omit<ZardChartTooltipContext, 'colors' | 'config'> & { cursor: boolean }) | null;
hasLegend: boolean;
/** True on touch, where a tooltip has to survive the finger lifting. */
coarsePointer: boolean;
/** Turns `var(--token)` into a literal ECharts can paint with. */
resolveColor: (value: string) => string;
}
type OptionRecord = Record<string, unknown>;
export function toNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
/** Accepts both the `string[]` shorthand and the fully described `ZardChartSeries[]`. */
export function normalizeSeries(input: ZardChartSeriesInput | undefined): ZardChartSeries[] {
if (!Array.isArray(input)) {
return [];
}
return input.map(item => (typeof item === 'string' ? { dataKey: item } : { ...item }));
}
function labelFor(config: ZardChartConfig, key: string): string {
return config[key]?.label ?? key;
}
function colorFor(ctx: ZardChartBuildContext, key: string, index: number, declared?: string): string {
if (declared) {
return ctx.resolveColor(declared);
}
const resolved = ctx.colors[key];
if (resolved) {
return resolved;
}
return ctx.resolveColor(paletteColor(index));
}
/** Reads a row's category, honouring `zNameKey` before falling back to `zXAxisKey`. */
function categoryOf(ctx: ZardChartBuildContext, row: ZardChartDatum): string {
const key = ctx.nameKey ?? ctx.xAxisKey;
return key ? String(row[key] ?? '') : '';
}
/** `stackOffset="expand"` has no ECharts counterpart — normalise the rows to 0-1 first. */
function expandRow(row: ZardChartDatum, keys: string[]): Record<string, number | null> {
const total = keys.reduce((sum, key) => sum + Math.abs(toNumber(row[key]) ?? 0), 0);
const normalized: Record<string, number | null> = {};
for (const key of keys) {
const value = toNumber(row[key]);
normalized[key] = value === null ? null : total === 0 ? 0 : value / total;
}
return normalized;
}
/** A cartesian data point: a bare number, or an object when the row styles itself. */
type CartesianPoint = number | null | OptionRecord;
/**
* Per-point overrides a row may carry, mirroring what Recharts expresses with `<Cell>`:
* `fill` for the color, plus `itemStyle` and `label` for anything else about that one point.
*/
function pointStyleOf(ctx: ZardChartBuildContext, row: ZardChartDatum): OptionRecord | undefined {
const fill = typeof row['fill'] === 'string' ? ctx.resolveColor(row['fill'] as string) : undefined;
const declared = isPlainObject(row['itemStyle']) ? row['itemStyle'] : undefined;
const label = isPlainObject(row['label']) ? row['label'] : undefined;
if (!fill && !declared && !label) {
return undefined;
}
const itemStyle = { ...(fill ? { color: fill } : {}), ...(declared ?? {}) };
return {
...(Object.keys(itemStyle).length > 0 ? { itemStyle } : {}),
...(label ? { label } : {}),
};
}
function seriesValues(ctx: ZardChartBuildContext, definitions: ZardChartSeries[]): Map<string, CartesianPoint[]> {
const keys = definitions.map(definition => definition.dataKey);
const values = new Map<string, CartesianPoint[]>(keys.map(key => [key, []]));
for (const row of ctx.data) {
const source = ctx.stackOffset === 'expand' ? expandRow(row, keys) : row;
const style = pointStyleOf(ctx, row);
for (const key of keys) {
const value = toNumber(source[key]);
values.get(key)?.push(style ? { value, ...style } : value);
}
}
return values;
}
function stackIdOf(ctx: ZardChartBuildContext, definition: ZardChartSeries): string | undefined {
if (definition.stack) {
return definition.stack;
}
return ctx.stacked ? IMPLICIT_STACK_ID : undefined;
}
/** Only the outermost bar of a stack is rounded, exactly like Recharts. */
function barRadius(
ctx: ZardChartBuildContext,
definition: ZardChartSeries,
isStackTop: boolean,
): number | number[] | undefined {
const declared = definition.radius ?? DEFAULT_BAR_RADIUS;
if (Array.isArray(declared)) {
return declared;
}
if (!isStackTop) {
return 0;
}
return ctx.horizontal ? [0, declared, declared, 0] : [declared, declared, 0, 0];
}
function areaFill(ctx: ZardChartBuildContext, color: string, definition: ZardChartSeries): OptionRecord {
if (ctx.gradient) {
return {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: withAlpha(color, 0.8) },
{ offset: 1, color: withAlpha(color, 0.1) },
],
},
};
}
return { color, opacity: definition.fillOpacity ?? DEFAULT_AREA_OPACITY };
}
function seriesLabelOption(ctx: ZardChartBuildContext, definition: ZardChartSeries): OptionRecord {
const show = definition.label ?? ctx.label;
if (!show) {
return { show: false };
}
return {
show: true,
position: ctx.horizontal ? 'right' : 'top',
distance: 6,
color: ctx.chrome.foreground,
fontSize: 12,
};
}
function gridVisibility(grid: ZardChartGrid): { horizontal: boolean; vertical: boolean } {
return {
horizontal: grid === true || grid === 'horizontal',
vertical: grid === true || grid === 'vertical',
};
}
function buildAxes(ctx: ZardChartBuildContext, definitions: ZardChartSeries[], categories: string[]): OptionRecord {
const lines = gridVisibility(ctx.grid);
const hasBars = definitions.some(definition => (definition.type ?? ctx.type) === 'bar');
const categoryAxis: OptionRecord = {
type: 'category',
data: categories,
boundaryGap: hasBars,
axisLine: { show: false },
axisTick: { show: false },
// `border/50`, like shadcn's `[&_.recharts-cartesian-grid_line]:stroke-border/50`. `opacity`
// multiplies the token's own alpha instead of replacing it, which `--border` already carries.
splitLine: { show: false, lineStyle: { color: ctx.chrome.border, opacity: 0.5 } },
axisLabel: {
// `zXAxis` and `zXAxisFormatter` both address the category axis, whichever way the chart is
// turned — the same pairing shadcn gets from `<XAxis dataKey>` plus its `tickFormatter`.
show: ctx.xAxis,
margin: 8,
hideOverlap: true,
color: ctx.chrome.mutedForeground,
fontSize: 12,
...(ctx.xAxisFormatter ? { formatter: (value: string) => ctx.xAxisFormatter?.(value) ?? value } : {}),
},
};
const valueAxis: OptionRecord = {
type: 'value',
axisLine: { show: false },
axisTick: { show: false },
// `border/50`, like shadcn's `[&_.recharts-cartesian-grid_line]:stroke-border/50`. `opacity`
// multiplies the token's own alpha instead of replacing it, which `--border` already carries.
splitLine: { show: false, lineStyle: { color: ctx.chrome.border, opacity: 0.5 } },
axisLabel: {
// Likewise `zYAxis` and `zYAxisFormatter` always address the value axis.
show: ctx.yAxis,
margin: 8,
color: ctx.chrome.mutedForeground,
fontSize: 12,
...(ctx.yAxisFormatter ? { formatter: (value: number) => ctx.yAxisFormatter?.(value) ?? String(value) } : {}),
},
...(ctx.stackOffset === 'expand' ? { max: 1, min: 0 } : {}),
};
const xAxis = ctx.horizontal ? valueAxis : categoryAxis;
const yAxis = ctx.horizontal ? categoryAxis : valueAxis;
// `splitLine` of the Y axis always draws horizontal lines, whichever way the chart is turned.
(yAxis['splitLine'] as OptionRecord)['show'] = lines.horizontal;
(xAxis['splitLine'] as OptionRecord)['show'] = lines.vertical;
if (ctx.horizontal) {
yAxis['inverse'] = true;
}
return { xAxis, yAxis };
}
function buildCartesianSeries(ctx: ZardChartBuildContext, definitions: ZardChartSeries[]): OptionRecord[] {
const values = seriesValues(ctx, definitions);
const lastOfStack = new Map<string, number>();
definitions.forEach((definition, index) => {
const stack = stackIdOf(ctx, definition);
if (stack) {
lastOfStack.set(stack, index);
}
});
return definitions.map((definition, index) => {
const type = definition.type ?? ctx.type;
const isBar = type === 'bar';
const isArea = type === 'area';
const color = colorFor(ctx, definition.dataKey, index, definition.color);
const stack = stackIdOf(ctx, definition);
const isStackTop = !stack || lastOfStack.get(stack) === index;
const series: OptionRecord = {
id: definition.dataKey,
name: labelFor(ctx.config, definition.dataKey),
type: isBar ? 'bar' : 'line',
data: values.get(definition.dataKey) ?? [],
itemStyle: { color, ...(isBar ? { borderRadius: barRadius(ctx, definition, isStackTop) } : {}) },
// Recharts sets a hair of space between the bars of a group; ECharts leaves a third of a bar.
...(isBar ? { emphasis: { disabled: true }, barGap: '5%' } : {}),
label: seriesLabelOption(ctx, definition),
animation: ctx.animation,
...(stack ? { stack } : {}),
...(definition.yAxisIndex === undefined ? {} : { yAxisIndex: definition.yAxisIndex }),
};
if (!isBar) {
const area = isArea ? areaFill(ctx, color, definition) : undefined;
series['lineStyle'] = { color, width: definition.strokeWidth ?? 2 };
series['smooth'] = definition.smooth ?? false;
series['showSymbol'] = definition.showSymbol ?? false;
series['symbol'] = 'circle';
series['symbolSize'] = definition.symbolSize ?? DEFAULT_SYMBOL_SIZE;
series['emphasis'] = { focus: 'none' };
// An axis tooltip highlights one point, which fades the rest of the line to nothing.
// Recharts only adds an active dot, so pin the blur state to the normal one.
series['blur'] = {
lineStyle: { opacity: 1 },
itemStyle: { opacity: 1 },
...(area ? { areaStyle: { opacity: (area['opacity'] as number) ?? 1 } } : {}),
};
if (definition.step) {
series['step'] = definition.step;
}
if (area) {
series['areaStyle'] = area;
}
}
return series;
});
}
/**
* ECharts lays radar indicators out counter-clockwise from the top; Recharts goes clockwise.
* Keeping the first row at twelve o'clock and reversing the rest flips the direction without
* moving the starting point.
*/
function clockwise<T>(items: readonly T[]): T[] {
const [first, ...rest] = items;
return first === undefined ? [] : [first, ...rest.reverse()];
}
function buildRadar(ctx: ZardChartBuildContext, definitions: ZardChartSeries[]): OptionRecord {
const lines = gridVisibility(ctx.grid);
const gridVisible = lines.horizontal || lines.vertical;
const indicators = clockwise(ctx.data.map(row => ({ name: categoryOf(ctx, row) })));
const data = definitions.map((definition, index) => {
const color = colorFor(ctx, definition.dataKey, index, definition.color);
const opacity = definition.fillOpacity ?? DEFAULT_RADAR_OPACITY;
return {
name: labelFor(ctx.config, definition.dataKey),
value: clockwise(ctx.data.map(row => toNumber(row[definition.dataKey]))),
itemStyle: { color },
lineStyle: { color, width: definition.strokeWidth ?? DEFAULT_RADAR_STROKE },
symbol: (definition.showSymbol ?? false) ? 'circle' : 'none',
symbolSize: definition.symbolSize ?? DEFAULT_SYMBOL_SIZE,
...(opacity > 0 ? { areaStyle: { color, opacity } } : {}),
label: seriesLabelOption(ctx, definition),
};
});
return {
radar: {
indicator: indicators,
shape: ctx.radarShape,
axisName: { color: ctx.chrome.mutedForeground, fontSize: 12 },
axisLine: { show: gridVisible && ctx.radarRadialLines, lineStyle: { color: ctx.chrome.border } },
splitLine: { show: gridVisible, lineStyle: { color: ctx.chrome.border } },
splitArea: { show: false },
// Recharts sizes the web off the container, not off however much room the names leave.
radius: ctx.outerRadius ?? DEFAULT_RADAR_RADIUS,
},
series: [{ type: 'radar', data, animation: ctx.animation }],
};
}
function buildPie(ctx: ZardChartBuildContext, definitions: ZardChartSeries[]): OptionRecord {
const [definition] = definitions;
const valueKey = definition?.dataKey ?? '';
const data = ctx.data.map((row, index) => {
const name = categoryOf(ctx, row);
const declared = typeof row['fill'] === 'string' ? (row['fill'] as string) : undefined;
return {
name: labelFor(ctx.config, name),
value: toNumber(row[valueKey]) ?? 0,
itemStyle: { color: colorFor(ctx, name, index, declared ?? definition?.color) },
};
});
return {
series: [
{
type: 'pie',
radius: [ctx.innerRadius ?? 0, ctx.outerRadius ?? '80%'],
center: ['50%', '50%'],
padAngle: ctx.padAngle,
// Recharts walks a pie counter-clockwise from twelve o'clock; ECharts goes the other way.
clockwise: ctx.startAngle !== undefined && ctx.endAngle !== undefined ? ctx.endAngle < ctx.startAngle : false,
avoidLabelOverlap: true,
animation: ctx.animation,
itemStyle: { borderColor: ctx.chrome.background, borderWidth: definitions[0]?.strokeWidth ?? 0 },
label: {
show: ctx.label,
color: ctx.chrome.foreground,
fontSize: 12,
formatter: '{c}',
},
labelLine: { show: ctx.label, lineStyle: { color: ctx.chrome.border } },
data,
// Recharts starts a pie at three o'clock, not twelve.
startAngle: ctx.startAngle ?? 0,
...(ctx.endAngle === undefined ? {} : { endAngle: ctx.endAngle }),
},
],
};
}
/** Reproduces the polar layout ECharts is about to compute, so the labels can ride the rings. */
function arcLabels(ctx: ZardChartBuildContext, names: string[]): OptionRecord[] {
const { width, height } = ctx.size;
if (width <= 0 || height <= 0 || names.length === 0) {
return [];
}
const base = Math.min(width, height) / 2;
const inner = resolveRadius(ctx.innerRadius, base, base * 0.3);
const outer = resolveRadius(ctx.outerRadius, base, base * 0.9);
const band = (outer - inner) / names.length;
const fontSize = 11;
return buildArcLabels(names, {
cx: width / 2,
cy: height / 2,
radii: names.map((_, index) => inner + band * (index + 0.5)),
startAngle: ctx.startAngle ?? 90,
ascending: (ctx.endAngle ?? 360) >= (ctx.startAngle ?? 90),
font: `${fontSize}px ${ctx.fontFamily}`,
fontSize,
fill: '#fff',
});
}
function buildRadialBar(ctx: ZardChartBuildContext, definitions: ZardChartSeries[]): OptionRecord {
const [definition] = definitions;
const stacked = definitions.length > 1;
const names = stacked
? definitions.map(item => labelFor(ctx.config, item.dataKey))
: ctx.data.map(row => labelFor(ctx.config, categoryOf(ctx, row)));
const rowValues = stacked
? definitions.map(item => toNumber(ctx.data[0]?.[item.dataKey]) ?? 0)
: ctx.data.map(row => toNumber(row[definition?.dataKey ?? '']) ?? 0);
const max = Math.max(...rowValues, 0) || 1;
const track = withAlpha(ctx.chrome.mutedForeground, 0.15);
const series: OptionRecord[] = [];
if (stacked) {
// Every series shares one ring, so only the outermost segment gets a rounded cap.
definitions.forEach((item, index) => {
series.push({
type: 'bar',
coordinateSystem: 'polar',
name: labelFor(ctx.config, item.dataKey),
stack: IMPLICIT_STACK_ID,
roundCap: index === definitions.length - 1,
data: [rowValues[index]],
itemStyle: { color: colorFor(ctx, item.dataKey, index, item.color) },
animation: ctx.animation,
});
});
} else {
const data = ctx.data.map((row, index) => {
const name = categoryOf(ctx, row);
const declared = typeof row['fill'] === 'string' ? (row['fill'] as string) : undefined;
return {
value: toNumber(row[definition?.dataKey ?? '']) ?? 0,
itemStyle: { color: colorFor(ctx, name, index, declared ?? definition?.color) },
};
});
series.push({
type: 'bar',
coordinateSystem: 'polar',
name: labelFor(ctx.config, definition?.dataKey ?? ''),
roundCap: true,
// Recharts leaves barely a hair between the rings; ECharts' default gap is four times that.
barCategoryGap: '10%',
showBackground: ctx.track,
backgroundStyle: { color: track },
animation: ctx.animation,
data,
});
}
return {
...(ctx.radialLabel && !stacked ? { graphic: arcLabels(ctx, names) } : {}),
polar: {
radius: [ctx.innerRadius ?? '30%', ctx.outerRadius ?? '90%'],
center: ['50%', '50%'],
},
angleAxis: {
type: 'value',
min: 0,
max: stacked ? rowValues.reduce((sum, value) => sum + value, 0) || 1 : max,
show: false,
// Recharts reads the two angles as a direction: 180 → 0 sweeps clockwise, -90 → 380
// counter-clockwise. ECharts needs that spelled out.
...(ctx.startAngle === undefined || ctx.endAngle === undefined
? {}
: { clockwise: ctx.endAngle < ctx.startAngle }),
...(ctx.startAngle === undefined ? {} : { startAngle: ctx.startAngle }),
...(ctx.endAngle === undefined ? {} : { endAngle: ctx.endAngle }),
},
radiusAxis: {
type: 'category',
data: stacked ? [names.join(' / ')] : names,
show: false,
z: 10,
},
series,
};
}
function buildRadialGauge(ctx: ZardChartBuildContext, definitions: ZardChartSeries[]): OptionRecord {
const [definition] = definitions;
const row = ctx.data[0] ?? {};
const value = toNumber(row[definition?.dataKey ?? '']) ?? 0;
const declared = typeof row['fill'] === 'string' ? (row['fill'] as string) : undefined;
const color = colorFor(ctx, categoryOf(ctx, row), 0, declared ?? definition?.color);
const track = withAlpha(ctx.chrome.mutedForeground, 0.15);
const width = 26;
return {
series: [
{
type: 'gauge',
startAngle: ctx.startAngle ?? 90,
endAngle: ctx.endAngle ?? -270,
radius: ctx.outerRadius ?? '85%',
center: ['50%', '50%'],
min: 0,
max: Math.max(value, 1) * 1.25,
animation: ctx.animation,
progress: { show: true, roundCap: true, width, itemStyle: { color } },
axisLine: { roundCap: true, lineStyle: { width, color: [[1, track]] } },
pointer: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
title: { show: false },
detail: { show: false },
data: [{ value, name: labelFor(ctx.config, definition?.dataKey ?? '') }],
},
],
};
}
/**
* shadcn nests a `<Label content={…} />` in the donut; ECharts needs `graphic` text nodes.
*
* The nodes are positioned with plain `x`/`y` inside the group — `left`/`top` would override
* them and stack the value and the caption on the very same pixel.
*/
function buildCenterText(ctx: ZardChartBuildContext): OptionRecord[] {
if (!ctx.centerValue && !ctx.centerLabel) {
return [];
}
const children: OptionRecord[] = [];
const hasBoth = !!ctx.centerValue && !!ctx.centerLabel;
if (ctx.centerValue) {
children.push({
type: 'text',
x: 0,
y: 0,
z: CENTER_TEXT_Z,
style: {
text: ctx.centerValue,
fill: ctx.chrome.foreground,
fontSize: 36,
fontWeight: 700,
align: 'center',
verticalAlign: 'middle',
textAlign: 'center',
textVerticalAlign: 'middle',
},
});
}
if (ctx.centerLabel) {
children.push({
type: 'text',
x: 0,
y: hasBoth ? 24 : 0,
z: CENTER_TEXT_Z,
style: {
text: ctx.centerLabel,
fill: ctx.chrome.mutedForeground,
fontSize: 12,
align: 'center',
verticalAlign: 'middle',
textAlign: 'center',
textVerticalAlign: 'middle',
},
});
}
return children;
}
function buildTooltip(ctx: ZardChartBuildContext, legendEntries: ZardChartLegendEntry[]): OptionRecord | undefined {
if (!ctx.tooltip) {
return undefined;
}
const colors: Record<string, string> = {};
for (const entry of legendEntries) {
colors[entry.name] = entry.color;
}
// A radar arrives as one param holding every indicator, so the tooltip needs their names.
const indicators =
ctx.type === 'radar' ? clockwise(ctx.data.map(row => labelFor(ctx.config, categoryOf(ctx, row)))) : undefined;
const context: ZardChartTooltipContext = { ...ctx.tooltip, config: ctx.config, colors, indicators };
const hasBars = normalizeSeries(ctx.series).some(definition => (definition.type ?? ctx.type) === 'bar');
const cursor = ctx.tooltip.cursor
? hasBars
? { type: 'shadow', shadowStyle: { color: withAlpha(ctx.chrome.mutedForeground, 0.15) } }
: { type: 'line', lineStyle: { color: ctx.chrome.border, width: 1 } }
: { type: 'none' };
return {
trigger: ctx.tooltip.trigger,
// A tap is a hover that ends immediately, so on touch the tooltip would flash and vanish.
// Binding it to the click alone keeps it up until the next tap, the way Recharts behaves.
triggerOn: ctx.coarsePointer ? 'click' : 'mousemove|click',
axisPointer: cursor,
backgroundColor: 'transparent',
borderWidth: 0,
padding: 0,
// Recharts keeps its tooltip inside the responsive container; ECharts would let it
// spill over whatever sits next to the chart.
confine: true,
extraCssText: 'box-shadow:none;',
appendToBody: false,
formatter: (params: ZardChartTooltipParam | ZardChartTooltipParam[]) => buildTooltipHtml(params, context),
};
}
/** One legend entry per series — or per slice, for the single-series chart types. */
export function buildLegendEntries(ctx: ZardChartBuildContext): ZardChartLegendEntry[] {
const definitions = normalizeSeries(ctx.series);
const perSlice = ctx.type === 'pie' || (ctx.type === 'radial' && definitions.length <= 1);
if (perSlice) {
return ctx.data.map((row, index) => {
const name = categoryOf(ctx, row);
const declared = typeof row['fill'] === 'string' ? (row['fill'] as string) : undefined;
return {
name: labelFor(ctx.config, name),
dataKey: name,
label: labelFor(ctx.config, name),
color: colorFor(ctx, name, index, declared),
icon: ctx.config[name]?.icon,
};
});
}
return definitions.map((definition, index) => ({
name: labelFor(ctx.config, definition.dataKey),
dataKey: definition.dataKey,
label: labelFor(ctx.config, definition.dataKey),
color: colorFor(ctx, definition.dataKey, index, definition.color),
icon: ctx.config[definition.dataKey]?.icon,
}));
}
function isPlainObject(value: unknown): value is OptionRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Deep-merges the `[zOption]` escape hatch over the generated option. Arrays merge
* index by index so `{ series: [{ symbolSize: 12 }] }` patches the first series
* instead of replacing the whole list.
*/
export function deepMerge<T>(base: T, override: unknown): T {
if (override === undefined) {
return base;
}
if (Array.isArray(base) && Array.isArray(override)) {
const length = Math.max(base.length, override.length);
const merged = [];
for (let index = 0; index < length; index++) {
merged.push(index >= override.length ? base[index] : deepMerge(base[index], override[index]));
}
return merged as T;
}
if (isPlainObject(base) && isPlainObject(override)) {
const merged: OptionRecord = { ...base };
for (const [key, value] of Object.entries(override)) {
merged[key] = key in base ? deepMerge(base[key], value) : value;
}
return merged as T;
}
return override as T;
}
/**
* Resolves every `var(--token)` left anywhere in the option. The builder already resolves the
* colors it produces, but `[zOption]` is authored by hand — and ECharts silently falls back to
* black when it meets a `var()` it cannot parse, so the escape hatch has to be swept too.
*/
export function resolveOptionColors<T>(option: T, resolveColor: (value: string) => string): T {
if (typeof option === 'string') {
return (option.startsWith('var(--') ? resolveColor(option) : option) as T;
}
if (Array.isArray(option)) {
return option.map(item => resolveOptionColors(item, resolveColor)) as T;
}
if (isPlainObject(option)) {
const resolved: OptionRecord = {};
for (const [key, value] of Object.entries(option)) {
resolved[key] = resolveOptionColors(value, resolveColor);
}
return resolved as T;
}
return option;
}
/** Turns a `ZardChartConfig` plus the component inputs into a complete `EChartsOption`. */
export function buildChartOption(ctx: ZardChartBuildContext): EChartsOption {
const definitions = normalizeSeries(ctx.series);
const legendEntries = buildLegendEntries(ctx);
const isCartesian = ctx.type === 'area' || ctx.type === 'bar' || ctx.type === 'line';
const option: OptionRecord = {
animation: ctx.animation,
animationDuration: ENTRY_DURATION,
animationEasing: ENTRY_EASING,
animationDurationUpdate: UPDATE_DURATION,
animationEasingUpdate: 'cubicInOut',
aria: { enabled: ctx.accessibility },
color: legendEntries.map(entry => entry.color),
textStyle: { fontFamily: 'inherit' },
};
if (isCartesian) {
const categories = ctx.data.map(row => String(row[ctx.xAxisKey ?? ''] ?? ''));
Object.assign(option, buildAxes(ctx, definitions, categories));
const inset =
ctx.horizontal || definitions.some(definition => (definition.type ?? ctx.type) === 'bar') ? 0 : CURVE_GRID_INSET;
option['grid'] = {
left: inset,
right: inset,
top: 12,
bottom: 0,
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
};
option['series'] = buildCartesianSeries(ctx, definitions);
} else if (ctx.type === 'radar') {
Object.assign(option, buildRadar(ctx, definitions));
} else if (ctx.type === 'pie') {
Object.assign(option, buildPie(ctx, definitions));
} else {
Object.assign(
option,
ctx.radialVariant === 'gauge' ? buildRadialGauge(ctx, definitions) : buildRadialBar(ctx, definitions),
);
}
const tooltip = buildTooltip(ctx, legendEntries);
if (tooltip) {
option['tooltip'] = tooltip;
}
// The legend stays hidden — `z-chart-legend` renders the shadcn markup — but it must be
// declared so `dispatchAction({ type: 'legendToggleSelect' })` has something to act on.
if (ctx.hasLegend) {
option['legend'] = { show: false, data: legendEntries.map(entry => entry.name) };
}
const centerText = buildCenterText(ctx);
if (centerText.length > 0) {
// A radial chart may already have put its ring labels here; the centre joins them.
const existing = Array.isArray(option['graphic']) ? (option['graphic'] as OptionRecord[]) : [];
option['graphic'] = [...existing, { type: 'group', left: 'center', top: 'center', children: centerText }];
}
if (ctx.dataZoom && isCartesian) {
option['dataZoom'] = [
{ type: 'inside' },
{
type: 'slider',
height: 18,
bottom: 0,
borderColor: ctx.chrome.border,
backgroundColor: 'transparent',
fillerColor: withAlpha(ctx.chrome.mutedForeground, 0.12),
handleStyle: { color: ctx.chrome.background, borderColor: ctx.chrome.border },
textStyle: { color: ctx.chrome.mutedForeground },
},
];
(option['grid'] as OptionRecord)['bottom'] = 28;
}
// A brush selects along an axis, and only the cartesian charts have one — the same guard
// `dataZoom` carries above.
const brush = ctx.brush && isCartesian;
if (brush) {
option['brush'] = { toolbox: ['rect', 'polygon', 'clear'], xAxisIndex: 0 };
}
// The brush is only reachable through the toolbox, so asking for one brings the other along.
if (ctx.toolbox || brush) {
option['toolbox'] = {
right: 0,
top: 0,
iconStyle: { borderColor: ctx.chrome.mutedForeground },
feature: {
...(ctx.brush ? { brush: {} } : {}),
...(ctx.toolbox ? { dataZoom: { yAxisIndex: 'none' }, restore: {}, saveAsImage: {} } : {}),
},
};
}
return option as EChartsOption;
}
Create the following file in your project.
import type { EChartsOption } from 'echarts';
/** The slice of an ECharts instance the server renderer uses. */
interface ZardEchartsSsrInstance {
setOption(option: EChartsOption): void;
renderToSVGString(): string;
dispose(): void;
}
/** The slice of `echarts/core` the server renderer uses. */
export interface ZardEchartsSsrApi {
init(
dom: null,
theme: null,
opts: { renderer: 'svg'; ssr: true; width: number; height: number },
): ZardEchartsSsrInstance;
}
/**
* Lets the server-rendered SVG scale with its container. ECharts stamps the fixed pixel
* size it was initialised with, so it is swapped for a `viewBox`.
*/
function makeResponsive(svg: string, width: number, height: number): string {
return svg.replace(/^<svg([^>]*)>/, (_match, attributes: string) => {
const cleaned = attributes.replace(/\s(?:width|height)="[^"]*"/g, '');
return `<svg${cleaned} width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">`;
});
}
/**
* Renders the chart to an SVG string on the server. The browser replaces it with the real
* chart as soon as the ngx-echarts directive initialises, which is why the host carries
* `ngSkipHydration`. Returns `null` when anything goes wrong, so the caller can fall back
* to a client-only chart instead of breaking the prerender.
*/
export function renderChartToSvg(
api: ZardEchartsSsrApi | null | undefined,
option: EChartsOption,
width: number,
height: number,
): string | null {
if (!api || typeof api.init !== 'function') {
return null;
}
let instance: ZardEchartsSsrInstance | undefined;
try {
instance = api.init(null, null, { renderer: 'svg', ssr: true, width, height });
instance.setOption(option);
return makeResponsive(instance.renderToSVGString(), width, height);
} catch (error) {
console.warn('[z-chart] server-side rendering failed; the chart will render on the client only.', error);
return null;
} finally {
instance?.dispose();
}
}
Create the following file in your project.
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
input,
numberAttribute,
ViewEncapsulation,
} from '@angular/core';
import type { ClassValue } from 'clsx';
import type { ZardChartTooltipIndicator, ZardChartTooltipTrigger } from './chart.types';
/**
* Declares the tooltip the parent `z-chart` should build.
*
* ECharts is config-driven: there is a single `<div>` and a single option object, so this
* component renders nothing. `z-chart` reads it through `contentChild` and turns it into
* `tooltip.formatter`.
*/
@Component({
selector: 'z-chart-tooltip',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
host: { 'data-slot': 'chart-tooltip', class: 'hidden' },
exportAs: 'zChartTooltip',
})
export class ZardChartTooltipComponent {
readonly class = input<ClassValue>('');
readonly zIndicator = input<ZardChartTooltipIndicator>('dot');
readonly zHideLabel = input(false, { transform: booleanAttribute });
readonly zHideIndicator = input(false, { transform: booleanAttribute });
readonly zLabelKey = input<string>();
readonly zNameKey = input<string>();
readonly zLabelFormatter = input<(value: string) => string>();
readonly zValueFormatter = input<(value: number, name: string) => string>();
readonly zTrigger = input<ZardChartTooltipTrigger>('axis');
readonly zLabelClass = input<ClassValue>('');
/**
* Draws the axis pointer under the tooltip — a line on curves, a band on bars.
* Off by default, like shadcn's `<ChartTooltip cursor={false} />`.
*/
readonly zCursor = input(false, { transform: booleanAttribute });
/** Opens the tooltip on this data index as soon as the chart draws, like shadcn's `defaultIndex`. */
readonly zDefaultIndex = input<number | undefined>(undefined, { transform: numberAttribute });
}
Create the following file in your project.
import { mergeClasses } from '@/shared/utils/merge-classes';
import type { ZardChartConfig, ZardChartTooltipIndicator, ZardChartTooltipTrigger } from './chart.types';
/** The subset of the ECharts callback params the tooltip actually reads. */
export interface ZardChartTooltipParam {
axisValue?: string | number;
axisValueLabel?: string;
seriesName?: string;
seriesId?: string;
name?: string;
dataIndex?: number;
color?: string;
percent?: number;
value?: unknown;
data?: unknown;
}
/** Everything `z-chart-tooltip` declares, plus what only the parent chart can resolve. */
export interface ZardChartTooltipContext {
indicator: ZardChartTooltipIndicator;
trigger: ZardChartTooltipTrigger;
hideLabel: boolean;
hideIndicator: boolean;
labelKey?: string;
nameKey?: string;
labelFormatter?: (value: string) => string;
valueFormatter?: (value: number, name: string) => string;
class: string;
labelClass: string;
config: ZardChartConfig;
/** Series colors resolved by the parent chart, keyed by the name ECharts uses. */
colors: Record<string, string>;
/**
* Radar only: the indicator names, in the order ECharts lays them out. A radar hands the whole
* web over in a single param, so the rows are read off the value array against these names.
*/
indicators?: readonly string[];
}
const CONTAINER_CLASSES =
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl';
const ROW_CLASSES =
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground';
const INDICATOR_CLASSES: Record<ZardChartTooltipIndicator, string> = {
dashed: 'w-0 border-[1.5px] border-dashed bg-transparent',
dot: 'h-2.5 w-2.5',
line: 'w-1',
};
const HTML_ENTITIES: Record<string, string> = {
'"': '"',
'&': '&',
"'": ''',
'<': '<',
'>': '>',
};
/** Escapes user data before it is concatenated into the tooltip's innerHTML. */
export function escapeHtml(value: unknown): string {
return String(value ?? '').replace(/[&<>"']/g, char => HTML_ENTITIES[char]);
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
/** ECharts hands array-typed values for cartesian series and plain values for pie/radar. */
function readValue(param: ZardChartTooltipParam): number | null {
const { value } = param;
if (Array.isArray(value)) {
for (let index = value.length - 1; index >= 0; index--) {
const parsed = toNumber(value[index]);
if (parsed !== null) {
return parsed;
}
}
return null;
}
if (value && typeof value === 'object') {
return toNumber((value as { value?: unknown }).value);
}
return toNumber(value);
}
function formatValue(value: number | null, name: string, ctx: ZardChartTooltipContext): string {
if (value === null) {
return '';
}
if (ctx.valueFormatter) {
return ctx.valueFormatter(value, name);
}
return value.toLocaleString();
}
/** One line of the tooltip: a swatch, a name and a value. */
interface TooltipRow {
name: string;
color: string;
value: string;
}
function colorOf(param: ZardChartTooltipParam, ctx: ZardChartTooltipContext): string {
return ctx.colors[param.seriesName ?? ''] ?? ctx.colors[param.name ?? ''] ?? param.color ?? '';
}
/** True when the params are a radar's, which arrive as one param holding every indicator. */
function isRadar(items: ZardChartTooltipParam[], ctx: ZardChartTooltipContext): boolean {
return !!ctx.indicators?.length && items.every(param => Array.isArray(param.value));
}
/**
* A radar's rows: one per indicator, the way ECharts' own tooltip reads a web. Taking a single
* number off the array instead would show the same value at every vertex.
*/
function radarRows(items: ZardChartTooltipParam[], ctx: ZardChartTooltipContext): TooltipRow[] {
const indicators = ctx.indicators ?? [];
return items.flatMap(param => {
const color = colorOf(param, ctx);
const values = Array.isArray(param.value) ? param.value : [];
return values.flatMap((raw, index) => {
const parsed = toNumber(raw);
if (parsed === null) {
return [];
}
const name = indicators[index] ?? '';
const label = ctx.config[name]?.label ?? name;
return [{ name: label, color, value: formatValue(parsed, label, ctx) }];
});
});
}
/** One param, one row: everything that is not a radar. */
function rowOf(param: ZardChartTooltipParam, ctx: ZardChartTooltipContext): TooltipRow {
const name = itemLabel(param, ctx);
return { name, color: colorOf(param, ctx), value: formatValue(readValue(param), name, ctx) };
}
function itemLabel(param: ZardChartTooltipParam, ctx: ZardChartTooltipContext): string {
const key = ctx.nameKey ?? (ctx.trigger === 'item' ? (param.name ?? '') : (param.seriesName ?? ''));
return ctx.config[key]?.label ?? (ctx.trigger === 'item' ? (param.name ?? '') : (param.seriesName ?? ''));
}
function headerLabel(params: ZardChartTooltipParam[], ctx: ZardChartTooltipContext): string {
const [first] = params;
if (!first) {
return '';
}
const raw =
ctx.labelKey && ctx.config[ctx.labelKey]?.label
? (ctx.config[ctx.labelKey]?.label ?? '')
: ctx.trigger === 'item'
? (first.name ?? '')
: String(first.axisValueLabel ?? first.axisValue ?? '');
const configured = ctx.config[raw]?.label ?? raw;
return ctx.labelFormatter ? ctx.labelFormatter(configured) : configured;
}
function indicatorHtml(color: string, ctx: ZardChartTooltipContext, nestLabel: boolean): string {
if (ctx.hideIndicator) {
return '';
}
const classes = mergeClasses(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
INDICATOR_CLASSES[ctx.indicator],
nestLabel && ctx.indicator === 'dashed' ? 'my-0.5' : '',
);
// The two custom properties the classes above read the swatch color from.
const swatch = escapeHtml(color);
const declarations = [`--color-bg: ${swatch}`, `--color-border: ${swatch}`].join('; ');
return `<div class="${classes}" style="${declarations};"></div>`;
}
/**
* Builds the tooltip markup, mirroring shadcn/ui's `ChartTooltipContent`.
* ECharts injects the returned string straight into the DOM, so every dynamic
* fragment goes through {@link escapeHtml} first.
*/
export function buildTooltipHtml(
params: ZardChartTooltipParam | ZardChartTooltipParam[],
ctx: ZardChartTooltipContext,
): string {
const items = (Array.isArray(params) ? params : [params]).filter(Boolean);
if (items.length === 0) {
return '';
}
const radar = isRadar(items, ctx);
const entries: TooltipRow[] = radar ? radarRows(items, ctx) : items.map(param => rowOf(param, ctx));
if (entries.length === 0) {
return '';
}
// A radar is headed by the series it belongs to; everything else by its category.
const label = radar ? (items[0]?.seriesName ?? '') : headerLabel(items, ctx);
const nestLabel = entries.length === 1 && ctx.indicator !== 'dot';
const showHeader = !ctx.hideLabel && !nestLabel && label !== '';
// Resolved once: the heading reads the same whether it sits above the rows or inside one, and
// a class carrying a quote would otherwise close the attribute and corrupt the markup.
const labelClasses = escapeHtml(mergeClasses('font-medium', ctx.labelClass));
const rows = entries
.map(({ name, color, value }) => {
const rowClasses = mergeClasses(ROW_CLASSES, ctx.indicator === 'dot' ? 'items-center' : '');
const innerAlign = nestLabel ? 'items-end' : 'items-center';
const nestedLabel =
nestLabel && !ctx.hideLabel && label !== '' ? `<div class="${labelClasses}">${escapeHtml(label)}</div>` : '';
return `<div class="${rowClasses}">${indicatorHtml(color, ctx, nestLabel)}<div class="flex flex-1 justify-between leading-none ${innerAlign}"><div class="grid gap-1.5">${nestedLabel}<span class="text-muted-foreground">${escapeHtml(name)}</span></div><span class="font-mono font-medium tabular-nums text-foreground">${escapeHtml(value)}</span></div></div>`;
})
.join('');
const header = showHeader ? `<div class="${labelClasses}">${escapeHtml(label)}</div>` : '';
const containerClasses = escapeHtml(mergeClasses(CONTAINER_CLASSES, ctx.class));
// `role="tooltip"` is what a screen reader — and a test — recognises this box by.
return `<div role="tooltip" class="${containerClasses}">${header}<div class="grid gap-1.5">${rows}</div></div>`;
}
Create the following file in your project.
import { ZardChartLegendComponent } from '@/shared/components/chart/chart-legend.component';
import { ZardChartTooltipComponent } from '@/shared/components/chart/chart-tooltip.component';
import { ZardChartComponent } from '@/shared/components/chart/chart.component';
export const ZardChartImports = [ZardChartComponent, ZardChartTooltipComponent, ZardChartLegendComponent] as const;
Create the following file in your project.
import type { EChartsOption } from 'echarts';
/** Human-readable metadata for a data key: label, color and optional icon. */
export interface ZardChartConfigItem {
/** Human-readable label shown in tooltip and legend. */
label?: string;
/** Icon name resolved through @ng-icons/lucide. */
icon?: string;
/** A CSS color: 'var(--chart-1)', '#2563eb', 'hsl(220 98% 61%)' or 'oklch(0.5 0.2 240)'. */
color?: string;
/** Per-theme colors, taking precedence over `color`. */
theme?: { light: string; dark: string };
}
/** Maps every data key to its label, color and icon. Decoupled from the data itself. */
export type ZardChartConfig = Record<string, ZardChartConfigItem>;
export type ZardChartType = 'area' | 'bar' | 'line' | 'pie' | 'radar' | 'radial';
export type ZardChartTooltipIndicator = 'dot' | 'line' | 'dashed';
export type ZardChartTooltipTrigger = 'axis' | 'item';
export type ZardChartGrid = boolean | 'horizontal' | 'vertical';
export type ZardChartStackOffset = 'none' | 'expand';
export type ZardChartRadialVariant = 'bar' | 'gauge';
export type ZardChartRadarShape = 'polygon' | 'circle';
export type ZardChartRenderer = 'canvas' | 'svg';
/** A single data row. Keys are data keys, values are numbers, strings or a `fill` color. */
export type ZardChartDatum = Record<string, unknown>;
/** Per-series overrides. Use when `zSeries: string[]` is not expressive enough. */
export interface ZardChartSeries {
dataKey: string;
type?: ZardChartType;
stack?: string;
smooth?: boolean | number;
step?: 'start' | 'middle' | 'end';
radius?: number | number[];
showSymbol?: boolean;
/** Stroke width of the line, or of a radar web's outline. */
strokeWidth?: number;
symbolSize?: number;
yAxisIndex?: number;
color?: string;
label?: boolean;
/**
* ZardUI extension. Opacity of the filled band, for `area` and `radar` series.
* Ignored when the chart renders a gradient fill.
*/
fillOpacity?: number;
}
/** Either the shorthand list of data keys or the fully described series. */
export type ZardChartSeriesInput = readonly string[] | readonly ZardChartSeries[];
/** Escape hatch: deep-merged into the generated option, always winning. */
export type ZardChartOptionOverride = EChartsOption;
/** Colors of everything that is not a series: grid, axes, labels and surfaces. */
export interface ZardChartChromeColors {
border: string;
mutedForeground: string;
background: string;
foreground: string;
}
/** What the legend needs to render one entry, resolved by the parent chart. */
export interface ZardChartLegendEntry {
/** The name ECharts knows the series (or pie slice) by. */
name: string;
/** The data key this entry came from. */
dataKey: string;
label: string;
color: string;
icon?: string;
}
import { ApplicationConfig } from '@angular/core';
import { provideZardCharts } from '@/shared/components/chart/chart-echarts.provider';
export const appConfig: ApplicationConfig = {
providers: [
provideZardCharts(),
],
};import { ZardChartImports } from '@/shared/components/chart/chart.imports';<z-chart
[zConfig]="chartConfig"
[zData]="chartData"
zType="bar"
[zSeries]="['desktop', 'mobile']"
zXAxisKey="month"
class="h-[250px] w-full"
>
<z-chart-tooltip zIndicator="dot" />
<z-chart-legend />
</z-chart>import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
// The config is decoupled from the data. It maps every data key to how it should be
// presented — label, color and (optionally) an icon rendered by the legend.
export const chartConfig: ZardChartConfig = {
desktop: {
label: 'Desktop',
color: 'var(--chart-1)',
icon: 'lucideMonitor',
},
mobile: {
label: 'Mobile',
// `theme` wins over `color` and lets each mode pick its own value.
theme: { light: 'var(--chart-2)', dark: 'var(--chart-5)' },
},
};/* Recommended: declare the palette once, as CSS variables, and let every chart pick it up.
The chart resolves `var(--token)` to a computed value before handing it to ECharts and
re-resolves it whenever the theme changes. */
:root,
.dark {
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
}
/* Hex, rgb(), hsl() and oklch() literals are accepted too — anywhere a color is expected. */z-chart handles that on its own with aspect-video; pass a height class only when you want a different shape, and note that a height without an aspect ratio needs a width too.<z-chart zType="bar" [zData]="chartData" [zSeries]="series" />
<z-chart class="h-[250px] w-full" zType="bar" [zData]="chartData" />
<z-chart class="mx-auto aspect-square h-[250px]" zType="pie" [zData]="chartData" />provideZardCharts() and imported lazily, so it never reaches the initial bundle. Trim the registry to what you actually render.import { BarChart, GaugeChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
AriaComponent,
BrushComponent,
DatasetComponent,
DataZoomComponent,
GraphicComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
MarkPointComponent,
PolarComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
// Drop what you do not use — every entry removed here leaves the bundle.
// Two must stay: LegendComponent (the HTML legend dispatches `legendToggleSelect`
// through it) and SVGRenderer (the server renders through renderToSVGString()).
echarts.use([
BarChart,
LineChart,
PieChart,
RadarChart,
GaugeChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
MarkLineComponent,
MarkAreaComponent,
MarkPointComponent,
PolarComponent,
GraphicComponent,
ToolboxComponent,
BrushComponent,
AriaComponent,
DatasetComponent,
TransformComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer,
SVGRenderer,
]);
export const zardEcharts = echarts;import { provideEchartsCore } from 'ngx-echarts';
// The registry above is imported lazily, so roughly a megabyte of ECharts stays out of
// your application's initial bundle: it is fetched the first time a chart is created.
export const provideZardCharts = () =>
provideEchartsCore({ echarts: () => import('./chart-echarts.registry').then(module => module.zardEcharts) });z-chart-tooltip declares the tooltip: zLabelKey and zNameKey pick the config keys used for the heading and each row, zHideLabel and zHideIndicator control what shows, and the tooltip ships the same three indicator shapes as shadcn/ui — dot, line and dashed.zIndicator="dot"
zIndicator="line"
zIndicator="dashed"
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig, ZardChartTooltipIndicator } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-tooltip-indicators',
imports: [ZardCardImports, ZardChartImports],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Tooltip Indicators" />
<z-card-description zDescription="The three indicator shapes, side by side" />
</z-card-header>
<z-card-content>
<div class="grid gap-6 md:grid-cols-3">
@for (indicator of indicators; track indicator) {
<div class="flex flex-col gap-2">
<p class="text-muted-foreground text-xs">zIndicator="{{ indicator }}"</p>
<z-chart
zType="bar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
class="h-[180px] w-full"
>
<z-chart-tooltip [zIndicator]="indicator" [zDefaultIndex]="1" />
</z-chart>
</div>
}
</div>
</z-card-content>
</z-card>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartTooltipIndicatorsComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series = ['desktop', 'mobile'];
protected readonly indicators: ZardChartTooltipIndicator[] = ['dot', 'line', 'dashed'];
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
z-chart-legend renders the shadcn markup below (or above) the chart and toggles a series on click when it is hovered or pressed. zVerticalAlign picks which side it sits on.zVerticalAlign="top"
zVerticalAlign="bottom"
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
import type { ZardChartLegendAlignVariants } from '@/shared/components/chart/chart.variants';
@Component({
selector: 'z-demo-chart-legend',
imports: [ZardCardImports, ZardChartImports],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Legend" />
<z-card-description zDescription="zVerticalAlign places z-chart-legend above or below the chart" />
</z-card-header>
<z-card-content>
<div class="grid gap-6 md:grid-cols-2">
@for (align of aligns; track align) {
<div class="flex flex-col gap-2">
<p class="text-muted-foreground text-xs">zVerticalAlign="{{ align }}"</p>
<z-chart
zType="bar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
class="h-[220px] w-full"
>
<z-chart-legend [zVerticalAlign]="align" />
</z-chart>
</div>
}
</div>
</z-card-content>
</z-card>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartLegendComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series = ['desktop', 'mobile'];
protected readonly aligns: ZardChartLegendAlignVariants[] = ['top', 'bottom'];
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
zAccessibility (on by default) sets role="img" and a generated aria-label on the chart surface and turns on ECharts' own aria description. Turn it off only if an accessible summary is provided elsewhere.role="img" aria-label="bar chart of Desktop, Mobile"
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
import { ZardSwitchComponent } from '@/shared/components/switch/switch.component';
@Component({
selector: 'z-demo-chart-accessibility',
imports: [ZardCardImports, ZardChartImports, ZardSwitchComponent],
template: `
<z-card class="w-full">
<z-card-header class="flex flex-row items-center justify-between gap-4">
<div>
<z-card-title zTitle="Accessibility" />
<z-card-description zDescription="zAccessibility puts an ARIA role and a generated label on the canvas" />
</div>
<z-switch zId="chart-accessibility" [(zChecked)]="accessible">zAccessibility</z-switch>
</z-card-header>
<z-card-content>
<z-chart
zType="bar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
[zAccessibility]="accessible()"
class="h-[220px] w-full"
/>
<p class="text-muted-foreground mt-3 font-mono text-xs">
{{ accessible() ? 'role="img" aria-label="bar chart of Desktop, Mobile"' : 'role and aria-label removed' }}
</p>
</z-card-content>
</z-card>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartAccessibilityComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series = ['desktop', 'mobile'];
protected readonly accessible = signal(true);
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig, ZardChartSeries } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-echarts-datazoom',
imports: [ZardCardImports, ZardChartImports],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="ECharts Extras - Data Zoom" />
<z-card-description zDescription="Opt-in ECharts features Recharts has no counterpart for" />
</z-card-header>
<z-card-content>
<z-chart
zType="line"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="date"
[zXAxisFormatter]="shortDate"
zDataZoom
zToolbox
class="w-full"
>
<z-chart-tooltip zIndicator="dot" />
</z-chart>
</z-card-content>
</z-card>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartEchartsDatazoomComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
];
protected readonly series: ZardChartSeries[] = [
{ dataKey: 'desktop', smooth: true },
{ dataKey: 'mobile', smooth: true },
];
protected readonly shortDate = (value: string) =>
new Date(`${value}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
[zOption] away — it is deep-merged over the option.import type { ZardChartOptionOverride } from '@/shared/components/chart/chart.types';
// `[zOption]` is deep-merged over the generated option and always wins, so any ECharts
// feature is one property away. Arrays merge index by index: the object below patches
// the first series instead of replacing the whole list.
export const override: ZardChartOptionOverride = {
series: [{ label: { position: 'insideLeft', color: '#fff', formatter: '{b}' } }],
xAxis: { axisLabel: { rotate: 45 } },
};natural, monotone and basis; ECharts has a single smooth flag, so all three map to it. Pass a number between 0 and 1 to a series' smooth for finer control.zStackOffset="expand" normalises the rows to 0-1 before they reach it and clamps the value axis. Format the ticks with [zYAxisFormatter].axisLabel.hideOverlap instead: labels stop colliding, but the threshold is not tunable.z-chart-legend renders real HTML. The native legend stays registered but hidden, because it is what toggles a series.graphic text, so zCenterValue and zCenterLabel are plain strings, and zRadialLabel lays a ring name out one glyph at a time.[zSsrWidth] and [zSsrHeight] to match your layout.zSeries to draw them side by side.import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideTrendingUp } from '@ng-icons/lucide';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-bar-multiple',
imports: [ZardCardImports, ZardChartImports, NgIcon],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Bar Chart - Multiple" />
<z-card-description zDescription="January - June 2024" />
</z-card-header>
<z-card-content>
<z-chart
zType="bar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
class="w-full"
>
<z-chart-tooltip zIndicator="dashed" />
</z-chart>
</z-card-content>
<z-card-footer class="flex-col items-start gap-2 bg-transparent px-4 pt-0 pb-4 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
<ng-icon name="lucideTrendingUp" class="size-4" />
</div>
<div class="text-muted-foreground leading-none">Showing total visitors for the last 6 months</div>
</z-card-footer>
</z-card>
`,
providers: [provideIcons({ lucideTrendingUp })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartBarMultipleComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series = ['desktop', 'mobile'];
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
signal is all it takes to make the chart interactive.import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig, ZardChartSeries } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-bar-interactive',
imports: [ZardCardImports, ZardChartImports],
template: `
<z-card class="w-full py-0">
<z-card-header class="flex flex-col items-stretch border-b p-0! sm:flex-row">
<div class="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:py-0!">
<z-card-title zTitle="Bar Chart - Interactive" />
<z-card-description zDescription="Showing total visitors for the last 3 months" />
</div>
<div class="flex">
@for (option of options; track option.key) {
<button
type="button"
[attr.data-active]="active() === option.key"
[attr.aria-pressed]="active() === option.key"
class="data-[active=true]:bg-muted/50 relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
(click)="select(option.key)"
>
<span class="text-muted-foreground text-xs">{{ option.label }}</span>
<span class="text-lg leading-none font-bold sm:text-3xl">{{ totals()[option.key] }}</span>
</button>
}
</div>
</z-card-header>
<z-card-content class="px-2 sm:p-6">
<z-chart
zType="bar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series()"
zXAxisKey="date"
[zXAxisFormatter]="shortDate"
class="aspect-auto h-[250px] w-full"
>
<z-chart-tooltip zIndicator="dot" zNameKey="views" class="w-[150px]" [zLabelFormatter]="longDate" zCursor />
</z-chart>
</z-card-content>
</z-card>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartBarInteractiveComponent {
protected readonly chartConfig: ZardChartConfig = {
views: { label: 'Page Views' },
desktop: { label: 'Desktop', color: 'var(--chart-2)' },
mobile: { label: 'Mobile', color: 'var(--chart-1)' },
};
protected readonly chartData = [
{ date: '2024-04-01', desktop: 222, mobile: 150 },
{ date: '2024-04-02', desktop: 97, mobile: 180 },
{ date: '2024-04-03', desktop: 167, mobile: 120 },
{ date: '2024-04-04', desktop: 242, mobile: 260 },
{ date: '2024-04-05', desktop: 373, mobile: 290 },
{ date: '2024-04-06', desktop: 301, mobile: 340 },
{ date: '2024-04-07', desktop: 245, mobile: 180 },
{ date: '2024-04-08', desktop: 409, mobile: 320 },
{ date: '2024-04-09', desktop: 59, mobile: 110 },
{ date: '2024-04-10', desktop: 261, mobile: 190 },
{ date: '2024-04-11', desktop: 327, mobile: 350 },
{ date: '2024-04-12', desktop: 292, mobile: 210 },
{ date: '2024-04-13', desktop: 342, mobile: 380 },
{ date: '2024-04-14', desktop: 137, mobile: 220 },
{ date: '2024-04-15', desktop: 120, mobile: 170 },
{ date: '2024-04-16', desktop: 138, mobile: 190 },
{ date: '2024-04-17', desktop: 446, mobile: 360 },
{ date: '2024-04-18', desktop: 364, mobile: 410 },
{ date: '2024-04-19', desktop: 243, mobile: 180 },
{ date: '2024-04-20', desktop: 89, mobile: 150 },
{ date: '2024-04-21', desktop: 137, mobile: 200 },
{ date: '2024-04-22', desktop: 224, mobile: 170 },
{ date: '2024-04-23', desktop: 138, mobile: 230 },
{ date: '2024-04-24', desktop: 387, mobile: 290 },
{ date: '2024-04-25', desktop: 215, mobile: 250 },
{ date: '2024-04-26', desktop: 75, mobile: 130 },
{ date: '2024-04-27', desktop: 383, mobile: 420 },
{ date: '2024-04-28', desktop: 122, mobile: 180 },
{ date: '2024-04-29', desktop: 315, mobile: 240 },
{ date: '2024-04-30', desktop: 454, mobile: 380 },
{ date: '2024-05-01', desktop: 165, mobile: 220 },
{ date: '2024-05-02', desktop: 293, mobile: 310 },
{ date: '2024-05-03', desktop: 247, mobile: 190 },
{ date: '2024-05-04', desktop: 385, mobile: 420 },
{ date: '2024-05-05', desktop: 481, mobile: 390 },
{ date: '2024-05-06', desktop: 498, mobile: 520 },
{ date: '2024-05-07', desktop: 388, mobile: 300 },
{ date: '2024-05-08', desktop: 149, mobile: 210 },
{ date: '2024-05-09', desktop: 227, mobile: 180 },
{ date: '2024-05-10', desktop: 293, mobile: 330 },
{ date: '2024-05-11', desktop: 335, mobile: 270 },
{ date: '2024-05-12', desktop: 197, mobile: 240 },
{ date: '2024-05-13', desktop: 197, mobile: 160 },
{ date: '2024-05-14', desktop: 448, mobile: 490 },
{ date: '2024-05-15', desktop: 473, mobile: 380 },
{ date: '2024-05-16', desktop: 338, mobile: 400 },
{ date: '2024-05-17', desktop: 499, mobile: 420 },
{ date: '2024-05-18', desktop: 315, mobile: 350 },
{ date: '2024-05-19', desktop: 235, mobile: 180 },
{ date: '2024-05-20', desktop: 177, mobile: 230 },
{ date: '2024-05-21', desktop: 82, mobile: 140 },
{ date: '2024-05-22', desktop: 81, mobile: 120 },
{ date: '2024-05-23', desktop: 252, mobile: 290 },
{ date: '2024-05-24', desktop: 294, mobile: 220 },
{ date: '2024-05-25', desktop: 201, mobile: 250 },
{ date: '2024-05-26', desktop: 213, mobile: 170 },
{ date: '2024-05-27', desktop: 420, mobile: 460 },
{ date: '2024-05-28', desktop: 233, mobile: 190 },
{ date: '2024-05-29', desktop: 78, mobile: 130 },
{ date: '2024-05-30', desktop: 340, mobile: 280 },
{ date: '2024-05-31', desktop: 178, mobile: 230 },
{ date: '2024-06-01', desktop: 178, mobile: 200 },
{ date: '2024-06-02', desktop: 470, mobile: 410 },
{ date: '2024-06-03', desktop: 103, mobile: 160 },
{ date: '2024-06-04', desktop: 439, mobile: 380 },
{ date: '2024-06-05', desktop: 88, mobile: 140 },
{ date: '2024-06-06', desktop: 294, mobile: 250 },
{ date: '2024-06-07', desktop: 323, mobile: 370 },
{ date: '2024-06-08', desktop: 385, mobile: 320 },
{ date: '2024-06-09', desktop: 438, mobile: 480 },
{ date: '2024-06-10', desktop: 155, mobile: 200 },
{ date: '2024-06-11', desktop: 92, mobile: 150 },
{ date: '2024-06-12', desktop: 492, mobile: 420 },
{ date: '2024-06-13', desktop: 81, mobile: 130 },
{ date: '2024-06-14', desktop: 426, mobile: 380 },
{ date: '2024-06-15', desktop: 307, mobile: 350 },
{ date: '2024-06-16', desktop: 371, mobile: 310 },
{ date: '2024-06-17', desktop: 475, mobile: 520 },
{ date: '2024-06-18', desktop: 107, mobile: 170 },
{ date: '2024-06-19', desktop: 341, mobile: 290 },
{ date: '2024-06-20', desktop: 408, mobile: 450 },
{ date: '2024-06-21', desktop: 169, mobile: 210 },
{ date: '2024-06-22', desktop: 317, mobile: 270 },
{ date: '2024-06-23', desktop: 480, mobile: 530 },
{ date: '2024-06-24', desktop: 132, mobile: 180 },
{ date: '2024-06-25', desktop: 141, mobile: 190 },
{ date: '2024-06-26', desktop: 434, mobile: 380 },
{ date: '2024-06-27', desktop: 448, mobile: 490 },
{ date: '2024-06-28', desktop: 149, mobile: 200 },
{ date: '2024-06-29', desktop: 103, mobile: 160 },
{ date: '2024-06-30', desktop: 446, mobile: 400 },
];
protected readonly options = [
{ key: 'desktop', label: 'Desktop' },
{ key: 'mobile', label: 'Mobile' },
];
protected readonly active = signal('desktop');
protected readonly totals = computed<Record<string, string>>(() => ({
desktop: this.sum('desktop'),
mobile: this.sum('mobile'),
}));
protected readonly series = computed<ZardChartSeries[]>(() => [{ dataKey: this.active() }]);
protected readonly shortDate = (value: string) =>
new Date(`${value}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
protected readonly longDate = (value: string) =>
new Date(`${value}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
protected select(key: string): void {
this.active.set(key);
}
private sum(key: 'desktop' | 'mobile'): string {
return this.chartData.reduce((total, row) => total + row[key], 0).toLocaleString();
}
}
zGradient to fill the area under a zType="area" series with a vertical gradient.import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideTrendingUp } from '@ng-icons/lucide';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig, ZardChartSeries } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-area-gradient',
imports: [ZardCardImports, ZardChartImports, NgIcon],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Area Chart - Gradient" />
<z-card-description zDescription="Showing total visitors for the last 6 months" />
</z-card-header>
<z-card-content>
<z-chart
zType="area"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
zStacked
zGradient
class="w-full"
>
<z-chart-tooltip zIndicator="dot" />
</z-chart>
</z-card-content>
<z-card-footer class="flex-col items-start gap-2 bg-transparent px-4 pt-0 pb-4 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
<ng-icon name="lucideTrendingUp" class="size-4" />
</div>
<div class="text-muted-foreground leading-none">January - June 2024</div>
</z-card-footer>
</z-card>
`,
providers: [provideIcons({ lucideTrendingUp })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartAreaGradientComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
mobile: { label: 'Mobile', color: 'var(--chart-2)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series: ZardChartSeries[] = [
{ dataKey: 'mobile', smooth: true },
{ dataKey: 'desktop', smooth: true },
];
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideTrendingUp } from '@ng-icons/lucide';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig, ZardChartSeries } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-line-default',
imports: [ZardCardImports, ZardChartImports, NgIcon],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Line Chart" />
<z-card-description zDescription="January - June 2024" />
</z-card-header>
<z-card-content>
<z-chart
zType="line"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zXAxisKey="month"
[zXAxisFormatter]="shortMonth"
class="w-full"
>
<z-chart-tooltip zIndicator="line" />
</z-chart>
</z-card-content>
<z-card-footer class="flex-col items-start gap-2 bg-transparent px-4 pt-0 pb-4 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
<ng-icon name="lucideTrendingUp" class="size-4" />
</div>
<div class="text-muted-foreground leading-none">Showing total visitors for the last 6 months</div>
</z-card-footer>
</z-card>
`,
providers: [provideIcons({ lucideTrendingUp })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartLineDefaultComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series: ZardChartSeries[] = [{ dataKey: 'desktop', smooth: true }];
protected readonly shortMonth = (value: string) => value.slice(0, 3);
}
zInnerRadius for the donut and zCenterValue / zCenterLabel for the text in the middle.import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideTrendingUp } from '@ng-icons/lucide';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-pie-donut-text',
imports: [ZardCardImports, ZardChartImports, NgIcon],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Pie Chart - Donut with Text" />
<z-card-description zDescription="January - June 2024" />
</z-card-header>
<z-card-content>
<z-chart
zType="pie"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zNameKey="browser"
zInnerRadius="48%"
zCenterValue="925"
zCenterLabel="Visitors"
class="mx-auto aspect-square h-[250px]"
>
<z-chart-tooltip zTrigger="item" zIndicator="dot" zHideLabel />
</z-chart>
</z-card-content>
<z-card-footer class="flex-col gap-2 bg-transparent px-4 pt-0 pb-4 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
<ng-icon name="lucideTrendingUp" class="size-4" />
</div>
<div class="text-muted-foreground leading-none">Showing total visitors for the last 6 months</div>
</z-card-footer>
</z-card>
`,
providers: [provideIcons({ lucideTrendingUp })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartPieDonutTextComponent {
protected readonly chartConfig: ZardChartConfig = {
visitors: { label: 'Visitors' },
chrome: { label: 'Chrome', color: 'var(--chart-1)' },
safari: { label: 'Safari', color: 'var(--chart-2)' },
firefox: { label: 'Firefox', color: 'var(--chart-3)' },
edge: { label: 'Edge', color: 'var(--chart-4)' },
other: { label: 'Other', color: 'var(--chart-5)' },
};
protected readonly chartData = [
{ browser: 'chrome', visitors: 275 },
{ browser: 'safari', visitors: 200 },
{ browser: 'firefox', visitors: 187 },
{ browser: 'edge', visitors: 173 },
{ browser: 'other', visitors: 90 },
];
protected readonly series = ['visitors'];
}
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideTrendingUp } from '@ng-icons/lucide';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-radar-default',
imports: [ZardCardImports, ZardChartImports, NgIcon],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Radar Chart" />
<z-card-description zDescription="Showing total visitors for the last 6 months" />
</z-card-header>
<z-card-content>
<z-chart
zType="radar"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zNameKey="month"
class="mx-auto aspect-square h-[250px]"
>
<z-chart-tooltip zTrigger="item" zIndicator="dot" zHideLabel />
</z-chart>
</z-card-content>
<z-card-footer class="flex-col gap-2 bg-transparent px-4 pt-0 pb-4 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
<ng-icon name="lucideTrendingUp" class="size-4" />
</div>
<div class="text-muted-foreground leading-none">January - June 2024</div>
</z-card-footer>
</z-card>
`,
providers: [provideIcons({ lucideTrendingUp })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartRadarDefaultComponent {
protected readonly chartConfig: ZardChartConfig = {
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
};
protected readonly chartData = [
{ month: 'January', desktop: 186, mobile: 80 },
{ month: 'February', desktop: 305, mobile: 200 },
{ month: 'March', desktop: 237, mobile: 120 },
{ month: 'April', desktop: 73, mobile: 190 },
{ month: 'May', desktop: 209, mobile: 130 },
{ month: 'June', desktop: 214, mobile: 140 },
];
protected readonly series = ['desktop'];
}
zRadialVariant="bar" chart.import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideTrendingUp } from '@ng-icons/lucide';
import { ZardCardImports } from '@/shared/components/card/card.imports';
import { ZardChartImports } from '@/shared/components/chart/chart.imports';
import type { ZardChartConfig } from '@/shared/components/chart/chart.types';
@Component({
selector: 'z-demo-chart-radial-simple',
imports: [ZardCardImports, ZardChartImports, NgIcon],
template: `
<z-card class="w-full">
<z-card-header>
<z-card-title zTitle="Radial Chart" />
<z-card-description zDescription="January - June 2024" />
</z-card-header>
<z-card-content>
<z-chart
zType="radial"
[zConfig]="chartConfig"
[zData]="chartData"
[zSeries]="series"
zNameKey="browser"
zInnerRadius="24%"
zOuterRadius="88%"
class="mx-auto aspect-square h-[250px]"
>
<z-chart-tooltip zTrigger="item" zIndicator="dot" zHideLabel />
</z-chart>
</z-card-content>
<z-card-footer class="flex-col gap-2 bg-transparent px-4 pt-0 pb-4 text-sm">
<div class="flex items-center gap-2 leading-none font-medium">
Trending up by 5.2% this month
<ng-icon name="lucideTrendingUp" class="size-4" />
</div>
<div class="text-muted-foreground leading-none">Showing total visitors for the last 6 months</div>
</z-card-footer>
</z-card>
`,
providers: [provideIcons({ lucideTrendingUp })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ZardDemoChartRadialSimpleComponent {
protected readonly chartConfig: ZardChartConfig = {
visitors: { label: 'Visitors' },
chrome: { label: 'Chrome', color: 'var(--chart-1)' },
safari: { label: 'Safari', color: 'var(--chart-2)' },
firefox: { label: 'Firefox', color: 'var(--chart-3)' },
edge: { label: 'Edge', color: 'var(--chart-4)' },
other: { label: 'Other', color: 'var(--chart-5)' },
};
protected readonly chartData = [
{ browser: 'chrome', visitors: 275 },
{ browser: 'safari', visitors: 200 },
{ browser: 'firefox', visitors: 187 },
{ browser: 'edge', visitors: 173 },
{ browser: 'other', visitors: 90 },
];
protected readonly series = ['visitors'];
}