跳至正文
组件

Flow Diagram流程动画图示

节点、细线与流动光点组成的循环动画流程图:统一时间轴、进入视口才播放、减少动态效果时停在静帧;色调与底纹可配置,附带光点路径、逐字输入、加载圈与状态点等原语。

正在加载…

上图是一条 AI 应用的全链路:Web、App、CLI 三个入口错开发出请求,经网关进入 Agent;Agent 并行调度知识库、工具与大模型,三路耗时各异;结果汇总后沿原路送回每个入口。入口蓝、Agent 紫、三类能力各一种颜色、返回的结果绿,用到了图标节点、点亮、加载圈与状态点、常驻连线的流动纹理和方格底纹。

提炼自本站首页「我在做什么」里的三幅小插画。它不是一个“传数据就出图”的图表组件,而是一组 SVG 原语:你按设计坐标摆节点、连线、写标签,再给每一步指定出现的时刻,整张图就会按这条时间轴循环播放。

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/flow-diagram.json
会连带安装 本站条目 grid-layout。已存在的文件会询问是否覆盖。

用法

一张图由 FlowDiagram 外框和里面的原语组成。所有坐标都落在 width × height 的设计画布上,显示时整体等比缩小,所以 FlowDiagram 的宽度交给 className 控制即可。

app/pipeline.tsx
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>
  )
}

最小的例子渲染出来是下面这样,三个节点依次点亮:

正在加载…

约定很少:

  • 节点是容器。 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 图标。

正在加载…

AI Agent

文档与工具两路来源汇入输入框,逐字提问后进入“思考”,再依次给出答案条目。示例里的“星花变加载环”是用 useFlowClock 自己写的动画元素。文档蓝、工具琥珀、提问紫、结果绿,答案条目的图标和标签沿用各自来源的颜色。

正在加载…

大模型部署

模型汇入推理引擎,再分发到 API、Chat、Agent 三个出口。同一条线上有两轮请求先后流过,第二轮用 base={false} 只画光点,避免底线叠粗。推理引擎到三个出口的线是常驻连接,画好后用 flow 让虚线持续向前流动。两个模型各用一种冷色,节点图标取自 simple-icons 的品牌图标(示例里包成 FlowIcon),推理引擎是琥珀色,就绪的出口变绿。

正在加载…

RAG 检索增强

上排是入库:文档切片、嵌入、写入向量库;下排是问答:问题经检索拿到 top-3 片段,交给 LLM 生成,最后逐字打出回答。两条竖线分别负责“查询进入”和“结果取出”,不会重叠。

正在加载…

CI/CD 流水线

push 之后 lint 与 test 并行,耗时不同;先跑完的先打勾,汇合点要等最慢的那个。build 完成后部署上线,状态点带涟漪。琥珀色表示进行中、绿色表示通过:加载圈是琥珀色,对勾和通过之后的光点、部署节点是绿色。对勾是自己写的 lucide 图标,用 flowToneStyle("green") 给它设置色调。

正在加载…

登录鉴权

第一次密码错误,Auth 返回 401,红色光点沿一条环路绕回用户;重试通过后签发 JWT,带着 Bearer 访问受保护资源。FlowBeam 的 tone="destructive" 用来标出失败分支,这条环路的底线也会淡淡染成红色。

正在加载…

微服务请求链路

网关扇出到三个服务并行处理,光点按路径长度反推出发时刻,所以同时到达;各服务按自己的耗时返回,耗时标签随返回的光点出现,汇合点等最慢的那个,最后得到总耗时。每个服务一种颜色(tone),扇出的光点、节点和返回的光点都用它,节点里的加载圈和状态点自动继承节点的颜色。

正在加载…

数据管道 ETL

竖向布局:三个数据源汇入清洗,进度条走完后写入数据仓库,再在看板上依次长出柱状图。每个数据源一种颜色,用 icon + label 一行写完节点;汇入清洗的三条线带 flow,表示数据源一直在接入。进度条和柱子是用 useFlowClock + useTransform 写的自定义元素,颜色用 fill-(--flow-tone) 取所在节点的色调。

正在加载…

MCP 工具调用

