Графики и статистика chartsvgzero-dependencybarstackedcompositionhorizontal
Stacked Bar Chart
Вертикальные или горизонтальные накопительные столбцы с режимом 100% и зазором 2px между сегментами.
Живое превью
Per sprint
- Done
- In review
- Blocked
View data
| Category | Done | In review | Blocked | Total |
|---|---|---|---|---|
| S18 | 42 | 12 | 5 | 59 |
| S19 | 48 | 9 | 7 | 64 |
| S20 | 51 | 14 | 3 | 68 |
| S21 | 46 | 11 | 6 | 63 |
| S22 | 58 | 10 | 4 | 72 |
| S23 | 63 | 8 | 2 | 73 |
Использование
Скопируйте и вставьте этот код на вашу страницу Astro:
astro
---
import { StackedBarChart } from 'astro-component-kit/charts';
---
<StackedBarChart
categories={['S1', 'S2', 'S3']}
series={[
{ name: 'Done', data: [42, 48, 51] },
{ name: 'Blocked', data: [5, 7, 3] },
]}
horizontal={false}
normalized={false}
/> ---
import { StackedBarChart } from 'astro-component-kit/charts';
---
<StackedBarChart
categories={['S1', 'S2', 'S3']}
series={[
{ name: 'Done', data: [42, 48, 51] },
{ name: 'Blocked', data: [5, 7, 3] },
]}
horizontal={false}
normalized={false}
/> Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/lib/StackedBarChart.astro:
astro
---
/**
* StackedBarChart — Vertical or horizontal stacked bars, with an optional 100%
* mode. Segments are separated by a 2px surface gap instead of borders.
*
* @param {string[]} categories - Bar labels.
* @param {Array<{name: string, data: number[]}>} series - Stack layers (max 8).
* @param {boolean} horizontal - Draw bars horizontally.
* @param {boolean} normalized - Show each bar as a 100% share.
* @param {string} prefix - Value prefix.
* @param {string} suffix - Value suffix.
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { colorAt, scaleLinear, niceTicks, formatValue } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
categories?: string[];
series?: Array<{ name: string; data: number[] }>;
horizontal?: boolean;
normalized?: boolean;
prefix?: string;
suffix?: string;
height?: number;
colors?: string[];
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'Tickets by status',
subtitle = 'Per sprint',
categories = ['S18', 'S19', 'S20', 'S21', 'S22', 'S23'],
series = [
{ name: 'Done', data: [42, 48, 51, 46, 58, 63] },
{ name: 'In review', data: [12, 9, 14, 11, 10, 8] },
{ name: 'Blocked', data: [5, 7, 3, 6, 4, 2] },
],
horizontal = false,
normalized = false,
prefix = '',
suffix = '',
height = 280,
colors,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = horizontal ? Math.max(height, categories.length * 40 + 40) : height;
const m = horizontal ? { t: 8, r: 16, b: 24, l: 72 } : { t: 12, r: 12, b: 28, l: 44 };
const totals = categories.map((_, i) => series.reduce((a, s) => a + (s.data[i] ?? 0), 0));
const ticks = normalized ? [0, 25, 50, 75, 100] : niceTicks(0, Math.max(...totals), 4);
const maxT = ticks[ticks.length - 1];
const v = horizontal ? scaleLinear(0, maxT, m.l, W - m.r) : scaleLinear(0, maxT, H - m.b, m.t);
const band = horizontal ? (H - m.t - m.b) / categories.length : (W - m.l - m.r) / categories.length;
const thick = band * 0.6;
const GAP = 2;
const fmt = (n: number) => formatValue(n, prefix, suffix);
const fmtAxis = (n: number) => (normalized ? `${n}%` : fmt(n));
const bars = categories.map((c, ci) => {
let acc = 0;
const segs = series.slice(0, 8).map((s, si) => {
const raw = s.data[ci] ?? 0;
const val = normalized ? (raw / (totals[ci] || 1)) * 100 : raw;
const start = acc;
acc += val;
return { name: s.name, raw, val, start, end: acc, color: colorAt(si, colors) };
});
return { c, ci, segs };
});
---
<ChartFrame
title={title}
subtitle={subtitle}
variant={variant}
size={size}
className={className}
legend={series.map((s, i) => ({ label: s.name, color: colorAt(i, colors) }))}
table={{ headers: ['Category', ...series.map((s) => s.name), 'Total'], rows: categories.map((c, i) => [c, ...series.map((s) => fmt(s.data[i])), fmt(totals[i])]) }}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: ${normalized ? '100% ' : ''}stacked bar chart`}>
{ticks.map((t) => horizontal ? (
<g>
<line class={t === 0 ? 'cf-axis' : 'cf-grid'} x1={v(t)} x2={v(t)} y1={m.t} y2={H - m.b} />
<text class="cf-label" x={v(t)} y={H - 6} text-anchor="middle">{fmtAxis(t)}</text>
</g>
) : (
<g>
<line class={t === 0 ? 'cf-axis' : 'cf-grid'} x1={m.l} x2={W - m.r} y1={v(t)} y2={v(t)} />
<text class="cf-label" x={m.l - 8} y={v(t) + 4} text-anchor="end">{fmtAxis(t)}</text>
</g>
))}
{bars.map(({ c, ci, segs }) => {
const pos = (horizontal ? m.t : m.l) + ci * band + (band - thick) / 2;
return (
<g>
{horizontal
? <text class="cf-label" x={m.l - 8} y={pos + thick / 2 + 4} text-anchor="end">{c}</text>
: <text class="cf-label" x={pos + thick / 2} y={H - 8} text-anchor="middle">{c}</text>}
{segs.map((s, si) => {
const a = v(s.start);
const b = v(s.end);
const isLast = si === segs.length - 1;
const len = Math.abs(b - a) - (isLast ? 0 : GAP);
if (!(len > 0)) return null;
const rect = horizontal
? { x: a, y: pos, width: len, height: thick }
: { x: pos, y: b + (isLast ? 0 : GAP), width: thick, height: len };
return (
<rect
class="cf-mark cf-dim"
{...rect}
rx={isLast ? 3 : 0}
fill={s.color}
tabindex="0"
role="img"
aria-label={`${c}, ${s.name}: ${fmt(s.raw)}${normalized ? ` (${s.val.toFixed(0)}%)` : ''}`}
data-tip-title={c}
data-tip={`${s.color}::${s.name}: ${fmt(s.raw)}${normalized ? ` · ${s.val.toFixed(0)}%` : ''}`}
/>
);
})}
</g>
);
})}
</svg>
</ChartFrame> ---
/**
* StackedBarChart — Vertical or horizontal stacked bars, with an optional 100%
* mode. Segments are separated by a 2px surface gap instead of borders.
*
* @param {string[]} categories - Bar labels.
* @param {Array<{name: string, data: number[]}>} series - Stack layers (max 8).
* @param {boolean} horizontal - Draw bars horizontally.
* @param {boolean} normalized - Show each bar as a 100% share.
* @param {string} prefix - Value prefix.
* @param {string} suffix - Value suffix.
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { colorAt, scaleLinear, niceTicks, formatValue } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
categories?: string[];
series?: Array<{ name: string; data: number[] }>;
horizontal?: boolean;
normalized?: boolean;
prefix?: string;
suffix?: string;
height?: number;
colors?: string[];
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'Tickets by status',
subtitle = 'Per sprint',
categories = ['S18', 'S19', 'S20', 'S21', 'S22', 'S23'],
series = [
{ name: 'Done', data: [42, 48, 51, 46, 58, 63] },
{ name: 'In review', data: [12, 9, 14, 11, 10, 8] },
{ name: 'Blocked', data: [5, 7, 3, 6, 4, 2] },
],
horizontal = false,
normalized = false,
prefix = '',
suffix = '',
height = 280,
colors,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = horizontal ? Math.max(height, categories.length * 40 + 40) : height;
const m = horizontal ? { t: 8, r: 16, b: 24, l: 72 } : { t: 12, r: 12, b: 28, l: 44 };
const totals = categories.map((_, i) => series.reduce((a, s) => a + (s.data[i] ?? 0), 0));
const ticks = normalized ? [0, 25, 50, 75, 100] : niceTicks(0, Math.max(...totals), 4);
const maxT = ticks[ticks.length - 1];
const v = horizontal ? scaleLinear(0, maxT, m.l, W - m.r) : scaleLinear(0, maxT, H - m.b, m.t);
const band = horizontal ? (H - m.t - m.b) / categories.length : (W - m.l - m.r) / categories.length;
const thick = band * 0.6;
const GAP = 2;
const fmt = (n: number) => formatValue(n, prefix, suffix);
const fmtAxis = (n: number) => (normalized ? `${n}%` : fmt(n));
const bars = categories.map((c, ci) => {
let acc = 0;
const segs = series.slice(0, 8).map((s, si) => {
const raw = s.data[ci] ?? 0;
const val = normalized ? (raw / (totals[ci] || 1)) * 100 : raw;
const start = acc;
acc += val;
return { name: s.name, raw, val, start, end: acc, color: colorAt(si, colors) };
});
return { c, ci, segs };
});
---
<ChartFrame
title={title}
subtitle={subtitle}
variant={variant}
size={size}
className={className}
legend={series.map((s, i) => ({ label: s.name, color: colorAt(i, colors) }))}
table={{ headers: ['Category', ...series.map((s) => s.name), 'Total'], rows: categories.map((c, i) => [c, ...series.map((s) => fmt(s.data[i])), fmt(totals[i])]) }}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: ${normalized ? '100% ' : ''}stacked bar chart`}>
{ticks.map((t) => horizontal ? (
<g>
<line class={t === 0 ? 'cf-axis' : 'cf-grid'} x1={v(t)} x2={v(t)} y1={m.t} y2={H - m.b} />
<text class="cf-label" x={v(t)} y={H - 6} text-anchor="middle">{fmtAxis(t)}</text>
</g>
) : (
<g>
<line class={t === 0 ? 'cf-axis' : 'cf-grid'} x1={m.l} x2={W - m.r} y1={v(t)} y2={v(t)} />
<text class="cf-label" x={m.l - 8} y={v(t) + 4} text-anchor="end">{fmtAxis(t)}</text>
</g>
))}
{bars.map(({ c, ci, segs }) => {
const pos = (horizontal ? m.t : m.l) + ci * band + (band - thick) / 2;
return (
<g>
{horizontal
? <text class="cf-label" x={m.l - 8} y={pos + thick / 2 + 4} text-anchor="end">{c}</text>
: <text class="cf-label" x={pos + thick / 2} y={H - 8} text-anchor="middle">{c}</text>}
{segs.map((s, si) => {
const a = v(s.start);
const b = v(s.end);
const isLast = si === segs.length - 1;
const len = Math.abs(b - a) - (isLast ? 0 : GAP);
if (!(len > 0)) return null;
const rect = horizontal
? { x: a, y: pos, width: len, height: thick }
: { x: pos, y: b + (isLast ? 0 : GAP), width: thick, height: len };
return (
<rect
class="cf-mark cf-dim"
{...rect}
rx={isLast ? 3 : 0}
fill={s.color}
tabindex="0"
role="img"
aria-label={`${c}, ${s.name}: ${fmt(s.raw)}${normalized ? ` (${s.val.toFixed(0)}%)` : ''}`}
data-tip-title={c}
data-tip={`${s.color}::${s.name}: ${fmt(s.raw)}${normalized ? ` · ${s.val.toFixed(0)}%` : ''}`}
/>
);
})}
</g>
);
})}
</svg>
</ChartFrame>
Краткая информация
- Категория
- Графики и статистика
- Имя файла
StackedBarChart.astro- Зависимости
- apexcharts (peer)
- Теги
- chartsvgzero-dependencybarstackedcompositionhorizontal