Графики и статистика chartsvgzero-dependencysankeyflowattributionjourney
Sankey Chart
Двухколоночная диаграмма потоков, где толщина ленты отражает объём перехода.
Живое превью
Acquisition channel → plan chosen
View data
| From | To | Value | Share |
|---|---|---|---|
| Organic | Free | 820 | 35.2% |
| Organic | Pro | 310 | 13.3% |
| Organic | Team | 90 | 3.9% |
| Paid ads | Free | 410 | 17.6% |
| Paid ads | Pro | 160 | 6.9% |
| Referral | Pro | 220 | 9.4% |
| Referral | Team | 140 | 6.0% |
| Partners | Team | 120 | 5.2% |
| Partners | Enterprise | 60 | 2.6% |
Использование
Скопируйте и вставьте этот код на вашу страницу Astro:
astro
---
import { SankeyChart } from 'astro-component-kit/charts';
---
<SankeyChart
flows={[
{ source: 'Organic', target: 'Free', value: 820 },
{ source: 'Organic', target: 'Pro', value: 310 },
{ source: 'Referral', target: 'Pro', value: 220 },
]}
/> ---
import { SankeyChart } from 'astro-component-kit/charts';
---
<SankeyChart
flows={[
{ source: 'Organic', target: 'Free', value: 820 },
{ source: 'Organic', target: 'Pro', value: 310 },
{ source: 'Referral', target: 'Pro', value: 220 },
]}
/> Ручная установка
Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/lib/SankeyChart.astro:
astro
---
/**
* SankeyChart — Two-column flow diagram: sources on the left, targets on the
* right, ribbons whose thickness encodes the flow volume.
*
* @param {Array<{source: string, target: string, value: number}>} flows - Links between nodes.
* @param {string} suffix - Value suffix.
* @param {number} height - SVG height in viewBox units (width is 640).
* @param {string[]} colors - Optional per-source colors.
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { colorAt, formatValue, sum } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
flows?: Array<{ source: string; target: string; value: number }>;
prefix?: string;
suffix?: string;
height?: number;
colors?: string[];
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'Where signups come from',
subtitle = 'Acquisition channel → plan chosen',
flows = [
{ source: 'Organic', target: 'Free', value: 820 },
{ source: 'Organic', target: 'Pro', value: 310 },
{ source: 'Organic', target: 'Team', value: 90 },
{ source: 'Paid ads', target: 'Free', value: 410 },
{ source: 'Paid ads', target: 'Pro', value: 160 },
{ source: 'Referral', target: 'Pro', value: 220 },
{ source: 'Referral', target: 'Team', value: 140 },
{ source: 'Partners', target: 'Team', value: 120 },
{ source: 'Partners', target: 'Enterprise', value: 60 },
],
prefix = '',
suffix = '',
height = 320,
colors,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = height;
const nodeW = 12;
const gap = 14;
const xL = 110;
const xR = W - 110 - nodeW;
const sources = [...new Set(flows.map((f) => f.source))];
const targets = [...new Set(flows.map((f) => f.target))];
const total = sum(flows.map((f) => f.value)) || 1;
const scaleFor = (count: number) => (H - gap * (count - 1)) / total;
const k = Math.min(scaleFor(sources.length), scaleFor(targets.length));
const layout = (names: string[], key: 'source' | 'target') => {
const used = names.reduce((a, nm) => a + sum(flows.filter((f) => f[key] === nm).map((f) => f.value)) * k, 0) + gap * (names.length - 1);
let y = (H - used) / 2;
return Object.fromEntries(names.map((nm) => {
const v = sum(flows.filter((f) => f[key] === nm).map((f) => f.value));
const node = { y, h: v * k, v, offset: 0 };
y += v * k + gap;
return [nm, node];
}));
};
const L = layout(sources, 'source');
const R = layout(targets, 'target');
const srcColor = Object.fromEntries(sources.map((s, i) => [s, colorAt(i, colors)]));
const links = flows.map((f) => {
const s = L[f.source];
const t = R[f.target];
const h = f.value * k;
const y0 = s.y + s.offset + h / 2;
const y1 = t.y + t.offset + h / 2;
s.offset += h;
t.offset += h;
const x0 = xL + nodeW;
const x1 = xR;
const mx = (x0 + x1) / 2;
return { ...f, h, d: `M${x0},${y0} C${mx},${y0} ${mx},${y1} ${x1},${y1}`, color: srcColor[f.source] };
});
const fmt = (v: number) => formatValue(v, prefix, suffix);
---
<ChartFrame
title={title}
subtitle={subtitle}
variant={variant}
size={size}
className={className}
table={{ headers: ['From', 'To', 'Value', 'Share'], rows: flows.map((f) => [f.source, f.target, fmt(f.value), `${((f.value / total) * 100).toFixed(1)}%`]) }}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: flow from ${sources.length} sources to ${targets.length} targets`}>
{links.map((l) => (
<path class="cf-mark cf-dim sk__link" d={l.d} fill="none" stroke={l.color} stroke-opacity="0.38" stroke-width={Math.max(1, l.h)}
tabindex="0" role="img" aria-label={`${l.source} to ${l.target}: ${fmt(l.value)}`}
data-tip-title={`${l.source} → ${l.target}`} data-tip={`${l.color}::${fmt(l.value)} · ${((l.value / total) * 100).toFixed(1)}%`} />
))}
{sources.map((s) => (
<g>
<rect x={xL} y={L[s].y} width={nodeW} height={L[s].h} rx="3" fill={srcColor[s]} />
<text class="cf-label cf-label--strong" x={xL - 8} y={L[s].y + L[s].h / 2} text-anchor="end">{s}</text>
<text class="cf-label" x={xL - 8} y={L[s].y + L[s].h / 2 + 14} text-anchor="end">{fmt(L[s].v)}</text>
</g>
))}
{targets.map((t) => (
<g>
<rect x={xR} y={R[t].y} width={nodeW} height={R[t].h} rx="3" fill="var(--c-text-2, #a3a3a3)" />
<text class="cf-label cf-label--strong" x={xR + nodeW + 8} y={R[t].y + R[t].h / 2}>{t}</text>
<text class="cf-label" x={xR + nodeW + 8} y={R[t].y + R[t].h / 2 + 14}>{fmt(R[t].v)}</text>
</g>
))}
</svg>
</ChartFrame>
<style>
.sk__link { transition: stroke-opacity 0.2s ease; }
.sk__link:hover, .sk__link:focus-visible { stroke-opacity: 0.7; }
</style> ---
/**
* SankeyChart — Two-column flow diagram: sources on the left, targets on the
* right, ribbons whose thickness encodes the flow volume.
*
* @param {Array<{source: string, target: string, value: number}>} flows - Links between nodes.
* @param {string} suffix - Value suffix.
* @param {number} height - SVG height in viewBox units (width is 640).
* @param {string[]} colors - Optional per-source colors.
* @param {string} variant - Card style (glass|solid|ghost).
* @param {string} size - Density (sm|md|lg).
*/
import ChartFrame from './ChartFrame.astro';
import { colorAt, formatValue, sum } from './chart-utils';
interface Props {
title?: string;
subtitle?: string;
flows?: Array<{ source: string; target: string; value: number }>;
prefix?: string;
suffix?: string;
height?: number;
colors?: string[];
variant?: 'glass' | 'solid' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const {
title = 'Where signups come from',
subtitle = 'Acquisition channel → plan chosen',
flows = [
{ source: 'Organic', target: 'Free', value: 820 },
{ source: 'Organic', target: 'Pro', value: 310 },
{ source: 'Organic', target: 'Team', value: 90 },
{ source: 'Paid ads', target: 'Free', value: 410 },
{ source: 'Paid ads', target: 'Pro', value: 160 },
{ source: 'Referral', target: 'Pro', value: 220 },
{ source: 'Referral', target: 'Team', value: 140 },
{ source: 'Partners', target: 'Team', value: 120 },
{ source: 'Partners', target: 'Enterprise', value: 60 },
],
prefix = '',
suffix = '',
height = 320,
colors,
variant = 'glass',
size = 'md',
className = '',
} = Astro.props;
const W = 640;
const H = height;
const nodeW = 12;
const gap = 14;
const xL = 110;
const xR = W - 110 - nodeW;
const sources = [...new Set(flows.map((f) => f.source))];
const targets = [...new Set(flows.map((f) => f.target))];
const total = sum(flows.map((f) => f.value)) || 1;
const scaleFor = (count: number) => (H - gap * (count - 1)) / total;
const k = Math.min(scaleFor(sources.length), scaleFor(targets.length));
const layout = (names: string[], key: 'source' | 'target') => {
const used = names.reduce((a, nm) => a + sum(flows.filter((f) => f[key] === nm).map((f) => f.value)) * k, 0) + gap * (names.length - 1);
let y = (H - used) / 2;
return Object.fromEntries(names.map((nm) => {
const v = sum(flows.filter((f) => f[key] === nm).map((f) => f.value));
const node = { y, h: v * k, v, offset: 0 };
y += v * k + gap;
return [nm, node];
}));
};
const L = layout(sources, 'source');
const R = layout(targets, 'target');
const srcColor = Object.fromEntries(sources.map((s, i) => [s, colorAt(i, colors)]));
const links = flows.map((f) => {
const s = L[f.source];
const t = R[f.target];
const h = f.value * k;
const y0 = s.y + s.offset + h / 2;
const y1 = t.y + t.offset + h / 2;
s.offset += h;
t.offset += h;
const x0 = xL + nodeW;
const x1 = xR;
const mx = (x0 + x1) / 2;
return { ...f, h, d: `M${x0},${y0} C${mx},${y0} ${mx},${y1} ${x1},${y1}`, color: srcColor[f.source] };
});
const fmt = (v: number) => formatValue(v, prefix, suffix);
---
<ChartFrame
title={title}
subtitle={subtitle}
variant={variant}
size={size}
className={className}
table={{ headers: ['From', 'To', 'Value', 'Share'], rows: flows.map((f) => [f.source, f.target, fmt(f.value), `${((f.value / total) * 100).toFixed(1)}%`]) }}
>
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label={`${title}: flow from ${sources.length} sources to ${targets.length} targets`}>
{links.map((l) => (
<path class="cf-mark cf-dim sk__link" d={l.d} fill="none" stroke={l.color} stroke-opacity="0.38" stroke-width={Math.max(1, l.h)}
tabindex="0" role="img" aria-label={`${l.source} to ${l.target}: ${fmt(l.value)}`}
data-tip-title={`${l.source} → ${l.target}`} data-tip={`${l.color}::${fmt(l.value)} · ${((l.value / total) * 100).toFixed(1)}%`} />
))}
{sources.map((s) => (
<g>
<rect x={xL} y={L[s].y} width={nodeW} height={L[s].h} rx="3" fill={srcColor[s]} />
<text class="cf-label cf-label--strong" x={xL - 8} y={L[s].y + L[s].h / 2} text-anchor="end">{s}</text>
<text class="cf-label" x={xL - 8} y={L[s].y + L[s].h / 2 + 14} text-anchor="end">{fmt(L[s].v)}</text>
</g>
))}
{targets.map((t) => (
<g>
<rect x={xR} y={R[t].y} width={nodeW} height={R[t].h} rx="3" fill="var(--c-text-2, #a3a3a3)" />
<text class="cf-label cf-label--strong" x={xR + nodeW + 8} y={R[t].y + R[t].h / 2}>{t}</text>
<text class="cf-label" x={xR + nodeW + 8} y={R[t].y + R[t].h / 2 + 14}>{fmt(R[t].v)}</text>
</g>
))}
</svg>
</ChartFrame>
<style>
.sk__link { transition: stroke-opacity 0.2s ease; }
.sk__link:hover, .sk__link:focus-visible { stroke-opacity: 0.7; }
</style>
Краткая информация
- Категория
- Графики и статистика
- Имя файла
SankeyChart.astro- Зависимости
- apexcharts (peer)
- Теги
- chartsvgzero-dependencysankeyflowattributionjourney