
### Basic Chart

```svelte
<script lang="ts">
	import { EChartsRadialChart } from '$lib/components/evilcharts/charts/echarts-radial-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data = [
		{ browser: 'chrome', visitors: 275 },
		{ browser: 'safari', visitors: 200 },
		{ browser: 'firefox', visitors: 187 },
		{ browser: 'edge', visitors: 173 },
		{ browser: 'other', visitors: 90 }
	];

	const chartConfig = {
		chrome: {
			label: 'Chrome',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		safari: {
			label: 'Safari',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		firefox: {
			label: 'Firefox',
			colors: {
				light: ['#f59e0b'],
				dark: ['#fbbf24']
			}
		},
		edge: {
			label: 'Edge',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		other: {
			label: 'Other',
			colors: {
				light: ['#6b7280'],
				dark: ['#9ca3af']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadialChart
	class="h-full w-full p-4"
	{data}
	nameKey="browser"
	config={chartConfig}
	variant="full"
	accessibility={{
		label: 'Browser visitor totals radial chart',
		description:
			'Concentric bars compare visitors from Chrome, Safari, Firefox, Edge, and other browsers.'
	}}
>
	<EChartsRadialChart.Legend isClickable />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" isClickable />
</EChartsRadialChart>
```

## Installation


  
  
    ### npm

```bash
npx shadcn-svelte@latest add @evilcharts/echarts-radial-chart
```

### yarn

```bash
yarn dlx shadcn-svelte@latest add @evilcharts/echarts-radial-chart
```

### bun

```bash
bunx --bun shadcn-svelte@latest add @evilcharts/echarts-radial-chart
```

### pnpm

```bash
pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-radial-chart
```
  
  
    
      
        ### Install the following dependencies:
        
          ### npm

```bash
npm install echarts
```

### yarn

```bash
yarn add echarts
```

### bun

```bash
bun add echarts
```

### pnpm

```bash
pnpm add echarts
```
        
      
      
        ### Copy and paste the following code snippets into your project.
        

In your `components` directory, create `evilcharts` → `charts`, then paste the radial-chart code into a new `echarts-radial-chart` file there.


        
          ### $lib/components/evilcharts/charts/echarts-radial-chart

`$lib/components/evilcharts/charts/echarts-radial-chart/background.svelte`

