Круговая диаграмма
PieChart рисует по сектору на каждую строку данных: dataKey — значение, nameKey — имя сектора, по которому берутся цвет и подпись из config. Ненулевой innerRadius превращает круг в кольцо, отрицательный paddingAngle накладывает секторы друг на друга.
Интерактивное демо
Пропсы
50%
3°
6
360°
Подписи
Свечение Chrome
Градиенты
Загрузка
<script lang="ts" module>
export type PieLabelOptions = {
/** Data key for the label text — defaults to the pie value key. */
dataKey?: string;
formatter?: (value: unknown) => string;
};
</script>
<script lang="ts">
import type { BackgroundVariant, LegendOptions, TooltipOptions } from './types';
import ChartContainer from './ChartContainer.svelte';
import ChartTooltip from './ChartTooltip.svelte';
import ChartLegend from './ChartLegend.svelte';
import ChartBackground from './ChartBackground.svelte';
import SeriesColorGradient from './SeriesColorGradient.svelte';
import GlowFilter from './GlowFilter.svelte';
import { resolveOptions } from './cartesian';
import {
DEFAULT_MARGIN,
formatValue,
polarToCartesian,
resolveRadius,
sectorPath,
toNumber,
type ChartConfig,
type ChartRow,
toLabel
} from './chart-utils';
type Props = {
data: ChartRow[];
/** Sector colors + labels, keyed by each row's `nameKey` value. */
config: ChartConfig;
/** Key holding each sector's numeric value. */
dataKey: string;
/** Key holding each sector's name. */
nameKey: string;
class?: string;
/** Inner radius — above 0 turns the pie into a donut. */
innerRadius?: number | string;
outerRadius?: number | string;
cornerRadius?: number;
/** Gap between sectors in degrees — negative values overlap them. */
paddingAngle?: number;
startAngle?: number;
endAngle?: number;
isClickable?: boolean;
/** Sector names rendered with a soft outer glow. */
glowingSectors?: string[];
/** Value labels in the middle of each sector. */
label?: boolean | PieLabelOptions;
tooltip?: boolean | TooltipOptions;
legend?: boolean | LegendOptions;
backgroundVariant?: BackgroundVariant;
defaultSelectedSector?: string | null;
onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;
isLoading?: boolean;
ariaLabel?: string;
};
let {
data,
config,
dataKey,
nameKey,
class: className = '',
innerRadius = 0,
outerRadius = '80%',
cornerRadius = 0,
paddingAngle = 0,
startAngle = 0,
endAngle = 360,
isClickable = false,
glowingSectors = [],
label = false,
tooltip = true,
legend = false,
backgroundVariant,
defaultSelectedSector = null,
onSelectionChange,
isLoading = false,
ariaLabel = 'Pie chart'
}: Props = $props();
const LOADING_SECTORS = 5;
const chartId = $props.id();
// svelte-ignore state_referenced_locally
let selectedSector = $state<string | null>(defaultSelectedSector);
let hovered = $state<{ index: number; x: number; y: number } | null>(null);
const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
const legendOpts = $derived.by(() => {
const opts = resolveOptions<LegendOptions>(legend, false);
return opts ? { align: 'center' as const, verticalAlign: 'bottom' as const, ...opts } : null;
});
const labelOpts = $derived(resolveOptions<PieLabelOptions>(label, false));
const names = $derived(data.map((row) => toLabel(row[nameKey])));
const rows = $derived(
isLoading
? Array.from({ length: LOADING_SECTORS }, (_, i) => ({
name: `loading${String(i)}`,
value: 100 / LOADING_SECTORS
}))
: data.map((row) => ({ name: toLabel(row[nameKey]), value: toNumber(row[dataKey]) ?? 0 }))
);
const sectors = $derived.by(() => {
const delta = endAngle - startAngle;
const sign = Math.sign(delta) || 1;
const absDelta = Math.min(360, Math.abs(delta));
const total = rows.reduce((sum, row) => sum + Math.max(0, row.value), 0);
const nonZero = rows.filter((row) => row.value > 0).length;
const paddingTotal = (absDelta >= 360 ? nonZero : Math.max(0, nonZero - 1)) * paddingAngle;
const real = absDelta - paddingTotal;
let prevEnd = startAngle;
return rows.map((row, i) => {
const angle = total > 0 ? (Math.max(0, row.value) / total) * real : 0;
const start = i === 0 ? startAngle : prevEnd + sign * paddingAngle;
const end = start + sign * angle;
prevEnd = end;
return { ...row, startAngle: start, endAngle: end, midAngle: (start + end) / 2 };
});
});
function selectSector(name: string | null) {
selectedSector = name;
if (name === null) {
onSelectionChange?.(null);
return;
}
const value = rows.find((row) => row.name === name)?.value;
if (value !== undefined) onSelectionChange?.({ dataKey: name, value });
}
function pointerOf(event: PointerEvent) {
const svg = (event.currentTarget as SVGElement).ownerSVGElement;
const rect = (svg ?? (event.currentTarget as Element)).getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function labelText(index: number) {
const key = labelOpts?.dataKey ?? dataKey;
const value = data[index]?.[key];
return labelOpts?.formatter ? labelOpts.formatter(value) : formatValue(value);
}
</script>
<ChartContainer id={chartId} {config} class={className} {isLoading}>
{#snippet header()}
{#if legendOpts && legendOpts.verticalAlign === 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedSector} onSelectChange={selectSector} options={legendOpts} />
{/if}
{/snippet}
{#snippet bottom()}
{#if legendOpts && legendOpts.verticalAlign !== 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedSector} onSelectChange={selectSector} options={legendOpts} />
{/if}
{/snippet}
{#snippet children({ width, height }: { width: number; height: number })}
{@const cx = width / 2}
{@const cy = height / 2}
{@const maxRadius =
Math.min(width - DEFAULT_MARGIN.left - DEFAULT_MARGIN.right, height - DEFAULT_MARGIN.top - DEFAULT_MARGIN.bottom) / 2}
{@const ro = resolveRadius(outerRadius, maxRadius)}
{@const ri = resolveRadius(innerRadius, maxRadius)}
<svg
{width}
{height}
viewBox="0 0 {width} {height}"
class="absolute inset-0 block"
role="img"
aria-label={ariaLabel}
onpointerleave={() => (hovered = null)}
>
<defs>
{#each Object.entries(config) as [key, item] (key)}
<SeriesColorGradient id={chartId} dataKey={key} {item} direction="diagonal" />
{/each}
{#each glowingSectors as name (name)}
<GlowFilter id="{chartId}-glow-{name}" stdDeviation={8} alpha={0.5} region={100} />
{/each}
</defs>
{#if backgroundVariant}
<ChartBackground variant={backgroundVariant} />
{/if}
{#if isLoading}
<g class="text-foreground">
{#each sectors as sector, i (sector.name)}
<path
class="chart-pulse"
style="animation-delay: {(i / LOADING_SECTORS) * 2}s"
d={sectorPath({
cx,
cy,
innerRadius: ri,
outerRadius: ro,
startAngle: sector.startAngle,
endAngle: sector.endAngle,
cornerRadius
})}
fill="currentColor"
/>
{/each}
</g>
{:else}
{#each sectors as sector, i (sector.name)}
{@const isDimmed = isClickable && selectedSector !== null && selectedSector !== sector.name}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
d={sectorPath({
cx,
cy,
innerRadius: ri,
outerRadius: ro,
startAngle: sector.startAngle,
endAngle: sector.endAngle,
cornerRadius
})}
fill="url(#{chartId}-colors-{sector.name})"
filter={glowingSectors.includes(sector.name) ? `url(#${chartId}-glow-${sector.name})` : undefined}
stroke={paddingAngle < 0 ? 'var(--color-background)' : 'none'}
stroke-width={paddingAngle < 0 ? 5 : 0}
opacity={isDimmed ? 0.15 : 1}
class="transition-opacity duration-200"
style:cursor={isClickable ? 'pointer' : undefined}
onpointermove={(event) => (hovered = { index: i, ...pointerOf(event) })}
onclick={() => {
if (isClickable) selectSector(selectedSector === sector.name ? null : sector.name);
}}
/>
{/each}
{#if labelOpts}
{#each sectors as sector, i (sector.name)}
{@const position = polarToCartesian(cx, cy, (ri + ro) / 2, sector.midAngle)}
<text
x={position.x}
y={position.y}
text-anchor="middle"
dominant-baseline="central"
font-size="12"
font-weight="500"
class="pointer-events-none fill-background"
>
{labelText(i)}
</text>
{/each}
{/if}
{/if}
</svg>
{#if tooltipOpts && !isLoading}
{@const shown =
hovered ??
(tooltipOpts.defaultIndex !== undefined && sectors[tooltipOpts.defaultIndex]
? {
index: tooltipOpts.defaultIndex,
...polarToCartesian(cx, cy, (ri + ro) / 2, sectors[tooltipOpts.defaultIndex].midAngle)
}
: null)}
{#if shown}
{@const sector = sectors[shown.index]}
<ChartTooltip
x={shown.x}
y={shown.y}
boundsWidth={width}
boundsHeight={height}
hideLabel
options={tooltipOpts}
items={[
{
key: sector.name,
name: config[sector.name]?.label ?? sector.name,
value: data[shown.index]?.[dataKey],
item: config[sector.name]
}
]}
/>
{/if}
{/if}
{/snippet}
</ChartContainer>
<script lang="ts" module>
export type PieLabelOptions = {
/** Data key for the label text — defaults to the pie value key. */
dataKey?: string;
formatter?: (value: unknown) => string;
};
</script>
<script lang="ts">
import type { BackgroundVariant, LegendOptions, TooltipOptions } from './types';
import ChartContainer from './ChartContainer.svelte';
import ChartTooltip from './ChartTooltip.svelte';
import ChartLegend from './ChartLegend.svelte';
import ChartBackground from './ChartBackground.svelte';
import SeriesColorGradient from './SeriesColorGradient.svelte';
import GlowFilter from './GlowFilter.svelte';
import { resolveOptions } from './cartesian';
import {
DEFAULT_MARGIN,
formatValue,
polarToCartesian,
resolveRadius,
sectorPath,
toNumber,
type ChartConfig,
type ChartRow,
toLabel
} from './chart-utils';
type Props = {
data: ChartRow[];
/** Sector colors + labels, keyed by each row's `nameKey` value. */
config: ChartConfig;
/** Key holding each sector's numeric value. */
dataKey: string;
/** Key holding each sector's name. */
nameKey: string;
class?: string;
/** Inner radius — above 0 turns the pie into a donut. */
innerRadius?: number | string;
outerRadius?: number | string;
cornerRadius?: number;
/** Gap between sectors in degrees — negative values overlap them. */
paddingAngle?: number;
startAngle?: number;
endAngle?: number;
isClickable?: boolean;
/** Sector names rendered with a soft outer glow. */
glowingSectors?: string[];
/** Value labels in the middle of each sector. */
label?: boolean | PieLabelOptions;
tooltip?: boolean | TooltipOptions;
legend?: boolean | LegendOptions;
backgroundVariant?: BackgroundVariant;
defaultSelectedSector?: string | null;
onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;
isLoading?: boolean;
ariaLabel?: string;
};
let {
data,
config,
dataKey,
nameKey,
class: className = '',
innerRadius = 0,
outerRadius = '80%',
cornerRadius = 0,
paddingAngle = 0,
startAngle = 0,
endAngle = 360,
isClickable = false,
glowingSectors = [],
label = false,
tooltip = true,
legend = false,
backgroundVariant,
defaultSelectedSector = null,
onSelectionChange,
isLoading = false,
ariaLabel = 'Pie chart'
}: Props = $props();
const LOADING_SECTORS = 5;
const chartId = $props.id();
// svelte-ignore state_referenced_locally
let selectedSector = $state<string | null>(defaultSelectedSector);
let hovered = $state<{ index: number; x: number; y: number } | null>(null);
const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
const legendOpts = $derived.by(() => {
const opts = resolveOptions<LegendOptions>(legend, false);
return opts ? { align: 'center' as const, verticalAlign: 'bottom' as const, ...opts } : null;
});
const labelOpts = $derived(resolveOptions<PieLabelOptions>(label, false));
const names = $derived(data.map((row) => toLabel(row[nameKey])));
const rows = $derived(
isLoading
? Array.from({ length: LOADING_SECTORS }, (_, i) => ({
name: `loading${String(i)}`,
value: 100 / LOADING_SECTORS
}))
: data.map((row) => ({ name: toLabel(row[nameKey]), value: toNumber(row[dataKey]) ?? 0 }))
);
const sectors = $derived.by(() => {
const delta = endAngle - startAngle;
const sign = Math.sign(delta) || 1;
const absDelta = Math.min(360, Math.abs(delta));
const total = rows.reduce((sum, row) => sum + Math.max(0, row.value), 0);
const nonZero = rows.filter((row) => row.value > 0).length;
const paddingTotal = (absDelta >= 360 ? nonZero : Math.max(0, nonZero - 1)) * paddingAngle;
const real = absDelta - paddingTotal;
let prevEnd = startAngle;
return rows.map((row, i) => {
const angle = total > 0 ? (Math.max(0, row.value) / total) * real : 0;
const start = i === 0 ? startAngle : prevEnd + sign * paddingAngle;
const end = start + sign * angle;
prevEnd = end;
return { ...row, startAngle: start, endAngle: end, midAngle: (start + end) / 2 };
});
});
function selectSector(name: string | null) {
selectedSector = name;
if (name === null) {
onSelectionChange?.(null);
return;
}
const value = rows.find((row) => row.name === name)?.value;
if (value !== undefined) onSelectionChange?.({ dataKey: name, value });
}
function pointerOf(event: PointerEvent) {
const svg = (event.currentTarget as SVGElement).ownerSVGElement;
const rect = (svg ?? (event.currentTarget as Element)).getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function labelText(index: number) {
const key = labelOpts?.dataKey ?? dataKey;
const value = data[index]?.[key];
return labelOpts?.formatter ? labelOpts.formatter(value) : formatValue(value);
}
</script>
<ChartContainer id={chartId} {config} class={className} {isLoading}>
{#snippet header()}
{#if legendOpts && legendOpts.verticalAlign === 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedSector} onSelectChange={selectSector} options={legendOpts} />
{/if}
{/snippet}
{#snippet bottom()}
{#if legendOpts && legendOpts.verticalAlign !== 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedSector} onSelectChange={selectSector} options={legendOpts} />
{/if}
{/snippet}
{#snippet children({ width, height }: { width: number; height: number })}
{@const cx = width / 2}
{@const cy = height / 2}
{@const maxRadius =
Math.min(width - DEFAULT_MARGIN.left - DEFAULT_MARGIN.right, height - DEFAULT_MARGIN.top - DEFAULT_MARGIN.bottom) / 2}
{@const ro = resolveRadius(outerRadius, maxRadius)}
{@const ri = resolveRadius(innerRadius, maxRadius)}
<svg
{width}
{height}
viewBox="0 0 {width} {height}"
class="absolute inset-0 block"
role="img"
aria-label={ariaLabel}
onpointerleave={() => (hovered = null)}
>
<defs>
{#each Object.entries(config) as [key, item] (key)}
<SeriesColorGradient id={chartId} dataKey={key} {item} direction="diagonal" />
{/each}
{#each glowingSectors as name (name)}
<GlowFilter id="{chartId}-glow-{name}" stdDeviation={8} alpha={0.5} region={100} />
{/each}
</defs>
{#if backgroundVariant}
<ChartBackground variant={backgroundVariant} />
{/if}
{#if isLoading}
<g class="text-foreground">
{#each sectors as sector, i (sector.name)}
<path
class="chart-pulse"
style="animation-delay: {(i / LOADING_SECTORS) * 2}s"
d={sectorPath({
cx,
cy,
innerRadius: ri,
outerRadius: ro,
startAngle: sector.startAngle,
endAngle: sector.endAngle,
cornerRadius
})}
fill="currentColor"
/>
{/each}
</g>
{:else}
{#each sectors as sector, i (sector.name)}
{@const isDimmed = isClickable && selectedSector !== null && selectedSector !== sector.name}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
d={sectorPath({
cx,
cy,
innerRadius: ri,
outerRadius: ro,
startAngle: sector.startAngle,
endAngle: sector.endAngle,
cornerRadius
})}
fill="url(#{chartId}-colors-{sector.name})"
filter={glowingSectors.includes(sector.name) ? `url(#${chartId}-glow-${sector.name})` : undefined}
stroke={paddingAngle < 0 ? 'var(--color-background)' : 'none'}
stroke-width={paddingAngle < 0 ? 5 : 0}
opacity={isDimmed ? 0.15 : 1}
class="transition-opacity duration-200"
style:cursor={isClickable ? 'pointer' : undefined}
onpointermove={(event) => (hovered = { index: i, ...pointerOf(event) })}
onclick={() => {
if (isClickable) selectSector(selectedSector === sector.name ? null : sector.name);
}}
/>
{/each}
{#if labelOpts}
{#each sectors as sector, i (sector.name)}
{@const position = polarToCartesian(cx, cy, (ri + ro) / 2, sector.midAngle)}
<text
x={position.x}
y={position.y}
text-anchor="middle"
dominant-baseline="central"
font-size="12"
font-weight="500"
class="pointer-events-none fill-background"
>
{labelText(i)}
</text>
{/each}
{/if}
{/if}
</svg>
{#if tooltipOpts && !isLoading}
{@const shown =
hovered ??
(tooltipOpts.defaultIndex !== undefined && sectors[tooltipOpts.defaultIndex]
? {
index: tooltipOpts.defaultIndex,
...polarToCartesian(cx, cy, (ri + ro) / 2, sectors[tooltipOpts.defaultIndex].midAngle)
}
: null)}
{#if shown}
{@const sector = sectors[shown.index]}
<ChartTooltip
x={shown.x}
y={shown.y}
boundsWidth={width}
boundsHeight={height}
hideLabel
options={tooltipOpts}
items={[
{
key: sector.name,
name: config[sector.name]?.label ?? sector.name,
value: data[shown.index]?.[dataKey],
item: config[sector.name]
}
]}
/>
{/if}
{/if}
{/snippet}
</ChartContainer>
Использование
<script lang="ts">
import PieChart from '$lib/components/charts/PieChart.svelte';
import type { ChartConfig } from '$lib/components/charts/chart-utils';
const data = [
{ browser: 'chrome', visitors: 275 },
{ browser: 'safari', visitors: 200 },
{ browser: 'firefox', visitors: 187 }
];
const config = {
chrome: { label: 'Chrome', colors: { light: ['#3b82f6'], dark: ['#60a5fa'] } },
safari: { label: 'Safari', colors: { light: ['#10b981'], dark: ['#34d399'] } },
firefox: { label: 'Firefox', colors: { light: ['#f59e0b'], dark: ['#fbbf24'] } }
} satisfies ChartConfig;
</script>
<PieChart {data} {config} dataKey="visitors" nameKey="browser" innerRadius="55%" isClickable legend={{ isClickable: true }} /><script lang="ts">
import PieChart from '$lib/components/charts/PieChart.svelte';
import type { ChartConfig } from '$lib/components/charts/chart-utils';
const data = [
{ browser: 'chrome', visitors: 275 },
{ browser: 'safari', visitors: 200 },
{ browser: 'firefox', visitors: 187 }
];
const config = {
chrome: { label: 'Chrome', colors: { light: ['#3b82f6'], dark: ['#60a5fa'] } },
safari: { label: 'Safari', colors: { light: ['#10b981'], dark: ['#34d399'] } },
firefox: { label: 'Firefox', colors: { light: ['#f59e0b'], dark: ['#fbbf24'] } }
} satisfies ChartConfig;
</script>
<PieChart {data} {config} dataKey="visitors" nameKey="browser" innerRadius="55%" isClickable legend={{ isClickable: true }} />Примеры
базовая
Chrome
Safari
Firefox
Edge
Другие
innerRadius="60%" label
paddingAngle={5} cornerRadius={8}
paddingAngle={-8} — наложение
градиенты + свечение
startAngle={180} endAngle={0}
<PieChart {data} {config} dataKey="visitors" nameKey="browser" innerRadius="55%" paddingAngle={-8} cornerRadius={20} />
<PieChart {data} {config} dataKey="visitors" nameKey="browser" glowingSectors={['chrome', 'edge']} />
<PieChart {data} {config} dataKey="visitors" nameKey="browser" startAngle={180} endAngle={0} /><PieChart {data} {config} dataKey="visitors" nameKey="browser" innerRadius="55%" paddingAngle={-8} cornerRadius={20} />
<PieChart {data} {config} dataKey="visitors" nameKey="browser" glowingSectors={['chrome', 'edge']} />
<PieChart {data} {config} dataKey="visitors" nameKey="browser" startAngle={180} endAngle={0} />В markdown
[type]pie — круг, [type]donut — кольцо (старое ring тоже работает).
:::chart
[type]donut
[title]Источники трафика
| Источник | Доля |
|--------------|------|
| Органический | 42 |
| Реклама | 28 |
| Социальные | 18 |
| Прямые | 12 |
::::::chart
[type]donut
[title]Источники трафика
| Источник | Доля |
|--------------|------|
| Органический | 42 |
| Реклама | 28 |
| Социальные | 18 |
| Прямые | 12 |
:::Источники трафика
Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
data | ChartRow[] | — | Строка на сектор |
config | ChartConfig | — | Цвета и подписи по имени сектора |
dataKey | string | — | Ключ значения |
nameKey | string | — | Ключ имени сектора |
innerRadius | number string | 0 | Внутренний радиус (кольцо) |
outerRadius | number string | '80%' | Внешний радиус |
cornerRadius | number | 0 | Скругление секторов |
paddingAngle | number | 0 | Отступ между секторами в градусах |
startAngle / endAngle | number | 0 / 360 | Дуга диаграммы (0° — «3 часа», против часовой) |
isClickable | boolean | false | Выбор сектора кликом |
glowingSectors | string[] | [] | Имена светящихся секторов |
label | boolean PieLabelOptions | false | Подписи значений в секторах |
tooltip | boolean TooltipOptions | true | Тултип |
legend | boolean LegendOptions | false | Легенда (по умолчанию снизу по центру) |
backgroundVariant | BackgroundVariant | — | Декоративный фон |
defaultSelectedSector | string null | null | Выбранный сектор при старте |
onSelectionChange | (selection) => void | — | Смена выбранного сектора |
isLoading | boolean | false | Скелетон загрузки |
class | string | — | Классы контейнера |
