
### Basic Chart

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart
	class="h-full w-full p-4"
	xDataKey="month"
	{data}
	config={chartConfig}
	accessibility={{
		label: 'Monthly revenue and profit chart',
		description: 'Revenue bars and a profit line from January through December, with a range brush.'
	}}
>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Brush formatLabel={(value) => String(value).substring(0, 3)} />
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable>
		<EChartsComposedChart.ActiveDot variant="colored-border" />
		<EChartsComposedChart.Dot variant="default" />
	</EChartsComposedChart.Line>
</EChartsComposedChart>
```

## Installation


  
  
    ### npm

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

### yarn

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

### bun

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

### pnpm

```bash
pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-composed-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 copy the code below into a new `echarts-composed-chart` file there.


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

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

```svelte
<script lang="ts">
	import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
	import { useEChartsComposedLineSlots } from './line-slots.svelte.js';
	let { variant = 'default' }: { variant?: DotVariant } = $props();
	const token = $props.id();
	const slots = useEChartsComposedLineSlots();
	$effect(() => slots.activeDots.register(token, () => ({ variant })));
</script>
```

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

```svelte
<script lang="ts">
	import type { BarSeriesOption } from 'echarts/charts';
	import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
	import { useEChartsComposedChart } from './composed-chart-context.svelte.js';
	import type { BarVariant, ComposedAnimationType } from './types.js';
	let {
		dataKey,
		variant = 'default',
		radius = 4,
		glow = false,
		animationType,
		isClickable = false,
		enableHoverHighlight = false,
		barProps,
		ditherVariant
	}: {
		dataKey: string;
		variant?: BarVariant;
		radius?: number;
		glow?: boolean;
		animationType?: ComposedAnimationType;
		isClickable?: boolean;
		enableHoverHighlight?: boolean;
		barProps?: Partial<BarSeriesOption>;
		ditherVariant?: DitherVariant;
	} = $props();
	const token = $props.id();
	const chart = useEChartsComposedChart();
	$effect(() =>
		chart.bars.register(token, () => ({
			dataKey,
			variant,
			radius,
			glow,
			animationType,
			isClickable,
			enableHoverHighlight,
			barProps,
			ditherVariant
		}))
	);
</script>
```

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

```ts
import { getContext, setContext } from 'svelte';
import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import type { AxisRegistration, BarRegistration, LineRegistration } from './types.js';
const CONTEXT = Symbol('echarts-composed-chart');
export class EChartsComposedChartContext {
	bars = new RegistrationSet<BarRegistration>();
	lines = new RegistrationSet<LineRegistration>();
	xAxes = new RegistrationSet<AxisRegistration>();
	yAxes = new RegistrationSet<AxisRegistration>();
	grids = new RegistrationSet<Record<string, never>>();
}
export function setEChartsComposedChartContext() {
	const value = new EChartsComposedChartContext();
	setContext(CONTEXT, value);
	return value;
}
export function useEChartsComposedChart() {
	const value = getContext<EChartsComposedChartContext | undefined>(CONTEXT);
	if (!value)
		throw new Error(
			'[EvilCharts] ECharts composed parts must be children of EChartsComposedChart.'
		);
	return value;
}
```

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