```svelte
<script lang="ts">
	import type { BackgroundVariant } from './types.js';
	let { variant }: { variant: BackgroundVariant } = $props();
	const rawId = $props.id();
	const id = rawId.replaceAll(':', '');
	const patternId = $derived(`${id}-${variant}`);
	const maskId = `${id}-fade`;
</script>

<svg class="pointer-events-none absolute inset-0 z-0 h-full w-full" aria-hidden="true">
	<defs>
		{#if variant === 'dots'}
			<pattern id={patternId} width="20" height="20" patternUnits="userSpaceOnUse"
				><circle class="text-border" cx="2" cy="2" r="1" fill="currentColor" /></pattern
			>
		{:else if variant === 'grid'}
			<pattern id={patternId} width="20" height="20" patternUnits="userSpaceOnUse"
				><path
					class="text-border"
					d="M20 0H0v20"
					fill="none"
					stroke="currentColor"
					stroke-width=".5"
				/></pattern
			>
		{:else if variant === 'cross-hatch'}
			<pattern id={patternId} width="20" height="20" patternUnits="userSpaceOnUse"
				><path
					class="text-border/60 dark:text-border/50"
					d="M0 0l20 20M20 0L0 20"
					fill="none"
					stroke="currentColor"
					stroke-width=".5"
				/></pattern
			>
		{:else if variant === 'diagonal-lines'}
			<pattern
				id={patternId}
				width="6"
				height="6"
				patternUnits="userSpaceOnUse"
				patternTransform="rotate(45)"
				><line class="text-border" y2="6" stroke="currentColor" stroke-width=".5" /></pattern
			>
		{:else if variant === 'plus'}
			<pattern id={patternId} width="16" height="16" patternUnits="userSpaceOnUse"
				><path
					class="text-border"
					d="M8 4v8M4 8h8"
					fill="none"
					stroke="currentColor"
					stroke-width=".5"
					stroke-linecap="round"
				/></pattern
			>
		{:else if variant === 'falling-triangles'}
			<pattern id={patternId} width="18" height="36" patternUnits="userSpaceOnUse"
				><path
					class="text-border"
					d="M2 6h12L8 18 2 6zm18 36h12l-6 12-6-12z"
					transform="scale(.5)"
					fill="currentColor"
					fill-opacity=".4"
				/></pattern
			>
		{:else if variant === '4-pointed-star'}
			<pattern id={patternId} width="16" height="16" patternUnits="userSpaceOnUse"
				><polygon
					class="text-border"
					points="5 3 8 4 5 5 4 8 3 5 0 4 3 3 4 0"
					fill="currentColor"
					fill-opacity=".4"
				/></pattern
			>
		{:else if variant === 'tiny-checkers'}
			<pattern id={patternId} width="8" height="8" patternUnits="userSpaceOnUse"
				><path
					class="text-border"
					d="M0 0h4v4H0zm4 4h4v4H4z"
					fill="currentColor"
					fill-opacity=".2"
				/></pattern
			>
		{:else if variant === 'overlapping-circles'}
			<pattern id={patternId} width="40" height="40" patternUnits="userSpaceOnUse"
				><path
					class="text-border"
					d="M25 25c0-2.762 2.238-5 5-5s5 2.238 5 5-2.238 5-5 5c0 2.762-2.238 5-5 5s-5-2.238-5-5 2.238-5 5-5zM5 5c0-2.762 2.238-5 5-5s5 2.238 5 5-2.238 5-5 5c0 2.762-2.238 5-5 5S0 12.762 0 10s2.238-5 5-5zm5 4c2.209 0 4-1.791 4-4s-1.791-4-4-4-4 1.791-4 4 1.791 4 4 4zm20 20c2.209 0 4-1.791 4-4s-1.791-4-4-4-4 1.791-4 4 1.791 4 4 4z"
					fill="currentColor"
					fill-opacity=".4"
					fill-rule="evenodd"
				/></pattern
			>
		{:else if variant === 'wiggle-lines'}
			<pattern
				id={patternId}
				width="52"
				height="26"
				patternUnits="userSpaceOnUse"
				patternTransform="scale(0.6)"
				><path
					class="text-border"
					d="M10 10c0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6h2c0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4 3.314 0 6 2.686 6 6 0 2.21 1.79 4 4 4v2c-3.314 0-6-2.686-6-6 0-2.21-1.79-4-4-4-3.314 0-6-2.686-6-6zm25.464-1.95l8.486 8.486-1.414 1.414-8.486-8.486 1.414-1.414z"
					fill="currentColor"
					fill-opacity=".4"
				/></pattern
			>
		{:else}
			<pattern
				id={patternId}
				width="100"
				height="100"
				patternUnits="userSpaceOnUse"
				patternTransform="scale(0.6667)"
				><path
					class="text-border"
					d="M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z"
					fill="currentColor"
					fill-opacity=".4"
					fill-rule="evenodd"
				/></pattern
			>
		{/if}
		<filter id={`${maskId}-blur`}><feGaussianBlur stdDeviation="25" /></filter>
		<mask id={maskId} maskUnits="userSpaceOnUse"
			><rect
				x="8%"
				y="20%"
				width="85%"
				height="60%"
				fill="white"
				filter={`url(#${maskId}-blur)`}
			/></mask
		>
	</defs>
	<rect width="100%" height="100%" fill={`url(#${patternId})`} mask={`url(#${maskId})`} />
</svg>
```

`$lib/components/evilcharts/charts/echarts-radial-chart/index.ts`

```ts
import Root from './radial-chart.svelte';
import RadialBar from './radial-bar.svelte';
import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
import Legend from './legend.svelte';

type RootComponent = typeof Root;
export const EChartsRadialChart: RootComponent & {
	RadialBar: typeof RadialBar;
	Tooltip: typeof Tooltip;
	Legend: typeof Legend;
} = Object.assign(Root, { RadialBar, Tooltip, Legend });

export type {
	ChartAccessibility,
	ChartConfig,
	EChartsRenderer
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
export type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
export type {
	TooltipPosition,
	TooltipRoundness,
	TooltipVariant
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
export type { BackgroundVariant, RadialSelection, RadialVariant } from './types.js';
```

`$lib/components/evilcharts/charts/echarts-radial-chart/legend.svelte`

```svelte
<script lang="ts">
	import { getEChartsSharedSlotContext } from '$lib/components/evilcharts/ui/echarts-chart/index.js';
	import type { LegendProps, LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js';

	let {
		variant = 'rounded-square',
		align = 'center',
		verticalAlign = 'bottom',
		isClickable = false
	}: LegendProps = $props();

	const token = $props.id();
	const slots = getEChartsSharedSlotContext();
	$effect(() =>
		slots.register('legend', token, () => ({
			variant: variant as LegendVariant,
			align,
			verticalAlign,
			isClickable
		}))
	);
</script>
```

`$lib/components/evilcharts/charts/echarts-radial-chart/option.ts`

```ts
import type { BarSeriesOption } from 'echarts/charts';
import type { PolarComponentOption, TooltipComponentOption } from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import {
	getColorsCount,
	withAlpha,
	type ChartConfig,
	type ResolvedColors
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import {
	resolveTooltipPosition,
	roundnessClass,
	tooltipIndicatorHtml,
	tooltipRow,
	tooltipVariantClass
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
import {
	LOADING_BARS,
	LOADING_MAX,
	type RadialBarRegistration,
	type RadialVariant,
	type TooltipRegistration
} from './types.js';

export type EChartsRadialOption = ComposeOption<
	BarSeriesOption | TooltipComponentOption | PolarComponentOption
>;

type ArrayItem<T> = T extends readonly (infer Item)[] ? Item : T;
type PolarOption = ArrayItem<NonNullable<EChartsRadialOption['polar']>>;
type AngleAxisOption = ArrayItem<NonNullable<EChartsRadialOption['angleAxis']>>;
type RadiusAxisOption = ArrayItem<NonNullable<EChartsRadialOption['radiusAxis']>>;

const FALLBACK_COLOR = 'rgba(120, 120, 120, 1)';
const TRACK_OPACITY = 0.15;
const SELECTED_DIM_OPACITY = 0.15;

export type RadialOptionContext = {
	categories: string[];
	values: number[];
	config: ChartConfig;
	radialBar: RadialBarRegistration;
	variant: RadialVariant;
	innerRadius: number | string;
	outerRadius: number | string;
	angleMax: number;
	selectedBar: string | null;
	tooltip?: TooltipRegistration;
	isLoading: boolean;
	loadingData: number[];
	resolved: ResolvedColors;
	animation: boolean;
	reducedMotion: boolean;
};

export function niceCeil(value: number): number {
	if (!Number.isFinite(value) || value <= 0) return 1;
	const rough = value / 5;
	const base = 10 ** Math.floor(Math.log10(rough));
	const fraction = rough / base;
	const niceFraction = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10;
	const interval = niceFraction * base;
	return Math.ceil(value / interval) * interval;
}

export function createRadialLoadingData(count = LOADING_BARS): number[] {
	const rows: number[] = [];
	let value = 55 + Math.random() * 30;
	for (let index = 0; index < count; index += 1) {
		value = Math.min(LOADING_MAX, Math.max(40, value + (Math.random() - 0.5) * 30));
		rows.push(Math.round(value));
	}
	return rows;
}

function geometry(variant: RadialVariant) {
	return variant === 'semi'
		? { center: ['50%', '70%'] as [string, string], startAngle: 180, endAngle: 0 }
		: { center: ['50%', '50%'] as [string, string], startAngle: 90, endAngle: -270 };
}

function barPaint(colors: string[]): string | echarts.graphic.LinearGradient {
	if (colors.length <= 1) return colors[0] ?? FALLBACK_COLOR;
	return new echarts.graphic.LinearGradient(
		0,
		0,
		1,
		1,
		colors.map((color, index) => ({ offset: index / (colors.length - 1), color }))
	);
}

function polar(context: RadialOptionContext): PolarOption[] {
	const { center } = geometry(context.variant);
	const item: PolarOption = {
		center,
		radius: [context.innerRadius, context.outerRadius] as (number | string)[]
	};
	return [item, { ...item }];
}

function angleAxis(context: RadialOptionContext): AngleAxisOption[] {
	const { startAngle, endAngle } = geometry(context.variant);
	const item: AngleAxisOption = {
		type: 'value',
		min: 0,
		max: context.angleMax,
		startAngle,
		endAngle,
		clockwise: true,
		show: false,
		axisLine: { show: false },
		axisTick: { show: false },
		axisLabel: { show: false },
		splitLine: { show: false }
	};
	return [
		{ ...item, polarIndex: 0 },
		{ ...item, polarIndex: 1 }
	];
}

function radiusAxis(context: RadialOptionContext): RadiusAxisOption[] {
	const item: RadiusAxisOption = {
		type: 'category',
		data: context.categories,
		show: false,
		axisLine: { show: false },
		axisTick: { show: false },
		axisLabel: { show: false },
		splitLine: { show: false }
	};
	return [
		{ ...item, polarIndex: 0 },
		{ ...item, polarIndex: 1 }
	];
}

function track(context: RadialOptionContext, loading: boolean): BarSeriesOption {
	return {
		id: loading ? '__loading-track' : '__track',
		type: 'bar',
		coordinateSystem: 'polar',
		polarIndex: 1,
		data: context.categories.map(() => context.angleMax),
		barWidth: context.radialBar.barSize,
		roundCap: context.radialBar.cornerRadius > 0,
		silent: true,
		animation: false,
		emphasis: { disabled: true },
		itemStyle: { color: withAlpha(context.resolved.tokens.mutedForeground, TRACK_OPACITY) },
		z: 1
	};
}

function tooltip(context: RadialOptionContext): TooltipComponentOption {
	const slot = context.tooltip;
	return {
		show: Boolean(slot) && !context.isLoading,
		trigger: 'item',
		confine: true,
		displayTransition: false,
		backgroundColor: 'transparent',
		borderWidth: 0,
		padding: 0,
		extraCssText: 'box-shadow:none;',
		position: resolveTooltipPosition(slot?.position ?? 'variable'),
		formatter: (params) => {
			const item = (Array.isArray(params) ? params[0] : params) as {
				dataIndex?: number;
				value?: number | string;
				seriesId?: string;
			};
			if (!item || String(item.seriesId ?? '').startsWith('__')) return '';
			const key = context.categories[item.dataIndex ?? 0] ?? '';
			const configItem = context.config[key];
			const label = typeof configItem?.label === 'string' ? configItem.label : key;
			const value =
				typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value ?? '');
			const row = tooltipRow({
				indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(configItem ?? {})),
				labelText: label,
				valueText: value,
				dimmed: ''
			});
			return `<div class="grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl ${roundnessClass[slot?.roundness ?? 'lg']} ${tooltipVariantClass[slot?.variant ?? 'default']}"><div class="grid gap-1.5">${row}</div></div>`;
		}
	};
}

export function buildRadialOption(context: RadialOptionContext): EChartsRadialOption {
	if (context.isLoading) {
		const loadingContext = {
			...context,
			categories: Array.from({ length: LOADING_BARS }, (_, index) => String(index)),
			angleMax: LOADING_MAX
		};
		return {
			animation: false,
			aria: { enabled: false },
			polar: polar(loadingContext),
			angleAxis: angleAxis(loadingContext),
			radiusAxis: radiusAxis(loadingContext),
			tooltip: { show: false },
			series: [
				track(loadingContext, true),
				{
					id: '__loading',
					type: 'bar',
					coordinateSystem: 'polar',
					polarIndex: 0,
					data: context.loadingData,
					barWidth: context.radialBar.barSize,
					roundCap: context.radialBar.cornerRadius > 0,
					silent: true,
					emphasis: { disabled: true },
					animation: false,
					itemStyle: { color: withAlpha(context.resolved.tokens.foreground, 0) },
					z: 2
				}
			]
		};
	}

	const series: BarSeriesOption[] = [
		{
			id: 'radial-bars',
			type: 'bar',
			coordinateSystem: 'polar',
			polarIndex: 0,
			data: context.categories.map((key, index) => ({
				name: key,
				value: context.values[index] ?? 0,
				itemStyle: {
					color: barPaint(context.resolved.series[key] ?? [FALLBACK_COLOR]),
					opacity:
						context.radialBar.isClickable &&
						context.selectedBar !== null &&
						context.selectedBar !== key
							? SELECTED_DIM_OPACITY
							: 1
				}
			})),
			barWidth: context.radialBar.barSize,
			roundCap: context.radialBar.cornerRadius > 0,
			cursor: context.radialBar.isClickable ? 'pointer' : 'default',
			emphasis: { disabled: true },
			z: 3,
			animation: context.animation && !context.reducedMotion,
			animationDuration: 1000,
			animationDurationUpdate: 0
		}
	];
	if (context.radialBar.showBackground) series.push(track(context, false));

	const ariaDescription = context.categories
		.map((key, index) => {
			const configured = context.config[key]?.label;
			const label = typeof configured === 'string' && configured.length > 0 ? configured : key;
			return `${label} ${(context.values[index] ?? 0).toLocaleString()}`;
		})
		.join(', ');

	return {
		animation: context.animation && !context.reducedMotion,
		animationDuration: 1000,
		animationDurationUpdate: 0,
		aria: {
			enabled: true,
			label: { description: `Radial chart values: ${ariaDescription}.` }
		},
		polar: polar(context),
		angleAxis: angleAxis(context),
		radiusAxis: radiusAxis(context),
		tooltip: tooltip(context),
		series
	};
}

export function mergeRadialChartOptions(
	built: EChartsRadialOption,
	chartOptions?: Record<string, unknown>
): EChartsRadialOption {
	const merged = chartOptions ? { ...built, ...chartOptions } : built;
	return Object.assign(merged, {
		animation: built.animation,
		animationDuration: built.animationDuration,
		animationDurationUpdate: built.animationDurationUpdate
	}) as EChartsRadialOption;
}

export function radialShimmerStops(center: number, color: string) {
	const half = 0.2;
	const feather = 0.2;
	const alphaAt = (offset: number) => {
		const distance = Math.abs(offset - center);
		if (distance <= half - feather) return 0.4;
		if (distance >= half) return 0;
		return 0.4 * Math.sin(((1 - (distance - (half - feather)) / feather) * Math.PI) / 2);
	};
	return [
		0,
		center - half,
		center - half + feather,
		center,
		center + half - feather,
		center + half,
		1
	]
		.filter((offset) => offset >= 0 && offset <= 1)
		.sort((left, right) => left - right)
		.filter((offset, index, values) => index === 0 || offset - values[index - 1] > 1e-4)
		.map((offset) => ({ offset, color: withAlpha(color, alphaAt(offset)) }));
}
```

`$lib/components/evilcharts/charts/echarts-radial-chart/radial-bar.svelte`

```svelte
<script lang="ts">
	import { useEChartsRadialChart } from './radial-chart-context.svelte.js';
	import { DEFAULT_BAR_SIZE, DEFAULT_CORNER_RADIUS } from './types.js';

	let {
		dataKey,
		cornerRadius = DEFAULT_CORNER_RADIUS,
		barSize = DEFAULT_BAR_SIZE,
		showBackground = true,
		isClickable = false
	}: {
		dataKey: string;
		cornerRadius?: number;
		barSize?: number;
		showBackground?: boolean;
		isClickable?: boolean;
	} = $props();

	const token = $props.id();
	const chart = useEChartsRadialChart();
	$effect(() =>
		chart.radialBars.register(token, () => ({
			dataKey,
			cornerRadius,
			barSize,
			showBackground,
			isClickable
		}))
	);
</script>
```

`$lib/components/evilcharts/charts/echarts-radial-chart/radial-chart-context.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import type { RadialBarRegistration } from './types.js';

const RADIAL_CHART_CONTEXT = Symbol('evilcharts.echarts-radial-chart');

export class EChartsRadialChartContext {
	radialBars = new RegistrationSet<RadialBarRegistration>();
}

export function setEChartsRadialChartContext(): EChartsRadialChartContext {
	const context = new EChartsRadialChartContext();
	setContext(RADIAL_CHART_CONTEXT, context);
	return context;
}

export function useEChartsRadialChart(): EChartsRadialChartContext {
	const context = getContext<EChartsRadialChartContext | undefined>(RADIAL_CHART_CONTEXT);
	if (!context) {
		throw new Error('[EvilCharts] ECharts radial parts must be children of EChartsRadialChart.');
	}
	return context;
}
```

`$lib/components/evilcharts/charts/echarts-radial-chart/radial-chart.svelte`

```svelte
<script lang="ts">
	import { untrack, type Snippet } from 'svelte';
	import { prefersReducedMotion } from 'svelte/motion';
	import type { EChartsCoreOption, EChartsType } from 'echarts/core';
	import { AriaComponent, PolarComponent, TooltipComponent } from 'echarts/components';
	import { BarChart } from 'echarts/charts';
	import * as echarts from 'echarts/core';
	import {
		ChartContainer,
		DEFAULT_ECHARTS_RENDERER,
		EChartsHost,
		LoadingIndicator,
		RegistrationSet,
		resolveColors,
		setEChartsSharedSlotContext,
		type ChartAccessibility,
		type ChartConfig,
		type EChartsRenderer,
		type ResolvedColors
	} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
	import { LegendOverlay } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
	import Background from './background.svelte';
	import {
		buildRadialOption,
		createRadialLoadingData,
		mergeRadialChartOptions,
		niceCeil,
		radialShimmerStops
	} from './option.js';
	import { setEChartsRadialChartContext } from './radial-chart-context.svelte.js';
	import {
		DEFAULT_BAR_SIZE,
		DEFAULT_CORNER_RADIUS,
		DEFAULT_INNER_RADIUS,
		DEFAULT_OUTER_RADIUS,
		LOADING_ANIMATION_DURATION,
		type BackgroundVariant,
		type LegendRegistration,
		type RadialSelection,
		type RadialVariant,
		type TooltipRegistration
	} from './types.js';

	echarts.use([BarChart, PolarComponent, TooltipComponent, AriaComponent]);

	let {
		data,
		config,
		nameKey,
		class: className,
		renderer = DEFAULT_ECHARTS_RENDERER,
		variant = 'full',
		max,
		innerRadius = DEFAULT_INNER_RADIUS,
		outerRadius = DEFAULT_OUTER_RADIUS,
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		backgroundVariant,
		chartOptions,
		accessibility,
		initialDimension = { width: 320, height: 200 },
		children
	}: {
		data: Record<string, unknown>[];
		config: ChartConfig;
		nameKey: string;
		class?: string;
		renderer?: EChartsRenderer;
		variant?: RadialVariant;
		max?: number;
		innerRadius?: number | string;
		outerRadius?: number | string;
		defaultSelectedDataKey?: string | null;
		onSelectionChange?: (selection: RadialSelection | null) => void;
		isLoading?: boolean;
		backgroundVariant?: BackgroundVariant;
		chartOptions?: Record<string, unknown>;
		accessibility?: ChartAccessibility;
		initialDimension?: { width: number; height: number };
		children?: Snippet;
	} = $props();

	let container = $state<HTMLDivElement>();
	let themeRevision = $state(0);
	let instance = $state.raw<EChartsType>();
	let introComplete = $state(false);
	let selectedBar = $state<string | null>(untrack(() => defaultSelectedDataKey));
	let resolved = $state.raw<ResolvedColors>({
		series: {},
		tokens: {
			mutedForeground: 'rgba(120, 120, 120, 1)',
			border: 'rgba(120, 120, 120, 0.35)',
			foreground: 'rgba(120, 120, 120, 1)',
			background: 'rgba(0, 0, 0, 1)'
		}
	});
	let loadingData = $state.raw(createRadialLoadingData());

	const chart = setEChartsRadialChartContext();
	const tooltipSlots = new RegistrationSet<TooltipRegistration>();
	const legendSlots = new RegistrationSet<LegendRegistration>();
	setEChartsSharedSlotContext({
		register(slot, token, getter) {
			if (slot === 'tooltip')
				return tooltipSlots.register(token, getter as () => TooltipRegistration);
			if (slot === 'legend') return legendSlots.register(token, getter as () => LegendRegistration);
			return () => {};
		}
	});

	const radialBar = $derived(
		chart.radialBars.first ?? {
			dataKey: '',
			cornerRadius: DEFAULT_CORNER_RADIUS,
			barSize: DEFAULT_BAR_SIZE,
			showBackground: true,
			isClickable: false
		}
	);
	const tooltip = $derived(tooltipSlots.first);
	const legend = $derived(legendSlots.first);
	const categories = $derived(data.map((row) => String(row[nameKey] ?? '')));
	const values = $derived(
		data.map((row) => {
			const value = Number(row[radialBar.dataKey]);
			return Number.isFinite(value) ? value : 0;
		})
	);
	const angleMax = $derived(max != null && max > 0 ? max : niceCeil(Math.max(0, ...values)));

	$effect(() => {
		void themeRevision;
		const host = container;
		const keys = categories;
		if (host) resolved = resolveColors(host, config, keys);
	});

	const option = $derived.by(() => {
		const built = buildRadialOption({
			categories,
			values,
			config,
			radialBar,
			variant,
			innerRadius,
			outerRadius,
			angleMax,
			selectedBar,
			tooltip,
			isLoading,
			loadingData,
			resolved,
			animation: !introComplete,
			reducedMotion: prefersReducedMotion.current
		});
		return mergeRadialChartOptions(built, chartOptions) as EChartsCoreOption;
	});

	$effect(() => {
		if (isLoading) {
			introComplete = false;
			return;
		}
		if (introComplete || !instance || !radialBar.dataKey) return;
		if (prefersReducedMotion.current) {
			introComplete = true;
			return;
		}
		const timer = window.setTimeout(() => (introComplete = true), 1000);
		return () => window.clearTimeout(timer);
	});

	function select(name: string) {
		if (!radialBar.isClickable && !legend?.isClickable) return;
		selectedBar = selectedBar === name ? null : name;
		const index = categories.indexOf(selectedBar ?? '');
		onSelectionChange?.(
			selectedBar === null
				? null
				: {
						dataKey: selectedBar,
						value: values[index] ?? 0
					}
		);
	}

	const events = $derived({
		click: (params: unknown) => {
			if (!radialBar.isClickable || !params || typeof params !== 'object') return;
			const item = params as { seriesId?: string; dataIndex?: number };
			if (item.seriesId !== 'radial-bars' || typeof item.dataIndex !== 'number') return;
			const name = categories[item.dataIndex];
			if (name !== undefined) select(name);
		}
	});

	$effect(() => {
		const chartInstance = instance;
		const defaultIndex = tooltip?.defaultIndex;
		if (!chartInstance || isLoading || defaultIndex === undefined) return;
		queueMicrotask(() => {
			if (!chartInstance.isDisposed())
				chartInstance.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: defaultIndex });
		});
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance || !isLoading || prefersReducedMotion.current) return;
		let frame = 0;
		let lastPhase = 0;
		const start = performance.now();
		const tick = (now: number) => {
			const phase = ((now - start) / LOADING_ANIMATION_DURATION) % 1;
			if (phase < lastPhase) loadingData = createRadialLoadingData();
			lastPhase = phase;
			const width = chartInstance.getWidth();
			const height = chartInstance.getHeight();
			if (width > 0 && height > 0) {
				const maxT = (width + height) / (2 * width);
				const center = phase * (maxT + 0.4) - 0.2;
				chartInstance.setOption(
					{
						series: [
							{
								id: '__loading',
								data: loadingData,
								itemStyle: {
									color: new echarts.graphic.LinearGradient(
										0,
										0,
										width,
										width,
										radialShimmerStops(center, resolved.tokens.foreground),
										true
									)
								}
							}
						]
					},
					{ silent: true, lazyUpdate: true }
				);
			}
			frame = requestAnimationFrame(tick);
		};
		frame = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(frame);
	});

	const legendStyle = $derived(
		legend?.verticalAlign === 'middle'
			? 'position:absolute;left:16px;right:16px;top:50%;transform:translateY(-50%);z-index:20;gap:12px;flex-wrap:wrap;'
			: 'padding:8px 16px;gap:12px;flex-wrap:wrap;'
	);
</script>

{#snippet overlay()}
	<LoadingIndicator {isLoading} />
{/snippet}

<ChartContainer
	{config}
	{accessibility}
	{overlay}
	{initialDimension}
	bind:element={container}
	bind:themeRevision
	aria-busy={isLoading}
	class={className}
>
	{@render children?.()}
	{#if legend && legend.verticalAlign === 'top' && !isLoading}
		<LegendOverlay
			seriesKeys={categories}
			{config}
			variant={legend.variant}
			align={legend.align}
			selectedKey={selectedBar}
			hoveredKey={null}
			isClickable={legend.isClickable}
			onToggle={select}
			style={legendStyle}
		/>
	{/if}
	<div class="relative min-h-0 w-full flex-1">
		{#if backgroundVariant}<Background variant={backgroundVariant} />{/if}
		{#if radialBar.dataKey}
			<EChartsHost {option} {renderer} {events} bind:instance class="z-10" />
		{/if}
		{#if legend && legend.verticalAlign === 'middle' && !isLoading}
			<LegendOverlay
				seriesKeys={categories}
				{config}
				variant={legend.variant}
				align={legend.align}
				selectedKey={selectedBar}
				hoveredKey={null}
				isClickable={legend.isClickable}
				onToggle={select}
				style={legendStyle}
			/>
		{/if}
	</div>
	{#if legend && legend.verticalAlign === 'bottom' && !isLoading}
		<LegendOverlay
			seriesKeys={categories}
			{config}
			variant={legend.variant}
			align={legend.align}
			selectedKey={selectedBar}
			hoveredKey={null}
			isClickable={legend.isClickable}
			onToggle={select}
			style={legendStyle}
		/>
	{/if}
	{#if radialBar.isClickable}
		<div class="sr-only" aria-label="Chart values">
			{#each categories as name, index (name)}
				<button type="button" aria-pressed={selectedBar === name} onclick={() => select(name)}
					>{name}: {values[index]}</button
				>
			{/each}
		</div>
	{/if}
</ChartContainer>
```

`$lib/components/evilcharts/charts/echarts-radial-chart/types.ts`

```ts
import type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
import type {
	TooltipPosition,
	TooltipRoundness,
	TooltipVariant
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';

export type RadialVariant = 'full' | 'semi';
export type BackgroundVariant =
	| 'dots'
	| 'grid'
	| 'cross-hatch'
	| 'diagonal-lines'
	| 'plus'
	| 'falling-triangles'
	| '4-pointed-star'
	| 'tiny-checkers'
	| 'overlapping-circles'
	| 'wiggle-lines'
	| 'bubbles';

export type RadialSelection = { dataKey: string; value: number };

export type RadialBarRegistration = {
	dataKey: string;
	cornerRadius: number;
	barSize: number;
	showBackground: boolean;
	isClickable: boolean;
};

export type TooltipRegistration = {
	variant: TooltipVariant;
	roundness: TooltipRoundness;
	cursor?: boolean;
	defaultIndex?: number;
	position: TooltipPosition;
};

export type LegendRegistration = {
	variant: LegendVariant;
	align: 'left' | 'center' | 'right';
	verticalAlign: 'top' | 'middle' | 'bottom';
	isClickable: boolean;
};

export const DEFAULT_INNER_RADIUS = '30%';
export const DEFAULT_OUTER_RADIUS = '100%';
export const DEFAULT_CORNER_RADIUS = 5;
export const DEFAULT_BAR_SIZE = 14;
export const LOADING_BARS = 5;
export const LOADING_MAX = 100;
export const LOADING_ANIMATION_DURATION = 2000;
```
        
      
      
        ### Add the shared chart module.
        

Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it.


        
          ### $lib/components/evilcharts/ui/echarts-chart

`$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils.js';
	import ChartStyle from './chart-style.svelte';
	import { validateChartConfigColors, type ChartAccessibility, type ChartConfig } from './types.js';

	type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
		config: ChartConfig;
		children?: Snippet;
		overlay?: Snippet;
		footer?: Snippet;
		initialDimension?: { width: number; height: number };
		dimension?: { width: number; height: number };
		element?: HTMLDivElement;
		themeRevision?: number;
		accessibility?: ChartAccessibility;
	};

	let {
		id,
		config,
		children,
		overlay,
		footer,
		initialDimension = { width: 320, height: 200 },
		dimension = $bindable(),
		element = $bindable(),
		themeRevision = $bindable(0),
		accessibility,
		class: className,
		...restProps
	}: Props = $props();

	const uniqueId = $props.id();
	const chartId = $derived(`chart-${id ?? uniqueId}`);
	const descriptionId = $derived(`${chartId}-description`);
	const describedBy = $derived(
		[accessibility?.description ? descriptionId : undefined, accessibility?.describedBy]
			.filter(Boolean)
			.join(' ') || undefined
	);
	let measuredWidth = $state(0);
	let measuredHeight = $state(0);
	const resolvedDimension = $derived(
		measuredWidth > 0 && measuredHeight > 0
			? { width: measuredWidth, height: measuredHeight }
			: initialDimension
	);

	$effect(() => {
		dimension = resolvedDimension;
	});

	$effect.pre(() => validateChartConfigColors(config));

	function observeTheme(node: HTMLElement) {
		const observer = new MutationObserver(() => {
			themeRevision += 1;
		});
		const options = {
			attributes: true,
			attributeFilter: ['class', 'style']
		};
		observer.observe(document.documentElement, options);
		if (node !== document.documentElement) observer.observe(node, options);
		return () => observer.disconnect();
	}
</script>

<div
	{@attach observeTheme}
	bind:this={element}
	data-slot="chart"
	data-chart={chartId}
	role={accessibility ? 'group' : undefined}
	aria-label={accessibility?.label}
	aria-labelledby={accessibility?.labelledBy}
	aria-describedby={describedBy}
	class={cn(
		'relative flex min-h-0 w-full flex-1 flex-col justify-center text-xs',
		!footer && 'aspect-video',
		className
	)}
	{...restProps}
>
	{#if accessibility?.description}
		<span id={descriptionId} class="sr-only">{accessibility.description}</span>
	{/if}
	<ChartStyle id={chartId} {config} />
	<div
		class="relative flex min-h-0 w-full flex-1 flex-col"
		bind:clientWidth={measuredWidth}
		bind:clientHeight={measuredHeight}
	>
		{@render children?.()}
	</div>
	{@render overlay?.()}
	{@render footer?.()}
</div>
```

`$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte`

```svelte
<script lang="ts">
	import { buildChartCss } from './colors.js';
	import type { ChartConfig } from './types.js';

	let { id, config }: { id: string; config: ChartConfig } = $props();
	const css = $derived(buildChartCss(id, config));
</script>

{#if css}
	<svelte:element this={"style"}>{css}</svelte:element>
{/if}
```

`$lib/components/evilcharts/ui/echarts-chart/colors.ts`

```ts
import * as echarts from 'echarts/core';
import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js';

const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/;

export function chartColorToken(key: string): string {
	if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key;
	return `u-${Array.from(key, (character) =>
		(character.codePointAt(0) ?? 0).toString(16).padStart(6, '0')
	).join('')}`;
}

export function chartColorVariableName(key: string, index: number): string {
	return `--color-${chartColorToken(key)}-${index}`;
}

export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string {
	const name = chartColorVariableName(key, index);
	return fallbackIndex === undefined
		? `var(${name})`
		: `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`;
}

export function quoteCssString(value: string): string {
	return `"${Array.from(value, (character) => {
		const codePoint = character.codePointAt(0) ?? 0;
		if (character === '"' || character === '\\') return `\\${character}`;
		if (codePoint === 0) return '\uFFFD';
		if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `;
		return character;
	}).join('')}"`;
}

export function getColorsCount(item: ChartConfig[string]): number {
	if (!item.colors) return 1;
	return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1);
}

export function distributeColors(colors: string[], maxCount: number): string[] {
	if (colors.length === 0) return [];
	if (colors.length >= maxCount) return colors.slice(0, maxCount);

	const result: string[] = [];
	const baseSlots = Math.floor(maxCount / colors.length);
	const extraSlots = maxCount % colors.length;
	for (let index = 0; index < colors.length; index += 1) {
		const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0);
		for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]);
	}
	return result;
}

export function buildChartCss(id: string, config: ChartConfig): string {
	const colorConfig = Object.entries(config).filter(([, item]) => item.colors);
	if (colorConfig.length === 0) return '';

	const variablesFor = (theme: ThemeKey) =>
		colorConfig
			.flatMap(([key, item]) => {
				const authored = item.colors?.[theme];
				if (!authored?.length) return [];
				return distributeColors(authored, getColorsCount(item)).map(
					(color, index) => `  ${chartColorVariableName(key, index)}: ${color};`
				);
			})
			.join('\n');

	return Object.entries(THEMES)
		.map(
			([theme, prefix]) =>
				`${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}`
		)
		.join('\n');
}

let normalizerContext: CanvasRenderingContext2D | null = null;

export function normalizeColor(value: string): string {
	const raw = value.trim();
	if (!raw || typeof document === 'undefined') return raw;

	if (!normalizerContext) {
		const canvas = document.createElement('canvas');
		canvas.width = 1;
		canvas.height = 1;
		normalizerContext = canvas.getContext('2d', { willReadFrequently: true });
	}
	if (!normalizerContext) return raw;

	normalizerContext.clearRect(0, 0, 1, 1);
	normalizerContext.fillStyle = '#000';
	normalizerContext.fillStyle = raw;
	normalizerContext.fillRect(0, 0, 1, 1);
	const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data;
	return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`;
}

export function withAlpha(color: string, alpha: number): string {
	const match = color.match(/rgba?\(([^)]+)\)/);
	if (!match) return color;
	const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim());
	const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0;
	return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`;
}

export type ResolvedColors = {
	series: Record<string, string[]>;
	tokens: {
		mutedForeground: string;
		border: string;
		foreground: string;
		background: string;
	};
};

export function resolveColors(
	container: HTMLElement,
	config: ChartConfig,
	seriesKeys: string[]
): ResolvedColors {
	const computed = getComputedStyle(container);
	const series: Record<string, string[]> = {};

	for (const key of seriesKeys) {
		const count = getColorsCount(config[key] ?? {});
		series[key] = Array.from({ length: count }, (_, index) => {
			const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim();
			return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)';
		});
	}

	const probe = document.createElement('span');
	probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;';
	container.appendChild(probe);
	const readToken = (className: string) => {
		probe.className = className;
		return normalizeColor(getComputedStyle(probe).color);
	};
	const tokens = {
		mutedForeground: readToken('text-muted-foreground'),
		border: readToken('text-border'),
		foreground: readToken('text-foreground'),
		background: readToken('text-background')
	};
	probe.remove();

	return { series, tokens };
}

export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient {
	if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)';
	return new echarts.graphic.LinearGradient(
		0,
		0,
		1,
		0,
		slots.map((color, index) => ({ offset: index / (slots.length - 1), color }))
	);
}

export function indicatorBackground(key: string, colorsCount: number): string {
	if (colorsCount <= 1) return chartColorVariable(key, 0);
	const stops = Array.from({ length: colorsCount }, (_, index) => {
		const offset = (index / (colorsCount - 1)) * 100;
		return `${chartColorVariable(key, index)} ${offset}%`;
	}).join(', ');
	return `linear-gradient(to right, ${stops})`;
}

export function flattenColor(color: string, base: string): string {
	const parse = (value: string) =>
		value
			.match(/rgba?\(([^)]+)\)/)?.[1]
			.split(',')
			.map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1];
	const [red, green, blue, alpha = 1] = parse(color);
	const [baseRed, baseGreen, baseBlue] = parse(base);
	const mix = (channel: number, baseChannel: number) =>
		Math.round(channel * alpha + baseChannel * (1 - alpha));
	return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`;
}
```

`$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte`

```svelte
<script lang="ts">
	import type { HTMLAttributes } from 'svelte/elements';
	import type { EChartsCoreOption, EChartsType, SetOptionOpts } from 'echarts/core';
	import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
	import * as echarts from 'echarts/core';
	import { cn } from '$lib/utils.js';
	import type { EChartsRenderer } from './types.js';

	// Register renderers in the module that calls `echarts.init`. Keeping this beside the runtime
	// use prevents production tree-shaking from dropping a side-effect-only barrel registration.
	echarts.use([CanvasRenderer, SVGRenderer]);

	export type EChartsEventHandler = (params: unknown) => void;

	type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
		option: EChartsCoreOption;
		renderer: EChartsRenderer;
		instance?: EChartsType;
		events?: Record<string, EChartsEventHandler>;
		setOptionOptions?: SetOptionOpts;
		hideSource?: boolean;
	};

	let {
		option,
		renderer,
		instance = $bindable(),
		events = {},
		setOptionOptions = { notMerge: true, lazyUpdate: false },
		hideSource = false,
		class: className,
		...restProps
	}: Props = $props();

	function createChartAttachment(activeRenderer: EChartsRenderer) {
		return (node: HTMLDivElement) => {
			const chart = echarts.init(node, undefined, { renderer: activeRenderer });
			instance = chart;
			const resizeObserver = new ResizeObserver(() => {
				if (chart.isDisposed()) return;
				if (node.clientWidth === chart.getWidth() && node.clientHeight === chart.getHeight())
					return;
				chart.resize();
			});
			resizeObserver.observe(node);

			return () => {
				resizeObserver.disconnect();
				if (!chart.isDisposed()) chart.dispose();
				if (instance === chart) instance = undefined;
			};
		};
	}

	$effect(() => {
		const chart = instance;
		const nextOption = option;
		const options = setOptionOptions;
		if (!chart || chart.isDisposed()) return;
		chart.setOption(nextOption, options);
	});

	$effect(() => {
		const chart = instance;
		const bindings = Object.entries(events);
		if (!chart || chart.isDisposed()) return;
		for (const [event, handler] of bindings) chart.on(event, handler);
		return () => {
			if (chart.isDisposed()) return;
			for (const [event, handler] of bindings) chart.off(event, handler);
		};
	});
</script>

<div
	{@attach createChartAttachment(renderer)}
	data-slot="echarts-host"
	data-echarts-source-hidden={hideSource || undefined}
	class={cn(
		'absolute inset-0 min-h-0 min-w-0',
		hideSource && '[&_canvas]:opacity-0 [&_svg]:opacity-0',
		className
	)}
	{...restProps}
></div>
```

`$lib/components/evilcharts/ui/echarts-chart/index.ts`

```ts
export { default as ChartContainer } from './chart-container.svelte';
export { default as ChartStyle } from './chart-style.svelte';
export { default as EChartsHost } from './echarts-host.svelte';
export { default as LoadingIndicator } from './loading-indicator.svelte';
export { default as SelectableSeriesControls } from './selectable-series-controls.svelte';
export { mergeLifecycleOptions } from './merge-options.js';
export type { EChartsEventHandler } from './echarts-host.svelte';
export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js';
export {
	getEChartsSharedSlotContext,
	setEChartsSharedSlotContext,
	type EChartsSharedSlotName
} from './shared-slots.svelte.js';
export {
	buildChartCss,
	chartColorToken,
	chartColorVariable,
	chartColorVariableName,
	distributeColors,
	flattenColor,
	getColorsCount,
	indicatorBackground,
	normalizeColor,
	resolveColors,
	seriesPaint,
	withAlpha,
	quoteCssString,
	type ResolvedColors
} from './colors.js';
export {
	DEFAULT_ECHARTS_RENDERER,
	ECHARTS_RENDERERS,
	THEMES,
	THEME_KEYS,
	validateChartConfigColors,
	type AtLeastOneThemeColor,
	type ChartAccessibility,
	type ChartConfig,
	type EChartsRenderer,
	type EChartsRenderStyle,
	type ThemeKey
} from './types.js';
```

`$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte`

```svelte
<script lang="ts">
	import { cubicOut } from 'svelte/easing';
	import { prefersReducedMotion } from 'svelte/motion';
	import { scale } from 'svelte/transition';

	let { isLoading }: { isLoading: boolean } = $props();
</script>

{#if isLoading}
	<div class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
		<div
			role="status"
			aria-live="polite"
			in:scale={{
				duration: prefersReducedMotion.current ? 0 : 250,
				start: 0.92,
				opacity: 0,
				easing: cubicOut
			}}
			class="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary"
		>
			<div
				aria-hidden="true"
				class="h-3 w-3 animate-spin rounded-full border border-border border-t-primary motion-reduce:animate-none"
			></div>
			<span>Loading</span>
		</div>
	</div>
{/if}
```

`$lib/components/evilcharts/ui/echarts-chart/merge-options.ts`

```ts
type LifecycleOptions = {
	animation?: unknown;
	animationDuration?: unknown;
	animationDurationUpdate?: unknown;
};

/** Merge consumer options without letting them break chart-owned animation lifecycles. */
export function mergeLifecycleOptions<T extends object>(built: T, overrides?: object): T {
	const lifecycle = built as T & LifecycleOptions;
	return {
		...built,
		...overrides,
		animation: lifecycle.animation,
		animationDuration: lifecycle.animationDuration,
		animationDurationUpdate: lifecycle.animationDurationUpdate
	} as T;
}
```

`$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts`

```ts
import { SvelteMap } from 'svelte/reactivity';

export type RegistrationGetter<T> = () => T;

/** Ordered, reactive storage for DOM-free compound-component registrations. */
export class RegistrationSet<T> {
	#entries = new SvelteMap<string, RegistrationGetter<T>>();

	register(token: string, getter: RegistrationGetter<T>): () => void {
		this.#entries.set(token, getter);
		return () => {
			if (this.#entries.get(token) === getter) this.#entries.delete(token);
		};
	}

	get values(): T[] {
		return Array.from(this.#entries.values(), (getter) => getter());
	}

	get first(): T | undefined {
		return this.#entries.values().next().value?.();
	}

	get size(): number {
		return this.#entries.size;
	}
}
```

`$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte`

```svelte
<script lang="ts">
	let {
		items,
		selectedKey,
		onToggle
	}: {
		items: { key: string; label: string }[];
		selectedKey: string | null;
		onToggle: (key: string) => void;
	} = $props();
</script>

{#if items.length > 0}
	<div
		class="pointer-events-none absolute inset-0 z-50"
		role="group"
		aria-label="Selectable chart series"
	>
		{#each items as item (item.key)}
			<button
				type="button"
				aria-pressed={selectedKey === item.key}
				class="sr-only focus:pointer-events-auto focus:not-sr-only focus:absolute focus:top-2 focus:left-1/2 focus:-translate-x-1/2 focus:rounded-md focus:border focus:bg-background focus:px-3 focus:py-2 focus:text-foreground focus:shadow-md focus:ring-2 focus:ring-ring focus:outline-none"
				onclick={() => onToggle(item.key)}
			>
				{item.label}
			</button>
		{/each}
	</div>
{/if}
```

`$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';

export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush';

type SharedSlotContext = {
	register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void;
};

const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots');

export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext {
	setContext(ECHARTS_SHARED_SLOT_CONTEXT, context);
	return context;
}

export function getEChartsSharedSlotContext(): SharedSlotContext {
	const context = getContext<SharedSlotContext | undefined>(ECHARTS_SHARED_SLOT_CONTEXT);
	if (!context) {
		throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.');
	}
	return context;
}
```

`$lib/components/evilcharts/ui/echarts-chart/types.ts`

```ts
import type { Component, Snippet } from 'svelte';

export const ECHARTS_RENDERERS = {
	canvas: 'canvas',
	svg: 'svg'
} as const;

export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS];
export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas;

export const THEMES = { light: '', dark: '.dark' } as const;
export type ThemeKey = keyof typeof THEMES;
export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[];

type ThemeColorsBase = {
	[K in ThemeKey]?: string[];
};

export type AtLeastOneThemeColor = {
	[K in ThemeKey]: Required<Pick<ThemeColorsBase, K>> & Partial<Omit<ThemeColorsBase, K>>;
}[ThemeKey];

export type ChartConfig = Record<
	string,
	{
		label?: string | Snippet;
		icon?: Component<Record<string, never>>;
		colors?: AtLeastOneThemeColor;
	}
>;

export function validateChartConfigColors(config: ChartConfig): void {
	for (const [key, item] of Object.entries(config)) {
		if (!item.colors) continue;
		if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue;

		throw new Error(
			`[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.`
		);
	}
}

export type ChartAccessibility =
	| {
			label: string;
			labelledBy?: never;
			description?: string;
			describedBy?: string;
	  }
	| {
			label?: never;
			labelledBy: string;
			description?: string;
			describedBy?: string;
	  };

export type EChartsRenderStyle = 'native' | 'dither';
```
        
      
      
        ### Add the sub-components.
        

Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there.


        
          ### $lib/components/evilcharts/ui/echarts-tooltip

`$lib/components/evilcharts/ui/echarts-tooltip/index.ts`

```ts
export { default as Tooltip } from './tooltip.svelte';
export type { TooltipProps } from './tooltip.svelte';
export {
	escapeTooltipHtml,
	resolveTooltipPosition,
	roundnessClass,
	tooltipBaseOption,
	tooltipIndicatorHtml,
	tooltipRow,
	tooltipShell,
	tooltipVariantClass,
	type TooltipPosition,
	type TooltipRoundness,
	type TooltipVariant
} from './tooltip.js';
```

`$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte`

```svelte
<script lang="ts">
	import { getEChartsSharedSlotContext } from '../echarts-chart/index.js';
	import type { TooltipPosition, TooltipRoundness, TooltipVariant } from './tooltip.js';

	export type TooltipProps = {
		variant?: TooltipVariant;
		roundness?: TooltipRoundness;
		cursor?: boolean;
		defaultIndex?: number;
		position?: TooltipPosition;
	};

	let {
		variant = 'default',
		roundness = 'lg',
		cursor,
		defaultIndex,
		position = 'variable'
	}: TooltipProps = $props();

	const token = $props.id();
	const slots = getEChartsSharedSlotContext();

	$effect(() =>
		slots.register('tooltip', token, () => ({
			variant,
			roundness,
			cursor,
			defaultIndex,
			position
		}))
	);
</script>
```

`$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts`

```ts
import type { TooltipComponentOption } from 'echarts/components';
import { indicatorBackground } from '../echarts-chart/index.js';

export type TooltipVariant = 'default' | 'frosted-glass';
export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl';
export type TooltipPosition = 'fixed' | 'variable';

export const roundnessClass: Record<TooltipRoundness, string> = {
	sm: 'rounded-sm',
	md: 'rounded-md',
	lg: 'rounded-lg',
	xl: 'rounded-xl'
};

export const tooltipVariantClass: Record<TooltipVariant, string> = {
	default: 'bg-background',
	'frosted-glass': 'bg-background/50 backdrop-blur-md'
};

export function escapeTooltipHtml(value: unknown): string {
	return String(value ?? '')
		.replaceAll('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
		.replaceAll("'", '&#039;');
}

export function tooltipIndicatorHtml(key: string, colorsCount: number): string {
	return `<div class="h-2.5 w-2.5 shrink-0 rounded-[2px]" style="background:${indicatorBackground(key, colorsCount)}"></div>`;
}

export function tooltipRow({
	indicatorHtml,
	labelText,
	valueText,
	dimmed
}: {
	indicatorHtml: string;
	labelText: string;
	valueText: string;
	dimmed: string;
}): string {
	return `<div class="flex w-full flex-wrap items-center gap-2${dimmed}">
          ${indicatorHtml}
          <div class="flex flex-1 items-center justify-between gap-4 leading-none">
            <span class="text-muted-foreground">${escapeTooltipHtml(labelText)}</span>
            <span class="text-foreground font-mono font-medium tabular-nums">${escapeTooltipHtml(valueText)}</span>
          </div>
        </div>`;
}

export function tooltipShell({
	label,
	body,
	roundness,
	variant
}: {
	label: string;
	body: string;
	roundness: TooltipRoundness;
	variant: TooltipVariant;
}): string {
	return `<div class="grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl ${roundnessClass[roundness]} ${tooltipVariantClass[variant]}">
      <div class="font-medium text-primary">${escapeTooltipHtml(label)}</div>
      <div class="grid gap-1.5">${body}</div>
    </div>`;
}

export function resolveTooltipPosition(
	position: TooltipPosition
): TooltipComponentOption['position'] {
	if (position === 'variable') return undefined;
	return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8];
}

export function tooltipBaseOption(params: {
	present: boolean;
	cursor: boolean;
	position: TooltipPosition;
	axisPointerColor: string;
	strokeWidth: number;
}): TooltipComponentOption {
	const { present, cursor, position, axisPointerColor, strokeWidth } = params;
	return {
		show: present,
		trigger: 'axis',
		confine: true,
		displayTransition: false,
		backgroundColor: 'transparent',
		borderWidth: 0,
		padding: 0,
		extraCssText: 'box-shadow:none;',
		axisPointer: cursor
			? {
					type: 'line',
					lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] }
				}
			: { type: 'none' },
		position: resolveTooltipPosition(position)
	};
}
```
        
        

Finally, create `echarts-legend` in the same `ui` folder and paste the legend overlay there.


        
          ### $lib/components/evilcharts/ui/echarts-legend

`$lib/components/evilcharts/ui/echarts-legend/index.ts`

```ts
export { default as Legend } from './legend.svelte';
export { default as LegendIndicator } from './legend-indicator.svelte';
export { default as LegendOverlay } from './legend-overlay.svelte';
export type { LegendProps, LegendVariant } from './legend.svelte';
```

`$lib/components/evilcharts/ui/echarts-legend/legend-indicator.svelte`

```svelte
<script lang="ts">
	import { indicatorBackground } from '../echarts-chart/index.js';
	import type { LegendVariant } from './legend.svelte';

	let {
		variant,
		dataKey,
		colorsCount
	}: { variant: LegendVariant; dataKey: string; colorsCount: number } = $props();

	const background = $derived(indicatorBackground(dataKey, colorsCount));
	const fillStyle = $derived(`background:${background}`);
	const outlineStyle = $derived(
		`${fillStyle};-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);-webkit-mask-composite:xor;mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);mask-composite:exclude`
	);
</script>

{#if variant === 'square'}
	<span class="h-2 w-2 shrink-0" style={fillStyle}></span>
{:else if variant === 'circle'}
	<span class="h-2 w-2 shrink-0 rounded-full" style={fillStyle}></span>
{:else if variant === 'circle-outline'}
	<span class="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]" style={outlineStyle}></span>
{:else if variant === 'vertical-bar'}
	<span class="h-3 w-1 shrink-0 rounded-[2px]" style={fillStyle}></span>
{:else if variant === 'horizontal-bar'}
	<span class="h-1 w-3 shrink-0 rounded-[2px]" style={fillStyle}></span>
{:else if variant === 'rounded-square-outline'}
	<span class="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]" style={outlineStyle}></span>
{:else}
	<span class="h-2 w-2 shrink-0 rounded-[2px]" style={fillStyle}></span>
{/if}
```

