
### Basic Chart

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

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadarChart
	{data}
	config={chartConfig}
	accessibility={{
		label: 'Desktop and mobile skill comparison radar chart',
		description: 'Desktop and mobile scores across six programming skills.'
	}}
	class="h-full w-full p-4"
>
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend isClickable />
	<EChartsRadarChart.Tooltip />
	<!-- [!code highlight:2] -->
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled" isClickable>
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled" isClickable>
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
</EChartsRadarChart>
```

## Installation


  
  
    ### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-radar-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.
        

Create the folder `evilcharts` with a `charts` subfolder in your `components` directory, then paste the radar-chart code into a new `echarts-radar-chart` file there.


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

`$lib/components/evilcharts/charts/echarts-radar-chart/active-dot.svelte`

```svelte
<script lang="ts">
	import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
	import { useEChartsRadarSlots } from './radar-slots.svelte.js';

	let { variant = 'default' }: { variant?: DotVariant } = $props();
	const token = $props.id();
	const slots = useEChartsRadarSlots();
	$effect(() => slots.activeDots.register(token, () => ({ variant })));
</script>
```

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

```svelte
<script lang="ts">
	import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
	import { useEChartsRadarSlots } from './radar-slots.svelte.js';

	let { variant = 'default' }: { variant?: DotVariant } = $props();
	const token = $props.id();
	const slots = useEChartsRadarSlots();
	$effect(() => slots.dots.register(token, () => ({ variant })));
</script>
```

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

```ts
import Root from './radar-chart.svelte';
import Radar from './radar.svelte';
import Dot from './dot.svelte';
import ActiveDot from './active-dot.svelte';
import PolarGrid from './polar-grid.svelte';
import PolarAngleAxis from './polar-angle-axis.svelte';
import PolarRadiusAxis from './polar-radius-axis.svelte';
import Legend from './legend.svelte';
import Tooltip from './tooltip.svelte';

type RootComponent = typeof Root;

export const EChartsRadarChart: RootComponent & {
	Radar: typeof Radar;
	Dot: typeof Dot;
	ActiveDot: typeof ActiveDot;
	PolarGrid: typeof PolarGrid;
	PolarAngleAxis: typeof PolarAngleAxis;
	PolarRadiusAxis: typeof PolarRadiusAxis;
	Tooltip: typeof Tooltip;
	Legend: typeof Legend;
} = Object.assign(Root, {
	Radar,
	Dot,
	ActiveDot,
	PolarGrid,
	PolarAngleAxis,
	PolarRadiusAxis,
	Tooltip,
	Legend
});