请求与结果走同一条线:先 tools/list 向三个服务器发现能力,再 tools/call 调用数据库,结果沿原路返回。返回路径只是把端点倒过来,用 base={false} 复用已经画好的线。

正在加载…

多模型路由

请求先经过路由器,再按任务分流到不同的模型:每个模型一种颜色,请求的光点、路由器到模型的连线和模型节点都用它。入口的线用一条 FlowEdge 画一次,三个请求的光点都用 base={false} 走这条线。模型节点用 label + description 写两行文字,加载圈和状态点放在节点里,颜色自动继承。

正在加载…

品牌色

在 FlowDiagram 上设一次 tone,里面没有单独指定色调的节点、光点、加载圈、状态点都继承它。tone 不限于内置名,任何 CSS 颜色都可以,这个例子用主题变量混出一个颜色;换成你自己的品牌色直接写 tone="#ff6a00" 或 tone="oklch(0.7 0.2 40)" 即可。

正在加载…

时间轴

一张图只有一个时钟:从 0 走到 duration 秒,到头回到 0 重新开始。所有元素都是这个时间的函数,没有各自的定时器,所以不同元素之间不会错位。

三者的关系是 最后一个元素出现 < 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>
正在加载…

内置色调

名称取值说明
primaryvar(--primary)默认。随颜色主题变化
foregroundvar(--foreground)最醒目的中性色
mutedvar(--muted-foreground)弱化的中性色
destructivevar(--destructive)失败、拒绝
blue green amber rose violet cyanvar(--flow-blue) 等多彩色调,不随颜色主题变化

多彩色调来自六个 CSS 变量,由 registry 条目的 cssVars 提供亮 / 暗两套 oklch 值,shadcn add 时会写进你的全局样式(本站已经在 globals.css 里同名定义):

变量亮色暗色
--flow-blueoklch(0.56 0.2 258)oklch(0.72 0.15 255)
--flow-greenoklch(0.62 0.16 150)oklch(0.77 0.17 152)
--flow-amberoklch(0.7 0.16 70)oklch(0.82 0.15 82)
--flow-roseoklch(0.62 0.21 5)oklch(0.74 0.16 8)
--flow-violetoklch(0.56 0.22 292)oklch(0.74 0.15 295)
--flow-cyanoklch(0.66 0.12 215)oklch(0.82 0.11 208)

想换成品牌色,在全局样式里覆盖这些变量即可,所有用到它的图示一起变:

app/globals.css
: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 选择外框的底纹,默认是向四周淡出的点阵:

正在加载…
<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,随亮暗模式和颜色主题变化。

播放控制

正在加载…
<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

其余属性透传到根 div。

FlowNode

带细边框的节点;x、y 是中心点。litAt 到达时,边框和底色换成 tone 的颜色。shape 选择胶囊或方框,icon、label、description 是“图标 + 文字”的快捷写法,其余内容放进子元素。

FlowDiamond

分叉 / 汇合点,旋转 45° 的小方块。

FlowLabel

等宽小标签。颜色用 tone,或者用 className 覆盖(例如 fill-muted-foreground text-[9px],注意 SVG 文字用的是 fill-* 而不是 text-*)。

FlowBeam

连线加一枚沿线流动的光点,带一小段拖尾。底线默认跟随光点生长,flow 让画好的线持续流动。

FlowEdge

不带光点的连线,用来画背景关系、备用路径,同样支持生长和流动。

FlowReveal

在 at 时刻淡入的容器,淡出由时间轴统一负责。

FlowTypedText

逐字打出的文字,后面跟一个会闪烁的光标。已打出的字数由时间推导。

FlowSpinner

旋转的加载圈,只在 at 到 until 之间显示。

FlowStatusDot

状态点,可选向外扩散涟漪表示服务在运行。

路径工具

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"。

源码

components/ui/flow-diagram/flow-diagram.tsx
"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,
}
components/ui/flow-diagram/elements.tsx
"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 }
components/ui/flow-diagram/beam.tsx
"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 }
components/ui/flow-diagram/route.ts
// 纯函数,不依赖 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 }
components/ui/flow-diagram/flow-clock.tsx
"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 }
components/ui/flow-diagram/tone.ts
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 }
components/ui/flow-diagram/background.ts
/** 外框内置的底纹:`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 }