← All primitives
ForecastCone
3DA 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.jsonDependencies: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>
);
}