Prediction Marketsv0.3.0
Probability Chart
Odds over time per outcome with a snapping crosshair.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/probability-chart.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/markets/format.tsimport { FULL } from "@/lib/markets/math"; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K"). const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 }); /** "64%" (or "64.2%" with decimals). */ export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`; /** "64¢". */ export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`; /** "$1,234.56", or "$1.2M" compact. Input in cents. */ export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100)); /** Shares from hundredths: "156.25". */ export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */ export function formatChange(bps: number) { const value = Math.abs(bps) / 100; return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`; } /** Signed percent from basis points: "+56%". */ export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`; /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */ export function timeLeft(endsAt: number, now: number) { const ms = endsAt - now; if (ms <= 0) return "Ended"; const minutes = Math.floor(ms / 60000); const days = Math.floor(minutes / 1440); const hours = Math.floor((minutes % 1440) / 60); if (days > 0) return `Ends in ${days}d ${hours}h`; if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`; return `Ends in ${Math.max(1, minutes)}m`; } /** A fixed, locale-stable date: "Mar 14, 2027". */ export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms); export { FULL };lib/markets/math.ts/** * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins. */ export const FULL = 10000; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); /** The other side of a binary price: Yes + No = 100¢. */ export const complement = (price: number) => FULL - price; /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */ export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0); /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */ export const payoutFor = (shares: number) => shares; /** Value in cents of shares (hundredths) at a price. */ export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL); export interface BuyQuote { /** Average fill price, basis points. */ averagePrice: number; /** Price after the trade, basis points. */ newPrice: number; /** Price impact, basis points (newPrice − price). */ impact: number; /** Shares received, hundredths. */ shares: number; /** Payout if it wins, cents. */ payout: number; /** Return if it wins, basis points of the amount (5600 = +56%). */ returnBps: number; } /** * A simulated buy against a constant-product style curve: the more of the pool you take, the further * the price moves toward $1. Good enough to show impact and average price in a demo. */ export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote { const p = clamp(Math.round(price), 1, FULL - 1); if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 }; const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity))); const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1); const averagePrice = Math.floor((p + newPrice) / 2); const shares = sharesFor(amount, averagePrice); const payout = payoutFor(shares); return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) }; } /** A simulated limit buy: fills entirely at the limit price, no impact. */ export function quoteLimit(limitPrice: number, amount: number): BuyQuote { const p = clamp(Math.round(limitPrice), 1, FULL - 1); const shares = sharesFor(Math.max(0, amount), p); return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 }; } /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */ export function quoteSell(price: number, shares: number, liquidity: number) { const p = clamp(Math.round(price), 1, FULL - 1); const gross = valueOf(shares, p); const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity))); const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1); const averagePrice = Math.floor((p + newPrice) / 2); return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p }; } /** Profit and loss in cents for shares bought at an average price, marked at the current price. */ export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) { const cost = valueOf(shares, averagePrice); const value = valueOf(shares, currentPrice); return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 }; } /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */ export function normalize(prices: number[]) { const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1; const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total)); const rest = FULL - scaled.reduce((sum, p) => sum + p, 0); const largest = scaled.indexOf(Math.max(...scaled)); if (largest >= 0) scaled[largest] += rest; return scaled; }lib/markets/types.ts/** Prices and probabilities are integer basis points (0–10000: 6400 = 64% = 64¢), money is integer cents. */ export type MarketStatus = "open" | "closing" | "resolved"; export interface MarketOutcome { id: string; name: string; /** Price in basis points (0–10000). Binary markets: Yes + No always sum to 10000. */ price: number; /** 24h change in basis points (positive = up). */ change24h?: number; /** Avatar or icon image for the outcome. */ image?: string; /** Volume traded on this outcome, in cents. */ volume?: number; } export interface PricePoint { /** Timestamp, ms. */ t: number; /** Price per outcome, in basis points, same order as outcomes. */ prices: number[]; } export interface Market { id: string; question: string; /** Topic label, e.g. "Tech". */ topic: string; /** Market image URL; a generated icon is used without one. */ image?: string; type: "binary" | "multi"; /** Binary markets: [Yes, No]. Multi-outcome: probabilities normalized to 10000. */ outcomes: MarketOutcome[]; /** Total volume, cents. */ volume: number; /** Simulated liquidity used for price impact, cents. */ liquidity: number; traders: number; /** Names of a few traders, for the avatar group. */ traderNames?: string[]; /** End time, ms since epoch. */ endsAt: number; status: MarketStatus; /** Winning outcome once resolved. */ resolvedOutcomeId?: string; comments?: number; history?: PricePoint[]; rules?: string; resolutionSource?: { label: string; href: string }; } export type TradeSide = "buy" | "sell";lib/markets/use-now.tsimport { useSyncExternalStore } from "react"; let now = 0; const listeners = new Set<() => void>(); let timer = 0; function subscribe(listener: () => void) { listeners.add(listener); // Refresh on every subscribe, so a component that mounts while the timer is already running // does not start with a timestamp up to 15 s old. now = Date.now(); if (!timer) { timer = window.setInterval(() => { now = Date.now(); listeners.forEach((l) => l()); }, 15000); } return () => { listeners.delete(listener); if (!listeners.size) { window.clearInterval(timer); timer = 0; } }; } /** * The current time, refreshed every 15 s. It is 0 on the server and during hydration, so countdowns * render a placeholder first and never mismatch. */ export function useNow() { return useSyncExternalStore( subscribe, () => now || Date.now(), () => 0, ); } const noop = () => () => {}; /** * False on the server and during hydration, true after. Gate text derived from timestamps (axis * labels, dates) with it when the data may be generated relative to page load. */ export function useHydrated() { return useSyncExternalStore( noop, () => true, () => false, ); }Copy the source
components/ui/probability-chart/probability-chart.tsx"use client"; import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { formatProbability } from "@/lib/markets/format"; import type { Market } from "@/lib/markets/types"; import { useHydrated } from "@/lib/markets/use-now"; export type ChartTimeframe = "1H" | "6H" | "1D" | "1W" | "1M" | "ALL"; export interface ChartAnnotation { /** Timestamp, ms. */ t: number; /** Short label, e.g. "News: launch delayed". */ label: string; } export interface ProbabilityChartProps { /** Market with `history` (prices per outcome in basis points). */ market: Market; /** Initial timeframe. @default "1W" */ defaultTimeframe?: ChartTimeframe; /** Event dots on the first line, with a hover card. */ annotations?: ChartAnnotation[]; /** Plot height in px. @default 220 */ height?: number; /** Classes for the root. */ className?: string; } const FRAMES: { id: ChartTimeframe; ms: number }[] = [ { id: "1H", ms: 3600_000 }, { id: "6H", ms: 6 * 3600_000 }, { id: "1D", ms: 86400_000 }, { id: "1W", ms: 7 * 86400_000 }, { id: "1M", ms: 30 * 86400_000 }, { id: "ALL", ms: Infinity }, ]; const COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; const POINTS = 48; const PAD = { top: 10, right: 44, bottom: 22, left: 8 }; const timeFormat = (span: number) => new Intl.DateTimeFormat("en-US", span <= 86400_000 ? { hour: "numeric", minute: "2-digit", timeZone: "UTC" } : { month: "short", day: "numeric", timeZone: "UTC" }); /** Resamples history to POINTS evenly spaced points in [start, end] so paths can morph between timeframes. */ function resample(history: NonNullable<Market["history"]>, series: number, start: number, end: number) { const out: { t: number; values: number[] }[] = []; for (let i = 0; i < POINTS; i++) { const t = start + ((end - start) * i) / (POINTS - 1); let k = history.findIndex((point) => point.t >= t); if (k <= 0) k = Math.max(1, k === -1 ? history.length - 1 : 1); const a = history[k - 1]; const b = history[k]; const f = b.t === a.t ? 1 : Math.min(1, Math.max(0, (t - a.t) / (b.t - a.t))); out.push({ t, values: Array.from({ length: series }, (_, s) => Math.round(a.prices[s] + (b.prices[s] - a.prices[s]) * f)) }); } return out; } /** * Probability over time, one line per outcome, with timeframe tabs that morph the lines, a legend that * toggles them, a snapping crosshair, live ticks and optional event annotations. */ export function ProbabilityChart({ market, defaultTimeframe = "1W", annotations = [], height = 220, className }: ProbabilityChartProps) { const id = useId(); const reduceMotion = useReducedMotion(); const box = useRef<HTMLDivElement>(null); const hydrated = useHydrated(); const [width, setWidth] = useState(600); const [frame, setFrame] = useState<ChartTimeframe>(defaultTimeframe); const [hidden, setHidden] = useState<Record<number, boolean>>({}); const [active, setActive] = useState<number | null>(null); const [note, setNote] = useState<number | null>(null); const series = Math.min(market.type === "binary" ? 1 : market.outcomes.length, COLORS.length); useEffect(() => { const el = box.current; if (!el) return; const observer = new ResizeObserver(([entry]) => setWidth(Math.max(240, Math.round(entry.contentRect.width)))); observer.observe(el); return () => observer.disconnect(); }, []); const history = useMemo(() => market.history ?? [], [market.history]); const end = history.length ? history[history.length - 1].t : 0; const span = FRAMES.find((f) => f.id === frame)!.ms; const start = history.length ? Math.max(history[0].t, span === Infinity ? history[0].t : end - span) : 0; const points = useMemo(() => (history.length > 1 ? resample(history, series, start, end) : []), [history, series, start, end]); const plotW = width - PAD.left - PAD.right; const plotH = height - PAD.top - PAD.bottom; const x = (i: number) => PAD.left + (i / (POINTS - 1)) * plotW; const y = (bps: number) => PAD.top + (1 - bps / 10000) * plotH; const fmt = timeFormat(end - start); const path = (s: number) => points.map((p, i) => `${i ? "L" : "M"}${x(i).toFixed(1)} ${y(p.values[s]).toFixed(1)}`).join(" "); const last = points[points.length - 1]; const shown = active !== null ? points[active] : null; const pick = (event: PointerEvent<SVGSVGElement>) => { const rect = event.currentTarget.getBoundingClientRect(); const i = Math.round(((event.clientX - rect.left - PAD.left) / plotW) * (POINTS - 1)); setActive(Math.min(POINTS - 1, Math.max(0, i))); }; const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); setActive((current) => Math.min(POINTS - 1, Math.max(0, (current ?? POINTS - 1) + (event.key === "ArrowRight" ? 1 : -1)))); } else if (event.key === "Home" || event.key === "End") { event.preventDefault(); setActive(event.key === "Home" ? 0 : POINTS - 1); } else if (event.key === "Escape") setActive(null); }; const describe = (p: { t: number; values: number[] }) => `${fmt.format(p.t)}: ${Array.from({ length: series }, (_, s) => `${market.type === "binary" ? "Yes" : market.outcomes[s].name} ${formatProbability(p.values[s])}`).join(", ")}`; return ( <div className={cn("w-full", className)}> <div className="flex flex-wrap items-center justify-between gap-2"> <ul className="flex flex-wrap gap-1.5" aria-label="Lines"> {Array.from({ length: series }, (_, s) => { const name = market.type === "binary" ? "Yes" : market.outcomes[s].name; return ( <li key={name}> <button type="button" aria-pressed={!hidden[s]} onClick={() => setHidden((h) => ({ ...h, [s]: !h[s] }))} className={cn("flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-border px-2.5 text-[12px] outline-none focus-visible:ring-2 focus-visible:ring-ring", hidden[s] ? "text-muted-foreground opacity-60" : "text-foreground")} > <span aria-hidden="true" className="size-2 rounded-full" style={{ background: COLORS[s] }} /> {name} {last ? <span className="font-medium tabular-nums">{formatProbability(last.values[s])}</span> : null} </button> </li> ); })} </ul> <div role="tablist" aria-label="Timeframe" className="flex rounded-full bg-surface-2 p-0.5"> {FRAMES.map((f) => ( <button key={f.id} type="button" role="tab" aria-selected={frame === f.id} onClick={() => setFrame(f.id)} className={cn("relative h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", frame === f.id ? "text-foreground" : "text-[color-mix(in_oklab,var(--muted-foreground)_75%,var(--foreground))]")}> {frame === f.id ? <motion.span layoutId={`${id}-frame`} transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} className="absolute inset-0 rounded-full bg-background shadow-sm" /> : null} <span className="relative">{f.id}</span> </button> ))} </div> </div> <div ref={box} className="relative mt-3"> <svg width={width} height={height} role="img" aria-label={`Probability chart, ${frame}. ${last ? describe(last) : ""}. Use the arrow keys to read values.`} tabIndex={0} onPointerMove={pick} onPointerLeave={() => setActive(null)} onKeyDown={onKeyDown} onBlur={() => setActive(null)} className="block touch-pan-y overflow-visible rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring" > {[0, 2500, 5000, 7500, 10000].map((g) => ( <g key={g}> <line x1={PAD.left} x2={PAD.left + plotW} y1={y(g)} y2={y(g)} stroke="var(--border)" strokeDasharray={g === 0 ? undefined : "2 4"} /> <text x={PAD.left + plotW + 6} y={y(g) + 4} fontSize="11" fill="var(--muted-foreground)"> {g / 100}% </text> </g> ))} {points.length ? ( <> <text x={PAD.left} y={height - 4} fontSize="11" fill="var(--muted-foreground)"> {hydrated ? fmt.format(points[0].t) : null} </text> <text x={PAD.left + plotW} y={height - 4} fontSize="11" fill="var(--muted-foreground)" textAnchor="end"> {hydrated ? fmt.format(end) : null} </text> </> ) : null} {Array.from({ length: series }, (_, s) => hidden[s] || !points.length ? null : ( <motion.path key={s} d={path(s)} initial={false} animate={{ d: path(s) }} transition={reduceMotion ? { duration: 0 } : { duration: 0.5, ease: [0.22, 1, 0.36, 1] }} fill="none" stroke={COLORS[s]} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" /> ), )} {last ? Array.from({ length: series }, (_, s) => hidden[s] ? null : ( <g key={`end-${s}`}> <circle cx={x(POINTS - 1)} cy={y(last.values[s])} r="7" fill={COLORS[s]} opacity="0.25" className="origin-center animate-ping [transform-box:fill-box] motion-reduce:animate-none" /> <circle cx={x(POINTS - 1)} cy={y(last.values[s])} r="3.5" fill={COLORS[s]} stroke="var(--background)" strokeWidth="2" /> </g> ), ) : null} {annotations.map((a, k) => { if (a.t < start || a.t > end || !points.length) return null; const i = Math.round(((a.t - start) / Math.max(1, end - start)) * (POINTS - 1)); return ( <circle key={a.t} cx={x(i)} cy={y(points[i].values[0])} r="5" fill="var(--background)" stroke="var(--foreground)" strokeWidth="2" onPointerEnter={() => setNote(k)} onPointerLeave={() => setNote(null)} className="cursor-help" /> ); })} {shown && active !== null ? ( <g pointerEvents="none"> <line x1={x(active)} x2={x(active)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--foreground)" strokeOpacity="0.3" /> {Array.from({ length: series }, (_, s) => (hidden[s] ? null : <circle key={s} cx={x(active)} cy={y(shown.values[s])} r="4" fill={COLORS[s]} stroke="var(--background)" strokeWidth="2" />))} </g> ) : null} </svg> {shown && active !== null ? ( <div aria-hidden="true" className="pointer-events-none absolute top-0 z-10 rounded-lg border border-border bg-background px-2.5 py-1.5 text-[12px] shadow-md" style={{ left: Math.min(Math.max(0, x(active) + 10), width - 150) }} > <p className="text-muted-foreground">{fmt.format(shown.t)}</p> {Array.from({ length: series }, (_, s) => hidden[s] ? null : ( <p key={s} className="flex items-center gap-1.5"> <span className="size-1.5 rounded-full" style={{ background: COLORS[s] }} /> {market.type === "binary" ? "Yes" : market.outcomes[s].name} <span className="ml-auto pl-3 font-medium tabular-nums">{formatProbability(shown.values[s])}</span> </p> ), )} </div> ) : null} {note !== null && annotations[note] ? ( <div role="tooltip" className="pointer-events-none absolute top-8 left-1/2 z-10 -translate-x-1/2 rounded-lg bg-foreground px-2.5 py-1.5 text-[12px] text-background shadow-md"> {annotations[note].label} </div> ) : null} </div> <p aria-live="polite" className="sr-only"> {shown ? describe(shown) : ""} </p> <table className="sr-only"> <caption>Probability by time</caption> <thead> <tr> <th scope="col">Time</th> {Array.from({ length: series }, (_, s) => ( <th key={s} scope="col"> {market.type === "binary" ? "Yes" : market.outcomes[s].name} </th> ))} </tr> </thead> <tbody> {points.filter((_, i) => i % 6 === 0 || i === POINTS - 1).map((p) => ( <tr key={p.t}> <th scope="row">{hydrated ? fmt.format(p.t) : null}</th> {p.values.map((v, s) => ( <td key={s}>{formatProbability(v)}</td> ))} </tr> ))} </tbody> </table> </div> ); }
Usage
import { ProbabilityChart } from "@/components/ui/probability-chart/probability-chart";
<ProbabilityChart
market={market} // market.history: [{ t, prices: [bps, ...] }]
defaultTimeframe="1W"
annotations={[{ t: Date.UTC(2026, 9, 12), label: "Supplier report" }]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
market* | Market | – | Market with `history` (prices per outcome in basis points). |
defaultTimeframe | ChartTimeframe | "1W" | Initial timeframe. |
annotations | ChartAnnotation[] | – | Event dots on the first line, with a hover card. |
height | number | 220 | Plot height in px. |
className | string | – | Classes for the root. |
- Lines are resampled to a fixed number of points, so switching timeframe morphs the path instead of redrawing it.
- Prices are basis points. Binary markets draw the Yes line; multi-outcome markets draw up to five outcomes using the --chart-1…5 tokens.
- Date labels render after hydration, because demo data is generated relative to page load.
Accessibility
- Timeframes are a tab list and the legend entries are toggle buttons (aria-pressed), so every series can be hidden or shown from the keyboard.
- The plot is focusable: ←/→ move the crosshair point by point, Home/End jump to the ends and Escape clears it. The value under the crosshair is announced politely.
- A visually hidden table lists the sampled values, so the data is readable without the chart. Series are named in the legend and tooltip, never by color alone.
- With reduced motion the line morph and the pulsing live point are skipped.