"use client"
import {
Bot,
Database,
Globe,
ShieldCheck,
Smartphone,
Sparkles,
Terminal,
Wrench,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
FlowBeam,
FlowDiagram,
FlowNode,
FlowSpinner,
FlowStatusDot,
flowArrival,
flowRoute,
type FlowPoint,
} from "@/components/ui/flow-diagram/flow-diagram"
// 色调表达角色:三个入口蓝、Agent 紫、三类能力各一种颜色、返回的结果绿
const CLIENTS = [
{ y: 46, label: "Web", icon: Globe },
{ y: 115, label: "App", icon: Smartphone },
{ y: 184, label: "CLI", icon: Terminal },
] as const
const TOOLS = [
{ y: 44, label: "知识库", description: "向量检索", icon: Database, tone: "cyan", work: 0.5 },
{ y: 115, label: "工具", description: "MCP 调用", icon: Wrench, tone: "amber", work: 0.9 },
{ y: 186, label: "大模型", description: "流式推理", icon: Sparkles, tone: "rose", work: 1.4 },
] as const
const reversed = (points: readonly FlowPoint[]) => [...points].reverse()
// 入口 → 网关:上下两个先横向再汇到中线,中间的直接进网关
const CLIENT_POINTS = CLIENTS.map(({ y }) =>
y === 115
? ([
[62, 115],
[85, 115],
] as const)
: ([
[62, y],
[74, y],
[74, 115],
[85, 115],
] as const),
)
const REQUESTS = CLIENT_POINTS.map((points) => flowRoute(points))
const RESPONSES = CLIENT_POINTS.map((points) => flowRoute(reversed(points)))
const TO_AGENT = flowRoute([
[131, 115],
[150, 115],
])
const BACK_TO_GATEWAY = flowRoute([
[150, 115],
[131, 115],
])
// Agent → 三类能力:中间直连,上下两路先贴着 Agent 出来再分开
const FAN_OUT_POINTS = TOOLS.map(({ y }) =>
y === 115
? ([
[222, 115],
[270, 115],
] as const)
: ([
[222, y === 44 ? 103 : 127],
[246, y === 44 ? 103 : 127],
[246, y],
[270, y],
] as const),
)
const FAN_OUT = FAN_OUT_POINTS.map((points) => flowRoute(points))
const FAN_IN = FAN_OUT_POINTS.map((points) => flowRoute(reversed(points)))
// 时间轴(秒):三个入口错开发出请求 → 网关转给 Agent → Agent 同时调度三类能力,
// 它们耗时不同、各自返回 → 汇总后沿原路把结果送回每个入口
const REQUEST_AT = [0.3, 0.7, 1.1]
const FORWARD_AT = REQUESTS.map(
(route, i) => flowArrival(REQUEST_AT[i], route) + 0.1,
)
const GATEWAY_AT = Math.min(
...REQUESTS.map((route, i) => flowArrival(REQUEST_AT[i], route)),
)
const FIRST = FORWARD_AT.indexOf(Math.min(...FORWARD_AT))
const AGENT_AT = flowArrival(FORWARD_AT[FIRST], TO_AGENT)
const DISPATCH_AT = AGENT_AT + 0.2
const ARRIVE_AT = FAN_OUT.map((route) => flowArrival(DISPATCH_AT, route))
const DONE_AT = TOOLS.map(({ work }, i) => ARRIVE_AT[i] + work)
const MERGED_AT = Math.max(
...FAN_IN.map((route, i) => flowArrival(DONE_AT[i], route)),
)
const REPLY_AT = MERGED_AT + 0.2
const GATEWAY_BACK_AT = flowArrival(REPLY_AT, BACK_TO_GATEWAY) + 0.05
const CLIENT_BACK_AT = CLIENTS.map((_, i) => GATEWAY_BACK_AT + i * 0.12)
const DELIVERED_AT = RESPONSES.map((route, i) =>
flowArrival(CLIENT_BACK_AT[i], route),
)
const REST = Math.max(...DELIVERED_AT) + 0.8
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/**
* AI 应用全链路:多端请求经网关进入 Agent,Agent 并行调度知识库、工具与大模型,
* 三路结果汇总后原路返回。专为封面和缩略图设计:多色、多路并行、静帧也完整。
*/
export default function FlowDiagramShowcaseDemo({
className,
speed,
}: {
className?: string
speed?: number
}) {
return (
<FlowDiagram
background="grid"
width={360}
height={230}
fit="contain"
duration={DURATION}
exit={EXIT}
rest={REST}
speed={speed}
className={cn(
"aspect-[360/230] w-full max-w-xl p-3 max-sm:-mx-3 max-sm:w-[calc(100%+1.5rem)] max-sm:max-w-none max-sm:p-0",
className,
)}
>
{/* 请求:三个入口错开出发,进网关前汇成一路 */}
{REQUESTS.map((route, i) => (
<FlowBeam key={`request-${i}`} route={route} at={REQUEST_AT[i]} tone="blue" />
))}
{/* 网关 → Agent:共用一条线,只有最早的一束带底线 */}
{FORWARD_AT.map((at, i) => (
<FlowBeam
key={`forward-${i}`}
route={TO_AGENT}
at={at}
base={i === FIRST}
tone="violet"
/>
))}
{/* Agent 到各能力的线是常驻连接,画好后持续流动 */}
{FAN_OUT.map((route, i) => (
<FlowBeam
key={`dispatch-${i}`}
route={route}
at={DISPATCH_AT}
tone={TOOLS[i].tone}
flow
/>
))}
{FAN_IN.map((route, i) => (
<FlowBeam
key={`result-${i}`}
route={route}
at={DONE_AT[i]}
tone={TOOLS[i].tone}
base={false}
/>
))}
<FlowBeam route={BACK_TO_GATEWAY} at={REPLY_AT} tone="green" base={false} />
{RESPONSES.map((route, i) => (
<FlowBeam
key={`response-${i}`}
route={route}
at={CLIENT_BACK_AT[i]}
tone="green"
base={false}
/>
))}
{CLIENTS.map(({ y, label, icon }, i) => (
<FlowNode
key={label}
x={34}
y={y}
width={56}
icon={icon}
label={label}
tone="blue"
litAt={REQUEST_AT[i] - 0.1}
>
{/* 结果送达后亮起绿色状态点 */}
<FlowStatusDot x={57} y={y} at={DELIVERED_AT[i]} tone="green" />
</FlowNode>
))}
<FlowNode
x={108}
y={115}
width={46}
icon={ShieldCheck}
label="网关"
litAt={GATEWAY_AT}
/>
<FlowNode
x={186}
y={115}
width={72}
height={40}
shape="box"
icon={Bot}
label="Agent"
description="编排"
tone="violet"
litAt={AGENT_AT}
>
{/* 等三路结果期间转圈,汇总完成后换成绿色状态点 */}
<FlowSpinner x={215} y={103} size={9} at={AGENT_AT} until={MERGED_AT} />
<FlowStatusDot x={215} y={103} at={MERGED_AT} tone="green" />
</FlowNode>
{TOOLS.map(({ y, label, description, icon, tone }, i) => (
<FlowNode
key={label}
x={312}
y={y}
width={84}
height={32}
shape="box"
icon={icon}
label={label}
description={description}
tone={tone}
litAt={ARRIVE_AT[i]}
>
<FlowSpinner x={343} y={y - 6} size={9} at={ARRIVE_AT[i]} until={DONE_AT[i]} />
<FlowStatusDot x={343} y={y - 6} at={DONE_AT[i]} tone="green" />
</FlowNode>
))}
</FlowDiagram>
)
}
上图是一条 AI 应用的全链路:Web、App、CLI 三个入口错开发出请求,经网关进入 Agent;Agent 并行调度知识库、工具与大模型,三路耗时各异;结果汇总后沿原路送回每个入口。入口蓝、Agent 紫、三类能力各一种颜色、返回的结果绿,用到了图标节点、点亮、加载圈与状态点、常驻连线的流动纹理和方格底纹。
提炼自本站首页「我在做什么」里的三幅小插画。它不是一个“传数据就出图”的图表组件,而是一组 SVG 原语:你按设计坐标摆节点、连线、写标签,再给每一步指定出现的时刻,整张图就会按这条时间轴循环播放。
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/flow-diagram.json用法
一张图由 FlowDiagram 外框和里面的原语组成。所有坐标都落在 width × height 的设计画布上,显示时整体等比缩小,所以 FlowDiagram 的宽度交给 className 控制即可。
import {
FlowBeam,
FlowDiagram,
FlowNode,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
// 路径在组件外创建:长度要提前算出,光点的到达时刻靠它换算
const TO_PROCESS = flowRoute([[80, 50], [130, 50]])
const PROCESS_AT = flowArrival(0.5, TO_PROCESS)
export function Pipeline() {
return (
<FlowDiagram width={320} height={100} duration={4.4} exit={3.6} rest={3.4} className="w-full max-w-md">
<FlowBeam route={TO_PROCESS} at={0.5} />
<FlowNode x={50} y={50} width={60} litAt={0.2} label="输入" />
<FlowNode x={160} y={50} width={60} litAt={PROCESS_AT} label="处理" />
</FlowDiagram>
)
}最小的例子渲染出来是下面这样,三个节点依次点亮:
import {
FlowBeam,
FlowDiagram,
FlowNode,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
// 时间轴(秒):A 点亮 → 光点飞向 B → B 点亮 → 光点飞向 C → C 点亮
const TO_PROCESS = flowRoute([
[80, 50],
[130, 50],
])
const TO_OUTPUT = flowRoute([
[190, 50],
[240, 50],
])
const FIRST_AT = 0.5
const PROCESS_AT = flowArrival(FIRST_AT, TO_PROCESS)
const SECOND_AT = PROCESS_AT + 0.2
const OUTPUT_AT = flowArrival(SECOND_AT, TO_OUTPUT)
// 静帧停在所有节点都点亮之后;整轮 4.4 秒,3.6 秒起淡出
const DURATION = 4.4
const EXIT = 3.6
const REST = 3.4
export default function FlowDiagramDemo() {
return (
<FlowDiagram
background="grid"
width={320}
height={100}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
<FlowBeam route={TO_PROCESS} at={FIRST_AT} />
<FlowBeam route={TO_OUTPUT} at={SECOND_AT} />
<FlowNode x={50} y={50} width={60} litAt={0.2} label="输入" />
<FlowNode x={160} y={50} width={60} litAt={PROCESS_AT} label="处理" />
<FlowNode x={270} y={50} width={60} litAt={OUTPUT_AT} label="输出" />
</FlowDiagram>
)
}
约定很少:
- 节点是容器。
FlowNode画边框和点亮效果;简单的“图标 + 文字”直接用icon、label、description,它们会整体居中。复杂内容(加载圈、状态点、进度条……)作为子元素按绝对坐标摆放,通常用FlowLabel或 lucide 图标(SVG 里直接给x、y、width、height即可)。 - 点亮时刻来自光点。 光点出发的时刻
at加上路径长度除以速度,就是它抵达的时刻,flowArrival(at, route)帮你算好,直接传给目标节点的litAt。 - 连线默认跟着光点一路长出来。
FlowBeam的底线从起点生长到终点,光点走到哪线画到哪,到达时节点点亮;常驻的数据流还可以再加flow,详见「连线动态」。 - 颜色来自主题变量。 默认点亮用
primary,其他色调用tone指定,亮暗模式和颜色主题切换后自动跟随,详见「色调」。
示例
每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整速度、默认色调与底纹,并开关底纹渐隐、循环与暂停;未改动的项显示示例自己的取值。色调只改默认色调,节点和光束自己指定的 tone 不受影响;关闭「循环」后只播一遍并停在静帧,再打开即从头重播。调整时外框与设计画布大小不变。
下面每个案例都是完整的源码,点「代码」标签可以看到写法。
全栈开发
需求分叉成前端和后端两条分支,汇合后敲出 pnpm build,再流向代码和上线。用到了分叉 / 汇合点 FlowDiamond 和逐字输入 FlowTypedText。前端蓝、后端紫、构建与代码琥珀、上线绿,光点和连线跟随各自分支的色调,节点带 lucide 图标。
"use client"
import {
Check,
ClipboardList,
Code,
Monitor,
Rocket,
Server,
Terminal,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
FlowBeam,
FlowDiagram,
FlowDiamond,
FlowLabel,
FlowNode,
FlowReveal,
FlowTypedText,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
// 时间轴(秒):按「需求 → 分叉 → 汇合 → 命令 → 代码 → 上线」依次推进
const DURATION = 9
const EXIT = 8.2
const REST = 8
const TITLE = "需求:任务看板"
const TITLE_AT = 0.3
const TITLE_INTERVAL = 0.12
const COMMAND = "pnpm build"
const COMMAND_INTERVAL = 0.1
// 色调表达分工:前端蓝、后端紫、构建与产物琥珀、上线绿;需求与分叉点保持中性
const TO_SPLIT = flowRoute([
[108, 34],
[108, 41],
])
const TO_BRANCHES = [
flowRoute([
[102, 47],
[56, 47],
[56, 61],
]),
flowRoute([
[114, 47],
[160, 47],
[160, 61],
]),
]
const TO_MERGE = [
flowRoute([
[56, 81],
[56, 95],
[102, 95],
]),
flowRoute([
[160, 81],
[160, 95],
[114, 95],
]),
]
const TO_INPUT = flowRoute([
[108, 101],
[108, 109],
])
const TO_CODE = flowRoute([
[108, 133],
[108, 149],
[56, 149],
[56, 159],
])
const TO_RESULT = flowRoute([
[92, 169],
[124, 169],
])
const SPLIT_AT = TITLE_AT + 7 * TITLE_INTERVAL + 0.3
const BRANCH_AT = SPLIT_AT + 0.3
const MERGE_AT = flowArrival(BRANCH_AT, TO_BRANCHES[0]) + 0.4
const INPUT_AT = flowArrival(MERGE_AT, TO_MERGE[0]) + 0.2
const COMMAND_AT = flowArrival(INPUT_AT, TO_INPUT) + 0.1
const CODE_AT = COMMAND_AT + COMMAND.length * COMMAND_INTERVAL + 0.3
const RESULT_BEAM_AT = flowArrival(CODE_AT, TO_CODE) + 0.1
const RESULT_AT = flowArrival(RESULT_BEAM_AT, TO_RESULT)
const BRANCHES = [
{ x: 56, label: "前端 UI", icon: Monitor, tone: "blue" },
{ x: 160, label: "后端 API", icon: Server, tone: "violet" },
] as const
/** 全栈开发:需求分出前后端两条分支,汇合后敲命令,再流向代码和上线。 */
function FlowDiagramFullstackDemo({ className }: { className?: string }) {
return (
<FlowDiagram
className={cn("h-56 w-full max-w-md", className)}
duration={DURATION}
exit={EXIT}
rest={REST}
>
<FlowBeam route={TO_SPLIT} at={SPLIT_AT} />
{TO_BRANCHES.map((path, i) => (
<FlowBeam
key={`branch-${i}`}
route={path}
at={BRANCH_AT}
tone={BRANCHES[i].tone}
/>
))}
{TO_MERGE.map((path, i) => (
<FlowBeam
key={`merge-${i}`}
route={path}
at={MERGE_AT}
tone={BRANCHES[i].tone}
/>
))}
<FlowBeam route={TO_INPUT} at={INPUT_AT} tone="amber" />
<FlowBeam route={TO_CODE} at={CODE_AT} tone="amber" />
<FlowBeam route={TO_RESULT} at={RESULT_BEAM_AT} tone="green" />
<FlowNode x={108} y={23} width={148} height={22}>
<ClipboardList
x={42}
y={17}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-primary"
/>
<FlowTypedText
text={TITLE}
at={TITLE_AT}
interval={TITLE_INTERVAL}
x={60}
y={23}
/>
</FlowNode>
<FlowDiamond x={108} y={47} litAt={flowArrival(SPLIT_AT, TO_SPLIT)} />
{BRANCHES.map(({ x, label, icon, tone }, i) => (
<FlowNode
key={label}
x={x}
y={71}
width={76}
icon={icon}
label={label}
tone={tone}
litAt={flowArrival(BRANCH_AT, TO_BRANCHES[i])}
/>
))}
<FlowDiamond x={108} y={95} litAt={flowArrival(MERGE_AT, TO_MERGE[0])} />
<FlowNode
x={108}
y={121}
width={164}
height={24}
radius={4}
tone="amber"
litAt={flowArrival(INPUT_AT, TO_INPUT)}
>
<Terminal
x={34}
y={115}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
<FlowTypedText
text={COMMAND}
at={COMMAND_AT}
interval={COMMAND_INTERVAL}
x={52}
y={121}
/>
</FlowNode>
<FlowNode
x={56}
y={169}
width={72}
icon={Code}
label="代码"
tone="amber"
litAt={flowArrival(CODE_AT, TO_CODE)}
/>
<FlowNode x={160} y={169} width={72} tone="green" litAt={RESULT_AT}>
<Rocket
x={134}
y={163}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
<FlowLabel x={150} y={169} anchor="start">
上线
</FlowLabel>
<FlowReveal at={RESULT_AT}>
<Check
x={174}
y={163}
width={12}
height={12}
strokeWidth={2}
aria-hidden="true"
className="text-(--flow-tone)"
/>
</FlowReveal>
</FlowNode>
</FlowDiagram>
)
}
export default FlowDiagramFullstackDemo
AI Agent
文档与工具两路来源汇入输入框,逐字提问后进入“思考”,再依次给出答案条目。示例里的“星花变加载环”是用 useFlowClock 自己写的动画元素。文档蓝、工具琥珀、提问紫、结果绿,答案条目的图标和标签沿用各自来源的颜色。
"use client"
import { motion, useTransform } from "motion/react"
import {
CircleCheck,
FileText,
Loader,
Search,
Sparkles,
Wrench,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
FlowBeam,
FlowDiagram,
FlowLabel,
FlowNode,
FlowReveal,
FlowTypedText,
flowArrival,
flowRoute,
useFlowClock,
} from "@/components/ui/flow-diagram/flow-diagram"
// 时间轴(秒):来源汇入 → 逐字提问 → 思考 → 答案条目依次出现
const DURATION = 9
const EXIT = 8.2
const REST = 7.5
const QUESTION = "如何接入新的工具?"
const QUESTION_INTERVAL = 0.14
// 色调表达来源:文档蓝、工具琥珀、提问与思考紫、最终结果绿;答案条目沿用各自来源的颜色
const SOURCES = [
{ x: 56, label: "文档", icon: FileText, tone: "blue" },
{ x: 160, label: "工具", icon: Wrench, tone: "amber" },
] as const
const ANSWERS = [
{ title: "在 tools/ 注册定义", tag: "文档", icon: FileText, tone: "blue" },
{ title: "挂载到 Agent 循环", tag: "工具", icon: Wrench, tone: "amber" },
{ title: "运行并验证调用", tag: "结果", icon: CircleCheck, tone: "green" },
] as const
const FEED = SOURCES.map(({ x }) =>
flowRoute([
[x, 33],
[x, 50],
[108, 50],
[108, 63],
]),
)
const FEED_AT = 0.4
const ASK_AT = flowArrival(FEED_AT, FEED[0]) + 0.35
const THINK_AT = ASK_AT + Array.from(QUESTION).length * QUESTION_INTERVAL + 0.15
const ANSWER_AT = THINK_AT + 1
const ROW_Y = [113, 140, 167]
/** 输入框右侧的状态图标:平时是星花,思考时换成旋转的加载环。 */
function Status() {
const { time } = useFlowClock()
const idle = useTransform(
time,
[THINK_AT - 0.15, THINK_AT, ANSWER_AT, ANSWER_AT + 0.2],
[1, 0, 0, 1],
)
const busy = useTransform(
time,
[THINK_AT - 0.15, THINK_AT, ANSWER_AT, ANSWER_AT + 0.2],
[0, 1, 1, 0],
)
const rotate = useTransform(time, (value) => value * 540)
return (
<>
<motion.g style={{ opacity: idle }}>
<Sparkles
x={184}
y={70}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
</motion.g>
<motion.g style={{ opacity: busy }}>
<motion.g
style={{
rotate,
transformBox: "fill-box",
transformOrigin: "center",
}}
>
<Loader
x={184}
y={70}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
</motion.g>
</motion.g>
</>
)
}
/** 提问之前的占位提示,开始打字时淡出,循环末尾再回来。 */
function Placeholder() {
const { time, exit, duration } = useFlowClock()
const opacity = useTransform(
time,
[ASK_AT - 0.2, ASK_AT - 0.05, exit, duration],
[1, 0, 0, 1],
)
return (
<motion.g style={{ opacity }}>
<FlowLabel x={40} y={76} anchor="start" className="fill-muted-foreground">
向 Agent 提问…
</FlowLabel>
</motion.g>
)
}
/** AI 应用与 Agent:文档与工具两路来源汇入输入框,逐字提问后给出答案条目。 */
function FlowDiagramAgentDemo({ className }: { className?: string }) {
return (
<FlowDiagram
className={cn("h-56 w-full max-w-md", className)}
duration={DURATION}
exit={EXIT}
rest={REST}
>
{FEED.map((path, i) => (
<FlowBeam
key={`feed-${i}`}
route={path}
at={FEED_AT}
tone={SOURCES[i].tone}
/>
))}
{SOURCES.map(({ x, label, icon, tone }) => (
<FlowNode
key={label}
x={x}
y={22}
width={64}
height={22}
icon={icon}
label={label}
tone={tone}
litAt={0.2}
/>
))}
{/* 答案出现之前的骨架行 */}
{ROW_Y.map((y) => (
<g key={y}>
<rect
x={14}
y={y - 11}
width={188}
height={22}
rx={4}
strokeWidth={1}
className="stroke-foreground/10"
/>
<rect
x={22}
y={y - 4}
width={10}
height={8}
rx={2}
className="fill-muted"
/>
<rect
x={44}
y={y - 2}
width={84}
height={4}
rx={2}
className="fill-muted"
/>
<rect
x={172}
y={y - 2}
width={22}
height={4}
rx={2}
className="fill-muted"
/>
</g>
))}
<FlowNode
x={108}
y={76}
width={188}
height={26}
radius={5}
tone="violet"
litAt={flowArrival(FEED_AT, FEED[0])}
>
<Search
x={22}
y={70}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
<Placeholder />
<FlowTypedText
text={QUESTION}
at={ASK_AT}
interval={QUESTION_INTERVAL}
x={40}
y={76}
/>
<Status />
</FlowNode>
{ANSWERS.map(({ title, tag, icon: Icon, tone }, i) => (
<FlowReveal key={title} at={ANSWER_AT + i * 0.25} rise={5}>
<FlowNode
x={108}
y={ROW_Y[i]}
width={188}
height={22}
shape="box"
radius={4}
tone={tone}
>
<Icon
x={22}
y={ROW_Y[i] - 6}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
<FlowLabel x={44} y={ROW_Y[i]} anchor="start">
{title}
</FlowLabel>
<FlowLabel
x={194}
y={ROW_Y[i]}
anchor="end"
className="fill-(--flow-tone) text-[9px]"
>
{tag}
</FlowLabel>
</FlowNode>
</FlowReveal>
))}
</FlowDiagram>
)
}
export default FlowDiagramAgentDemo
大模型部署
模型汇入推理引擎,再分发到 API、Chat、Agent 三个出口。同一条线上有两轮请求先后流过,第二轮用 base={false} 只画光点,避免底线叠粗。推理引擎到三个出口的线是常驻连接,画好后用 flow 让虚线持续向前流动。两个模型各用一种冷色,节点图标取自 simple-icons 的品牌图标(示例里包成 FlowIcon),推理引擎是琥珀色,就绪的出口变绿。
"use client"
import { Fragment, type SVGProps } from "react"
import { Bot, Cpu, MessageSquare, Plug } from "lucide-react"
import { siDeepseek, siQwen } from "simple-icons"
import { cn } from "@/lib/utils"
import {
FlowBeam,
FlowDiagram,
FlowLabel,
FlowNode,
flowArrival,
flowRoute,
type FlowIcon,
} from "@/components/ui/flow-diagram/flow-diagram"
// 时间轴(秒):两轮请求先后流过;REST 落在最后一束光点之后、淡出之前
const DURATION = 7.2
const EXIT = 6.4
const REST = 6.3
const ROUNDS = [
{ intake: 0.4, dispatch: 1.7 },
{ intake: 3.6, dispatch: 4.9 },
] as const
/** 把 simple-icons 的品牌路径包成 FlowNode 能用的图标,颜色跟随节点的色调。 */
function brandIcon(path: string): FlowIcon {
return function BrandIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props}>
<path d={path} fill="currentColor" stroke="none" />
</svg>
)
}
}
// 色调表达流向:两个模型用冷色(Qwen 紫、DeepSeek 蓝),推理引擎琥珀,出口在请求到达时点亮成绿色(即「就绪」)
const MODELS = [
{ x: 56, label: "Qwen", icon: brandIcon(siQwen.path), tone: "violet" },
{ x: 160, label: "DeepSeek", icon: brandIcon(siDeepseek.path), tone: "blue" },
] as const
const TARGETS = [
{ x: 34, label: "API", icon: Plug },
{ x: 108, label: "Chat", icon: MessageSquare },
{ x: 182, label: "Agent", icon: Bot },
] as const
// 模型 → 推理引擎:先下行,再在 y=56 汇到中线
const INTAKE = MODELS.map(({ x }) =>
flowRoute([
[x, 34],
[x, 56],
[108, 56],
[108, 76],
]),
)
// 推理引擎 → 三个出口:中间直下,两侧先下到 y=132 再横向分开
const DISPATCH = TARGETS.map(({ x }) =>
flowRoute(
x === 108
? [
[108, 110],
[108, 158],
]
: [
[108, 110],
[108, 132],
[x, 132],
[x, 158],
],
),
)
/** 大模型部署:模型汇入推理引擎,再分发到 API / Chat / Agent。 */
function FlowDiagramDeployDemo({ className }: { className?: string }) {
const engineLit = flowArrival(ROUNDS[0].intake, INTAKE[0])
return (
<FlowDiagram
className={cn("h-56 w-full max-w-md", className)}
duration={DURATION}
exit={EXIT}
rest={REST}
>
{ROUNDS.map((round, index) => (
<Fragment key={round.intake}>
{INTAKE.map((path, i) => (
<FlowBeam
key={`in-${i}`}
route={path}
at={round.intake}
tone={MODELS[i].tone}
base={index === 0}
/>
))}
{DISPATCH.map((path, i) => (
<FlowBeam
key={`out-${i}`}
route={path}
at={round.dispatch}
tone="green"
base={index === 0}
// 出口是常驻的服务连接:线画好后持续流动,只在第一轮设置,避免两层纹理叠加
flow={index === 0}
/>
))}
</Fragment>
))}
{MODELS.map(({ x, label, icon, tone }) => (
<FlowNode
key={label}
x={x}
y={24}
width={80}
icon={icon}
label={label}
tone={tone}
litAt={0.2}
/>
))}
<FlowNode
x={108}
y={93}
width={124}
height={34}
radius={5}
tone="amber"
litAt={engineLit}
>
<Cpu
x={54}
y={87}
width={12}
height={12}
strokeWidth={1.5}
aria-hidden="true"
className="text-(--flow-tone)"
/>
<FlowLabel x={120} y={88}>
vLLM / SGLang
</FlowLabel>
<FlowLabel x={120} y={100} className="fill-muted-foreground text-[9px]">
推理引擎
</FlowLabel>
</FlowNode>
{TARGETS.map(({ x, label, icon }, i) => {
const arrival = flowArrival(ROUNDS[0].dispatch, DISPATCH[i])
return (
<FlowNode
key={label}
x={x}
y={168}
width={56}
icon={icon}
label={label}
tone="green"
litAt={arrival}
/>
)
})}
</FlowDiagram>
)
}
export default FlowDiagramDeployDemo
RAG 检索增强
上排是入库:文档切片、嵌入、写入向量库;下排是问答:问题经检索拿到 top-3 片段,交给 LLM 生成,最后逐字打出回答。两条竖线分别负责“查询进入”和“结果取出”,不会重叠。
import {
FlowBeam,
FlowDiagram,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowTypedText,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
const QUESTION = "如何退款?"
const QUESTION_INTERVAL = 0.12
const ANSWER = "7 天内可退款"
const ANSWER_INTERVAL = 0.1
// 上排:文档切片后写入向量库(建索引)
const SLICE = flowRoute([
[64, 34],
[92, 34],
])
const EMBED = flowRoute([
[148, 34],
[198, 34],
])
// 下排:问题 → 检索 → 向量库(查询,从左侧竖线进入)→ top-3 片段(从右侧竖线下行)→ LLM → 回答
const ASK = flowRoute([
[80, 104],
[106, 104],
])
const QUERY = flowRoute([
[162, 104],
[224, 104],
[224, 47],
])
const HITS = flowRoute([
[248, 47],
[248, 159],
])
const COMPOSE = flowRoute([
[216, 170],
[168, 170],
])
// 时间轴(秒):先建索引,再提问,最后检索、生成、逐字给出回答
const SLICE_AT = 0.5
const EMBED_AT = flowArrival(SLICE_AT, SLICE) + 0.1
const STORE_AT = flowArrival(EMBED_AT, EMBED)
const QUESTION_AT = STORE_AT + 0.4
const ASK_AT =
QUESTION_AT + Array.from(QUESTION).length * QUESTION_INTERVAL + 0.15
const SEARCH_AT = flowArrival(ASK_AT, ASK)
const QUERY_AT = SEARCH_AT + 0.15
const HITS_AT = flowArrival(QUERY_AT, QUERY) + 0.25
const MODEL_AT = flowArrival(HITS_AT, HITS)
const THINK_END = MODEL_AT + 1
const COMPOSE_AT = THINK_END + 0.1
const REPLY_AT = flowArrival(COMPOSE_AT, COMPOSE)
const ANSWER_AT = REPLY_AT + 0.15
const REST = ANSWER_AT + Array.from(ANSWER).length * ANSWER_INTERVAL + 0.5
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** RAG 检索增强:文档切片入库,提问时先检索相关片段,再交给 LLM 生成回答。 */
export default function FlowDiagramRagDemo() {
return (
<FlowDiagram
background="lines"
width={320}
height={204}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
<FlowBeam route={SLICE} at={SLICE_AT} />
<FlowBeam route={EMBED} at={EMBED_AT} />
<FlowBeam route={ASK} at={ASK_AT} />
<FlowBeam route={QUERY} at={QUERY_AT} />
<FlowBeam route={HITS} at={HITS_AT} />
<FlowBeam route={COMPOSE} at={COMPOSE_AT} />
<FlowNode x={36} y={34} width={56} litAt={0.2}>
<FlowLabel x={36} y={34}>
文档
</FlowLabel>
</FlowNode>
<FlowNode x={120} y={34} width={56} litAt={flowArrival(SLICE_AT, SLICE)}>
<FlowLabel x={120} y={34}>
切片
</FlowLabel>
</FlowNode>
<FlowReveal at={EMBED_AT}>
<FlowLabel x={173} y={22} className="fill-muted-foreground text-[9px]">
嵌入
</FlowLabel>
</FlowReveal>
<FlowNode
x={236}
y={34}
width={76}
height={26}
radius={5}
litAt={STORE_AT}
>
<FlowLabel x={236} y={34}>
向量库
</FlowLabel>
</FlowNode>
<FlowNode x={44} y={104} width={72} litAt={QUESTION_AT}>
<FlowTypedText
text={QUESTION}
at={QUESTION_AT}
interval={QUESTION_INTERVAL}
x={14}
y={104}
/>
</FlowNode>
<FlowNode x={134} y={104} width={56} litAt={SEARCH_AT}>
<FlowLabel x={134} y={104}>
检索
</FlowLabel>
</FlowNode>
<FlowReveal at={HITS_AT}>
<FlowLabel
x={256}
y={104}
anchor="start"
className="fill-muted-foreground text-[9px]"
>
top-3
</FlowLabel>
</FlowReveal>
<FlowNode x={248} y={170} width={64} litAt={MODEL_AT}>
<FlowSpinner
x={228}
y={170}
size={10}
at={MODEL_AT}
until={THINK_END}
/>
<FlowLabel x={252} y={170}>
LLM
</FlowLabel>
</FlowNode>
<FlowNode
x={96}
y={170}
width={140}
height={24}
radius={5}
litAt={REPLY_AT}
>
<FlowTypedText
text={ANSWER}
at={ANSWER_AT}
interval={ANSWER_INTERVAL}
x={36}
y={170}
/>
</FlowNode>
</FlowDiagram>
)
}
CI/CD 流水线
push 之后 lint 与 test 并行,耗时不同;先跑完的先打勾,汇合点要等最慢的那个。build 完成后部署上线,状态点带涟漪。琥珀色表示进行中、绿色表示通过:加载圈是琥珀色,对勾和通过之后的光点、部署节点是绿色。对勾是自己写的 lucide 图标,用 flowToneStyle("green") 给它设置色调。
import { Check, GitCommitHorizontal } from "lucide-react"
import {
FlowBeam,
FlowDiagram,
FlowDiamond,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowStatusDot,
flowArrival,
flowRoute,
flowToneStyle,
} from "@/components/ui/flow-diagram/flow-diagram"
const JOBS = [
{ y: 62, label: "lint", duration: 0.7 },
{ y: 138, label: "test", duration: 1.7 },
] as const
const TO_SPLIT = flowRoute([
[64, 100],
[82, 100],
])
const TO_JOBS = JOBS.map(({ y }) =>
flowRoute([
[88, y === 62 ? 94 : 106],
[88, y],
[112, y],
]),
)
const TO_MERGE = JOBS.map(({ y }) =>
flowRoute([
[172, y],
[200, y],
[200, y === 62 ? 94 : 106],
]),
)
const TO_BUILD = flowRoute([
[206, 100],
[226, 100],
])
const TO_DEPLOY = flowRoute([
[254, 111],
[254, 157],
])
// 时间轴(秒):push → 并行跑 lint / test(耗时不同)→ 都通过后汇合 → build → deploy
const SPLIT_AT = 0.5
const JOBS_AT = flowArrival(SPLIT_AT, TO_SPLIT) + 0.15
const JOBS_START = flowArrival(JOBS_AT, TO_JOBS[0])
const JOBS_DONE = JOBS.map(({ duration }) => JOBS_START + duration)
// 先跑完的 job 先发光点,但汇合点要等最慢的那个
const MERGE_AT = JOBS_DONE.map((done) => done + 0.1)
const MERGED = Math.max(
...MERGE_AT.map((at, i) => flowArrival(at, TO_MERGE[i])),
)
const BUILD_AT = MERGED + 0.15
const BUILD_START = flowArrival(BUILD_AT, TO_BUILD)
const BUILD_DONE = BUILD_START + 1
const DEPLOY_AT = BUILD_DONE + 0.1
const LIVE_AT = flowArrival(DEPLOY_AT, TO_DEPLOY)
const REST = LIVE_AT + 0.6
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 一个会转圈、完成后换成对勾的 job 状态:进行中是琥珀色,通过后是绿色。 */
function JobStatus({
x,
y,
from,
to,
}: {
x: number
y: number
from: number
to: number
}) {
return (
<>
<FlowSpinner x={x} y={y} size={10} at={from} until={to} tone="amber" />
<FlowReveal at={to}>
{/* flowToneStyle 给自己写的 SVG 元素设置色调,里面用 --flow-tone 取色 */}
<g style={flowToneStyle("green")}>
<Check
x={x - 6}
y={y - 6}
width={12}
height={12}
strokeWidth={2}
className="text-(--flow-tone)"
/>
</g>
</FlowReveal>
</>
)
}
/** CI/CD 流水线:push 之后 lint 与 test 并行,全部通过才进入 build 和 deploy。 */
export default function FlowDiagramCicdDemo() {
return (
<FlowDiagram
background="grid"
width={320}
height={200}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
<FlowBeam route={TO_SPLIT} at={SPLIT_AT} />
{TO_JOBS.map((route, i) => (
<FlowBeam key={`job-${i}`} route={route} at={JOBS_AT} />
))}
{/* 通过之后的光点是绿色:红 / 绿 / 琥珀分别表示失败 / 通过 / 进行中 */}
{TO_MERGE.map((route, i) => (
<FlowBeam
key={`merge-${i}`}
route={route}
at={MERGE_AT[i]}
tone="green"
/>
))}
<FlowBeam route={TO_BUILD} at={BUILD_AT} tone="green" />
<FlowBeam route={TO_DEPLOY} at={DEPLOY_AT} tone="green" />
<FlowNode x={38} y={100} width={52} litAt={0.2}>
<GitCommitHorizontal
x={19}
y={94}
width={12}
height={12}
strokeWidth={1.5}
className="text-muted-foreground"
/>
<FlowLabel x={48} y={100}>
push
</FlowLabel>
</FlowNode>
<FlowDiamond x={88} y={100} litAt={flowArrival(SPLIT_AT, TO_SPLIT)} />
{JOBS.map(({ y, label }, i) => (
<FlowNode key={label} x={142} y={y} width={60} litAt={JOBS_START}>
<JobStatus x={124} y={y} from={JOBS_START} to={JOBS_DONE[i]} />
<FlowLabel x={150} y={y}>
{label}
</FlowLabel>
</FlowNode>
))}
<FlowDiamond x={200} y={100} tone="green" litAt={MERGED} />
<FlowNode x={254} y={100} width={56} litAt={BUILD_START}>
<JobStatus x={236} y={100} from={BUILD_START} to={BUILD_DONE} />
<FlowLabel x={262} y={100}>
build
</FlowLabel>
</FlowNode>
<FlowNode x={254} y={170} width={72} tone="green" litAt={LIVE_AT}>
<FlowStatusDot x={232} y={170} at={LIVE_AT} pulse />
<FlowLabel x={256} y={170}>
deploy
</FlowLabel>
</FlowNode>
<FlowReveal at={LIVE_AT}>
<FlowLabel x={254} y={188} className="fill-muted-foreground text-[9px]">
v1.4.2 · prod
</FlowLabel>
</FlowReveal>
</FlowDiagram>
)
}
登录鉴权
第一次密码错误,Auth 返回 401,红色光点沿一条环路绕回用户;重试通过后签发 JWT,带着 Bearer 访问受保护资源。FlowBeam 的 tone="destructive" 用来标出失败分支,这条环路的底线也会淡淡染成红色。
import { Check } from "lucide-react"
import {
FlowBeam,
FlowDiagram,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
const LOGIN = flowRoute([
[68, 46],
[102, 46],
])
const VERIFY = flowRoute([
[158, 46],
[204, 46],
])
// 失败:从 Auth 下方绕一圈回到用户,形成重试环
const REJECT = flowRoute([
[220, 57],
[220, 92],
[40, 92],
[40, 57],
])
const ISSUE = flowRoute([
[260, 57],
[260, 139],
])
const ACCESS = flowRoute([
[204, 150],
[130, 150],
])
// 时间轴(秒):第一次密码错误被拒绝,沿环路回到用户,重试后通过,拿到令牌访问资源
const LOGIN_AT = 0.5
const VERIFY_AT = flowArrival(LOGIN_AT, LOGIN) + 0.15
const CHECK_AT = flowArrival(VERIFY_AT, VERIFY)
const CHECK_END = CHECK_AT + 0.7
const REJECT_AT = CHECK_END + 0.1
const RETRY_LOGIN_AT = flowArrival(REJECT_AT, REJECT) + 0.3
const RETRY_VERIFY_AT = flowArrival(RETRY_LOGIN_AT, LOGIN) + 0.15
const RECHECK_AT = flowArrival(RETRY_VERIFY_AT, VERIFY)
const RECHECK_END = RECHECK_AT + 0.6
const ISSUE_AT = RECHECK_END + 0.1
const TOKEN_AT = flowArrival(ISSUE_AT, ISSUE)
const ACCESS_AT = TOKEN_AT + 0.3
const GRANTED_AT = flowArrival(ACCESS_AT, ACCESS)
const REST = GRANTED_AT + 0.7
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 登录鉴权:首次失败后沿环路重试,通过后签发令牌并访问受保护资源。 */
export default function FlowDiagramAuthDemo() {
return (
<FlowDiagram
background="hatch"
width={320}
height={190}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
<FlowBeam route={LOGIN} at={LOGIN_AT} />
<FlowBeam route={VERIFY} at={VERIFY_AT} />
<FlowBeam route={REJECT} at={REJECT_AT} tone="destructive" />
<FlowBeam route={LOGIN} at={RETRY_LOGIN_AT} base={false} />
<FlowBeam route={VERIFY} at={RETRY_VERIFY_AT} base={false} />
<FlowBeam route={ISSUE} at={ISSUE_AT} />
<FlowBeam route={ACCESS} at={ACCESS_AT} />
<FlowNode x={40} y={46} width={56} litAt={0.2}>
<FlowLabel x={40} y={46}>
用户
</FlowLabel>
</FlowNode>
<FlowNode x={130} y={46} width={56} litAt={flowArrival(LOGIN_AT, LOGIN)}>
<FlowLabel x={130} y={46}>
网关
</FlowLabel>
</FlowNode>
<FlowNode x={240} y={46} width={72} height={22} litAt={CHECK_AT}>
<FlowSpinner x={218} y={46} size={10} at={CHECK_AT} until={CHECK_END} />
<FlowSpinner
x={218}
y={46}
size={10}
at={RECHECK_AT}
until={RECHECK_END}
/>
<FlowLabel x={246} y={46}>
Auth
</FlowLabel>
</FlowNode>
<FlowReveal at={REJECT_AT}>
<FlowLabel x={130} y={80} tone="destructive" className="text-[9px]">
401 密码错误,重试
</FlowLabel>
</FlowReveal>
<FlowReveal at={ISSUE_AT}>
<FlowLabel
x={268}
y={98}
anchor="start"
className="fill-primary text-[9px]"
>
200
</FlowLabel>
</FlowReveal>
<FlowNode x={240} y={150} width={72} height={22} litAt={TOKEN_AT}>
<FlowLabel x={240} y={150}>
JWT
</FlowLabel>
</FlowNode>
<FlowReveal at={ACCESS_AT}>
<FlowLabel x={167} y={136} className="fill-muted-foreground text-[9px]">
Bearer
</FlowLabel>
</FlowReveal>
<FlowNode x={90} y={150} width={80} height={22} litAt={GRANTED_AT}>
<FlowReveal at={GRANTED_AT}>
<Check
x={56}
y={144}
width={12}
height={12}
strokeWidth={2}
className="text-primary"
/>
</FlowReveal>
<FlowLabel x={96} y={150}>
/orders
</FlowLabel>
</FlowNode>
</FlowDiagram>
)
}
微服务请求链路
网关扇出到三个服务并行处理,光点按路径长度反推出发时刻,所以同时到达;各服务按自己的耗时返回,耗时标签随返回的光点出现,汇合点等最慢的那个,最后得到总耗时。每个服务一种颜色(tone),扇出的光点、节点和返回的光点都用它,节点里的加载圈和状态点自动继承节点的颜色。
import {
FLOW_BEAM_SPEED,
FlowBeam,
FlowDiagram,
FlowDiamond,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowStatusDot,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
// 每项服务的耗时:动画里 40ms 对应 1 秒,慢的服务让汇合点多等一会儿;
// 每项服务一种颜色,从扇出的光点、节点到返回的光点都用它,一眼就能追到哪条线是谁的
const SERVICES = [
{ y: 40, label: "用户服务", ms: 12, tone: "blue" },
{ y: 110, label: "订单服务", ms: 64, tone: "violet" },
{ y: 180, label: "库存服务", ms: 28, tone: "amber" },
] as const
const MS_PER_SECOND = 40
const TO_SPLIT = flowRoute([
[68, 110],
[92, 110],
])
const FAN_OUT = SERVICES.map(({ y }) =>
flowRoute(
y === 110
? [
[104, 110],
[126, 110],
]
: [
[98, y === 40 ? 104 : 116],
[98, y],
[126, y],
],
),
)
const FAN_IN = SERVICES.map(({ y }) =>
flowRoute(
y === 110
? [
[194, 110],
[232, 110],
]
: [
[194, y],
[238, y],
[238, y === 40 ? 104 : 116],
],
),
)
const TO_REPLY = flowRoute([
[244, 110],
[256, 110],
])
// 时间轴(秒):请求扇出,三条线同时到达;各服务按自己的耗时返回,汇合点等最慢的那个
const SPLIT_AT = 0.5
const ARRIVE = 1.9
// 长短不一的路径反推出发时刻,让三束光点同时到达
const FAN_OUT_AT = FAN_OUT.map(
(route) => ARRIVE - route.length / FLOW_BEAM_SPEED,
)
const DONE_AT = SERVICES.map(({ ms }) => ARRIVE + ms / MS_PER_SECOND)
const MERGED = Math.max(...DONE_AT.map((at, i) => flowArrival(at, FAN_IN[i])))
const REPLY_AT = MERGED + 0.15
const REPLIED = flowArrival(REPLY_AT, TO_REPLY)
// 总耗时 = 最慢的服务 + 网关与汇合的 2ms 开销
const TOTAL = Math.max(...SERVICES.map(({ ms }) => ms)) + 2
const REST = REPLIED + 0.7
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 微服务请求链路:网关扇出到多个服务并行处理,耗时各异,最后汇合成一个响应。 */
export default function FlowDiagramMicroserviceDemo() {
return (
<FlowDiagram
width={320}
height={220}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
<FlowBeam route={TO_SPLIT} at={SPLIT_AT} />
{FAN_OUT.map((route, i) => (
<FlowBeam
key={`out-${i}`}
route={route}
at={FAN_OUT_AT[i]}
tone={SERVICES[i].tone}
/>
))}
{FAN_IN.map((route, i) => (
<FlowBeam
key={`in-${i}`}
route={route}
at={DONE_AT[i]}
tone={SERVICES[i].tone}
/>
))}
<FlowBeam route={TO_REPLY} at={REPLY_AT} tone="green" />
<FlowNode x={40} y={110} width={56} litAt={0.2}>
<FlowLabel x={40} y={110}>
网关
</FlowLabel>
</FlowNode>
<FlowDiamond x={98} y={110} litAt={flowArrival(SPLIT_AT, TO_SPLIT)} />
{SERVICES.map(({ y, label, ms, tone }, i) => (
<g key={label}>
{/* 节点的色调会传给里面的加载圈和状态点,不用再各写一遍 */}
<FlowNode x={160} y={y} width={68} tone={tone} litAt={ARRIVE}>
<FlowSpinner
x={138}
y={y}
size={9}
at={ARRIVE}
until={DONE_AT[i]}
/>
<FlowStatusDot x={138} y={y} at={DONE_AT[i]} />
<FlowLabel x={165} y={y}>
{label}
</FlowLabel>
</FlowNode>
<FlowReveal at={DONE_AT[i]}>
<FlowLabel
x={213}
y={y - 9}
className="fill-muted-foreground text-[9px]"
>
{ms}ms
</FlowLabel>
</FlowReveal>
</g>
))}
<FlowDiamond x={238} y={110} tone="green" litAt={MERGED} />
<FlowNode x={284} y={110} width={56} tone="green" litAt={REPLIED}>
<FlowLabel x={284} y={110}>
响应
</FlowLabel>
</FlowNode>
<FlowReveal at={REPLIED}>
<FlowLabel x={284} y={130} tone="green" className="text-[9px]">
{TOTAL}ms
</FlowLabel>
</FlowReveal>
</FlowDiagram>
)
}
数据管道 ETL
竖向布局:三个数据源汇入清洗,进度条走完后写入数据仓库,再在看板上依次长出柱状图。每个数据源一种颜色,用 icon + label 一行写完节点;汇入清洗的三条线带 flow,表示数据源一直在接入。进度条和柱子是用 useFlowClock + useTransform 写的自定义元素,颜色用 fill-(--flow-tone) 取所在节点的色调。
"use client"
import { motion, useTransform } from "motion/react"
import { Database, Globe, ScrollText } from "lucide-react"
import {
FlowBeam,
FlowDiagram,
FlowLabel,
FlowNode,
FlowReveal,
FlowStatusDot,
flowArrival,
flowRoute,
useFlowClock,
} from "@/components/ui/flow-diagram/flow-diagram"
// 每个数据源一种颜色:从节点到汇入的光点都用它
const SOURCES = [
{ x: 48, label: "MySQL", icon: Database, tone: "blue" },
{ x: 130, label: "日志", icon: ScrollText, tone: "amber" },
{ x: 212, label: "API", icon: Globe, tone: "cyan" },
] as const
const BARS = [10, 16, 12, 22, 18] as const
const TO_CLEAN = SOURCES.map(({ x }) =>
flowRoute(
x === 130
? [
[130, 34],
[130, 68],
]
: [
[x, 34],
[x, 52],
[130, 52],
[130, 68],
],
),
)
const TO_STORE = flowRoute([
[130, 102],
[130, 128],
])
const TO_BOARD = flowRoute([
[130, 160],
[130, 184],
])
// 时间轴(秒):多源汇入 → 清洗(进度条走完)→ 入仓 → 看板的柱子依次长出来
const INTAKE_AT = 0.5
const CLEAN_AT = flowArrival(INTAKE_AT, TO_CLEAN[0])
const CLEAN_END = CLEAN_AT + 1.4
const STORE_AT = CLEAN_END + 0.1
const WAREHOUSE_AT = flowArrival(STORE_AT, TO_STORE)
const BOARD_AT = WAREHOUSE_AT + 0.2
const DASHBOARD_AT = flowArrival(BOARD_AT, TO_BOARD)
const GROW_START = DASHBOARD_AT + 0.1
const GROW_STEP = 0.12
const GROW_END = GROW_START + (BARS.length - 1) * GROW_STEP + 0.4
const REST = GROW_END + 0.6
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 清洗节点里的进度条,宽度随时间轴从 0 涨到满;颜色取所在节点的色调(`--flow-tone`)。 */
function CleanProgress() {
const { time, exit, duration } = useFlowClock()
const scaleX = useTransform(time, [CLEAN_AT, CLEAN_END], [0, 1])
const opacity = useTransform(time, [exit, duration], [1, 0])
return (
<motion.g style={{ opacity }}>
<rect
x={80}
y={92}
width={100}
height={3}
rx={1.5}
className="fill-muted"
/>
<motion.rect
x={80}
y={92}
width={100}
height={3}
rx={1.5}
className="fill-(--flow-tone)"
style={{ scaleX, transformBox: "fill-box", transformOrigin: "left" }}
/>
</motion.g>
)
}
/** 看板里的柱子,从底边向上长出来。 */
function GrowingBar({
x,
height,
index,
}: {
x: number
height: number
index: number
}) {
const { time } = useFlowClock()
const start = GROW_START + index * GROW_STEP
const scaleY = useTransform(time, [start, start + 0.4], [0, 1])
return (
<motion.rect
x={x}
y={218 - height}
width={6}
height={height}
rx={1}
className="fill-(--flow-tone)"
style={{ scaleY, transformBox: "fill-box", transformOrigin: "bottom" }}
/>
)
}
/** 数据管道 ETL:多个数据源汇入清洗,写入数仓,最后在看板上长出图表。 */
export default function FlowDiagramEtlDemo() {
return (
<FlowDiagram
background="lines"
width={260}
height={236}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-sm"
>
{TO_CLEAN.map((route, i) => (
// 数据源是常驻的接入:线画好后持续流动,直到清洗完成
<FlowBeam
key={`intake-${i}`}
route={route}
at={INTAKE_AT}
tone={SOURCES[i].tone}
flow
/>
))}
<FlowBeam route={TO_STORE} at={STORE_AT} tone="violet" />
<FlowBeam route={TO_BOARD} at={BOARD_AT} tone="green" />
{/* icon + label:图标与文字整体居中,不用手算坐标 */}
{SOURCES.map(({ x, label, icon, tone }) => (
<FlowNode
key={label}
x={x}
y={24}
width={72}
tone={tone}
litAt={0.2}
icon={icon}
label={label}
/>
))}
<FlowNode
x={130}
y={85}
width={140}
height={34}
shape="box"
tone="violet"
litAt={CLEAN_AT}
>
<FlowLabel x={130} y={77}>
清洗 / 去重
</FlowLabel>
<CleanProgress />
</FlowNode>
<FlowNode
x={130}
y={144}
width={140}
height={32}
shape="box"
litAt={WAREHOUSE_AT}
>
<FlowLabel x={130} y={139}>
数据仓库
</FlowLabel>
<FlowLabel x={130} y={151} className="fill-muted-foreground text-[9px]">
ClickHouse
</FlowLabel>
<FlowStatusDot x={74} y={144} at={WAREHOUSE_AT} pulse />
</FlowNode>
<FlowNode
x={130}
y={204}
width={188}
height={40}
shape="box"
tone="green"
litAt={DASHBOARD_AT}
>
<FlowLabel x={48} y={204} anchor="start">
看板
</FlowLabel>
{BARS.map((height, index) => (
<GrowingBar
key={index}
x={104 + index * 12}
height={height}
index={index}
/>
))}
<FlowReveal at={GROW_END - 0.3}>
<FlowLabel
x={196}
y={204}
anchor="end"
tone="green"
className="text-[9px]"
>
+12%
</FlowLabel>
</FlowReveal>
</FlowNode>
</FlowDiagram>
)
}
MCP 工具调用
请求与结果走同一条线:先 tools/list 向三个服务器发现能力,再 tools/call 调用数据库,结果沿原路返回。返回路径只是把端点倒过来,用 base={false} 复用已经画好的线。
import { Check } from "lucide-react"
import {
FLOW_BEAM_SPEED,
FlowBeam,
FlowDiagram,
FlowDiamond,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowStatusDot,
flowArrival,
flowRoute,
type FlowPoint,
} from "@/components/ui/flow-diagram/flow-diagram"
const SERVERS = [
{ y: 34, label: "GitHub", tool: "search_issues" },
{ y: 95, label: "文件系统", tool: "read_file" },
{ y: 156, label: "数据库", tool: "run_query" },
] as const
// 请求和结果走同一条线,所以返回的路径只是把端点倒过来
const reversed = (points: readonly FlowPoint[]) => [...points].reverse()
const TO_CLIENT_POINTS = [
[68, 95],
[122, 95],
] as const
const TO_SPLIT_POINTS = [
[174, 95],
[188, 95],
] as const
const TO_SERVER_POINTS = SERVERS.map(({ y }) =>
y === 95
? ([
[200, 95],
[210, 95],
] as const)
: ([
[194, y === 34 ? 89 : 101],
[194, y],
[210, y],
] as const),
)
const TO_CLIENT = flowRoute(TO_CLIENT_POINTS)
const BACK_TO_AGENT = flowRoute(reversed(TO_CLIENT_POINTS))
const TO_SPLIT = flowRoute(TO_SPLIT_POINTS)
const BACK_TO_CLIENT = flowRoute(reversed(TO_SPLIT_POINTS))
const TO_SERVERS = TO_SERVER_POINTS.map((points) => flowRoute(points))
const BACK_FROM_SERVERS = TO_SERVER_POINTS.map((points) =>
flowRoute(reversed(points)),
)
const DATABASE = 2
// 时间轴(秒):先 tools/list 向三个服务器发现能力,再 tools/call 调用其中一个并把结果带回
const LIST_AT = 0.5
const CLIENT_AT = flowArrival(LIST_AT, TO_CLIENT)
const SPLIT_AT = CLIENT_AT + 0.15
const SPLIT_LIT = flowArrival(SPLIT_AT, TO_SPLIT)
const DISCOVER = SPLIT_LIT + 0.1 + 0.8
// 路径长短不一,反推出发时刻让三束光点同时到达
const DISCOVER_AT = TO_SERVERS.map(
(route) => DISCOVER - route.length / FLOW_BEAM_SPEED,
)
const REPORT_AT = DISCOVER + 0.3
const REPORTED = Math.max(
...BACK_FROM_SERVERS.map((route) => flowArrival(REPORT_AT, route)),
)
const CALL_AT = REPORTED + 0.4
const CALL_CLIENT_AT = flowArrival(CALL_AT, TO_CLIENT) + 0.1
const CALL_SPLIT_AT = flowArrival(CALL_CLIENT_AT, TO_SPLIT) + 0.1
const RUN_AT = flowArrival(CALL_SPLIT_AT, TO_SERVERS[DATABASE])
const RUN_END = RUN_AT + 0.8
const RESULT_AT = RUN_END + 0.1
const RESULT_SPLIT_AT =
flowArrival(RESULT_AT, BACK_FROM_SERVERS[DATABASE]) + 0.1
const RESULT_CLIENT_AT = flowArrival(RESULT_SPLIT_AT, BACK_TO_CLIENT) + 0.1
const ANSWER_AT = flowArrival(RESULT_CLIENT_AT, BACK_TO_AGENT)
const REST = ANSWER_AT + 0.7
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** MCP 工具调用:Agent 经由客户端先发现能力,再调用某个工具,结果沿原路返回。 */
export default function FlowDiagramMcpDemo() {
return (
<FlowDiagram
background="none"
width={320}
height={190}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
<FlowBeam route={TO_CLIENT} at={LIST_AT} />
<FlowBeam route={TO_SPLIT} at={SPLIT_AT} />
{TO_SERVERS.map((route, i) => (
<FlowBeam key={`list-${i}`} route={route} at={DISCOVER_AT[i]} />
))}
{BACK_FROM_SERVERS.map((route, i) => (
<FlowBeam
key={`report-${i}`}
route={route}
at={REPORT_AT}
base={false}
/>
))}
<FlowBeam route={TO_CLIENT} at={CALL_AT} base={false} />
<FlowBeam route={TO_SPLIT} at={CALL_CLIENT_AT} base={false} />
<FlowBeam route={TO_SERVERS[DATABASE]} at={CALL_SPLIT_AT} base={false} />
<FlowBeam
route={BACK_FROM_SERVERS[DATABASE]}
at={RESULT_AT}
base={false}
/>
<FlowBeam route={BACK_TO_CLIENT} at={RESULT_SPLIT_AT} base={false} />
<FlowBeam route={BACK_TO_AGENT} at={RESULT_CLIENT_AT} base={false} />
<FlowNode x={38} y={95} width={60} litAt={0.2}>
<FlowLabel x={50} y={95}>
Agent
</FlowLabel>
<FlowReveal at={ANSWER_AT}>
<Check
x={15}
y={89}
width={12}
height={12}
strokeWidth={2}
className="text-primary"
/>
</FlowReveal>
</FlowNode>
<FlowReveal at={LIST_AT}>
<FlowLabel x={95} y={81} className="fill-muted-foreground text-[9px]">
JSON-RPC
</FlowLabel>
</FlowReveal>
<FlowNode x={148} y={95} width={52} litAt={CLIENT_AT}>
<FlowLabel x={148} y={95}>
MCP
</FlowLabel>
</FlowNode>
<FlowDiamond x={194} y={95} litAt={SPLIT_LIT} />
{SERVERS.map(({ y, label, tool }, i) => (
<FlowNode
key={label}
x={256}
y={y}
width={92}
height={30}
radius={5}
litAt={DISCOVER}
>
<FlowLabel x={263} y={y - 6}>
{label}
</FlowLabel>
<FlowLabel
x={263}
y={y + 7}
className="fill-muted-foreground text-[9px]"
>
{tool}
</FlowLabel>
{i === DATABASE ? (
<>
<FlowSpinner
x={223}
y={y}
size={10}
at={RUN_AT}
until={RUN_END}
/>
<FlowStatusDot x={223} y={y} at={RUN_END} />
</>
) : (
<FlowStatusDot x={223} y={y} at={DISCOVER} tone="primary" />
)}
</FlowNode>
))}
</FlowDiagram>
)
}
多模型路由
请求先经过路由器,再按任务分流到不同的模型:每个模型一种颜色,请求的光点、路由器到模型的连线和模型节点都用它。入口的线用一条 FlowEdge 画一次,三个请求的光点都用 base={false} 走这条线。模型节点用 label + description 写两行文字,加载圈和状态点放在节点里,颜色自动继承。
import {
FlowBeam,
FlowDiagram,
FlowEdge,
FlowNode,
FlowSpinner,
FlowStatusDot,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
// 每个模型一种颜色:请求被路由到谁,光点、连线和节点就是谁的颜色
const MODELS = [
{ y: 36, label: "轻量模型", description: "简单问答", tone: "cyan" },
{ y: 100, label: "推理模型", description: "复杂推理", tone: "violet" },
{ y: 164, label: "长文本模型", description: "超长文档", tone: "amber" },
] as const
const TO_ROUTER = flowRoute([
[72, 100],
[112, 100],
])
const TO_MODELS = MODELS.map(({ y }) =>
flowRoute(
y === 100
? [
[176, 100],
[212, 100],
]
: [
[176, y === 36 ? 90 : 110],
[194, y === 36 ? 90 : 110],
[194, y],
[212, y],
],
),
)
// 时间轴(秒):三个请求依次进来,每个被路由到不同的模型,模型处理完亮起状态点
const ROUND = 1.7
const PROCESS = 0.7
const REQUEST_AT = MODELS.map((_, i) => 0.5 + i * ROUND)
const ROUTED_AT = REQUEST_AT.map((at) => flowArrival(at, TO_ROUTER) + 0.2)
const ARRIVE_AT = ROUTED_AT.map((at, i) => flowArrival(at, TO_MODELS[i]))
const DONE_AT = ARRIVE_AT.map((at) => at + PROCESS)
const REST = DONE_AT[MODELS.length - 1] + 0.8
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 多模型路由:请求经路由器分流到不同的模型,每个模型一种颜色。 */
export default function FlowDiagramRouterDemo() {
return (
<FlowDiagram
background={
// 自定义底纹:中心一圈淡紫,呼应路由到的多个模型
<div className="size-full bg-radial from-(--flow-violet)/12 to-transparent to-70%" />
}
width={320}
height={200}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
>
{/* 入口的线只画一次,三个请求的光点都走这条线,所以光点都不带底线 */}
<FlowEdge route={TO_ROUTER} at={0.1} />
{MODELS.map(({ tone }, i) => (
<FlowBeam
key={`request-${i}`}
route={TO_ROUTER}
at={REQUEST_AT[i]}
base={false}
tone={tone}
/>
))}
{/* 路由器到各模型的连线是常驻的,光点沿着它们走 */}
{MODELS.map(({ tone }, i) => (
<FlowBeam
key={`route-${i}`}
route={TO_MODELS[i]}
at={ROUTED_AT[i]}
tone={tone}
/>
))}
<FlowNode x={40} y={100} width={64} litAt={0.2} label="请求" />
<FlowNode
x={144}
y={100}
width={64}
height={36}
shape="box"
litAt={ROUTED_AT[0]}
label="路由"
description="按任务分流"
/>
{MODELS.map(({ y, label, description, tone }, i) => (
<FlowNode
key={label}
x={266}
y={y}
width={92}
height={34}
shape="box"
tone={tone}
litAt={ARRIVE_AT[i]}
label={label}
description={description}
>
{/* 处理中转圈,处理完换成状态点;两者都继承节点的颜色 */}
<FlowSpinner
x={230}
y={y}
size={10}
at={ARRIVE_AT[i]}
until={DONE_AT[i]}
/>
<FlowStatusDot x={230} y={y} at={DONE_AT[i]} />
</FlowNode>
))}
</FlowDiagram>
)
}
品牌色
在 FlowDiagram 上设一次 tone,里面没有单独指定色调的节点、光点、加载圈、状态点都继承它。tone 不限于内置名,任何 CSS 颜色都可以,这个例子用主题变量混出一个颜色;换成你自己的品牌色直接写 tone="#ff6a00" 或 tone="oklch(0.7 0.2 40)" 即可。
import { GitCommitHorizontal } from "lucide-react"
import {
FlowBeam,
FlowDiagram,
FlowLabel,
FlowNode,
FlowSpinner,
FlowStatusDot,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
// 品牌色:任意 CSS 颜色都行(十六进制、oklch、变量……),这里用主题变量混出一个
const BRAND = "color-mix(in oklab, var(--flow-blue) 55%, var(--flow-green))"
const TO_BUILD = flowRoute([
[90, 45],
[120, 45],
])
const TO_SHIP = flowRoute([
[200, 45],
[230, 45],
])
// 时间轴(秒):提交 → 构建(转圈)→ 发布(状态点涟漪)
const PUSH_AT = 0.5
const BUILD_AT = flowArrival(PUSH_AT, TO_BUILD)
const BUILD_END = BUILD_AT + 1
const SHIP_AT = BUILD_END + 0.1
const LIVE_AT = flowArrival(SHIP_AT, TO_SHIP)
const REST = LIVE_AT + 0.8
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 品牌色:只在 FlowDiagram 上设一次 tone,里面的节点、光点、加载圈和状态点都继承它。 */
export default function FlowDiagramBrandDemo() {
return (
<FlowDiagram
background="hatch"
width={320}
height={90}
duration={DURATION}
exit={EXIT}
rest={REST}
tone={BRAND}
className="w-full max-w-md"
>
<FlowBeam route={TO_BUILD} at={PUSH_AT} />
<FlowBeam route={TO_SHIP} at={SHIP_AT} />
<FlowNode
x={50}
y={45}
width={72}
litAt={0.2}
icon={GitCommitHorizontal}
label="提交"
/>
<FlowNode x={160} y={45} width={72} litAt={BUILD_AT}>
<FlowSpinner x={136} y={45} size={10} at={BUILD_AT} until={BUILD_END} />
<FlowLabel x={166} y={45}>
构建
</FlowLabel>
</FlowNode>
<FlowNode x={270} y={45} width={72} litAt={LIVE_AT}>
<FlowStatusDot x={246} y={45} at={LIVE_AT} pulse />
<FlowLabel x={276} y={45}>
发布
</FlowLabel>
</FlowNode>
</FlowDiagram>
)
}
时间轴
一张图只有一个时钟:从 0 走到 duration 秒,到头回到 0 重新开始。所有元素都是这个时间的函数,没有各自的定时器,所以不同元素之间不会错位。
元素出现 / 光点出发的时刻(秒),各原语的叫法略有不同:FlowBeam 的 at、FlowNode 的 litAt
number这一刻起所有元素一起淡出,到 duration 时完全消失,下一轮从空白重新开始
numberduration - 0.8静帧时刻:首帧、服务端渲染和“减少动态效果”下显示的画面
类型numberexit - 0.2一轮循环的总时长(秒)
类型number8三者的关系是 最后一个元素出现 < rest < exit < duration。
rest要选在“画面完整”的一刻:所有节点都已点亮、没有光点在飞。减少动态效果时图示会永远停在这一帧,所以它就是图示的“封面”。exit和duration之间留出淡出的时间,默认 0.8 秒。- 时刻不用手写死。 光点到达时刻用
flowArrival(at, route)推算,下一步从上一步的时刻加一点间隔算出来;示例里的REST、EXIT、DURATION都是这样级联得到的,改一个起点,后面自动顺延。
播放与暂停
- 进入视口(可见面积达到 40%)才开始走,离开视口立即暂停,不占 CPU。
- 初始时钟停在
rest,所以服务端渲染的静态 HTML、首帧水合结果、减少动态效果下的画面是同一帧,不会闪一下,也没有水合不一致。 - 切回标签页后时钟从原地继续,不会一下子跳过一大段。
- 开启系统的“减少动态效果”时不播放,也不显示光点,画面固定在
rest,连线都是完整的(带flow的线停在一帧虚线纹理上)。
循环、触发时机、暂停和速度都可以配置,见下面的「播放控制」。
自定义动画元素
useFlowClock() 返回 { time, duration, exit },time 是 motion 的 MotionValue<number>。用 useTransform(time, 输入区间, 输出区间) 就能把任何属性绑到时间轴上:
function Progress() {
const { time, exit, duration } = useFlowClock()
const scaleX = useTransform(time, [2, 3.5], [0, 1])
const opacity = useTransform(time, [exit, duration], [1, 0])
return (
<motion.g style={{ opacity }}>
<motion.rect
x={80} y={92} width={100} height={3}
className="fill-primary"
style={{ scaleX, transformBox: "fill-box", transformOrigin: "left" }}
/>
</motion.g>
)
}别忘了在 exit 到 duration 之间把元素淡出,否则它会在下一轮开头突然消失。需要在渲染里读取时间做判断时,请像 FlowTypedText 那样用 useMotionValueEvent,不要在渲染期间调用 Date.now(),那会让服务端和客户端的结果对不上。
色调
tone 决定“亮起来是什么颜色”。FlowNode、FlowDiamond 点亮后的描边与淡底色、FlowBeam 的光点与流动纹理、FlowEdge、FlowSpinner、FlowStatusDot、FlowTypedText 的光标,以及可选的 FlowLabel 文字,都接受同一个 tone:
<FlowBeam route={TO_API} at={1} tone="blue" />
<FlowNode x={160} y={50} width={72} tone="green" litAt={1.4} label="API" />
<FlowLabel x={160} y={80} tone="muted">200 OK</FlowLabel>import type { CSSProperties } from "react"
import {
FlowDiagram,
FlowLabel,
FlowNode,
FlowReveal,
type FlowTone,
} from "@/components/ui/flow-diagram/flow-diagram"
// 前十个是内置色调;后两个是任意 CSS 颜色:color-mix 直接写,或先定义成变量再引用
const TONES: { name: string; tone: FlowTone }[] = [
{ name: "primary", tone: "primary" },
{ name: "foreground", tone: "foreground" },
{ name: "muted", tone: "muted" },
{ name: "destructive", tone: "destructive" },
{ name: "blue", tone: "blue" },
{ name: "green", tone: "green" },
{ name: "amber", tone: "amber" },
{ name: "rose", tone: "rose" },
{ name: "violet", tone: "violet" },
{ name: "cyan", tone: "cyan" },
{
name: "color-mix",
tone: "color-mix(in oklab, var(--flow-blue) 50%, var(--flow-rose))",
},
{ name: "--brand", tone: "var(--brand)" },
]
const COLUMNS = 4
const STEP = 0.2
const LAST_AT = 0.2 + (TONES.length - 1) * STEP
const REST = LAST_AT + 1
const EXIT = REST + 0.8
const DURATION = EXIT + 0.8
/** 色调一览:每个节点用一种色调点亮,最后两个是任意 CSS 颜色。 */
export default function FlowDiagramTonesDemo() {
return (
<FlowDiagram
background="none"
width={320}
height={142}
duration={DURATION}
exit={EXIT}
rest={REST}
className="w-full max-w-md"
// --brand 是自己定义的变量,tone 里直接引用它
style={
{
"--brand":
"color-mix(in oklab, var(--flow-green) 55%, var(--flow-cyan))",
} as CSSProperties
}
>
{TONES.map(({ name, tone }, index) => {
const x = 37 + (index % COLUMNS) * 82
const y = 26 + Math.floor(index / COLUMNS) * 38
return (
<FlowNode
key={name}
x={x}
y={y}
width={72}
height={24}
tone={tone}
litAt={0.2 + index * STEP}
>
<FlowLabel x={x} y={y} tone={tone}>
{name}
</FlowLabel>
</FlowNode>
)
})}
<FlowReveal at={LAST_AT}>
<FlowLabel x={160} y={128} tone="muted" className="text-[9px]">
最后两个:任意 CSS 颜色
</FlowLabel>
</FlowReveal>
</FlowDiagram>
)
}
内置色调
| 名称 | 取值 | 说明 |
|---|---|---|
primary | var(--primary) | 默认。随颜色主题变化 |
foreground | var(--foreground) | 最醒目的中性色 |
muted | var(--muted-foreground) | 弱化的中性色 |
destructive | var(--destructive) | 失败、拒绝 |
blue green amber rose violet cyan | var(--flow-blue) 等 | 多彩色调,不随颜色主题变化 |
多彩色调来自六个 CSS 变量,由 registry 条目的 cssVars 提供亮 / 暗两套 oklch 值,shadcn add 时会写进你的全局样式(本站已经在 globals.css 里同名定义):
| 变量 | 亮色 | 暗色 |
|---|---|---|
--flow-blue | oklch(0.56 0.2 258) | oklch(0.72 0.15 255) |
--flow-green | oklch(0.62 0.16 150) | oklch(0.77 0.17 152) |
--flow-amber | oklch(0.7 0.16 70) | oklch(0.82 0.15 82) |
--flow-rose | oklch(0.62 0.21 5) | oklch(0.74 0.16 8) |
--flow-violet | oklch(0.56 0.22 292) | oklch(0.74 0.15 295) |
--flow-cyan | oklch(0.66 0.12 215) | oklch(0.82 0.11 208) |
想换成品牌色,在全局样式里覆盖这些变量即可,所有用到它的图示一起变:
:root {
--flow-blue: oklch(0.6 0.2 250);
}
.dark {
--flow-blue: oklch(0.75 0.14 250);
}任意颜色
tone 也接受任意 CSS 颜色,不在内置名里的字符串都按颜色处理(所以内置名拼错了也不会报错,只是颜色失效):
<FlowBeam route={TO_API} at={1} tone="#ff6a00" />
<FlowNode x={160} y={50} width={72} tone="oklch(0.7 0.2 150)" litAt={1.4} label="API" />
<FlowNode x={260} y={50} width={72} tone="var(--brand)" litAt={1.8} label="CDN" />继承
所有原语只读一个 CSS 变量 --flow-tone,它沿 DOM 往下继承,所以不用在每个元素上重复写:
FlowDiagram的tone是整张图的默认色调(默认primary);FlowNode指定了tone后,节点里没有单独指定色调的加载圈、状态点、光标都跟着变,图标也会染成这个颜色;- 元素自己的
tone优先。
自己写的 SVG 元素也可以用:flowToneStyle("green") 返回 { "--flow-tone": ... },放到 style 上,里面用 fill-(--flow-tone)、stroke-(--flow-tone)/40、text-(--flow-tone) 取色。
连线的颜色
连线默认是中性的前景色。给 FlowBeam 或 FlowEdge 明确指定 tone 后,生长的底线、虚线也会淡淡染上这个颜色(30% 不透明度);继承自 FlowDiagram 的默认色调不会染线。
背景
FlowDiagram 的 background 选择外框的底纹,默认是向四周淡出的点阵:
import {
FlowBeam,
FlowDiagram,
FlowNode,
flowArrival,
flowRoute,
type FlowBackgroundName,
} from "@/components/ui/flow-diagram/flow-diagram"
const FIRST = flowRoute([
[56, 45],
[76, 45],
])
const SECOND = flowRoute([
[124, 45],
[144, 45],
])
// 时间轴(秒):同一张小图在不同底纹下反复播放,方便对比
const FIRST_AT = 0.5
const MIDDLE_AT = flowArrival(FIRST_AT, FIRST)
const SECOND_AT = MIDDLE_AT + 0.2
const LAST_AT = flowArrival(SECOND_AT, SECOND)
const REST = LAST_AT + 0.8
const EXIT = REST + 0.2
const DURATION = EXIT + 0.8
const TILES: {
caption: string
background: FlowBackgroundName | "custom"
fade?: boolean
}[] = [
{ caption: 'background="dots"', background: "dots" },
{ caption: 'background="grid"', background: "grid" },
{ caption: 'background="lines"', background: "lines" },
{ caption: 'background="hatch"', background: "hatch" },
{ caption: 'background="none"', background: "none" },
{ caption: "自定义元素 · fade={false}", background: "custom", fade: false },
]
/** 底纹选项:内置的四种底纹、不要底纹,以及传入自定义元素。 */
export default function FlowDiagramBackgroundDemo() {
return (
<div className="grid w-full max-w-2xl grid-cols-2 gap-4 sm:grid-cols-3">
{TILES.map(({ caption, background, fade }) => (
<figure key={caption} className="flex flex-col gap-2">
<FlowDiagram
width={200}
height={90}
duration={DURATION}
exit={EXIT}
rest={REST}
fit="contain"
background={
background === "custom" ? (
// 自定义底纹是一个铺满外框的元素,这里用两个多彩色调做斜向渐变
<div className="size-full bg-linear-to-br from-(--flow-blue)/15 to-(--flow-violet)/15" />
) : (
background
)
}
fade={fade}
className="aspect-[20/9] w-full rounded-lg border border-edge"
>
<FlowBeam route={FIRST} at={FIRST_AT} />
<FlowBeam route={SECOND} at={SECOND_AT} />
<FlowNode x={32} y={45} width={48} litAt={0.2} label="输入" />
<FlowNode
x={100}
y={45}
width={48}
litAt={MIDDLE_AT}
label="处理"
/>
<FlowNode x={168} y={45} width={48} litAt={LAST_AT} label="输出" />
</FlowDiagram>
<figcaption className="text-center font-mono text-xs text-muted-foreground">
{caption}
</figcaption>
</figure>
))}
</div>
)
}
<FlowDiagram background="grid" /> {/* 方格 */}
<FlowDiagram background="none" /> {/* 不要底纹 */}
<FlowDiagram background="dots" fade={false} /> {/* 点阵铺满,不淡出 */}
<FlowDiagram background={<MyPattern />} /> {/* 自己的底纹元素 */}
<FlowDiagram background="none" backgroundClassName="bg-muted/40" />- 内置底纹:
dots点阵、grid方格、lines横线、hatch斜纹、none无。点阵与斜纹用grid-layout提供的bg-dots、bg-hatch,方格与横线直接用线条颜色--edge画渐变,所以都不需要额外的工具类,安装grid-layout就够了。 fade控制底纹是否向四周淡出(默认淡出),让图示像摆在图纸上;贴着边框铺满时设为false。- 传一个元素给
background,它会被放进铺满外框的绝对定位层(带pointer-events-none),受fade影响;backgroundClassName追加到这一层上,调透明度、换颜色,或者配合background="none"写一个纯 CSS 的底纹。 - 底纹颜色用的是线条颜色
--edge,随亮暗模式和颜色主题变化。
播放控制
"use client"
import { useState } from "react"
import { Pause, Play, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
FlowBeam,
FlowDiagram,
FlowNode,
flowArrival,
flowRoute,
} from "@/components/ui/flow-diagram/flow-diagram"
const TO_PROCESS = flowRoute([
[80, 50],
[130, 50],
])
const TO_OUTPUT = flowRoute([
[190, 50],
[240, 50],
])
const FIRST_AT = 0.5
const PROCESS_AT = flowArrival(FIRST_AT, TO_PROCESS)
const SECOND_AT = PROCESS_AT + 0.2
const OUTPUT_AT = flowArrival(SECOND_AT, TO_OUTPUT)
const DURATION = 4.4
const EXIT = 3.6
const REST = 3.4
/** 播放控制:暂停 / 继续、倍速、是否循环;单次播放结束后停在静帧,点“重播”再来一遍。 */
export default function FlowDiagramPlaybackDemo() {
const [paused, setPaused] = useState(false)
const [fast, setFast] = useState(false)
const [loop, setLoop] = useState(true)
const [round, setRound] = useState(0)
return (
<div className="flex w-full max-w-md flex-col items-center gap-4">
<FlowDiagram
// 换 key 会重新挂载,时钟回到初始状态,单次播放就会重新开始
key={round}
width={320}
height={100}
duration={DURATION}
exit={EXIT}
rest={REST}
paused={paused}
speed={fast ? 2 : 1}
loop={loop}
trigger="always"
className="w-full"
>
<FlowBeam route={TO_PROCESS} at={FIRST_AT} />
<FlowBeam route={TO_OUTPUT} at={SECOND_AT} />
<FlowNode x={50} y={50} width={60} litAt={0.2} label="输入" />
<FlowNode x={160} y={50} width={60} litAt={PROCESS_AT} label="处理" />
<FlowNode x={270} y={50} width={60} litAt={OUTPUT_AT} label="输出" />
</FlowDiagram>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="outline"
size="sm"
aria-pressed={paused}
onClick={() => setPaused((value) => !value)}
>
{paused ? <Play /> : <Pause />}
{paused ? "继续" : "暂停"}
</Button>
<Button
variant="outline"
size="sm"
aria-pressed={fast}
onClick={() => setFast((value) => !value)}
>
{fast ? "2× 速" : "1× 速"}
</Button>
<Button
variant="outline"
size="sm"
aria-pressed={loop}
onClick={() => setLoop((value) => !value)}
>
{loop ? "循环" : "单次"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRound((value) => value + 1)}
>
<RotateCcw />
重播
</Button>
</div>
</div>
)
}
<FlowDiagram loop={false} /> {/* 只播一遍,停在 rest */}
<FlowDiagram trigger="always" /> {/* 不管是否在视口里都播 */}
<FlowDiagram paused={paused} speed={1.5} /> {/* 外部控制暂停与速度 */}loop(默认true):设为false时从头播一遍,走到rest就停在那一帧,不再淡出重来。因为首帧是rest静帧,所以单次播放开始时会从完整画面跳回起点再播;想重播,给FlowDiagram换一个key。trigger(默认"in-view"):in-view进入视口才播、离开暂停;always只要没暂停就一直播。paused(默认false):暂停,画面停在当前这一刻;还没开始播时就是rest静帧。可以由外部状态控制,例如“悬停时暂停”。speed(默认1):整条时间轴的速度倍率,2 是两倍速,0.5 是半速。duration、exit、rest等仍然按原速的秒数写。
不论怎么配置,三条保证不变:离开视口暂停、“减少动态效果”下固定在 rest、渲染期间不依赖随机数(水合稳定)。
布局
画布尺寸由 width、height 决定,子元素的坐标都以它为准;fit 决定显示时怎样适应外框:
scale-down(默认):只缩小不放大。外框比画布窄时等比缩小,宽时保持设计尺寸并居中,文字大小不会超过设计值;contain:等比缩放到刚好装进外框,外框更宽时会放大,适合让图示填满一个格子;none:始终保持设计尺寸,装不下的部分被外框裁掉。
外框的宽高交给 className:w-full max-w-md、h-48、aspect-video 等都可以,外框本身没有边框和固定高度。
连线动态
连线的两种动态都由同一个时间轴推导,没有独立的 CSS 动画或定时器,所以同样会在离开视口时暂停、在“减少动态效果”下停在 rest 这一帧(此时连线完整)。
生长
FlowBeam 默认 line="draw":底线从起点沿路径画出来,长度正好跟随光点头部,光点走到哪线画到哪,所以目标节点的 litAt 一到,线也刚好抵达。线画完后保持完整,到 exit 才随循环淡出,下一轮从空白重新生长。
- 生长之前会留一条很淡的虚线(
foreground/10)当引导,画面不会空着;不需要时传guide={false}。 - 并行分支用同一个
at出发,会同时开始生长,短的先到。 - 同一条线上的重复脉冲照旧用
base={false},只有第一次负责生长,线不会重复画。 - 想保留旧的写法(整条线一开始就在、不随循环淡出),用
line="static"。
不带光点的 FlowEdge 传了 at 也是默认生长,用时 drawDuration 缺省按光点的飞行速度由路径长度换算,和光点线的节奏一致;reveal="fade" 可以改回整条淡入。虚线(dashed)没法分段生长,会自动改成淡入。
<FlowEdge route={BACKUP} at={2.4} /> {/* 生长,2.4 秒开始 */}
<FlowEdge route={BACKUP} at={2.4} drawDuration={1} /> {/* 指定用时 */}
<FlowEdge route={BACKUP} at={2.4} reveal="fade" /> {/* 整条淡入 */}流动
给连线加 flow,画好之后线上会出现持续向前的虚线纹理,像数据在管道里流动,用来表示“常驻连接 / 持续的数据流”,例如推理引擎到各个服务的出口、数据源到清洗。纹理的颜色跟随 tone,速度是 FLOW_LINE_SPEED(14 像素/秒),比光点慢得多,只是一点克制的呼吸感;方向沿路径从起点指向终点。它在连线画完后淡入,到 exit 随循环淡出。
<FlowBeam route={TO_API} at={1.7} flow />
<FlowEdge route={TO_API} at={1.7} flow />flow 按条开启:同一条线上重复脉冲的几个 FlowBeam,只在负责画线的那一个上设置,避免两层纹理叠在一起。
API
FlowDiagram
设计画布宽度。子元素的坐标都以它为准,实际显示时按 fit 缩放
number | undefined216设计画布高度
类型number | undefined192一轮循环的总时长(秒),不含 speed 的换算
number | undefined8从这一刻起内容淡出,循环末尾回到起点
类型number | undefinedduration - 0.8静帧停留的时刻:服务端渲染、首帧与“减少动态效果”下显示的就是这一刻的画面,
应落在所有内容都已点亮、光点都已飞完之后、exit 之前
number | undefinedexit - 0.2默认色调:里面没有指定 tone 的原语都用它。可以是内置色调名,也可以是任意 CSS 颜色
FlowTone | undefined"primary"底纹:dots 点阵、grid 方格、lines 横线、hatch 斜纹、none 无;
也可以传一个元素自定义底纹,它会被放进铺满外框的绝对定位层里
ReactElement<unknown, string | React.JSXElementConstructor<any>> | FlowBackgroundName | undefined"dots"底纹是否向四周淡出
类型boolean | undefinedtrue追加到底纹层的类名,可以调整透明度,或者配合 background="none" 写自己的底纹
string | undefined画布如何适应外框:scale-down 只缩小不放大,contain 等比缩放到装进外框(会放大),
none 始终保持设计尺寸
FlowFit | undefined"scale-down"是否循环播放。设为 false 时从头播一遍,停在 rest 这一帧,不再淡出重来
boolean | undefinedtrue何时播放:in-view 进入视口才播、离开暂停,always 只要没暂停就一直播
"always" | "in-view" | undefined"in-view"暂停播放,画面停在当前这一刻(还没开始播时就是 rest 静帧)。可以由外部控制
boolean | undefinedfalse播放速度倍率,作用在整条时间轴上:2 是两倍速,0.5 是半速
类型number | undefined1图示的文字说明。传入后读屏会朗读它;不传则整个图示按纯装饰处理
类型string | undefined组件内容
类型ReactNode其余属性透传到根 div。
FlowNode
带细边框的节点;x、y 是中心点。litAt 到达时,边框和底色换成 tone 的颜色。shape 选择胶囊或方框,icon、label、description 是“图标 + 文字”的快捷写法,其余内容放进子元素。
中心点的横坐标
类型number中心点的纵坐标
类型numbernumber节点高度,带 description 的两行节点建议 30 以上
number | undefined20外形:pill 两头全圆的胶囊,box 小圆角矩形
FlowNodeShape | undefined"pill"圆角半径,覆盖 shape 的默认值
number | undefinedshape 为 "pill" 时是 height / 2,为 "box" 时是 5点亮后的色调,同时也是节点内部没有指定色调的状态点、加载圈、光标的颜色; 指定后图标也会染成这个颜色
类型FlowTone | undefined沿用 FlowDiagram 的 tone("primary")被点亮的时刻(秒),不传则不点亮
类型number | undefined放在文字左侧的图标,例如 lucide 的 Database
FlowIcon | undefined节点里的文字。有图标时图标 + 文字整体居中
类型string | undefined文字下方的第二行小字,muted 色
类型string | undefined组件内容
类型ReactNodeFlowDiamond
分叉 / 汇合点,旋转 45° 的小方块。
中心点的横坐标
类型number中心点的纵坐标
类型number点亮后的色调
类型FlowTone | undefined沿用 FlowDiagram 的 tone("primary")被点亮的时刻(秒),不传则不点亮
类型number | undefinedFlowLabel
等宽小标签。颜色用 tone,或者用 className 覆盖(例如 fill-muted-foreground text-[9px],注意 SVG 文字用的是 fill-* 而不是 text-*)。
锚点的横坐标
类型number文字垂直中线的纵坐标
类型number文字相对 x 的对齐方式
"end" | "middle" | "start" | undefined"middle"文字色调。不传则是前景色;注意 SVG 文字用 fill-* 而不是 text-* 着色
FlowTone | undefined附加的样式类名
类型string | undefined组件内容
类型ReactNodeFlowBeam
连线加一枚沿线流动的光点,带一小段拖尾。底线默认跟随光点生长,flow 让画好的线持续流动。
由 flowRoute 生成的路径
FlowRoute光点出发的时刻(秒)
类型number是否同时画出底线。同一条线上的重复脉冲设为 false,避免底线叠加变粗
boolean | undefinedtrue底线的出现方式:draw 跟随光点头部逐渐画出,到 exit 后淡出;
static 一开始就整条存在
"draw" | "static" | undefined"draw"生长之前是否先留一条很淡的虚线引导,只对 line="draw" 有效
boolean | undefinedtrue连线画好之后,线上是否有持续向前移动的虚线纹理,用于常驻的数据流
类型boolean | undefinedfalse色调,作用于光点、拖尾和流动纹理;明确指定后,生长的底线也会淡淡染上这个颜色。
失败、拒绝之类的分支用 destructive,多条并行链路可以各用一种颜色
FlowTone | undefined沿用 FlowDiagram 的 tone("primary")FlowEdge
不带光点的连线,用来画背景关系、备用路径,同样支持生长和流动。
由 flowRoute 生成的路径
FlowRoute出现的时刻(秒),不传则一直显示
类型number | undefined出现方式:draw 沿路径生长,fade 整条淡入。虚线(dashed)无法生长,会自动改为淡入
"draw" | "fade" | undefined"draw"生长用时(秒),缺省按光点的飞行速度由路径长度换算,和光点线的节奏一致
类型number | undefinedroute.length / FLOW_BEAM_SPEED生长之前是否先留一条很淡的虚线引导,只对生长有效
类型boolean | undefinedtrue是否画成虚线,表示可选或备用的路径
类型boolean | undefinedfalse线画好之后,线上是否有持续向前移动的虚线纹理,用于常驻的数据流
类型boolean | undefinedfalse连线和流动纹理的色调。不传则是中性的前景色,纹理跟随外层色调
类型FlowTone | undefinedundefinedFlowReveal
在 at 时刻淡入的容器,淡出由时间轴统一负责。
开始淡入的时刻(秒)
类型number淡入时从下方上浮的距离(设计尺寸中的像素)
类型number | undefined0淡入用时(秒)
类型number | undefined0.3组件内容
类型ReactNodeFlowTypedText
逐字打出的文字,后面跟一个会闪烁的光标。已打出的字数由时间推导。
string开始打第一个字的时刻(秒)
类型number每个字的间隔(秒)
类型number文字起点(左对齐)的横坐标
类型number文字垂直中线的纵坐标
类型number光标的色调
类型FlowTone | undefined沿用 FlowDiagram 的 tone("primary")FlowSpinner
旋转的加载圈,只在 at 到 until 之间显示。
中心点的横坐标
类型number中心点的纵坐标
类型number开始显示的时刻(秒)
类型number消失的时刻(秒),不传则停留到本轮淡出
类型number | undefined外径(设计尺寸中的像素)
类型number | undefined12圆环色调
类型FlowTone | undefined沿用外层(所在节点或 FlowDiagram)的 toneFlowStatusDot
状态点,可选向外扩散涟漪表示服务在运行。
圆心的横坐标
类型number圆心的纵坐标
类型number淡入的时刻(秒),不传则一直显示
类型number | undefined半径(设计尺寸中的像素)
类型number | undefined2点的色调,例如成功用 green、失败用 destructive
FlowTone | undefined沿用外层(所在节点或 FlowDiagram)的 tone是否向外扩散涟漪,表示服务在运行
类型boolean | undefinedfalse路径工具
function flowRoute(points: readonly FlowPoint[], radius?: number): FlowRoute
function flowArrival(at: number, route: FlowRoute): number
const FLOW_BEAM_SPEED: number // 110,光点飞行速度,单位:设计像素 / 秒
const FLOW_LINE_SPEED: number // 14,连线上流动纹理的速度,单位:设计像素 / 秒
function flowTextWidth(text: string, fontSize?: number): number // 估算文字宽度
function flowToneStyle(tone?: FlowTone): CSSProperties | undefined // 见「色调」flowRoute 把折线变成圆角路径并算出长度,radius 是拐角半径(默认 6)。它是纯函数,请在组件外调用。flowArrival 返回光点抵达终点的时刻。它们和 FLOW_BEAM_SPEED 都定义在不依赖 React 的 route.ts 里。flow-diagram.tsx 是客户端模块,服务端组件不能直接调用它再导出的函数:要在服务端组件里算路径,请改从 route.ts 引入;在带 "use client" 的文件里(案例都是这样)从 flow-diagram.tsx 引入即可。FLOW_BEAM_SPEED 在需要“让几束光点同时到达”时有用:出发时刻 = 到达时刻 - route.length / FLOW_BEAM_SPEED,参见微服务和 MCP 案例。
type FlowPoint = readonly [x: number, y: number]
type FlowRoute = { d: string; length: number }
type FlowToneName =
| "primary" | "foreground" | "muted" | "destructive"
| "blue" | "green" | "amber" | "rose" | "violet" | "cyan"
type FlowTone = FlowToneName | (string & {}) // 内置名,或任意 CSS 颜色
type FlowBackgroundName = "dots" | "grid" | "lines" | "hatch" | "none"
type FlowFit = "scale-down" | "contain" | "none"
type FlowNodeShape = "pill" | "box"flowTextWidth 按“拉丁字符 6px、中文 10px”(10px 字号)线性估算文字宽度,FlowNode 用它把图标和文字整体居中,FlowTypedText 用它摆光标;自己写需要对齐文字的元素时也可以用。
data-slot
| 元素 | data-slot |
|---|---|
| 外框 | flow-diagram |
| 底纹层 | flow-diagram-background |
| 节点 | flow-node |
| 分叉点 | flow-diamond |
| 标签 | flow-label |
| 光点连线 | flow-beam |
| 生长的底线 | flow-line |
| 流动纹理 | flow-line-flow |
| 独立连线 | flow-edge |
| 淡入容器 | flow-reveal |
| 逐字文字 | flow-typed-text |
| 加载圈 | flow-spinner |
| 状态点 | flow-status-dot |
可访问性
- 默认整个图示
aria-hidden,读屏完全忽略,适合作为纯装饰。传入label后外框变成role="img"并带上aria-label,内部的 SVG 内容不会再被逐个朗读,所以label应该概括整张图在讲什么,例如“请求从网关扇出到三个服务再汇合”。 - 图示不承载任何交互,没有可聚焦元素。
- 开启系统的“减少动态效果”时没有任何运动,画面固定在
rest这一帧,信息完整。 - 状态不只靠颜色:失败分支除了红色还有 “401” 之类的文字,完成状态有对勾。
注意事项
- 调整面板通过
FlowDiagramTuningContext覆盖子树里FlowDiagram的speed、tone、background、fade、loop与paused,并用onReport回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。 - 画布按设计尺寸绘制,窄屏时整体缩小,文字也会跟着缩小。设计时留意最窄的容器:10px 的文字缩到 0.7 倍就接近 7px,宽度小于 300px 的场景请把画布设计得更窄一些。
- 文字使用等宽字体,
FlowTypedText的光标位置按“拉丁字符 6px、中文 10px”估算,混排时请留一点余量。 - 底纹依赖
grid-layout提供的bg-dots、bg-hatch和--edge变量,安装时会一并装上;不需要底纹时传background="none"。 - 多彩色调依赖
--flow-blue等六个变量。如果你的项目是手动安装、没有经过shadcn add,需要自己把它们加进全局样式,否则这些色调的元素不会显示颜色。 tone里不是内置名的字符串都按 CSS 颜色处理,写错内置名(例如"bleu")不会报错,只是颜色失效。- 路径只支持直线和圆角,拐角由
flowRoute的radius控制;复杂曲线请自己写path并放进FlowReveal。 - 生长依赖路径长度的估算,圆角处有一两像素的误差,不影响观感;同一路径的光点与线头始终同步,因为两者用的是同一个长度。
- 外框默认没有边框和固定高度,首页的用法是
className="h-48 border-b border-edge"。
源码
"use client"
import { useInView, useMotionValue, useReducedMotion } from "motion/react"
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
type ComponentProps,
type ReactElement,
type ReactNode,
} from "react"
import { cn } from "@/lib/utils"
import {
FADE_MASK,
backgroundClasses,
type FlowBackgroundName,
} from "./background"
import { FlowBeam } from "./beam"
import {
FlowDiamond,
FlowEdge,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowStatusDot,
FlowTypedText,
type FlowIcon,
type FlowNodeShape,
} from "./elements"
import { FlowClockContext, useFlowClock } from "./flow-clock"
import {
FLOW_BEAM_SPEED,
FLOW_LINE_SPEED,
flowArrival,
flowRoute,
flowTextWidth,
type FlowPoint,
type FlowRoute,
} from "./route"
import { flowToneStyle, type FlowTone, type FlowToneName } from "./tone"
type FlowFit = "scale-down" | "contain" | "none"
const fitClasses: Record<FlowFit, string> = {
// 只缩小不放大:容器比设计画布窄时等比缩小,宽时保持设计尺寸
"scale-down": "h-auto max-w-full",
// 等比缩放到刚好装进容器,容器宽时会放大
contain: "size-full",
// 始终保持设计尺寸,装不下的部分被外框裁掉
none: "max-w-none shrink-0",
}
/**
* 调整用的覆盖项:子树里所有 `FlowDiagram` 的对应属性被这里的值替换。
* 用于调试面板一类的场景;不包 `FlowDiagramTuningContext.Provider` 时组件行为不变。
*/
type FlowDiagramTuning = {
speed?: number
tone?: FlowTone
background?: FlowBackgroundName
fade?: boolean
loop?: boolean
paused?: boolean
/** 回报示例自己的实际取值(未被覆盖前),面板据此显示当前值。 */
onReport?: (values: FlowDiagramReport) => void
}
type FlowDiagramReport = {
speed: number
tone: FlowTone
background: FlowBackgroundName | undefined
fade: boolean
loop: boolean
paused: boolean
}
const FlowDiagramTuningContext = createContext<FlowDiagramTuning>({})
/**
* 流程动画外框:底纹 + 固定设计尺寸的 SVG,并提供统一的时间轴。
*
* 所有动画都由同一个 `time` 推导,渲染期间不依赖 Date / 随机数,水合结果稳定:
* - 初始(含服务端渲染、减少动态效果)停在 `rest`,即内容完整、没有光点的一帧;
* - 进入视口后从 `rest` 继续走完停留 → 淡出 → 重新开始,所以不会闪一下;
* - 离开视口暂停,不占 CPU。
*/
function FlowDiagram({
width = 216,
height = 192,
duration = 8,
exit = duration - 0.8,
rest = exit - 0.2,
tone: toneProp = "primary",
background: backgroundProp = "dots",
fade: fadeProp = true,
backgroundClassName,
fit = "scale-down",
loop: loopProp = true,
trigger = "in-view",
paused: pausedProp = false,
speed: speedProp = 1,
label,
className,
style,
children,
...props
}: Omit<ComponentProps<"div">, "children"> & {
/**
* 设计画布宽度。子元素的坐标都以它为准,实际显示时按 `fit` 缩放
* @default 216
*/
width?: number
/**
* 设计画布高度
* @default 192
*/
height?: number
/**
* 一轮循环的总时长(秒),不含 `speed` 的换算
* @default 8
*/
duration?: number
/**
* 从这一刻起内容淡出,循环末尾回到起点
* @default duration - 0.8
*/
exit?: number
/**
* 静帧停留的时刻:服务端渲染、首帧与“减少动态效果”下显示的就是这一刻的画面,
* 应落在所有内容都已点亮、光点都已飞完之后、`exit` 之前
* @default exit - 0.2
*/
rest?: number
/**
* 默认色调:里面没有指定 `tone` 的原语都用它。可以是内置色调名,也可以是任意 CSS 颜色
* @default "primary"
*/
tone?: FlowTone
/**
* 底纹:`dots` 点阵、`grid` 方格、`lines` 横线、`hatch` 斜纹、`none` 无;
* 也可以传一个元素自定义底纹,它会被放进铺满外框的绝对定位层里
* @default "dots"
*/
background?: FlowBackgroundName | ReactElement
/**
* 底纹是否向四周淡出
* @default true
*/
fade?: boolean
/** 追加到底纹层的类名,可以调整透明度,或者配合 `background="none"` 写自己的底纹 */
backgroundClassName?: string
/**
* 画布如何适应外框:`scale-down` 只缩小不放大,`contain` 等比缩放到装进外框(会放大),
* `none` 始终保持设计尺寸
* @default "scale-down"
*/
fit?: FlowFit
/**
* 是否循环播放。设为 `false` 时从头播一遍,停在 `rest` 这一帧,不再淡出重来
* @default true
*/
loop?: boolean
/**
* 何时播放:`in-view` 进入视口才播、离开暂停,`always` 只要没暂停就一直播
* @default "in-view"
*/
trigger?: "in-view" | "always"
/**
* 暂停播放,画面停在当前这一刻(还没开始播时就是 `rest` 静帧)。可以由外部控制
* @default false
*/
paused?: boolean
/**
* 播放速度倍率,作用在整条时间轴上:2 是两倍速,0.5 是半速
* @default 1
*/
speed?: number
/** 图示的文字说明。传入后读屏会朗读它;不传则整个图示按纯装饰处理 */
label?: string
children?: ReactNode
}) {
const tuning = useContext(FlowDiagramTuningContext)
const { onReport } = tuning
useEffect(() => {
onReport?.({
speed: speedProp,
tone: toneProp,
background:
typeof backgroundProp === "string" ? backgroundProp : undefined,
fade: fadeProp,
loop: loopProp,
paused: pausedProp,
})
}, [
onReport,
speedProp,
toneProp,
backgroundProp,
fadeProp,
loopProp,
pausedProp,
])
const speed = tuning.speed ?? speedProp
const tone = tuning.tone ?? toneProp
const background = tuning.background ?? backgroundProp
const fade = tuning.fade ?? fadeProp
const loop = tuning.loop ?? loopProp
const paused = tuning.paused ?? pausedProp
const ref = useRef<HTMLDivElement>(null)
const inView = useInView(ref, { amount: 0.4 })
const reducedMotion = useReducedMotion()
const time = useMotionValue(rest)
const playing = !paused && !reducedMotion && (trigger === "always" || inView)
// 单次播放:是否已经从头开始过 / 已经播完
const began = useRef(false)
const finished = useRef(false)
useEffect(() => {
if (reducedMotion) time.set(rest)
}, [reducedMotion, rest, time])
// 切换循环模式后重新计一轮
useEffect(() => {
began.current = false
finished.current = false
}, [loop])
useEffect(() => {
if (!playing || (!loop && finished.current)) return
// 单次播放从起点开始;首帧是 rest 静帧,所以会从完整画面跳回空白再播
if (!loop && !began.current) {
began.current = true
time.set(0)
}
let last = performance.now()
let frame = requestAnimationFrame(function tick(now) {
// 切回标签页时间差会很大,截断后动画从原地继续而不是跳一大段
const delta = Math.min((now - last) / 1000, 0.1) * speed
last = now
const next = time.get() + delta
if (loop) {
time.set(next % duration)
} else if (next >= rest) {
time.set(rest)
finished.current = true
return
} else {
time.set(next)
}
frame = requestAnimationFrame(tick)
})
return () => cancelAnimationFrame(frame)
}, [playing, loop, speed, duration, rest, time])
const clock = useMemo(
() => ({ time, duration, exit }),
[time, duration, exit],
)
const preset = typeof background === "string" ? background : undefined
return (
<div
{...props}
ref={ref}
data-slot="flow-diagram"
{...(label
? { role: "img", "aria-label": label }
: { "aria-hidden": true })}
style={{ ...flowToneStyle(tone), ...style }}
className={cn(
"relative flex items-center justify-center overflow-hidden",
className,
)}
>
{preset !== "none" || backgroundClassName ? (
<div
data-slot="flow-diagram-background"
className={cn(
"pointer-events-none absolute inset-0",
preset && backgroundClasses[preset],
fade && FADE_MASK,
backgroundClassName,
)}
>
{typeof background === "string" ? null : background}
</div>
) : null}
<svg
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
fill="none"
focusable="false"
aria-hidden="true"
className={cn("relative", fitClasses[fit])}
>
{/* 整体平移半像素,1px 的线落在像素中心,边缘才清晰 */}
<g transform="translate(0.5 0.5)">
<FlowClockContext.Provider value={clock}>
{children}
</FlowClockContext.Provider>
</g>
</svg>
</div>
)
}
export {
FLOW_BEAM_SPEED,
FLOW_LINE_SPEED,
FlowBeam,
FlowDiagram,
FlowDiagramTuningContext,
FlowDiamond,
FlowEdge,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowStatusDot,
FlowTypedText,
flowArrival,
flowRoute,
flowTextWidth,
flowToneStyle,
useFlowClock,
}
export type {
FlowBackgroundName,
FlowDiagramReport,
FlowDiagramTuning,
FlowFit,
FlowIcon,
FlowNodeShape,
FlowPoint,
FlowRoute,
FlowTone,
FlowToneName,
}
"use client"
import { motion, useMotionValueEvent, useTransform } from "motion/react"
import {
useMemo,
useState,
type ComponentType,
type ReactNode,
type SVGProps,
} from "react"
import { cn } from "@/lib/utils"
import { FlowLineDraw, FlowLineFlow } from "./beam"
import { FLOW_BEAM_SPEED, flowTextWidth, type FlowRoute } from "./route"
import { useFlowClock } from "./flow-clock"
import { flowToneStyle, toneClasses, type FlowTone } from "./tone"
/** 节点里图标的边长,以及图标与文字之间的间距(设计像素) */
const ICON_SIZE = 12
const ICON_GAP = 4
/** 节点圆角:`pill` 是两头全圆的胶囊,`box` 是小圆角矩形 */
type FlowNodeShape = "pill" | "box"
const BOX_RADIUS = 5
/** 节点里的图标:任何接受 `x`、`y`、`width`、`height` 的 SVG 组件,例如 lucide 图标 */
type FlowIcon = ComponentType<SVGProps<SVGSVGElement>>
type Box = { x: number; y: number; width: number; height: number; rx: number }
/** 在 `at` 秒淡入(可带上浮),停留到 `exit` 后随循环淡出。 */
function FlowReveal({
at,
rise = 0,
fade = 0.3,
children,
}: {
/** 开始淡入的时刻(秒) */
at: number
/**
* 淡入时从下方上浮的距离(设计尺寸中的像素)
* @default 0
*/
rise?: number
/**
* 淡入用时(秒)
* @default 0.3
*/
fade?: number
children: ReactNode
}) {
const { time, exit, duration } = useFlowClock()
const opacity = useTransform(
time,
[at, at + fade, exit, duration],
[0, 1, 1, 0],
)
const y = useTransform(time, [at, at + fade], [rise, 0])
return (
<motion.g data-slot="flow-reveal" style={{ opacity, y }}>
{children}
</motion.g>
)
}
/** 节点被光点点亮后的状态层:色调描边加淡底色,颜色取外层的 `--flow-tone`。 */
function Lit({ box, at }: { box: Box; at: number }) {
const { time, exit, duration } = useFlowClock()
const opacity = useTransform(
time,
[at, at + 0.25, exit, duration],
[0, 1, 1, 0],
)
return (
<motion.g style={{ opacity }}>
<rect {...box} strokeWidth={1} className={toneClasses.lit} />
</motion.g>
)
}
/**
* 细边框节点。`x`、`y` 是中心点;`litAt` 到达时,边框和底色换成 `tone` 的颜色。
* 常见的“图标 + 文字”可以直接用 `icon`、`label`、`description`;
* 更复杂的内容请作为 `children` 传入,用 `FlowLabel` 等原语按绝对坐标摆放。
*/
function FlowNode({
x,
y,
width,
height = 20,
shape = "pill",
radius = shape === "box" ? BOX_RADIUS : height / 2,
tone,
litAt,
icon: Icon,
label,
description,
children,
}: {
/** 中心点的横坐标 */
x: number
/** 中心点的纵坐标 */
y: number
width: number
/**
* 节点高度,带 `description` 的两行节点建议 30 以上
* @default 20
*/
height?: number
/**
* 外形:`pill` 两头全圆的胶囊,`box` 小圆角矩形
* @default "pill"
*/
shape?: FlowNodeShape
/**
* 圆角半径,覆盖 `shape` 的默认值
* @default shape 为 "pill" 时是 height / 2,为 "box" 时是 5
*/
radius?: number
/**
* 点亮后的色调,同时也是节点内部没有指定色调的状态点、加载圈、光标的颜色;
* 指定后图标也会染成这个颜色
* @default 沿用 FlowDiagram 的 tone("primary")
*/
tone?: FlowTone
/** 被点亮的时刻(秒),不传则不点亮 */
litAt?: number
/** 放在文字左侧的图标,例如 lucide 的 `Database` */
icon?: FlowIcon
/** 节点里的文字。有图标时图标 + 文字整体居中 */
label?: string
/** 文字下方的第二行小字,muted 色 */
description?: string
children?: ReactNode
}) {
const box: Box = {
x: x - width / 2,
y: y - height / 2,
width,
height,
rx: radius,
}
const labelY = description ? y - 6 : y
// 图标 + 文字整体居中:先估算文字宽度,再从总宽度的左端开始排
const labelWidth = label ? flowTextWidth(label) : 0
const rowWidth = Icon
? ICON_SIZE + (label ? ICON_GAP + labelWidth : 0)
: labelWidth
const rowLeft = x - rowWidth / 2
return (
<g data-slot="flow-node" style={flowToneStyle(tone)}>
<rect
{...box}
strokeWidth={1}
className="fill-background stroke-foreground/30"
/>
{litAt === undefined ? null : <Lit box={box} at={litAt} />}
{Icon ? (
<Icon
x={rowLeft}
y={labelY - ICON_SIZE / 2}
width={ICON_SIZE}
height={ICON_SIZE}
strokeWidth={1.5}
aria-hidden="true"
className={
tone !== undefined ? "text-(--flow-tone)" : "text-muted-foreground"
}
/>
) : null}
{label ? (
<FlowLabel
x={Icon ? rowLeft + ICON_SIZE + ICON_GAP : x}
y={labelY}
anchor={Icon ? "start" : "middle"}
>
{label}
</FlowLabel>
) : null}
{description ? (
<FlowLabel x={x} y={y + 7} className="fill-muted-foreground text-[9px]">
{description}
</FlowLabel>
) : null}
{children}
</g>
)
}
/** 流程图里的分叉 / 汇合点:旋转 45° 的小方块。 */
function FlowDiamond({
x,
y,
tone,
litAt,
}: {
/** 中心点的横坐标 */
x: number
/** 中心点的纵坐标 */
y: number
/**
* 点亮后的色调
* @default 沿用 FlowDiagram 的 tone("primary")
*/
tone?: FlowTone
/** 被点亮的时刻(秒),不传则不点亮 */
litAt?: number
}) {
const box: Box = { x: -4, y: -4, width: 8, height: 8, rx: 1.5 }
return (
<g
data-slot="flow-diamond"
transform={`translate(${x} ${y}) rotate(45)`}
style={flowToneStyle(tone)}
>
<rect
{...box}
strokeWidth={1}
className="fill-background stroke-foreground/30"
/>
{litAt === undefined ? null : <Lit box={box} at={litAt} />}
</g>
)
}
/** 等宽小标签,默认居中;字号用 `className` 覆盖,颜色用 `tone` 或 `className`。 */
function FlowLabel({
x,
y,
anchor = "middle",
tone,
className,
children,
}: {
/** 锚点的横坐标 */
x: number
/** 文字垂直中线的纵坐标 */
y: number
/**
* 文字相对 `x` 的对齐方式
* @default "middle"
*/
anchor?: "start" | "middle" | "end"
/**
* 文字色调。不传则是前景色;注意 SVG 文字用 `fill-*` 而不是 `text-*` 着色
*/
tone?: FlowTone
className?: string
children: ReactNode
}) {
return (
<text
data-slot="flow-label"
x={x}
y={y}
textAnchor={anchor}
dominantBaseline="central"
style={flowToneStyle(tone)}
className={cn(
"font-mono text-[10px]",
tone !== undefined ? toneClasses.fill : "fill-foreground",
className,
)}
>
{children}
</text>
)
}
/**
* 逐字打出的文字,后面跟一个光标。
* 已打出的字数由时间推导,不依赖定时器;`x` 是文字起点(左对齐)。
*/
function FlowTypedText({
text,
at,
interval,
x,
y,
tone,
}: {
text: string
/** 开始打第一个字的时刻(秒) */
at: number
/** 每个字的间隔(秒) */
interval: number
/** 文字起点(左对齐)的横坐标 */
x: number
/** 文字垂直中线的纵坐标 */
y: number
/**
* 光标的色调
* @default 沿用 FlowDiagram 的 tone("primary")
*/
tone?: FlowTone
}) {
const { time, exit, duration } = useFlowClock()
const chars = useMemo(() => Array.from(text), [text])
const typedCount = (value: number) =>
Math.max(0, Math.min(chars.length, Math.floor((value - at) / interval) + 1))
const [count, setCount] = useState(() => typedCount(time.get()))
useMotionValueEvent(time, "change", (value) => setCount(typedCount(value)))
const typed = chars.slice(0, count).join("")
const caretX = x + flowTextWidth(typed)
const typingEnd = at + chars.length * interval
// 打字时常亮,打完后闪几下就收起
const caretOpacity = useTransform(time, (value) => {
if (value < at || value > typingEnd + 0.9) return 0
if (value < typingEnd) return 1
return Math.floor((value - typingEnd) * 3) % 2 === 0 ? 1 : 0
})
const opacity = useTransform(time, [exit, duration], [1, 0])
return (
<g data-slot="flow-typed-text" style={flowToneStyle(tone)}>
<motion.g style={{ opacity }}>
<FlowLabel x={x} y={y} anchor="start">
{typed}
</FlowLabel>
<motion.g style={{ opacity: caretOpacity }}>
<rect
x={caretX + 1}
y={y - 6}
width={1}
height={12}
className={toneClasses.fill}
/>
</motion.g>
</motion.g>
</g>
)
}
/**
* 不带光点的连线,用来画背景关系、备用路径。
* 传入 `at` 后默认从起点沿路径生长出来,到 `exit` 后随循环淡出;不传则一直显示。
*/
function FlowEdge({
route,
at,
reveal = "draw",
drawDuration = route.length / FLOW_BEAM_SPEED,
guide = true,
dashed = false,
flow = false,
tone,
}: {
/** 由 `flowRoute` 生成的路径 */
route: FlowRoute
/** 出现的时刻(秒),不传则一直显示 */
at?: number
/**
* 出现方式:`draw` 沿路径生长,`fade` 整条淡入。虚线(`dashed`)无法生长,会自动改为淡入
* @default "draw"
*/
reveal?: "draw" | "fade"
/**
* 生长用时(秒),缺省按光点的飞行速度由路径长度换算,和光点线的节奏一致
* @default route.length / FLOW_BEAM_SPEED
*/
drawDuration?: number
/**
* 生长之前是否先留一条很淡的虚线引导,只对生长有效
* @default true
*/
guide?: boolean
/**
* 是否画成虚线,表示可选或备用的路径
* @default false
*/
dashed?: boolean
/**
* 线画好之后,线上是否有持续向前移动的虚线纹理,用于常驻的数据流
* @default false
*/
flow?: boolean
/**
* 连线和流动纹理的色调。不传则是中性的前景色,纹理跟随外层色调
* @default undefined
*/
tone?: FlowTone
}) {
const growing = reveal === "draw" && !dashed
const tinted = tone !== undefined
const line = (
<path
d={route.d}
strokeWidth={1}
strokeDasharray={dashed ? "3 3" : undefined}
className={tinted ? toneClasses.line : "stroke-foreground/20"}
/>
)
const flowFrom = at === undefined ? 0 : at + (growing ? drawDuration : 0.3)
return (
<g data-slot="flow-edge" style={flowToneStyle(tone)}>
{at !== undefined && growing ? (
<FlowLineDraw
route={route}
at={at}
duration={drawDuration}
guide={guide}
tinted={tinted}
/>
) : at === undefined ? (
line
) : (
<FlowReveal at={at}>{line}</FlowReveal>
)}
{flow ? <FlowLineFlow route={route} from={flowFrom} /> : null}
</g>
)
}
/** 旋转的加载圈,只在 `at` 到 `until` 之间显示。`x`、`y` 是中心点。 */
function FlowSpinner({
x,
y,
at,
until,
size = 12,
tone,
}: {
/** 中心点的横坐标 */
x: number
/** 中心点的纵坐标 */
y: number
/** 开始显示的时刻(秒) */
at: number
/** 消失的时刻(秒),不传则停留到本轮淡出 */
until?: number
/**
* 外径(设计尺寸中的像素)
* @default 12
*/
size?: number
/**
* 圆环色调
* @default 沿用外层(所在节点或 FlowDiagram)的 tone
*/
tone?: FlowTone
}) {
const { time, exit, duration } = useFlowClock()
const end = until ?? exit
const opacity = useTransform(
time,
[at, at + 0.15, end, end + 0.2, duration],
[0, 1, 1, 0, 0],
)
const rotate = useTransform(time, (value) => value * 540)
const radius = size / 2 - 1
const circumference = 2 * Math.PI * radius
return (
<g data-slot="flow-spinner" style={flowToneStyle(tone)}>
<motion.g style={{ opacity }}>
<motion.g
style={{
rotate,
transformBox: "fill-box",
transformOrigin: "center",
}}
>
<circle
cx={x}
cy={y}
r={radius}
strokeWidth={1.5}
strokeLinecap="round"
strokeDasharray={`${circumference * 0.7} ${circumference}`}
className={toneClasses.stroke}
/>
</motion.g>
</motion.g>
</g>
)
}
/** 状态点:到 `at` 时淡入;`pulse` 打开后向外扩散一圈圈涟漪。 */
function FlowStatusDot({
x,
y,
at,
radius = 2,
tone,
pulse = false,
}: {
/** 圆心的横坐标 */
x: number
/** 圆心的纵坐标 */
y: number
/** 淡入的时刻(秒),不传则一直显示 */
at?: number
/**
* 半径(设计尺寸中的像素)
* @default 2
*/
radius?: number
/**
* 点的色调,例如成功用 `green`、失败用 `destructive`
* @default 沿用外层(所在节点或 FlowDiagram)的 tone
*/
tone?: FlowTone
/**
* 是否向外扩散涟漪,表示服务在运行
* @default false
*/
pulse?: boolean
}) {
const dot = (
<g data-slot="flow-status-dot" style={flowToneStyle(tone)}>
<circle cx={x} cy={y} r={radius} className={toneClasses.fill} />
{pulse ? <Ripple x={x} y={y} radius={radius} at={at ?? 0} /> : null}
</g>
)
return at === undefined ? dot : <FlowReveal at={at}>{dot}</FlowReveal>
}
/** 每 1.6 秒一圈的涟漪,相位由时间推导。 */
function Ripple({
x,
y,
radius,
at,
}: {
x: number
y: number
radius: number
at: number
}) {
const { time } = useFlowClock()
const phase = useTransform(time, (value) =>
value < at ? 1 : ((value - at) % 1.6) / 1.6,
)
const r = useTransform(phase, [0, 1], [radius, radius * 3.2])
const opacity = useTransform(phase, [0, 1], [0.5, 0])
return (
<motion.circle
cx={x}
cy={y}
r={r}
strokeWidth={1}
className={cn("fill-none", toneClasses.stroke)}
style={{ opacity }}
/>
)
}
export {
FlowDiamond,
FlowEdge,
FlowLabel,
FlowNode,
FlowReveal,
FlowSpinner,
FlowStatusDot,
FlowTypedText,
}
export type { FlowIcon, FlowNodeShape }
"use client"
import { motion, motionValue, useTransform } from "motion/react"
import { useMemo } from "react"
import { useFlowClock } from "./flow-clock"
import { FLOW_BEAM_SPEED, FLOW_LINE_SPEED, type FlowRoute } from "./route"
import { flowToneStyle, toneClasses, type FlowTone } from "./tone"
/** 光点本体与拖尾的长度(像素) */
const HEAD_LENGTH = 8
const TAIL_LENGTH = 26
/** “流动”虚线纹理的短划与间隔长度(像素) */
const FLOW_DASH = 4
const FLOW_GAP = 8
/** 生长完成后,引导虚线退场、流动纹理淡入所用的时间(秒) */
const SETTLE = 0.3
/**
* 常量也要包成 MotionValue 再放进 `style`:motion 只在 `pathLength` 是动画值时才把
* `pathLength` / `pathSpacing` / `pathOffset` 换算成 SVG 的 stroke-dasharray,
* 直接写数字会被当成普通 CSS,虚线不生效,光点会变成一整条实线。
*/
function useConstantValue(value: number) {
return useMemo(() => motionValue(value), [value])
}
/**
* 沿路径逐渐画出来的连线。
* 画出的长度由时间推导(`at` 到 `at + duration` 从 0 到整条),到 `exit` 后随循环淡出;
* 生长之前可以留一条很淡的虚线当引导,画完后让位给实线。
*/
function FlowLineDraw({
route: { d },
at,
duration,
guide = true,
tinted = false,
}: {
route: FlowRoute
/** 开始生长的时刻(秒) */
at: number
/** 生长用时(秒) */
duration: number
/** 生长之前是否先画一条很淡的虚线引导 */
guide?: boolean
/** 实线是否染上 `--flow-tone` 的颜色,否则是中性的前景色 */
tinted?: boolean
}) {
const { time, exit, duration: cycle } = useFlowClock()
const arrival = at + duration
const progress = useTransform(time, [at, arrival], [0, 1])
const opacity = useTransform(time, [exit, cycle], [1, 0])
// 引导线在画完后很快退场,循环末尾随实线淡出时再回来,下一轮开头才不会空
const guideOpacity = useTransform(
time,
[arrival, Math.min(arrival + SETTLE, exit), exit, cycle],
[1, 0, 0, 1],
)
return (
<g data-slot="flow-line">
{guide ? (
<motion.path
d={d}
strokeWidth={1}
strokeDasharray="2 3"
className="stroke-foreground/10"
style={{ opacity: guideOpacity }}
/>
) : null}
<motion.path
d={d}
strokeWidth={1}
className={tinted ? toneClasses.line : "stroke-foreground/20"}
style={{ pathLength: progress, opacity }}
/>
</g>
)
}
/**
* 连线上持续向前移动的虚线纹理,像数据在管道里流动。
* 位移由时间推导,所以和光点一样会在离开视口时暂停、在静帧下停住。
*/
function FlowLineFlow({
route: { d, length },
from,
}: {
route: FlowRoute
/** 纹理开始淡入的时刻(秒),通常是连线画完的那一刻;颜色取外层的 `--flow-tone` */
from: number
}) {
const { time, exit, duration } = useFlowClock()
const dash = useConstantValue(FLOW_DASH / length)
const gap = useConstantValue(FLOW_GAP / length)
const offset = useTransform(
time,
(value) => (value * FLOW_LINE_SPEED) / length,
)
const opacity = useTransform(
time,
[from, Math.min(from + SETTLE, exit), exit, duration],
[0, 1, 1, 0],
)
return (
<motion.path
data-slot="flow-line-flow"
d={d}
strokeWidth={1.5}
className={toneClasses.flow}
style={{
pathLength: dash,
pathSpacing: gap,
pathOffset: offset,
opacity,
}}
/>
)
}
/**
* 连线 + 沿线流动的光点(带一小段拖尾)。
* 默认连线跟着光点头部一路生长出来;`flow` 可以在画好之后让线上持续流动。
*/
function FlowBeam({
route,
at,
base = true,
line = "draw",
guide = true,
flow = false,
tone,
}: {
/** 由 `flowRoute` 生成的路径 */
route: FlowRoute
/** 光点出发的时刻(秒) */
at: number
/**
* 是否同时画出底线。同一条线上的重复脉冲设为 `false`,避免底线叠加变粗
* @default true
*/
base?: boolean
/**
* 底线的出现方式:`draw` 跟随光点头部逐渐画出,到 `exit` 后淡出;
* `static` 一开始就整条存在
* @default "draw"
*/
line?: "draw" | "static"
/**
* 生长之前是否先留一条很淡的虚线引导,只对 `line="draw"` 有效
* @default true
*/
guide?: boolean
/**
* 连线画好之后,线上是否有持续向前移动的虚线纹理,用于常驻的数据流
* @default false
*/
flow?: boolean
/**
* 色调,作用于光点、拖尾和流动纹理;明确指定后,生长的底线也会淡淡染上这个颜色。
* 失败、拒绝之类的分支用 `destructive`,多条并行链路可以各用一种颜色
* @default 沿用 FlowDiagram 的 tone("primary")
*/
tone?: FlowTone
}) {
const { d, length } = route
const { time } = useFlowClock()
const travel = length / FLOW_BEAM_SPEED
const end = at + travel + TAIL_LENGTH / FLOW_BEAM_SPEED
// 光点头部沿路径走过的距离;超出终点的部分让拖尾也走完
const head = useTransform(time, [at, end], [0, length + TAIL_LENGTH])
const headOffset = useTransform(
head,
(value) => (value - HEAD_LENGTH) / length,
)
const tailOffset = useTransform(
head,
(value) => (value - TAIL_LENGTH) / length,
)
const headOpacity = useTransform(time, (value) =>
value > at && value < end ? 1 : 0,
)
const tailOpacity = useTransform(time, (value) =>
value > at && value < end ? 0.35 : 0,
)
const tailLength = useConstantValue(TAIL_LENGTH / length)
const headLength = useConstantValue(HEAD_LENGTH / length)
const spacing = useConstantValue(2)
const stroke = toneClasses.stroke
return (
<g data-slot="flow-beam" style={flowToneStyle(tone)}>
{base ? (
line === "draw" ? (
<FlowLineDraw
route={route}
at={at}
duration={travel}
guide={guide}
tinted={tone !== undefined}
/>
) : (
<path
d={d}
strokeWidth={1}
className={
tone !== undefined ? toneClasses.line : "stroke-foreground/20"
}
/>
)
) : null}
{flow ? <FlowLineFlow route={route} from={at + travel} /> : null}
<motion.path
d={d}
strokeWidth={1.5}
strokeLinecap="round"
className={stroke}
style={{
pathLength: tailLength,
pathSpacing: spacing,
pathOffset: tailOffset,
opacity: tailOpacity,
}}
/>
<motion.path
d={d}
strokeWidth={2}
strokeLinecap="round"
className={stroke}
style={{
pathLength: headLength,
pathSpacing: spacing,
pathOffset: headOffset,
opacity: headOpacity,
}}
/>
</g>
)
}
export { FlowBeam, FlowLineDraw, FlowLineFlow }
// 纯函数,不依赖 React:服务端组件也可以直接调用
/** 光点飞行速度(像素/秒):各段耗时由路径长度换算,同一张图里节奏才一致 */
const FLOW_BEAM_SPEED = 110
/** 连线上“流动”虚线纹理的前进速度(像素/秒):比光点慢得多,只是常驻连接的呼吸感 */
const FLOW_LINE_SPEED = 14
/** 等宽字 10px 下的字符宽度,用来估算文字宽度(中文按全角 10px 计) */
const LATIN_ADVANCE = 6
const CJK_ADVANCE = 10
type FlowPoint = readonly [number, number]
type FlowRoute = { d: string; length: number }
function round(value: number) {
return Math.round(value * 100) / 100
}
/**
* 把折线变成圆角路径,并返回近似长度。
* 长度要提前知道:光点的出现和到达时间都靠它换算,不用在浏览器里量。
* 建议在组件外调用,每次渲染重新计算没有意义。
*/
function flowRoute(points: readonly FlowPoint[], radius = 6): FlowRoute {
let d = `M${points[0][0]} ${points[0][1]}`
let length = 0
for (let index = 1; index < points.length; index += 1) {
const [px, py] = points[index - 1]
const [cx, cy] = points[index]
const next = points[index + 1]
const incoming = Math.hypot(cx - px, cy - py)
length += incoming
if (!next) {
d += ` L${cx} ${cy}`
continue
}
const [nx, ny] = next
const outgoing = Math.hypot(nx - cx, ny - cy)
const corner = Math.min(radius, incoming / 2, outgoing / 2)
const inX = (cx - px) / incoming
const inY = (cy - py) / incoming
const outX = (nx - cx) / outgoing
const outY = (ny - cy) / outgoing
d += ` L${round(cx - inX * corner)} ${round(cy - inY * corner)}`
d += ` Q${cx} ${cy} ${round(cx + outX * corner)} ${round(cy + outY * corner)}`
// 二次贝塞尔拐角比两条直角边短约 0.4 倍半径
length -= 0.4 * corner
}
return { d, length: round(length) }
}
/** 光点从 `at` 出发后到达终点的时刻,用来点亮目标节点。 */
function flowArrival(at: number, { length }: FlowRoute) {
return at + length / FLOW_BEAM_SPEED
}
/**
* 估算一段文字的宽度(设计像素),用来给图标 + 文字居中、摆放光标。
* 不量真实字形,按“拉丁字符 6px、中文 10px”(10px 字号)线性换算,混排时请留一点余量。
*/
function flowTextWidth(text: string, fontSize = 10) {
let width = 0
for (const char of text)
width += char.charCodeAt(0) > 0xff ? CJK_ADVANCE : LATIN_ADVANCE
return (width * fontSize) / 10
}
export {
FLOW_BEAM_SPEED,
FLOW_LINE_SPEED,
flowArrival,
flowRoute,
flowTextWidth,
}
export type { FlowPoint, FlowRoute }
"use client"
import { createContext, useContext } from "react"
import type { MotionValue } from "motion/react"
type FlowClock = {
/** 当前循环内的时间(秒) */
time: MotionValue<number>
/** 一轮循环的总时长(秒) */
duration: number
/** 从这一刻起动态内容淡出,循环末尾回到起点 */
exit: number
}
const FlowClockContext = createContext<FlowClock | null>(null)
/** 读取 `FlowDiagram` 提供的时间轴,用来写自定义的动画元素。 */
function useFlowClock() {
const clock = useContext(FlowClockContext)
if (!clock) throw new Error("useFlowClock 只能在 FlowDiagram 内使用")
return clock
}
export { FlowClockContext, useFlowClock }
export type { FlowClock }
import type { CSSProperties } from "react"
/**
* 内置色调。
* - `primary`、`foreground`、`muted`、`destructive` 直接引用主题变量,跟随颜色主题;
* - `blue` 等多彩色调引用 `--flow-blue` 这类变量,由 registry 条目的 `cssVars` 提供亮 / 暗两套取值,
* 在自己的全局样式里改写它们即可换成品牌色。
*/
type FlowToneName =
| "primary"
| "foreground"
| "muted"
| "destructive"
| "blue"
| "green"
| "amber"
| "rose"
| "violet"
| "cyan"
/** 色调:内置名,或任意 CSS 颜色(`"#f97316"`、`"oklch(0.7 0.2 150)"`、`"var(--brand)"` 都可以)。 */
type FlowTone = FlowToneName | (string & {})
const FLOW_TONES: Record<FlowToneName, string> = {
primary: "var(--primary)",
foreground: "var(--foreground)",
muted: "var(--muted-foreground)",
destructive: "var(--destructive)",
blue: "var(--flow-blue)",
green: "var(--flow-green)",
amber: "var(--flow-amber)",
rose: "var(--flow-rose)",
violet: "var(--flow-violet)",
cyan: "var(--flow-cyan)",
}
/**
* 把色调换成 `--flow-tone` 这个 CSS 变量。
* 所有带颜色的原语都只读 `--flow-tone`,变量沿 DOM 向下继承,所以在任何元素(包括你自己写的 SVG)
* 上设置它,里面没有指定 `tone` 的原语都会跟着变。不传 `tone` 返回 `undefined`,即沿用外层的色调。
*/
function flowToneStyle(tone?: FlowTone): CSSProperties | undefined {
if (tone === undefined) return undefined
const color = Object.hasOwn(FLOW_TONES, tone)
? FLOW_TONES[tone as FlowToneName]
: tone
return { "--flow-tone": color } as CSSProperties
}
// Tailwind 只识别完整的类名,所以每种用法都把类写全
const toneClasses = {
/** 节点被点亮:淡底色 + 描边 */
lit: "fill-(--flow-tone)/10 stroke-(--flow-tone)",
stroke: "stroke-(--flow-tone)",
/** 常驻连接上流动的虚线纹理 */
flow: "stroke-(--flow-tone)/40",
/** 指定了色调的连线:淡淡染上色调 */
line: "stroke-(--flow-tone)/30",
fill: "fill-(--flow-tone)",
} as const
export { FLOW_TONES, flowToneStyle, toneClasses }
export type { FlowTone, FlowToneName }
/** 外框内置的底纹:`dots` 点阵、`grid` 方格、`lines` 横线、`hatch` 斜纹、`none` 不要底纹。 */
type FlowBackgroundName = "dots" | "grid" | "lines" | "hatch" | "none"
// 点阵与斜纹来自 grid-layout 提供的 bg-dots / bg-hatch;方格与横线直接用 --edge 画渐变,
// 所以只依赖 grid-layout 带来的 --edge 变量,不需要额外的工具类
const backgroundClasses = {
dots: "bg-dots",
grid: "bg-[linear-gradient(to_right,var(--edge)_1px,transparent_1px),linear-gradient(to_bottom,var(--edge)_1px,transparent_1px)] bg-[size:16px_16px]",
lines:
"bg-[linear-gradient(to_bottom,var(--edge)_1px,transparent_1px)] bg-[size:100%_12px]",
hatch: "bg-hatch",
none: "",
} as const satisfies Record<FlowBackgroundName, string>
/** 向四周淡出的遮罩,让图示像摆在图纸上 */
const FADE_MASK =
"[mask-image:radial-gradient(ellipse_at_center,black_20%,transparent_80%)]"
export { FADE_MASK, backgroundClasses }
export type { FlowBackgroundName }