`$lib/components/evilcharts/ui/echarts-legend/legend-overlay.svelte`

```svelte
<script lang="ts">
	import type { ChartConfig } from '../echarts-chart/index.js';
	import { getColorsCount } from '../echarts-chart/index.js';
	import LegendIndicator from './legend-indicator.svelte';
	import type { LegendVariant } from './legend.svelte';

	let {
		seriesKeys,
		config,
		variant,
		align,
		selectedKey,
		hoveredKey,
		isClickable,
		onToggle,
		style
	}: {
		seriesKeys: string[];
		config: ChartConfig;
		variant: LegendVariant;
		align: 'left' | 'center' | 'right';
		selectedKey: string | null;
		hoveredKey: string | null;
		isClickable: boolean;
		onToggle: (key: string) => void;
		style?: string;
	} = $props();

	const justify = $derived(
		align === 'left' ? 'justify-start' : align === 'center' ? 'justify-center' : 'justify-end'
	);
	const entries = $derived(
		seriesKeys.map((key) => ({
			key,
			item: config[key],
			colorsCount: getColorsCount(config[key] ?? {}),
			selected:
				(selectedKey === null || selectedKey === key) && (hoveredKey === null || hoveredKey === key)
		}))
	);
</script>

<div {style} class={['pointer-events-auto flex items-center gap-4 select-none', justify]}>
	{#each entries as entry (entry.key)}
		{#if isClickable}
			<button
				type="button"
				aria-pressed={selectedKey === entry.key}
				class={[
					'flex appearance-none items-center gap-1.5 border-0 bg-transparent p-0 text-inherit transition-opacity',
					!entry.selected && 'opacity-30',
					'cursor-pointer'
				]}
				onclick={() => onToggle(entry.key)}
			>
				<LegendIndicator {variant} dataKey={entry.key} colorsCount={entry.colorsCount} />
				{#if typeof entry.item?.label === 'function'}
					{@render entry.item.label()}
				{:else}
					{entry.item?.label ?? entry.key}
				{/if}
			</button>
		{:else}
			<div
				class={['flex items-center gap-1.5 transition-opacity', !entry.selected && 'opacity-30']}
			>
				<LegendIndicator {variant} dataKey={entry.key} colorsCount={entry.colorsCount} />
				{#if typeof entry.item?.label === 'function'}
					{@render entry.item.label()}
				{:else}
					{entry.item?.label ?? entry.key}
				{/if}
			</div>
		{/if}
	{/each}
</div>
```

