Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import { cn } from "@/lib/utils"
import type { Chart3dScatterData, Chart3dScatterItem } from "./chart-3d-scatter.contract"

export interface Chart3dScatterProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dScatterData {

Installation

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

Prompt

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 a useSyncExternalStore flag flips on the client, so a WebGL context can never be requested where none can exist.
  • Token-to-WebGL color bridgeTHREE.Color cannot parse oklch, 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, and setRGB(..., sRGB) moves the bytes into the renderer's working space.
  • Theme flip re-resolution — a MutationObserver on the html class 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 with bg-popover / border tokens: 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.

On This Page