```svelte
<script lang="ts">
	import { prefersReducedMotion } from 'svelte/motion';
	import { untrack, type Snippet } from 'svelte';
	import type { EChartsCoreOption, EChartsType } from 'echarts/core';
	import {
		AriaComponent,
		DataZoomComponent,
		GridComponent,
		TooltipComponent
	} from 'echarts/components';
	import { BarChart, LineChart } from 'echarts/charts';
	import * as echarts from 'echarts/core';
	import {
		ChartContainer,
		DEFAULT_ECHARTS_RENDERER,
		EChartsHost,
		LoadingIndicator,
		mergeLifecycleOptions,
		RegistrationSet,
		SelectableSeriesControls,
		resolveColors,
		setEChartsSharedSlotContext,
		withAlpha,
		type ChartAccessibility,
		type ChartConfig,
		type EChartsRenderer,
		type EChartsRenderStyle,
		type ResolvedColors
	} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
	import type { DitherBloom, DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
	import { LegendOverlay } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
	import {
		BrushControls,
		syncBrushOverlay,
		type BrushOverlayElements
	} from '$lib/components/evilcharts/ui/echarts-brush/index.js';
	import { setEChartsComposedChartContext } from './composed-chart-context.svelte.js';
	import { buildComposedOption, createComposedLoadingData } from './option.js';
	import type {
		BrushRegistration,
		CurveType,
		LegendRegistration,
		ComposedAnimationType,
		TooltipRegistration
	} from './types.js';

	echarts.use([
		BarChart,
		LineChart,
		GridComponent,
		TooltipComponent,
		DataZoomComponent,
		AriaComponent
	]);

	let {
		data,
		config,
		renderer = DEFAULT_ECHARTS_RENDERER,
		renderStyle = 'native',
		ditherVariant = 'gradient',
		ditherCellSize = 2,
		bloom = 'off',
		xDataKey,
		class: className,
		curveType = 'linear',
		animation = true,
		animationType = 'left-to-right',
		barGap,
		barCategoryGap,
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		loadingBars = 12,
		chartOptions,
		accessibility,
		children
	}: {
		data: Record<string, unknown>[];
		config: ChartConfig;
		renderer?: EChartsRenderer;
		renderStyle?: EChartsRenderStyle;
		ditherVariant?: DitherVariant;
		ditherCellSize?: number;
		bloom?: DitherBloom;
		xDataKey?: string;
		class?: string;
		curveType?: CurveType;
		animation?: boolean;
		animationType?: ComposedAnimationType;
		barGap?: number | string;
		barCategoryGap?: number | string;
		defaultSelectedDataKey?: string | null;
		onSelectionChange?: (key: string | null) => void;
		isLoading?: boolean;
		loadingBars?: number;
		chartOptions?: Record<string, unknown>;
		accessibility?: ChartAccessibility;
		children?: Snippet;
	} = $props();

	let container = $state<HTMLDivElement>();
	let dimension = $state({ width: 320, height: 200 });
	let themeRevision = $state(0);
	let instance = $state.raw<EChartsType>();
	let selectedDataKey = $state<string | null>(untrack(() => defaultSelectedDataKey));
	let hoveredDataKey = $state<string | null>(null);
	let introComplete = $state(false);
	let brushRange = $state({ start: 0, end: 100 });
	let brushHover = $state({ inside: false, left: false, right: false });
	const brushOverlayStore: { brushOverlay: BrushOverlayElements | null } = { brushOverlay: null };
	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 = setEChartsComposedChartContext();
	const tooltipSlots = new RegistrationSet<TooltipRegistration>();
	const legendSlots = new RegistrationSet<LegendRegistration>();
	const brushSlots = new RegistrationSet<BrushRegistration>();
	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 brushSlots.register(token, getter as () => BrushRegistration);
		}
	});

	const bars = $derived(chart.bars.values);
	const lines = $derived(chart.lines.values);
	const selectableSeries = $derived(
		[...bars, ...lines]
			.filter((series) => series.isClickable)
			.filter(
				(series, index, all) => all.findIndex((item) => item.dataKey === series.dataKey) === index
			)
			.map((series) => ({
				key: series.dataKey,
				label:
					typeof config[series.dataKey]?.label === 'string'
						? (config[series.dataKey].label as string)
						: series.dataKey
			}))
	);
	const effectiveAnimation = $derived(
		bars[0]?.animationType ?? lines[0]?.animationType ?? animationType
	);
	const seriesKeys = $derived([
		...bars.map((bar) => bar.dataKey),
		...lines.map((line) => line.dataKey)
	]);
	const xAxis = $derived(chart.xAxes.first);
	const yAxis = $derived(chart.yAxes.first);
	const tooltip = $derived(tooltipSlots.first);
	const legend = $derived(legendSlots.first);
	const brush = $derived(brushSlots.first);
	let loadingData = $state.raw<number[]>(untrack(() => createComposedLoadingData(loadingBars)));
	let loadingLineData = $state.raw<number[]>(untrack(() => createComposedLoadingData(loadingBars)));
	$effect(() => {
		loadingData = createComposedLoadingData(loadingBars);
		loadingLineData = createComposedLoadingData(loadingBars);
	});
	const categoryValues = $derived.by(() => {
		const series = new Set([
			...bars.map((bar) => bar.dataKey),
			...lines.map((line) => line.dataKey)
		]);
		const key =
			xDataKey ?? xAxis?.dataKey ?? Object.keys(data[0] ?? {}).find((item) => !series.has(item));
		return data.map((row, index) => String((key ? row[key] : undefined) ?? index));
	});

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

	const option = $derived.by(() => {
		const built = buildComposedOption({
			data,
			config,
			bars,
			lines,
			xDataKey,
			curveType,
			barGap,
			barCategoryGap,
			selectedDataKey,
			xAxis,
			yAxis,
			showGrid: chart.grids.size > 0,
			tooltip,
			legend,
			brush,
			brushRange,
			isLoading,
			loadingData,
			loadingLineData,
			resolved,
			animation: animation && !introComplete && effectiveAnimation !== 'none',
			animationType,
			reducedMotion: prefersReducedMotion.current,
			rendererSize: dimension,
			renderStyle,
			ditherVariant,
			ditherCellSize,
			bloom
		});
		return mergeLifecycleOptions(built, chartOptions) as EChartsCoreOption;
	});

	$effect(() => {
		if (isLoading) {
			introComplete = false;
			return;
		}
		if (introComplete || !instance || bars.length + lines.length === 0) return;
		if (!animation || effectiveAnimation === 'none' || prefersReducedMotion.current) {
			introComplete = true;
			return;
		}
		const timer = window.setTimeout(
			() => (introComplete = true),
			Math.max(1000, 500 + Math.max(0, data.length - 1) * 50)
		);
		return () => window.clearTimeout(timer);
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance) return;
		if (!brush || isLoading) {
			syncBrushOverlay(chartInstance, brushOverlayStore, null);
			return;
		}
		const last = Math.max(0, categoryValues.length - 1);
		const startIndex = Math.round((brushRange.start / 100) * last);
		const endIndex = Math.round((brushRange.end / 100) * last);
		const format = brush.formatLabel;
		syncBrushOverlay(chartInstance, brushOverlayStore, {
			range: brushRange,
			geom: { bottom: legend?.verticalAlign === 'bottom' ? 34 : 6, height: brush.height ?? 56 },
			size: dimension,
			tokens: resolved.tokens,
			labels: format
				? {
						start: format(categoryValues[startIndex] ?? '', startIndex),
						end: format(categoryValues[endIndex] ?? '', endIndex)
					}
				: null,
			showLabels: brushHover.inside,
			hover: brushHover
		});
	});

	$effect(() => {
		const chartInstance = instance;
		const activeBrush = brush;
		const currentLegend = legend;
		if (!chartInstance || !activeBrush || isLoading) return;
		const renderer = chartInstance.getZr();
		const move = (event: { offsetX?: number; offsetY?: number }) => {
			const x = event.offsetX ?? -1;
			const y = event.offsetY ?? -1;
			const height = activeBrush.height ?? 56;
			const bottom = currentLegend?.verticalAlign === 'bottom' ? 34 : 6;
			const top = chartInstance.getHeight() - bottom - height;
			const inside = y >= top - 4 && y <= top + height + 4;
			const trackWidth = Math.max(chartInstance.getWidth() - 16, 1);
			const leftX = 8 + (trackWidth * brushRange.start) / 100;
			const rightX = 8 + (trackWidth * brushRange.end) / 100;
			brushHover = {
				inside,
				left: inside && Math.abs(x - leftX) <= 8,
				right: inside && Math.abs(x - rightX) <= 8
			};
		};
		const out = () => {
			brushHover = { inside: false, left: false, right: false };
		};
		renderer.on('mousemove', move);
		renderer.on('globalout', out);
		return () => {
			renderer.off('mousemove', move);
			renderer.off('globalout', out);
		};
	});

	$effect(() => {
		const chartInstance = instance;
		const index = tooltip?.defaultIndex;
		if (!chartInstance || isLoading || index === undefined) return;
		const timer = window.setTimeout(
			() => {
				if (!chartInstance.isDisposed()) {
					chartInstance.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: index });
				}
			},
			animation && effectiveAnimation !== 'none' && !prefersReducedMotion.current ? 1060 : 60
		);
		return () => {
			window.clearTimeout(timer);
			if (!chartInstance.isDisposed()) chartInstance.dispatchAction({ type: 'hideTip' });
		};
	});

	function toggleSelection(key: string) {
		if (
			![...bars, ...lines].some((series) => series.dataKey === key && series.isClickable) &&
			!legend?.isClickable
		) {
			return;
		}
		const next = selectedDataKey === key ? null : key;
		selectedDataKey = next;
		onSelectionChange?.(next);
	}

	function eventSeriesKey(params: unknown): string | null {
		if (!params || typeof params !== 'object') return null;
		const id = (params as { seriesId?: unknown }).seriesId;
		if (typeof id !== 'string' || id.startsWith('__')) return null;
		return id;
	}

	const events = $derived({
		click: (params: unknown) => {
			const key = eventSeriesKey(params);
			if (key) toggleSelection(key);
		},
		mouseover: (params: unknown) => {
			if (selectedDataKey !== null) return;
			hoveredDataKey = eventSeriesKey(params);
		},
		mouseout: () => {
			hoveredDataKey = null;
		},
		datazoom: () => {
			const zoom = (instance?.getOption() as { dataZoom?: { start?: number; end?: number }[] })
				?.dataZoom?.[0];
			if (!zoom) return;
			brushRange = { start: zoom.start ?? 0, end: zoom.end ?? 100 };
			if (!brush?.onChange) return;
			const last = Math.max(0, data.length - 1);
			brush.onChange({
				startIndex: Math.round((brushRange.start / 100) * last),
				endIndex: Math.round((brushRange.end / 100) * last)
			});
		}
	});

	$effect(() => {
		const chartInstance = instance;
		const animatedKeys = lines
			.filter((line) => line.strokeVariant === 'animated-dashed')
			.map((line) => line.dataKey);
		if (
			!chartInstance ||
			isLoading ||
			selectedDataKey !== null ||
			animatedKeys.length === 0 ||
			prefersReducedMotion.current ||
			!introComplete
		)
			return;
		let frame = 0;
		const start = performance.now();
		const tick = (now: number) => {
			if (chartInstance.isDisposed()) return;
			const dashOffset = -(((now - start) / 1000) % 1) * 6;
			chartInstance.setOption(
				{ series: animatedKeys.map((id) => ({ id, lineStyle: { dashOffset } })) },
				{ silent: true, lazyUpdate: true }
			);
			frame = requestAnimationFrame(tick);
		};
		frame = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(frame);
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance || chartInstance.isDisposed() || !isLoading) return;
		if (prefersReducedMotion.current) {
			chartInstance.setOption(
				{
					series: [
						{
							id: '__loading',
							data: loadingData,
							itemStyle: { color: withAlpha(resolved.tokens.foreground, 0.22) }
						},
						{
							id: '__loading-line',
							data: loadingLineData,
							lineStyle: { color: withAlpha(resolved.tokens.foreground, 0.5) }
						}
					]
				},
				{ silent: true, lazyUpdate: true }
			);
			return;
		}
		let frame = 0;
		let lastPhase = 0;
		const start = performance.now();
		const tick = (now: number) => {
			if (chartInstance.isDisposed()) return;
			const phase = ((now - start) / 2000) % 1;
			if (phase < lastPhase) {
				loadingData = createComposedLoadingData(loadingBars);
				loadingLineData = createComposedLoadingData(loadingBars);
			}
			lastPhase = phase;
			const width = chartInstance.getWidth();
			const height = chartInstance.getHeight();
			if (!width || !height) {
				frame = requestAnimationFrame(tick);
				return;
			}
			const maxT = (width + height) / (2 * width);
			const center = phase * (maxT + 0.4) - 0.2;
			const color = resolved.tokens.foreground;
			const stops = (peak: number) =>
				[0, center - 0.2, center, center + 0.2, 1]
					.filter((offset) => offset >= 0 && offset <= 1)
					.sort((left, right) => left - right)
					.filter(
						(offset, index, all) =>
							index === 0 || offset - (all[index - 1] ?? Number.NEGATIVE_INFINITY) > 0.0001
					)
					.map((offset) => {
						const distance = Math.abs(offset - center);
						const alpha =
							distance >= 0.2 ? 0 : peak * Math.sin(((1 - distance / 0.2) * Math.PI) / 2);
						return { offset, color: withAlpha(color, alpha) };
					});
			chartInstance.setOption(
				{
					series: [
						{
							id: '__loading',
							data: loadingData,
							itemStyle: {
								color: new echarts.graphic.LinearGradient(0, 0, width, width, stops(0.22), true)
							}
						},
						{
							id: '__loading-line',
							data: loadingLineData,
							lineStyle: {
								color: new echarts.graphic.LinearGradient(
									0,
									0,
									Math.max(width, 1),
									Math.max(width, 1),
									stops(0.5),
									true
								)
							}
						}
					]
				},
				{ silent: true, lazyUpdate: true }
			);
			frame = requestAnimationFrame(tick);
		};
		frame = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(frame);
	});

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

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

<ChartContainer
	{config}
	{accessibility}
	{overlay}
	bind:element={container}
	bind:dimension
	bind:themeRevision
	aria-busy={isLoading}
	class={className}
>
	{@render children?.()}
	{#if bars.length + lines.length > 0}
		<EChartsHost {option} {renderer} {events} bind:instance />
	{/if}
	{#if !legend?.isClickable}
		<SelectableSeriesControls
			items={selectableSeries}
			selectedKey={selectedDataKey}
			onToggle={toggleSelection}
		/>
	{/if}
	{#if brush && !isLoading && data.length > 0}
		<BrushControls
			startIndex={Math.round((brushRange.start / 100) * Math.max(0, data.length - 1))}
			endIndex={Math.round((brushRange.end / 100) * Math.max(0, data.length - 1))}
			totalPoints={data.length}
			formatLabel={(index) =>
				brush.formatLabel?.(categoryValues[index] ?? '', index) ??
				String(categoryValues[index] ?? index)}
			onChange={(range) => {
				const last = Math.max(0, data.length - 1);
				brushRange = {
					start: last === 0 ? 0 : (range.startIndex / last) * 100,
					end: last === 0 ? 100 : (range.endIndex / last) * 100
				};
				instance?.dispatchAction(
					{ type: 'dataZoom', start: brushRange.start, end: brushRange.end },
					{ silent: true }
				);
				brush.onChange?.(range);
			}}
		/>
	{/if}
</ChartContainer>
```

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