`$lib/components/evilcharts/ui/echarts-legend/legend.svelte`

```svelte
<script lang="ts">
	import { getEChartsSharedSlotContext } from '../echarts-chart/index.js';

	export type LegendVariant =
		| 'square'
		| 'circle'
		| 'circle-outline'
		| 'rounded-square'
		| 'rounded-square-outline'
		| 'vertical-bar'
		| 'horizontal-bar';

	export type LegendProps = {
		variant?: LegendVariant;
		align?: 'left' | 'center' | 'right';
		verticalAlign?: 'top' | 'middle' | 'bottom';
		isClickable?: boolean;
	};

	let {
		variant = 'rounded-square',
		align = 'right',
		verticalAlign = 'top',
		isClickable = false
	}: LegendProps = $props();

	const token = $props.id();
	const slots = getEChartsSharedSlotContext();
	$effect(() =>
		slots.register('legend', token, () => ({ variant, align, verticalAlign, isClickable }))
	);
</script>
```
        
      
    
  


## Usage

The ECharts radial chart is composable, sharing the LayerChart sibling's API shape. `<EChartsRadialChart>` is the container, and every part hangs off it as a compound member — `<EChartsRadialChart.Legend>`, `<EChartsRadialChart.Tooltip>`, and a `<EChartsRadialChart.RadialBar>` — so a single import gives you the whole chart. `<RadialBar>` carries its own `isClickable`, so styling and interactivity live with the series.

