{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "integration-map",
  "type": "registry:ui",
  "title": "Integration map",
  "description": "Explains system relationships through nodes, routed links, and moving signals.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "data-commerce"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/integration-map/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "medium",
    "signature": "Integration topology connected by SVG paths",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Integration topology with active paths",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/integration-map.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/integration-map.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst DRAW = { duration: 0.38, ease: [0.23, 1, 0.32, 1] } as const;\nconst CELL = { type: \"spring\", stiffness: 520, damping: 38, mass: 0.5 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type IntegrationNodeTone = \"blue\" | \"clay\" | \"moss\" | \"neutral\";\n\nexport type IntegrationNode = {\n  id: string;\n  label: string;\n  meta?: string;\n  x: number;\n  y: number;\n  tone?: IntegrationNodeTone;\n};\n\nexport type IntegrationEdge = {\n  id?: string;\n  from: string;\n  to: string;\n};\n\nexport type IntegrationMapProps = {\n  nodes: readonly IntegrationNode[];\n  edges: readonly IntegrationEdge[];\n  label?: string;\n  width?: number;\n  height?: number;\n  emptyLabel?: string;\n  formatStatus?: (label: string) => string;\n  className?: string;\n};\n\nconst tone: Record<IntegrationNodeTone, { node: string; text: string }> = {\n  blue: { node: \"fill-[#e5efff] stroke-[#477ae8] dark:fill-[#16316b] dark:stroke-[#7da7ff]\", text: \"fill-[#1c3e91] dark:fill-[#dbe8ff]\" },\n  clay: { node: \"fill-[#fff0e3] stroke-[#d98545] dark:fill-[#5b2f17] dark:stroke-[#f0ad70]\", text: \"fill-[#8a4513] dark:fill-[#ffe4c8]\" },\n  moss: { node: \"fill-[#e3f2e8] stroke-[#4c9b6a] dark:fill-[#163d2a] dark:stroke-[#80c69a]\", text: \"fill-[#1d613d] dark:fill-[#d9f5e2]\" },\n  neutral: { node: \"fill-[#fff] stroke-[#d4d4d4] dark:fill-[#202020] dark:stroke-[#525252]\", text: \"fill-[#525252] dark:fill-[#d4d4d4]\" },\n};\n\nfunction useFinePointer() {\n  const [fine, setFine] = useState(false);\n  useEffect(() => {\n    if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return;\n    const query = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setFine(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n  return fine;\n}\n\nfunction useAnimationActivity<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [active, setActive] = useState(true);\n  useEffect(() => {\n    const node = ref.current;\n    if (!node || typeof IntersectionObserver === \"undefined\") return;\n    let intersecting = true;\n    let visible = !document.hidden;\n    const update = () => setActive(intersecting && visible);\n    const observer = new IntersectionObserver(([entry]) => {\n      intersecting = entry.isIntersecting;\n      update();\n    });\n    const onVisibility = () => {\n      visible = !document.hidden;\n      update();\n    };\n    observer.observe(node);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, []);\n  return { ref, active };\n}\n\nfunction edgePath(from: IntegrationNode, to: IntegrationNode) {\n  const middle = (from.x + to.x) / 2;\n  return `M ${from.x} ${from.y} C ${middle} ${from.y}, ${middle} ${to.y}, ${to.x} ${to.y}`;\n}\n\nfunction fitNodeText(value: string, maxUnits: number) {\n  const characters = Array.from(value);\n  let units = 0;\n  let result = \"\";\n  for (const character of characters) {\n    const next = (character.codePointAt(0) ?? 0) <= 0xff ? 0.55 : 1;\n    if (units + next > maxUnits) return `${result.trimEnd()}…`;\n    result += character;\n    units += next;\n  }\n  return result;\n}\n\nexport function IntegrationMap({\n  nodes,\n  edges,\n  label = \"Integration map\",\n  width = 440,\n  height = 230,\n  emptyLabel = \"No integrations available\",\n  formatStatus = (name) => `${name} connections highlighted`,\n  className = \"\",\n}: IntegrationMapProps) {\n  const resolvedWidth = Number.isFinite(width) && width > 0 ? width : 440;\n  const resolvedHeight = Number.isFinite(height) && height > 0 ? height : 230;\n  const reduced = useReducedMotion() === true;\n  const finePointer = useFinePointer();\n  const { ref, active } = useAnimationActivity<HTMLDivElement>();\n  const [keyboardFocused, setKeyboardFocused] = useState<string | null>(null);\n  const [hovered, setHovered] = useState<string | null>(null);\n  const [selected, setSelected] = useState<string | null>(null);\n  const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);\n  const validKeyboardFocused = keyboardFocused && nodeById.has(keyboardFocused) ? keyboardFocused : null;\n  const validHovered = hovered && nodeById.has(hovered) ? hovered : null;\n  const validSelected = selected && nodeById.has(selected) ? selected : null;\n  const activeNode = validHovered ?? validKeyboardFocused ?? validSelected;\n\n  useEffect(() => {\n    if (keyboardFocused && !nodeById.has(keyboardFocused)) setKeyboardFocused(null);\n    if (hovered && !nodeById.has(hovered)) setHovered(null);\n    if (selected && !nodeById.has(selected)) setSelected(null);\n  }, [hovered, keyboardFocused, nodeById, selected]);\n\n  const related = useMemo(() => {\n    if (!activeNode) return new Set(nodes.map((node) => node.id));\n    const ids = new Set([activeNode]);\n    for (const edge of edges) {\n      if (edge.from === activeNode) ids.add(edge.to);\n      if (edge.to === activeNode) ids.add(edge.from);\n    }\n    return ids;\n  }, [activeNode, edges, nodes]);\n\n  if (nodes.length === 0) {\n    return (\n      <div\n        ref={ref}\n        role=\"group\"\n        aria-label={label}\n        className={`grid min-h-28 w-full place-items-center rounded-[10px] border border-neutral-200 bg-white px-4 text-center text-[12px] text-neutral-600 dark:border-white/[0.14] dark:bg-[#181818] dark:text-neutral-300 ${className}`}\n      >\n        <span role=\"status\">{emptyLabel}</span>\n      </div>\n    );\n  }\n\n  return (\n    <div ref={ref} className={`w-full ${className}`}>\n      <div className=\"relative\">\n        <svg\n          viewBox={`0 0 ${resolvedWidth} ${resolvedHeight}`}\n          aria-hidden=\"true\"\n          focusable=\"false\"\n          className=\"block h-auto w-full overflow-visible\"\n        >\n        <g aria-hidden=\"true\">\n          {edges.map((edge, index) => {\n            const from = nodeById.get(edge.from);\n            const to = nodeById.get(edge.to);\n            if (!from || !to) return null;\n            const id = edge.id ?? `${edge.from}-${edge.to}-${index}`;\n            const connected = !activeNode || edge.from === activeNode || edge.to === activeNode;\n            const path = edgePath(from, to);\n            return (\n              <g key={id}>\n                <motion.path\n                  d={path}\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                  initial={reduced ? false : { pathLength: 0, opacity: 0 }}\n                  animate={{ pathLength: 1, opacity: connected ? 0.42 : 0.1 }}\n                  transition={reduced ? INSTANT : { ...DRAW, delay: index * 0.04 }}\n                  className=\"text-neutral-400 dark:text-neutral-500\"\n                />\n                <motion.path\n                  d={path}\n                  fill=\"none\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeDasharray=\"2 22\"\n                  initial={false}\n                  animate={{\n                    opacity: connected ? 0.9 : 0,\n                    strokeDashoffset: reduced || !active ? 0 : -48,\n                  }}\n                  transition={\n                    reduced || !active || !connected\n                      ? INSTANT\n                      : {\n                          opacity: { duration: 0.16, ease: [0.23, 1, 0.32, 1] },\n                          strokeDashoffset: { duration: 1.5 + index * 0.16, ease: \"linear\", repeat: Infinity },\n                        }\n                  }\n                  className=\"stroke-neutral-900 dark:stroke-neutral-100\"\n                />\n              </g>\n            );\n          })}\n        </g>\n\n        {nodes.map((node) => {\n          const colors = tone[node.tone ?? \"neutral\"];\n          const visible = related.has(node.id);\n          return (\n            <motion.g\n              key={node.id}\n              initial={false}\n              animate={{ opacity: visible ? 1 : 0.3, scale: activeNode === node.id ? 1.035 : 1 }}\n              transition={reduced ? INSTANT : CELL}\n              style={{ transformOrigin: `${node.x}px ${node.y}px` }}\n            >\n              <rect\n                x={node.x - 48}\n                y={node.y - 22}\n                width=\"96\"\n                height=\"44\"\n                rx=\"8\"\n                strokeWidth={activeNode === node.id ? 2 : 1}\n                className={`${colors.node} ${activeNode === node.id ? \"stroke-neutral-950 dark:stroke-neutral-50\" : \"\"}`}\n              />\n              <text x={node.x} y={node.y - (node.meta ? 2 : -4)} textAnchor=\"middle\" fontSize=\"12\" fontWeight=\"600\" className={colors.text}>\n                {fitNodeText(node.label, 7.4)}\n              </text>\n              {node.meta ? <text x={node.x} y={node.y + 12} textAnchor=\"middle\" fontSize=\"9.5\" className={colors.text}>{fitNodeText(node.meta, 9.2)}</text> : null}\n            </motion.g>\n          );\n        })}\n        </svg>\n        <div\n          role=\"group\"\n          aria-label={label}\n          className=\"absolute inset-0\"\n          onClick={() => setSelected(null)}\n        >\n          {nodes.map((node) => (\n            <button\n              key={node.id}\n              data-integration-map-node={node.id}\n              type=\"button\"\n              aria-label={`${node.label}${node.meta ? `, ${node.meta}` : \"\"}`}\n              aria-pressed={validSelected === node.id}\n              onClick={(event) => {\n                event.stopPropagation();\n                setSelected((current) => current === node.id ? null : node.id);\n              }}\n              onKeyDown={(event) => {\n                if (event.key === \"Escape\") setSelected(null);\n              }}\n              onFocus={() => setKeyboardFocused(node.id)}\n              onBlur={() => setKeyboardFocused((current) => current === node.id ? null : current)}\n              onMouseEnter={() => {\n                if (finePointer) setHovered(node.id);\n              }}\n              onMouseLeave={() => {\n                if (finePointer) setHovered((current) => current === node.id ? null : current);\n              }}\n              className=\"absolute min-h-11 min-w-11 rounded-[11px] bg-transparent outline-none focus-visible:ring-2 focus-visible:ring-[#4568FF] focus-visible:ring-offset-1 dark:focus-visible:ring-[#93B0FF]\"\n              style={{\n                left: `${(node.x / resolvedWidth) * 100}%`,\n                top: `${(node.y / resolvedHeight) * 100}%`,\n                width: `${(96 / resolvedWidth) * 100}%`,\n                height: `${(44 / resolvedHeight) * 100}%`,\n                minWidth: 44,\n                minHeight: 44,\n                transform: \"translate(-50%, -50%)\",\n              }}\n            />\n          ))}\n        </div>\n      </div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {activeNode ? formatStatus(nodeById.get(activeNode)?.label ?? activeNode) : \"\"}\n      </span>\n    </div>\n  );\n}\n"
    }
  ]
}