```svelte
<script lang="ts">
	import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
	import { useEChartsComposedLineSlots } from './line-slots.svelte.js';
	let { variant = 'default' }: { variant?: DotVariant } = $props();
	const token = $props.id();
	const slots = useEChartsComposedLineSlots();
	$effect(() => slots.dots.register(token, () => ({ variant })));
</script>
```

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

```svelte
<script lang="ts">
	import { useEChartsComposedChart } from './composed-chart-context.svelte.js';
	const token = $props.id();
	const chart = useEChartsComposedChart();
	$effect(() => chart.grids.register(token, () => ({})));
</script>
```

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

```ts
import Root from './composed-chart.svelte';
import Bar from './bar.svelte';
import Line from './line.svelte';
import Dot from './dot.svelte';
import ActiveDot from './active-dot.svelte';
import XAxis from './x-axis.svelte';
import YAxis from './y-axis.svelte';
import Grid from './grid.svelte';
import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
import { Legend } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
import { Brush } from '$lib/components/evilcharts/ui/echarts-brush/index.js';
type RootComponent = typeof Root;
export const EChartsComposedChart: RootComponent & {
	Bar: typeof Bar;
	Line: typeof Line;
	Dot: typeof Dot;
	ActiveDot: typeof ActiveDot;
	XAxis: typeof XAxis;
	YAxis: typeof YAxis;
	Grid: typeof Grid;
	Tooltip: typeof Tooltip;
	Legend: typeof Legend;
	Brush: typeof Brush;
} = Object.assign(Root, { Bar, Line, Dot, ActiveDot, XAxis, YAxis, Grid, Tooltip, Legend, Brush });
export type {
	ChartAccessibility,
	ChartConfig,
	EChartsRenderer,
	EChartsRenderStyle
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
export type { DitherBloom, DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
export type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/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 { BarVariant, ComposedAnimationType, CurveType, StrokeVariant } from './types.js';
```

`$lib/components/evilcharts/charts/echarts-composed-chart/line-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';
type DotRegistration = { variant: DotVariant };
const CONTEXT = Symbol('echarts-composed-line-slots');
class Slots {
	dots = new RegistrationSet<DotRegistration>();
	activeDots = new RegistrationSet<DotRegistration>();
}
export function setEChartsComposedLineSlots() {
	const value = new Slots();
	setContext(CONTEXT, value);
	return value;
}
export function useEChartsComposedLineSlots() {
	const value = getContext<Slots | undefined>(CONTEXT);
	if (!value)
		throw new Error('[EvilCharts] Dot parts must be children of EChartsComposedChart.Line.');
	return value;
}
```

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

```svelte
<script lang="ts">
	import type { LineSeriesOption } from 'echarts/charts';
	import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
	import type { Snippet } from 'svelte';
	import { useEChartsComposedChart } from './composed-chart-context.svelte.js';
	import { setEChartsComposedLineSlots } from './line-slots.svelte.js';
	import type { ComposedAnimationType, CurveType, StrokeVariant } from './types.js';
	let {
		dataKey,
		strokeVariant = 'solid',
		curveType,
		animationType,
		connectNulls = false,
		glow = false,
		isClickable = false,
		lineProps,
		children,
		ditherVariant
	}: {
		dataKey: string;
		strokeVariant?: StrokeVariant;
		curveType?: CurveType;
		animationType?: ComposedAnimationType;
		connectNulls?: boolean;
		glow?: boolean;
		isClickable?: boolean;
		lineProps?: Partial<LineSeriesOption>;
		children?: Snippet;
		ditherVariant?: DitherVariant;
	} = $props();
	const token = $props.id();
	const chart = useEChartsComposedChart();
	const slots = setEChartsComposedLineSlots();
	$effect(() =>
		chart.lines.register(token, () => ({
			dataKey,
			strokeVariant,
			curveType,
			animationType,
			connectNulls,
			glow,
			isClickable,
			lineProps,
			dotVariant: slots.dots.first?.variant ?? 'none',
			activeDotVariant: slots.activeDots.first?.variant ?? 'none',
			ditherVariant
		}))
	);
</script>

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

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

```ts
import type {
	DataZoomComponentOption,
	GridComponentOption,
	TooltipComponentOption
} from 'echarts/components';
import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts';
import type { ComposeOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import {
	flattenColor,
	getColorsCount,
	seriesPaint,
	withAlpha,
	type ChartConfig,
	type ResolvedColors
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import { dotItemStyle, dotStyle, sampleGradient } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
import {
	tooltipBaseOption,
	tooltipIndicatorHtml,
	tooltipRow,
	tooltipShell
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
import { buildBrushDataZoom, type BrushRange } from '$lib/components/evilcharts/ui/echarts-brush/index.js';
import {
	createDitherPattern,
	type DitherBloom,
	type DitherVariant,
	type RenderStyle
} from '$lib/components/evilcharts/ui/echarts-dither/index.js';
import type {
	AxisRegistration,
	BarRegistration,
	BrushRegistration,
	CurveType,
	LegendRegistration,
	LineRegistration,
	TooltipRegistration
} from './types.js';

export type EChartsComposedOption = ComposeOption<
	| LineSeriesOption
	| BarSeriesOption
	| GridComponentOption
	| TooltipComponentOption
	| DataZoomComponentOption
>;

type ArrayItem<T> = T extends readonly (infer Item)[] ? Item : T;
type XAxisOption = ArrayItem<NonNullable<EChartsComposedOption['xAxis']>>;
type YAxisOption = ArrayItem<NonNullable<EChartsComposedOption['yAxis']>>;

export type ComposedOptionContext = {
	data: Record<string, unknown>[];
	config: ChartConfig;
	lines: LineRegistration[];
	bars: BarRegistration[];
	xDataKey?: string;
	curveType: CurveType;
	selectedDataKey: string | null;
	barGap?: number | string;
	barCategoryGap?: number | string;
	xAxis?: AxisRegistration;
	yAxis?: AxisRegistration;
	showGrid: boolean;
	tooltip?: TooltipRegistration;
	legend?: LegendRegistration;
	brush?: BrushRegistration;
	brushRange: BrushRange;
	isLoading: boolean;
	loadingData: number[];
	loadingLineData: number[];
	resolved: ResolvedColors;
	animation: boolean;
	animationType: string;
	reducedMotion: boolean;
	rendererSize: { width: number; height: number };
	renderStyle: RenderStyle;
	ditherVariant: DitherVariant;
	ditherCellSize: number;
	bloom?: DitherBloom;
};

const ditherBloomBlur = (style: RenderStyle, bloom?: DitherBloom) =>
	style !== 'dither' || bloom === 'off' || bloom === undefined
		? 0
		: bloom === 'aura'
			? 14
			: bloom === 'high'
				? 8
				: 4;

function labelFor(config: ChartConfig, key: string): string {
	const label = config[key]?.label;
	return typeof label === 'string' ? label : key;
}

function curveConfig(curveType: CurveType): { smooth: boolean; step: 'middle' | false } {
	if (curveType === 'step') return { smooth: false, step: 'middle' };
	if (curveType === 'linear') return { smooth: false, step: false };
	return { smooth: true, step: false };
}

function opacityFor(selected: string | null, key: string): number {
	return selected === null || selected === key ? 1 : 0.3;
}

function ditherPlotBounds(context: ComposedOptionContext) {
	const top = context.legend?.verticalAlign === 'top' ? 42 : 16;
	const showBrush = Boolean(context.brush) && !context.isLoading;
	const bottom =
		8 +
		(showBrush ? (context.brush?.height ?? 56) + 30 + (context.xAxis?.label ? 22 : 0) : 0) +
		(context.legend?.verticalAlign === 'bottom' ? 34 : 0);
	return {
		height: Math.max(context.ditherCellSize, context.rendererSize.height - top - bottom),
		offsetY: top
	};
}

function categoryKey(context: ComposedOptionContext): string | undefined {
	if (context.xDataKey) return context.xDataKey;
	if (context.xAxis?.dataKey) return context.xAxis.dataKey;
	const series = new Set([
		...context.lines.map((line) => line.dataKey),
		...context.bars.map((bar) => bar.dataKey)
	]);
	return Object.keys(context.data[0] ?? {}).find((key) => !series.has(key));
}

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

function finiteNumber(value: unknown): number | null {
	return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

function seriesValues(context: ComposedOptionContext, key: string): (number | null)[] {
	return context.data.map((row) => finiteNumber(row[key]));
}

function buildAxes(context: ComposedOptionContext): { xAxis: XAxisOption; yAxis: YAxisOption } {
	const { mutedForeground, border, background } = context.resolved.tokens;
	const splitLineColor = withAlpha(border, 1);
	const dotColor = flattenColor(splitLineColor, background);
	const xAxis: XAxisOption = {
		type: 'category',
		boundaryGap: context.bars.length > 0 || context.isLoading,
		show: true,
		data: context.isLoading ? context.loadingData.map((_, index) => index) : categories(context),
		name: context.isLoading ? undefined : context.xAxis?.label,
		nameLocation: 'middle',
		nameGap: 30,
		nameTextStyle: { color: mutedForeground, fontSize: 10 },
		axisLine: { show: false },
		axisTick: {
			show: !context.isLoading && Boolean(context.xAxis) && !context.xAxis?.hideDots,
			alignWithLabel: true,
			length: 0.5,
			lineStyle: { color: dotColor, width: 3, cap: 'round' }
		},
		splitLine: { show: false },
		axisLabel: {
			show: !context.isLoading && Boolean(context.xAxis),
			color: mutedForeground,
			fontSize: 10,
			margin: 8,
			formatter: context.xAxis?.tickFormatter
		}
	};
	const yAxis: YAxisOption = {
		type: 'value',
		show: Boolean(context.yAxis) || context.showGrid,
		min: context.bars.length > 0 ? 0 : undefined,
		name: context.isLoading ? undefined : context.yAxis?.label,
		nameLocation: 'middle',
		nameGap: 38,
		nameTextStyle: { color: mutedForeground, fontSize: 10 },
		axisLine: { show: false },
		axisTick: {
			show: !context.isLoading && Boolean(context.yAxis) && !context.yAxis?.hideDots,
			length: 0.5,
			lineStyle: { color: dotColor, width: 3, cap: 'round' }
		},
		axisLabel: {
			show: !context.isLoading && Boolean(context.yAxis),
			color: mutedForeground,
			fontSize: 10,
			margin: 8,
			formatter: context.yAxis?.tickFormatter
		},
		splitLine: {
			show: context.showGrid && !context.isLoading,
			lineStyle: { color: splitLineColor, type: [3, 3], width: 1 }
		}
	};
	return { xAxis, yAxis };
}

function tooltip(context: ComposedOptionContext): TooltipComponentOption {
	const slot = context.tooltip;
	const base = tooltipBaseOption({
		present: Boolean(slot) && !context.isLoading,
		cursor: slot?.cursor ?? true,
		position: slot?.position ?? 'variable',
		axisPointerColor: context.resolved.tokens.border,
		strokeWidth: 0.8
	});
	return {
		...base,
		formatter: (rawParams) => {
			const params = (Array.isArray(rawParams) ? rawParams : [rawParams]) as Array<{
				seriesId?: string;
				seriesName?: string;
				axisValueLabel?: string;
				value?: unknown;
				data?: unknown;
			}>;
			const rows = params
				.filter((item) => item.seriesId && !item.seriesId.startsWith('__'))
				.map((item) => {
					const key = item.seriesId as string;
					const value =
						typeof item.data === 'object' && item.data && 'value' in item.data
							? (item.data as { value: unknown }).value
							: item.value;
					const numericValue = finiteNumber(value);
					if (numericValue === null) return '';
					return tooltipRow({
						indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(context.config[key] ?? {})),
						labelText: labelFor(context.config, key),
						valueText: numericValue.toLocaleString(),
						dimmed: opacityFor(context.selectedDataKey, key) < 1 ? ' opacity-30' : ''
					});
				})
				.join('');
			return tooltipShell({
				label: params[0]?.axisValueLabel ?? '',
				body: rows,
				roundness: slot?.roundness ?? 'lg',
				variant: slot?.variant ?? 'default'
			});
		}
	};
}

function lineData(
	values: (number | null)[],
	line: LineRegistration,
	slots: string[],
	background: string,
	active = false
): LineSeriesOption['data'] {
	const variant = active ? line.activeDotVariant : line.dotVariant;
	return values.map((value, index) => {
		if (value === null) return null;
		const paint = sampleGradient(slots, values.length > 1 ? index / (values.length - 1) : 0);
		return {
			value,
			itemStyle: dotItemStyle(variant, paint, background),
			emphasis: { itemStyle: dotItemStyle(line.activeDotVariant, paint, background) }
		};
	});
}

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

type ImagePattern = {
	image: HTMLCanvasElement;
	repeat: 'repeat';
	rotation: number;
	scaleX: number;
	scaleY: number;
};

function barHatchPattern(color: string): ImagePattern | null {
	if (typeof document === 'undefined') return null;
	const dpr = Math.max(window.devicePixelRatio || 1, 1);
	const canvas = document.createElement('canvas');
	const drawing = canvas.getContext('2d');
	if (!drawing) return null;
	canvas.width = 5 * dpr;
	canvas.height = 5 * dpr;
	drawing.scale(dpr, dpr);
	drawing.fillStyle = withAlpha(color, 0.3);
	drawing.fillRect(0, 0, 5, 5);
	drawing.fillStyle = color;
	drawing.fillRect(0, 0, 1.5, 5);
	return {
		image: canvas,
		repeat: 'repeat',
		rotation: -Math.PI / 4,
		scaleX: 1 / dpr,
		scaleY: 1 / dpr
	};
}

function composedBarPaint(context: ComposedOptionContext, bar: BarRegistration, slots: string[]) {
	const color = slots[0] ?? context.resolved.tokens.foreground;
	if (context.renderStyle === 'dither')
		return createDitherPattern(
			slots,
			bar.ditherVariant ?? context.ditherVariant,
			context.ditherCellSize,
			1,
			ditherPlotBounds(context)
		);
	if (bar.variant === 'hatched') return barHatchPattern(color) ?? color;
	if (bar.variant === 'gradient')
		return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
			{ offset: 0, color: sampleGradient(slots, 0) },
			{ offset: 0.2, color: sampleGradient(slots, 0.2) },
			{ offset: 0.9, color: withAlpha(sampleGradient(slots, 0.9), 0) },
			{ offset: 1, color: withAlpha(sampleGradient(slots, 1), 0) }
		]);
	if (bar.variant === 'duotone' || bar.variant === 'duotone-reverse') {
		const left = withAlpha(color, bar.variant === 'duotone-reverse' ? 1 : 0.4);
		const right = withAlpha(color, bar.variant === 'duotone-reverse' ? 0.4 : 1);
		return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
			{ offset: 0, color: left },
			{ offset: 0.5, color: left },
			{ offset: 0.5, color: right },
			{ offset: 1, color: right }
		]);
	}
	if (bar.variant === 'stripped')
		return new echarts.graphic.LinearGradient(0, 0, 0, 1, [
			{ offset: 0, color },
			{ offset: 0.05, color: withAlpha(color, 0.4) },
			{ offset: 1, color: withAlpha(color, 0.1) }
		]);
	return verticalBarPaint(slots);
}