```svelte
<script lang="ts">
	import {
		EChartsRadialChart,
		type ChartConfig
	} from '$lib/components/evilcharts/charts/echarts-radial-chart/index.js';
</script>
```

```svelte
const data = [
  { browser: "chrome", visitors: 275 },
  { browser: "safari", visitors: 200 },
  { browser: "firefox", visitors: 187 },
];

const chartConfig = {
  chrome: {
    label: "Chrome",
    colors: { light: ["#3b82f6"], dark: ["#60a5fa"] },
  },
  safari: {
    label: "Safari",
    colors: { light: ["#10b981"], dark: ["#34d399"] },
  },
  firefox: {
    label: "Firefox",
    colors: { light: ["#f59e0b"], dark: ["#fbbf24"] },
  },
} satisfies ChartConfig;
```

```svelte
<EChartsRadialChart {data} nameKey="browser" config={chartConfig} variant="full">
	<EChartsRadialChart.Legend isClickable />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" isClickable />
</EChartsRadialChart>
```

The difference is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles each ring into an ECharts polar `bar` series, which ECharts paints with Canvas by default or SVG when `renderer="svg"`.

The `config` is the same contract as every EvilCharts chart — each key matches a `nameKey` value and maps it to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from your CSS variables at runtime, so dark mode just works.

