Prism
← All primitives

ForecastCone

3D

A 3D probability cone widening from a 'now' apex into the future, with seeded branching paths inside a translucent uncertainty hull and a sweeping present-ring. Deterministic, no backend — built to show forecasts with uncertainty.

Render via next/dynamic ssr:false. Self-contained; `spread` controls how uncertain the future is.

$npx shadcn@latest add https://prism.icglabs.co/r/forecast-cone.json
Dependencies:three@react-three/fiber@react-three/postprocessing
View raw manifest →

components/prism/ForecastCone.tsx
"use client";

import { useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { EffectComposer, Bloom } from "@react-three/postprocessing";
import * as THREE from "three";
import { useDeviceTier } from "@/lib/useDeviceTier";
import { AutoDpr, PauseWhenOffscreen } from "@/components/showcase/CanvasPerf";

/**
 * ForecastCone — a 3D probability cone widening from a single "now" apex into the
 * future. Seeded Brownian sample paths branch inside a translucent uncertainty
 * hull; a cross-section ring sweeps forward = "the present moment advancing."
 * Deterministic (same `seed` → same cone), so it needs no backend or token.
 *
 * Built for Sky's forecast-with-uncertainty + grounded-vs-imagined framing:
 * `spread` is how uncertain the future is.
 */
export type ForecastConeProps = {
  seed?: string;
  paths?: number;
  spread?: number;
  /** [median, hull, wildcard] hex. */
  colors?: [string, string, string];
  className?: string;
};

function mulberry32(seedStr: string) {
  let h = 1779033703 ^ seedStr.length;
  for (let i = 0; i < seedStr.length; i++) {
    h = Math.imul(h ^ seedStr.charCodeAt(i), 3432918353);
    h = (h << 13) | (h >>> 19);
  }
  let a = h >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const STEPS = 48;
const SPAN = 6;

function ConeScene({
  seed,
  paths,
  spread,
  colors,
  reduced,
}: {
  seed: string;
  paths: number;
  spread: number;
  colors: [string, string, string];
  reduced: boolean;
}) {
  const sweep = useRef<THREE.Mesh>(null);
  const group = useRef<THREE.Group>(null);

  const hullGeo = useMemo(() => {
    const pts: THREE.Vector2[] = [];
    for (let i = 0; i <= STEPS; i++) {
      const t = i / STEPS;
      pts.push(new THREE.Vector2(spread * Math.sqrt(t) * 1.6, t * SPAN));
    }
    const g = new THREE.LatheGeometry(pts, 48);
    g.rotateZ(-Math.PI / 2); // grow along +x (left = now, right = future)
    return g;
  }, [spread]);

  const lines = useMemo(() => {
    const rnd = mulberry32(seed);
    const out: { geo: THREE.BufferGeometry; mid: boolean }[] = [];
    for (let p = 0; p < paths; p++) {
      const pos = new Float32Array((STEPS + 1) * 3);
      let y = 0;
      let z = 0;
      const mid = p === 0;
      for (let i = 0; i <= STEPS; i++) {
        const t = i / STEPS;
        const cap = spread * Math.sqrt(t) * 1.5;
        if (!mid) {
          y += (rnd() - 0.5) * spread * 0.18;
          z += (rnd() - 0.5) * spread * 0.18;
          y = Math.max(-cap, Math.min(cap, y));
          z = Math.max(-cap, Math.min(cap, z));
        }
        pos[i * 3] = t * SPAN;
        pos[i * 3 + 1] = y;
        pos[i * 3 + 2] = z;
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
      out.push({ geo, mid });
    }
    return out;
  }, [seed, paths, spread]);

  const median = useMemo(() => new THREE.Color(colors[0]), [colors]);
  const hull = useMemo(() => new THREE.Color(colors[1]), [colors]);
  const wild = useMemo(() => new THREE.Color(colors[2]), [colors]);

  const lineObjs = useMemo(
    () =>
      lines.map((l) => {
        const mat = new THREE.LineBasicMaterial({
          color: l.mid ? median : wild,
          transparent: true,
          opacity: l.mid ? 1 : 0.28,
          blending: THREE.AdditiveBlending,
          depthWrite: false,
        });
        return new THREE.Line(l.geo, mat);
      }),
    [lines, median, wild]
  );

  useFrame((state) => {
    if (group.current && !reduced)
      group.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.15) * 0.12;
    if (sweep.current) {
      const tt = reduced ? 0.5 : (state.clock.elapsedTime * 0.25) % 1;
      sweep.current.position.x = tt * SPAN;
      const r = Math.max(0.001, spread * Math.sqrt(tt) * 1.5);
      sweep.current.scale.set(r, r, r);
    }
  });

  return (
    <group ref={group}>
      <mesh geometry={hullGeo}>
        <meshBasicMaterial
          color={hull}
          transparent
          opacity={0.12}
          side={THREE.DoubleSide}
          depthWrite={false}
          blending={THREE.AdditiveBlending}
        />
      </mesh>
      <mesh position={[0, 0, 0]}>
        <sphereGeometry args={[0.05, 16, 16]} />
        <meshBasicMaterial color={median} />
      </mesh>
      {lineObjs.map((o, i) => (
        <primitive key={i} object={o} />
      ))}
      <mesh ref={sweep}>
        <torusGeometry args={[1, 0.012, 8, 48]} />
        <meshBasicMaterial color={median} transparent opacity={0.9} blending={THREE.AdditiveBlending} depthWrite={false} />
      </mesh>
    </group>
  );
}

export function ForecastCone({
  seed = "prism",
  paths = 24,
  spread = 1.4,
  colors = ["#1fd4e6", "#5b7cff", "#ff3d81"],
  className,
}: ForecastConeProps) {
  const reduced =
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const q = useDeviceTier();
  const n = Math.max(8, Math.round(paths * q.scale));

  return (
    <div className={className} style={{ width: "100%", height: "100%" }}>
      <Canvas
        dpr={q.dpr}
        gl={{ antialias: true, powerPreference: "high-performance" }}
        camera={{ position: [-1.5, 1.4, 5.2], fov: 50 }}
      >
        <ConeScene seed={seed} paths={n} spread={spread} colors={colors} reduced={reduced} />
        <AutoDpr />
        <PauseWhenOffscreen />
        {q.bloom && (
          <EffectComposer>
            <Bloom intensity={0.9} luminanceThreshold={0} luminanceSmoothing={0.3} mipmapBlur radius={0.6} />
          </EffectComposer>
        )}
      </Canvas>
    </div>
  );
}
Live demo — read-only. Every section is a real, copyable primitive.