function staggerDelay(type: string, index: number, count: number) {
	const last = Math.max(0, count - 1);
	const center = last / 2;
	const step =
		type === 'right-to-left'
			? last - index
			: type === 'center-out'
				? Math.abs(index - center)
				: type === 'edges-in'
					? center - Math.abs(index - center)
					: index;
	return step * 50;
}

function buildSeries(context: ComposedOptionContext): (LineSeriesOption | BarSeriesOption)[] {
	if (context.isLoading) {
		return [
			{
				id: '__loading',
				type: 'bar',
				data: context.loadingData,
				silent: true,
				barCategoryGap: '30%',
				itemStyle: {
					color: withAlpha(context.resolved.tokens.foreground, 0),
					borderRadius: [4, 4, 0, 0]
				},
				animation: false
			},
			{
				id: '__loading-line',
				type: 'line',
				data: context.loadingLineData,
				smooth: true,
				showSymbol: false,
				silent: true,
				lineStyle: { color: withAlpha(context.resolved.tokens.foreground, 0), width: 2 },
				animation: false,
				tooltip: { show: false }
			}
		];
	}

	const bars: BarSeriesOption[] = context.bars.map((bar) => {
		const slots = context.resolved.series[bar.dataKey] ?? [context.resolved.tokens.foreground];
		const color = slots[0] ?? context.resolved.tokens.foreground;
		const bloomBlur = ditherBloomBlur(context.renderStyle, context.bloom);
		const bloomColor = bloomBlur > 0 ? withAlpha(color, 0.55) : undefined;
		const values = seriesValues(context, bar.dataKey);
		const opacity =
			context.selectedDataKey === null || context.selectedDataKey === bar.dataKey ? 1 : 0.15;
		const variantColor = composedBarPaint(context, bar, slots);
		const base: BarSeriesOption = {
			id: bar.dataKey,
			name: labelFor(context.config, bar.dataKey),
			type: 'bar',
			data:
				bar.glow && slots.length > 1
					? values.map((value, index) => ({
							value,
							itemStyle: {
								shadowBlur: 16,
								shadowColor: withAlpha(
									sampleGradient(slots, values.length > 1 ? index / (values.length - 1) : 0),
									0.6
								)
							}
						}))
					: values,
			barGap: context.barGap,
			barCategoryGap: context.barCategoryGap,
			cursor: bar.isClickable ? 'pointer' : 'default',
			z: 2,
			itemStyle: {
				color: variantColor,
				opacity,
				borderRadius: bar.variant === 'stripped' ? 0 : bar.radius,
				shadowBlur: bar.glow && slots.length === 1 ? 16 : bloomBlur,
				shadowColor: bar.glow && slots.length === 1 ? withAlpha(color, 0.6) : bloomColor
			},
			emphasis: {
				focus: bar.enableHoverHighlight && context.selectedDataKey === null ? 'self' : 'none',
				blurScope: 'series'
			},
			blur:
				bar.enableHoverHighlight && context.selectedDataKey === null
					? { itemStyle: { opacity: 0.15 } }
					: undefined,
			animation:
				context.animation &&
				(bar.animationType ?? context.animationType) !== 'none' &&
				!context.reducedMotion,
			animationDuration: 500,
			animationEasing: 'cubicOut',
			animationDelay: (i) =>
				staggerDelay(bar.animationType ?? context.animationType, i, context.data.length)
		};
		return bar.barProps ? { ...base, ...bar.barProps } : base;
	});
	const lines = context.lines.flatMap((line, lineIndex) => {
		const values = seriesValues(context, line.dataKey);
		const slots = context.resolved.series[line.dataKey] ?? ['rgba(120, 120, 120, 1)'];
		const bloomBlur = ditherBloomBlur(context.renderStyle, context.bloom);
		const bloomColor =
			bloomBlur > 0 ? withAlpha(slots[0] ?? context.resolved.tokens.foreground, 0.55) : undefined;
		const ditherStroke =
			context.renderStyle === 'dither' && line.strokeVariant !== 'animated-dashed';
		const paint = ditherStroke
			? createDitherPattern(
					slots,
					line.ditherVariant ?? context.ditherVariant,
					context.ditherCellSize,
					1,
					ditherPlotBounds(context)
				)
			: seriesPaint(slots);
		const dotPaint = seriesPaint(slots);
		const curve = curveConfig(line.curveType ?? context.curveType);
		const opacity = opacityFor(context.selectedDataKey, line.dataKey);
		const dot = dotStyle(line.dotVariant, dotPaint, context.resolved.tokens.background);
		const activeDot = dotStyle(line.activeDotVariant, dotPaint, context.resolved.tokens.background);
		const series: LineSeriesOption[] = [];
		if (line.glow) {
			for (const [glowIndex, layer] of [
				{ opacity: 0.22, blur: 42 },
				{ opacity: 0.38, blur: 24 },
				{ opacity: 0.6, blur: 12 },
				{ opacity: 0.9, blur: 5 }
			].entries()) {
				series.push({
					id: `__glow-${line.dataKey}-${glowIndex}`,
					type: 'line',
					data: values,
					smooth: curve.smooth,
					step: curve.step,
					connectNulls: line.connectNulls,
					showSymbol: false,
					silent: true,
					z: 2,
					lineStyle: {
						color: paint,
						width: 2,
						opacity: layer.opacity * opacity,
						shadowBlur: layer.blur,
						shadowColor: sampleGradient(slots, 0.5),
						cap: 'round',
						join: 'round'
					},
					animation: false,
					tooltip: { show: false },
					emphasis: { disabled: true }
				});
			}
		}
		const base: LineSeriesOption = {
			id: line.dataKey,
			name: labelFor(context.config, line.dataKey),
			type: 'line',
			data: lineData(values, line, slots, context.resolved.tokens.background),
			smooth: curve.smooth,
			step: curve.step,
			connectNulls: line.connectNulls,
			showSymbol: dot.size > 0,
			symbol: 'circle',
			symbolSize: dot.size > 0 ? dot.size : activeDot.size,
			cursor: line.isClickable ? 'pointer' : 'default',
			triggerEvent: line.isClickable,
			silent: false,
			z: 3 + lineIndex,
			lineStyle: {
				color: paint,
				width: ditherStroke ? Math.max(2, context.ditherCellSize) : 2,
				type: ditherStroke
					? [context.ditherCellSize, context.ditherCellSize]
					: line.strokeVariant === 'solid'
						? 'solid'
						: [5, 5],
				opacity,
				cap: 'round',
				join: 'round',
				shadowBlur: bloomBlur,
				shadowColor: bloomColor
			},
			itemStyle: { ...dot.itemStyle, opacity },
			emphasis: {
				focus: 'none',
				scale: activeDot.size > dot.size ? activeDot.size / Math.max(1, dot.size) : false,
				lineStyle: { opacity },
				itemStyle: { ...activeDot.itemStyle, opacity }
			},
			blur: {
				lineStyle: { opacity: 0.3 },
				itemStyle: { opacity: 0.3 }
			},
			animation: context.animation && context.animationType !== 'none' && !context.reducedMotion,
			animationDuration: 1000,
			animationDurationUpdate: 0
		};
		series.push(line.lineProps ? { ...base, ...line.lineProps } : base);
		return series;
	});
	return [...bars, ...lines];
}