> 
  

The ECharts implementation brings a few small departures from the LayerChart sibling: multi-color bars use a diagonal ECharts gradient, and corner rounding becomes a rounded cap on each ring's ends.




### SVG Renderer

Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer.

### renderer="svg"

```svelte
<script lang="ts">
	import { EChartsRadialChart } from '$lib/components/evilcharts/charts/echarts-radial-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data = [
		{ browser: 'chrome', visitors: 275 },
		{ browser: 'safari', visitors: 200 },
		{ browser: 'firefox', visitors: 187 },
		{ browser: 'edge', visitors: 173 },
		{ browser: 'other', visitors: 90 }
	];

	const chartConfig = {
		chrome: {
			label: 'Chrome',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		safari: {
			label: 'Safari',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		firefox: {
			label: 'Firefox',
			colors: {
				light: ['#f59e0b'],
				dark: ['#fbbf24']
			}
		},
		edge: {
			label: 'Edge',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		other: {
			label: 'Other',
			colors: {
				light: ['#6b7280'],
				dark: ['#9ca3af']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadialChart
	renderer="svg"
	class="h-full w-full p-4"
	{data}
	nameKey="browser"
	config={chartConfig}
	variant="full"
>
	<EChartsRadialChart.Legend isClickable />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" isClickable />
</EChartsRadialChart>
```

