{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "network-globe",
  "type": "registry:ui",
  "title": "Network globe",
  "description": "Maps global connections across a 3D globe with nodes, arcs, and selectable focus.",
  "dependencies": [
    "motion",
    "three"
  ],
  "devDependencies": [
    "@types/three"
  ],
  "categories": [
    "visual-ambient"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/network-globe/",
  "meta": {
    "engines": [
      "motion",
      "three"
    ],
    "runtimeCost": "heavy",
    "signature": "Three.js globe with routed network arcs",
    "sceneFamily": "spatial-dark",
    "motionRole": "ambient",
    "primaryState": "Dark network globe focused on a selected node",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/network-globe.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/network-globe.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"motion/react\";\nimport * as THREE from \"three\";\n\nexport type NetworkGlobeNode = {\n  id: string;\n  label: string;\n  latitude: number;\n  longitude: number;\n  value?: string;\n  color?: string;\n};\n\nexport type NetworkGlobeProps = {\n  nodes: readonly NetworkGlobeNode[];\n  label?: string;\n  interactiveHint?: string;\n  staticHint?: string;\n  activateLabel?: string;\n  liveLabel?: string;\n  staticLabel?: string;\n  onlineLabel?: string;\n  emptyLabel?: string;\n  activation?: \"intent\" | \"auto\";\n  className?: string;\n  onFocusNode?: (node: NetworkGlobeNode) => void;\n};\n\ntype GlobeRuntime = {\n  renderer: THREE.WebGLRenderer;\n  scene: THREE.Scene;\n  camera: THREE.PerspectiveCamera;\n  globe: THREE.Group;\n  nodeMeshes: Map<string, THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial>>;\n  arcMaterials: Map<string, THREE.LineBasicMaterial>;\n};\n\nconst FRAME_INTERVAL_MS = 1000 / 30;\nconst AUTO_ROTATE_DURATION_MS = 2400;\nconst yieldToMain = () => new Promise<void>((resolve) => window.setTimeout(resolve, 0));\n\nasync function precompileMaterialStages(\n  renderer: THREE.WebGLRenderer,\n  scene: THREE.Scene,\n  camera: THREE.Camera,\n  cancelled: () => boolean,\n) {\n  const renderables: Array<{ object: THREE.Object3D; visible: boolean; materialTypes: readonly string[] }> = [];\n  scene.traverse((object) => {\n    const material = \"material\" in object\n      ? (object as THREE.Mesh).material\n      : undefined;\n    if (!material) return;\n    const materials = Array.isArray(material) ? material : [material];\n    renderables.push({\n      object,\n      visible: object.visible,\n      materialTypes: materials.map((entry) => entry.type),\n    });\n  });\n  const materialTypes = [...new Set(renderables.flatMap((entry) => entry.materialTypes))];\n\n  for (const materialType of materialTypes) {\n    await yieldToMain();\n    if (cancelled()) return;\n    for (const entry of renderables) {\n      entry.object.visible = entry.visible && entry.materialTypes.includes(materialType);\n    }\n    renderer.compile(scene, camera);\n  }\n  for (const entry of renderables) entry.object.visible = entry.visible;\n}\n\nfunction applyFocusStyles(runtime: GlobeRuntime, focusedId: string | undefined) {\n  runtime.nodeMeshes.forEach((mesh, id) => {\n    const selected = id === focusedId;\n    mesh.scale.setScalar(selected ? 1.42 : 1);\n    mesh.material.opacity = selected ? 1 : 0.72;\n  });\n  runtime.arcMaterials.forEach((material, id) => {\n    material.opacity = id === focusedId ? 0.92 : 0.24;\n  });\n}\n\nfunction pointOnGlobe(latitude: number, longitude: number, radius: number) {\n  const phi = THREE.MathUtils.degToRad(90 - latitude);\n  const theta = THREE.MathUtils.degToRad(longitude + 180);\n  return new THREE.Vector3(\n    -(radius * Math.sin(phi) * Math.cos(theta)),\n    radius * Math.cos(phi),\n    radius * Math.sin(phi) * Math.sin(theta),\n  );\n}\n\nfunction arcBetween(start: THREE.Vector3, end: THREE.Vector3) {\n  const distance = start.distanceTo(end);\n  const middle = start\n    .clone()\n    .add(end)\n    .multiplyScalar(0.5)\n    .normalize()\n    .multiplyScalar(1.64 + distance * 0.24);\n  return new THREE.QuadraticBezierCurve3(start, middle, end).getPoints(42);\n}\n\nexport function NetworkGlobe({\n  nodes,\n  label = \"Global network\",\n  interactiveHint = \"Drag or use arrow keys to rotate.\",\n  staticHint = \"Static network preview.\",\n  activateLabel = \"Explore 3D\",\n  liveLabel = \"Live network\",\n  staticLabel = \"Static network\",\n  onlineLabel = \"Online\",\n  emptyLabel = \"No network nodes available.\",\n  activation = \"intent\",\n  className = \"\",\n  onFocusNode,\n}: NetworkGlobeProps) {\n  const mountRef = useRef<HTMLDivElement>(null);\n  const runtimeRef = useRef<GlobeRuntime | null>(null);\n  const frameRef = useRef<number | null>(null);\n  const lastFrameTimeRef = useRef(0);\n  const autoRotateUntilRef = useRef(0);\n  const requestFrameRef = useRef<() => void>(() => undefined);\n  const focusAfterActivationRef = useRef(false);\n  const visibleRef = useRef(true);\n  const rotationRef = useRef({ y: -0.42, targetY: -0.42, dragging: false, pointerId: -1, x: 0 });\n  const sceneNodesRef = useRef(nodes);\n  sceneNodesRef.current = nodes;\n  const reduced = useReducedMotion() === true;\n  const reducedRef = useRef(reduced);\n  reducedRef.current = reduced;\n  const [activationRequested, setActivationRequested] = useState(activation === \"auto\");\n  const firstId = nodes[0]?.id ?? \"\";\n  const [focusedId, setFocusedId] = useState(firstId);\n  const [rendererReady, setRendererReady] = useState(false);\n  const sceneSignature = JSON.stringify(\n    nodes.map(({ id, latitude, longitude, color }) => [id, latitude, longitude, color]),\n  );\n  const effectiveFocusedId = nodes.some((node) => node.id === focusedId)\n    ? focusedId\n    : firstId;\n  const effectiveFocusedIdRef = useRef(effectiveFocusedId);\n  effectiveFocusedIdRef.current = effectiveFocusedId;\n\n  const focused = useMemo(\n    () => nodes.find((node) => node.id === effectiveFocusedId),\n    [effectiveFocusedId, nodes],\n  );\n\n  useEffect(() => {\n    if (focusedId !== effectiveFocusedId) setFocusedId(effectiveFocusedId);\n  }, [effectiveFocusedId, focusedId]);\n\n  useEffect(() => {\n    if (activation === \"auto\") setActivationRequested(true);\n  }, [activation]);\n\n  const draw = () => {\n    const runtime = runtimeRef.current;\n    if (runtime) runtime.renderer.render(runtime.scene, runtime.camera);\n  };\n\n  const animate = (timestamp: number) => {\n    frameRef.current = null;\n    const runtime = runtimeRef.current;\n    if (!runtime || !visibleRef.current) return;\n    const elapsed = lastFrameTimeRef.current > 0\n      ? timestamp - lastFrameTimeRef.current\n      : FRAME_INTERVAL_MS;\n    if (lastFrameTimeRef.current > 0 && elapsed < FRAME_INTERVAL_MS) {\n      frameRef.current = requestAnimationFrame(animate);\n      return;\n    }\n    lastFrameTimeRef.current = timestamp;\n    const rotation = rotationRef.current;\n    if (!reducedRef.current) {\n      const autoRotating = !rotation.dragging && timestamp < autoRotateUntilRef.current;\n      if (autoRotating) rotation.targetY += 0.0011 * Math.min(2, Math.max(1, elapsed / 16.67));\n      rotation.y += (rotation.targetY - rotation.y) * 0.14;\n      runtime.globe.rotation.y = rotation.y;\n      draw();\n      const moving = rotation.dragging || autoRotating || Math.abs(rotation.targetY - rotation.y) > 0.0005;\n      if (moving) frameRef.current = requestAnimationFrame(animate);\n    } else {\n      runtime.globe.rotation.y = rotation.targetY;\n      draw();\n    }\n  };\n\n  const requestFrame = () => {\n    if (frameRef.current === null && visibleRef.current) {\n      frameRef.current = requestAnimationFrame(animate);\n    }\n  };\n  requestFrameRef.current = requestFrame;\n\n  useEffect(() => {\n    if (!activationRequested) return;\n    const mount = mountRef.current;\n    const sceneNodes = sceneNodesRef.current;\n    if (!mount || sceneNodes.length === 0) {\n      setRendererReady(false);\n      return;\n    }\n    let cancelled = false;\n    const resources = new Set<{ dispose: () => void }>();\n    const track = <T extends { dispose: () => void }>(resource: T) => {\n      resources.add(resource);\n      return resource;\n    };\n\n    const scene = new THREE.Scene();\n    const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);\n    camera.position.set(0, 0.12, 5.15);\n    let renderer: THREE.WebGLRenderer;\n    try {\n      renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false, powerPreference: \"high-performance\" });\n    } catch {\n      setRendererReady(false);\n      if (activation === \"intent\") setActivationRequested(false);\n      return;\n    }\n    renderer.outputColorSpace = THREE.SRGBColorSpace;\n    renderer.setClearColor(0x000000, 0);\n    renderer.domElement.setAttribute(\"aria-hidden\", \"true\");\n    renderer.domElement.style.width = \"100%\";\n    renderer.domElement.style.height = \"100%\";\n    renderer.domElement.style.display = \"block\";\n    mount.prepend(renderer.domElement);\n    const onContextLost = (event: Event) => {\n      event.preventDefault();\n      const rotation = rotationRef.current;\n      if (rotation.pointerId >= 0 && mount.hasPointerCapture(rotation.pointerId)) {\n        mount.releasePointerCapture(rotation.pointerId);\n      }\n      rotation.dragging = false;\n      rotation.pointerId = -1;\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n      frameRef.current = null;\n      setRendererReady(false);\n    };\n    const onContextRestored = () => {\n      setRendererReady(true);\n      requestFrameRef.current();\n    };\n    renderer.domElement.addEventListener(\"webglcontextlost\", onContextLost);\n    renderer.domElement.addEventListener(\"webglcontextrestored\", onContextRestored);\n\n    const globe = new THREE.Group();\n    globe.rotation.set(-0.12, rotationRef.current.y, 0.02);\n    scene.add(globe);\n\n    const sphereGeometry = track(new THREE.SphereGeometry(1.55, 28, 20));\n    const sphereMaterial = track(\n      new THREE.MeshBasicMaterial({\n        color: 0x80b4c7,\n        wireframe: true,\n        transparent: true,\n        opacity: 0.26,\n      }),\n    );\n    globe.add(new THREE.Mesh(sphereGeometry, sphereMaterial));\n\n    const innerGeometry = track(new THREE.SphereGeometry(1.515, 40, 28));\n    const innerMaterial = track(\n      new THREE.MeshBasicMaterial({\n        color: 0x0e1a21,\n        transparent: true,\n        opacity: 0.9,\n      }),\n    );\n    globe.add(new THREE.Mesh(innerGeometry, innerMaterial));\n\n    const nodeMeshes = new Map<string, THREE.Mesh<THREE.SphereGeometry, THREE.MeshBasicMaterial>>();\n    const arcMaterials = new Map<string, THREE.LineBasicMaterial>();\n    const hub = pointOnGlobe(sceneNodes[0].latitude, sceneNodes[0].longitude, 1.58);\n\n    sceneNodes.forEach((node, index) => {\n      const geometry = track(new THREE.SphereGeometry(index === 0 ? 0.075 : 0.055, 18, 12));\n      const material = track(\n        new THREE.MeshBasicMaterial({\n          color: node.color ?? (index === 0 ? \"#ffb05e\" : \"#76d0db\"),\n          transparent: true,\n          opacity: 0.72,\n        }),\n      );\n      const mesh = new THREE.Mesh(geometry, material);\n      mesh.position.copy(pointOnGlobe(node.latitude, node.longitude, 1.59));\n      globe.add(mesh);\n      nodeMeshes.set(node.id, mesh);\n\n      if (index > 0) {\n        const arcGeometry = track(\n          new THREE.BufferGeometry().setFromPoints(\n            arcBetween(hub, pointOnGlobe(node.latitude, node.longitude, 1.58)),\n          ),\n        );\n        const arcMaterial = track(\n          new THREE.LineBasicMaterial({\n            color: node.color ?? \"#76d0db\",\n            transparent: true,\n            opacity: 0.4,\n          }),\n        );\n        globe.add(new THREE.Line(arcGeometry, arcMaterial));\n        arcMaterials.set(node.id, arcMaterial);\n      }\n    });\n\n    const runtime = { renderer, scene, camera, globe, nodeMeshes, arcMaterials };\n    applyFocusStyles(runtime, effectiveFocusedIdRef.current || undefined);\n    runtimeRef.current = runtime;\n\n    const resize = new ResizeObserver(([entry]) => {\n      const width = Math.max(1, entry.contentRect.width);\n      const height = Math.max(1, entry.contentRect.height);\n      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.6));\n      renderer.setSize(width, height, false);\n      camera.aspect = width / height;\n      camera.updateProjectionMatrix();\n      requestFrameRef.current();\n    });\n    resize.observe(mount);\n\n    let intersecting = true;\n    const intersection = new IntersectionObserver(([entry]) => {\n      intersecting = entry.isIntersecting;\n      visibleRef.current = intersecting && !document.hidden;\n      if (visibleRef.current) requestFrameRef.current();\n      else if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current);\n        frameRef.current = null;\n      }\n    });\n    const onVisibility = () => {\n      visibleRef.current = intersecting && !document.hidden;\n      if (visibleRef.current) requestFrameRef.current();\n      else if (frameRef.current !== null) {\n        cancelAnimationFrame(frameRef.current);\n        frameRef.current = null;\n      }\n    };\n    intersection.observe(mount);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    void precompileMaterialStages(renderer, scene, camera, () => cancelled).then(() => {\n      if (cancelled) return;\n      lastFrameTimeRef.current = 0;\n      autoRotateUntilRef.current = performance.now() + AUTO_ROTATE_DURATION_MS;\n      setRendererReady(true);\n      requestFrameRef.current();\n    });\n\n    return () => {\n      cancelled = true;\n      renderer.domElement.removeEventListener(\"webglcontextlost\", onContextLost);\n      renderer.domElement.removeEventListener(\"webglcontextrestored\", onContextRestored);\n      resize.disconnect();\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n      resources.forEach((resource) => resource.dispose());\n      renderer.dispose();\n      renderer.forceContextLoss();\n      renderer.domElement.remove();\n      runtimeRef.current = null;\n      frameRef.current = null;\n      lastFrameTimeRef.current = 0;\n    };\n  }, [activation, activationRequested, sceneSignature]);\n\n  useEffect(() => {\n    const runtime = runtimeRef.current;\n    if (!runtime) return;\n    applyFocusStyles(runtime, focused?.id);\n    requestFrameRef.current();\n  }, [focused?.id]);\n\n  useEffect(() => {\n    requestFrameRef.current();\n  }, [reduced]);\n\n  useEffect(() => {\n    if (!rendererReady || !focusAfterActivationRef.current) return;\n    focusAfterActivationRef.current = false;\n    mountRef.current?.focus({ preventScroll: true });\n  }, [rendererReady]);\n\n  const focusNode = (node: NetworkGlobeNode) => {\n    setFocusedId(node.id);\n    onFocusNode?.(node);\n  };\n\n  return (\n    <div\n      ref={mountRef}\n      data-webgl-root=\"network-globe\"\n      role=\"group\"\n      tabIndex={rendererReady ? 0 : undefined}\n      aria-label={\n        rendererReady\n          ? `${label}. ${interactiveHint}`\n          : `${label}. ${staticHint}`\n      }\n      onPointerDown={(event) => {\n        if (!rendererReady || !(event.target instanceof HTMLCanvasElement)) return;\n        const rotation = rotationRef.current;\n        autoRotateUntilRef.current = 0;\n        rotation.dragging = true;\n        rotation.pointerId = event.pointerId;\n        rotation.x = event.clientX;\n        event.currentTarget.setPointerCapture(event.pointerId);\n        event.currentTarget.focus({ preventScroll: true });\n      }}\n      onPointerMove={(event) => {\n        if (!rendererReady) return;\n        const rotation = rotationRef.current;\n        if (!rotation.dragging || rotation.pointerId !== event.pointerId) return;\n        rotation.targetY += (event.clientX - rotation.x) * 0.007;\n        rotation.x = event.clientX;\n        requestFrame();\n      }}\n      onPointerUp={(event) => {\n        if (!rendererReady) return;\n        const rotation = rotationRef.current;\n        if (rotation.pointerId !== event.pointerId) return;\n        rotation.dragging = false;\n        rotation.pointerId = -1;\n        event.currentTarget.releasePointerCapture?.(event.pointerId);\n      }}\n      onPointerCancel={() => {\n        if (!rendererReady) return;\n        rotationRef.current.dragging = false;\n        rotationRef.current.pointerId = -1;\n      }}\n      onLostPointerCapture={(event) => {\n        const rotation = rotationRef.current;\n        if (rotation.pointerId !== event.pointerId) return;\n        rotation.dragging = false;\n        rotation.pointerId = -1;\n      }}\n      onKeyDown={\n        rendererReady\n          ? (event) => {\n              if (event.key !== \"ArrowLeft\" && event.key !== \"ArrowRight\") return;\n              autoRotateUntilRef.current = 0;\n              rotationRef.current.targetY += event.key === \"ArrowLeft\" ? -0.16 : 0.16;\n              event.preventDefault();\n              requestFrame();\n            }\n          : undefined\n      }\n      className={`relative isolate min-h-[250px] w-full overflow-hidden rounded-[18px] border border-[#8ec8d4]/25 bg-[#081216] outline-none focus-visible:ring-2 focus-visible:ring-[#8ec8d4] focus-visible:ring-offset-2 focus-visible:ring-offset-[#081216] ${rendererReady ? \"touch-none\" : \"touch-pan-y\"} ${className}`}\n    >\n      <div aria-hidden className=\"pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_72%_20%,rgba(38,127,153,.3),transparent_30%),radial-gradient(circle_at_24%_84%,rgba(238,128,73,.18),transparent_36%)]\" />\n      <div\n        data-webgl-fallback=\"network-globe\"\n        aria-hidden\n        className={`pointer-events-none absolute inset-0 grid place-items-center ${rendererReady ? \"opacity-0\" : \"opacity-100\"}`}\n      >\n        <div className=\"relative size-40 rounded-full border border-[#a5e1e5]/35 bg-[#10272f] shadow-[0_24px_48px_-26px_rgba(0,0,0,.92)]\">\n          <span className=\"absolute inset-[18%] rounded-full border border-[#a5e1e5]/25\" />\n          <span className=\"absolute inset-x-2 top-1/2 border-t border-[#a5e1e5]/20\" />\n          <span className=\"absolute inset-y-2 left-1/2 border-l border-[#a5e1e5]/20\" />\n          {nodes.slice(0, 6).map((node, index) => (\n            <span\n              key={node.id}\n              className=\"absolute size-2 rounded-full border border-white/80 shadow-[0_2px_10px_rgba(126,222,230,.45)]\"\n              style={{\n                left: `${20 + ((node.longitude + 180) / 360) * 60}%`,\n                top: `${18 + ((90 - node.latitude) / 180) * 64}%`,\n                background: node.color ?? (index === 0 ? \"#ffb05e\" : \"#76d0db\"),\n              }}\n            />\n          ))}\n        </div>\n      </div>\n\n      {!rendererReady && activation === \"intent\" && nodes.length > 0 ? (\n        <button\n          type=\"button\"\n          data-webgl-activation=\"network-globe\"\n          disabled={activationRequested}\n          onClick={() => {\n            focusAfterActivationRef.current = true;\n            setActivationRequested(true);\n          }}\n          className=\"absolute left-1/2 top-1/2 z-30 min-h-11 -translate-x-1/2 -translate-y-1/2 rounded-full border border-[#b4e8e5]/35 bg-[#dff3e8] px-4 text-[12px] font-semibold text-[#102127] shadow-[0_12px_30px_-14px_rgba(0,0,0,.8)] outline-none focus-visible:ring-2 focus-visible:ring-[#8ec8d4] focus-visible:ring-offset-2 focus-visible:ring-offset-[#081216] disabled:opacity-60\"\n        >\n          {activateLabel}\n        </button>\n      ) : null}\n\n      {nodes.length === 0 ? (\n        <p role=\"status\" className=\"absolute inset-x-4 top-1/2 z-20 -translate-y-1/2 text-center text-[12px] font-medium text-[#def6ed]\">\n          {emptyLabel}\n        </p>\n      ) : null}\n\n      <div aria-hidden className=\"pointer-events-none absolute inset-x-0 top-0 z-10 flex items-start justify-between p-4\">\n        <span>\n          <span className=\"block font-mono text-[9px] tracking-[.15em] text-[#b5e6df]/58\">\n            {rendererReady ? liveLabel : staticLabel}\n          </span>\n          <strong className=\"mt-1 block text-[15px] font-medium tracking-[-0.03em] text-[#ebfff5]\">{label}</strong>\n        </span>\n        {focused ? <span className=\"text-right\">\n          <strong className=\"block font-mono text-[12px] font-medium tabular-nums text-[#ffd49f]\">{focused?.value ?? onlineLabel}</strong>\n          <span className=\"mt-0.5 block text-[10px] text-[#c5ece5]/70\">{focused.label}</span>\n        </span> : null}\n      </div>\n\n      {nodes.length > 0 ? <div className=\"absolute inset-x-3 bottom-3 z-20 grid grid-cols-3 gap-1.5 rounded-xl border border-white/15 bg-[#071013]/74 p-1.5 backdrop-blur-md\">\n        {nodes.map((node) => {\n          const selected = node.id === focused?.id;\n          return (\n            <button\n              key={node.id}\n              type=\"button\"\n              aria-pressed={selected}\n              onClick={() => focusNode(node)}\n              className={`min-h-11 min-w-0 truncate rounded-[9px] px-2 text-[11px] font-medium outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-[#8ec8d4] ${\n                selected\n                  ? \"bg-[#dff3e8] text-[#102127]\"\n                  : \"text-[#c5ece5]/75 hover:bg-white/[0.1]\"\n              }`}\n            >\n              {node.label}\n            </button>\n          );\n        })}\n      </div> : null}\n    </div>\n  );\n}\n"
    }
  ]
}
