Перейти к содержимому

Графики

Линейный график

LineChart — линейные графики со свечением, пунктиром, «буферным» последним отрезком, точками, легендой, тултипом и брашем.


Линейный график

LineChart — линейный график на Svelte 5 без сторонних библиотек: чистый SVG, цвета из ChartConfig, светлая и тёмная темы. Каждая линия может светиться, быть пунктирной или показывать последний отрезок как «буфер» — незавершённый период.

Интерактивное демо

Пропсы

Кривая
Линия
Точки
Фон
Свечение
Буферная линия
Браш
Ось Y
Загрузка
<script lang="ts" module>
	import type {
		BackgroundVariant,
		BrushOptions,
		BrushRange,
		DotVariant,
		LegendOptions,
		TooltipOptions
	} from './types';
	import type { CurveType } from './curves';
	import type { StrokeVariant } from './chart-utils';

	export type LineSeries = {
		/** Series key — must exist on the data rows and in the config. */
		dataKey: string;
		strokeVariant?: StrokeVariant;
		strokeWidth?: number;
		curveType?: CurveType;
		connectNulls?: boolean;
		/** Clicking the line selects (or clears) the series. */
		isClickable?: boolean;
		/** Soft outer glow around the line. */
		glowing?: boolean;
		/** Draws the last segment dashed — an unfinished "buffer" period. */
		enableBufferLine?: boolean;
		dot?: DotVariant | false;
		activeDot?: DotVariant | false;
	};
</script>