export function buildComposedOption(context: ComposedOptionContext): EChartsComposedOption {
	const legendTop = context.legend?.verticalAlign === 'top';
	const legendBottom = context.legend?.verticalAlign === 'bottom';
	const brushHeight = context.brush?.height ?? 56;
	const showBrush = Boolean(context.brush) && !context.isLoading;
	const brushGap = showBrush ? brushHeight + 30 + (context.xAxis?.label ? 22 : 0) : 0;
	const mainGrid: GridComponentOption = {
		left: 8,
		right: 8,
		top: legendTop ? 42 : 16,
		bottom: 8 + brushGap + (legendBottom ? 34 : 0)
	};
	const { xAxis, yAxis } = buildAxes(context);
	const series = buildSeries(context);

	if (!showBrush) {
		return {
			animation: context.animation && !context.reducedMotion,
			animationDurationUpdate: 0,
			aria: { enabled: true },
			grid: mainGrid,
			xAxis,
			yAxis,
			tooltip: tooltip(context),
			series
		};
	}

	const brushBottom = legendBottom ? 34 : 6;
	const miniGrid: GridComponentOption = {
		left: 8,
		right: 8,
		bottom: brushBottom,
		height: brushHeight
	};
	const miniInputs = [
		...context.bars.map((bar) => ({
			dataKey: bar.dataKey,
			curveType: undefined,
			connectNulls: false
		})),
		...context.lines.map((line) => ({
			dataKey: line.dataKey,
			curveType: line.curveType,
			connectNulls: line.connectNulls
		}))
	];
	const miniLines = miniInputs.map((line) => {
		const curve = curveConfig(line.curveType ?? context.curveType);
		const opacity = opacityFor(context.selectedDataKey, line.dataKey);
		return {
			id: `__mini-${line.dataKey}`,
			type: 'line' as const,
			xAxisIndex: 1,
			yAxisIndex: 1,
			data: seriesValues(context, line.dataKey),
			smooth: curve.smooth,
			step: curve.step,
			connectNulls: line.connectNulls,
			showSymbol: false,
			silent: true,
			lineStyle: {
				color: seriesPaint(context.resolved.series[line.dataKey] ?? []),
				width: 1,
				opacity: 0.5 * opacity
			},
			areaStyle: {
				color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
					{
						offset: 0,
						color: withAlpha(
							context.resolved.series[line.dataKey]?.[0] ?? context.resolved.tokens.foreground,
							0.15 *
								(context.selectedDataKey === null || context.selectedDataKey === line.dataKey
									? 1
									: 0.15)
						)
					},
					{
						offset: 1,
						color: withAlpha(
							context.resolved.series[line.dataKey]?.[0] ?? context.resolved.tokens.foreground,
							0
						)
					}
				])
			},
			emphasis: { disabled: true },
			tooltip: { show: false },
			z: 0,
			animation: false
		};
	});
	return {
		animation: context.animation && !context.reducedMotion,
		animationDurationUpdate: 0,
		aria: { enabled: true },
		grid: [mainGrid, { ...miniGrid, outerBoundsMode: 'none' }],
		xAxis: [
			xAxis,
			{ type: 'category', gridIndex: 1, boundaryGap: false, data: categories(context), show: false }
		],
		yAxis: [yAxis, { type: 'value', gridIndex: 1, show: false }],
		tooltip: tooltip(context),
		dataZoom: buildBrushDataZoom({
			brushBottom,
			brushHeight,
			brushRange: context.brushRange,
			fillerColor: 'transparent'
		}),
		series: [...series, ...miniLines]
	};
}

export function createComposedLoadingData(points: number): number[] {
	const values: number[] = [];
	let value = 30 + Math.random() * 20;
	for (let index = 0; index < points; index += 1) {
		value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16));
		values.push(Math.round(value));
	}
	return values;
}
```

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

```ts
import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts';
import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js';
import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
export type BarVariant =
	'default' | 'hatched' | 'duotone' | 'duotone-reverse' | 'gradient' | 'stripped';
export type StrokeVariant = 'solid' | 'dashed' | 'animated-dashed';
export type ComposedAnimationType =
	'none' | 'left-to-right' | 'right-to-left' | 'center-out' | 'edges-in';
export type CurveType =
	'linear' | 'smooth' | 'bump' | 'monotone' | 'monotoneX' | 'monotoneY' | 'natural' | 'step';
