Радиальная диаграмма
RadialChart рисует по кольцу на каждую строку: первая строка — внутреннее кольцо. Длина дуги пропорциональна значению; max фиксирует значение полного круга, иначе шкала подбирается автоматически.
Интерактивное демо
Пропсы
Форма
14
5
0
Дорожки
Градиенты
Загрузка
<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 { resolveOptions } from './cartesian';
import {
getNiceTicks,
resolvePosition,
resolveRadius,
sectorPath,
toNumber,
type ChartConfig,
type ChartRow,
toLabel
} from './chart-utils';
type Props = {
/** One bar per row. */
data: ChartRow[];
/** Bar colors + labels, keyed by each row's `nameKey` value. */
config: ChartConfig;
/** Key holding each bar's numeric value. */
dataKey: string;
/** Key holding each bar's name. */
nameKey: string;
class?: string;
/** `full` circle or `semi` (half) circle. */
variant?: 'full' | 'semi';
/** Pins the value that equals a complete arc (otherwise auto). */
max?: number;
innerRadius?: number | string;
outerRadius?: number | string;
cornerRadius?: number;
/** Thickness of each bar in px. */
barSize?: number;
/** Renders the unfilled track behind each bar. */
showBackground?: boolean;
isClickable?: boolean;
tooltip?: boolean | TooltipOptions;
legend?: boolean | LegendOptions;
backgroundVariant?: BackgroundVariant;
defaultSelectedDataKey?: string | null;
onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;
isLoading?: boolean;
ariaLabel?: string;
};
let {
data,
config,
dataKey,
nameKey,
class: className = '',
variant = 'full',
max,
innerRadius = '30%',
outerRadius = '100%',
cornerRadius = 5,
barSize = 14,
showBackground = true,
isClickable = false,
tooltip = true,
legend = false,
backgroundVariant,
defaultSelectedDataKey = null,
onSelectionChange,
isLoading = false,
ariaLabel = 'Radial chart'
}: Props = $props();
const LOADING_BARS = 5;
const LOADING_DURATION = 1500;
const MARGIN = 5;
const chartId = $props.id();
// svelte-ignore state_referenced_locally
let selectedBar = $state<string | null>(defaultSelectedDataKey);
let hovered = $state<{ index: number; x: number; y: number } | null>(null);
let loadingRows = $state<{ name: string; value: number }[]>([]);
$effect(() => {
if (!isLoading) return;
const generate = () =>
(loadingRows = Array.from({ length: LOADING_BARS }, (_, i) => ({
name: `loading${String(i)}`,
value: 40 + Math.random() * 60
})));
generate();
const interval = setInterval(generate, LOADING_DURATION);
return () => clearInterval(interval);
});
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 geometry = $derived(
variant === 'semi'
? { startAngle: 180, endAngle: 0, cx: '50%', cy: '70%' }
: { startAngle: 90, endAngle: -270, cx: '50%', cy: '50%' }
);
const rows = $derived(
isLoading
? loadingRows
: data.map((row) => ({ name: toLabel(row[nameKey]), value: toNumber(row[dataKey]) ?? 0 }))
);
const names = $derived(data.map((row) => toLabel(row[nameKey])));
const domainMax = $derived.by(() => {
if (isLoading) return 100;
if (max != null && max > 0) return max;
const ticks = getNiceTicks(0, Math.max(0, ...rows.map((row) => row.value)), 5);
return ticks[ticks.length - 1] || 1;
});
function selectBar(name: string | null) {
selectedBar = name;
if (name === null) {
onSelectionChange?.(null);
return;
}
onSelectionChange?.({ dataKey: name, value: rows.find((row) => row.name === name)?.value ?? 0 });
}
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 };
}
</script>
<ChartContainer id={chartId} {config} class={className} {isLoading}>
{#snippet header()}
{#if legendOpts && legendOpts.verticalAlign === 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedBar} onSelectChange={selectBar} options={legendOpts} />
{/if}
{/snippet}
{#snippet bottom()}
{#if legendOpts && legendOpts.verticalAlign !== 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedBar} onSelectChange={selectBar} options={legendOpts} />
{/if}
{/snippet}
{#snippet children({ width, height }: { width: number; height: number })}
{@const cx = MARGIN + resolvePosition(geometry.cx, width - MARGIN * 2)}
{@const cy = MARGIN + resolvePosition(geometry.cy, height - MARGIN * 2)}
{@const maxRadius = Math.min(width - MARGIN * 2, height - MARGIN * 2) / 2}
{@const ri = resolveRadius(innerRadius, maxRadius)}
{@const ro = resolveRadius(outerRadius, maxRadius)}
{@const band = rows.length ? (ro - ri) / rows.length : 0}
{@const thickness = Math.min(barSize, band)}
{@const sweep = geometry.endAngle - geometry.startAngle}
<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"
gradientId="{chartId}-radial-colors-{key}"
/>
{/each}
</defs>
{#if backgroundVariant}
<ChartBackground variant={backgroundVariant} />
{/if}
{#each rows as row, i (row.name)}
{@const inner = ri + band * i + (band - thickness) / 2}
{@const outer = inner + thickness}
{@const end = geometry.startAngle + sweep * Math.min(1, Math.max(0, row.value) / domainMax)}
{@const isDimmed = isClickable && selectedBar !== null && selectedBar !== row.name}
{#if showBackground || isLoading}
<path
d={sectorPath({
cx,
cy,
innerRadius: inner,
outerRadius: outer,
startAngle: geometry.startAngle,
endAngle: geometry.endAngle,
cornerRadius
})}
class="fill-background-muted"
/>
{/if}
{#if isLoading}
<path
class="chart-morph text-foreground"
d={sectorPath({
cx,
cy,
innerRadius: inner,
outerRadius: outer,
startAngle: geometry.startAngle,
endAngle: end,
cornerRadius
})}
fill="currentColor"
fill-opacity="0.25"
/>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
d={sectorPath({
cx,
cy,
innerRadius: inner,
outerRadius: outer,
startAngle: geometry.startAngle,
endAngle: end,
cornerRadius
})}
fill="url(#{chartId}-radial-colors-{row.name})"
opacity={isDimmed ? 0.15 : 1}
class="drop-shadow-sm transition-opacity duration-200"
style:cursor={isClickable ? 'pointer' : undefined}
onpointermove={(event) => (hovered = { index: i, ...pointerOf(event) })}
onclick={() => {
if (isClickable) selectBar(selectedBar === row.name ? null : row.name);
}}
/>
{/if}
{/each}
</svg>
{#if tooltipOpts && !isLoading}
{@const defaultIndex = tooltipOpts.defaultIndex}
{@const shown =
hovered ??
(defaultIndex !== undefined && rows[defaultIndex]
? { index: defaultIndex, x: cx, y: cy }
: null)}
{#if shown}
{@const row = rows[shown.index]}
<ChartTooltip
x={shown.x}
y={shown.y}
boundsWidth={width}
boundsHeight={height}
hideLabel
options={tooltipOpts}
items={[
{
key: row.name,
name: config[row.name]?.label ?? row.name,
value: data[shown.index]?.[dataKey],
item: config[row.name]
}
]}
/>
{/if}
{/if}
{/snippet}
</ChartContainer>
<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 { resolveOptions } from './cartesian';
import {
getNiceTicks,
resolvePosition,
resolveRadius,
sectorPath,
toNumber,
type ChartConfig,
type ChartRow,
toLabel
} from './chart-utils';
type Props = {
/** One bar per row. */
data: ChartRow[];
/** Bar colors + labels, keyed by each row's `nameKey` value. */
config: ChartConfig;
/** Key holding each bar's numeric value. */
dataKey: string;
/** Key holding each bar's name. */
nameKey: string;
class?: string;
/** `full` circle or `semi` (half) circle. */
variant?: 'full' | 'semi';
/** Pins the value that equals a complete arc (otherwise auto). */
max?: number;
innerRadius?: number | string;
outerRadius?: number | string;
cornerRadius?: number;
/** Thickness of each bar in px. */
barSize?: number;
/** Renders the unfilled track behind each bar. */
showBackground?: boolean;
isClickable?: boolean;
tooltip?: boolean | TooltipOptions;
legend?: boolean | LegendOptions;
backgroundVariant?: BackgroundVariant;
defaultSelectedDataKey?: string | null;
onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;
isLoading?: boolean;
ariaLabel?: string;
};
let {
data,
config,
dataKey,
nameKey,
class: className = '',
variant = 'full',
max,
innerRadius = '30%',
outerRadius = '100%',
cornerRadius = 5,
barSize = 14,
showBackground = true,
isClickable = false,
tooltip = true,
legend = false,
backgroundVariant,
defaultSelectedDataKey = null,
onSelectionChange,
isLoading = false,
ariaLabel = 'Radial chart'
}: Props = $props();
const LOADING_BARS = 5;
const LOADING_DURATION = 1500;
const MARGIN = 5;
const chartId = $props.id();
// svelte-ignore state_referenced_locally
let selectedBar = $state<string | null>(defaultSelectedDataKey);
let hovered = $state<{ index: number; x: number; y: number } | null>(null);
let loadingRows = $state<{ name: string; value: number }[]>([]);
$effect(() => {
if (!isLoading) return;
const generate = () =>
(loadingRows = Array.from({ length: LOADING_BARS }, (_, i) => ({
name: `loading${String(i)}`,
value: 40 + Math.random() * 60
})));
generate();
const interval = setInterval(generate, LOADING_DURATION);
return () => clearInterval(interval);
});
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 geometry = $derived(
variant === 'semi'
? { startAngle: 180, endAngle: 0, cx: '50%', cy: '70%' }
: { startAngle: 90, endAngle: -270, cx: '50%', cy: '50%' }
);
const rows = $derived(
isLoading
? loadingRows
: data.map((row) => ({ name: toLabel(row[nameKey]), value: toNumber(row[dataKey]) ?? 0 }))
);
const names = $derived(data.map((row) => toLabel(row[nameKey])));
const domainMax = $derived.by(() => {
if (isLoading) return 100;
if (max != null && max > 0) return max;
const ticks = getNiceTicks(0, Math.max(0, ...rows.map((row) => row.value)), 5);
return ticks[ticks.length - 1] || 1;
});
function selectBar(name: string | null) {
selectedBar = name;
if (name === null) {
onSelectionChange?.(null);
return;
}
onSelectionChange?.({ dataKey: name, value: rows.find((row) => row.name === name)?.value ?? 0 });
}
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 };
}
</script>
<ChartContainer id={chartId} {config} class={className} {isLoading}>
{#snippet header()}
{#if legendOpts && legendOpts.verticalAlign === 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedBar} onSelectChange={selectBar} options={legendOpts} />
{/if}
{/snippet}
{#snippet bottom()}
{#if legendOpts && legendOpts.verticalAlign !== 'top' && !isLoading}
<ChartLegend {config} keys={names} selected={selectedBar} onSelectChange={selectBar} options={legendOpts} />
{/if}
{/snippet}
{#snippet children({ width, height }: { width: number; height: number })}
{@const cx = MARGIN + resolvePosition(geometry.cx, width - MARGIN * 2)}
{@const cy = MARGIN + resolvePosition(geometry.cy, height - MARGIN * 2)}
{@const maxRadius = Math.min(width - MARGIN * 2, height - MARGIN * 2) / 2}
{@const ri = resolveRadius(innerRadius, maxRadius)}
{@const ro = resolveRadius(outerRadius, maxRadius)}
{@const band = rows.length ? (ro - ri) / rows.length : 0}
{@const thickness = Math.min(barSize, band)}
{@const sweep = geometry.endAngle - geometry.startAngle}
<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"
gradientId="{chartId}-radial-colors-{key}"
/>
{/each}
</defs>
{#if backgroundVariant}
<ChartBackground variant={backgroundVariant} />
{/if}
{#each rows as row, i (row.name)}
{@const inner = ri + band * i + (band - thickness) / 2}
{@const outer = inner + thickness}
{@const end = geometry.startAngle + sweep * Math.min(1, Math.max(0, row.value) / domainMax)}
{@const isDimmed = isClickable && selectedBar !== null && selectedBar !== row.name}
{#if showBackground || isLoading}
<path
d={sectorPath({
cx,
cy,
innerRadius: inner,
outerRadius: outer,
startAngle: geometry.startAngle,
endAngle: geometry.endAngle,
cornerRadius
})}
class="fill-background-muted"
/>
{/if}
{#if isLoading}
<path
class="chart-morph text-foreground"
d={sectorPath({
cx,
cy,
innerRadius: inner,
outerRadius: outer,
startAngle: geometry.startAngle,
endAngle: end,
cornerRadius
})}
fill="currentColor"
fill-opacity="0.25"
/>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<path
d={sectorPath({
cx,
cy,
innerRadius: inner,
outerRadius: outer,
startAngle: geometry.startAngle,
endAngle: end,
cornerRadius
})}
fill="url(#{chartId}-radial-colors-{row.name})"
opacity={isDimmed ? 0.15 : 1}
class="drop-shadow-sm transition-opacity duration-200"
style:cursor={isClickable ? 'pointer' : undefined}
onpointermove={(event) => (hovered = { index: i, ...pointerOf(event) })}
onclick={() => {
if (isClickable) selectBar(selectedBar === row.name ? null : row.name);
}}
/>
{/if}
{/each}
</svg>
{#if tooltipOpts && !isLoading}
{@const defaultIndex = tooltipOpts.defaultIndex}
{@const shown =
hovered ??
(defaultIndex !== undefined && rows[defaultIndex]
? { index: defaultIndex, x: cx, y: cy }
: null)}
{#if shown}
{@const row = rows[shown.index]}
<ChartTooltip
x={shown.x}
y={shown.y}
boundsWidth={width}
boundsHeight={height}
hideLabel
options={tooltipOpts}
items={[
{
key: row.name,
name: config[row.name]?.label ?? row.name,
value: data[shown.index]?.[dataKey],
item: config[row.name]
}
]}
/>
{/if}
{/if}
{/snippet}
</ChartContainer>
Использование
<script lang="ts">
import RadialChart from '$lib/components/charts/RadialChart.svelte';
</script>
<RadialChart {data} {config} dataKey="visitors" nameKey="browser" isClickable legend={{ isClickable: true }} /><script lang="ts">
import RadialChart from '$lib/components/charts/RadialChart.svelte';
</script>
<RadialChart {data} {config} dataKey="visitors" nameKey="browser" isClickable legend={{ isClickable: true }} />Примеры
variant="semi"
Chrome
Safari
Firefox
Edge
Другие
градиенты, max={300}
<RadialChart {data} {config} dataKey="visitors" nameKey="browser" variant="semi" legend />
<RadialChart {data} {config} dataKey="visitors" nameKey="browser" max={300} barSize={10} /><RadialChart {data} {config} dataKey="visitors" nameKey="browser" variant="semi" legend />
<RadialChart {data} {config} dataKey="visitors" nameKey="browser" max={300} barSize={10} />В markdown
:::chart
[type]radial
[title]Выполнение плана
[max]100
| Команда | Процент |
|-----------|---------|
| Backend | 82 |
| Frontend | 64 |
| Mobile | 47 |
::::::chart
[type]radial
[title]Выполнение плана
[max]100
| Команда | Процент |
|-----------|---------|
| Backend | 82 |
| Frontend | 64 |
| Mobile | 47 |
:::Выполнение плана
Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
data | ChartRow[] | — | Строка на кольцо |
config | ChartConfig | — | Цвета и подписи по имени |
dataKey | string | — | Ключ значения |
nameKey | string | — | Ключ имени |
variant | 'full' 'semi' | 'full' | Полный круг или полукруг |
max | number | авто | Значение полного круга |
innerRadius / outerRadius | number string | '30%' / '100%' | Радиусы |
cornerRadius | number | 5 | Скругление концов |
barSize | number | 14 | Толщина кольца |
showBackground | boolean | true | Дорожка под кольцом |
isClickable | boolean | false | Выбор кольца кликом |
tooltip | boolean TooltipOptions | true | Тултип |
legend | boolean LegendOptions | false | Легенда (снизу по центру) |
backgroundVariant | BackgroundVariant | — | Декоративный фон |
onSelectionChange | (selection) => void | — | Смена выбранного кольца |
isLoading | boolean | false | Скелетон загрузки |
class | string | — | Классы контейнера |
