Catalog

Edit Props (live)

JSON
TEXT
config
config
config
config
config
config
config

Source

452 lines
// Extruded Bar Chart 3D — the WebGL answer to animated_bar_chart. Real
// 3D boxes with perspective, depth and lighting grow in (spring, staggered)
// from a JSON array of { label, value }, on a dark floor, with a gentle sway
// and emissive top-cap glow. Renders through @remotion/three (ThreeCanvas)
// driven entirely by useCurrentFrame() so Lambda's out-of-order, software-GL
// (swangle) render is frame-deterministic.
//
// Determinism contract:
//   - NO useFrame (r3f). Every transform is recomputed from useCurrentFrame()
//     each render and applied as a prop — pure function of the frame.
//   - NO wall-clock / random / rAF — the per-bar settle wobble is seeded from
//     a fixed integer hash (mulberry-ish) at module scope, never at render.
//   - NO postprocessing / bloom (swangle can't do it) — glow is the emissive
//     top cap; the 2D value labels are projected to sit exactly under the bars.

import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { ThreeCanvas } from '@remotion/three';
import * as THREE from 'three';
import { loadFont as loadGrotesk } from '@remotion/google-fonts/SpaceGrotesk';

const { fontFamily: GROTESK } = loadGrotesk();

type Datum = { label: string; value: number };

export type ExtrudedBarChart3DProps = {
  data: Datum[] | string;
  title: string;
  barColor: string;
  accentColor: string;
  floorColor: string;
  backgroundColor: string;
  textColor: string;
  cameraDistance: number;
  orbitAmount: number;
};

// ── Deterministic helpers (module scope — never per-render) ───────────────────

/** Tiny integer hash → [0,1). Pure, stable, no Math.random. */
function hash01(n: number): number {
  let t = (n + 0x6d2b79f5) >>> 0;
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}

function parseData(data: Datum[] | string): Datum[] {
  let raw: unknown = data;
  if (typeof data === 'string') {
    try {
      raw = JSON.parse(
        data.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
      );
    } catch {
      raw = [];
    }
  }
  if (!Array.isArray(raw)) return [];
  return raw.slice(0, 8).map((d) => ({
    label: String((d as Datum)?.label ?? ''),
    value: Number((d as Datum)?.value) || 0,
  }));
}

/**
 * Validate a hex color string, returning a safe hex (never throws, never
 * silently white). THREE.Color does NOT throw on bad input — it warns and
 * defaults to white — so we must validate the string ourselves. Returned hex
 * is used for BOTH the GL materials and the CSS background so they can't drift.
 */
function safeHex(c: string, fallback: string): string {
  const s = typeof c === 'string' ? c.trim() : '';
  return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s) ? s : fallback;
}

// Reusable geometry — created once, never per frame, never per bar.
const BAR_GEO = new THREE.BoxGeometry(1, 1, 1);

// ── The 3D scene (rendered inside <ThreeCanvas>) ─────────────────────────────

type SceneProps = {
  bars: Datum[];
  frame: number;
  fps: number;
  barColor: THREE.Color;
  accentColor: THREE.Color;
  floorColor: THREE.Color;
  bgColor: THREE.Color;
  orbitAmount: number;
};