export type BarRegistration = {
	dataKey: string;
	variant: BarVariant;
	radius: number;
	glow: boolean;
	animationType?: ComposedAnimationType;
	isClickable: boolean;
	enableHoverHighlight: boolean;
	barProps?: Partial<BarSeriesOption>;
	ditherVariant?: DitherVariant;
};
export type LineRegistration = {
	dataKey: string;
	strokeVariant: StrokeVariant;
	curveType?: CurveType;
	animationType?: ComposedAnimationType;
	connectNulls: boolean;
	glow: boolean;
	isClickable: boolean;
	dotVariant: DotVariant;
	activeDotVariant: DotVariant;
	lineProps?: Partial<LineSeriesOption>;
	ditherVariant?: DitherVariant;
};
export type AxisRegistration = {
	dataKey?: string;
	tickFormatter?: (value: string | number, index: number) => string;
	label?: string;
	hideDots: boolean;
};
export type TooltipRegistration = {
	variant: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipVariant;
	roundness: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipRoundness;
	cursor?: boolean;
	defaultIndex?: number;
	position: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipPosition;
};
export type LegendRegistration = {
	variant: import('$lib/components/evilcharts/ui/echarts-legend/index.js').LegendVariant;
	align: 'left' | 'center' | 'right';
	verticalAlign: 'top' | 'middle' | 'bottom';
	isClickable: boolean;
};
export type BrushRegistration = {
	height?: number;
	formatLabel?: (value: string, index: number) => string;
	onChange?: (range: { startIndex: number; endIndex: number }) => void;
};
```

`$lib/components/evilcharts/charts/echarts-composed-chart/x-axis.svelte`

```svelte
<script lang="ts">
	import { useEChartsComposedChart } from './composed-chart-context.svelte.js';
	let {
		dataKey,
		tickFormatter,
		label,
		hideDots = false
	}: {
		dataKey?: string;
		tickFormatter?: (value: string | number, index: number) => string;
		label?: string;
		hideDots?: boolean;
	} = $props();
	const token = $props.id();
	const chart = useEChartsComposedChart();
	$effect(() => chart.xAxes.register(token, () => ({ dataKey, tickFormatter, label, hideDots })));
</script>
```

`$lib/components/evilcharts/charts/echarts-composed-chart/y-axis.svelte`

```svelte
<script lang="ts">
	import { useEChartsComposedChart } from './composed-chart-context.svelte.js';
	let {
		dataKey,
		tickFormatter,
		label,
		hideDots = false
	}: {
		dataKey?: string;
		tickFormatter?: (value: string | number, index: number) => string;
		label?: string;
		hideDots?: boolean;
	} = $props();
	const token = $props.id();
	const chart = useEChartsComposedChart();
	$effect(() => chart.yAxes.register(token, () => ({ dataKey, tickFormatter, label, hideDots })));
</script>
```
        
      
      
        ### 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>
```
        
        

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

Finally, create `echarts-brush` in the same `ui` folder and paste the zoom brush there.


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

`$lib/components/evilcharts/ui/echarts-brush/brush-controls.svelte`

```svelte
<script lang="ts">
	type BrushRange = { startIndex: number; endIndex: number };
	type BrushControl = 'range' | 'start' | 'end';

	let {
		startIndex,
		endIndex,
		totalPoints,
		formatLabel = (index: number) => String(index),
		onChange
	}: {
		startIndex: number;
		endIndex: number;
		totalPoints: number;
		formatLabel?: (index: number) => string;
		onChange: (range: BrushRange) => void;
	} = $props();

	const maximum = $derived(Math.max(0, totalPoints - 1));
	const minimumSpan = $derived(totalPoints > 1 ? 1 : 0);
	const windowSize = $derived(Math.max(0, endIndex - startIndex));
	const maximumWindowStart = $derived(Math.max(0, maximum - windowSize));

	function commit(next: BrushRange) {
		const start = Math.max(0, Math.min(next.startIndex, maximum));
		const end = Math.max(start, Math.min(next.endIndex, maximum));
		if (start === startIndex && end === endIndex) return;
		onChange({ startIndex: start, endIndex: end });
	}

	function handleKey(event: KeyboardEvent, control: BrushControl) {
		if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
		event.preventDefault();

		if (control === 'range') {
			const delta =
				event.key === 'ArrowLeft'
					? -1
					: event.key === 'ArrowRight'
						? 1
						: event.key === 'Home'
							? -startIndex
							: maximum - endIndex;
			const nextStart = Math.max(0, Math.min(startIndex + delta, maximumWindowStart));
			commit({ startIndex: nextStart, endIndex: nextStart + windowSize });
			return;
		}

		if (control === 'start') {
			const nextStart =
				event.key === 'Home'
					? 0
					: event.key === 'End'
						? endIndex - minimumSpan
						: startIndex + (event.key === 'ArrowLeft' ? -1 : 1);
			commit({ startIndex: Math.min(nextStart, endIndex - minimumSpan), endIndex });
			return;
		}

		const nextEnd =
			event.key === 'Home'
				? startIndex + minimumSpan
				: event.key === 'End'
					? maximum
					: endIndex + (event.key === 'ArrowLeft' ? -1 : 1);
		commit({ startIndex, endIndex: Math.max(nextEnd, startIndex + minimumSpan) });
	}
</script>

{#if totalPoints > 0}
	<div
		class="pointer-events-none absolute inset-0 z-50"
		role="group"
		aria-label="Chart range controls"
	>
		<div
			role="slider"
			tabindex="0"
			class="sr-only focus:pointer-events-auto focus:not-sr-only focus:absolute focus:bottom-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"
			aria-label="Selected chart range"
			aria-orientation="horizontal"
			aria-valuemin="0"
			aria-valuemax={maximumWindowStart}
			aria-valuenow={startIndex}
			aria-valuetext={`${formatLabel(startIndex)} to ${formatLabel(endIndex)}`}
			onkeydown={(event) => handleKey(event, 'range')}
		>
			Selected range: {formatLabel(startIndex)} to {formatLabel(endIndex)}
		</div>
		<div
			role="slider"
			tabindex="0"
			class="sr-only focus:pointer-events-auto focus:not-sr-only focus:absolute focus:bottom-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"
			aria-label="Range start"
			aria-orientation="horizontal"
			aria-valuemin="0"
			aria-valuemax={Math.max(0, endIndex - minimumSpan)}
			aria-valuenow={startIndex}
			aria-valuetext={formatLabel(startIndex)}
			onkeydown={(event) => handleKey(event, 'start')}
		>
			Range start: {formatLabel(startIndex)}
		</div>
		<div
			role="slider"
			tabindex="0"
			class="sr-only focus:pointer-events-auto focus:not-sr-only focus:absolute focus:bottom-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"
			aria-label="Range end"
			aria-orientation="horizontal"
			aria-valuemin={Math.min(maximum, startIndex + minimumSpan)}
			aria-valuemax={maximum}
			aria-valuenow={endIndex}
			aria-valuetext={formatLabel(endIndex)}
			onkeydown={(event) => handleKey(event, 'end')}
		>
			Range end: {formatLabel(endIndex)}
		</div>
	</div>
{/if}
```

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

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

	export type BrushProps = {
		height?: number;
		formatLabel?: (value: string, index: number) => string;
		onChange?: (range: { startIndex: number; endIndex: number }) => void;
	};

	let { height, formatLabel, onChange }: BrushProps = $props();
	const token = $props.id();
	const slots = getEChartsSharedSlotContext();
	$effect(() => slots.register('brush', token, () => ({ height, formatLabel, onChange })));
</script>
```

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

```ts
import type { DataZoomComponentOption } from 'echarts/components';
import type { EChartsType } from 'echarts/core';
import * as echarts from 'echarts/core';
import { withAlpha, type ResolvedColors } from '../echarts-chart/index.js';

export const BRUSH_BORDER_OPACITY = 1;

export type BrushRange = { start: number; end: number };
export type BrushGeometry = { bottom: number; height: number };

export type BrushOverlayParams = {
	range: BrushRange;
	geom: BrushGeometry;
	size: { width: number; height: number };
	tokens: ResolvedColors['tokens'];
	labels: { start: string; end: string } | null;
	showLabels: boolean;
	hover: { left: boolean; right: boolean };
};

type ZrRect = InstanceType<typeof echarts.graphic.Rect>;
type ZrCircle = InstanceType<typeof echarts.graphic.Circle>;
type ZrText = InstanceType<typeof echarts.graphic.Text>;

export type BrushOverlayElements = {
	dimLeft: ZrRect;
	dimRight: ZrRect;
	frame: ZrRect;
	pillLeft: ZrRect;
	pillRight: ZrRect;
	grips: ZrCircle[];
	labelStart: ZrText;
	labelEnd: ZrText;
};