<script lang="ts">
	import ChartContainer from './ChartContainer.svelte';
	import CartesianFrame from './CartesianFrame.svelte';
	import ChartTooltip from './ChartTooltip.svelte';
	import ChartLegend from './ChartLegend.svelte';
	import ChartBrush from './ChartBrush.svelte';
	import ChartDot from './ChartDot.svelte';
	import ChartLoadingPattern from './ChartLoadingPattern.svelte';
	import SeriesColorGradient from './SeriesColorGradient.svelte';
	import GlowFilter from './GlowFilter.svelte';
	import { linePath } from './curves';
	import { bufferLine } from './buffer-line';
	import {
		computeCartesianLayout,
		computeValueTicks,
		resolveOptions,
		stackSeries,
		type AxisOptions,
		type GridOptions
	} from './cartesian';
	import { toNumber, type ChartConfig, type ChartRow, type Margin, toLabel } from './chart-utils';
	import { createLoadingData } from './loading.svelte';

	type Props = {
		data: ChartRow[];
		config: ChartConfig;
		/** Lines to draw. Defaults to every key of `config`. */
		series?: LineSeries[];
		/** Category key for the x-axis, tooltip label and brush. */
		xDataKey?: string;
		class?: string;
		curveType?: CurveType;
		/** Defaults for every series (overridden per series). */
		strokeVariant?: StrokeVariant;
		dot?: DotVariant | false;
		activeDot?: DotVariant | false;
		isClickable?: boolean;
		glowing?: boolean;
		grid?: boolean | GridOptions;
		xAxis?: boolean | AxisOptions;
		yAxis?: boolean | AxisOptions;
		tooltip?: boolean | TooltipOptions;
		legend?: boolean | LegendOptions;
		brush?: boolean | BrushOptions;
		backgroundVariant?: BackgroundVariant;
		defaultSelectedDataKey?: string | null;
		onSelectionChange?: (dataKey: string | null) => void;
		isLoading?: boolean;
		loadingPoints?: number;
		margin?: Margin;
		ariaLabel?: string;
	};

	let {
		data,
		config,
		series,
		xDataKey,
		class: className = '',
		curveType = 'linear',
		strokeVariant = 'solid',
		dot = false,
		activeDot = false,
		isClickable = false,
		glowing = false,
		grid = true,
		xAxis = true,
		yAxis = false,
		tooltip = true,
		legend = false,
		brush = false,
		backgroundVariant,
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		loadingPoints = 14,
		margin,
		ariaLabel = 'Line chart'
	}: Props = $props();

	const STROKE_WIDTH = 0.8;
	const chartId = $props.id();

	// svelte-ignore state_referenced_locally
	let selectedDataKey = $state<string | null>(defaultSelectedDataKey);
	let activeIndex = $state<number | null>(null);
	let pointer = $state<{ x: number; y: number } | null>(null);
	// Resets to the full range whenever the data changes; the brush overrides it.
	let range = $derived<BrushRange>({ startIndex: 0, endIndex: Math.max(0, data.length - 1) });

	const loading = createLoadingData(() => isLoading, { points: () => loadingPoints });

	const gridOpts = $derived(resolveOptions<GridOptions>(grid, true));
	const xAxisOpts = $derived(resolveOptions<AxisOptions>(xAxis, true));
	const yAxisOpts = $derived(resolveOptions<AxisOptions>(yAxis, false));
	const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
	const legendOpts = $derived(resolveOptions<LegendOptions>(legend, false));
	const brushOpts = $derived(resolveOptions<BrushOptions>(brush, false));
	const categoryKey = $derived(xDataKey ?? xAxisOpts?.dataKey);

	const resolvedSeries = $derived(
		(series ?? Object.keys(config).map((dataKey): LineSeries => ({ dataKey }))).map((s) => ({
			dataKey: s.dataKey,
			strokeVariant: s.strokeVariant ?? strokeVariant,
			strokeWidth: s.strokeWidth ?? STROKE_WIDTH,
			curveType: s.curveType ?? curveType,
			connectNulls: s.connectNulls ?? false,
			isClickable: s.isClickable ?? isClickable,
			glowing: s.glowing ?? glowing,
			enableBufferLine: s.enableBufferLine ?? false,
			dot: s.dot ?? dot,
			activeDot: s.activeDot ?? activeDot
		}))
	);
	const keys = $derived(resolvedSeries.map((s) => s.dataKey));

	const visibleData = $derived(
		brushOpts && !isLoading ? data.slice(range.startIndex, range.endIndex + 1) : data
	);
	const rows = $derived(isLoading ? (loading.data as ChartRow[]) : visibleData);
	const stacks = $derived(stackSeries(rows, isLoading ? ['loading'] : keys, 'default'));
	const valueTicks = $derived(computeValueTicks(stacks, 'default', yAxisOpts));

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

	function toggle(key: string) {
		selectDataKey(selectedDataKey === key ? null : key);
	}

	function opacityFor(key: string) {
		if (selectedDataKey === null || selectedDataKey === key) return { stroke: 1, dot: 1 };
		return { stroke: 0.3, dot: 0.3 };
	}

	function categoryLabel(index: number) {
		const raw = categoryKey ? rows[index]?.[categoryKey] : index;
		const text = toLabel(raw);
		return config[text]?.label ?? text;
	}
</script>

