
### Basic Chart

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart
	{data}
	config={chartConfig}
	accessibility={{
		label: 'Monthly desktop and mobile usage line chart',
		description: 'Desktop and mobile values from January through December, with a range brush.'
	}}
	class="h-full w-full p-4"
	xDataKey="month"
>
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Brush formatLabel={(value) => String(value).substring(0, 3)} />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```

## Installation


  
  
    ### npm

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

### yarn

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

### bun

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

### pnpm

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

```bash
npm install echarts
```

### yarn

```bash
yarn add echarts
```

### bun

```bash
bun add echarts
```

### pnpm

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

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


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

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

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

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

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

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

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

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

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

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

```ts
import Root from './line-chart.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 EChartsLineChart: RootComponent & {
	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, { 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 { CurveType, LineAnimationType, StrokeVariant } from './types.js';
```

`$lib/components/evilcharts/charts/echarts-line-chart/interactions.ts`

```ts
export function resolveEventSeriesKey(
	params: unknown,
	seriesKeyByIndex: readonly (string | undefined)[]
): string | null {
	if (!params || typeof params !== 'object') return null;
	const event = params as { seriesId?: unknown; seriesIndex?: unknown };
	const key =
		typeof event.seriesId === 'string'
			? event.seriesId
			: typeof event.seriesIndex === 'number'
				? seriesKeyByIndex[event.seriesIndex]
				: undefined;
	return typeof key === 'string' && !key.startsWith('__') ? key : null;
}

export function companionSeriesIds(
	line: { dataKey: string; glowing: boolean; enableBufferLine: boolean },
	enableHoverReveal: boolean,
	dataLength: number
): string[] {
	if (enableHoverReveal) return [`__reveal-base-${line.dataKey}`];
	const ids: string[] = [];
	if (line.glowing) {
		for (let index = 0; index < 4; index += 1) ids.push(`__glow-${index}-${line.dataKey}`);
	}
	if (line.enableBufferLine && dataLength >= 2) ids.push(`__buffer-${line.dataKey}`);
	return ids;
}

export function sliceToIndex<T>(values: readonly T[], index: number): (T | null)[] {
	return values.map((value, valueIndex) => (valueIndex > index ? null : value));
}

export function sliceFromIndex<T>(values: readonly T[], index: number): (T | null)[] {
	return values.map((value, valueIndex) => (valueIndex < index ? null : value));
}
```

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

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

const LINE_CHART_CONTEXT = Symbol('evilcharts.echarts-line-chart');

export class EChartsLineChartContext {
	lines = new RegistrationSet<LineRegistration>();
	xAxes = new RegistrationSet<AxisRegistration>();
	yAxes = new RegistrationSet<AxisRegistration>();
	grids = new RegistrationSet<Record<string, never>>();
}

export function setEChartsLineChartContext(): EChartsLineChartContext {
	const context = new EChartsLineChartContext();
	setContext(LINE_CHART_CONTEXT, context);
	return context;
}

export function useEChartsLineChart(): EChartsLineChartContext {
	const context = getContext<EChartsLineChartContext | undefined>(LINE_CHART_CONTEXT);
	if (!context) {
		throw new Error('[EvilCharts] ECharts line parts must be children of EChartsLineChart.');
	}
	return context;
}
```

`$lib/components/evilcharts/charts/echarts-line-chart/line-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 { 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 { setEChartsLineChartContext } from './line-chart-context.svelte.js';
	import { buildLineOption, createLineLoadingData } from './option.js';
	import {
		companionSeriesIds,
		resolveEventSeriesKey,
		sliceFromIndex,
		sliceToIndex
	} from './interactions.js';
	import type {
		BrushRegistration,
		CurveType,
		LegendRegistration,
		LineAnimationType,
		TooltipRegistration
	} from './types.js';

	echarts.use([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',
		enableHoverHighlight = false,
		enableHoverReveal = false,
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		loadingPoints = 14,
		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?: LineAnimationType;
		enableHoverHighlight?: boolean;
		enableHoverReveal?: boolean;
		defaultSelectedDataKey?: string | null;
		onSelectionChange?: (key: string | null) => void;
		isLoading?: boolean;
		loadingPoints?: number;
		chartOptions?: Record<string, unknown>;
		accessibility?: ChartAccessibility;
		children?: Snippet;
	} = $props();

	let container = $state<HTMLDivElement>();
	let 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 hoverRevealIndex: number | null = null;
	let introComplete = $state(false);
	let previousLoading = untrack(() => isLoading);
	let brushRange = $state({ start: 0, end: 100 });
	let loadingData = $state.raw<number[]>(untrack(() => createLineLoadingData(loadingPoints)));
	let brushOverlayInstance: EChartsType | undefined;
	let brushHover = { inside: false, left: false, right: false };
	const brushOverlayStore: { brushOverlay: BrushOverlayElements | null } = { brushOverlay: null };
	const revealValues: Record<string, unknown[]> = {};
	let seriesKeyByIndex: (string | undefined)[] = [];
	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 = setEChartsLineChartContext();
	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 lines = $derived(chart.lines.values);
	const selectableSeries = $derived(
		lines
			.filter((line) => line.isClickable)
			.filter(
				(line, index, all) => all.findIndex((item) => item.dataKey === line.dataKey) === index
			)
			.map((line) => ({
				key: line.dataKey,
				label:
					typeof config[line.dataKey]?.label === 'string'
						? (config[line.dataKey].label as string)
						: line.dataKey
			}))
	);
	const seriesKeys = $derived(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);
	const effectiveAnimation = $derived(lines[0]?.animationType ?? animationType);
	const categoryValues = $derived.by(() => {
		const series = new Set(lines.map((line) => line.dataKey));
		const key =
			xAxis?.dataKey ?? xDataKey ?? Object.keys(data[0] ?? {}).find((item) => !series.has(item));
		return data.map((row, index) => String((key ? row[key] : undefined) ?? index));
	});

	$effect(() => {
		loadingData = createLineLoadingData(loadingPoints);
	});

	$effect(() => {
		const loadingNow = isLoading;
		if (previousLoading && !loadingNow) introComplete = false;
		previousLoading = loadingNow;
	});

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

	const option = $derived.by(() => {
		for (const key of Object.keys(revealValues)) delete revealValues[key];
		const built = buildLineOption({
			data,
			config,
			lines,
			xDataKey,
			curveType,
			selectedDataKey,
			enableHoverHighlight,
			enableHoverReveal,
			hoverRevealIndex: null,
			xAxis,
			yAxis,
			showGrid: chart.grids.size > 0,
			tooltip,
			legend,
			brush,
			brushRange,
			isLoading,
			loadingData,
			resolved,
			animation: animation && !introComplete,
			animationType,
			reducedMotion: prefersReducedMotion.current,
			rendererSize: dimension,
			renderStyle,
			ditherVariant,
			ditherCellSize,
			bloom,
			getHoveredDataKey: () => hoveredDataKey,
			revealSink: revealValues
		});
		seriesKeyByIndex = (Array.isArray(built.series) ? built.series : [built.series])
			.filter(Boolean)
			.map((series) => {
				const id = (series as { id?: unknown }).id;
				return typeof id === 'string' && !id.startsWith('__') ? id : undefined;
			});
		return mergeLifecycleOptions(built, chartOptions) as EChartsCoreOption;
	});

	$effect(() => {
		if (introComplete || !instance || lines.length === 0 || isLoading) return;
		if (!animation || effectiveAnimation === 'none' || prefersReducedMotion.current) {
			introComplete = true;
			return;
		}
		const timer = window.setTimeout(() => (introComplete = true), 1000);
		return () => window.clearTimeout(timer);
	});

	function syncBrushOverlayNow() {
		const chartInstance = instance;
		if (!chartInstance) return;
		if (brushOverlayInstance !== chartInstance) {
			brushOverlayStore.brushOverlay = null;
			brushOverlayInstance = chartInstance;
		}
		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(() => {
		void brushRange;
		void dimension;
		void resolved;
		void categoryValues;
		syncBrushOverlayNow();
	});

	function companionsFor(key: string): string[] {
		const line = lines.find((candidate) => candidate.dataKey === key);
		return line ? companionSeriesIds(line, enableHoverReveal, data.length) : [];
	}

	function clearHoveredSeries() {
		const key = hoveredDataKey;
		if (key && instance) {
			for (const seriesId of companionsFor(key)) {
				instance.dispatchAction({ type: 'downplay', seriesId });
			}
		}
		hoveredDataKey = null;
	}

	function toggleSelection(key: string) {
		clearHoveredSeries();
		selectedDataKey = selectedDataKey === key ? null : key;
		onSelectionChange?.(selectedDataKey);
	}

	function toggleLegendSelection(key: string) {
		if (legend?.isClickable) toggleSelection(key);
	}

	const events = $derived({
		click: (params: unknown) => {
			const key = resolveEventSeriesKey(params, seriesKeyByIndex);
			if (key && lines.some((line) => line.dataKey === key && line.isClickable)) {
				toggleSelection(key);
			}
		},
		mouseover: (params: unknown) => {
			if (!enableHoverHighlight || enableHoverReveal || selectedDataKey !== null) return;
			const key = resolveEventSeriesKey(params, seriesKeyByIndex);
			if (!key || key === hoveredDataKey) return;
			clearHoveredSeries();
			hoveredDataKey = key;
			if (instance) {
				for (const seriesId of companionsFor(key)) {
					instance.dispatchAction({ type: 'highlight', seriesId });
				}
			}
		},
		mouseout: clearHoveredSeries,
		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;
		if (!chartInstance) return;
		hoverRevealIndex = null;
		hoveredDataKey = null;
		brushHover = { inside: false, left: false, right: false };
		const renderer = chartInstance.getZr();
		const pushReveal = (index: number | null) => {
			const active = index !== null;
			chartInstance.setOption(
				{
					series: seriesKeys.flatMap((key) => {
						const values = revealValues[key] ?? [];
						return [
							{
								id: key,
								data: active ? sliceToIndex(values, index) : values
							},
							{
								id: `__reveal-base-${key}`,
								data: active ? sliceFromIndex(values, index) : values,
								lineStyle: { opacity: active ? 0.3 : 0 }
							}
						];
					})
				},
				{ silent: true }
			);
			for (const key of seriesKeys) {
				chartInstance.dispatchAction(
					active
						? { type: 'highlight', seriesId: key, dataIndex: index }
						: { type: 'downplay', seriesId: key }
				);
			}
		};
		const clearReveal = () => {
			if (hoverRevealIndex === null) return;
			hoverRevealIndex = null;
			pushReveal(null);
		};
		const onMove = (event: { offsetX?: number; offsetY?: number }) => {
			const x = event.offsetX ?? -1;
			const y = event.offsetY ?? -1;
			if (enableHoverReveal) {
				if (data.length > 0 && chartInstance.containPixel({ gridIndex: 0 }, [x, y])) {
					const raw = chartInstance.convertFromPixel({ gridIndex: 0 }, [x, y])[0];
					const index = Math.max(0, Math.min(data.length - 1, Math.round(Number(raw))));
					if (index !== hoverRevealIndex) {
						hoverRevealIndex = index;
						pushReveal(index);
					}
				} else {
					clearReveal();
				}
			}

			if (!brush || isLoading) return;
			const height = brush.height ?? 56;
			const bottom = legend?.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 left = 8 + (trackWidth * brushRange.start) / 100;
			const right = 8 + (trackWidth * brushRange.end) / 100;
			const next = {
				inside,
				left: inside && Math.abs(x - left) <= 8,
				right: inside && Math.abs(x - right) <= 8
			};
			if (
				next.inside !== brushHover.inside ||
				next.left !== brushHover.left ||
				next.right !== brushHover.right
			) {
				brushHover = next;
				syncBrushOverlayNow();
			}
		};
		const onOut = () => {
			clearReveal();
			brushHover = { inside: false, left: false, right: false };
			syncBrushOverlayNow();
		};
		renderer.on('mousemove', onMove);
		renderer.on('globalout', onOut);
		return () => {
			clearReveal();
			renderer.off('mousemove', onMove);
			renderer.off('globalout', onOut);
		};
	});

	$effect(() => {
		const chartInstance = instance;
		const animatedKeys = lines
			.filter((line) => line.strokeVariant === 'animated-dashed' && !line.enableBufferLine)
			.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) => {
			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 || !isLoading) return;
		if (prefersReducedMotion.current) {
			chartInstance.setOption(
				{
					series: [
						{
							id: '__loading',
							data: loadingData,
							lineStyle: { color: withAlpha(resolved.tokens.foreground, 0.5), width: 1 }
						}
					]
				},
				{ silent: true, lazyUpdate: true }
			);
			return;
		}
		let frame = 0;
		let lastPhase = 0;
		const start = performance.now();
		const tick = (now: number) => {
			const phase = ((now - start) / 2000) % 1;
			if (phase < lastPhase) loadingData = createLineLoadingData(loadingPoints);
			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 alphaAt = (offset: number) => {
				const distance = Math.abs(offset - center);
				if (distance >= 0.2) return 0;
				return 0.5 * Math.sin(((1 - distance / 0.2) * Math.PI) / 2);
			};
			const stops = [0, center - 0.2, center, center + 0.2, 1]
				.filter((offset) => offset >= 0 && offset <= 1)
				.sort((left, right) => left - right)
				.filter((offset, index, values) => index === 0 || offset - values[index - 1] > 0.0001)
				.map((offset) => ({ offset, color: withAlpha(color, alphaAt(offset)) }));
			chartInstance.setOption(
				{
					series: [
						{
							id: '__loading',
							data: loadingData,
							lineStyle: {
								color: new echarts.graphic.LinearGradient(0, 0, width, width, stops, true),
								width: 1
							}
						}
					]
				},
				{ 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={toggleLegendSelection}
			style={legendStyle}
		/>
	{/if}
	<LoadingIndicator {isLoading} />
{/snippet}

<ChartContainer
	{config}
	{accessibility}
	{overlay}
	bind:element={container}
	bind:dimension
	bind:themeRevision
	aria-busy={isLoading}
	class={className}
>
	{@render children?.()}
	<EChartsHost {option} {renderer} {events} bind:instance />
	{#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-line-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 };

export class EChartsLineSlots {
	dots = new RegistrationSet<DotRegistration>();
	activeDots = new RegistrationSet<DotRegistration>();
}

const LINE_SLOTS_CONTEXT = Symbol('evilcharts.echarts-line-slots');

export function setEChartsLineSlots(): EChartsLineSlots {
	const context = new EChartsLineSlots();
	setContext(LINE_SLOTS_CONTEXT, context);
	return context;
}

export function useEChartsLineSlots(): EChartsLineSlots {
	const context = getContext<EChartsLineSlots | undefined>(LINE_SLOTS_CONTEXT);
	if (!context) throw new Error('[EvilCharts] ECharts Dot must be nested inside Line.');
	return context;
}
```

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

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { LineSeriesOption } from 'echarts/charts';
	import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js';
	import { useEChartsLineChart } from './line-chart-context.svelte.js';
	import { setEChartsLineSlots } from './line-slots.svelte.js';
	import {
		STROKE_WIDTH,
		type CurveType,
		type LineAnimationType,
		type StrokeVariant
	} from './types.js';

	let {
		dataKey,
		strokeVariant = 'solid',
		strokeWidth = STROKE_WIDTH,
		curveType,
		animationType,
		connectNulls = false,
		isClickable = false,
		glowing = false,
		enableBufferLine = false,
		lineProps,
		ditherVariant,
		children
	}: {
		dataKey: string;
		strokeVariant?: StrokeVariant;
		strokeWidth?: number;
		curveType?: CurveType;
		animationType?: LineAnimationType;
		connectNulls?: boolean;
		isClickable?: boolean;
		glowing?: boolean;
		enableBufferLine?: boolean;
		lineProps?: Partial<LineSeriesOption>;
		ditherVariant?: DitherVariant;
		children?: Snippet;
	} = $props();

	const token = $props.id();
	const chart = useEChartsLineChart();
	const slots = setEChartsLineSlots();

	$effect(() =>
		chart.lines.register(token, () => ({
			dataKey,
			strokeVariant,
			strokeWidth,
			curveType,
			animationType,
			connectNulls,
			isClickable,
			glowing,
			enableBufferLine,
			lineProps,
			ditherVariant,
			dotVariant: slots.dots.first?.variant ?? 'none',
			activeDotVariant: slots.activeDots.first?.variant ?? 'none'
		}))
	);
</script>

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

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

```ts
import type {
	DataZoomComponentOption,
	GridComponentOption,
	TooltipComponentOption
} from 'echarts/components';
import type { 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 {
	BUFFER_DASH,
	type AxisRegistration,
	type BrushRegistration,
	type CurveType,
	type LegendRegistration,
	type LineRegistration,
	type TooltipRegistration
} from './types.js';
import { sliceFromIndex, sliceToIndex } from './interactions.js';

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

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

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

const GLOW_LAYERS = [
	{ width: 2, opacity: 0.9, blur: 5, symbolPad: 2 },
	{ width: 2, opacity: 0.6, blur: 12, symbolPad: 6 },
	{ width: 2, opacity: 0.38, blur: 24, symbolPad: 11 },
	{ width: 2, opacity: 0.22, blur: 42, symbolPad: 16 }
] as const;

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 categoryKey(context: LineOptionContext): string | undefined {
	if (context.xAxis?.dataKey) return context.xAxis.dataKey;
	if (context.xDataKey) return context.xDataKey;
	const series = new Set(context.lines.map((line) => line.dataKey));
	return Object.keys(context.data[0] ?? {}).find((key) => !series.has(key));
}

function categories(context: LineOptionContext): 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: LineOptionContext, key: string): (number | null)[] {
	return context.data.map((row) => finiteNumber(row[key]));
}

function buildAxes(context: LineOptionContext): { 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: false,
		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,
		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: LineOptionContext): 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;
				axisValue?: string | number;
				axisValueLabel?: string;
				name?: string;
				value?: unknown;
				data?: unknown;
			}>;
			const seen = new Set<string>();
			const rows = params
				.map((item) => {
					const rawId = item.seriesId ?? '';
					const key = rawId.startsWith('__buffer-')
						? rawId.slice('__buffer-'.length)
						: rawId.startsWith('__')
							? ''
							: rawId;
					if (!key) return '';
					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 '';
					if (seen.has(key)) return '';
					seen.add(key);
					const hovered = context.getHoveredDataKey?.() ?? null;
					return tooltipRow({
						indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(context.config[key] ?? {})),
						labelText: labelFor(context.config, key),
						valueText: numericValue.toLocaleString(),
						dimmed:
							opacityFor(context.selectedDataKey, key) < 1 || (hovered !== null && hovered !== key)
								? ' opacity-30'
								: ''
					});
				})
				.join('');
			return tooltipShell({
				label: String(params[0]?.axisValue ?? params[0]?.name ?? ''),
				body: rows,
				roundness: slot?.roundness ?? 'lg',
				variant: slot?.variant ?? 'default'
			});
		}
	};
}

function lineData(
	values: (number | null)[],
	line: LineRegistration,
	slots: string[],
	background: string,
	dotOpacity = 1
): LineSeriesOption['data'] {
	if (slots.length <= 1) return values;
	const restingVariant = line.dotVariant === 'none' ? line.activeDotVariant : line.dotVariant;
	const activeVariant =
		line.activeDotVariant === 'none' ? ('default' as const) : line.activeDotVariant;
	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(restingVariant, paint, background), opacity: dotOpacity },
			emphasis: {
				itemStyle: { ...dotItemStyle(activeVariant, paint, background), opacity: 1 }
			}
		};
	});
}

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

function buildGlowSeries({
	line,
	values,
	curve,
	paint,
	slots,
	z,
	opacity,
	dotSize
}: {
	line: LineRegistration;
	values: (number | null)[];
	curve: { smooth: boolean; step: 'middle' | false };
	paint: ReturnType<typeof seriesPaint> | ReturnType<typeof createDitherPattern>;
	slots: string[];
	z: number;
	opacity: number;
	dotSize: number;
}): LineSeriesOption[] {
	const showDots = dotSize > 0;
	return GLOW_LAYERS.map((layer, index) => {
		const glowOpacity = layer.opacity * opacity;
		const data =
			slots.length <= 1 || !showDots
				? values
				: values.map((value, valueIndex) => {
						if (value === null) return null;
						return {
							value,
							itemStyle: {
								color: sampleGradient(
									slots,
									values.length > 1 ? valueIndex / (values.length - 1) : 0
								),
								opacity: glowOpacity
							}
						};
					});
		return {
			id: `__glow-${index}-${line.dataKey}`,
			type: 'line',
			data,
			smooth: curve.smooth,
			step: curve.step,
			connectNulls: line.connectNulls,
			silent: true,
			showSymbol: showDots,
			symbol: 'circle',
			symbolSize: showDots ? dotSize + layer.symbolPad : 0,
			tooltip: { show: false },
			z,
			lineStyle: {
				color: paint,
				width: layer.width,
				opacity: glowOpacity,
				shadowBlur: layer.blur,
				shadowColor: sampleGradient(slots, 0.5),
				cap: 'round',
				join: 'round'
			},
			itemStyle: { color: slots[0], opacity: glowOpacity },
			emphasis: {
				focus: 'none',
				scale: false,
				lineStyle: { opacity: glowOpacity },
				itemStyle: { opacity: glowOpacity }
			},
			blur: {
				lineStyle: { opacity: glowOpacity * 0.3 },
				itemStyle: { opacity: glowOpacity * 0.3 }
			}
		};
	});
}

function buildSeries(context: LineOptionContext): LineSeriesOption[] {
	if (context.isLoading) {
		const curve = curveConfig(context.curveType);
		return [
			{
				id: '__loading',
				type: 'line',
				data: context.loadingData,
				smooth: curve.smooth,
				step: curve.step,
				showSymbol: false,
				silent: true,
				lineStyle: { color: withAlpha(context.resolved.tokens.foreground, 0), width: 1 },
				z: 1,
				animation: false,
				tooltip: { show: false }
			}
		];
	}

	return context.lines.flatMap((line) => {
		const values = seriesValues(context, line.dataKey);
		const slots = context.resolved.series[line.dataKey] ?? ['rgba(120, 120, 120, 1)'];
		const isDither = context.renderStyle === 'dither' && line.strokeVariant !== 'animated-dashed';
		const paint = isDither
			? createDitherPattern(
					slots,
					line.ditherVariant ?? context.ditherVariant,
					context.ditherCellSize,
					1,
					{ height: context.rendererSize?.height }
				)
			: 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 restingVisible = line.dotVariant !== 'none';
		const reveal = context.enableHoverReveal;
		const hasBuffer = !reveal && line.enableBufferLine && values.length >= 2;
		const revealActive = reveal && context.hoverRevealIndex !== null;
		const bodyValues = hasBuffer
			? values.map((value, index) => (index === values.length - 1 ? null : value))
			: values;
		const fullPoints = lineData(
			values,
			line,
			slots,
			context.resolved.tokens.background,
			opacity
		) as unknown[];
		if (reveal && context.revealSink) context.revealSink[line.dataKey] = fullPoints;
		const revealedPoints = revealActive
			? sliceToIndex(fullPoints, context.hoverRevealIndex as number)
			: hasBuffer
				? lineData(bodyValues, line, slots, context.resolved.tokens.background, opacity)
				: fullPoints;
		const hasSelection = context.selectedDataKey !== null;
		const z = context.selectedDataKey === line.dataKey ? 3 : hasSelection ? 1 : 2;
		const mainDash =
			hasBuffer || line.strokeVariant === 'solid' ? 'solid' : ([3, 3] as [number, number]);
		const rendererWidth = context.rendererSize?.width ?? 0;
		const strokePaint =
			reveal && slots.length > 1 && !isDither
				? new echarts.graphic.LinearGradient(
						8,
						0,
						Math.max(rendererWidth - 8, 9),
						0,
						slots.map((color, index) => ({
							offset: index / (slots.length - 1),
							color
						})),
						true
					)
				: paint;
		const series: LineSeriesOption[] = [];
		if (line.glowing && !reveal) {
			series.push(
				...buildGlowSeries({
					line,
					values,
					curve,
					paint,
					slots,
					z,
					opacity,
					dotSize: restingVisible ? dot.size : 0
				})
			);
		}
		const main: LineSeriesOption = {
			id: line.dataKey,
			name: labelFor(context.config, line.dataKey),
			type: 'line',
			data: revealedPoints as LineSeriesOption['data'],
			smooth: curve.smooth,
			step: curve.step,
			connectNulls: line.connectNulls,
			showSymbol: restingVisible,
			symbol: 'circle',
			symbolSize: restingVisible ? dot.size : activeDot.size,
			cursor: line.isClickable ? 'pointer' : 'default',
			triggerEvent: line.isClickable,
			silent: false,
			z,
			lineStyle: {
				color: strokePaint,
				width: isDither ? Math.max(line.strokeWidth, context.ditherCellSize) : line.strokeWidth,
				type: isDither ? [context.ditherCellSize, context.ditherCellSize] : mainDash,
				opacity,
				shadowBlur: isDither ? bloomPixels(context.bloom) : undefined,
				shadowColor:
					isDither && bloomPixels(context.bloom) > 0 ? sampleGradient(slots, 0.5) : undefined,
				cap:
					isDither && (line.ditherVariant ?? context.ditherVariant) === 'hatched'
						? 'butt'
						: 'round',
				join: 'round'
			},
			itemStyle:
				slots.length > 1
					? { opacity }
					: { ...(restingVisible ? dot.itemStyle : activeDot.itemStyle), opacity },
			emphasis: {
				focus:
					context.enableHoverHighlight && !context.enableHoverReveal && !hasSelection
						? 'series'
						: 'none',
				scale: restingVisible ? activeDot.size / Math.max(dot.size, 1) : 1,
				...(slots.length > 1 ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } })
			},
			blur: {
				lineStyle: { opacity: 0.3 },
				itemStyle: { opacity: 0.3 }
			}
		};
		series.push(line.lineProps ? { ...main, ...line.lineProps } : main);

		if (reveal) {
			series.unshift({
				id: `__reveal-base-${line.dataKey}`,
				type: 'line',
				data: revealActive
					? (sliceFromIndex(
							fullPoints,
							context.hoverRevealIndex as number
						) as LineSeriesOption['data'])
					: (fullPoints as LineSeriesOption['data']),
				smooth: curve.smooth,
				step: curve.step,
				connectNulls: false,
				showSymbol: false,
				silent: true,
				z: z - 1,
				lineStyle: {
					color: context.resolved.tokens.mutedForeground,
					width: line.strokeWidth,
					type: mainDash,
					opacity: revealActive ? 0.3 : 0
				},
				emphasis: { disabled: true },
				blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } },
				tooltip: { show: false }
			});
			return series;
		}

		if (hasBuffer) {
			const bufferValues = values.map((value, index) =>
				index >= values.length - 2 ? value : null
			);
			series.push({
				id: `__buffer-${line.dataKey}`,
				name: labelFor(context.config, line.dataKey),
				type: 'line',
				data: lineData(bufferValues, line, slots, context.resolved.tokens.background, opacity),
				smooth: curve.smooth,
				step: curve.step,
				connectNulls: true,
				showSymbol: restingVisible,
				symbol: 'circle',
				symbolSize: restingVisible ? dot.size : activeDot.size,
				silent: true,
				z,
				lineStyle: { color: paint, width: line.strokeWidth, type: BUFFER_DASH, opacity },
				itemStyle:
					slots.length > 1
						? { opacity }
						: { ...(restingVisible ? dot.itemStyle : activeDot.itemStyle), opacity },
				emphasis: {
					focus: 'none',
					scale: false,
					lineStyle: { opacity },
					itemStyle: { opacity }
				},
				blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } }
			});
		}
		return series;
	});
}

export function buildLineOption(context: LineOptionContext): EChartsLineOption {
	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);
	const effectiveAnimation = context.lines[0]?.animationType ?? context.animationType;
	const animation =
		!context.isLoading &&
		context.animation &&
		effectiveAnimation !== 'none' &&
		!context.reducedMotion;
	const animationOption = {
		animation,
		animationDuration: 1000,
		animationDurationUpdate: 0
	};

	if (!showBrush) {
		return {
			...animationOption,
			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,
		outerBoundsMode: 'none'
	};
	const miniSeries = context.lines.map((line) => ({
		id: `__mini-${line.dataKey}`,
		type: 'line' as const,
		xAxisIndex: 1,
		yAxisIndex: 1,
		data: seriesValues(context, line.dataKey),
		smooth: curveConfig(line.curveType ?? context.curveType).smooth,
		step: curveConfig(line.curveType ?? context.curveType).step,
		connectNulls: line.connectNulls,
		showSymbol: false,
		silent: true,
		emphasis: { disabled: true },
		tooltip: { show: false },
		z: 0,
		lineStyle: {
			color: context.resolved.series[line.dataKey]?.[0] ?? 'rgba(120, 120, 120, 1)',
			width: 1,
			opacity: opacityFor(context.selectedDataKey, line.dataKey) * 0.5
		},
		animation: false
	}));
	return {
		...animationOption,
		aria: { enabled: true },
		grid: [mainGrid, miniGrid],
		xAxis: [
			xAxis,
			{
				type: 'category',
				gridIndex: 1,
				boundaryGap: false,
				data: categories(context),
				show: false,
				axisPointer: { show: false }
			}
		],
		yAxis: [yAxis, { type: 'value', gridIndex: 1, show: false }],
		tooltip: tooltip(context),
		dataZoom: buildBrushDataZoom({
			brushBottom,
			brushHeight,
			brushRange: context.brushRange,
			fillerColor: 'transparent'
		}),
		series: [...series, ...miniSeries]
	};
}

export function createLineLoadingData(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-line-chart/types.ts`

```ts
import type { 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';
import type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js';
import type {
	TooltipPosition,
	TooltipRoundness,
	TooltipVariant
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';

export const STROKE_WIDTH = 0.8;
export const LOADING_ANIMATION_DURATION = 2000;
export const REVEAL_DURATION = 1000;
export const BUFFER_DASH: [number, number] = [4, 3];

export type StrokeVariant = 'solid' | 'dashed' | 'animated-dashed';
export type LineAnimationType =
	'none' | 'left-to-right' | 'right-to-left' | 'center-out' | 'edges-in';
export type CurveType =
	'linear' | 'smooth' | 'bump' | 'monotone' | 'monotoneX' | 'monotoneY' | 'natural' | 'step';

export type LineRegistration = {
	dataKey: string;
	strokeVariant: StrokeVariant;
	strokeWidth: number;
	curveType?: CurveType;
	animationType?: LineAnimationType;
	connectNulls: boolean;
	isClickable: boolean;
	glowing: boolean;
	enableBufferLine: 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: TooltipVariant;
	roundness: TooltipRoundness;
	cursor?: boolean;
	defaultIndex?: number;
	position: TooltipPosition;
};

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

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

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

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

	let {
		dataKey,
		tickFormatter,
		label,
		hideDots = false
	}: {
		dataKey?: string;
		tickFormatter?: (value: string, index: number) => string;
		label?: string;
		hideDots?: boolean;
	} = $props();
	const token = $props.id();
	const chart = useEChartsLineChart();
	$effect(() =>
		chart.xAxes.register(token, () => ({
			dataKey,
			tickFormatter: tickFormatter as
				((value: string | number, index: number) => string) | undefined,
			label,
			hideDots
		}))
	);
</script>
```

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

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

	let {
		dataKey,
		tickFormatter,
		label,
		hideDots = false
	}: {
		dataKey?: string;
		tickFormatter?: (value: number, index: number) => string;
		label?: string;
		hideDots?: boolean;
	} = $props();
	const token = $props.id();
	const chart = useEChartsLineChart();
	$effect(() =>
		chart.yAxes.register(token, () => ({
			dataKey,
			tickFormatter: tickFormatter as
				((value: string | number, index: number) => string) | undefined,
			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 line chart is composable, sharing the LayerChart sibling's API shape. `<EChartsLineChart>` is the container, and every part hangs off it as a compound member — `<EChartsLineChart.Grid>`, `<EChartsLineChart.XAxis>`, `<EChartsLineChart.YAxis>`, `<EChartsLineChart.Legend>`, `<EChartsLineChart.Tooltip>`, and one or more `<EChartsLineChart.Line>` — so a single import gives you the whole chart. Each `<Line>` carries its own `strokeVariant`, `curveType`, `glowing`, `enableBufferLine`, and `isClickable`, so one chart can mix stroke styles and make only some series interactive.

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

```svelte
<EChartsLineChart {data} config={chartConfig} curveType="monotone">
	<EChartsLineChart.Grid />
	<EChartsLineChart.XAxis dataKey="month" />
	<EChartsLineChart.YAxis />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="dashed" glowing>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```

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 gradients tint each dot with the color at its x-position; the glow is layered gradient strokes stacked under the line, following the series' color in place of the LayerChart SVG blur filter; and the zoom brush is a themed mini chart driven by ECharts' native `dataZoom` rather than the custom `EvilBrush`.




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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

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

### Interactive Selection

Add `isClickable` to any `<Line>` (and to `<Legend>`) to make those series selectable, then handle events via the `onSelectionChange` callback on `<EChartsLineChart>`:

```svelte
<EChartsLineChart
	{data}
	config={chartConfig}
	onSelectionChange={(selectedDataKey) => {
		if (selectedDataKey) {
			console.log('Selected:', selectedDataKey);
		} else {
			console.log('Deselected');
		}
	}}
>
	<EChartsLineChart.XAxis dataKey="month" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable />
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable />
</EChartsLineChart>
```

### Loading State

### isLoading='true'

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

	const data: { month: string; desktop: number; mobile: number }[] = [];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight:7] -->
<EChartsLineChart
	{data}
	config={chartConfig}
	class="h-full w-full p-4"
	isLoading={true}
	curveType="bump"
>
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
> 
  

Pass `isLoading` to show an animated skeleton, `loadingPoints` to set how many points it draws, and `curveType` to match the real chart's curve.




### Buffer Line

### enableBufferLine='true'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4" xDataKey="month">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Brush />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<!-- [!code highlight:4] -->
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" enableBufferLine isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
	<!-- [!code highlight:4] -->
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" enableBufferLine isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
> 
  

With `enableBufferLine`, each line's last segment renders dashed while the rest stays solid — useful for marking projected, estimated, or incomplete data at the end of a series, as in financial charts and forecasting dashboards.




### Hover Reveal

### enableHoverReveal='true'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 245 },
		{ month: 'February', desktop: 876, mobile: 654 },
		{ month: 'March', desktop: 512, mobile: 387 },
		{ month: 'April', desktop: 629, mobile: 521 },
		{ month: 'May', desktop: 458, mobile: 412 },
		{ month: 'June', desktop: 781, mobile: 598 },
		{ month: 'July', desktop: 394, mobile: 312 },
		{ month: 'August', desktop: 925, mobile: 743 },
		{ month: 'September', desktop: 647, mobile: 489 },
		{ month: 'October', desktop: 532, mobile: 476 },
		{ month: 'November', desktop: 803, mobile: 687 },
		{ month: 'December', desktop: 271, mobile: 198 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4" enableHoverReveal>
	<EChartsLineChart.Grid />
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Legend />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid">
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid">
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
> 
  

With `enableHoverReveal`, hovering colors each line only up to the pointer's position and mutes everything past it to a neutral gray, with the active dot riding the cursor — a scrubbing effect for reading a series left-to-right. When not hovering, the chart looks completely normal.




## Examples

Examples with different settings. Change `strokeVariant` on a `<Line>` or `curveType` on the chart to restyle it.

### Gradient Colors

### gradient colors

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['red', 'orange', 'rosybrown', 'purple', 'blue'], // [!code highlight]
				dark: ['red', 'orange', 'rosybrown', 'purple', 'blue'] // [!code highlight]
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['gray'],
				dark: ['gray']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="colored-border" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="colored-border" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
### gradient colors - bump

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['red', 'orange', 'rosybrown', 'purple', 'blue'],
				dark: ['red', 'orange', 'rosybrown', 'purple', 'blue']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['gray'],
				dark: ['gray']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight:6] -->
<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4" curveType="bump">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="colored-border" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="colored-border" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```

### Curve Types

### curveType='bump'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight:6] -->
<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4" curveType="bump">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="default" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="default" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
### curveType='step'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight:6] -->
<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4" curveType="step">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="default" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="default" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
### curveType='monotoneY'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight:6] -->
<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4" curveType="monotoneY">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="default" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="default" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```

### Stroke Variants

### strokeVariant='solid'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<!-- [!code highlight:3] -->
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
### strokeVariant='dashed'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="dashed" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<!-- [!code highlight:3] -->
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="dashed" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
### strokeVariant='animated-dashed'

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.YAxis dataKey="desktop" />
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<!-- [!code highlight:3] -->
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="animated-dashed" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<!-- [!code highlight:3] -->
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="animated-dashed" isClickable>
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```

### Glowing Lines

### glowing - gradient colors

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['red', 'orange', 'rosybrown', 'purple', 'blue'],
				dark: ['red', 'orange', 'rosybrown', 'purple', 'blue']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['gray'],
				dark: ['gray']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<!-- [!code highlight:4] -->
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" glowing isClickable>
		<EChartsLineChart.Dot variant="colored-border" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="colored-border" />
		<EChartsLineChart.ActiveDot variant="default" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```
### glowing - solid colors

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

	const data = [
		{ month: 'January', desktop: 342, mobile: 184 },
		{ month: 'February', desktop: 876, mobile: 491 },
		{ month: 'March', desktop: 512, mobile: 290 },
		{ month: 'April', desktop: 629, mobile: 391 },
		{ month: 'May', desktop: 458, mobile: 309 },
		{ month: 'June', desktop: 781, mobile: 449 },
		{ month: 'July', desktop: 394, mobile: 234 },
		{ month: 'August', desktop: 925, mobile: 557 },
		{ month: 'September', desktop: 647, mobile: 367 },
		{ month: 'October', desktop: 532, mobile: 357 },
		{ month: 'November', desktop: 803, mobile: 515 },
		{ month: 'December', desktop: 271, mobile: 149 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#047857'],
				dark: ['#10b981']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#be123c'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsLineChart {data} config={chartConfig} class="h-full w-full p-4">
	<EChartsLineChart.XAxis
		dataKey="month"
		tickFormatter={(value) => String(value).substring(0, 3)}
	/>
	<EChartsLineChart.Legend isClickable />
	<EChartsLineChart.Tooltip />
	<EChartsLineChart.Line dataKey="desktop" strokeVariant="solid" isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
	<!-- [!code highlight:4] -->
	<EChartsLineChart.Line dataKey="mobile" strokeVariant="solid" glowing isClickable>
		<EChartsLineChart.Dot variant="border" />
		<EChartsLineChart.ActiveDot variant="colored-border" />
	</EChartsLineChart.Line>
</EChartsLineChart>
```

### 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 {
		EChartsLineChart,
		type ChartConfig
	} from '$lib/components/evilcharts/charts/echarts-line-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>

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

## API Reference

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

### EChartsLineChart

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 the chart's series — each key matches a data key with a `label` and 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 `<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 `<Line />` claims.
  ### `curveType`

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

Default curve interpolation inherited by every `<Line />`. Each `<Line />` 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 `<Line />`. Any value but `"none"` plays ECharts' native progressive draw-in (the line traces in and dots pop up as its front passes; direction values exist for API parity with the LayerChart sibling). `"none"` disables it; devices set to OS reduce-motion fall back to `"none"` automatically.
  ### `enableHoverHighlight`

type: `boolean` · default: `false`

Highlights the hovered line by dimming the rest — the hover twin of click selection. Dim levels match the selection styling; a glowing line's glow and a buffer line's dashed tail dim and brighten with their parent.
  ### `enableHoverReveal`

type: `boolean` · default: `false`

On hover, colors each line up to the pointer's x-position and mutes the rest to a neutral gray, with the active dot at the cursor. A standalone hover mode that takes visual precedence over `enableHoverHighlight`; idle, the chart renders normally.
  ### `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 via a clickable `<Line />` or `<Legend />`. Receives the selected data key, or `null` on deselect.
  ### `isLoading`

type: `boolean` · default: `false`

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

type: `number` · default: `14`

Number of points 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.


### Line

A single line series. Each `<Line />` is self-contained — its own stroke, glow, and clickability — so a chart can hold any number of independently styled 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.
  ### `strokeWidth`

type: `number` · default: `0.8`

Stroke thickness for this line, in pixels.
  ### `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 draw-in for this line (the first `<Line />`'s 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.
  ### `isClickable`

type: `boolean` · default: `false`

Lets this line be selected by clicking it. When any line is selected, the rest become semi-transparent.
  ### `glowing`

type: `boolean` · default: `false`

Applies a soft outer glow to this line, tinted with its series color.
  ### `enableBufferLine`

type: `boolean` · default: `false`

Renders this line's last segment as a dashed buffer while the rest stays solid — useful for projected or incomplete data at the end of a series.
  ### `ditherVariant`

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

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

type: `Snippet`

Optional `<Dot />` and `<ActiveDot />` config that adds point markers to this line.


### 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 x-axis labels and `<YAxis />` for the y-axis; omit either to hide it. Both hide automatically while the chart loads.


  ### `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 clear of the tick labels — centered below the x-axis labels, or rotated alongside 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 chart's selection state, dimming unselected series in its content.


  ### `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.
  ### `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 (the default). `"fixed"` pins the tooltip near the top of the chart 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 `<EChartsLineChart.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.

