SankeyDiagram 流向图
节点 + 加权边的流向图,节点高度 = 流量。
基础用法
数据通过 props 传入,纯 SVG 渲染,无第三方图表库依赖。
配色取自 --viz-1..8 token,色盲友好。
背景 视口
<script setup lang="ts">
import { CfSankeyDiagram } from '@chufix-design/vue';
const nodes = [
{ id: 'organic', name: 'Organic', layer: 0 },
{ id: 'paid', name: 'Paid', layer: 0 },
{ id: 'referral', name: 'Referral', layer: 0 },
{ id: 'web', name: 'Web', layer: 1 },
{ id: 'mobile', name: 'Mobile', layer: 1 },
{ id: 'success', name: '2xx', layer: 2 },
{ id: 'redirect', name: '3xx', layer: 2 },
{ id: 'error', name: '4xx/5xx', layer: 2 },
];
const links = [
{ source: 'organic', target: 'web', value: 50 },
{ source: 'organic', target: 'mobile', value: 30 },
{ source: 'paid', target: 'web', value: 20 },
{ source: 'paid', target: 'mobile', value: 25 },
{ source: 'referral', target: 'web', value: 10 },
{ source: 'referral', target: 'mobile', value: 5 },
{ source: 'web', target: 'success', value: 60 },
{ source: 'web', target: 'redirect', value: 12 },
{ source: 'web', target: 'error', value: 8 },
{ source: 'mobile', target: 'success', value: 45 },
{ source: 'mobile', target: 'redirect', value: 8 },
{ source: 'mobile', target: 'error', value: 7 },
];
</script>
<template>
<CfSankeyDiagram :nodes="nodes" :links="links" />
</template> <script setup>
import { CfSankeyDiagram } from '@chufix-design/vue';
const nodes = [
{ id: 'organic', name: 'Organic', layer: 0 },
{ id: 'paid', name: 'Paid', layer: 0 },
{ id: 'referral', name: 'Referral', layer: 0 },
{ id: 'web', name: 'Web', layer: 1 },
{ id: 'mobile', name: 'Mobile', layer: 1 },
{ id: 'success', name: '2xx', layer: 2 },
{ id: 'redirect', name: '3xx', layer: 2 },
{ id: 'error', name: '4xx/5xx', layer: 2 },
];
const links = [
{ source: 'organic', target: 'web', value: 50 },
{ source: 'organic', target: 'mobile', value: 30 },
{ source: 'paid', target: 'web', value: 20 },
{ source: 'paid', target: 'mobile', value: 25 },
{ source: 'referral', target: 'web', value: 10 },
{ source: 'referral', target: 'mobile', value: 5 },
{ source: 'web', target: 'success', value: 60 },
{ source: 'web', target: 'redirect', value: 12 },
{ source: 'web', target: 'error', value: 8 },
{ source: 'mobile', target: 'success', value: 45 },
{ source: 'mobile', target: 'redirect', value: 8 },
{ source: 'mobile', target: 'error', value: 7 },
];
</script>
<template>
<CfSankeyDiagram :nodes="nodes" :links="links" />
</template> import { CfSankeyDiagram } from '@chufix-design/react';
export default function Demo() {
const nodes = [
{ id: 'organic', name: 'Organic', layer: 0 },
{ id: 'paid', name: 'Paid', layer: 0 },
{ id: 'referral', name: 'Referral', layer: 0 },
{ id: 'web', name: 'Web', layer: 1 },
{ id: 'mobile', name: 'Mobile', layer: 1 },
{ id: 'success', name: '2xx', layer: 2 },
{ id: 'redirect', name: '3xx', layer: 2 },
{ id: 'error', name: '4xx/5xx', layer: 2 },
];
const links = [
{ source: 'organic', target: 'web', value: 50 },
{ source: 'organic', target: 'mobile', value: 30 },
{ source: 'paid', target: 'web', value: 20 },
{ source: 'paid', target: 'mobile', value: 25 },
{ source: 'referral', target: 'web', value: 10 },
{ source: 'referral', target: 'mobile', value: 5 },
{ source: 'web', target: 'success', value: 60 },
{ source: 'web', target: 'redirect', value: 12 },
{ source: 'web', target: 'error', value: 8 },
{ source: 'mobile', target: 'success', value: 45 },
{ source: 'mobile', target: 'redirect', value: 8 },
{ source: 'mobile', target: 'error', value: 7 },
];
return (
<>
<CfSankeyDiagram nodes={nodes} links={links} />
</>
);
} import { CfSankeyDiagram } from '@chufix-design/react';
export default function Demo() {
const nodes = [
{ id: 'organic', name: 'Organic', layer: 0 },
{ id: 'paid', name: 'Paid', layer: 0 },
{ id: 'referral', name: 'Referral', layer: 0 },
{ id: 'web', name: 'Web', layer: 1 },
{ id: 'mobile', name: 'Mobile', layer: 1 },
{ id: 'success', name: '2xx', layer: 2 },
{ id: 'redirect', name: '3xx', layer: 2 },
{ id: 'error', name: '4xx/5xx', layer: 2 },
];
const links = [
{ source: 'organic', target: 'web', value: 50 },
{ source: 'organic', target: 'mobile', value: 30 },
{ source: 'paid', target: 'web', value: 20 },
{ source: 'paid', target: 'mobile', value: 25 },
{ source: 'referral', target: 'web', value: 10 },
{ source: 'referral', target: 'mobile', value: 5 },
{ source: 'web', target: 'success', value: 60 },
{ source: 'web', target: 'redirect', value: 12 },
{ source: 'web', target: 'error', value: 8 },
{ source: 'mobile', target: 'success', value: 45 },
{ source: 'mobile', target: 'redirect', value: 8 },
{ source: 'mobile', target: 'error', value: 7 },
];
return (
<>
<CfSankeyDiagram nodes={nodes} links={links} />
</>
);
} 购物路径 3 层
搜索引擎 / 直接访问 → 产品列表 / 详情 → 加购 / 支付 三层流向。
背景 视口
<script setup lang="ts">
import { CfSankeyDiagram } from '@chufix-design/vue';
const nodes = [
{ id: 'a', name: '搜索引擎', layer: 0 },
{ id: 'b', name: '直接访问', layer: 0 },
{ id: 'c', name: '产品列表', layer: 1 },
{ id: 'd', name: '产品详情', layer: 1 },
{ id: 'e', name: '加入购物车', layer: 2 },
{ id: 'f', name: '完成支付', layer: 2 },
];
const links = [
{ source: 'a', target: 'c', value: 60 },
{ source: 'a', target: 'd', value: 25 },
{ source: 'b', target: 'c', value: 30 },
{ source: 'b', target: 'd', value: 40 },
{ source: 'c', target: 'e', value: 20 },
{ source: 'd', target: 'e', value: 18 },
{ source: 'e', target: 'f', value: 24 },
];
</script>
<template>
<CfSankeyDiagram :nodes="nodes" :links="links" :height="220" />
</template> <script setup>
import { CfSankeyDiagram } from '@chufix-design/vue';
const nodes = [
{ id: 'a', name: '搜索引擎', layer: 0 },
{ id: 'b', name: '直接访问', layer: 0 },
{ id: 'c', name: '产品列表', layer: 1 },
{ id: 'd', name: '产品详情', layer: 1 },
{ id: 'e', name: '加入购物车', layer: 2 },
{ id: 'f', name: '完成支付', layer: 2 },
];
const links = [
{ source: 'a', target: 'c', value: 60 },
{ source: 'a', target: 'd', value: 25 },
{ source: 'b', target: 'c', value: 30 },
{ source: 'b', target: 'd', value: 40 },
{ source: 'c', target: 'e', value: 20 },
{ source: 'd', target: 'e', value: 18 },
{ source: 'e', target: 'f', value: 24 },
];
</script>
<template>
<CfSankeyDiagram :nodes="nodes" :links="links" :height="220" />
</template> import { CfSankeyDiagram } from '@chufix-design/react';
export default function Demo() {
const nodes = [
{ id: 'a', name: '搜索引擎', layer: 0 },
{ id: 'b', name: '直接访问', layer: 0 },
{ id: 'c', name: '产品列表', layer: 1 },
{ id: 'd', name: '产品详情', layer: 1 },
{ id: 'e', name: '加入购物车', layer: 2 },
{ id: 'f', name: '完成支付', layer: 2 },
];
const links = [
{ source: 'a', target: 'c', value: 60 },
{ source: 'a', target: 'd', value: 25 },
{ source: 'b', target: 'c', value: 30 },
{ source: 'b', target: 'd', value: 40 },
{ source: 'c', target: 'e', value: 20 },
{ source: 'd', target: 'e', value: 18 },
{ source: 'e', target: 'f', value: 24 },
];
return (
<>
<CfSankeyDiagram nodes={nodes} links={links} height={220} />
</>
);
} import { CfSankeyDiagram } from '@chufix-design/react';
export default function Demo() {
const nodes = [
{ id: 'a', name: '搜索引擎', layer: 0 },
{ id: 'b', name: '直接访问', layer: 0 },
{ id: 'c', name: '产品列表', layer: 1 },
{ id: 'd', name: '产品详情', layer: 1 },
{ id: 'e', name: '加入购物车', layer: 2 },
{ id: 'f', name: '完成支付', layer: 2 },
];
const links = [
{ source: 'a', target: 'c', value: 60 },
{ source: 'a', target: 'd', value: 25 },
{ source: 'b', target: 'c', value: 30 },
{ source: 'b', target: 'd', value: 40 },
{ source: 'c', target: 'e', value: 20 },
{ source: 'd', target: 'e', value: 18 },
{ source: 'e', target: 'f', value: 24 },
];
return (
<>
<CfSankeyDiagram nodes={nodes} links={links} height={220} />
</>
);
} 节点拖拽 + 跨层迁移
draggable 默认 true。按住任意节点:
- 竖直拖: 落手后组件按 y 中心重排同层顺序,内部
orderOverrides记忆这个顺序。 - 横向拖到另一列: 节点跨层迁移到最近的 layer,源层 / 目标层都会自动收紧 — 不需要外部重算。
- 落手后清掉漂浮 offset,下次再拖从新的基础位置开始。
- 监听
@node-drag拿到完整 payload{ node, y, deltaY, layer, orderIndex, layerChanged },可以反写到外部数据源做持久化。
draggable={false} 退回只读视图。
背景 视口
竖直拖拽 → 自动按落点 y 重排同层顺序;横向拖到另一列附近 → 节点跨层迁移,源层 / 目标层同时收紧。
last drag 尚未拖拽
<script setup lang="ts">
import { ref } from 'vue';
import { CfSankeyDiagram, CfTag } from '@chufix-design/vue';
const nodes = [
{ id: 'src-organic', name: '自然搜索', layer: 0, colorIndex: 0 },
{ id: 'src-paid', name: '广告投放', layer: 0, colorIndex: 1 },
{ id: 'src-social', name: '社交分享', layer: 0, colorIndex: 2 },
{ id: 'lp-home', name: '首页', layer: 1, colorIndex: 3 },
{ id: 'lp-product', name: '产品页', layer: 1, colorIndex: 4 },
{ id: 'lp-blog', name: '博客', layer: 1, colorIndex: 5 },
{ id: 'cv-trial', name: '试用注册', layer: 2, colorIndex: 6 },
{ id: 'cv-purchase', name: '直接购买', layer: 2, colorIndex: 7 },
];
const links = [
{ source: 'src-organic', target: 'lp-home', value: 240 },
{ source: 'src-organic', target: 'lp-product', value: 180 },
{ source: 'src-organic', target: 'lp-blog', value: 80 },
{ source: 'src-paid', target: 'lp-home', value: 140 },
{ source: 'src-paid', target: 'lp-product', value: 220 },
{ source: 'src-social', target: 'lp-blog', value: 120 },
{ source: 'src-social', target: 'lp-home', value: 60 },
{ source: 'lp-home', target: 'cv-trial', value: 220 },
{ source: 'lp-home', target: 'cv-purchase', value: 60 },
{ source: 'lp-product', target: 'cv-trial', value: 160 },
{ source: 'lp-product', target: 'cv-purchase', value: 200 },
{ source: 'lp-blog', target: 'cv-trial', value: 90 },
];
const lastDrag = ref<string>('');
function onDrag(p: { node: { name: string }; deltaY: number; layer: number; orderIndex: number; layerChanged: boolean }) {
const dirY = p.deltaY > 0 ? '↓' : p.deltaY < 0 ? '↑' : '=';
const layerNote = p.layerChanged ? ` → 层 ${p.layer}` : '';
lastDrag.value = `${p.node.name} ${dirY}${Math.abs(Math.round(p.deltaY))}px · 位置 #${p.orderIndex + 1}${layerNote}`;
}
</script>
<template>
<p style="margin: 0 0 8px; color: var(--fg-3); font-size: 12px;">
竖直拖拽 → 自动按落点 y 重排同层顺序;横向拖到另一列附近 → 节点跨层迁移,源层 / 目标层同时收紧。
</p>
<CfSankeyDiagram
:nodes="nodes"
:links="links"
:width="640"
:height="320"
:node-width="14"
@node-drag="onDrag"
/>
<p style="margin-top: 8px; font-size: 12px;">
<CfTag tone="info" size="sm">last drag</CfTag>
{{ lastDrag || '尚未拖拽' }}
</p>
</template> <script setup>
import { ref } from 'vue';
import { CfSankeyDiagram, CfTag } from '@chufix-design/vue';
const nodes = [
{ id: 'src-organic', name: '自然搜索', layer: 0, colorIndex: 0 },
{ id: 'src-paid', name: '广告投放', layer: 0, colorIndex: 1 },
{ id: 'src-social', name: '社交分享', layer: 0, colorIndex: 2 },
{ id: 'lp-home', name: '首页', layer: 1, colorIndex: 3 },
{ id: 'lp-product', name: '产品页', layer: 1, colorIndex: 4 },
{ id: 'lp-blog', name: '博客', layer: 1, colorIndex: 5 },
{ id: 'cv-trial', name: '试用注册', layer: 2, colorIndex: 6 },
{ id: 'cv-purchase', name: '直接购买', layer: 2, colorIndex: 7 },
];
const links = [
{ source: 'src-organic', target: 'lp-home', value: 240 },
{ source: 'src-organic', target: 'lp-product', value: 180 },
{ source: 'src-organic', target: 'lp-blog', value: 80 },
{ source: 'src-paid', target: 'lp-home', value: 140 },
{ source: 'src-paid', target: 'lp-product', value: 220 },
{ source: 'src-social', target: 'lp-blog', value: 120 },
{ source: 'src-social', target: 'lp-home', value: 60 },
{ source: 'lp-home', target: 'cv-trial', value: 220 },
{ source: 'lp-home', target: 'cv-purchase', value: 60 },
{ source: 'lp-product', target: 'cv-trial', value: 160 },
{ source: 'lp-product', target: 'cv-purchase', value: 200 },
{ source: 'lp-blog', target: 'cv-trial', value: 90 },
];
const lastDrag = ref<string>('');
function onDrag(p: { node: { name: string }; deltaY: number; layer: number; orderIndex: number; layerChanged: boolean }) {
const dirY = p.deltaY > 0 ? '↓' : p.deltaY < 0 ? '↑' : '=';
const layerNote = p.layerChanged ? ` → 层 ${p.layer}` : '';
lastDrag.value = `${p.node.name} ${dirY}${Math.abs(Math.round(p.deltaY))}px · 位置 #${p.orderIndex + 1}${layerNote}`;
}
</script>
<template>
<p style="margin: 0 0 8px; color: var(--fg-3); font-size: 12px;">
竖直拖拽 → 自动按落点 y 重排同层顺序;横向拖到另一列附近 → 节点跨层迁移,源层 / 目标层同时收紧。
</p>
<CfSankeyDiagram
:nodes="nodes"
:links="links"
:width="640"
:height="320"
:node-width="14"
@node-drag="onDrag"
/>
<p style="margin-top: 8px; font-size: 12px;">
<CfTag tone="info" size="sm">last drag</CfTag>
{{ lastDrag || '尚未拖拽' }}
</p>
</template> import { useState } from 'react';
import { CfSankeyDiagram, CfTag } from '@chufix-design/react';
export default function Demo() {
const nodes = [
{ id: 'src-organic', name: '自然搜索', layer: 0, colorIndex: 0 },
{ id: 'src-paid', name: '广告投放', layer: 0, colorIndex: 1 },
{ id: 'src-social', name: '社交分享', layer: 0, colorIndex: 2 },
{ id: 'lp-home', name: '首页', layer: 1, colorIndex: 3 },
{ id: 'lp-product', name: '产品页', layer: 1, colorIndex: 4 },
{ id: 'lp-blog', name: '博客', layer: 1, colorIndex: 5 },
{ id: 'cv-trial', name: '试用注册', layer: 2, colorIndex: 6 },
{ id: 'cv-purchase', name: '直接购买', layer: 2, colorIndex: 7 },
];
const links = [
{ source: 'src-organic', target: 'lp-home', value: 240 },
{ source: 'src-organic', target: 'lp-product', value: 180 },
{ source: 'src-organic', target: 'lp-blog', value: 80 },
{ source: 'src-paid', target: 'lp-home', value: 140 },
{ source: 'src-paid', target: 'lp-product', value: 220 },
{ source: 'src-social', target: 'lp-blog', value: 120 },
{ source: 'src-social', target: 'lp-home', value: 60 },
{ source: 'lp-home', target: 'cv-trial', value: 220 },
{ source: 'lp-home', target: 'cv-purchase', value: 60 },
{ source: 'lp-product', target: 'cv-trial', value: 160 },
{ source: 'lp-product', target: 'cv-purchase', value: 200 },
{ source: 'lp-blog', target: 'cv-trial', value: 90 },
];
const [lastDrag, setLastDrag] = useState<string>('');
function onDrag(p: { node: { name: string }; deltaY: number; layer: number; orderIndex: number; layerChanged: boolean }) {
const dirY = p.deltaY > 0 ? '↓' : p.deltaY < 0 ? '↑' : '=';
const layerNote = p.layerChanged ? ` → 层 ${p.layer}` : '';
setLastDrag(`${p.node.name} ${dirY}${Math.abs(Math.round(p.deltaY))}px · 位置 #${p.orderIndex + 1}${layerNote}`);
}
return (
<>
<p style={{ margin: "0 0 8px", color: "var(--fg-3)", fontSize: 12 }}>
竖直拖拽 → 自动按落点 y 重排同层顺序;横向拖到另一列附近 → 节点跨层迁移,源层 / 目标层同时收紧。
</p>
<CfSankeyDiagram nodes={nodes} links={links} width={640} height={320} nodeWidth={14} onNodeDrag={onDrag} />
<p style={{ marginTop: 8, fontSize: 12 }}>
<CfTag tone="info" size="sm">last drag</CfTag>
{lastDrag || '尚未拖拽'}
</p>
</>
);
} import { useState } from 'react';
import { CfSankeyDiagram, CfTag } from '@chufix-design/react';
export default function Demo() {
const nodes = [
{ id: 'src-organic', name: '自然搜索', layer: 0, colorIndex: 0 },
{ id: 'src-paid', name: '广告投放', layer: 0, colorIndex: 1 },
{ id: 'src-social', name: '社交分享', layer: 0, colorIndex: 2 },
{ id: 'lp-home', name: '首页', layer: 1, colorIndex: 3 },
{ id: 'lp-product', name: '产品页', layer: 1, colorIndex: 4 },
{ id: 'lp-blog', name: '博客', layer: 1, colorIndex: 5 },
{ id: 'cv-trial', name: '试用注册', layer: 2, colorIndex: 6 },
{ id: 'cv-purchase', name: '直接购买', layer: 2, colorIndex: 7 },
];
const links = [
{ source: 'src-organic', target: 'lp-home', value: 240 },
{ source: 'src-organic', target: 'lp-product', value: 180 },
{ source: 'src-organic', target: 'lp-blog', value: 80 },
{ source: 'src-paid', target: 'lp-home', value: 140 },
{ source: 'src-paid', target: 'lp-product', value: 220 },
{ source: 'src-social', target: 'lp-blog', value: 120 },
{ source: 'src-social', target: 'lp-home', value: 60 },
{ source: 'lp-home', target: 'cv-trial', value: 220 },
{ source: 'lp-home', target: 'cv-purchase', value: 60 },
{ source: 'lp-product', target: 'cv-trial', value: 160 },
{ source: 'lp-product', target: 'cv-purchase', value: 200 },
{ source: 'lp-blog', target: 'cv-trial', value: 90 },
];
const [lastDrag, setLastDrag] = useState<string>('');
function onDrag(p: { node: { name: string }; deltaY: number; layer: number; orderIndex: number; layerChanged: boolean }) {
const dirY = p.deltaY > 0 ? '↓' : p.deltaY < 0 ? '↑' : '=';
const layerNote = p.layerChanged ? ` → 层 ${p.layer}` : '';
setLastDrag(`${p.node.name} ${dirY}${Math.abs(Math.round(p.deltaY))}px · 位置 #${p.orderIndex + 1}${layerNote}`);
}
return (
<>
<p style={{ margin: "0 0 8px", color: "var(--fg-3)", fontSize: 12 }}>
竖直拖拽 → 自动按落点 y 重排同层顺序;横向拖到另一列附近 → 节点跨层迁移,源层 / 目标层同时收紧。
</p>
<CfSankeyDiagram nodes={nodes} links={links} width={640} height={320} nodeWidth={14} onNodeDrag={onDrag} />
<p style={{ marginTop: 8, fontSize: 12 }}>
<CfTag tone="info" size="sm">last drag</CfTag>
{lastDrag || '尚未拖拽'}
</p>
</>
);
} API
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
nodes | SankeyNode[] | — | { id, name, layer?, colorIndex? }[] |
links | SankeyLink[] | — | { source, target, value }[] |
width | number | 480 | SVG 宽度 |
height | number | 280 | SVG 高度 |
nodeWidth | number | 12 | 节点条宽 |
draggable | boolean | true | 允许竖直拖拽节点重排顺序 |
ariaLabel | string | — | 透传给根 <svg> 的 aria-label |
Events
| Vue 事件 | React 回调 | 载荷类型 | 说明 |
|---|---|---|---|
node-enter | onNodeEnter | SankeyNodeInteractionPayload | 鼠标进入节点 |
node-leave | onNodeLeave | SankeyNodeInteractionPayload | 鼠标离开节点 |
link-enter | onLinkEnter | SankeyLinkInteractionPayload | 鼠标进入连接边 |
link-leave | onLinkLeave | SankeyLinkInteractionPayload | 鼠标离开连接边 |
node-drag | onNodeDrag | SankeyDragPayload | 拖拽落手后,载荷含 node / y / deltaY / layer / orderIndex / layerChanged |
类型
interface SankeyNodeInteractionPayload {
node: SankeyNode;
nodeIndex: number;
nativeEvent?: PointerEvent;
}
interface SankeyLinkInteractionPayload {
link: SankeyLink;
linkIndex: number;
nativeEvent?: PointerEvent;
}
反馈与讨论
SankeyDiagram 流向图 的讨论