<ChartContainer id={chartId} {config} class={className} {isLoading} hasFooter={!!brushOpts}>
	{#snippet header()}
		{#if legendOpts && (legendOpts.verticalAlign ?? 'top') === 'top'}
			<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
		{/if}
	{/snippet}

	{#snippet bottom()}
		{#if legendOpts && legendOpts.verticalAlign === 'bottom'}
			<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
		{/if}
	{/snippet}

	{#snippet footer()}
		{#if brushOpts && !isLoading}
			<ChartBrush
				{data}
				{config}
				{keys}
				xDataKey={categoryKey}
				variant="line"
				{curveType}
				options={brushOpts}
				bind:range
				class="mt-1"
			/>
		{/if}
	{/snippet}

	{#snippet children({ width, height }: { width: number; height: number })}
		{@const layout = computeCartesianLayout({
			width,
			height,
			rows,
			categoryKey,
			valueTicks,
			categoryAxis: isLoading ? null : xAxisOpts,
			valueAxis: isLoading ? null : yAxisOpts,
			margin
		})}
		<CartesianFrame
			{width}
			{height}
			{layout}
			grid={gridOpts}
			{backgroundVariant}
			cursor={!isLoading && tooltipOpts !== null && (tooltipOpts.cursor ?? true)}
			defaultIndex={tooltipOpts?.defaultIndex}
			bind:activeIndex
			bind:pointer
			{ariaLabel}
		>
			{#snippet defs()}
				{#if isLoading}
					<ChartLoadingPattern id={chartId} />
				{:else}
					{#each resolvedSeries as s, index (s.dataKey)}
						{@const sid = `${chartId}-${index}`}
						<SeriesColorGradient id={sid} dataKey={s.dataKey} item={config[s.dataKey]} />
						{#if s.glowing}
							<GlowFilter id="{sid}-glow-{s.dataKey}" stdDeviation={10} alpha={2} region={50} />
						{/if}
					{/each}
				{/if}
			{/snippet}

			{#if isLoading}
				{@const points = rows.map((row, i) => ({
					x: layout.categoryPositions[i],
					y: layout.valueToPx(toNumber(row.loading) ?? 0)
				}))}
				<path
					d={linePath(points, curveType)}
					fill="none"
					stroke="currentColor"
					stroke-opacity="0.5"
					class="text-foreground"
					style="mask: url(#{chartId}-loading-mask)"
				/>
			{:else}
				{#each resolvedSeries as s, index (s.dataKey)}
					{@const sid = `${chartId}-${index}`}
					{@const opacity = opacityFor(s.dataKey)}
					{@const points = rows.map((row, i) => {
						const value = toNumber(row[s.dataKey]);
						return value === null
							? null
							: { x: layout.categoryPositions[i], y: layout.valueToPx(value) };
					})}
					{@const d = linePath(points, s.curveType, s.connectNulls)}
					{@const drawn = points.filter((p) => p !== null)}
					{@const isAnimatedDashed = s.strokeVariant === 'animated-dashed'}
					{@const isDashed = s.strokeVariant === 'dashed' || isAnimatedDashed}
					<g>
						{#if s.isClickable}
							<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
							<path
								{d}
								fill="none"
								stroke="transparent"
								stroke-width="15"
								style="cursor: pointer"
								onclick={() => toggle(s.dataKey)}
							/>
						{/if}
						<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
						<path
							{d}
							fill="none"
							stroke="url(#{sid}-colors-{s.dataKey})"
							stroke-opacity={opacity.stroke}
							stroke-width={s.strokeWidth}
							stroke-dasharray={!s.enableBufferLine && isDashed ? '5 5' : undefined}
							filter={s.glowing ? `url(#${sid}-glow-${s.dataKey})` : undefined}
							style:cursor={s.isClickable ? 'pointer' : undefined}
							onclick={() => {
								if (s.isClickable) toggle(s.dataKey);
							}}
							use:bufferLine={{
								splitX: s.enableBufferLine && drawn.length >= 2 ? drawn[drawn.length - 2].x : null,
								d
							}}
						>
							{#if isAnimatedDashed && selectedDataKey === null && !s.enableBufferLine}
								<animate
									attributeName="stroke-dasharray"
									values="5 5; 0 5; 5 5"
									dur="1s"
									repeatCount="indefinite"
									keyTimes="0;0.5;1"
								/>
								<animate
									attributeName="stroke-dashoffset"
									values="0; -10"
									dur="1s"
									repeatCount="indefinite"
									keyTimes="0;1"
								/>
							{/if}
						</path>
						{#if s.dot}
							{#each points as p, i (i)}
								{#if p}
									<ChartDot
										cx={p.x}
										cy={p.y}
										variant={s.dot}
										gradientUrl="url(#{sid}-colors-{s.dataKey})"
										fillOpacity={opacity.dot}
										class="pointer-events-none"
									/>
								{/if}
							{/each}
						{/if}
					</g>
				{/each}

				{#if activeIndex !== null}
					{#each resolvedSeries as s, index (s.dataKey)}
						{@const value = toNumber(rows[activeIndex]?.[s.dataKey])}
						{#if s.activeDot && value !== null}
							<ChartDot
								cx={layout.categoryPositions[activeIndex]}
								cy={layout.valueToPx(value)}
								variant={s.activeDot}
								gradientUrl="url(#{chartId}-{index}-colors-{s.dataKey})"
								fillOpacity={opacityFor(s.dataKey).dot}
								class="pointer-events-none"
							/>
						{/if}
					{/each}
				{/if}
			{/if}

			{#snippet tooltip({ index, x, y }: { index: number; x: number; y: number })}
				{#if tooltipOpts && !isLoading}
					<ChartTooltip
						{x}
						{y}
						boundsWidth={width}
						boundsHeight={height}
						label={categoryLabel(index)}
						options={tooltipOpts}
						items={resolvedSeries
							.filter((s) => rows[index]?.[s.dataKey] != null)
							.map((s) => ({
								key: s.dataKey,
								name: config[s.dataKey]?.label ?? s.dataKey,
								value: rows[index]?.[s.dataKey],
								item: config[s.dataKey],
								dimmed: selectedDataKey !== null && selectedDataKey !== s.dataKey
							}))}
					/>
				{/if}
			{/snippet}
		</CartesianFrame>
	{/snippet}
</ChartContainer>
<script lang="ts" module>
	import type {
		BackgroundVariant,
		BrushOptions,
		BrushRange,
		DotVariant,
		LegendOptions,
		TooltipOptions
	} from './types';
	import type { CurveType } from './curves';
	import type { StrokeVariant } from './chart-utils';

	export type LineSeries = {
		/** Series key — must exist on the data rows and in the config. */
		dataKey: string;
		strokeVariant?: StrokeVariant;
		strokeWidth?: number;
		curveType?: CurveType;
		connectNulls?: boolean;
		/** Clicking the line selects (or clears) the series. */
		isClickable?: boolean;
		/** Soft outer glow around the line. */
		glowing?: boolean;
		/** Draws the last segment dashed — an unfinished "buffer" period. */
		enableBufferLine?: boolean;
		dot?: DotVariant | false;
		activeDot?: DotVariant | false;
	};
</script>

<script lang="ts">
	import ChartContainer from './ChartContainer.svelte';
	import CartesianFrame from './CartesianFrame.svelte';
	import ChartTooltip from './ChartTooltip.svelte';
	import ChartLegend from './ChartLegend.svelte';
	import ChartBrush from './ChartBrush.svelte';
	import ChartDot from './ChartDot.svelte';
	import ChartLoadingPattern from './ChartLoadingPattern.svelte';
	import SeriesColorGradient from './SeriesColorGradient.svelte';
	import GlowFilter from './GlowFilter.svelte';
	import { linePath } from './curves';
	import { bufferLine } from './buffer-line';
	import {
		computeCartesianLayout,
		computeValueTicks,
		resolveOptions,
		stackSeries,
		type AxisOptions,
		type GridOptions
	} from './cartesian';
	import { toNumber, type ChartConfig, type ChartRow, type Margin, toLabel } from './chart-utils';
	import { createLoadingData } from './loading.svelte';

	type Props = {
		data: ChartRow[];
		config: ChartConfig;
		/** Lines to draw. Defaults to every key of `config`. */
		series?: LineSeries[];
		/** Category key for the x-axis, tooltip label and brush. */
		xDataKey?: string;
		class?: string;
		curveType?: CurveType;
		/** Defaults for every series (overridden per series). */
		strokeVariant?: StrokeVariant;
		dot?: DotVariant | false;
		activeDot?: DotVariant | false;
		isClickable?: boolean;
		glowing?: boolean;
		grid?: boolean | GridOptions;
		xAxis?: boolean | AxisOptions;
		yAxis?: boolean | AxisOptions;
		tooltip?: boolean | TooltipOptions;
		legend?: boolean | LegendOptions;
		brush?: boolean | BrushOptions;
		backgroundVariant?: BackgroundVariant;
		defaultSelectedDataKey?: string | null;
		onSelectionChange?: (dataKey: string | null) => void;
		isLoading?: boolean;
		loadingPoints?: number;
		margin?: Margin;
		ariaLabel?: string;
	};

	let {
		data,
		config,
		series,
		xDataKey,
		class: className = '',
		curveType = 'linear',
		strokeVariant = 'solid',
		dot = false,
		activeDot = false,
		isClickable = false,
		glowing = false,
		grid = true,
		xAxis = true,
		yAxis = false,
		tooltip = true,
		legend = false,
		brush = false,
		backgroundVariant,
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		loadingPoints = 14,
		margin,
		ariaLabel = 'Line chart'
	}: Props = $props();

	const STROKE_WIDTH = 0.8;
	const chartId = $props.id();

	// svelte-ignore state_referenced_locally
	let selectedDataKey = $state<string | null>(defaultSelectedDataKey);
	let activeIndex = $state<number | null>(null);
	let pointer = $state<{ x: number; y: number } | null>(null);
	// Resets to the full range whenever the data changes; the brush overrides it.
	let range = $derived<BrushRange>({ startIndex: 0, endIndex: Math.max(0, data.length - 1) });

	const loading = createLoadingData(() => isLoading, { points: () => loadingPoints });

	const gridOpts = $derived(resolveOptions<GridOptions>(grid, true));
	const xAxisOpts = $derived(resolveOptions<AxisOptions>(xAxis, true));
	const yAxisOpts = $derived(resolveOptions<AxisOptions>(yAxis, false));
	const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
	const legendOpts = $derived(resolveOptions<LegendOptions>(legend, false));
	const brushOpts = $derived(resolveOptions<BrushOptions>(brush, false));
	const categoryKey = $derived(xDataKey ?? xAxisOpts?.dataKey);

	const resolvedSeries = $derived(
		(series ?? Object.keys(config).map((dataKey): LineSeries => ({ dataKey }))).map((s) => ({
			dataKey: s.dataKey,
			strokeVariant: s.strokeVariant ?? strokeVariant,
			strokeWidth: s.strokeWidth ?? STROKE_WIDTH,
			curveType: s.curveType ?? curveType,
			connectNulls: s.connectNulls ?? false,
			isClickable: s.isClickable ?? isClickable,
			glowing: s.glowing ?? glowing,
			enableBufferLine: s.enableBufferLine ?? false,
			dot: s.dot ?? dot,
			activeDot: s.activeDot ?? activeDot
		}))
	);
	const keys = $derived(resolvedSeries.map((s) => s.dataKey));

	const visibleData = $derived(
		brushOpts && !isLoading ? data.slice(range.startIndex, range.endIndex + 1) : data
	);
	const rows = $derived(isLoading ? (loading.data as ChartRow[]) : visibleData);
	const stacks = $derived(stackSeries(rows, isLoading ? ['loading'] : keys, 'default'));
	const valueTicks = $derived(computeValueTicks(stacks, 'default', yAxisOpts));

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

	function toggle(key: string) {
		selectDataKey(selectedDataKey === key ? null : key);
	}

	function opacityFor(key: string) {
		if (selectedDataKey === null || selectedDataKey === key) return { stroke: 1, dot: 1 };
		return { stroke: 0.3, dot: 0.3 };
	}

	function categoryLabel(index: number) {
		const raw = categoryKey ? rows[index]?.[categoryKey] : index;
		const text = toLabel(raw);
		return config[text]?.label ?? text;
	}
</script>

<ChartContainer id={chartId} {config} class={className} {isLoading} hasFooter={!!brushOpts}>
	{#snippet header()}
		{#if legendOpts && (legendOpts.verticalAlign ?? 'top') === 'top'}
			<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
		{/if}
	{/snippet}

	{#snippet bottom()}
		{#if legendOpts && legendOpts.verticalAlign === 'bottom'}
			<ChartLegend {config} {keys} selected={selectedDataKey} onSelectChange={selectDataKey} options={legendOpts} />
		{/if}
	{/snippet}

	{#snippet footer()}
		{#if brushOpts && !isLoading}
			<ChartBrush
				{data}
				{config}
				{keys}
				xDataKey={categoryKey}
				variant="line"
				{curveType}
				options={brushOpts}
				bind:range
				class="mt-1"
			/>
		{/if}
	{/snippet}

	{#snippet children({ width, height }: { width: number; height: number })}
		{@const layout = computeCartesianLayout({
			width,
			height,
			rows,
			categoryKey,
			valueTicks,
			categoryAxis: isLoading ? null : xAxisOpts,
			valueAxis: isLoading ? null : yAxisOpts,
			margin
		})}
		<CartesianFrame
			{width}
			{height}
			{layout}
			grid={gridOpts}
			{backgroundVariant}
			cursor={!isLoading && tooltipOpts !== null && (tooltipOpts.cursor ?? true)}
			defaultIndex={tooltipOpts?.defaultIndex}
			bind:activeIndex
			bind:pointer
			{ariaLabel}
		>
			{#snippet defs()}
				{#if isLoading}
					<ChartLoadingPattern id={chartId} />
				{:else}
					{#each resolvedSeries as s, index (s.dataKey)}
						{@const sid = `${chartId}-${index}`}
						<SeriesColorGradient id={sid} dataKey={s.dataKey} item={config[s.dataKey]} />
						{#if s.glowing}
							<GlowFilter id="{sid}-glow-{s.dataKey}" stdDeviation={10} alpha={2} region={50} />
						{/if}
					{/each}
				{/if}
			{/snippet}

			{#if isLoading}
				{@const points = rows.map((row, i) => ({
					x: layout.categoryPositions[i],
					y: layout.valueToPx(toNumber(row.loading) ?? 0)
				}))}
				<path
					d={linePath(points, curveType)}
					fill="none"
					stroke="currentColor"
					stroke-opacity="0.5"
					class="text-foreground"
					style="mask: url(#{chartId}-loading-mask)"
				/>
			{:else}
				{#each resolvedSeries as s, index (s.dataKey)}
					{@const sid = `${chartId}-${index}`}
					{@const opacity = opacityFor(s.dataKey)}
					{@const points = rows.map((row, i) => {
						const value = toNumber(row[s.dataKey]);
						return value === null
							? null
							: { x: layout.categoryPositions[i], y: layout.valueToPx(value) };
					})}
					{@const d = linePath(points, s.curveType, s.connectNulls)}
					{@const drawn = points.filter((p) => p !== null)}
					{@const isAnimatedDashed = s.strokeVariant === 'animated-dashed'}
					{@const isDashed = s.strokeVariant === 'dashed' || isAnimatedDashed}
					<g>
						{#if s.isClickable}
							<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
							<path
								{d}
								fill="none"
								stroke="transparent"
								stroke-width="15"
								style="cursor: pointer"
								onclick={() => toggle(s.dataKey)}
							/>
						{/if}
						<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
						<path
							{d}
							fill="none"
							stroke="url(#{sid}-colors-{s.dataKey})"
							stroke-opacity={opacity.stroke}
							stroke-width={s.strokeWidth}
							stroke-dasharray={!s.enableBufferLine && isDashed ? '5 5' : undefined}
							filter={s.glowing ? `url(#${sid}-glow-${s.dataKey})` : undefined}
							style:cursor={s.isClickable ? 'pointer' : undefined}
							onclick={() => {
								if (s.isClickable) toggle(s.dataKey);
							}}
							use:bufferLine={{
								splitX: s.enableBufferLine && drawn.length >= 2 ? drawn[drawn.length - 2].x : null,
								d
							}}
						>
							{#if isAnimatedDashed && selectedDataKey === null && !s.enableBufferLine}
								<animate
									attributeName="stroke-dasharray"
									values="5 5; 0 5; 5 5"
									dur="1s"
									repeatCount="indefinite"
									keyTimes="0;0.5;1"
								/>
								<animate
									attributeName="stroke-dashoffset"
									values="0; -10"
									dur="1s"
									repeatCount="indefinite"
									keyTimes="0;1"
								/>
							{/if}
						</path>
						{#if s.dot}
							{#each points as p, i (i)}
								{#if p}
									<ChartDot
										cx={p.x}
										cy={p.y}
										variant={s.dot}
										gradientUrl="url(#{sid}-colors-{s.dataKey})"
										fillOpacity={opacity.dot}
										class="pointer-events-none"
									/>
								{/if}
							{/each}
						{/if}
					</g>
				{/each}

				{#if activeIndex !== null}
					{#each resolvedSeries as s, index (s.dataKey)}
						{@const value = toNumber(rows[activeIndex]?.[s.dataKey])}
						{#if s.activeDot && value !== null}
							<ChartDot
								cx={layout.categoryPositions[activeIndex]}
								cy={layout.valueToPx(value)}
								variant={s.activeDot}
								gradientUrl="url(#{chartId}-{index}-colors-{s.dataKey})"
								fillOpacity={opacityFor(s.dataKey).dot}
								class="pointer-events-none"
							/>
						{/if}
					{/each}
				{/if}
			{/if}

			{#snippet tooltip({ index, x, y }: { index: number; x: number; y: number })}
				{#if tooltipOpts && !isLoading}
					<ChartTooltip
						{x}
						{y}
						boundsWidth={width}
						boundsHeight={height}
						label={categoryLabel(index)}
						options={tooltipOpts}
						items={resolvedSeries
							.filter((s) => rows[index]?.[s.dataKey] != null)
							.map((s) => ({
								key: s.dataKey,
								name: config[s.dataKey]?.label ?? s.dataKey,
								value: rows[index]?.[s.dataKey],
								item: config[s.dataKey],
								dimmed: selectedDataKey !== null && selectedDataKey !== s.dataKey
							}))}
					/>
				{/if}
			{/snippet}
		</CartesianFrame>
	{/snippet}
</ChartContainer>

Использование

<script lang="ts">
	import LineChart from '$lib/components/charts/LineChart.svelte';
	import type { ChartConfig } from '$lib/components/charts/chart-utils';

	const config = {
		desktop: { label: 'Десктоп', colors: { light: ['#047857'], dark: ['#10b981'] } },
		mobile: { label: 'Мобильные', colors: { light: ['#be123c'], dark: ['#f43f5e'] } }
	} satisfies ChartConfig;
</script>

<LineChart
	{data}
	{config}
	xDataKey="month"
	curveType="monotone"
	legend={{ isClickable: true }}
	series={[
		{ dataKey: 'desktop', glowing: true, dot: 'border', activeDot: 'colored-border', isClickable: true },
		{ dataKey: 'mobile', strokeVariant: 'dashed', isClickable: true }
	]}
	class="h-[320px] w-full"
/>
<script lang="ts">
	import LineChart from '$lib/components/charts/LineChart.svelte';
	import type { ChartConfig } from '$lib/components/charts/chart-utils';

	const config = {
		desktop: { label: 'Десктоп', colors: { light: ['#047857'], dark: ['#10b981'] } },
		mobile: { label: 'Мобильные', colors: { light: ['#be123c'], dark: ['#f43f5e'] } }
	} satisfies ChartConfig;
</script>

<LineChart
	{data}
	{config}
	xDataKey="month"
	curveType="monotone"
	legend={{ isClickable: true }}
	series={[
		{ dataKey: 'desktop', glowing: true, dot: 'border', activeDot: 'colored-border', isClickable: true },
		{ dataKey: 'mobile', strokeVariant: 'dashed', isClickable: true }
	]}
	class="h-[320px] w-full"
/>

Примеры

Свечение

Десктоп
Мобильные
<LineChart {data} {config} xDataKey="month" curveType="monotone"
	series={[{ dataKey: 'desktop', glowing: true }, { dataKey: 'mobile' }]} legend />
<LineChart {data} {config} xDataKey="month" curveType="monotone"
	series={[{ dataKey: 'desktop', glowing: true }, { dataKey: 'mobile' }]} legend />

Буферная линия

Последний отрезок рисуется пунктиром — удобно для текущего, ещё не закрытого периода.

<LineChart {data} {config} xDataKey="month" dot="default"
	series={[{ dataKey: 'desktop', enableBufferLine: true }, { dataKey: 'mobile', enableBufferLine: true }]} />
<LineChart {data} {config} xDataKey="month" dot="default"
	series={[{ dataKey: 'desktop', enableBufferLine: true }, { dataKey: 'mobile', enableBufferLine: true }]} />

Варианты точек

dot="default"
dot="border"
dot="colored-border"

Градиентные цвета и bump-кривая

Десктоп
Мобильные

Варианты легенды

legend.variant="square"
legend.variant="circle"
legend.variant="circle-outline"
legend.variant="rounded-square"
legend.variant="rounded-square-outline"
legend.variant="vertical-bar"
legend.variant="horizontal-bar"
<LineChart {data} {config} xDataKey="month" legend={{ variant: 'circle-outline', isClickable: true }} />
<LineChart {data} {config} xDataKey="month" legend={{ variant: 'circle-outline', isClickable: true }} />

Фоновые паттерны

dots
grid
cross-hatch
diagonal-lines
plus
falling-triangles
4-pointed-star
tiny-checkers
overlapping-circles
wiggle-lines
bubbles
<LineChart {data} {config} xDataKey="month" backgroundVariant="dots" />
<LineChart {data} {config} xDataKey="month" backgroundVariant="dots" />

В markdown

:::chart
[type]line
[title]Подписчики
[curve]monotone
[glow]true
[dots]border

| Месяц | Telegram | YouTube |
|-------|----------|---------|
| Янв   | 1400     | 900     |
| Фев   | 1650     | 1050    |
| Мар   | 1900     | 1300    |
| Апр   | 2200     | 1500    |
:::
:::chart
[type]line
[title]Подписчики
[curve]monotone
[glow]true
[dots]border

| Месяц | Telegram | YouTube |
|-------|----------|---------|
| Янв   | 1400     | 900     |
| Фев   | 1650     | 1050    |
| Мар   | 1900     | 1300    |
| Апр   | 2200     | 1500    |
:::
Подписчики

Пропсы

Пропс Тип По умолчанию Описание
data ChartRow[] — Строки данных
config ChartConfig — Подписи и цвета серий
series LineSeries[] все ключи config Линии и их настройки
xDataKey string — Ключ категории
curveType CurveType 'linear' Интерполяция кривой
strokeVariant 'solid' 'dashed' 'animated-dashed' 'solid' Стиль линии по умолчанию
dot / activeDot DotVariant false false Точки и точка под курсором
glowing boolean false Свечение всех линий
isClickable boolean false Выбор линии кликом
grid boolean GridOptions true Сетка
xAxis / yAxis boolean AxisOptions true / false Оси
tooltip boolean TooltipOptions true Тултип
legend boolean LegendOptions false Легенда
brush boolean BrushOptions false Выбор диапазона
backgroundVariant BackgroundVariant — Декоративный фон
defaultSelectedDataKey string null null Выбранная линия при старте
onSelectionChange (key: string null) => void — Смена выбранной линии
isLoading boolean false Скелетон загрузки
class string — Классы контейнера

LineSeries

Поле Тип Описание
dataKey string Ключ серии
strokeVariant StrokeVariant Стиль линии
strokeWidth number Толщина (по умолчанию 0.8)
curveType CurveType Кривая
connectNulls boolean Соединять пропуски
isClickable boolean Кликабельность
glowing boolean Свечение линии
enableBufferLine boolean Пунктирный последний отрезок
dot / activeDot DotVariant false Точки