{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "integration-visual",
  "title": "Integration visual",
  "author": "Tori Bryan",
  "description": "A hub and the tools wired into it, with pulses along the routes.",
  "dependencies": [
    "@base-ui/react",
    "motion",
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/integration-visual.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Tooltip as TooltipPrimitive } from \"@base-ui/react/tooltip\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { cva } from \"class-variance-authority\"\nimport { SnailIcon } from \"lucide-react\"\n\nimport { mergeRefs } from \"@/lib/merge-refs\"\nimport { cn } from \"@/lib/utils\"\n\ntype IntegrationItem = {\n  /** Name of the tool; the tile's accessible label and tooltip. */\n  title: string\n  /** Mark shown in the tile. */\n  icon: React.ReactNode\n  /** Idle items keep their route but drop the pulse and fade back. */\n  status?: \"active\" | \"idle\"\n  /** In the `sides` layout, which column the item sits in. */\n  side?: \"in\" | \"out\"\n}\n\n/** An image or clip for the preview. Videos play muted on a loop. */\ntype IntegrationPreviewMedia = {\n  src: string\n  /** Describes the media. Leave empty for purely decorative clips. */\n  alt?: string\n  /** Width over height, as a number or a CSS ratio. Defaults to `\"16 / 9\"`. */\n  aspectRatio?: number | string\n}\n\ntype Point = { x: number; y: number }\ntype Slot = Point & { path: string }\n\n// Drawing space. The plate keeps this aspect ratio, and every position below\n// is in these units.\nconst W = 400\nconst H = 260\nconst CX = W / 2\nconst CY = H / 2\n/*\n * The plate's lattice, centered on the hub. Corner and pipeline positions,\n * route legs and turns are all multiples of it, so routes run along grid\n * lines and tiles sit on dots instead of landing beside them.\n */\nconst PITCH = 12\n// Routes leave the hub a little inside its edge so they read as attached.\nconst HUB_EDGE = PITCH\nconst TURN = PITCH\n\n/*\n * `corners` is the reference layout: four tools in the corners, each route\n * leaving the hub, turning once and running out to its tile. Positions are\n * fixed rather than computed so the routes stay tidy.\n */\nfunction cornerSlots(count: number): Slot[] {\n  const top = CY - 5 * PITCH\n  const bottom = CY + 5 * PITCH\n  const left = CX - 11 * PITCH\n  const right = CX + 11 * PITCH\n  const route = (dx: number, y: number, x: number) => {\n    const dir = Math.sign(dx)\n    const dy = Math.sign(y - CY)\n    const hubX = CX + dir * HUB_EDGE\n    return `M ${hubX} ${CY} V ${y - dy * TURN} Q ${hubX} ${y} ${hubX + dir * TURN} ${y} H ${x}`\n  }\n  const slots: Slot[] = [\n    { x: left, y: top, path: route(-1, top, left) },\n    { x: right, y: top, path: route(1, top, right) },\n    { x: left, y: bottom, path: route(-1, bottom, left) },\n    { x: right, y: bottom, path: route(1, bottom, right) },\n  ]\n  return slots.slice(0, count)\n}\n\n/* `orbit` spaces any number of tools evenly on an ellipse, joined by spokes. */\nfunction orbitSlots(count: number): Slot[] {\n  const rx = 146\n  const ry = 92\n  return Array.from({ length: count }, (_, i) => {\n    const angle = -Math.PI / 2 + (i / count) * Math.PI * 2\n    const x = CX + Math.cos(angle) * rx\n    const y = CY + Math.sin(angle) * ry\n    const start = {\n      x: CX + Math.cos(angle) * 24,\n      y: CY + Math.sin(angle) * 24,\n    }\n    return { x, y, path: `M ${start.x} ${start.y} L ${x} ${y}` }\n  })\n}\n\n/*\n * `sides` reads left to right: inputs in one column, outputs in the other,\n * each route bending once through a shared bus beside the hub.\n */\nfunction sideSlots(items: IntegrationItem[]): Slot[] {\n  const columns = { in: [] as number[], out: [] as number[] }\n  items.forEach((item, i) => {\n    const column = item.side ?? (i < Math.ceil(items.length / 2) ? \"in\" : \"out\")\n    columns[column].push(i)\n  })\n  const slots: Slot[] = new Array(items.length)\n  // Rows are six pitches apart and centered on the hub, so both odd and even\n  // counts land on the lattice.\n  const place = (indices: number[], dir: -1 | 1) => {\n    const x = CX + dir * 11 * PITCH\n    const bus = CX + dir * 5 * PITCH\n    indices.forEach((itemIndex, row) => {\n      const y = CY + (row - (indices.length - 1) / 2) * 6 * PITCH\n      const hubX = CX + dir * 2 * PITCH\n      const bend = Math.min(TURN, Math.abs(y - CY) / 2)\n      const dy = Math.sign(y - CY)\n      const path =\n        dy === 0\n          ? `M ${hubX} ${CY} H ${x}`\n          : `M ${hubX} ${CY} H ${bus - dir * bend} Q ${bus} ${CY} ${bus} ${CY + dy * bend} V ${y - dy * bend} Q ${bus} ${y} ${bus + dir * bend} ${y} H ${x}`\n      slots[itemIndex] = { x, y, path }\n    })\n  }\n  place(columns.in, -1)\n  place(columns.out, 1)\n  return slots\n}\n\nconst MAX_ITEMS = { corners: 4, orbit: 8, sides: 8 } as const\n\nconst TILE =\n  \"flex size-10 items-center justify-center rounded-lg border border-border bg-card text-card-foreground shadow-xs [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5\"\n\nconst HUB =\n  \"relative rounded-xl border border-border bg-background p-1.5 shadow-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring-subtle\"\n\n// The face is square for an icon and widens for text, so a word or a count\n// never gets squeezed.\nconst hubFaceVariants = cva(\n  \"flex h-11 min-w-11 items-center justify-center rounded-lg border border-border text-foreground [&_svg:not([class*='size-'])]:size-5\",\n  {\n    variants: {\n      content: {\n        icon: \"\",\n        text: \"px-2.5 text-base font-semibold tracking-tight whitespace-nowrap tabular-nums\",\n      },\n    },\n    defaultVariants: { content: \"icon\" },\n  }\n)\n\nfunction Track({\n  d,\n  dashed,\n  idle,\n}: {\n  d: string\n  dashed: boolean\n  idle: boolean\n}) {\n  return (\n    <path\n      d={d}\n      className=\"stroke-border\"\n      strokeWidth={1}\n      strokeDasharray={dashed ? \"4 3\" : undefined}\n      vectorEffect=\"non-scaling-stroke\"\n      opacity={idle ? 0.45 : 1}\n    />\n  )\n}\n\nfunction Pulse({\n  d,\n  direction,\n  delay,\n}: {\n  d: string\n  direction: \"inward\" | \"outward\"\n  delay: number\n}) {\n  return (\n    <motion.path\n      d={d}\n      pathLength={1}\n      className=\"stroke-muted-foreground\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeDasharray=\"0.18 2\"\n      initial={{ strokeDashoffset: direction === \"outward\" ? 0.18 : -2 }}\n      animate={{ strokeDashoffset: direction === \"outward\" ? -2 : 0.18 }}\n      transition={{ duration: 3.2, repeat: Infinity, ease: \"linear\", delay }}\n    />\n  )\n}\n\n// Pulses and the halo repeat forever, so they only run while the plate can\n// be seen.\nfunction useInView(ref: React.RefObject<Element | null>) {\n  const [inView, setInView] = React.useState(false)\n  React.useEffect(() => {\n    const node = ref.current\n    if (!node) return\n    const observer = new IntersectionObserver(([entry]) =>\n      setInView(Boolean(entry?.isIntersecting))\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [ref])\n  return inView\n}\n\nfunction isMedia(\n  preview: React.ReactNode | IntegrationPreviewMedia\n): preview is IntegrationPreviewMedia {\n  return (\n    typeof preview === \"object\" &&\n    preview !== null &&\n    !React.isValidElement(preview) &&\n    \"src\" in preview\n  )\n}\n\nfunction PreviewMedia({\n  src,\n  alt = \"\",\n  aspectRatio = \"16 / 9\",\n  still,\n}: IntegrationPreviewMedia & { still: boolean }) {\n  const video = React.useRef<HTMLVideoElement>(null)\n  const className = \"block w-full rounded-lg bg-muted object-cover\"\n  const style = { aspectRatio }\n\n  // `autoPlay` is only read when the clip mounts, so a later change to\n  // reduced motion has to start or stop it by hand.\n  React.useEffect(() => {\n    const node = video.current\n    if (!node) return\n    if (still) node.pause()\n    else node.play().catch(() => {})\n  }, [still])\n\n  return /\\.(mp4|webm)(\\?|#|$)/i.test(src) ? (\n    <video\n      ref={video}\n      className={className}\n      style={style}\n      src={src}\n      aria-label={alt || undefined}\n      // Under reduced motion the clip waits on its first frame.\n      autoPlay={!still}\n      loop\n      muted\n      playsInline\n    />\n  ) : (\n    <img className={className} style={style} src={src} alt={alt} />\n  )\n}\n\ntype IntegrationVisualProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /**\n   * What names the hub. An icon element sits in a square face; a string or\n   * number is set as text and the face widens to fit. Defaults to a snail,\n   * for syncs that take their time.\n   */\n  center?: React.ReactNode\n  /** The tools wired into the hub. */\n  items: IntegrationItem[]\n  /**\n   * Shown above the hub while it is hovered or focused, for looking at\n   * only. Pass any content, or `{ src, alt }` for an image, an animated\n   * image, or a video (`.mp4`, `.webm`) that plays muted on a loop.\n   */\n  preview?: React.ReactNode | IntegrationPreviewMedia\n  /**\n   * `corners` holds up to four, `orbit` and `sides` up to eight. Items past\n   * the limit are not drawn.\n   */\n  layout?: \"corners\" | \"orbit\" | \"sides\"\n  /** The plate behind the diagram. */\n  background?: \"dots\" | \"grid\" | \"none\"\n  /** Route stroke. */\n  routes?: \"solid\" | \"dashed\"\n  /**\n   * Which way the pulse travels along each active route. `through` runs\n   * left to right, into the hub from the left and out of it on the right,\n   * so `sides` reads as a pipeline.\n   */\n  pulse?: \"inward\" | \"outward\" | \"through\" | \"none\"\n  /** A slow ring breathing out from the hub. */\n  halo?: boolean\n  /** Accessible name for the diagram. */\n  label?: string\n  /**\n   * Accessible name for the hub, which becomes a button when there's a\n   * preview. Defaults to `center` when that's text, otherwise to `label`.\n   */\n  centerLabel?: string\n}\n\n/**\n * A hub and the tools wired into it, on a plate. Pulses run along the routes\n * of active items, idle items sit back, and a preview springs up above the\n * hub on hover or focus.\n */\nfunction IntegrationVisual({\n  center = <SnailIcon />,\n  items,\n  preview,\n  layout = \"corners\",\n  background = \"dots\",\n  routes = \"solid\",\n  pulse = \"inward\",\n  halo = true,\n  label = \"Integrations\",\n  centerLabel,\n  className,\n  ref,\n  ...props\n}: IntegrationVisualProps) {\n  const reduceMotion = useReducedMotion()\n  const plateId = React.useId()\n  const plate = React.useRef<HTMLDivElement>(null)\n  // A caller's ref would otherwise replace the one that pauses it off screen.\n  const plateRef = React.useMemo(() => mergeRefs(plate, ref), [ref])\n  const animate = useInView(plate) && !reduceMotion\n\n  const shown = items.slice(0, MAX_ITEMS[layout])\n  const slots =\n    layout === \"orbit\"\n      ? orbitSlots(shown.length)\n      : layout === \"sides\"\n        ? sideSlots(shown)\n        : cornerSlots(shown.length)\n  const effectivePulse = animate ? pulse : \"none\"\n  const textCenter = typeof center === \"string\" || typeof center === \"number\"\n  const hubLabel = centerLabel ?? (textCenter ? String(center) : label)\n\n  const hub = (\n    <>\n      <div\n        data-slot=\"integration-visual-hub-face\"\n        className={hubFaceVariants({\n          content: textCenter ? \"text\" : \"icon\",\n        })}\n      >\n        {center}\n      </div>\n      {halo && animate ? (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 rounded-xl border-2 border-border\"\n          animate={{ scale: [1, 1.18, 1], opacity: [0.9, 0, 0.9] }}\n          transition={{ duration: 3, repeat: Infinity, ease: \"easeInOut\" }}\n        />\n      ) : null}\n    </>\n  )\n\n  return (\n    <div\n      ref={plateRef}\n      data-slot=\"integration-visual\"\n      data-layout={layout}\n      role=\"group\"\n      aria-label={label}\n      className={cn(\n        \"relative isolate aspect-[400/260] w-full overflow-hidden bg-muted\",\n        className\n      )}\n      {...props}\n    >\n      {background !== \"none\" ? (\n        <svg\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 size-full [mask-image:linear-gradient(to_bottom,transparent,black_18%,black_82%,transparent)]\"\n          viewBox={`0 0 ${W} ${H}`}\n        >\n          <defs>\n            <pattern\n              id={plateId}\n              patternUnits=\"userSpaceOnUse\"\n              x={CX - PITCH / 2}\n              y={CY - PITCH / 2}\n              width={PITCH}\n              height={PITCH}\n            >\n              {background === \"dots\" ? (\n                <circle\n                  cx={PITCH / 2}\n                  cy={PITCH / 2}\n                  r={0.7}\n                  className=\"fill-foreground\"\n                  opacity={0.22}\n                />\n              ) : (\n                <path\n                  d={`M ${PITCH / 2} 0 V ${PITCH} M 0 ${PITCH / 2} H ${PITCH}`}\n                  className=\"stroke-border\"\n                  strokeWidth={0.5}\n                  fill=\"none\"\n                />\n              )}\n            </pattern>\n          </defs>\n          <rect width={W} height={H} fill={`url(#${plateId})`} />\n        </svg>\n      ) : null}\n\n      <svg\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 size-full\"\n        viewBox={`0 0 ${W} ${H}`}\n        fill=\"none\"\n      >\n        {layout === \"orbit\" ? (\n          <ellipse\n            cx={CX}\n            cy={CY}\n            rx={146}\n            ry={92}\n            className=\"stroke-border\"\n            strokeDasharray=\"2 4\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        ) : null}\n        {/* Every track before any pulse: routes share legs near the hub,\n            and a later track would otherwise paint over an earlier pulse. */}\n        {shown.map((item, i) => (\n          <Track\n            key={`${i}-${item.title}`}\n            d={slots[i]!.path}\n            dashed={routes === \"dashed\"}\n            idle={item.status === \"idle\"}\n          />\n        ))}\n        {effectivePulse !== \"none\"\n          ? shown.map((item, i) =>\n              item.status === \"idle\" ? null : (\n                <Pulse\n                  key={`${i}-${item.title}`}\n                  d={slots[i]!.path}\n                  direction={\n                    effectivePulse === \"through\"\n                      ? slots[i]!.x < CX\n                        ? \"inward\"\n                        : \"outward\"\n                      : effectivePulse\n                  }\n                  delay={i * 0.55}\n                />\n              )\n            )\n          : null}\n      </svg>\n\n      <ul className=\"m-0 list-none p-0\">\n        {shown.map((item, i) => (\n          <motion.li\n            key={`${i}-${item.title}`}\n            title={item.title}\n            data-slot=\"integration-visual-item\"\n            data-status={item.status ?? \"active\"}\n            className={cn(\n              TILE,\n              \"absolute z-10 -translate-x-1/2 -translate-y-1/2 data-[status=idle]:text-muted-foreground\"\n            )}\n            style={{\n              left: `${(slots[i]!.x / W) * 100}%`,\n              top: `${(slots[i]!.y / H) * 100}%`,\n            }}\n            initial={reduceMotion ? false : { opacity: 0, scale: 0.8 }}\n            animate={{ opacity: item.status === \"idle\" ? 0.6 : 1, scale: 1 }}\n            transition={{ delay: 0.25 + i * 0.08 }}\n          >\n            {item.icon}\n            <span className=\"sr-only\">\n              {item.title}\n              {item.status === \"idle\" ? \", idle\" : \"\"}\n            </span>\n          </motion.li>\n        ))}\n      </ul>\n\n      <div className=\"absolute top-1/2 left-1/2 z-20 -translate-x-1/2 -translate-y-1/2\">\n        {preview ? (\n          // Base UI's tooltip portals the preview so the plate can't clip it,\n          // keeps it open while it's hovered, and closes it on Escape.\n          <TooltipPrimitive.Root>\n            <TooltipPrimitive.Trigger\n              delay={150}\n              closeDelay={100}\n              data-slot=\"integration-visual-hub\"\n              aria-label={hubLabel}\n              className={HUB}\n            >\n              {hub}\n            </TooltipPrimitive.Trigger>\n            <TooltipPrimitive.Portal>\n              <TooltipPrimitive.Positioner\n                side=\"top\"\n                sideOffset={12}\n                collisionPadding={8}\n                className=\"isolate z-50\"\n              >\n                <TooltipPrimitive.Popup\n                  data-slot=\"integration-visual-preview\"\n                  className=\"w-56 origin-(--transform-origin) rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:translate-y-2.5 data-starting-style:scale-[0.85] data-starting-style:opacity-0 motion-reduce:transition-opacity motion-reduce:data-ending-style:scale-100 motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100\"\n                >\n                  {isMedia(preview) ? (\n                    <PreviewMedia {...preview} still={Boolean(reduceMotion)} />\n                  ) : (\n                    preview\n                  )}\n                  <TooltipPrimitive.Arrow className=\"size-2.5 rotate-45 border-r border-b border-border bg-popover data-[side=bottom]:-top-[5px] data-[side=bottom]:rotate-[225deg] data-[side=top]:-bottom-[5px]\" />\n                </TooltipPrimitive.Popup>\n              </TooltipPrimitive.Positioner>\n            </TooltipPrimitive.Portal>\n          </TooltipPrimitive.Root>\n        ) : (\n          <div data-slot=\"integration-visual-hub\" className={HUB}>\n            {hub}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport {\n  IntegrationVisual,\n  type IntegrationItem,\n  type IntegrationPreviewMedia,\n  type IntegrationVisualProps,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/merge-refs.ts",
      "content": "import * as React from \"react\"\n\n/**\n * One callback ref that sets every ref given, so a part can keep its own\n * ref on an element and still hand the element to a caller's `ref`. Undo\n * runs through React 19's ref cleanup, which calls a function ref with\n * null and empties an object ref.\n */\nexport function mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node)\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null)\n      }\n      if (ref) {\n        ref.current = node\n        return () => {\n          ref.current = null\n        }\n      }\n      return undefined\n    })\n    return () => {\n      for (const cleanup of cleanups) cleanup?.()\n    }\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ring-subtle": "var(--ring-subtle)"
    },
    "light": {
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-400) 50%, transparent)"
    },
    "dark": {
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-500) 50%, transparent)"
    }
  },
  "meta": {
    "tier": "special-components",
    "group": "Diagrams",
    "docs": "https://fibo.toribryan.com/?path=/docs/special-components-integration-visual--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/special-components-integration-visual--docs",
  "categories": [
    "special-components",
    "diagrams"
  ],
  "type": "registry:ui"
}