### Interactive Selection

Add `isClickable` to `<RadialBar>` (and `<Legend>`) to make bars selectable. Handle selection with the `onSelectionChange` callback on `<EChartsRadialChart>`:

```svelte
<EChartsRadialChart
	{data}
	nameKey="browser"
	config={chartConfig}
	onSelectionChange={(selection) => {
		if (selection) {
			console.log('Selected:', selection.dataKey, 'Value:', selection.value);
		} else {
			console.log('Deselected');
		}
	}}
>
	<EChartsRadialChart.Legend isClickable />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" isClickable />
</EChartsRadialChart>
```

### Loading State

### isLoading='true'

```svelte
<script lang="ts">
	import { EChartsRadialChart } from '$lib/components/evilcharts/charts/echarts-radial-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data = [
		{ browser: 'chrome', visitors: 275 },
		{ browser: 'safari', visitors: 200 },
		{ browser: 'firefox', visitors: 187 },
		{ browser: 'edge', visitors: 173 },
		{ browser: 'other', visitors: 90 }
	];

	const chartConfig = {
		chrome: {
			label: 'Chrome',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		safari: {
			label: 'Safari',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		firefox: {
			label: 'Firefox',
			colors: {
				light: ['#f59e0b'],
				dark: ['#fbbf24']
			}
		},
		edge: {
			label: 'Edge',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		other: {
			label: 'Other',
			colors: {
				light: ['#6b7280'],
				dark: ['#9ca3af']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadialChart
	class="h-full w-full p-4"
	{data}
	nameKey="browser"
	config={chartConfig}
	isLoading
>
	<EChartsRadialChart.Legend />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" />
</EChartsRadialChart>
```
> 
  

