{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flow-diagram",
  "title": "流程动画图示",
  "description": "节点、细线与流动光点组成的循环动画流程图：统一时间轴、进入视口才播放、减少动态效果时停在静帧；色调与底纹可配置，附带光点路径、逐字输入、加载圈与状态点等原语。",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://yiui.dev/r/grid-layout.json",
    "https://yiui.dev/r/flow-tone.json"
  ],
  "files": [
    {
      "path": "src/registry/ui/flow-diagram/flow-diagram.tsx",
      "content": "\"use client\"\n\nimport { useInView, useMotionValue, useReducedMotion } from \"motion/react\"\nimport {\n  createContext,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  type ComponentProps,\n  type ReactElement,\n  type ReactNode,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport {\n  FADE_MASK,\n  backgroundClasses,\n  type FlowBackgroundName,\n} from \"./background\"\nimport { FlowBeam } from \"./beam\"\nimport {\n  FlowDiamond,\n  FlowEdge,\n  FlowLabel,\n  FlowNode,\n  FlowReveal,\n  FlowSpinner,\n  FlowStatusDot,\n  FlowTypedText,\n  type FlowIcon,\n  type FlowNodeShape,\n} from \"./elements\"\nimport { FlowClockContext, useFlowClock } from \"./flow-clock\"\nimport {\n  FLOW_BEAM_SPEED,\n  FLOW_LINE_SPEED,\n  flowArrival,\n  flowRoute,\n  flowTextWidth,\n  type FlowPoint,\n  type FlowRoute,\n} from \"./route\"\nimport {\n  flowToneStyle,\n  type FlowTone,\n  type FlowToneName,\n} from \"@/lib/flow-tone\"\n\ntype FlowFit = \"scale-down\" | \"contain\" | \"none\"\n\nconst fitClasses: Record<FlowFit, string> = {\n  // 只缩小不放大：容器比设计画布窄时等比缩小，宽时保持设计尺寸\n  \"scale-down\": \"h-auto max-w-full\",\n  // 等比缩放到刚好装进容器，容器宽时会放大\n  contain: \"size-full\",\n  // 始终保持设计尺寸，装不下的部分被外框裁掉\n  none: \"max-w-none shrink-0\",\n}\n\n/**\n * 调整用的覆盖项：子树里所有 `FlowDiagram` 的对应属性被这里的值替换。\n * 用于调试面板一类的场景；不包 `FlowDiagramTuningContext.Provider` 时组件行为不变。\n */\ntype FlowDiagramTuning = {\n  speed?: number\n  tone?: FlowTone\n  background?: FlowBackgroundName\n  fade?: boolean\n  loop?: boolean\n  paused?: boolean\n  /** 回报示例自己的实际取值（未被覆盖前），面板据此显示当前值。 */\n  onReport?: (values: FlowDiagramReport) => void\n}\n\ntype FlowDiagramReport = {\n  speed: number\n  tone: FlowTone\n  background: FlowBackgroundName | undefined\n  fade: boolean\n  loop: boolean\n  paused: boolean\n}\n\nconst FlowDiagramTuningContext = createContext<FlowDiagramTuning>({})\n\n/**\n * 流程动画外框：底纹 + 固定设计尺寸的 SVG，并提供统一的时间轴。\n *\n * 所有动画都由同一个 `time` 推导，渲染期间不依赖 Date / 随机数，水合结果稳定：\n * - 初始（含服务端渲染、减少动态效果）停在 `rest`，即内容完整、没有光点的一帧；\n * - 进入视口后从 `rest` 继续走完停留 → 淡出 → 重新开始，所以不会闪一下；\n * - 离开视口暂停，不占 CPU。\n */\nfunction FlowDiagram({\n  width = 216,\n  height = 192,\n  duration = 8,\n  exit = duration - 0.8,\n  rest = exit - 0.2,\n  tone: toneProp = \"primary\",\n  background: backgroundProp = \"dots\",\n  fade: fadeProp = true,\n  backgroundClassName,\n  fit = \"scale-down\",\n  loop: loopProp = true,\n  trigger = \"in-view\",\n  paused: pausedProp = false,\n  speed: speedProp = 1,\n  label,\n  className,\n  style,\n  children,\n  ...props\n}: Omit<ComponentProps<\"div\">, \"children\"> & {\n  /**\n   * 设计画布宽度。子元素的坐标都以它为准，实际显示时按 `fit` 缩放\n   * @default 216\n   */\n  width?: number\n  /**\n   * 设计画布高度\n   * @default 192\n   */\n  height?: number\n  /**\n   * 一轮循环的总时长（秒），不含 `speed` 的换算\n   * @default 8\n   */\n  duration?: number\n  /**\n   * 从这一刻起内容淡出，循环末尾回到起点\n   * @default duration - 0.8\n   */\n  exit?: number\n  /**\n   * 静帧停留的时刻：服务端渲染、首帧与“减少动态效果”下显示的就是这一刻的画面，\n   * 应落在所有内容都已点亮、光点都已飞完之后、`exit` 之前\n   * @default exit - 0.2\n   */\n  rest?: number\n  /**\n   * 默认色调：里面没有指定 `tone` 的原语都用它。可以是内置色调名，也可以是任意 CSS 颜色\n   * @default \"primary\"\n   */\n  tone?: FlowTone\n  /**\n   * 底纹：`dots` 点阵、`grid` 方格、`lines` 横线、`hatch` 斜纹、`none` 无；\n   * 也可以传一个元素自定义底纹，它会被放进铺满外框的绝对定位层里\n   * @default \"dots\"\n   */\n  background?: FlowBackgroundName | ReactElement\n  /**\n   * 底纹是否向四周淡出\n   * @default true\n   */\n  fade?: boolean\n  /** 追加到底纹层的类名，可以调整透明度，或者配合 `background=\"none\"` 写自己的底纹 */\n  backgroundClassName?: string\n  /**\n   * 画布如何适应外框：`scale-down` 只缩小不放大，`contain` 等比缩放到装进外框（会放大），\n   * `none` 始终保持设计尺寸\n   * @default \"scale-down\"\n   */\n  fit?: FlowFit\n  /**\n   * 是否循环播放。设为 `false` 时从头播一遍，停在 `rest` 这一帧，不再淡出重来\n   * @default true\n   */\n  loop?: boolean\n  /**\n   * 何时播放：`in-view` 进入视口才播、离开暂停，`always` 只要没暂停就一直播\n   * @default \"in-view\"\n   */\n  trigger?: \"in-view\" | \"always\"\n  /**\n   * 暂停播放，画面停在当前这一刻（还没开始播时就是 `rest` 静帧）。可以由外部控制\n   * @default false\n   */\n  paused?: boolean\n  /**\n   * 播放速度倍率，作用在整条时间轴上：2 是两倍速，0.5 是半速\n   * @default 1\n   */\n  speed?: number\n  /** 图示的文字说明。传入后读屏会朗读它；不传则整个图示按纯装饰处理 */\n  label?: string\n  children?: ReactNode\n}) {\n  const tuning = useContext(FlowDiagramTuningContext)\n  const { onReport } = tuning\n  useEffect(() => {\n    onReport?.({\n      speed: speedProp,\n      tone: toneProp,\n      background:\n        typeof backgroundProp === \"string\" ? backgroundProp : undefined,\n      fade: fadeProp,\n      loop: loopProp,\n      paused: pausedProp,\n    })\n  }, [\n    onReport,\n    speedProp,\n    toneProp,\n    backgroundProp,\n    fadeProp,\n    loopProp,\n    pausedProp,\n  ])\n  const speed = tuning.speed ?? speedProp\n  const tone = tuning.tone ?? toneProp\n  const background = tuning.background ?? backgroundProp\n  const fade = tuning.fade ?? fadeProp\n  const loop = tuning.loop ?? loopProp\n  const paused = tuning.paused ?? pausedProp\n  const ref = useRef<HTMLDivElement>(null)\n  const inView = useInView(ref, { amount: 0.4 })\n  const reducedMotion = useReducedMotion()\n  const time = useMotionValue(rest)\n  const playing = !paused && !reducedMotion && (trigger === \"always\" || inView)\n  // 单次播放：是否已经从头开始过 / 已经播完\n  const began = useRef(false)\n  const finished = useRef(false)\n\n  useEffect(() => {\n    if (reducedMotion) time.set(rest)\n  }, [reducedMotion, rest, time])\n\n  // 切换循环模式后重新计一轮\n  useEffect(() => {\n    began.current = false\n    finished.current = false\n  }, [loop])\n\n  useEffect(() => {\n    if (!playing || (!loop && finished.current)) return\n    // 单次播放从起点开始；首帧是 rest 静帧，所以会从完整画面跳回空白再播\n    if (!loop && !began.current) {\n      began.current = true\n      time.set(0)\n    }\n    let last = performance.now()\n    let frame = requestAnimationFrame(function tick(now) {\n      // 切回标签页时间差会很大，截断后动画从原地继续而不是跳一大段\n      const delta = Math.min((now - last) / 1000, 0.1) * speed\n      last = now\n      const next = time.get() + delta\n      if (loop) {\n        time.set(next % duration)\n      } else if (next >= rest) {\n        time.set(rest)\n        finished.current = true\n        return\n      } else {\n        time.set(next)\n      }\n      frame = requestAnimationFrame(tick)\n    })\n    return () => cancelAnimationFrame(frame)\n  }, [playing, loop, speed, duration, rest, time])\n\n  const clock = useMemo(\n    () => ({ time, duration, exit }),\n    [time, duration, exit],\n  )\n\n  const preset = typeof background === \"string\" ? background : undefined\n\n  return (\n    <div\n      {...props}\n      ref={ref}\n      data-slot=\"flow-diagram\"\n      {...(label\n        ? { role: \"img\", \"aria-label\": label }\n        : { \"aria-hidden\": true })}\n      style={{ ...flowToneStyle(tone), ...style }}\n      className={cn(\n        \"relative flex items-center justify-center overflow-hidden\",\n        className,\n      )}\n    >\n      {preset !== \"none\" || backgroundClassName ? (\n        <div\n          data-slot=\"flow-diagram-background\"\n          className={cn(\n            \"pointer-events-none absolute inset-0\",\n            preset && backgroundClasses[preset],\n            fade && FADE_MASK,\n            backgroundClassName,\n          )}\n        >\n          {typeof background === \"string\" ? null : background}\n        </div>\n      ) : null}\n      <svg\n        viewBox={`0 0 ${width} ${height}`}\n        width={width}\n        height={height}\n        fill=\"none\"\n        focusable=\"false\"\n        aria-hidden=\"true\"\n        className={cn(\"relative\", fitClasses[fit])}\n      >\n        {/* 整体平移半像素，1px 的线落在像素中心，边缘才清晰 */}\n        <g transform=\"translate(0.5 0.5)\">\n          <FlowClockContext.Provider value={clock}>\n            {children}\n          </FlowClockContext.Provider>\n        </g>\n      </svg>\n    </div>\n  )\n}\n\nexport {\n  FLOW_BEAM_SPEED,\n  FLOW_LINE_SPEED,\n  FlowBeam,\n  FlowDiagram,\n  FlowDiagramTuningContext,\n  FlowDiamond,\n  FlowEdge,\n  FlowLabel,\n  FlowNode,\n  FlowReveal,\n  FlowSpinner,\n  FlowStatusDot,\n  FlowTypedText,\n  flowArrival,\n  flowRoute,\n  flowTextWidth,\n  flowToneStyle,\n  useFlowClock,\n}\nexport type {\n  FlowBackgroundName,\n  FlowDiagramReport,\n  FlowDiagramTuning,\n  FlowFit,\n  FlowIcon,\n  FlowNodeShape,\n  FlowPoint,\n  FlowRoute,\n  FlowTone,\n  FlowToneName,\n}\n",
      "type": "registry:ui",
      "target": "@ui/flow-diagram/flow-diagram.tsx"
    },
    {
      "path": "src/registry/ui/flow-diagram/elements.tsx",
      "content": "\"use client\"\n\nimport { motion, useMotionValueEvent, useTransform } from \"motion/react\"\nimport {\n  useMemo,\n  useState,\n  type ComponentType,\n  type ReactNode,\n  type SVGProps,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport { FlowLineDraw, FlowLineFlow } from \"./beam\"\nimport { FLOW_BEAM_SPEED, flowTextWidth, type FlowRoute } from \"./route\"\nimport { useFlowClock } from \"./flow-clock\"\nimport {\n  flowToneStyle,\n  toneClasses,\n  type FlowTone,\n} from \"@/lib/flow-tone\"\n\n/** 节点里图标的边长，以及图标与文字之间的间距（设计像素） */\nconst ICON_SIZE = 12\nconst ICON_GAP = 4\n\n/** 节点圆角：`pill` 是两头全圆的胶囊，`box` 是小圆角矩形 */\ntype FlowNodeShape = \"pill\" | \"box\"\nconst BOX_RADIUS = 5\n\n/** 节点里的图标：任何接受 `x`、`y`、`width`、`height` 的 SVG 组件，例如 lucide 图标 */\ntype FlowIcon = ComponentType<SVGProps<SVGSVGElement>>\n\ntype Box = { x: number; y: number; width: number; height: number; rx: number }\n\n/** 在 `at` 秒淡入（可带上浮），停留到 `exit` 后随循环淡出。 */\nfunction FlowReveal({\n  at,\n  rise = 0,\n  fade = 0.3,\n  children,\n}: {\n  /** 开始淡入的时刻（秒） */\n  at: number\n  /**\n   * 淡入时从下方上浮的距离（设计尺寸中的像素）\n   * @default 0\n   */\n  rise?: number\n  /**\n   * 淡入用时（秒）\n   * @default 0.3\n   */\n  fade?: number\n  children: ReactNode\n}) {\n  const { time, exit, duration } = useFlowClock()\n  const opacity = useTransform(\n    time,\n    [at, at + fade, exit, duration],\n    [0, 1, 1, 0],\n  )\n  const y = useTransform(time, [at, at + fade], [rise, 0])\n  return (\n    <motion.g data-slot=\"flow-reveal\" style={{ opacity, y }}>\n      {children}\n    </motion.g>\n  )\n}\n\n/** 节点被光点点亮后的状态层：色调描边加淡底色，颜色取外层的 `--flow-tone`。 */\nfunction Lit({ box, at }: { box: Box; at: number }) {\n  const { time, exit, duration } = useFlowClock()\n  const opacity = useTransform(\n    time,\n    [at, at + 0.25, exit, duration],\n    [0, 1, 1, 0],\n  )\n  return (\n    <motion.g style={{ opacity }}>\n      <rect {...box} strokeWidth={1} className={toneClasses.lit} />\n    </motion.g>\n  )\n}\n\n/**\n * 细边框节点。`x`、`y` 是中心点；`litAt` 到达时，边框和底色换成 `tone` 的颜色。\n * 常见的“图标 + 文字”可以直接用 `icon`、`label`、`description`；\n * 更复杂的内容请作为 `children` 传入，用 `FlowLabel` 等原语按绝对坐标摆放。\n */\nfunction FlowNode({\n  x,\n  y,\n  width,\n  height = 20,\n  shape = \"pill\",\n  radius = shape === \"box\" ? BOX_RADIUS : height / 2,\n  tone,\n  litAt,\n  icon: Icon,\n  label,\n  description,\n  children,\n}: {\n  /** 中心点的横坐标 */\n  x: number\n  /** 中心点的纵坐标 */\n  y: number\n  width: number\n  /**\n   * 节点高度，带 `description` 的两行节点建议 30 以上\n   * @default 20\n   */\n  height?: number\n  /**\n   * 外形：`pill` 两头全圆的胶囊，`box` 小圆角矩形\n   * @default \"pill\"\n   */\n  shape?: FlowNodeShape\n  /**\n   * 圆角半径，覆盖 `shape` 的默认值\n   * @default shape 为 \"pill\" 时是 height / 2，为 \"box\" 时是 5\n   */\n  radius?: number\n  /**\n   * 点亮后的色调，同时也是节点内部没有指定色调的状态点、加载圈、光标的颜色；\n   * 指定后图标也会染成这个颜色\n   * @default 沿用 FlowDiagram 的 tone（\"primary\"）\n   */\n  tone?: FlowTone\n  /** 被点亮的时刻（秒），不传则不点亮 */\n  litAt?: number\n  /** 放在文字左侧的图标，例如 lucide 的 `Database` */\n  icon?: FlowIcon\n  /** 节点里的文字。有图标时图标 + 文字整体居中 */\n  label?: string\n  /** 文字下方的第二行小字，muted 色 */\n  description?: string\n  children?: ReactNode\n}) {\n  const box: Box = {\n    x: x - width / 2,\n    y: y - height / 2,\n    width,\n    height,\n    rx: radius,\n  }\n  const labelY = description ? y - 6 : y\n  // 图标 + 文字整体居中：先估算文字宽度，再从总宽度的左端开始排\n  const labelWidth = label ? flowTextWidth(label) : 0\n  const rowWidth = Icon\n    ? ICON_SIZE + (label ? ICON_GAP + labelWidth : 0)\n    : labelWidth\n  const rowLeft = x - rowWidth / 2\n  return (\n    <g data-slot=\"flow-node\" style={flowToneStyle(tone)}>\n      <rect\n        {...box}\n        strokeWidth={1}\n        className=\"fill-background stroke-foreground/30\"\n      />\n      {litAt === undefined ? null : <Lit box={box} at={litAt} />}\n      {Icon ? (\n        <Icon\n          x={rowLeft}\n          y={labelY - ICON_SIZE / 2}\n          width={ICON_SIZE}\n          height={ICON_SIZE}\n          strokeWidth={1.5}\n          aria-hidden=\"true\"\n          className={\n            tone !== undefined ? \"text-(--flow-tone)\" : \"text-muted-foreground\"\n          }\n        />\n      ) : null}\n      {label ? (\n        <FlowLabel\n          x={Icon ? rowLeft + ICON_SIZE + ICON_GAP : x}\n          y={labelY}\n          anchor={Icon ? \"start\" : \"middle\"}\n        >\n          {label}\n        </FlowLabel>\n      ) : null}\n      {description ? (\n        <FlowLabel x={x} y={y + 7} className=\"fill-muted-foreground text-[9px]\">\n          {description}\n        </FlowLabel>\n      ) : null}\n      {children}\n    </g>\n  )\n}\n\n/** 流程图里的分叉 / 汇合点：旋转 45° 的小方块。 */\nfunction FlowDiamond({\n  x,\n  y,\n  tone,\n  litAt,\n}: {\n  /** 中心点的横坐标 */\n  x: number\n  /** 中心点的纵坐标 */\n  y: number\n  /**\n   * 点亮后的色调\n   * @default 沿用 FlowDiagram 的 tone（\"primary\"）\n   */\n  tone?: FlowTone\n  /** 被点亮的时刻（秒），不传则不点亮 */\n  litAt?: number\n}) {\n  const box: Box = { x: -4, y: -4, width: 8, height: 8, rx: 1.5 }\n  return (\n    <g\n      data-slot=\"flow-diamond\"\n      transform={`translate(${x} ${y}) rotate(45)`}\n      style={flowToneStyle(tone)}\n    >\n      <rect\n        {...box}\n        strokeWidth={1}\n        className=\"fill-background stroke-foreground/30\"\n      />\n      {litAt === undefined ? null : <Lit box={box} at={litAt} />}\n    </g>\n  )\n}\n\n/** 等宽小标签，默认居中；字号用 `className` 覆盖，颜色用 `tone` 或 `className`。 */\nfunction FlowLabel({\n  x,\n  y,\n  anchor = \"middle\",\n  tone,\n  className,\n  children,\n}: {\n  /** 锚点的横坐标 */\n  x: number\n  /** 文字垂直中线的纵坐标 */\n  y: number\n  /**\n   * 文字相对 `x` 的对齐方式\n   * @default \"middle\"\n   */\n  anchor?: \"start\" | \"middle\" | \"end\"\n  /**\n   * 文字色调。不传则是前景色；注意 SVG 文字用 `fill-*` 而不是 `text-*` 着色\n   */\n  tone?: FlowTone\n  className?: string\n  children: ReactNode\n}) {\n  return (\n    <text\n      data-slot=\"flow-label\"\n      x={x}\n      y={y}\n      textAnchor={anchor}\n      dominantBaseline=\"central\"\n      style={flowToneStyle(tone)}\n      className={cn(\n        \"font-mono text-[10px]\",\n        tone !== undefined ? toneClasses.fill : \"fill-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </text>\n  )\n}\n\n/**\n * 逐字打出的文字，后面跟一个光标。\n * 已打出的字数由时间推导，不依赖定时器；`x` 是文字起点（左对齐）。\n */\nfunction FlowTypedText({\n  text,\n  at,\n  interval,\n  x,\n  y,\n  tone,\n}: {\n  text: string\n  /** 开始打第一个字的时刻（秒） */\n  at: number\n  /** 每个字的间隔（秒） */\n  interval: number\n  /** 文字起点（左对齐）的横坐标 */\n  x: number\n  /** 文字垂直中线的纵坐标 */\n  y: number\n  /**\n   * 光标的色调\n   * @default 沿用 FlowDiagram 的 tone（\"primary\"）\n   */\n  tone?: FlowTone\n}) {\n  const { time, exit, duration } = useFlowClock()\n  const chars = useMemo(() => Array.from(text), [text])\n  const typedCount = (value: number) =>\n    Math.max(0, Math.min(chars.length, Math.floor((value - at) / interval) + 1))\n  const [count, setCount] = useState(() => typedCount(time.get()))\n  useMotionValueEvent(time, \"change\", (value) => setCount(typedCount(value)))\n\n  const typed = chars.slice(0, count).join(\"\")\n  const caretX = x + flowTextWidth(typed)\n  const typingEnd = at + chars.length * interval\n  // 打字时常亮，打完后闪几下就收起\n  const caretOpacity = useTransform(time, (value) => {\n    if (value < at || value > typingEnd + 0.9) return 0\n    if (value < typingEnd) return 1\n    return Math.floor((value - typingEnd) * 3) % 2 === 0 ? 1 : 0\n  })\n  const opacity = useTransform(time, [exit, duration], [1, 0])\n\n  return (\n    <g data-slot=\"flow-typed-text\" style={flowToneStyle(tone)}>\n      <motion.g style={{ opacity }}>\n        <FlowLabel x={x} y={y} anchor=\"start\">\n          {typed}\n        </FlowLabel>\n        <motion.g style={{ opacity: caretOpacity }}>\n          <rect\n            x={caretX + 1}\n            y={y - 6}\n            width={1}\n            height={12}\n            className={toneClasses.fill}\n          />\n        </motion.g>\n      </motion.g>\n    </g>\n  )\n}\n\n/**\n * 不带光点的连线，用来画背景关系、备用路径。\n * 传入 `at` 后默认从起点沿路径生长出来，到 `exit` 后随循环淡出；不传则一直显示。\n */\nfunction FlowEdge({\n  route,\n  at,\n  reveal = \"draw\",\n  drawDuration = route.length / FLOW_BEAM_SPEED,\n  guide = true,\n  dashed = false,\n  flow = false,\n  tone,\n}: {\n  /** 由 `flowRoute` 生成的路径 */\n  route: FlowRoute\n  /** 出现的时刻（秒），不传则一直显示 */\n  at?: number\n  /**\n   * 出现方式：`draw` 沿路径生长，`fade` 整条淡入。虚线（`dashed`）无法生长，会自动改为淡入\n   * @default \"draw\"\n   */\n  reveal?: \"draw\" | \"fade\"\n  /**\n   * 生长用时（秒），缺省按光点的飞行速度由路径长度换算，和光点线的节奏一致\n   * @default route.length / FLOW_BEAM_SPEED\n   */\n  drawDuration?: number\n  /**\n   * 生长之前是否先留一条很淡的虚线引导，只对生长有效\n   * @default true\n   */\n  guide?: boolean\n  /**\n   * 是否画成虚线，表示可选或备用的路径\n   * @default false\n   */\n  dashed?: boolean\n  /**\n   * 线画好之后，线上是否有持续向前移动的虚线纹理，用于常驻的数据流\n   * @default false\n   */\n  flow?: boolean\n  /**\n   * 连线和流动纹理的色调。不传则是中性的前景色，纹理跟随外层色调\n   * @default undefined\n   */\n  tone?: FlowTone\n}) {\n  const growing = reveal === \"draw\" && !dashed\n  const tinted = tone !== undefined\n  const line = (\n    <path\n      d={route.d}\n      strokeWidth={1}\n      strokeDasharray={dashed ? \"3 3\" : undefined}\n      className={tinted ? toneClasses.line : \"stroke-foreground/20\"}\n    />\n  )\n  const flowFrom = at === undefined ? 0 : at + (growing ? drawDuration : 0.3)\n  return (\n    <g data-slot=\"flow-edge\" style={flowToneStyle(tone)}>\n      {at !== undefined && growing ? (\n        <FlowLineDraw\n          route={route}\n          at={at}\n          duration={drawDuration}\n          guide={guide}\n          tinted={tinted}\n        />\n      ) : at === undefined ? (\n        line\n      ) : (\n        <FlowReveal at={at}>{line}</FlowReveal>\n      )}\n      {flow ? <FlowLineFlow route={route} from={flowFrom} /> : null}\n    </g>\n  )\n}\n\n/** 旋转的加载圈，只在 `at` 到 `until` 之间显示。`x`、`y` 是中心点。 */\nfunction FlowSpinner({\n  x,\n  y,\n  at,\n  until,\n  size = 12,\n  tone,\n}: {\n  /** 中心点的横坐标 */\n  x: number\n  /** 中心点的纵坐标 */\n  y: number\n  /** 开始显示的时刻（秒） */\n  at: number\n  /** 消失的时刻（秒），不传则停留到本轮淡出 */\n  until?: number\n  /**\n   * 外径（设计尺寸中的像素）\n   * @default 12\n   */\n  size?: number\n  /**\n   * 圆环色调\n   * @default 沿用外层（所在节点或 FlowDiagram）的 tone\n   */\n  tone?: FlowTone\n}) {\n  const { time, exit, duration } = useFlowClock()\n  const end = until ?? exit\n  const opacity = useTransform(\n    time,\n    [at, at + 0.15, end, end + 0.2, duration],\n    [0, 1, 1, 0, 0],\n  )\n  const rotate = useTransform(time, (value) => value * 540)\n  const radius = size / 2 - 1\n  const circumference = 2 * Math.PI * radius\n  return (\n    <g data-slot=\"flow-spinner\" style={flowToneStyle(tone)}>\n      <motion.g style={{ opacity }}>\n        <motion.g\n          style={{\n            rotate,\n            transformBox: \"fill-box\",\n            transformOrigin: \"center\",\n          }}\n        >\n          <circle\n            cx={x}\n            cy={y}\n            r={radius}\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeDasharray={`${circumference * 0.7} ${circumference}`}\n            className={toneClasses.stroke}\n          />\n        </motion.g>\n      </motion.g>\n    </g>\n  )\n}\n\n/** 状态点：到 `at` 时淡入；`pulse` 打开后向外扩散一圈圈涟漪。 */\nfunction FlowStatusDot({\n  x,\n  y,\n  at,\n  radius = 2,\n  tone,\n  pulse = false,\n}: {\n  /** 圆心的横坐标 */\n  x: number\n  /** 圆心的纵坐标 */\n  y: number\n  /** 淡入的时刻（秒），不传则一直显示 */\n  at?: number\n  /**\n   * 半径（设计尺寸中的像素）\n   * @default 2\n   */\n  radius?: number\n  /**\n   * 点的色调，例如成功用 `green`、失败用 `destructive`\n   * @default 沿用外层（所在节点或 FlowDiagram）的 tone\n   */\n  tone?: FlowTone\n  /**\n   * 是否向外扩散涟漪，表示服务在运行\n   * @default false\n   */\n  pulse?: boolean\n}) {\n  const dot = (\n    <g data-slot=\"flow-status-dot\" style={flowToneStyle(tone)}>\n      <circle cx={x} cy={y} r={radius} className={toneClasses.fill} />\n      {pulse ? <Ripple x={x} y={y} radius={radius} at={at ?? 0} /> : null}\n    </g>\n  )\n  return at === undefined ? dot : <FlowReveal at={at}>{dot}</FlowReveal>\n}\n\n/** 每 1.6 秒一圈的涟漪，相位由时间推导。 */\nfunction Ripple({\n  x,\n  y,\n  radius,\n  at,\n}: {\n  x: number\n  y: number\n  radius: number\n  at: number\n}) {\n  const { time } = useFlowClock()\n  const phase = useTransform(time, (value) =>\n    value < at ? 1 : ((value - at) % 1.6) / 1.6,\n  )\n  const r = useTransform(phase, [0, 1], [radius, radius * 3.2])\n  const opacity = useTransform(phase, [0, 1], [0.5, 0])\n  return (\n    <motion.circle\n      cx={x}\n      cy={y}\n      r={r}\n      strokeWidth={1}\n      className={cn(\"fill-none\", toneClasses.stroke)}\n      style={{ opacity }}\n    />\n  )\n}\n\nexport {\n  FlowDiamond,\n  FlowEdge,\n  FlowLabel,\n  FlowNode,\n  FlowReveal,\n  FlowSpinner,\n  FlowStatusDot,\n  FlowTypedText,\n}\nexport type { FlowIcon, FlowNodeShape }\n",
      "type": "registry:ui",
      "target": "@ui/flow-diagram/elements.tsx"
    },
    {
      "path": "src/registry/ui/flow-diagram/beam.tsx",
      "content": "\"use client\"\n\nimport { motion, motionValue, useTransform } from \"motion/react\"\nimport { useMemo } from \"react\"\n\nimport { useFlowClock } from \"./flow-clock\"\nimport { FLOW_BEAM_SPEED, FLOW_LINE_SPEED, type FlowRoute } from \"./route\"\nimport {\n  flowToneStyle,\n  toneClasses,\n  type FlowTone,\n} from \"@/lib/flow-tone\"\n\n/** 光点本体与拖尾的长度（像素） */\nconst HEAD_LENGTH = 8\nconst TAIL_LENGTH = 26\n\n/** “流动”虚线纹理的短划与间隔长度（像素） */\nconst FLOW_DASH = 4\nconst FLOW_GAP = 8\n/** 生长完成后，引导虚线退场、流动纹理淡入所用的时间（秒） */\nconst SETTLE = 0.3\n\n/**\n * 常量也要包成 MotionValue 再放进 `style`：motion 只在 `pathLength` 是动画值时才把\n * `pathLength` / `pathSpacing` / `pathOffset` 换算成 SVG 的 stroke-dasharray，\n * 直接写数字会被当成普通 CSS，虚线不生效，光点会变成一整条实线。\n */\nfunction useConstantValue(value: number) {\n  return useMemo(() => motionValue(value), [value])\n}\n\n/**\n * 沿路径逐渐画出来的连线。\n * 画出的长度由时间推导（`at` 到 `at + duration` 从 0 到整条），到 `exit` 后随循环淡出；\n * 生长之前可以留一条很淡的虚线当引导，画完后让位给实线。\n */\nfunction FlowLineDraw({\n  route: { d },\n  at,\n  duration,\n  guide = true,\n  tinted = false,\n}: {\n  route: FlowRoute\n  /** 开始生长的时刻（秒） */\n  at: number\n  /** 生长用时（秒） */\n  duration: number\n  /** 生长之前是否先画一条很淡的虚线引导 */\n  guide?: boolean\n  /** 实线是否染上 `--flow-tone` 的颜色，否则是中性的前景色 */\n  tinted?: boolean\n}) {\n  const { time, exit, duration: cycle } = useFlowClock()\n  const arrival = at + duration\n  const progress = useTransform(time, [at, arrival], [0, 1])\n  const opacity = useTransform(time, [exit, cycle], [1, 0])\n  // 引导线在画完后很快退场，循环末尾随实线淡出时再回来，下一轮开头才不会空\n  const guideOpacity = useTransform(\n    time,\n    [arrival, Math.min(arrival + SETTLE, exit), exit, cycle],\n    [1, 0, 0, 1],\n  )\n  return (\n    <g data-slot=\"flow-line\">\n      {guide ? (\n        <motion.path\n          d={d}\n          strokeWidth={1}\n          strokeDasharray=\"2 3\"\n          className=\"stroke-foreground/10\"\n          style={{ opacity: guideOpacity }}\n        />\n      ) : null}\n      <motion.path\n        d={d}\n        strokeWidth={1}\n        className={tinted ? toneClasses.line : \"stroke-foreground/20\"}\n        style={{ pathLength: progress, opacity }}\n      />\n    </g>\n  )\n}\n\n/**\n * 连线上持续向前移动的虚线纹理，像数据在管道里流动。\n * 位移由时间推导，所以和光点一样会在离开视口时暂停、在静帧下停住。\n */\nfunction FlowLineFlow({\n  route: { d, length },\n  from,\n}: {\n  route: FlowRoute\n  /** 纹理开始淡入的时刻（秒），通常是连线画完的那一刻；颜色取外层的 `--flow-tone` */\n  from: number\n}) {\n  const { time, exit, duration } = useFlowClock()\n  const dash = useConstantValue(FLOW_DASH / length)\n  const gap = useConstantValue(FLOW_GAP / length)\n  const offset = useTransform(\n    time,\n    (value) => (value * FLOW_LINE_SPEED) / length,\n  )\n  const opacity = useTransform(\n    time,\n    [from, Math.min(from + SETTLE, exit), exit, duration],\n    [0, 1, 1, 0],\n  )\n  return (\n    <motion.path\n      data-slot=\"flow-line-flow\"\n      d={d}\n      strokeWidth={1.5}\n      className={toneClasses.flow}\n      style={{\n        pathLength: dash,\n        pathSpacing: gap,\n        pathOffset: offset,\n        opacity,\n      }}\n    />\n  )\n}\n\n/**\n * 连线 + 沿线流动的光点（带一小段拖尾）。\n * 默认连线跟着光点头部一路生长出来；`flow` 可以在画好之后让线上持续流动。\n */\nfunction FlowBeam({\n  route,\n  at,\n  base = true,\n  line = \"draw\",\n  guide = true,\n  flow = false,\n  tone,\n}: {\n  /** 由 `flowRoute` 生成的路径 */\n  route: FlowRoute\n  /** 光点出发的时刻（秒） */\n  at: number\n  /**\n   * 是否同时画出底线。同一条线上的重复脉冲设为 `false`，避免底线叠加变粗\n   * @default true\n   */\n  base?: boolean\n  /**\n   * 底线的出现方式：`draw` 跟随光点头部逐渐画出，到 `exit` 后淡出；\n   * `static` 一开始就整条存在\n   * @default \"draw\"\n   */\n  line?: \"draw\" | \"static\"\n  /**\n   * 生长之前是否先留一条很淡的虚线引导，只对 `line=\"draw\"` 有效\n   * @default true\n   */\n  guide?: boolean\n  /**\n   * 连线画好之后，线上是否有持续向前移动的虚线纹理，用于常驻的数据流\n   * @default false\n   */\n  flow?: boolean\n  /**\n   * 色调，作用于光点、拖尾和流动纹理；明确指定后，生长的底线也会淡淡染上这个颜色。\n   * 失败、拒绝之类的分支用 `destructive`，多条并行链路可以各用一种颜色\n   * @default 沿用 FlowDiagram 的 tone（\"primary\"）\n   */\n  tone?: FlowTone\n}) {\n  const { d, length } = route\n  const { time } = useFlowClock()\n  const travel = length / FLOW_BEAM_SPEED\n  const end = at + travel + TAIL_LENGTH / FLOW_BEAM_SPEED\n  // 光点头部沿路径走过的距离；超出终点的部分让拖尾也走完\n  const head = useTransform(time, [at, end], [0, length + TAIL_LENGTH])\n  const headOffset = useTransform(\n    head,\n    (value) => (value - HEAD_LENGTH) / length,\n  )\n  const tailOffset = useTransform(\n    head,\n    (value) => (value - TAIL_LENGTH) / length,\n  )\n  const headOpacity = useTransform(time, (value) =>\n    value > at && value < end ? 1 : 0,\n  )\n  const tailOpacity = useTransform(time, (value) =>\n    value > at && value < end ? 0.35 : 0,\n  )\n  const tailLength = useConstantValue(TAIL_LENGTH / length)\n  const headLength = useConstantValue(HEAD_LENGTH / length)\n  const spacing = useConstantValue(2)\n  const stroke = toneClasses.stroke\n\n  return (\n    <g data-slot=\"flow-beam\" style={flowToneStyle(tone)}>\n      {base ? (\n        line === \"draw\" ? (\n          <FlowLineDraw\n            route={route}\n            at={at}\n            duration={travel}\n            guide={guide}\n            tinted={tone !== undefined}\n          />\n        ) : (\n          <path\n            d={d}\n            strokeWidth={1}\n            className={\n              tone !== undefined ? toneClasses.line : \"stroke-foreground/20\"\n            }\n          />\n        )\n      ) : null}\n      {flow ? <FlowLineFlow route={route} from={at + travel} /> : null}\n      <motion.path\n        d={d}\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        className={stroke}\n        style={{\n          pathLength: tailLength,\n          pathSpacing: spacing,\n          pathOffset: tailOffset,\n          opacity: tailOpacity,\n        }}\n      />\n      <motion.path\n        d={d}\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        className={stroke}\n        style={{\n          pathLength: headLength,\n          pathSpacing: spacing,\n          pathOffset: headOffset,\n          opacity: headOpacity,\n        }}\n      />\n    </g>\n  )\n}\n\nexport { FlowBeam, FlowLineDraw, FlowLineFlow }\n",
      "type": "registry:ui",
      "target": "@ui/flow-diagram/beam.tsx"
    },
    {
      "path": "src/registry/ui/flow-diagram/route.ts",
      "content": "// 纯函数，不依赖 React：服务端组件也可以直接调用\n\n/** 光点飞行速度（像素/秒）：各段耗时由路径长度换算，同一张图里节奏才一致 */\nconst FLOW_BEAM_SPEED = 110\n/** 连线上“流动”虚线纹理的前进速度（像素/秒）：比光点慢得多，只是常驻连接的呼吸感 */\nconst FLOW_LINE_SPEED = 14\n/** 等宽字 10px 下的字符宽度，用来估算文字宽度（中文按全角 10px 计） */\nconst LATIN_ADVANCE = 6\nconst CJK_ADVANCE = 10\ntype FlowPoint = readonly [number, number]\ntype FlowRoute = { d: string; length: number }\n\nfunction round(value: number) {\n  return Math.round(value * 100) / 100\n}\n\n/**\n * 把折线变成圆角路径，并返回近似长度。\n * 长度要提前知道：光点的出现和到达时间都靠它换算，不用在浏览器里量。\n * 建议在组件外调用，每次渲染重新计算没有意义。\n */\nfunction flowRoute(points: readonly FlowPoint[], radius = 6): FlowRoute {\n  const first = points[0]\n  if (!first) throw new TypeError(\"路径至少需要一个点\")\n  let d = `M${first[0]} ${first[1]}`\n  let length = 0\n  for (let index = 1; index < points.length; index += 1) {\n    const [px, py] = points[index - 1]!\n    const [cx, cy] = points[index]!\n    const next = points[index + 1]\n    const incoming = Math.hypot(cx - px, cy - py)\n    length += incoming\n    if (!next) {\n      d += ` L${cx} ${cy}`\n      continue\n    }\n    const [nx, ny] = next\n    const outgoing = Math.hypot(nx - cx, ny - cy)\n    const corner = Math.min(radius, incoming / 2, outgoing / 2)\n    const inX = (cx - px) / incoming\n    const inY = (cy - py) / incoming\n    const outX = (nx - cx) / outgoing\n    const outY = (ny - cy) / outgoing\n    d += ` L${round(cx - inX * corner)} ${round(cy - inY * corner)}`\n    d += ` Q${cx} ${cy} ${round(cx + outX * corner)} ${round(cy + outY * corner)}`\n    // 二次贝塞尔拐角比两条直角边短约 0.4 倍半径\n    length -= 0.4 * corner\n  }\n  return { d, length: round(length) }\n}\n\n/** 光点从 `at` 出发后到达终点的时刻，用来点亮目标节点。 */\nfunction flowArrival(at: number, { length }: FlowRoute) {\n  return at + length / FLOW_BEAM_SPEED\n}\n\n/**\n * 估算一段文字的宽度（设计像素），用来给图标 + 文字居中、摆放光标。\n * 不量真实字形，按“拉丁字符 6px、中文 10px”（10px 字号）线性换算，混排时请留一点余量。\n */\nfunction flowTextWidth(text: string, fontSize = 10) {\n  let width = 0\n  for (const char of text)\n    width += char.charCodeAt(0) > 0xff ? CJK_ADVANCE : LATIN_ADVANCE\n  return (width * fontSize) / 10\n}\n\nexport {\n  FLOW_BEAM_SPEED,\n  FLOW_LINE_SPEED,\n  flowArrival,\n  flowRoute,\n  flowTextWidth,\n}\nexport type { FlowPoint, FlowRoute }\n",
      "type": "registry:ui",
      "target": "@ui/flow-diagram/route.ts"
    },
    {
      "path": "src/registry/ui/flow-diagram/flow-clock.tsx",
      "content": "\"use client\"\n\nimport { createContext, useContext } from \"react\"\nimport type { MotionValue } from \"motion/react\"\n\ntype FlowClock = {\n  /** 当前循环内的时间（秒） */\n  time: MotionValue<number>\n  /** 一轮循环的总时长（秒） */\n  duration: number\n  /** 从这一刻起动态内容淡出，循环末尾回到起点 */\n  exit: number\n}\n\nconst FlowClockContext = createContext<FlowClock | null>(null)\n\n/** 读取 `FlowDiagram` 提供的时间轴，用来写自定义的动画元素。 */\nfunction useFlowClock() {\n  const clock = useContext(FlowClockContext)\n  if (!clock) throw new Error(\"useFlowClock 只能在 FlowDiagram 内使用\")\n  return clock\n}\n\nexport { FlowClockContext, useFlowClock }\nexport type { FlowClock }\n",
      "type": "registry:ui",
      "target": "@ui/flow-diagram/flow-clock.tsx"
    },
    {
      "path": "src/registry/ui/flow-diagram/background.ts",
      "content": "/** 外框内置的底纹：`dots` 点阵、`grid` 方格、`lines` 横线、`hatch` 斜纹、`none` 不要底纹。 */\ntype FlowBackgroundName = \"dots\" | \"grid\" | \"lines\" | \"hatch\" | \"none\"\n\n// 点阵与斜纹来自 grid-layout 提供的 bg-dots / bg-hatch；方格与横线直接用 --edge 画渐变，\n// 所以只依赖 grid-layout 带来的 --edge 变量，不需要额外的工具类\nconst backgroundClasses = {\n  dots: \"bg-dots\",\n  grid: \"bg-[linear-gradient(to_right,var(--edge)_1px,transparent_1px),linear-gradient(to_bottom,var(--edge)_1px,transparent_1px)] bg-[size:16px_16px]\",\n  lines:\n    \"bg-[linear-gradient(to_bottom,var(--edge)_1px,transparent_1px)] bg-[size:100%_12px]\",\n  hatch: \"bg-hatch\",\n  none: \"\",\n} as const satisfies Record<FlowBackgroundName, string>\n\n/** 向四周淡出的遮罩，让图示像摆在图纸上 */\nconst FADE_MASK =\n  \"[mask-image:radial-gradient(ellipse_at_center,black_20%,transparent_80%)]\"\n\nexport { FADE_MASK, backgroundClasses }\nexport type { FlowBackgroundName }\n",
      "type": "registry:ui",
      "target": "@ui/flow-diagram/background.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