export type {
	ChartAccessibility,
	ChartConfig,
	EChartsRenderer
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
export type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
export type { DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/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 { DitherBloom, GridType, RadarStrokeVariant, RadarVariant } from './types.js';
```

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

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

	let {
		variant = 'rounded-square',
		align = 'center',
		verticalAlign = 'bottom',
		isClickable = false
	}: {
		variant?: LegendVariant;
		align?: 'left' | 'center' | 'right';
		verticalAlign?: 'top' | 'middle' | 'bottom';
		isClickable?: boolean;
	} = $props();

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

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

```ts
import type { RadarSeriesOption } from 'echarts/charts';
import type { RadarComponentOption, 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 { dotStyle, sampleGradient } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
import {
	createDitherPattern,
	type DitherVariant,
	type RenderStyle
} from '$lib/components/evilcharts/ui/echarts-dither/index.js';
import {
	escapeTooltipHtml,
	resolveTooltipPosition,
	roundnessClass,
	tooltipIndicatorHtml,
	tooltipRow,
	tooltipVariantClass
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
import {
	LOADING_MAX,
	STROKE_WIDTH,
	type DitherBloom,
	type AngleAxisRegistration,
	type GridRegistration,
	type LegendRegistration,
	type RadarRegistration,
	type TooltipRegistration
} from './types.js';

export type EChartsRadarOption = ComposeOption<
	RadarSeriesOption | RadarComponentOption | TooltipComponentOption
>;

export type RadarOptionContext = {
	data: Record<string, unknown>[];
	config: ChartConfig;
	radars: RadarRegistration[];
	angleAxis?: AngleAxisRegistration;
	radiusAxis: boolean;
	grid?: GridRegistration;
	tooltip?: TooltipRegistration;
	legend?: LegendRegistration;
	selectedDataKey: string | null;
	resolved: ResolvedColors;
	animation: boolean;
	reducedMotion: boolean;
	loadingPoints: number;
	loadingData: number[];
	isLoading: boolean;
	renderStyle?: RenderStyle;
	ditherVariant?: DitherVariant;
	ditherCellSize?: number;
	bloom?: DitherBloom;
	rendererSize?: { width: number; height: number };
};

const FALLBACK_COLOR = 'rgba(120, 120, 120, 1)';
const GRID_LINE_OPACITY = 1;
const LOADING_SHIMMER_BAND = 0.2;
const LOADING_SHIMMER_FEATHER = 0.2;

function radarDitherBounds(context: RadarOptionContext) {
	const size = context.rendererSize;
	if (!size || size.width <= 0 || size.height <= 0) return undefined;
	const radius = Math.min(size.width, size.height) * 0.34;
	const center = Number.parseFloat(radarCenterY(context.legend)) / 100;
	return { height: radius * 2, offsetY: size.height * center - radius };
}

function rendererDitherPattern(
	slots: string[],
	variant: DitherVariant,
	cellSize: number,
	context: RadarOptionContext
) {
	return createDitherPattern(slots, variant, cellSize, 1, radarDitherBounds(context));
}

export function createRadarLoadingData(points: number, random = Math.random): number[] {
	const count = Math.max(0, Math.floor(points));
	const values: number[] = [];
	let value = 45 + random() * 25;
	for (let index = 0; index < count; index += 1) {
		value = Math.min(90, Math.max(35, value + (random() - 0.5) * 35));
		values.push(Math.round(value));
	}
	return values;
}

export function createRadarShimmerStops(center: number, color: string, peak: number) {
	const alphaAt = (offset: number) => {
		const distance = Math.abs(offset - center);
		if (distance <= LOADING_SHIMMER_BAND - LOADING_SHIMMER_FEATHER) return peak;
		if (distance >= LOADING_SHIMMER_BAND) return 0;
		return (
			peak *
			Math.sin(
				((1 -
					(distance - (LOADING_SHIMMER_BAND - LOADING_SHIMMER_FEATHER)) / LOADING_SHIMMER_FEATHER) *
					Math.PI) /
					2
			)
		);
	};
	const offsets = [
		0,
		center - LOADING_SHIMMER_BAND,
		center - LOADING_SHIMMER_BAND + LOADING_SHIMMER_FEATHER,
		center,
		center + LOADING_SHIMMER_BAND - LOADING_SHIMMER_FEATHER,
		center + LOADING_SHIMMER_BAND,
		1
	]
		.filter((offset) => offset >= 0 && offset <= 1)
		.sort((left, right) => left - right);
	const stops: Array<{ offset: number; color: string }> = [];
	for (const offset of offsets) {
		if (stops.length === 0 || offset - stops[stops.length - 1].offset > 1e-4) {
			stops.push({ offset, color: withAlpha(color, alphaAt(offset)) });
		}
	}
	return stops;
}

function categoryKey(context: RadarOptionContext): string | undefined {
	if (context.angleAxis?.dataKey) return context.angleAxis.dataKey;
	const seriesKeys = new Set(context.radars.map((radar) => radar.dataKey));
	return Object.keys(context.data[0] ?? {}).find((key) => !seriesKeys.has(key));
}

function categories(context: RadarOptionContext): string[] {
	const key = categoryKey(context);
	return context.data.map((row, index) => String((key ? row[key] : undefined) ?? index));
}

function indicatorMax(context: RadarOptionContext): number {
	let maximum = 0;
	for (const radar of context.radars) {
		for (const row of context.data) maximum = Math.max(maximum, Number(row[radar.dataKey]) || 0);
	}
	return maximum || 1;
}

function radarCenterY(legend?: LegendRegistration): string {
	if (!legend) return '50%';
	if (legend.verticalAlign === 'bottom') return '46%';
	if (legend.verticalAlign === 'top') return '54%';
	return '50%';
}

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

function radarFillPaint(slots: string[]): echarts.graphic.RadialGradient {
	if (slots.length <= 1) {
		const color = slots[0] ?? FALLBACK_COLOR;
		return new echarts.graphic.RadialGradient(0.5, 0.5, 0.5, [
			{ offset: 0, color: withAlpha(color, 0.8) },
			{ offset: 1, color: withAlpha(color, 0.3) }
		]);
	}
	return new echarts.graphic.RadialGradient(
		0.5,
		0.5,
		0.5,
		slots.map((color, index) => ({
			offset: index / (slots.length - 1),
			color: withAlpha(color, index === 0 ? 0.8 : 0.3)
		}))
	);
}

function bloomPixels(bloom: DitherBloom | undefined): number {
	if (bloom === 'aura') return 14;
	if (bloom === 'high') return 8;
	if (bloom === 'low') return 4;
	return 0;
}

function radarComponent(context: RadarOptionContext): RadarComponentOption {
	const names = categories(context);
	const gridColor = withAlpha(context.resolved.tokens.border, GRID_LINE_OPACITY);
	return {
		center: ['50%', radarCenterY(context.legend)],
		radius: '68%',
		startAngle: 90,
		shape: context.grid?.gridType ?? 'polygon',
		splitNumber: 4,
		indicator: names.map((name) => ({ name, max: indicatorMax(context) })),
		axisName: {
			show: Boolean(context.angleAxis) && !context.isLoading,
			color: context.resolved.tokens.mutedForeground,
			fontSize: 10
		},
		axisLine: {
			show: Boolean(context.grid) && !context.isLoading,
			lineStyle: { color: gridColor }
		},
		axisTick: { show: false },
		splitLine: {
			show: Boolean(context.grid) && !context.isLoading,
			lineStyle: { color: gridColor, type: [3, 4] }
		},
		splitArea: { show: false },
		axisLabel: {
			show: context.radiusAxis && !context.isLoading,
			color: context.resolved.tokens.mutedForeground,
			fontSize: 10,
			showMinLabel: false
		}
	};
}

function tooltipOption(context: RadarOptionContext): TooltipComponentOption {
	const slot = context.tooltip;
	const names = categories(context);
	return {
		show: Boolean(slot) && !context.isLoading,
		trigger: 'item',
		confine: true,
		backgroundColor: 'transparent',
		borderWidth: 0,
		padding: 0,
		extraCssText: 'box-shadow:none;',
		displayTransition: false,
		position: resolveTooltipPosition(slot?.position ?? 'variable'),
		formatter: (params: unknown) => {
			const item = (Array.isArray(params) ? params[0] : params) as {
				seriesId?: unknown;
				seriesName?: unknown;
				value?: unknown;
			} | null;
			if (!item) return '';
			const key = String(item.seriesId ?? '');
			if (key.startsWith('__')) return '';
			const configItem = context.config[key];
			const label =
				typeof configItem?.label === 'string' ? configItem.label : String(item.seriesName ?? key);
			const values = Array.isArray(item.value) ? item.value : [];
			const rows = names
				.map((name, index) =>
					tooltipRow({
						indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(configItem ?? {})),
						labelText: name,
						valueText:
							typeof values[index] === 'number'
								? values[index].toLocaleString()
								: String(values[index] ?? ''),
						dimmed: ''
					})
				)
				.join('');
			const dimmed =
				context.selectedDataKey !== null && context.selectedDataKey !== key ? ' opacity-30' : '';
			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${dimmed} ${roundnessClass[slot?.roundness ?? 'lg']} ${tooltipVariantClass[slot?.variant ?? 'default']}"><div class="font-medium">${escapeTooltipHtml(label)}</div><div class="grid gap-1.5">${rows}</div></div>`;
		}
	};
}

function selectionOpacity(selected: string | null, key: string, clickable: boolean) {
	if (!clickable || selected === null || selected === key) return { fill: 1, stroke: 1, dot: 1 };
	return { fill: 0.1, stroke: 0.2, dot: 0.2 };
}

function realSeries(context: RadarOptionContext): RadarSeriesOption[] {
	const names = categories(context);
	const hasSelection = context.selectedDataKey !== null;
	return context.radars.map((radar) => {
		const key = radar.dataKey;
		const slots = context.resolved.series[key] ?? [FALLBACK_COLOR];
		const stroke = radarStrokePaint(slots);
		const isDither = context.renderStyle === 'dither';
		const areaPaint = isDither
			? rendererDitherPattern(
					slots,
					radar.ditherVariant ?? context.ditherVariant ?? 'gradient',
					context.ditherCellSize ?? 2,
					context
				)
			: radarFillPaint(slots);
		const shadowBlur = radar.glowing ? 8 : isDither ? bloomPixels(context.bloom) : 0;
		const dotColor = sampleGradient(slots, 0.5);
		const opacity = selectionOpacity(context.selectedDataKey, key, radar.isClickable);
		const restingVisible = radar.dotVariant !== 'none';
		const activeVisible = radar.activeDotVariant !== 'none';
		const restingDot = dotStyle(radar.dotVariant, dotColor, context.resolved.tokens.background);
		const activeDot = dotStyle(
			radar.activeDotVariant === 'none' ? 'default' : radar.activeDotVariant,
			dotColor,
			context.resolved.tokens.background
		);
		const areaStyle =
			radar.variant === 'filled'
				? { color: areaPaint, opacity: radar.fillOpacity * opacity.fill }
				: undefined;
		const lineStyle = {
			color: isDither ? sampleGradient(slots, 0.5) : stroke,
			width: isDither ? Math.max(STROKE_WIDTH, context.ditherCellSize ?? 2) : STROKE_WIDTH,
			type: isDither
				? ([context.ditherCellSize ?? 2, context.ditherCellSize ?? 2] as [number, number])
				: (radar.strokeVariant ?? 'solid'),
			opacity: opacity.stroke,
			shadowBlur,
			shadowColor: shadowBlur > 0 ? sampleGradient(slots, 0.5) : undefined
		};
		return {
			id: key,
			name: typeof context.config[key]?.label === 'string' ? context.config[key].label : key,
			type: 'radar',
			radarIndex: 0,
			data: [{ value: names.map((_, index) => Number(context.data[index]?.[key]) || 0) }],
			symbol: restingVisible || activeVisible ? 'circle' : 'none',
			symbolSize: restingVisible ? restingDot.size : activeDot.size,
			cursor: radar.isClickable ? 'pointer' : 'default',
			z: context.selectedDataKey === key ? 3 : hasSelection ? 1 : 2,
			lineStyle,
			areaStyle,
			itemStyle: restingVisible
				? { ...restingDot.itemStyle, opacity: opacity.dot }
				: { ...activeDot.itemStyle, opacity: 0 },
			emphasis: hasSelection
				? { disabled: true }
				: {
						itemStyle: { ...activeDot.itemStyle, opacity: 1 },
						lineStyle,
						...(areaStyle ? { areaStyle } : {})
					},
			animation: false
		};
	});
}

function loadingOption(context: RadarOptionContext): EChartsRadarOption {
	const count = Math.max(0, Math.floor(context.loadingPoints));
	return {
		animation: false,
		aria: { enabled: true },
		radar: {
			center: ['50%', radarCenterY(context.legend)],
			radius: '68%',
			startAngle: 90,
			shape: context.grid?.gridType ?? 'polygon',
			splitNumber: 4,
			indicator: Array.from({ length: count }, (_, index) => ({
				name: `${index}`,
				max: LOADING_MAX
			})),
			axisName: { show: false },
			axisLine: { show: false },
			axisTick: { show: false },
			splitLine: { show: false },
			splitArea: { show: false },
			axisLabel: { show: false }
		},
		tooltip: { show: false },
		series: [
			{
				id: '__loading',
				type: 'radar',
				radarIndex: 0,
				silent: true,
				symbol: 'none',
				data: [{ value: context.loadingData.slice(0, count) }],
				lineStyle: { color: withAlpha(context.resolved.tokens.foreground, 0), width: 2 },
				areaStyle: { color: withAlpha(context.resolved.tokens.foreground, 0) },
				animation: false,
				z: 1
			}
		]
	};
}

export function buildRadarOption(context: RadarOptionContext): EChartsRadarOption {
	if (context.isLoading) return loadingOption(context);
	return {
		animation: false,
		aria: { enabled: true },
		radar: radarComponent(context),
		tooltip: tooltipOption(context),
		series: realSeries(context)
	};
}
```

`$lib/components/evilcharts/charts/echarts-radar-chart/polar-angle-axis.svelte`

```svelte
<script lang="ts">
	import { useEChartsRadarChart } from './radar-chart-context.svelte.js';

	let { dataKey }: { dataKey?: string } = $props();
	const token = $props.id();
	const chart = useEChartsRadarChart();
	$effect(() => chart.angleAxes.register(token, () => ({ dataKey })));
</script>
```

`$lib/components/evilcharts/charts/echarts-radar-chart/polar-grid.svelte`

```svelte
<script lang="ts">
	import { useEChartsRadarChart } from './radar-chart-context.svelte.js';
	import type { GridType } from './types.js';

	let { gridType = 'polygon' }: { gridType?: GridType } = $props();
	const token = $props.id();
	const chart = useEChartsRadarChart();
	$effect(() => chart.grids.register(token, () => ({ gridType })));
</script>
```

`$lib/components/evilcharts/charts/echarts-radar-chart/polar-radius-axis.svelte`

```svelte
<script lang="ts">
	import { useEChartsRadarChart } from './radar-chart-context.svelte.js';

	const token = $props.id();
	const chart = useEChartsRadarChart();
	$effect(() => chart.radiusAxes.register(token, () => true));
</script>
```

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

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

const RADAR_CHART_CONTEXT = Symbol('echarts-radar-chart');

export type EChartsRadarChartContext = {
	radars: RegistrationSet<RadarRegistration>;
	grids: RegistrationSet<GridRegistration>;
	angleAxes: RegistrationSet<AngleAxisRegistration>;
	radiusAxes: RegistrationSet<true>;
};

export function setEChartsRadarChartContext(): EChartsRadarChartContext {
	const context = {
		radars: new RegistrationSet<RadarRegistration>(),
		grids: new RegistrationSet<GridRegistration>(),
		angleAxes: new RegistrationSet<AngleAxisRegistration>(),
		radiusAxes: new RegistrationSet<true>()
	};
	setContext(RADAR_CHART_CONTEXT, context);
	return context;
}

export function useEChartsRadarChart(): EChartsRadarChartContext {
	const context = getContext<EChartsRadarChartContext | undefined>(RADAR_CHART_CONTEXT);
	if (!context) {
		throw new Error('[EvilCharts] ECharts radar parts must be children of EChartsRadarChart.');
	}
	return context;
}
```

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

```svelte
<script lang="ts" generics="TData extends Record<string, unknown>">
	import { prefersReducedMotion } from 'svelte/motion';
	import { untrack, type Snippet } from 'svelte';
	import type { EChartsCoreOption, EChartsType } from 'echarts/core';
	import { AriaComponent, RadarComponent, TooltipComponent } from 'echarts/components';
	import { RadarChart } from 'echarts/charts';
	import * as echarts from 'echarts/core';
	import {
		ChartContainer,
		DEFAULT_ECHARTS_RENDERER,
		EChartsHost,
		LoadingIndicator,
		RegistrationSet,
		SelectableSeriesControls,
		resolveColors,
		setEChartsSharedSlotContext,
		withAlpha,
		type ChartAccessibility,
		type ChartConfig,
		type EChartsRenderer,
		type ResolvedColors
	} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
	import type { DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
	import { LegendOverlay } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
	import { setEChartsRadarChartContext } from './radar-chart-context.svelte.js';
	import { buildRadarOption, createRadarLoadingData, createRadarShimmerStops } from './option.js';
	import {
		LOADING_ANIMATION_DURATION,
		LOADING_DEFAULT_POINTS,
		REVEAL_DURATION,
		type DitherBloom,
		type LegendRegistration,
		type TooltipRegistration
	} from './types.js';

	echarts.use([RadarChart, RadarComponent, TooltipComponent, AriaComponent]);

	let {
		data,
		config,
		class: className,
		renderer = DEFAULT_ECHARTS_RENDERER,
		animation = true,
		renderStyle = 'native',
		ditherVariant = 'gradient',
		ditherCellSize = 2,
		bloom = 'off',
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		loadingPoints = LOADING_DEFAULT_POINTS,
		chartOptions,
		accessibility,
		children
	}: {
		data: TData[];
		config: ChartConfig;
		class?: string;
		renderer?: EChartsRenderer;
		animation?: boolean;
		renderStyle?: RenderStyle;
		ditherVariant?: DitherVariant;
		ditherCellSize?: number;
		bloom?: DitherBloom;
		defaultSelectedDataKey?: string | null;
		onSelectionChange?: (key: string | null) => void;
		isLoading?: boolean;
		loadingPoints?: number;
		chartOptions?: Record<string, unknown>;
		accessibility?: ChartAccessibility;
		children?: Snippet;
	} = $props();

	let container = $state<HTMLDivElement>();
	let instance = $state.raw<EChartsType>();
	let themeRevision = $state(0);
	let dimension = $state({ width: 320, height: 200 });
	let selectedDataKey = $state<string | null>(untrack(() => defaultSelectedDataKey));
	let hasRevealed = false;
	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)'
		}
	});

	const chart = setEChartsRadarChartContext();
	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 radars = $derived(chart.radars.values);
	const selectableSeries = $derived(
		radars
			.filter((radar) => radar.isClickable)
			.filter(
				(radar, index, all) => all.findIndex((item) => item.dataKey === radar.dataKey) === index
			)
			.map((radar) => ({
				key: radar.dataKey,
				label:
					typeof config[radar.dataKey]?.label === 'string'
						? (config[radar.dataKey].label as string)
						: radar.dataKey
			}))
	);
	const grid = $derived(chart.grids.first);
	const angleAxis = $derived(chart.angleAxes.first);
	const radiusAxis = $derived(Boolean(chart.radiusAxes.first));
	const tooltip = $derived(tooltipSlots.first);
	const legend = $derived(legendSlots.first);
	const seriesKeys = $derived(radars.map((radar) => radar.dataKey));
	const loadingData = $derived(createRadarLoadingData(loadingPoints));

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

	const fullOption = $derived.by(() => {
		const built = buildRadarOption({
			data: data as Record<string, unknown>[],
			config,
			radars,
			angleAxis,
			radiusAxis,
			grid,
			tooltip,
			legend,
			selectedDataKey,
			resolved,
			animation,
			reducedMotion: prefersReducedMotion.current,
			loadingPoints,
			loadingData,
			isLoading,
			renderStyle,
			ditherVariant,
			ditherCellSize,
			bloom,
			rendererSize: dimension
		});
		return {
			...(chartOptions ? { ...built, ...chartOptions } : built),
			animation: false,
			animationDurationUpdate: 0
		} as EChartsCoreOption;
	});

	type RevealSeries = {
		id?: string;
		data?: { value?: number[] }[];
	};

	const option = $derived.by(() => {
		if (isLoading || hasRevealed || !animation || prefersReducedMotion.current) return fullOption;
		const series = (fullOption.series as unknown as RevealSeries[] | undefined) ?? [];
		return {
			...fullOption,
			series: series.map((item) => ({
				...item,
				data: [{ value: (item.data?.[0]?.value ?? []).map(() => 0) }]
			}))
		} as EChartsCoreOption;
	});

	function selectSeries(key: string | null) {
		selectedDataKey = key;
		onSelectionChange?.(key);
	}

	function toggleSeries(key: string) {
		selectSeries(selectedDataKey === key ? null : key);
	}

	const events = $derived({
		click: (params: unknown) => {
			const event = params as { seriesId?: unknown; seriesIndex?: unknown } | null;
			const indexedKey =
				typeof event?.seriesIndex === 'number' ? seriesKeys[event.seriesIndex] : undefined;
			const key = String(event?.seriesId ?? indexedKey ?? '');
			if (!key || key.startsWith('__')) return;
			if (radars.find((radar) => radar.dataKey === key)?.isClickable) toggleSeries(key);
		}
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance || isLoading || tooltip?.defaultIndex === undefined) return;
		const defaultSeriesIndex = Math.min(
			Math.max(tooltip.defaultIndex, 0),
			Math.max(0, seriesKeys.length - 1)
		);
		if (seriesKeys.length === 0) return;
		const frame = requestAnimationFrame(() => {
			chartInstance.dispatchAction({
				type: 'showTip',
				seriesIndex: defaultSeriesIndex,
				dataIndex: 0
			});
		});
		return () => cancelAnimationFrame(frame);
	});

	$effect(() => {
		const chartInstance = instance;
		const series = (fullOption.series as unknown as RevealSeries[] | undefined) ?? [];
		if (isLoading) {
			hasRevealed = false;
			return;
		}
		if (
			!chartInstance ||
			hasRevealed ||
			series.length === 0 ||
			!animation ||
			prefersReducedMotion.current
		) {
			if (chartInstance && series.length > 0) hasRevealed = true;
			return;
		}
		const targets = series.map((item) => item.data?.[0]?.value ?? []);
		let frame = 0;
		let cancelled = false;
		hasRevealed = true;
		if (!chartInstance.isDisposed()) {
			chartInstance.setOption(
				{
					series: series.map((item, index) => ({
						id: item.id,
						data: [{ value: targets[index].map(() => 0) }]
					}))
				},
				{ silent: true, lazyUpdate: false }
			);
			const startedAt = performance.now();
			const tick = (now: number) => {
				if (cancelled || chartInstance.isDisposed()) return;
				const progress = Math.min(1, (now - startedAt) / REVEAL_DURATION);
				const eased = 1 - Math.pow(1 - progress, 3);
				chartInstance.setOption(
					{
						series: series.map((item, index) => ({
							id: item.id,
							data: [{ value: targets[index].map((value) => value * eased) }]
						}))
					},
					{ silent: true, lazyUpdate: true }
				);
				if (progress < 1) frame = requestAnimationFrame(tick);
			};
			frame = requestAnimationFrame(tick);
		}
		return () => {
			cancelled = true;
			cancelAnimationFrame(frame);
		};
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance || !isLoading) return;
		if (prefersReducedMotion.current) {
			chartInstance.setOption(
				{
					series: [
						{
							id: '__loading',
							data: [{ value: loadingData }],
							lineStyle: { color: withAlpha(resolved.tokens.foreground, 0.5), width: 2 },
							areaStyle: { color: withAlpha(resolved.tokens.foreground, 0.05) }
						}
					]
				},
				{ silent: true, lazyUpdate: true }
			);
			return;
		}
		const startedAt = performance.now();
		let currentLoadingData = loadingData;
		let lastPhase = 0;
		let frame = 0;
		const tick = (now: number) => {
			const phase = ((((now - startedAt) / LOADING_ANIMATION_DURATION) % 1) + 1) % 1;
			if (phase < lastPhase) currentLoadingData = createRadarLoadingData(loadingPoints);
			lastPhase = phase;
			const width = chartInstance.getWidth();
			const height = chartInstance.getHeight();
			if (!width || !height) {
				frame = requestAnimationFrame(tick);
				return;
			}
			const maxProgress = (width + height) / (2 * width);
			const center = phase * (maxProgress + 0.4) - 0.2;
			const clip = (peak: number) =>
				new echarts.graphic.LinearGradient(
					0,
					0,
					width,
					width,
					createRadarShimmerStops(center, resolved.tokens.foreground, peak),
					true
				);
			chartInstance.setOption(
				{
					series: [
						{
							id: '__loading',
							data: [{ value: currentLoadingData }],
							lineStyle: { color: clip(0.5), width: 2 },
							areaStyle: { color: clip(0.05) }
						}
					]
				},
				{ silent: true, lazyUpdate: true }
			);
			frame = requestAnimationFrame(tick);
		};
		frame = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(frame);
	});

	const legendStyle = $derived(
		`position:absolute;left:16px;right:16px;${
			legend?.verticalAlign === 'top'
				? 'top:12px;'
				: legend?.verticalAlign === 'middle'
					? 'top:50%;transform:translateY(-50%);'
					: 'bottom:12px;'
		}`
	);
</script>

{#snippet overlay()}
	{#if legend && !isLoading}
		<LegendOverlay
			{seriesKeys}
			{config}
			variant={legend.variant}
			align={legend.align}
			selectedKey={selectedDataKey}
			hoveredKey={null}
			isClickable={legend.isClickable}
			onToggle={toggleSeries}
			style={legendStyle}
		/>
	{/if}
	<LoadingIndicator {isLoading} />
{/snippet}

<ChartContainer
	{config}
	{accessibility}
	{overlay}
	bind:element={container}
	bind:themeRevision
	bind:dimension
	aria-busy={isLoading}
	class={className}
>
	{@render children?.()}
	<EChartsHost {option} {renderer} {events} bind:instance />
	{#if !legend?.isClickable}
		<SelectableSeriesControls
			items={selectableSeries}
			selectedKey={selectedDataKey}
			onToggle={toggleSeries}
		/>
	{/if}
</ChartContainer>
```

`$lib/components/evilcharts/charts/echarts-radar-chart/radar-slots.svelte.ts`

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

const RADAR_SLOTS_CONTEXT = Symbol('echarts-radar-slots');

type RadarSlots = {
	dots: RegistrationSet<{ variant: DotVariant }>;
	activeDots: RegistrationSet<{ variant: DotVariant }>;
};

export function setEChartsRadarSlots(): RadarSlots {
	const slots = {
		dots: new RegistrationSet<{ variant: DotVariant }>(),
		activeDots: new RegistrationSet<{ variant: DotVariant }>()
	};
	setContext(RADAR_SLOTS_CONTEXT, slots);
	return slots;
}

export function useEChartsRadarSlots(): RadarSlots {
	const slots = getContext<RadarSlots>(RADAR_SLOTS_CONTEXT);
	if (!slots) {
		throw new Error('[EvilCharts] ECharts Dot and ActiveDot must be nested inside Radar.');
	}
	return slots;
}
```

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

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
	import { useEChartsRadarChart } from './radar-chart-context.svelte.js';
	import { setEChartsRadarSlots } from './radar-slots.svelte.js';
	import { DEFAULT_FILL_OPACITY, type RadarStrokeVariant, type RadarVariant } from './types.js';

	let {
		dataKey,
		variant = 'filled',
		strokeVariant = 'solid',
		fillOpacity = DEFAULT_FILL_OPACITY,
		isClickable = false,
		glowing = false,
		ditherVariant,
		children
	}: {
		dataKey: string;
		variant?: RadarVariant;
		strokeVariant?: RadarStrokeVariant;
		fillOpacity?: number;
		isClickable?: boolean;
		glowing?: boolean;
		ditherVariant?: DitherVariant;
		children?: Snippet;
	} = $props();

	const token = $props.id();
	const chart = useEChartsRadarChart();
	const slots = setEChartsRadarSlots();

	$effect(() =>
		chart.radars.register(token, () => ({
			dataKey,
			variant,
			strokeVariant,
			fillOpacity,
			isClickable,
			glowing,
			ditherVariant,
			dotVariant: slots.dots.first?.variant ?? 'none',
			activeDotVariant: slots.activeDots.first?.variant ?? 'none'
		}))
	);
</script>

{@render children?.()}
```

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

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

	let {
		variant = 'default',
		roundness = 'lg',
		defaultIndex,
		position = 'variable'
	}: {
		variant?: TooltipVariant;
		roundness?: TooltipRoundness;
		defaultIndex?: number;
		position?: TooltipPosition;
	} = $props();

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

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

```ts
import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
import type { DitherBloom, DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
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 const STROKE_WIDTH = 1;
export const DEFAULT_FILL_OPACITY = 0.3;
export const REVEAL_DURATION = 1000;
export const LOADING_ANIMATION_DURATION = 2000;
export const LOADING_DEFAULT_POINTS = 6;
export const LOADING_MAX = 100;

export type RadarVariant = 'filled' | 'lines';
export type RadarStrokeVariant = 'solid' | 'dashed';
export type GridType = 'polygon' | 'circle';
export type { DitherBloom };

export type RadarRegistration = {
	dataKey: string;
	variant: RadarVariant;
	strokeVariant?: RadarStrokeVariant;
	fillOpacity: number;
	isClickable: boolean;
	glowing?: boolean;
	ditherVariant?: DitherVariant;
	dotVariant: DotVariant;
	activeDotVariant: DotVariant;
};

export type GridRegistration = { gridType: GridType };
export type AngleAxisRegistration = { dataKey?: string };

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

export type LegendRegistration = {
	variant: LegendVariant;
	align: 'left' | 'center' | 'right';
	verticalAlign: 'top' | 'middle' | 'bottom';
	isClickable: boolean;
};
```
        
      
      
        ### 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)
	};
}
```
        
        

Next, 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>
```
        
        

Finally, create `echarts-dot` in the same `ui` folder and paste the dot styles the series markers draw with there.


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

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

```ts
import type * as echarts from 'echarts/core';

export type DotVariant = 'none' | 'default' | 'border' | 'colored-border' | 'ping';

export type DotItemStyleOption = {
	color?: string | echarts.graphic.LinearGradient;
	borderColor?: string | echarts.graphic.LinearGradient;
	borderWidth?: number;
	opacity?: number;
};

export type DotStyle = { size: number; itemStyle: DotItemStyleOption };

function colorWithAlpha(color: string, alpha: number): string {
	if (color.startsWith('#')) {
		let hex = color.slice(1);
		if (hex.length === 3) hex = hex.replace(/./g, (character) => character.repeat(2));
		const numeric = Number.parseInt(hex, 16);
		return `rgba(${(numeric >> 16) & 255}, ${(numeric >> 8) & 255}, ${numeric & 255}, ${alpha})`;
	}
	const match = color.match(/rgba?\(([^)]+)\)/);
	if (!match) return color;
	const [red, green, blue] = match[1].split(',').map((part) => Number.parseFloat(part));
	return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}

export function dotItemStyle(
	variant: DotVariant,
	paint: string | echarts.graphic.LinearGradient,
	background: string
): DotItemStyleOption {
	switch (variant) {
		case 'border':
			return { color: paint, borderColor: background, borderWidth: 2 };
		case 'colored-border':
			return { color: background, borderColor: paint, borderWidth: 1 };
		case 'ping':
			return {
				color: paint,
				borderColor: typeof paint === 'string' ? colorWithAlpha(paint, 0.28) : paint,
				borderWidth: 10
			};
		case 'default':
			return { color: paint, borderWidth: 0 };
		default:
			return {};
	}
}

export const DOT_SIZES: Record<DotVariant, number> = {
	none: 0,
	default: 6,
	border: 8,
	'colored-border': 6,
	ping: 8
};

export function dotStyle(
	variant: DotVariant,
	paint: string | echarts.graphic.LinearGradient,
	background: string
): DotStyle {
	return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) };
}

export function sampleGradient(slots: string[], position: number): string {
	if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)';
	const parse = (color: string) =>
		color
			.match(/rgba?\(([^)]+)\)/)?.[1]
			.split(',')
			.map(Number) ?? [120, 120, 120, 1];
	const scaled = position * (slots.length - 1);
	const index = Math.min(Math.floor(scaled), slots.length - 2);
	const fraction = scaled - index;
	const [redFrom, greenFrom, blueFrom, alphaFrom = 1] = parse(slots[index]);
	const [redTo, greenTo, blueTo, alphaTo = 1] = parse(slots[index + 1]);
	const lerp = (from: number, to: number) => from + (to - from) * fraction;
	return `rgba(${Math.round(lerp(redFrom, redTo))}, ${Math.round(lerp(greenFrom, greenTo))}, ${Math.round(lerp(blueFrom, blueTo))}, ${lerp(alphaFrom, alphaTo).toFixed(3)})`;
}
```

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

```ts
export {
	DOT_SIZES,
	dotItemStyle,
	dotStyle,
	sampleGradient,
	type DotItemStyleOption,
	type DotStyle,
	type DotVariant
} from './dot.js';
```
        
      
    
  


## Usage

The ECharts radar chart is composable, sharing the LayerChart sibling's API shape. `<EChartsRadarChart>` is the container, and every part hangs off it as a compound member — `<EChartsRadarChart.PolarGrid>`, `<EChartsRadarChart.PolarAngleAxis>`, `<EChartsRadarChart.PolarRadiusAxis>`, `<EChartsRadarChart.Legend>`, `<EChartsRadarChart.Tooltip>`, and one or more `<EChartsRadarChart.Radar>` — so a single import gives you the whole chart. Each `<Radar>` carries its own `variant` and `isClickable`, so one chart can mix fill styles and make only some series interactive.

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

```svelte
const data = [
  { skill: "JavaScript", desktop: 186, mobile: 80 },
  { skill: "TypeScript", desktop: 305, mobile: 200 },
  { skill: "React", desktop: 237, mobile: 120 },
  { skill: "Node.js", desktop: 173, mobile: 190 },
  { skill: "CSS", desktop: 209, mobile: 130 },
];

const chartConfig = {
  desktop: {
    label: "Desktop",
    colors: { light: ["#3b82f6"], dark: ["#60a5fa"] },
  },
  mobile: {
    label: "Mobile",
    colors: { light: ["#10b981"], dark: ["#34d399"] },
  },
} satisfies ChartConfig;

<EChartsRadarChart data={data} config={chartConfig}>
  <EChartsRadarChart.PolarGrid />
  <EChartsRadarChart.PolarAngleAxis dataKey="skill" />
  <EChartsRadarChart.Legend />
  <EChartsRadarChart.Tooltip />
  <EChartsRadarChart.Radar dataKey="desktop" variant="filled">
    <EChartsRadarChart.Dot variant="colored-border" />
    <EChartsRadarChart.ActiveDot variant="default" />
  </EChartsRadarChart.Radar>
  <EChartsRadarChart.Radar dataKey="mobile" variant="filled" />
</EChartsRadarChart>
```

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

The `config` is the same contract as every EvilCharts chart — each key maps a data key 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 two small departures from the LayerChart sibling: a radar series is a single polygon, so multi-color configs paint the stroke and fill as gradients while the vertex dots take one representative color; and the tooltip is item-triggered, showing the hovered series and its per-category values rather than anchoring on a category.




### 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 { EChartsRadarChart } from '$lib/components/evilcharts/charts/echarts-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadarChart renderer="svg" {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend isClickable />
	<EChartsRadarChart.Tooltip />
	<!-- [!code highlight:2] -->
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled" isClickable>
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled" isClickable>
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
</EChartsRadarChart>
```

### Interactive Selection

Set `isClickable` on a `<Radar>` to make it selectable by click, and on `<Legend>` to let entries toggle selection. Handle range changes with the root's `onSelectionChange` callback:

```svelte
<EChartsRadarChart
	{data}
	config={chartConfig}
	onSelectionChange={(selectedDataKey) => {
		if (selectedDataKey) {
			console.log('Selected:', selectedDataKey);
		} else {
			console.log('Deselected');
		}
	}}
>
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend isClickable />
	<EChartsRadarChart.Tooltip />
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled" isClickable />
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled" isClickable />
</EChartsRadarChart>
```

### Loading State

### isLoading='true'

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

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight] -->
<EChartsRadarChart data={[]} config={chartConfig} class="h-full w-full p-4" isLoading={true}>
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend />
	<EChartsRadarChart.Tooltip />
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled" />
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled" />
</EChartsRadarChart>
```
> 
  

Pass `isLoading` to show an animated skeleton polygon, and `loadingPoints` to set how many points it draws.




```svelte
<EChartsRadarChart data={[]} config={chartConfig} isLoading>
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend />
	<EChartsRadarChart.Tooltip />
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled" />
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled" />
</EChartsRadarChart>
```

## Examples

Radar charts with different configurations.

### Lines Variant

### variant='lines'

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

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend />
	<EChartsRadarChart.Tooltip />
	<!-- [!code highlight:2] -->
	<EChartsRadarChart.Radar dataKey="desktop" variant="lines">
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
	<EChartsRadarChart.Radar dataKey="mobile" variant="lines">
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
</EChartsRadarChart>
```
> 
  

Set `variant="lines"` to show the outline without fill — cleaner for comparing multiple datasets.




### Circle Grid

### gridType='circle'

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

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<!-- [!code highlight:2] -->
	<EChartsRadarChart.PolarGrid gridType="circle" />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend />
	<EChartsRadarChart.Tooltip />
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled">
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled">
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
</EChartsRadarChart>
```
> 
  

Set `gridType="circle"` on <code>&lt;PolarGrid&gt;</code> for circular grid lines instead of the default polygon grid.




### Gradient Colors

### gradient colors

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

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				// [!code highlight:2]
				light: ['#6366f1', '#a855f7', '#ec4899'], // Indigo -> Purple -> Pink
				dark: ['red', 'orange', 'pink']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				// [!code highlight:2]
				light: ['#14b8a6', '#06b6d4', '#3b82f6'], // Teal -> Cyan -> Blue
				dark: ['#2dd4bf', '#22d3ee', '#60a5fa']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsRadarChart.PolarGrid />
	<EChartsRadarChart.PolarAngleAxis dataKey="skill" />
	<EChartsRadarChart.Legend />
	<EChartsRadarChart.Tooltip />
	<EChartsRadarChart.Radar dataKey="desktop" variant="filled">
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
	<EChartsRadarChart.Radar dataKey="mobile" variant="filled">
		<EChartsRadarChart.Dot variant="colored-border" />
		<EChartsRadarChart.ActiveDot variant="default" />
	</EChartsRadarChart.Radar>
</EChartsRadarChart>
```

### Ordered dither

Set `renderStyle="dither"` to use the independent ordered-dither treatment inspired by [Dither Kit](https://github.com/Boring-Software-Inc/dither-kit). Axes, tooltips, selection, and the ECharts renderer stay intact.

### renderStyle="dither"

```svelte
<script lang="ts">
	import {
		EChartsRadarChart,
		type ChartConfig
	} from '$lib/components/evilcharts/charts/echarts-radar-chart/index.js';
	const data = [
		{ skill: 'JS', desktop: 80, mobile: 48 },
		{ skill: 'TS', desktop: 96, mobile: 70 },
		{ skill: 'Svelte', desktop: 88, mobile: 76 },
		{ skill: 'CSS', desktop: 72, mobile: 84 },
		{ skill: 'Node', desktop: 82, mobile: 58 },
		{ skill: 'A11y', desktop: 68, mobile: 74 }
	];
	const config = {
		desktop: { label: 'Desktop', colors: { light: ['#047857'], dark: ['#10b981'] } },
		mobile: { label: 'Mobile', colors: { light: ['#be123c'], dark: ['#f43f5e'] } }
	} satisfies ChartConfig;
</script>

<EChartsRadarChart {data} {config} renderStyle="dither" bloom="low" class="h-full w-full p-4">
	<EChartsRadarChart.PolarGrid /><EChartsRadarChart.PolarAngleAxis
		dataKey="skill"
	/><EChartsRadarChart.Legend isClickable /><EChartsRadarChart.Tooltip />
	<EChartsRadarChart.Radar dataKey="desktop" ditherVariant="gradient" isClickable />
	<EChartsRadarChart.Radar dataKey="mobile" ditherVariant="hatched" isClickable />
</EChartsRadarChart>
```

## API Reference

The radar chart is a root container plus composable parts. Regardless of renderer, each part is declarative config the root compiles, but the API mirrors the LayerChart sibling. Each is documented below.

### EChartsRadarChart

The root container. It owns the data, shared selection state, loading skeleton, and intro reveal. Everything visual is composed as children and compiled into the ECharts option.


  ### `data` (required)

type: `TData[]`

The chart data — an array of objects, one per angle-axis category (`TData extends Record<string, unknown>`).
  ### `config` (required)

type: `ChartConfig`

Defines the radar series. Each key matches a numeric data key, with a `label` and a per-theme `colors` array. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config).
  ### `children` (required)

type: `Snippet`

The composed chart parts — `<PolarGrid />`, `<PolarAngleAxis />`, `<PolarRadiusAxis />`, `<Legend />`, `<Tooltip />`, and one or more `<Radar />`.
  ### `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.
  ### `renderStyle`

type: `"native" | "dither"` · default: `"native"`

Selects native ECharts paint or EvilCharts' ordered-dither rendering.
  ### `ditherVariant`

type: `"gradient" | "dotted" | "hatched" | "solid"` · default: `"gradient"`

Default ordered-dither pattern used by the chart's series.
  ### `ditherCellSize`

type: `number` · default: `2`

Dither cell size in CSS pixels.
  ### `bloom`

type: `"off" | "low" | "high" | "aura"` · default: `"off"`

Optional glow applied to dithered marks. It has no effect in native rendering mode.
  ### `animation`

type: `boolean` · default: `true`

Master switch for the intro draw-in — the radar polygon grows from the center on first render. Pass `false` to render instantly. The OS reduce-motion preference disables it automatically.
  ### `defaultSelectedDataKey`

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

The radar series selected on first render.
  ### `onSelectionChange`

type: `(key: string | null) => void`

Fires when a series is selected or deselected via a clickable `<Radar />` or `<Legend />`. Receives the selected data key, or `null` when deselected.
  ### `isLoading`

type: `boolean` · default: `false`

Shows the animated loading skeleton while data loads.
  ### `loadingPoints`

type: `number` · default: `6`

Number of points rendered in the loading skeleton polygon.
  ### `chartOptions`

type: `Record<string, unknown>`

Escape hatch merged over the underlying ECharts option object. 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.


### Radar

A single radar series — one polygon across every angle-axis category. Each `<Radar />` carries its own fill and clickability, so a chart can hold many radars styled independently. Compose `<Dot />` and `<ActiveDot />` inside for vertex markers.


  ### `dataKey` (required)

type: `string`

The series key to render. Must exist on both the data rows and the chart `config`.
  ### `variant`

type: `"filled" | "lines"` · default: `"filled"`

The visual style for this radar. `"filled"` shows a filled area, `"lines"` shows only the outline.
  ### `fillOpacity`

type: `number` · default: `0.3`

The opacity of the filled area when using `variant="filled"`.
  ### `strokeVariant`

type: `"solid" | "dashed"` · default: `"solid"`

Controls the outline style independently from the fill variant.
  ### `glowing`

type: `boolean` · default: `false`

Adds a restrained glow to this radar series.
  ### `isClickable`

type: `boolean` · default: `false`

Lets this radar be clicked to select/deselect it. When one is selected, unselected clickable radars turn semi-transparent.
  ### `children`

type: `Snippet`

Optional `<Dot />` and `<ActiveDot />` composition for vertex markers on this radar.


### Dot and ActiveDot

Vertex markers composed inside a `<Radar />`. `<Dot />` is the resting marker; `<ActiveDot />` is the hovered marker. They render nothing on their own — the parent `<Radar />` reads their `variant`.


  ### `variant`

type: `"default" | "border" | "colored-border"` · default: `"default"`

The visual style of the vertex marker.


### PolarGrid

The polar grid — the concentric rings and the radial spokes. Its presence draws the grid; omit it and no grid lines render.


  ### `gridType`

type: `"polygon" | "circle"` · default: `"polygon"`

The shape of the grid rings. `"polygon"` creates angular grid lines, `"circle"` creates circular grid lines.


### PolarAngleAxis

The angular category axis — the labels around the chart's perimeter. Its presence shows the labels; omit it and they hide. Hidden automatically while the chart is loading.


  ### `dataKey`

type: `string`

The data key for the angle-axis labels (e.g. categories, skills, months). When omitted, the first data column not claimed by a `<Radar />` is used.


### PolarRadiusAxis

The radial value axis — the scale running from the center outward. Its presence shows the scale labels; omit it and they hide. Hidden automatically while the chart is loading. It takes no props.

### Tooltip

The hover tooltip. Its presence enables the tooltip; omit it and none shows. The tooltip is item-triggered — it shows the hovered series and its value at each category, and dims its content when another series is selected.


  ### `variant`

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

The visual style of the tooltip surface.
  ### `roundness`

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

Controls the border-radius of the tooltip.
  ### `position`

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

Anchoring of the tooltip. `"variable"` follows the pointer (default). `"fixed"` pins the tooltip near the top and only tracks the pointer's X.
  ### `defaultIndex`

type: `number`

Shows a tooltip by default with no hover. Because the radar tooltip is item-triggered, this selects the series (by index) whose tooltip is revealed.


### Legend

The series legend, rendered as HTML above the chart surface. Its presence enables the legend; omit it and none shows. When `isClickable` is set, each entry toggles selection of its series. Hidden automatically while the chart is loading.


  ### `variant`

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

The 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`

Lets each legend entry toggle selection of its series, driving the shared selection state read by every `<Radar />`.

