Charts

3D Heightmap Matrix

A four-state 3D lego-plot matrix — rows × columns of WebGL columns whose height and colour intensity both encode one value, with orbit controls and hover tooltips.

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, Grid3x3, RefreshCcw } from "lucide-react"
import * as THREE from "three"

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

export interface Chart3dHeightmapProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    Chart3dHeightmapData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "Chart3dHeightmap" dashboard card on
three.js via @react-three/fiber + @react-three/drei, with zod. It renders a
rows × columns value matrix as a lego plot: one square column per cell, whose
height AND colour intensity encode the same value.

Contract
- A zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    rowLabels: string[]; colLabels: string[];
    values: number[][] (row-major, nonnegative — values[r][c] pairs with
    rowLabels[r] × colLabels[c]); unit?: string }.
- Component props = z.infer of the schema, plus onRetry?: () => void and the
  usual div props (className merged with cn(), rest spread on the root).
- The label lists define the grid: missing matrix entries render as flat zero
  pads, extras beyond the label counts are ignored — normalize once in a memo
  so the render loop never guards again.

Behavior
- Four first-class branches inside one bg-card shell: loading (pulsing DOM
  bar skeleton, aria-hidden, motion-reduce:animate-none), empty (icon + copy,
  also taken when either label list is empty), error (message + "Try again"
  rendered only when onRetry exists), ready (the WebGL scene).
- SSR must never touch WebGL: gate <Canvas> behind a client-mounted flag
  (useSyncExternalStore with a server snapshot of false) and keep showing the
  loading skeleton until the client has mounted AND tokens are resolved.
- Token → pixel → material: THREE.Color cannot parse modern CSS color
  functions, so resolve each token with getComputedStyle, paint it onto a 1×1
  offscreen 2D canvas, read the pixel channels back, and setRGB with the
  SRGBColorSpace flag. Keep the alpha byte and multiply it into the floor
  materials' opacity (dark themes often define --border as low-alpha white,
  and dropping alpha would render it as opaque white); skip the paint when a
  token resolves to an empty string and treat a fully transparent read-back
  as a missing token — stand in a neutral mid-grey instead of returning
  whatever colour the shared probe held last. Re-resolve through a
  MutationObserver on the html element's class attribute (that is where the
  theme flips) and disconnect the observer on unmount.
- Columns: footprint ~0.8 of a 1-unit floor cell; height = value / max × 2.4
  world units, with a small floor for tiny values and a flat pad for exact
  zeros; tint = lerp(muted → chart-1) by the same ratio — two channels, one
  number.
- Hover via R3F pointer events on each mesh: outline the column with an
  edges-geometry wireframe in the muted-foreground colour (never brighten the
  face — that would fake a different value on the intensity ramp) and float a
  drei <Html> tooltip above it: "rowLabel · colLabel" plus the value + unit.
  Guard pointer-out against out-of-order events between neighbouring columns,
  and clear the hover when the pointer leaves the wrapper.
- OrbitControls with damping; autoRotate only while prefers-reduced-motion
  does not match AND nothing is hovered; pan disabled and the polar angle
  clamped so the floor can never flip over.
- Cleanup: the MutationObserver, the matchMedia listener and the one owned
  geometry are all released on unmount; fiber disposes the declarative rest.

Rendering & styling
- Semantic tokens only, resolved at runtime: columns lerp var(--muted) →
  var(--chart-1); inner floor grid lines wear --border, the outer frame and
  the hover outline wear --muted-foreground. Tick labels and the tooltip are
  DOM (drei <Html>) styled with token classes (text-muted-foreground on a
  translucent bg-card chip so a label stays legible whenever a column swings
  behind it; bg-popover + border for the tooltip) — no hex, no raw palette
  values anywhere in source.
- <Canvas dpr={[1,2]}> inside a w-full aspect-[16/10] overflow-hidden
  role="img" wrapper with a descriptive aria-label; ambientLight plus a key
  and a fill directionalLight; meshStandardMaterial, roughness ~0.45.
- Below the canvas: a caption explaining the encoding and the gestures, and a
  0 → max gradient scale strip built from the same two tokens.
- Accessibility: an sr-only summary sentence (dimensions, peak cell, total)
  plus a full <table> of the matrix with row and column headers — the exact
  numbers never depend on WebGL or a pointer.

Customization levers
- Ramp endpoints: swap the lerp pair (muted → chart-1) for any two tokens,
  e.g. chart-2 → chart-5 for a two-hue ramp; keep one perceptual direction so
  intensity still reads as magnitude.
- Density and drama: the column footprint (0.6 airy … 0.9 lego-dense) and the
  max world height (1.6 flat terrain … 3.5 dramatic) are single constants.
- Motion: autoRotateSpeed (0 kills the idle spin entirely), dampingFactor,
  and the polar-angle clamps set how far the reader can tilt.
- Camera: the position formula scales with rows/cols, and the default pitch
  is deliberately steep enough that the floor-edge row labels clear the
  column silhouette — bias it higher for a map-like read, but not so low
  that tall columns swallow the row axis again.
- Chrome: drop the caption or the scale strip for a bare embed; keep the
  sr-only table — it is the accessible chart.

Concepts

  • Mount-gated WebGL — the <Canvas> renders only after a client-only mounted flag flips, so the server and the hydration pass see nothing but the loading skeleton: SSR never touches a WebGL context, and the card keeps one silhouette while the scene arrives.
  • Token → pixel → material — THREE cannot parse the modern CSS color functions design tokens are written in, so each token is painted onto a one-pixel 2D canvas and read back as channels; a MutationObserver on the html class attribute re-runs the pipeline the moment the theme flips, so the 3D materials re-theme like any Tailwind class.
  • Dual encoding — the same value/max ratio drives both the column's height and its tint along a single muted → chart-1 ramp; when perspective occludes a back row, colour still carries the reading, and in greyscale the height still does.
  • Honest hover — the hovered column gets a wireframe collar instead of a brightness shift, because brightening the face would fake a different position on the intensity ramp; the exact number rides in a DOM tooltip (drei Html) styled with popover tokens, not a texture.
  • Motion etiquette — the idle auto-rotate is decoration, so it stops for prefers-reduced-motion and pauses while a column is hovered; orbiting stays available either way, because reading the data must not depend on the animation.
  • The table is the chart — a screen reader gets the summary sentence plus the full row × column table, so every cell is reachable without WebGL, a pointer, or colour perception.

On This Page