function Scene({
  bars,
  frame,
  fps,
  barColor,
  accentColor,
  floorColor,
  bgColor,
  orbitAmount,
}: SceneProps) {
  const count = Math.max(1, bars.length);
  const max = Math.max(1, ...bars.map((b) => b.value));

  // Layout: spread bars across X, centered on origin.
  const gap = 2.0;
  const totalWidth = (count - 1) * gap;
  const startX = -totalWidth / 2;

  // Gentle sway (not a continuous orbit) so the bars stay roughly front-facing
  // and keep alignment with the 2D value labels, while still showing 3D
  // parallax. orbitAmount=0 locks it dead-on. Pure function of frame.
  const orbit = Math.sin((frame / fps) * 0.45) * 0.1 * orbitAmount; // radians
  const bob = Math.sin((frame / fps) * 0.9) * 0.1;

  // Intro: the whole rig eases up from below in the first ~16 frames.
  const rigRise = interpolate(frame, [0, 16], [-2.2, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <group position={[0, rigRise + bob, 0]} rotation={[0, orbit, 0]}>
      {/* Reflective-ish floor (lambert, dark). */}
      <mesh
        rotation={[-Math.PI / 2, 0, 0]}
        position={[0, 0, 0]}
        receiveShadow={false}
      >
        <planeGeometry args={[40, 40]} />
        {/* Lambert (not standard PBR): the floor is the largest plane on
            screen — cheapest per-pixel path on Lambda's software GL, no
            metalness/roughness needed for a dark grounding surface. */}
        <meshLambertMaterial color={floorColor} />
      </mesh>

      {/* Faint floor grid lines, faked with thin emissive strips along Z. */}
      {Array.from({ length: count }).map((_, i) => {
        const x = startX + i * gap;
        return (
          <mesh
            key={`grid-${i}`}
            rotation={[-Math.PI / 2, 0, 0]}
            position={[x, 0.01, 0]}
          >
            <planeGeometry args={[0.04, 14]} />
            <meshBasicMaterial color={accentColor} transparent opacity={0.12} />
          </mesh>
        );
      })}

      {bars.map((b, i) => {
        const grow = spring({
          frame: frame - 10 - i * 5,
          fps,
          config: { damping: 16, stiffness: 95, mass: 0.7 },
          durationInFrames: 30,
        });
        const targetH = (b.value / max) * 4.5 + 0.001; // world units
        const h = Math.max(0.001, targetH * grow);
        const x = startX + i * gap;
        const width = 1.2;
        const depth = 1.2;

        // Color ramps slightly toward the accent for taller bars.
        const t = (b.value / max) * 0.6;
        const bodyColor = barColor.clone().lerp(accentColor, t);
        // Emissive top-cap glow rises as the bar settles.
        const emissiveStrength = 0.25 + grow * 0.55;

        // Per-bar deterministic settle wobble — decays to 0 by ~frame settle.
        const wobblePhase = hash01(i * 17 + 3) * Math.PI * 2;
        const wobbleDecay = interpolate(grow, [0.7, 1], [1, 0], {
          extrapolateLeft: 'clamp',
          extrapolateRight: 'clamp',
        });
        const tilt =
          Math.sin((frame / fps) * 8 + wobblePhase) * 0.05 * wobbleDecay;

        return (
          <group key={i} position={[x, 0, 0]} rotation={[0, 0, tilt]}>
            {/* Main extruded bar body (sits on the floor, grows upward). */}
            <mesh
              geometry={BAR_GEO}
              position={[0, h / 2, 0]}
              scale={[width, h, depth]}
            >
              <meshStandardMaterial
                color={bodyColor}
                roughness={0.32}
                metalness={0.35}
                emissive={bodyColor}
                emissiveIntensity={0.08}
              />
            </mesh>

            {/* Emissive top cap — the "glow" source (no bloom needed). */}
            <mesh
              geometry={BAR_GEO}
              position={[0, h + 0.06, 0]}
              scale={[width * 1.02, 0.12, depth * 1.02]}
            >
              <meshStandardMaterial
                color={accentColor}
                emissive={accentColor}
                emissiveIntensity={emissiveStrength * 2.2}
                roughness={0.2}
                metalness={0.1}
              />
            </mesh>
          </group>
        );
      })}

      {/* Lighting: soft ambient fill + key directional + colored rim point. */}
      <ambientLight intensity={0.55} />
      <directionalLight position={[6, 12, 8]} intensity={1.15} />
      <pointLight
        position={[-6, 6, -4]}
        intensity={0.9}
        color={accentColor}
        distance={40}
        decay={2}
      />
      {/* Subtle fog-colored hemisphere to ground the palette. Colors passed
          as props (not constructor args) so r3f re-applies them via setters
          on prop change — constructor args would bake in at mount. */}
      <hemisphereLight
        intensity={0.35}
        color={barColor}
        groundColor={bgColor}
      />
    </group>
  );
}

// ── Block component ──────────────────────────────────────────────────────────

export default function ExtrudedBarChart3D({
  data = [
    { label: 'Jan', value: 32 },
    { label: 'Feb', value: 54 },
    { label: 'Mar', value: 71 },
    { label: 'Apr', value: 48 },
    { label: 'May', value: 89 },
  ],
  title = '',
  barColor = '#5b3df5',
  accentColor = '#22d3ee',
  floorColor = '#0b0b18',
  backgroundColor = '#06060c',
  textColor = '#ffffff',
  cameraDistance = 13,
  orbitAmount = 1,
}: Partial<ExtrudedBarChart3DProps>) {
  const frame = useCurrentFrame();
  const { fps, durationInFrames, width, height } = useVideoConfig();
  // Resolution-independent overlays: HTML px authored at a 1080-wide
  // reference, scaled by k. k=1 at 1080; scales cleanly at any size (the
  // GL scene is already resolution-independent via world units + fov).
  const k = width / 1080;

  const bars = parseData(data);
  const count = Math.max(1, bars.length);
  const max = Math.max(1, ...bars.map((b) => b.value));

  // Validate colors once into safe hex, used for BOTH GL + CSS (no drift).
  const barHex = safeHex(barColor, '#5b3df5');
  const accentHex = safeHex(accentColor, '#22d3ee');
  const floorHex = safeHex(floorColor, '#0b0b18');
  const bgHex = safeHex(backgroundColor, '#06060c');
  const textHex = safeHex(textColor, '#ffffff');
  const barCol = new THREE.Color(barHex);
  const accentCol = new THREE.Color(accentHex);
  const floorCol = new THREE.Color(floorHex);
  const bgCol = new THREE.Color(bgHex);

  // Camera + label framing derived from ONE perspective projection so the
  // 2D value labels always sit under their 3D bars, for any bar count / zoom.
  // The camera sits at x=0 (centered) and r3f points it at the origin, so its
  // right axis is world X and a bar at world x projects to screen fraction
  //   0.5 + 0.5 * x / (depth * tan(fov/2) * aspect),
  // where depth = camera→origin distance = camDist * sqrt(camYFrac^2 + 1).
  // We pick camDist so the OUTER bars land at ±TARGET_FIELD of center, then
  // reuse the SAME projection for the label row width — they can't drift.
  const gap = 2.0;
  const totalWidth = (count - 1) * gap;
  const camYFrac = 0.5; // camera height as a fraction of camDist (3/4 angle)
  const depthFactor = Math.sqrt(camYFrac * camYFrac + 1);
  const tanHalfFov = Math.tan((38 * Math.PI) / 180 / 2);
  const aspect = width / height;
  const TARGET_FIELD = 0.72; // outer bar centers span ~72% of frame width
  // depth (camera→origin) that frames the outer bars at ±TARGET_FIELD:
  const fitDepth =
    totalWidth > 0 ? totalWidth / 2 / (TARGET_FIELD * tanHalfFov * aspect) : 18;
  const zoom = Math.max(0.5, cameraDistance / 13); // prop = zoom multiplier
  const camDist = Math.max(7, (fitDepth / depthFactor) * zoom);
  const depth = camDist * depthFactor;
  // x / projDen = NDC x; screenFracX = 0.5 + 0.5 * x / projDen. Each label is
  // absolutely placed at its bar's projected screen-X with this. (The sway is
  // <=5deg so its effect on label X is <1% — safely ignored here.)
  const projDen = depth * tanHalfFov * aspect;
  const startX = -totalWidth / 2; // leftmost bar center, world units

  // Whole-block fade at the tail so it cuts cleanly in a sequence.
  const exit = interpolate(
    frame,
    [durationInFrames - 12, durationInFrames - 1],
    [1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );
  const titleIn = interpolate(frame, [0, 14], [0, 1], {
    extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill
      style={{
        backgroundColor: bgHex,
        fontFamily: GROTESK,
        opacity: exit,
        overflow: 'hidden',
      }}
    >
      {/* Radial vignette behind the GL for depth (pure CSS, frame-independent). */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          background: `radial-gradient(120% 90% at 50% 32%, ${accentHex}22 0%, ${bgHex} 62%)`,
        }}
      />

      <ThreeCanvas
        width={width}
        height={height}
        style={{ position: 'absolute', inset: 0 }}
        camera={{
          position: [0, camDist * camYFrac, camDist],
          fov: 38,
          near: 0.1,
          far: 200,
        }}
        gl={{ antialias: true, alpha: true }}
      >
        <Scene
          bars={bars}
          frame={frame}
          fps={fps}
          barColor={barCol}
          accentColor={accentCol}
          floorColor={floorCol}
          bgColor={bgCol}
          orbitAmount={orbitAmount}
        />
      </ThreeCanvas>

      {/* Title (HTML overlay — crisp text, frame-driven fade). */}
      {title ? (
        <div
          style={{
            position: 'absolute',
            top: '7%',
            left: 0,
            right: 0,
            textAlign: 'center',
            color: textHex,
            fontSize: 64 * k,
            fontWeight: 800,
            letterSpacing: '-0.02em',
            textShadow: '0 6px 28px rgba(0,0,0,0.6)',
            opacity: titleIn,
            transform: `translateY(${interpolate(
              titleIn,
              [0, 1],
              [-18 * k, 0]
            )}px)`,
            pointerEvents: 'none',
          }}
        >
          {title}
        </div>
      ) : null}

      {/* Value + label chips, each ABSOLUTELY positioned at its bar's projected
          screen-X (same projection as the camera framing) so they track the 3D
          bars exactly — no flex/space-between inset. Staggered with the same
          spring timing as the bars so label + bar read as one motion. */}
      {bars.map((b, i) => {
        const grow = spring({
          frame: frame - 10 - i * 5,
          fps,
          config: { damping: 16, stiffness: 95, mass: 0.7 },
          durationInFrames: 30,
        });
        const screenX = 0.5 + (0.5 * (startX + i * gap)) / projDen; // 0..1
        return (
          <div
            key={i}
            style={{
              position: 'absolute',
              bottom: '7%',
              left: `${screenX * 100}%`,
              transform: `translateX(-50%) translateY(${interpolate(
                grow,
                [0, 1],
                [14 * k, 0]
              )}px)`,
              display: 'flex',
              flexDirection: 'column',
              alignItems: 'center',
              opacity: grow,
              pointerEvents: 'none',
            }}
          >
            <div
              style={{
                color: textHex,
                fontSize: 40 * k,
                fontWeight: 700,
                fontVariantNumeric: 'tabular-nums',
                textShadow: '0 2px 12px rgba(0,0,0,0.7)',
              }}
            >
              {Math.round(b.value * grow)}
            </div>
            <div
              style={{
                color: textHex,
                opacity: 0.7,
                fontSize: 30 * k,
                fontWeight: 500,
                marginTop: 6 * k,
                letterSpacing: '0.04em',
                whiteSpace: 'nowrap',
              }}
            >
              {b.label}
            </div>
          </div>
        );
      })}
    </AbsoluteFill>
  );
}

Schema (Zod)

import { z } from 'zod';

const datumSchema = z.object({
  label: z.string(),
  value: z.number(),
});

export const Schema = z.object({
  data: z
    .union([z.array(datumSchema), z.string()])
    .default([
      { label: 'Jan', value: 32 },
      { label: 'Feb', value: 54 },
      { label: 'Mar', value: 71 },
      { label: 'Apr', value: 48 },
      { label: 'May', value: 89 },
    ])
    .describe('$port:json Array of { label, value } (up to 8 bars)'),
  title: z.string().default('').describe('$port:text Chart title (optional)'),

  barColor: z
    .string()
    .default('#5b3df5')
    .describe('$style:color Bar base color'),
  accentColor: z
    .string()
    .default('#22d3ee')
    .describe('$style:color Accent / glow color'),
  floorColor: z
    .string()
    .default('#0b0b18')
    .describe('$style:color Floor color'),
  backgroundColor: z
    .string()
    .default('#06060c')
    .describe('$style:color Background color'),
  textColor: z
    .string()
    .default('#ffffff')
    .describe('$style:color Label / value text color'),

  cameraDistance: z
    .number()
    .default(13)
    .describe('$style:scale Camera distance (zoom out)'),
  orbitAmount: z
    .number()
    .default(1)
    .describe('$style:scale Orbit speed multiplier (0 = locked)'),
});

Extruded Bar Chart 3D

extruded_bar_chart_3d

WebGL data-viz: real 3D extruded bars with perspective, depth, lighting and a slow orbit grow in (spring, staggered) from a JSON array of { label, value }, each counting up to its value. The 3D answer to Animated Bar Chart.

Workflow Inputs (2)

  • Array of { label, value } (up to 8 bars)
    data
    JSON
  • Chart title (optional)
    title
    TEXT

Config Fields (7)

  • barColor
  • textColor
  • floorColor
  • accentColor
  • orbitAmount
  • cameraDistance
  • backgroundColor

Meta

Updated
7/10/2026