Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import * as THREE from "three"
import { Canvas, useFrame, type ThreeEvent } from "@react-three/fiber"
import { Html, OrbitControls } from "@react-three/drei"

import { cn } from "@/lib/utils"
import type { Chart3dSurfaceData } from "./chart-3d-surface.contract"

export interface Chart3dSurfaceProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dSurfaceData {
  /** Fired by the retry button in the error branch; omit to hide the button. */

Installation

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

Prompt

Build a React + TypeScript + Tailwind "Chart3dSurface" widget on three +
@react-three/fiber + @react-three/drei, with zod.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    items: { id; label?; values: number[] }[];   // rows of a z grid
    xLabels?: string[]; unit?: string }.
- Component props = z.infer of the schema, plus onRetry?: () => void,
  autoRotate?: boolean (default true) and the usual div props spread on the
  bg-card root. No hand-written parallel interface.

Behavior
- Four first-class branches in one rounded-xl border bg-card panel; only
  ready mounts a <Canvas>. SSR must never touch WebGL: gate the canvas on a
  client-only signal (useSyncExternalStore whose server snapshot is null, or
  a mounted flag) and render the loading skeleton until it resolves.
- Token resolution: THREE.Color cannot parse oklch(), so read each token via
  getComputedStyle(document.documentElement).getPropertyValue(...), paint it
  onto an offscreen 1×1 2D canvas and read the rasterised rgb pixel back.
  Re-resolve when a MutationObserver on the <html> class attribute fires
  (theme flip); disconnect the observer on unmount.
- Geometry: one BufferGeometry over the rows × cols grid. Positions span a
  fixed world size regardless of grid density; heights normalise zMin→0 and
  zMax→PEAK; each vertex colour lerps low→high by its normalised height.
  Two triangles per cell wound so computed normals face up; dispose the
  geometry (and any helper) on rebuild and unmount.
- Guard degenerate input: fewer than 2 rows or 2 columns renders an honest
  "not enough grid" message instead of a broken mesh; ragged rows clamp to
  the shortest row; a flat grid (zMin === zMax) sits at mid-height.
- Hover: onPointerMove on the surface mesh converts event.point back to the
  nearest row/col; a small marker sphere plus a drei <Html> tooltip
  (bg-popover token classes) name the cell and its value; onPointerOut
  clears it. Commit hover state only when the cell actually changes.
- OrbitControls with damping; autoRotate only while the prop is true AND
  prefers-reduced-motion does not match; frameloop drops to "demand" when
  the idle orbit is off.

Rendering & styling
- Zero hardcoded colours: the surface ramps var(--chart-2) → var(--chart-1),
  the wireframe overlay and base GridHelper use --border, the hover marker
  --foreground — all rasterised at runtime; the DOM legend gradient uses the
  same two tokens straight in CSS, so WebGL and DOM can never disagree.
- <Canvas dpr={[1,2]} flat> (flat = no tone mapping, so colours stay true to
  the tokens) inside a w-full aspect-[16/10] wrapper with role="img" and a
  descriptive aria-label; an sr-only paragraph summarises rows × cols, the
  min–max range and where the peak sits.
- ambientLight + directionalLight; meshStandardMaterial with vertexColors
  and polygonOffset so the wireframe never z-fights; axis tick labels are
  drei <Html> nodes, capped and evenly sampled — muted-foreground text on a
  translucent rounded bg-card/85 chip, so labels stay legible when the idle
  orbit carries them in front of the mesh. Grazing camera angles make tick
  labels collide on screen, so a useFrame pass projects every tick and
  greedily hides (opacity, with a short transition) any chip that would
  overlap one already kept, rows before columns; start the camera far enough
  back that the ground-ring labels stay in frame through a full orbit.
- cn() merges className; the retry button carries focus-visible ring tokens;
  skeleton pulses respect motion-reduce.

Customization levers
- Palette ramp: swap TOKEN_LOW/TOKEN_HIGH (--chart-2 → --chart-1 by default)
  for any two chart tokens; the DOM legend gradient follows for free.
- Relief strength: PEAK, the world height zMax maps to — lower for subtle
  fields, higher for dramatic terrain.
- Camera & motion: initial position/fov, autoRotateSpeed, min/max zoom
  distance, or autoRotate={false} for a calmer dashboard.
- Wireframe: opacity ≈ 0.25 by default; delete that mesh for a smooth sheet
  or raise it for a technical CAD look.
- Label density: LABEL_CAP ticks per axis, sampled with endpoints kept —
  raise it for sparse grids, or omit xLabels for a pure shape read.
- Footprint: the wrapper's aspect-[16/10] plus the SPAN/depth clamp are the
  size levers; the grid's own row/col ratio drives depth automatically.

Concepts

  • Client-only WebGL gate — the server and the hydration pass render a plain-DOM skeleton; the <Canvas> mounts strictly after hydration, so SSR, crawlers and no-WebGL environments never crash on a GL context.
  • Runtime token rasterisationTHREE.Color cannot parse oklch(), so each semantic token is painted onto a 1×1 2D canvas and read back as an rgb pixel; the browser performs the colour-space conversion, and the same trick works for any future CSS colour syntax.
  • Theme flip as a subscription — a MutationObserver on the <html> class attribute is the store the palette subscribes to: flip dark mode and the surface, wireframe and marker re-resolve without a remount.
  • Height-mapped vertex colours — every vertex encodes its value twice, as elevation and as a chart-2 → chart-1 lerp, so the shape survives flat viewing angles and the colour survives silhouette views; the DOM legend uses the identical two tokens in plain CSS.
  • Inverse-projection hover — instead of per-cell hit targets, one pointer handler converts the world-space hit point back to the nearest grid indices; a marker sphere and an <Html> tooltip follow that vertex, and state only commits when the cell changes.
  • Motion as a preference, frames as a budget — the idle orbit runs only without prefers-reduced-motion, and when it is off the frameloop drops to demand mode, so a settled chart stops burning animation frames.

On This Page