3D Scatter Cloud
An orbitable WebGL scatter cloud with cluster-colored spheres, axis grids and DOM tooltips, chart tokens resolved to three.js at runtime.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-3d-scatter.jsonPrompt
Build a React + TypeScript + Tailwind "Chart3dScatter" widget on three.js via
@react-three/fiber and @react-three/drei (Canvas, OrbitControls, Html), with zod.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
xLabel?: string; yLabel?: string; zLabel?: string;
items: { id, label, cluster, x, y, z }[] }.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
usual div props; cn() merges className, the rest spread on the root.
Behavior
- Four first-class branches in one bg-card panel. loading / empty / error are
plain DOM (a WebGL context must never exist for them): loading is a
deterministic cloud of pulsing dots plus two text bars, empty is an icon +
zero-data copy, error shows a "Try again" button only when onRetry exists.
- ready gates <Canvas> behind a mounted flag (useSyncExternalStore returning
false on the server, true after hydration) so SSR never touches WebGL; the
pre-mount frame reuses the loading skeleton inside the same aspect box.
- Normalize each axis independently into a [-1, 1] cube, guarding zero-span
axes to 0 instead of dividing by zero. Spheres are grouped per cluster.
- Hover (R3F pointer events): the sphere scales up with an emissive lift and a
drei <Html> tooltip shows label, cluster and the three axis values;
pointerout is guarded against the out-then-over ordering so crossing spheres
never blinks the tooltip; hovering pauses the auto-rotation.
- OrbitControls with damping, pan disabled, zoom clamped; autoRotate only when
prefers-reduced-motion does not match (tracked live via matchMedia change).
Rendering & styling
- Never hand a color literal to WebGL. Resolve tokens at runtime:
getComputedStyle(document.documentElement).getPropertyValue("--chart-1")
→ set it as fillStyle on an offscreen 1×1 2D canvas → fillRect → read the
pixel back with getImageData → THREE.Color().setRGB(r/255, g/255, b/255,
SRGBColorSpace), because THREE.Color cannot parse oklch tokens. Re-resolve
whenever a MutationObserver sees the html class attribute change (theme
flip); disconnect the observer on unmount.
- Cluster i wears the resolved --chart-{(i % 5) + 1}; grid planes (floor +
two walls, lineBasicMaterial, transparent) wear --border; the three axis
lines wear --muted-foreground; ambientLight + directionalLight over
meshStandardMaterial; <Canvas dpr={[1, 2]} flat> inside a relative isolate
aspect-[4/3] w-full wrapper.
- DOM stays token-classed: tooltip is bg-popover/border/shadow-md, legend dots
and the tooltip swatch reuse var(--chart-N) directly, axis captions are
drei <Html> spans in text-muted-foreground.
- Accessibility: the canvas wrapper is role="img" with a descriptive
aria-label; an sr-only summary + table lists every point and per-cluster
means; the retry button has a focus-visible ring; skeletons are aria-hidden
with motion-reduce:animate-none.
Customization levers
- Framing: the wrapper's aspect ratio (aspect-[4/3] → aspect-square for grid
cards) and the camera's fov/position; keep the whole [-1,1] cube in frame.
- Point presence: sphere radius (~0.05 of the cube) and the hover pop
(scale 1.4 / emissiveIntensity 0.5) — shrink both for dense clouds of
hundreds of points, or switch the map to <Instances> past ~1k.
- Camera manners: autoRotateSpeed, zoom min/max, enablePan — a data-entry
dashboard usually wants autoRotate off entirely.
- Grid density: GRID_DIVISIONS and which planes are drawn; drop the walls and
keep the floor for a lighter stage.
- Palette: clusters cycle the five chart tokens; pin one token per known
cluster name upstream when semantics matter (e.g. team colors). More than
five clusters wrap the cycle — merge the tail upstream if that misleads.
- Legend & hint: both are plain DOM below the canvas — drop the hint line in
embeds, or move the legend beside the canvas on wide cards.Concepts
- Mounted gate — SSR and the hydration render both paint a plain-DOM skeleton;
<Canvas>only mounts after auseSyncExternalStoreflag flips on the client, so a WebGL context can never be requested where none can exist. - Token-to-WebGL color bridge —
THREE.Colorcannot parseoklch, so each token is painted onto an offscreen 1×1 2D canvas and read back as pixel bytes: the browser's own CSS engine does the parsing, andsetRGB(..., sRGB)moves the bytes into the renderer's working space. - Theme flip re-resolution — a
MutationObserveron thehtmlclass attribute re-runs the bridge when the theme changes, so materials inside the GL scene follow the DOM to the new palette; the observer disconnects on unmount. - Unit-cube normalization — each axis maps independently into
[-1, 1], so usage %, shooting % and assist % share one stage; the sr-only summary says out loud that the cube shows relative spread, not shared units. - DOM tooltips over WebGL — hover state lives in React and renders a drei
<Html>overlay styled withbg-popover/bordertokens: the tooltip re-themes for free and never needs text rendered inside the GL scene. - Camera etiquette — the idle auto-rotation that makes depth legible pauses while you point (so the tooltip holds still) and never starts under
prefers-reduced-motion; orbiting by hand stays available either way.
3D Bar Grid
A four-state WebGL bar grid — categories along X, series lanes along Z, orbitable boxes on a ground grid with hover tooltips, driven by one contract.
3D Surface Plot
A four-state rotatable 3D surface from a z-value grid — vertex colors lerped between chart tokens by height, a subtle wireframe overlay and hover tooltips.