export function syncBrushOverlay(
	chart: EChartsType,
	store: { brushOverlay: BrushOverlayElements | null },
	params: BrushOverlayParams | null
): void {
	const renderer = chart.getZr();
	if (!renderer) return;

	if (!params) {
		if (store.brushOverlay) {
			const { grips, ...rest } = store.brushOverlay;
			for (const element of [...Object.values(rest), ...grips]) renderer.remove(element);
			store.brushOverlay = null;
		}
		return;
	}

	if (!store.brushOverlay) {
		const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} });
		const elements: BrushOverlayElements = {
			dimLeft: rect(100),
			dimRight: rect(100),
			frame: rect(101),
			pillLeft: rect(102),
			pillRight: rect(102),
			grips: Array.from(
				{ length: 6 },
				() => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} })
			),
			labelStart: new echarts.graphic.Text({ silent: true, z: 104 }),
			labelEnd: new echarts.graphic.Text({ silent: true, z: 104 })
		};
		const { grips, ...rest } = elements;
		for (const element of [...Object.values(rest), ...grips]) renderer.add(element);
		store.brushOverlay = elements;
	}

	const elements = store.brushOverlay;
	const { range, geom, size, tokens, labels, showLabels, hover } = params;
	const trackLeft = 8;
	const trackRight = Math.max(size.width - 8, trackLeft);
	const trackWidth = trackRight - trackLeft;
	const top = size.height - geom.bottom - geom.height;
	const centerY = top + geom.height / 2;
	const selectionLeft = trackLeft + (trackWidth * range.start) / 100;
	const selectionRight = trackLeft + (trackWidth * range.end) / 100;

	const dimFill = withAlpha(tokens.background, 0.7);
	elements.dimLeft.setShape({
		x: trackLeft,
		y: top,
		width: Math.max(selectionLeft - trackLeft, 0),
		height: geom.height
	});
	elements.dimLeft.setStyle({ fill: dimFill });
	elements.dimRight.setShape({
		x: selectionRight,
		y: top,
		width: Math.max(trackRight - selectionRight, 0),
		height: geom.height
	});
	elements.dimRight.setStyle({ fill: dimFill });
	elements.frame.setShape({
		x: selectionLeft,
		y: top,
		width: Math.max(selectionRight - selectionLeft, 0),
		height: geom.height,
		r: 6
	});
	elements.frame.setStyle({
		fill: 'none',
		stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY),
		lineWidth: 1
	});

	const updatePill = (element: ZrRect, x: number, hovered: boolean) => {
		element.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 });
		element.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground });
	};
	updatePill(elements.pillLeft, selectionLeft, hover.left);
	updatePill(elements.pillRight, selectionRight, hover.right);

	const gripFill = withAlpha(tokens.background, 0.7);
	for (const [index, offset] of [-4, 0, 4].entries()) {
		elements.grips[index].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 });
		elements.grips[index].setStyle({ fill: gripFill });
		elements.grips[index + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 });
		elements.grips[index + 3].setStyle({ fill: gripFill });
	}

	const updateLabel = (element: ZrText, text: string, x: number, align: 'left' | 'right') => {
		element.setStyle({
			text,
			x: align === 'left' ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2),
			y: top + geom.height,
			align,
			verticalAlign: 'middle',
			fill: tokens.background,
			backgroundColor: tokens.foreground,
			padding: [2, 5],
			borderRadius: 4,
			font: '500 9px system-ui, sans-serif'
		});
		element.attr('invisible', !showLabels || !text);
	};
	updateLabel(elements.labelStart, labels?.start ?? '', selectionLeft, 'left');
	updateLabel(elements.labelEnd, labels?.end ?? '', selectionRight, 'right');
}

export function buildBrushDataZoom(params: {
	brushBottom: number;
	brushHeight: number;
	brushRange: BrushRange;
	fillerColor: string;
}): DataZoomComponentOption[] {
	const { brushBottom, brushHeight, brushRange, fillerColor } = params;
	return [
		{
			type: 'slider',
			show: true,
			xAxisIndex: [0],
			left: 8,
			right: 8,
			bottom: brushBottom,
			height: brushHeight,
			start: brushRange.start,
			end: brushRange.end,
			brushSelect: false,
			showDetail: false,
			backgroundColor: 'transparent',
			borderColor: 'transparent',
			fillerColor,
			dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } },
			selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } },
			handleIcon: 'path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z',
			handleSize: '35%',
			handleStyle: { opacity: 0 },
			moveHandleSize: 0,
			emphasis: { handleStyle: { opacity: 0 } }
		},
		{ type: 'inside', xAxisIndex: [0] }
	];
}
```

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

```ts
export { default as Brush } from './brush.svelte';
export { default as BrushControls } from './brush-controls.svelte';
export type { BrushProps } from './brush.svelte';
export {
	BRUSH_BORDER_OPACITY,
	buildBrushDataZoom,
	syncBrushOverlay,
	type BrushGeometry,
	type BrushOverlayElements,
	type BrushOverlayParams,
	type BrushRange
} from './brush.js';
```
        
      
    
  


## Usage

The ECharts composed chart is composable, sharing the LayerChart sibling's API shape. `<EChartsComposedChart>` is the container, and every part hangs off it as a compound member — `<EChartsComposedChart.Grid>`, `<EChartsComposedChart.XAxis>`, `<EChartsComposedChart.YAxis>`, `<EChartsComposedChart.Legend>`, `<EChartsComposedChart.Tooltip>`, and one or more `<EChartsComposedChart.Bar>` and `<EChartsComposedChart.Line>` — so a single import gives you the whole chart. Each `<Bar>` carries its own `variant`, `glow`, and `isClickable`, and each `<Line>` its own `strokeVariant`, `curveType`, `glow`, and `isClickable`, so one chart can freely mix bar and line styles.

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

```svelte
const chartConfig = {
  revenue: {
    label: "Revenue",
    colors: { light: ["#3b82f6"], dark: ["#6A5ACD"] },
  },
  profit: {
    label: "Profit",
    colors: { light: ["#10b981"], dark: ["#34d399"] },
  },
} satisfies ChartConfig;

<EChartsComposedChart xDataKey="month" data={data} config={chartConfig}>
  <EChartsComposedChart.Grid />
  <EChartsComposedChart.XAxis dataKey="month" />
  <EChartsComposedChart.YAxis />
  <EChartsComposedChart.Legend isClickable />
  <EChartsComposedChart.Tooltip />
  <EChartsComposedChart.Bar dataKey="revenue" variant="gradient" isClickable />
  <EChartsComposedChart.Line dataKey="profit" strokeVariant="dashed" isClickable>
    <EChartsComposedChart.Dot variant="default" />
    <EChartsComposedChart.ActiveDot variant="colored-border" />
  </EChartsComposedChart.Line>
</EChartsComposedChart>
```

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 a few small departures from the LayerChart sibling: multi-color bars run a vertical gradient through their color slots, the glow becomes a soft colored blur rather than the LayerChart SVG filter, and the zoom brush is a themed mini chart driven by native `dataZoom`. The textured `hatched` bar fill uses an offscreen canvas tile; with `renderer="svg"`, ECharts may embed that tile as a raster image inside the SVG.




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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart
	renderer="svg"
	class="h-full w-full p-4"
	xDataKey="month"
	{data}
	config={chartConfig}
>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Brush formatLabel={(value) => String(value).substring(0, 3)} />
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable>
		<EChartsComposedChart.ActiveDot variant="colored-border" />
		<EChartsComposedChart.Dot variant="default" />
	</EChartsComposedChart.Line>
</EChartsComposedChart>
```

### Interactive Selection

Add `isClickable` to any `<Bar>`, `<Line>`, or `<Legend>` to make its series selectable, and handle events with the `onSelectionChange` callback on `<EChartsComposedChart>`:

```svelte
<EChartsComposedChart
	{data}
	config={chartConfig}
	onSelectionChange={(selectedDataKey) => {
		if (selectedDataKey) {
			console.log('Selected:', selectedDataKey);
		} else {
			console.log('Deselected');
		}
	}}
>
	<EChartsComposedChart.XAxis dataKey="month" />
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```

### Loading State

### isLoading='true'

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart
	isLoading
	class="h-full w-full p-4"
	xDataKey="month"
	{data}
	config={chartConfig}
>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" />
	<EChartsComposedChart.Line dataKey="profit" />
</EChartsComposedChart>
```
> 
  

Pass `isLoading` to show an animated skeleton of shimmering bars and a line, both revealed by one diagonal shimmer sweep. Use `loadingBars` to set how many bars it draws.




## Examples

Examples of the composed chart with different `variants`. Customize each `<Bar>` with a `variant`, and each `<Line>` with a `strokeVariant`, `curveType`, and more.

### Gradient Colors

### gradient colors

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				// [!code highlight:2]
				light: ['#f43f5e', '#ec4899', '#a855f7', '#6366f1', '#3b82f6'],
				dark: ['#f43f5e', '#ec4899', '#a855f7', '#6366f1', '#3b82f6']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				// [!code highlight:2]
				light: ['#10b981', '#14b8a6', '#06b6d4'],
				dark: ['#10b981', '#14b8a6', '#06b6d4']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```

### Bar Variants

### <Bar variant='hatched' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Bar dataKey="revenue" variant="hatched" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```
### <Bar variant='duotone' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Bar dataKey="revenue" variant="duotone" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```
### <Bar variant='gradient' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Bar dataKey="revenue" variant="gradient" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```
### <Bar variant='stripped' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Bar dataKey="revenue" variant="stripped" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```

### Line Stroke Variants

### <Line strokeVariant='dashed' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Line dataKey="profit" strokeVariant="dashed" isClickable />
</EChartsComposedChart>
```
### <Line strokeVariant='animated-dashed' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Line dataKey="profit" strokeVariant="animated-dashed" isClickable />
</EChartsComposedChart>
```

### Curve Types

### <Line curveType='bump' />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Line dataKey="profit" curveType="bump" isClickable />
</EChartsComposedChart>
```

### Line Dots

### <Dot /> and <ActiveDot />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="revenue" isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable>
		<EChartsComposedChart.Dot variant="default" />
		<EChartsComposedChart.ActiveDot variant="border" />
	</EChartsComposedChart.Line>
</EChartsComposedChart>
```
> 
  

Inside a <code>&lt;Line&gt;</code>, compose a <code>&lt;Dot&gt;</code> for the resting marker and an <code>&lt;ActiveDot&gt;</code> for the one shown on hover. Available variants: `default`, `border`, `colored-border`.




### Hover Highlight

### <Bar enableHoverHighlight />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Bar dataKey="revenue" enableHoverHighlight isClickable />
	<EChartsComposedChart.Line dataKey="profit" isClickable />
