Charts

3D Network Graph

A four-state 3D node-link graph on react-three-fiber — degree-sized spheres, one line-segment pass for all edges, a deterministic golden-angle layout and theme tokens resolved into WebGL materials at runtime.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import * as THREE from "three"
import { Canvas } from "@react-three/fiber"
import { Html, OrbitControls } from "@react-three/drei"
import { AlertCircle, RefreshCcw, Share2 } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  Chart3dNetworkData,
  Chart3dNetworkLink,
  Chart3dNetworkNode,
} from "./chart-3d-network.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-network.json

Prompt

Build a React + TypeScript + Tailwind "Chart3dNetwork" card that renders a 3D
node-link graph with three, @react-three/fiber and @react-three/drei, driven
by one zod contract.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    nodes: { id, label, group? }[]; links: { source, target }[] }.
  Links are undirected; group maps a node onto one of the five chart tokens.
- Component props = z.infer of the schema, plus nodeSize?: number (largest
  sphere radius, clamped), onRetry?: () => void and the usual div props
  spread on the root. No hand-written parallel interface.

Behavior
- Four first-class branches inside one bg-card panel; loading, empty and
  error are plain DOM, so SSR never touches WebGL:
  - loading: a pulsing circles-and-hairlines silhouette, aria-hidden with
    motion-reduce:animate-none, plus an sr-only role="status" line.
  - empty: icon + "Nothing connected yet" copy (also when ready arrives
    with zero nodes).
  - error: message + a "Try again" button only when onRetry exists.
  - ready: the <Canvas>, gated behind a mounted flag (server snapshot
    false, client true via useSyncExternalStore); until mounted — and until
    token colors are resolved — render the same skeleton, so server HTML
    and the first client paint agree.
- Graph build: dedupe node ids, drop self-links / unknown endpoints /
  duplicate pairs but count them and say so in a role="status" line, then
  compute each node's degree.
- Layout is deterministic and lives in the component: seed nodes on a
  golden-angle (Fibonacci) sphere, then run a fixed number of relaxation
  steps — a spring along every link toward a rest length, inverse-square
  repulsion between every pair, a light pull to the origin, displacement
  capped by a linearly cooling step — and finally recenter + rescale the
  cloud to a fixed radius. No randomness, so the same graph always lands in
  the same shape.
- Nodes are spheres, radius = min + (max − min) · sqrt(degree / maxDegree);
  all edges share ONE lineSegments BufferGeometry (a single draw call),
  disposed on replace and on unmount.
- Hover via R3F pointer events (stopPropagation so only the sphere nearest
  the camera reacts) lifts the sphere's emissive and shows a drei <Html>
  tooltip — label · degree · group — with pointer-events: none; the
  pointerout reset is guarded against out-of-order enter/leave.
- OrbitControls with damping, pan disabled, zoom clamped; autoRotate ONLY
  when prefers-reduced-motion does not match (tracked live via a matchMedia
  change listener that is removed on unmount).

Rendering & styling
- Never hand THREE a literal color. Resolve tokens at runtime:
  getComputedStyle(document.documentElement).getPropertyValue("--chart-1")
  etc., normalized through an offscreen 1×1 2D canvas (fillStyle → fillRect
  → getImageData) because THREE.Color cannot parse oklch, then
  new THREE.Color().setRGB(r/255, g/255, b/255, SRGBColorSpace).
  Re-resolve when a MutationObserver sees the class change on <html> (theme
  flip); disconnect the observer on unmount.
- Materials: meshStandardMaterial per node with chart-1..5 by group slot
  under ambientLight + directionalLight; edge lines and the reference
  gridHelper use the resolved border / muted-foreground tokens.
- <Canvas dpr={[1,2]}> inside an aspect-[16/10] w-full wrapper that has
  role="img" and a descriptive aria-label; a full sr-only summary (counts,
  busiest node, groups, "distance is not data") sits beside the canvas.
- DOM-side legend dots (one per group, shown only when there are 2+) use
  var(--chart-N) directly; tooltip and panel use bg-popover / bg-card /
  border / text-muted-foreground; cn() merges className.

Customization levers
- Frame & camera: the aspect-[16/10] wrapper, fov ≈ 42 and the orbit
  min/max distances set how imposing the cloud feels.
- Sphere scale: nodeSize is the largest radius; the min:max ratio (0.45)
  decides how loudly degree speaks against the quiet nodes.
- Layout feel: rest length up = airier, repulsion up = rounder shell, more
  relaxation steps = tidier clusters; cost is O(n² · steps), comfortable to
  ~150 nodes — aggregate upstream past that.
- Motion: autoRotateSpeed (0 parks it entirely), dampingFactor for orbit
  inertia; reduced-motion handling must stay.
- Palette: the group → var(--chart-N) slot assignment cycles five tokens;
  remap it for fixed brand colors per group.
- Chrome: drop the gridHelper for a free-floating cloud, or the legend for
  a thumbnail embed; put link weight into edge opacity if the contract
  grows a value field.

Concepts

  • Mounted canvas gate — the WebGL canvas only exists after the client says so: the server and the hydration pass both render the same plain-DOM skeleton, so there is no SSR crash and no hydration mismatch, and the loading / empty / error branches never construct a GL context at all.
  • Runtime token resolution — theme tokens are oklch strings THREE cannot parse, so each one is painted onto a 1×1 offscreen 2D canvas and read back as sRGB pixels; a MutationObserver on the html class re-runs the resolve on theme flip, which is what makes a WebGL scene re-theme like any Tailwind div.
  • Deterministic golden-angle layout — nodes seed onto a Fibonacci sphere and relax for a fixed number of capped, cooling steps with zero randomness: the same graph produces the same picture on every client, so screenshots, previews and diffs never churn.
  • Degree-sized spheres — a node's radius carries its connectivity on a sqrt scale, so hubs read as mass from any camera angle; position deliberately carries nothing, and the copy under the chart says so.
  • One geometry for all edges — every link lives in a single lineSegments buffer: one draw call whether there are 20 edges or 2000, and one dispose() when it is replaced or unmounted, because GPU buffers do not garbage-collect themselves.
  • Reduced-motion contract — the idle auto-rotation is decoration and switches off under prefers-reduced-motion (live, via matchMedia), while hand-driven orbit and zoom keep working: turning off motion never costs function.

On This Page