Pass the `isLoading` prop to show an animated skeleton of shimmering rings while your data loads.




## Examples

Radial chart examples with different configurations. Customize `variant`, `innerRadius`, `outerRadius`, and more.

### Semi-Circle Variant

### variant='semi'

```svelte
<script lang="ts">
	import { EChartsRadialChart } from '$lib/components/evilcharts/charts/echarts-radial-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data = [
		{ browser: 'chrome', visitors: 275 },
		{ browser: 'safari', visitors: 200 },
		{ browser: 'firefox', visitors: 187 },
		{ browser: 'edge', visitors: 173 },
		{ browser: 'other', visitors: 90 }
	];

	const chartConfig = {
		chrome: {
			label: 'Chrome',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		safari: {
			label: 'Safari',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		firefox: {
			label: 'Firefox',
			colors: {
				light: ['#f59e0b'],
				dark: ['#fbbf24']
			}
		},
		edge: {
			label: 'Edge',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		other: {
			label: 'Other',
			colors: {
				light: ['#6b7280'],
				dark: ['#9ca3af']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadialChart
	class="h-full w-full p-4"
	{data}
	nameKey="browser"
	config={chartConfig}
	variant="semi"
>
	<EChartsRadialChart.Legend />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" />
</EChartsRadialChart>
```
> 
  

Set `variant="semi"` for a half-circle chart — useful for progress or gauges in a compact space.




### Gradient Colors

### gradient colors

```svelte
<script lang="ts">
	import { EChartsRadialChart } from '$lib/components/evilcharts/charts/echarts-radial-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data = [
		{ browser: 'chrome', visitors: 275 },
		{ browser: 'safari', visitors: 200 },
		{ browser: 'firefox', visitors: 187 },
		{ browser: 'edge', visitors: 173 },
		{ browser: 'other', visitors: 90 }
	];

	const chartConfig = {
		chrome: {
			label: 'Chrome',
			colors: {
				light: ['#ff6b6b', '#feca57', '#48dbfb'], // Coral -> Gold -> Electric Blue // [!code highlight]
				dark: ['#ff7979', '#ffeaa7', '#74b9ff'] // [!code highlight]
			}
		},
		safari: {
			label: 'Safari',
			colors: {
				light: ['#a29bfe', '#fd79a8', '#fdcb6e'], // Lavender -> Pink -> Sunflower // [!code highlight]
				dark: ['#b8b5ff', '#ff9ff3', '#ffeaa7'] // [!code highlight]
			}
		},
		firefox: {
			label: 'Firefox',
			colors: {
				light: ['#00d2d3', '#54a0ff', '#5f27cd'], // Turquoise -> Blue -> Purple // [!code highlight]
				dark: ['#01e2e3', '#74b9ff', '#7c3aed'] // [!code highlight]
			}
		},
		edge: {
			label: 'Edge',
			colors: {
				light: ['#ff9f43', '#ee5a24', '#b71540'], // Tangerine -> Vermillion -> Wine // [!code highlight]
				dark: ['#ffbe76', '#f0932b', '#e74c3c'] // [!code highlight]
			}
		},
		other: {
			label: 'Other',
			colors: {
				light: ['#1dd1a1', '#10ac84', '#01a3a4'], // Mint -> Jungle -> Teal // [!code highlight]
				dark: ['#55efc4', '#00b894', '#00cec9'] // [!code highlight]
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadialChart class="h-full w-full p-4" {data} nameKey="browser" config={chartConfig}>
	<EChartsRadialChart.Legend />
	<EChartsRadialChart.Tooltip />
	<EChartsRadialChart.RadialBar dataKey="visitors" />
</EChartsRadialChart>
```

## API Reference

The chart is composed of several parts; the props below are grouped by part. Regardless of renderer, each part is declarative config the root compiles, but the API mirrors the LayerChart sibling one-to-one.

### EChartsRadialChart

The root container. It owns the data, shared selection state, loading skeleton, and chart-wide arc shape. Everything visual is composed as children and compiled into the ECharts option.


  ### `data` (required)

type: `TData[]`

The chart data. An array of objects, each representing one radial bar (`TData extends Record<string, unknown>`).
  ### `config` (required)

type: `ChartConfig`

Defines the chart's bars. Each key matches a `nameKey` value, with a `label` and a per-theme `colors` array. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config).
  ### `nameKey` (required)

type: `keyof TData & string`

Data key used for bar names (string values for labels and the legend).
  ### `children` (required)

type: `Snippet`

The composed chart parts — `<Legend />`, `<Tooltip />`, and a `<RadialBar />`.
  ### `class`

type: `string`

Additional CSS classes for the chart container.
  ### `renderer`

type: `"canvas" | "svg"` · default: `"canvas"`

Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default.
  ### `variant`

type: `"full" | "semi"` · default: `"full"`

The chart's arc shape. `"full"` is a full circle (360°); `"semi"` is a half circle (180°).
  ### `max`

type: `number`

Value a full sweep represents. Without it the scale is derived from the data, so the largest bar always fills the arc — set it (e.g. `100`) for gauges, where a single value has to read against a fixed total.
  ### `innerRadius`

type: `number | string` · default: `"30%"`

Inner radius of the bars, as a number (pixels) or percentage string.
  ### `outerRadius`

type: `number | string` · default: `"100%"`

Outer radius of the bars, as a number (pixels) or percentage string.
  ### `defaultSelectedDataKey`

type: `string | null` · default: `null`

Bar name selected by default.
  ### `onSelectionChange`



Fires when a bar is selected or deselected by clicking a clickable `<RadialBar />` or `<Legend />` entry. Receives an object with `dataKey` (bar name) and `value` (bar value), or `null` when deselected.
  ### `isLoading`

type: `boolean` · default: `false`

Shows a placeholder animation while data loads.
  ### `backgroundVariant`

type: `BackgroundVariant`

Decorative background pattern behind the chart (`"dots"`, `"grid"`, `"cross-hatch"`, and more).
  ### `chartOptions`

type: `Record<string, unknown>`

Escape hatch merged over the underlying ECharts option. See the [ECharts option documentation](https://echarts.apache.org/en/option.html).
  ### `accessibility`

type: `ChartAccessibility`

Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology.


### RadialBar

The radial bar series. Each data row becomes one concentric ring. Its presence renders the bars; omit it and only the background (if any) shows.


  ### `dataKey` (required)

type: `string`

Data key used for bar values (numbers that set each bar's arc length).
  ### `cornerRadius`

type: `number` · default: `5`

The corner rounding for each bar. ECharts maps this to a rounded cap on the bar's ends — pass `0` for square ends.
  ### `barSize`

type: `number` · default: `14`

Thickness of each bar, in pixels.
  ### `showBackground`

type: `boolean` · default: `true`

Renders the background track (the unfilled portion of each bar).
  ### `isClickable`

type: `boolean` · default: `false`

Lets bars be clicked to select/deselect them. Unselected bars dim while a selection is active.


### Tooltip

The hover tooltip, labeling each bar by name. Its presence enables the tooltip; omit it and none shows. Hidden automatically while loading.


  ### `variant`

type: `"default" | "frosted-glass"` · default: `"default"`

Visual style of the tooltip surface.
  ### `roundness`

type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"`

Border-radius of the tooltip.
  ### `defaultIndex`

type: `number`

Shows the tooltip by default at this data point index.
  ### `position`

type: `"fixed" | "variable"` · default: `"variable"`

How the tooltip is anchored. `"variable"` lets it follow the pointer (the default). `"fixed"` pins the tooltip near the top of the chart and only tracks the pointer's X.


### Legend

The bar legend, rendered as HTML alongside the chart surface. Its presence enables the legend; omit it and none shows. With `isClickable`, each entry toggles selection of its bar.


  ### `variant`

type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | "vertical-bar" | "horizontal-bar"`

Visual style of the legend indicators.
  ### `align`

type: `"left" | "center" | "right"` · default: `"center"`

Horizontal placement of the legend.
  ### `verticalAlign`

type: `"top" | "middle" | "bottom"` · default: `"bottom"`

Vertical placement of the legend.
  ### `isClickable`

type: `boolean` · default: `false`

When enabled, each entry toggles selection of its bar, driving the shared selection state read by `<RadialBar />`.