</EChartsComposedChart>
```
> 
  

Set `enableHoverHighlight` on a <code>&lt;Bar&gt;</code> to dim its other columns when you hover one, making a single data point easier to focus on. It uses ECharts' native emphasis, so nothing re-renders mid-hover.




### Glowing Effects

### <Bar glow /> and <Line glow />

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

	const data = [
		{ month: 'January', revenue: 4200, profit: 1800 },
		{ month: 'February', revenue: 5800, profit: 2400 },
		{ month: 'March', revenue: 4100, profit: 1600 },
		{ month: 'April', revenue: 6200, profit: 2800 },
		{ month: 'May', revenue: 5400, profit: 2200 },
		{ month: 'June', revenue: 7800, profit: 3400 },
		{ month: 'July', revenue: 6100, profit: 2600 },
		{ month: 'August', revenue: 8200, profit: 3800 },
		{ month: 'September', revenue: 5900, profit: 2500 },
		{ month: 'October', revenue: 6800, profit: 3000 },
		{ month: 'November', revenue: 7200, profit: 3200 },
		{ month: 'December', revenue: 9100, profit: 4200 }
	];

	const chartConfig = {
		revenue: {
			label: 'Revenue',
			colors: {
				light: ['#3b82f6'],
				dark: ['#6A5ACD']
			}
		},
		profit: {
			label: 'Profit',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsComposedChart class="h-full w-full p-4" xDataKey="month" {data} config={chartConfig}>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Bar dataKey="revenue" glow isClickable />
	<!-- [!code highlight:3] -->
	<EChartsComposedChart.Line dataKey="profit" glow isClickable />
</EChartsComposedChart>
```
> 
  

Add the `glow` prop to a <code>&lt;Bar&gt;</code> or <code>&lt;Line&gt;</code> for a subtle glow. ECharts renders it as a soft colored blur that follows the series' own color along its length, staying faithful even on multi-stop gradients.




### 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 {
		EChartsComposedChart,
		type ChartConfig
	} from '$lib/components/evilcharts/charts/echarts-composed-chart/index.js';
	const data = [
		{ month: 'Jan', desktop: 42, mobile: 25 },
		{ month: 'Feb', desktop: 76, mobile: 54 },
		{ month: 'Mar', desktop: 51, mobile: 38 },
		{ month: 'Apr', desktop: 84, mobile: 62 },
		{ month: 'May', desktop: 63, mobile: 47 },
		{ month: 'Jun', desktop: 91, mobile: 70 }
	];
	const config = {
		desktop: { label: 'Desktop', colors: { light: ['#047857'], dark: ['#10b981'] } },
		mobile: { label: 'Mobile', colors: { light: ['#be123c'], dark: ['#f43f5e'] } }
	} satisfies ChartConfig;
</script>

<EChartsComposedChart
	{data}
	{config}
	xDataKey="month"
	renderStyle="dither"
	bloom="low"
	class="h-full w-full p-4"
>
	<EChartsComposedChart.Grid />
	<EChartsComposedChart.XAxis dataKey="month" />
	<EChartsComposedChart.Legend isClickable />
	<EChartsComposedChart.Tooltip />
	<EChartsComposedChart.Bar dataKey="desktop" ditherVariant="gradient" isClickable />
	<EChartsComposedChart.Line dataKey="mobile" ditherVariant="solid" isClickable />
</EChartsComposedChart>
```

## API Reference

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

### EChartsComposedChart

The root container. It owns the data, shared selection state, loading skeleton, and optional native `dataZoom` brush. Everything visual is composed as its children and compiled into the ECharts option.


  ### `data` (required)

type: `TData[]`

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

type: `ChartConfig`

Defines every bar and line series. Each key matches a 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 — `<Grid />`, `<XAxis />`, `<YAxis />`, `<Legend />`, `<Tooltip />`, and one or more `<Bar />` and `<Line />`.
  ### `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.
  ### `xDataKey`

type: `keyof TData & string`

The data key for the x-axis categories. Falls back to the `<XAxis dataKey="…" />` value, then to the first data column no series claims.
  ### `curveType`

type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` · default: `"linear"`

Default curve interpolation inherited by every `<Line />`; each may override it locally.
  ### `animation`

type: `boolean` · default: `true`

Master switch for the intro draw-in. Pass `false` to render the chart instantly, regardless of `animationType`.
  ### `animationType`

type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` · default: `"left-to-right"`

The intro animation inherited by every `<Bar />` and `<Line />`. Any value but `"none"` plays the draw-in: lines trace in left-to-right while bars grow from their baseline, staggered per-column in the chosen direction (`left-to-right`, `right-to-left`, `center-out`, `edges-in`). `"none"` disables it; the OS reduce-motion preference falls back to `"none"` automatically.
  ### `barGap`

type: `number | string`

The gap between bars in the same category (ECharts accepts a percentage like `"30%"` or a pixel number).
  ### `barCategoryGap`

type: `number | string`

The gap between bar categories.
  ### `defaultSelectedDataKey`

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

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

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

Fires when a series is selected or deselected — by clicking a clickable `<Bar />`, `<Line />`, or `<Legend />` entry. Receives the selected data key, or `null` when deselected.
  ### `isLoading`

type: `boolean` · default: `false`

Shows the animated loading skeleton.
  ### `loadingBars`

type: `number` · default: `12`

Number of bars in the loading skeleton.
  ### `chartOptions`

type: `Record<string, unknown>`

Escape hatch merged over the built 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.


### Bar

A single bar series. Each `<Bar />` carries its own fill variant, radius, glow, and clickability, so a chart can hold any number of bars.


  ### `dataKey` (required)

type: `string`

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

type: `"default" | "hatched" | "duotone" | "duotone-reverse" | "gradient" | "stripped"` · default: `"default"`

The bar fill style, for this bar only.
  ### `radius`

type: `number` · default: `4`

The corner radius of the bar in pixels.
  ### `animationType`

type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"`

The grow-in order for this bar (the first declared series' value drives the chart). Falls back to the chart's `animationType` when omitted.
  ### `glow`

type: `boolean` · default: `false`

Applies a soft neon glow to this bar.
  ### `isClickable`

type: `boolean` · default: `false`

Lets this bar be selected by clicking it. When any series is selected, unselected series become semi-transparent.
  ### `enableHoverHighlight`

type: `boolean` · default: `false`

Hovering a column dims this bar's other columns, easing focus on a single data point.
  ### `ditherVariant`

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

Overrides the root ordered-dither pattern for this bar only.
  ### `barProps`

type: `Partial<BarSeriesOption>`

Escape hatch merged into the raw ECharts bar series.


### Line

A single line series. Each `<Line />` carries its own stroke, curve, glow, and clickability, so a chart can hold any number of lines.


  ### `dataKey` (required)

type: `string`

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

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

The stroke style for this line.
  ### `curveType`

type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"`

The curve interpolation for this line. Falls back to the chart's `curveType` when omitted.
  ### `animationType`

type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"`

The intro reveal for this line (the first declared series' value drives the chart). Falls back to the chart's `animationType` when omitted.
  ### `connectNulls`

type: `boolean` · default: `false`

Whether to connect line segments across null or missing values.
  ### `glow`

type: `boolean` · default: `false`

A soft neon glow for this line — a colored blur that follows its color along its length.
  ### `isClickable`

type: `boolean` · default: `false`

Lets this line be selected by clicking it. When any series is selected, unselected series become semi-transparent.
  ### `children`

type: `Snippet`

Optional `<Dot />` and `<ActiveDot />` that add point markers to this line.
  ### `ditherVariant`

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

Overrides the root ordered-dither pattern for this line only.
  ### `lineProps`

type: `Partial<LineSeriesOption>`

Escape hatch merged into the raw ECharts line series.


### Dot and ActiveDot

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


  ### `variant`

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

The visual style of the point marker.


### XAxis and YAxis

The category and value axes. Include `<XAxis />` for the x-axis labels and `<YAxis />` for the y-axis; omit either to hide it. Both hide automatically while loading.


  ### `dataKey`

type: `string`

The data key for the axis values.
  ### `tickFormatter`

type: `(value: string | number, index: number) => string`

Formats the axis tick labels.
  ### `label`

type: `string`

An axis title. Rendered centered below the x-axis tick labels, or rotated beside the y-axis ones.
  ### `hideDots`

type: `boolean` · default: `false`

Hides the small tick dots that sit beside this axis's labels.


### Grid

The background grid lines. Include it to render the dashed horizontal split lines; omit it and they don't draw. Takes no props.

### Tooltip

The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads the selection state and dims unselected series.


  ### `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.
  ### `defaultIndex`

type: `number`

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

type: `boolean` · default: `true`

Whether the vertical cursor line follows the pointer on hover.
  ### `position`

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

How the tooltip is anchored. `variable` follows both axes (default); `fixed` pins the tooltip near the top and only tracks the pointer's X.


### Legend

The series legend, rendered as HTML above the chart surface. Include it to show the legend; omit it and none shows. With `isClickable`, each entry toggles selection of its series.


  ### `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: `"right"`

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

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

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

type: `boolean` · default: `false`

Lets each legend entry toggle selection of its series.


### Brush

An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `<EChartsComposedChart.Brush />` to render it; dragging the range filters the main chart.


  ### `height`

type: `number` · default: `56`

Height of the brush preview strip in pixels.
  ### `formatLabel`

type: `(value: string | number, index: number) => string`

Formats the range-handle labels below the brush.
  ### `onChange`



Fires when the brush selection range changes.

