diff --git a/client/src/App.tsx b/client/src/App.tsx index 671d377..bfb3628 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,4 @@ -import { Switch, Route } from "wouter"; +import { Switch, Route, Redirect } from "wouter"; import { queryClient } from "./lib/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; @@ -13,11 +13,7 @@ import ComputeFrontierMethodology from "@/pages/ComputeFrontierMethodology"; import ComputeFrontierCompare from "@/pages/ComputeFrontierCompare"; import ComputeFrontierDetail from "@/pages/ComputeFrontierDetail"; import NeocloudIntel from "@/pages/neocloud-intel"; -import GpuEconomics from "@/pages/gpu-economics"; -import PowerDeals from "@/pages/power-deals"; -import BriefPage from "@/pages/brief"; -import TheTrade from "@/pages/TheTrade"; -import PortfolioOverlay from "@/pages/PortfolioOverlay"; +import Analyze from "@/pages/Analyze"; import CatalystTracker from "@/pages/CatalystTracker"; import StockPage from "@/pages/StockPage"; import SectorPage from "@/pages/SectorPage"; @@ -25,9 +21,7 @@ import RegionPage from "@/pages/RegionPage"; import OperatorPage from "@/pages/OperatorPage"; import BlogIndex from "@/pages/BlogIndex"; import BlogPost from "@/pages/BlogPost"; -import SupplyChain from "@/pages/SupplyChain"; import Subscribe from "@/pages/Subscribe"; -import Queue from "@/pages/Queue"; import AdminDatacenters from "@/pages/AdminDatacenters"; import AdminSocial from "@/pages/AdminSocial"; import { NewsTicker } from "@/components/NewsTicker"; @@ -49,17 +43,11 @@ const PAGE_TITLES: Record = { "/": "GridTilt", "/overview": "Tilt Overview", "/stack": "The Stack", - "/power-map": "Power Map", + "/power-map": "Power", "/compute-frontier": "Compute Frontier", - "/neocloud-intel": "Neocloud Intel", - "/gpu-economics": "GPU Economics", - "/power-deals": "AI Power Deals", - "/brief": "The Buildout Brief", - "/supply-chain": "Supply Chain", - "/trade": "Scenario Calculator", - "/portfolio": "Portfolio Overlay", + "/neocloud-intel": "GPU Prices", + "/analyze": "Analyze", "/catalysts": "Catalyst Tracker", - "/queue": "Interconnection Backlog", "/blog": "Analysis", "/subscribe": "Subscribe", }; @@ -67,18 +55,12 @@ const PAGE_TITLES: Record = { const SHORTCUTS = [ { keys: ["G", "1"], description: "Go to Tilt Overview", path: "/overview" }, { keys: ["G", "2"], description: "Go to The Stack", path: "/stack" }, - { keys: ["G", "3"], description: "Go to Power Map", path: "/power-map" }, - { keys: ["G", "4"], description: "Go to Supply Chain", path: "/supply-chain" }, - { keys: ["G", "5"], description: "Go to Portfolio Overlay", path: "/portfolio" }, - { keys: ["G", "6"], description: "Go to Scenario Calculator", path: "/trade" }, - { keys: ["G", "7"], description: "Go to Catalyst Tracker", path: "/catalysts" }, + { keys: ["G", "3"], description: "Go to Power", path: "/power-map" }, + { keys: ["G", "4"], description: "Go to Compute Frontier", path: "/compute-frontier" }, + { keys: ["G", "5"], description: "Go to GPU Prices", path: "/neocloud-intel" }, + { keys: ["G", "6"], description: "Go to Catalyst Tracker", path: "/catalysts" }, + { keys: ["G", "7"], description: "Go to Analyze", path: "/analyze" }, { keys: ["G", "8"], description: "Go to Analysis", path: "/blog" }, - { keys: ["G", "9"], description: "Go to Interconnection Backlog", path: "/queue" }, - { keys: ["G", "0"], description: "Go to Compute Frontier", path: "/compute-frontier" }, - { keys: ["G", "N"], description: "Go to Neocloud Intel", path: "/neocloud-intel" }, - { keys: ["G", "D"], description: "Go to AI Power Deals", path: "/power-deals" }, - { keys: ["G", "B"], description: "Go to The Buildout Brief", path: "/brief" }, - { keys: ["G", "E"], description: "Go to GPU Economics", path: "/gpu-economics" }, { keys: ["?"], description: "Show this keyboard shortcuts panel", path: null }, ]; @@ -92,7 +74,7 @@ function KeyboardShortcutsModal({ onClose }: { onClose: () => void }) {
- +

Keyboard Shortcuts

-

+

Press Esc or click outside to close

@@ -169,14 +151,17 @@ function Router() { - - - - - - + {/* Consolidation: GPU Economics is now the economics tab of GPU Prices */} + {() => } + {/* Consolidation: Deals and Queue are tabs of Power; Brief lives in Analysis */} + {() => } + {() => } + {() => } + + {() => } + {() => } - + {() => } @@ -249,9 +234,8 @@ function App() { if (gPressed) { const routes: Record = { "1": "/overview", "2": "/stack", "3": "/power-map", - "4": "/supply-chain", "5": "/portfolio", "6": "/trade", - "7": "/catalysts", "8": "/blog", "9": "/queue", - "0": "/compute-frontier", "n": "/neocloud-intel", "d": "/power-deals", "b": "/brief", "e": "/gpu-economics", + "4": "/compute-frontier", "5": "/neocloud-intel", "6": "/catalysts", + "7": "/analyze", "8": "/blog", }; if (routes[e.key]) { navigate(routes[e.key]); diff --git a/client/src/components/EmailCapture.tsx b/client/src/components/EmailCapture.tsx index 02a4f09..36d351e 100644 --- a/client/src/components/EmailCapture.tsx +++ b/client/src/components/EmailCapture.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { Mail, CheckCircle2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { apiRequest } from "@/lib/queryClient"; +import { BORDER, BRAND, FONT, INK, SURFACE } from "@/lib/tokens"; interface ExtraField { // Constrained to body keys /api/subscribe knows how to persist. Adding a @@ -71,8 +72,8 @@ export function EmailCapture({ return (

@@ -95,8 +96,8 @@ export function EmailCapture({ return (

(e.currentTarget.style.borderColor = "#F07800")} + onFocus={(e) => (e.currentTarget.style.borderColor = BRAND.primary)} onBlur={(e) => - (e.currentTarget.style.borderColor = "rgba(255,255,255,0.06)") + (e.currentTarget.style.borderColor = BORDER.subtle) } data-testid="input-extra-field" /> @@ -195,18 +196,18 @@ export function EmailCapture({ style={{ flex: 1, padding: "11px 13px", - background: "#0B0B0A", - border: "1px solid rgba(255,255,255,0.06)", + background: SURFACE.sunken, + border: `1px solid ${BORDER.subtle}`, borderRadius: 4, - color: "#F2F1ED", - fontFamily: "Inter, sans-serif", + color: INK.primary, + fontFamily: FONT.sans, fontSize: 13, outline: "none", transition: "border-color 180ms ease-out", }} - onFocus={(e) => (e.currentTarget.style.borderColor = "#F07800")} + onFocus={(e) => (e.currentTarget.style.borderColor = BRAND.primary)} onBlur={(e) => - (e.currentTarget.style.borderColor = "rgba(255,255,255,0.06)") + (e.currentTarget.style.borderColor = BORDER.subtle) } data-testid="input-email-marketing" /> @@ -215,11 +216,11 @@ export function EmailCapture({ disabled={status === "loading"} style={{ padding: "11px 18px", - background: "#F07800", - color: "#0B0B0A", - border: "1px solid #F07800", + background: BRAND.primary, + color: SURFACE.sunken, + border: `1px solid ${BRAND.primary}`, borderRadius: 4, - fontFamily: "JetBrains Mono, monospace", + fontFamily: FONT.mono, fontSize: 11, fontWeight: 600, letterSpacing: "0.14em", @@ -230,7 +231,7 @@ export function EmailCapture({ }} onMouseEnter={(e) => { e.currentTarget.style.transform = "translateY(-1px)"; - e.currentTarget.style.boxShadow = "0 0 24px -6px rgba(240,120,0,0.4)"; + e.currentTarget.style.boxShadow = `0 0 24px -6px ${BRAND.glow}`; }} onMouseLeave={(e) => { e.currentTarget.style.transform = ""; @@ -245,9 +246,9 @@ export function EmailCapture({ {status === "error" && (

@@ -257,9 +258,9 @@ export function EmailCapture({ {status === "exists" && (

@@ -274,10 +275,10 @@ export function EmailCapture({ if (status === "success") { return (

-
- +
+
- You're in. + You're in. First brief arrives next month.
@@ -287,13 +288,13 @@ export function EmailCapture({ if (variant === "inline") { return ( -
+
-
- +
+
-

The GridTilt Brief

+

The GridTilt Brief

Monthly thesis check, top movers, new facilities. No spam.

@@ -303,27 +304,27 @@ export function EmailCapture({ value={email} onChange={(e) => { setEmail(e.target.value); setStatus("idle"); }} placeholder="you@example.com" - className="flex-1 bg-[#0E0E0C] border border-white/[0.08] rounded-md px-3 py-2 text-sm text-white placeholder:text-white/25 focus:outline-none focus:border-[#F07800]/40 transition-colors" + className="flex-1 bg-surface-base border border-subtle rounded-md px-3 py-2 text-sm text-ink placeholder:text-white/25 focus:outline-none focus:border-brand/40 transition-colors" data-testid="input-email-inline" /> - {status === "error" &&

Something went wrong, try again

} - {status === "exists" &&

You're already on the list

} + {status === "error" &&

Something went wrong, try again

} + {status === "exists" &&

You're already on the list

}
); } return (
-
+
- - Get the GridTilt Brief + + Get the GridTilt Brief Monthly. No spam.
@@ -342,20 +343,20 @@ export function EmailCapture({ value={email} onChange={(e) => { setEmail(e.target.value); setStatus("idle"); }} placeholder="you@example.com" - className="flex-1 bg-[#0E0E0C] border border-white/[0.08] rounded-md px-3 py-2 text-sm text-white placeholder:text-white/25 focus:outline-none focus:border-[#F07800]/40 transition-colors" + className="flex-1 bg-surface-base border border-subtle rounded-md px-3 py-2 text-sm text-ink placeholder:text-white/25 focus:outline-none focus:border-brand/40 transition-colors" data-testid="input-email-banner" />
- {status === "error" &&

Something went wrong, try again

} - {status === "exists" &&

You're already on the list

} + {status === "error" &&

Something went wrong, try again

} + {status === "exists" &&

You're already on the list

}
); diff --git a/client/src/components/Freshness.tsx b/client/src/components/Freshness.tsx new file mode 100644 index 0000000..8ef1824 --- /dev/null +++ b/client/src/components/Freshness.tsx @@ -0,0 +1,151 @@ +/** + * Trust primitives (Lake 7A/7C): every widget can say how fresh its data is + * and what to do when a fetch breaks. "Stale" (source missed its refresh + * schedule) is visually distinct from "broken" (fetch failed) and from + * "market closed" (handled per-page where it applies). + */ +import { useEffect, useState } from "react"; +import { AlertTriangle, Clock, RotateCw } from "lucide-react"; + +function fmtClock(ts: number): string { + return new Date(ts).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" }); +} + +function fmtAge(ageMs: number): string { + const s = Math.floor(ageMs / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m ago`; +} + +/** + * "as of" chip for widget headers. Hover shows the exact timestamp. + * When the data is older than `staleAfterMs` (default: 2x the refresh + * interval), it flips to an amber stale badge - the source missed its + * schedule, which is different from being broken. + */ +export function AsOf({ + updatedAt, + intervalMs = 900_000, + staleAfterMs, + className = "", +}: { + updatedAt: number | undefined; + intervalMs?: number; + staleAfterMs?: number; + className?: string; +}) { + // re-render each 30s so the age and staleness stay live + const [, tick] = useState(0); + useEffect(() => { + const id = setInterval(() => tick((n) => n + 1), 30_000); + return () => clearInterval(id); + }, []); + + if (!updatedAt) return null; + const age = Date.now() - updatedAt; + const staleAt = staleAfterMs ?? intervalMs * 2; + const stale = age > staleAt; + + if (stale) { + return ( + + + stale · {fmtAge(age)} + + ); + } + return ( + + as of {fmtAge(age)} + + ); +} + +/** + * Designed in-place fetch-failure state with retry. Distinct from stale: + * broken means the request failed, not that the source is late. + */ +export function ErrorState({ + label, + onRetry, + className = "", +}: { + label: string; + onRetry?: () => void; + className?: string; +}) { + return ( +
+ +

{label}

+ {onRetry && ( + + )} +
+ ); +} + +/** + * Screen-reader data table for a chart (Lake 7E): renders the series as an + * sr-only table so chart content is not locked inside pixels. + */ +export function SrChartTable({ + caption, + columns, + rows, +}: { + caption: string; + columns: string[]; + rows: Array>; +}) { + return ( + // sr-only must sit on a wrapper div: width:1px is only a minimum for + // display:table elements, so a bare table stays content-sized and + // stretches the page's scrollable area on narrow screens. +
+ + + + + {columns.map((c) => ( + + ))} + + + + {rows.map((r, i) => ( + + {r.map((cell, j) => ( + + ))} + + ))} + +
{caption}
+ {c} +
{cell}
+
+ ); +} diff --git a/client/src/components/NewsTicker.tsx b/client/src/components/NewsTicker.tsx index ca54da9..20d6f5e 100644 --- a/client/src/components/NewsTicker.tsx +++ b/client/src/components/NewsTicker.tsx @@ -1,4 +1,6 @@ import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { Pause, Play } from "lucide-react"; interface NewsItem { headline: string; @@ -13,6 +15,11 @@ export function NewsTicker() { staleTime: 30 * 60 * 1000, queryFn: () => fetch("/api/news").then((r) => r.json()), }); + // Pause is real state so touch and keyboard users can stop the marquee, + // not just mouse hover (Lake 7D/E). + const [pinnedPause, setPinnedPause] = useState(false); + const [hoverPause, setHoverPause] = useState(false); + const paused = pinnedPause || hoverPause; if (!items || items.length === 0) return null; @@ -23,38 +30,50 @@ export function NewsTicker() { return (
-
-
+
+
- + News +
{ (e.currentTarget as HTMLElement).style.animationPlayState = "paused"; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.animationPlayState = "running"; }} + className="ticker-scroll whitespace-nowrap text-11 text-muted-foreground font-mono" + style={{ animationPlayState: paused ? "paused" : "running" }} + onMouseEnter={() => setHoverPause(true)} + onMouseLeave={() => setHoverPause(false)} + onFocusCapture={() => setHoverPause(true)} + onBlurCapture={() => setHoverPause(false)} > {[...segments, ...segments].map((seg, i) => ( - {i > 0 && } + {i > 0 && } { if (seg.url === "#") e.preventDefault(); }} + tabIndex={i < segments.length ? 0 : -1} > {seg.text} diff --git a/client/src/components/ToolTabs.tsx b/client/src/components/ToolTabs.tsx new file mode 100644 index 0000000..11da8bd --- /dev/null +++ b/client/src/components/ToolTabs.tsx @@ -0,0 +1,62 @@ +/** + * Consolidation primitive: URL-persisted tabs for folded tools. + * ?tab= round-trips so old routes can 301 into a specific tab and + * views stay shareable (same pattern as the Neocloud chart params). + */ +import { useEffect, useState } from "react"; + +export interface ToolTab { + id: string; + label: string; +} + +export function readTabParam(tabs: ToolTab[], fallback: string): string { + const sp = new URLSearchParams(window.location.search); + const t = sp.get("tab"); + return tabs.some((x) => x.id === t) ? (t as string) : fallback; +} + +export function useToolTabs(tabs: ToolTab[], fallback: string): [string, (id: string) => void] { + const [active, setActive] = useState(() => readTabParam(tabs, fallback)); + useEffect(() => { + const sp = new URLSearchParams(window.location.search); + if (active === fallback) sp.delete("tab"); + else sp.set("tab", active); + const qs = sp.toString(); + window.history.replaceState(null, "", `${window.location.pathname}${qs ? `?${qs}` : ""}`); + }, [active, fallback]); + return [active, setActive]; +} + +export function ToolTabs({ + tabs, + active, + onChange, + className = "", +}: { + tabs: ToolTab[]; + active: string; + onChange: (id: string) => void; + className?: string; +}) { + return ( +
+ {tabs.map((t) => ( + + ))} +
+ ); +} diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index 4168e89..4e00482 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -14,17 +14,13 @@ import { Layers, Map, Cpu, - Link2, - BarChart3, CalendarDays, FileText, Calculator, - Cable, LineChart, - Handshake, - Newspaper, } from "lucide-react"; import logoPath from "@assets/Image_[Vectorized]_(2)_1773890483514.png"; +import { BORDER, BRAND, FONT, INK } from "@/lib/tokens"; const navItems = [ { @@ -40,10 +36,10 @@ const navItems = [ description: "Sector breakdown", }, { - title: "Power Map", + title: "Power", url: "/power-map", icon: Map, - description: "US data center locations", + description: "Facilities map, deals, grid queue", }, { title: "Compute Frontier", @@ -52,46 +48,16 @@ const navItems = [ description: "AI superclusters by GPU and power", }, { - title: "Neocloud Intel", + title: "GPU Prices", url: "/neocloud-intel", icon: LineChart, - description: "GPU rental price index", + description: "Rental price index + cost of compute", }, { - title: "GPU Economics", - url: "/gpu-economics", + title: "Analyze", + url: "/analyze", icon: Calculator, - description: "Cost of compute + training calculator", - }, - { - title: "AI Power Deals", - url: "/power-deals", - icon: Handshake, - description: "Corporate power procurement for AI", - }, - { - title: "The Buildout Brief", - url: "/brief", - icon: Newspaper, - description: "One read across every module", - }, - { - title: "Supply Chain", - url: "/supply-chain", - icon: Link2, - description: "Power flow bottlenecks", - }, - { - title: "Portfolio Overlay", - url: "/portfolio", - icon: BarChart3, - description: "AI Power Exposure score", - }, - { - title: "Scenario Calculator", - url: "/trade", - icon: Calculator, - description: "Scenario modeling", + description: "Portfolio exposure + scenarios", }, { title: "Catalyst Tracker", @@ -99,12 +65,6 @@ const navItems = [ icon: CalendarDays, description: "Upcoming market events", }, - { - title: "Backlog", - url: "/queue", - icon: Cable, - description: "Interconnection projects in flight", - }, { title: "Analysis", url: "/blog", @@ -126,10 +86,10 @@ export function AppSidebar() { className="h-16 w-16 rounded-md object-contain logo-entrance" />
-
- GridTilt +
+ GridTilt
-
AI Power Economy
+
AI Power Economy
@@ -147,8 +107,8 @@ export function AppSidebar() { data-testid={`nav-${item.title.toLowerCase().replace(/\s+/g, "-")}`} className={`sidebar-nav-link flex items-center gap-3 px-3 py-2.5 no-underline ${isActive ? "sidebar-nav-active" : ""}`} style={{ - background: isActive ? "rgba(240, 120, 0, 0.08)" : "transparent", - borderLeft: isActive ? "3px solid #F07800" : "3px solid transparent", + background: isActive ? BRAND.wash : "transparent", + borderLeft: isActive ? `3px solid ${BRAND.primary}` : "3px solid transparent", }} >
{item.title} {item.description} @@ -195,16 +155,16 @@ export function AppSidebar() {
-
+
- Live + Live
- 15-min refresh + 15-min refresh
-

+

Yahoo Finance · EIA · Public Sources

-

+

Made by Jack Schwartz · aurph

diff --git a/client/src/components/neocloud/PriceHistoryChart.tsx b/client/src/components/neocloud/PriceHistoryChart.tsx new file mode 100644 index 0000000..1d5a4a7 --- /dev/null +++ b/client/src/components/neocloud/PriceHistoryChart.tsx @@ -0,0 +1,561 @@ +/** + * Neocloud GPU price history chart (Lake 2 rebuild). + * + * - True UTC time x-scale: pixels proportional to elapsed time. + * - Log y-scale with 1-2-5 ticks, labeled "log scale" in the corner. + * - Data honesty: sourced anchors = solid dots; recorded days = small dots; + * spans between points render dashed + reduced opacity when interpolated, + * solid when observed. Linear interpolation only - no splines. + * - Direct right-edge labels with collision avoidance; hovering a line or + * label isolates that series (others drop to 25%). + * - Unified crosshair tooltip: all visible series at the crosshair time, + * sorted descending, interp/est flagged. + * - Launch marker (ring) on each series' first point. + * - Current low-high marketplace range bands when <= 3 series visible. + * - Overlay and Grid (small multiples) modes; 3M/6M/1Y/ALL ranges. + */ +import { useCallback, useMemo, useRef, useState } from "react"; +import { scaleUtc, scaleLog } from "@visx/scale"; +import { LinePath } from "@visx/shape"; +import { Group } from "@visx/group"; +import { localPoint } from "@visx/event"; +import { SrChartTable } from "@/components/Freshness"; +import { BORDER, INK, SURFACE } from "@/lib/tokens"; +import { chartTheme, timeTicks } from "@/lib/chart-theme"; +import { + ChartSeries, + ClippedPoint, + RangeKey, + clipSeries, + fmtDate, + logDomain, + logTicks125, + rangeStart, + solveLabelCollisions, + valueAt, +} from "@/lib/gpu-series"; + +export interface PriceHistoryChartProps { + series: ChartSeries[]; // already filtered to visible models + range: RangeKey; + now: number; + view: "overlay" | "grid"; + width: number; + hovered: string | null; + onHover: (model: string | null) => void; + /** current marketplace range per model, for the <= 3 series bands */ + ranges: Record; + estimatedModels: Set; + recorderEmpty: boolean; +} + +const MARGIN = { top: 14, right: 118, bottom: 26, left: 44 }; +const GRID_MARGIN = { top: 22, right: 12, bottom: 20, left: 38 }; +const OVERLAY_HEIGHT = 380; +const PANEL_HEIGHT = 150; +const LABEL_GAP = 15; + +interface TooltipState { + t: number; + x: number; + yPx: number; + dayPrecision: boolean; +} + +export default function PriceHistoryChart(props: PriceHistoryChartProps) { + const { series, range, now, view, width } = props; + + const start = rangeStart(range, now); + const clipped = useMemo( + () => + series + .map((s) => ({ ...s, clipped: clipSeries(s.points, start, now) })) + .filter((s) => s.clipped.length > 0), + [series, start, now], + ); + + // Screen-reader mirror of exactly what the chart draws: the visible + // series' real points in the current window (synthetic edge points excluded). + const srRows = useMemo( + () => + clipped.flatMap((s) => + s.clipped + .filter((p) => !p.edge) + .map((p) => [ + s.model, + fmtDate(p.t, p.kind === "recorded"), + `$${p.price.toFixed(2)}`, + p.kind === "anchor" ? "sourced anchor" : "recorded", + ]), + ), + [clipped], + ); + + if (width <= 0) return null; + + if (clipped.length === 0) { + return ( +
+ No price points in this window. + + {series.length === 0 + ? "No GPUs selected - hit All to reset." + : "Sourced anchors are sparse; the daily recorder widens coverage from here forward. Try 1Y or ALL."} + +
+ ); + } + + return ( + <> + {view === "overlay" ? : } + + + ); +} + +type ClippedSeries = ChartSeries & { clipped: ClippedPoint[] }; + +const xTicks = timeTicks; + +function spanDash(quality: "observed" | "interpolated"): { dash?: string; opacity: number } { + return quality === "observed" ? { opacity: 1 } : { dash: "5 4", opacity: 0.55 }; +} + +function seriesOpacity(model: string, hovered: string | null): number { + if (!hovered) return 1; + return model === hovered ? 1 : 0.22; +} + +function Overlay({ + clipped, + start, + now, + width, + hovered, + onHover, + ranges, + estimatedModels, + recorderEmpty, +}: PriceHistoryChartProps & { clipped: ClippedSeries[]; start: number | null }) { + const height = OVERLAY_HEIGHT; + // Narrow screens: collapse the right label gutter and drop label prices so + // the plot keeps usable width (model-only labels still identify each line). + const compact = width < 640; + const margin = compact ? { ...MARGIN, right: 72 } : MARGIN; + const innerW = Math.max(40, width - margin.left - margin.right); + const innerH = height - margin.top - margin.bottom; + const svgRef = useRef(null); + const [tip, setTip] = useState(null); + + const x0 = start ?? Math.min(...clipped.map((s) => s.clipped[0].t)); + const x1 = now; + + const xScale = useMemo(() => scaleUtc({ domain: [x0, x1], range: [0, innerW] }), [x0, x1, innerW]); + const allPrices = useMemo(() => { + const vals = clipped.flatMap((s) => s.clipped.map((p) => p.price)); + if (clipped.length <= 3) { + for (const s of clipped) { + const r = ranges[s.model]; + if (r) vals.push(r.low, r.high); + } + } + return vals; + }, [clipped, ranges]); + const yDomain = useMemo(() => logDomain(allPrices), [allPrices]); + const yScale = useMemo(() => scaleLog({ domain: yDomain, range: [innerH, 0] }), [yDomain, innerH]); + const yTicks = logTicks125(yDomain[0], yDomain[1]); + const ticks = xTicks(x0, x1, innerW); + + // Right-edge direct labels with collision avoidance + const labels = useMemo(() => { + const raw = clipped + .filter((s) => s.latest) + .map((s) => ({ id: s.model, y: yScale(s.clipped[s.clipped.length - 1].price) })); + return solveLabelCollisions(raw, 4, innerH - 4, LABEL_GAP); + }, [clipped, yScale, innerH]); + const labelById = new Map(labels.map((l) => [l.id, l.labelY])); + + const dayPrecision = clipped.some((s) => s.clipped.some((p) => p.kind === "recorded" && !p.edge)); + + const handleMove = useCallback( + (e: React.PointerEvent) => { + if (!svgRef.current) return; + const pt = localPoint(svgRef.current, e); + if (!pt) return; + const gx = pt.x - margin.left; + if (gx < 0 || gx > innerW) { + setTip(null); + return; + } + const t = xScale.invert(gx).getTime(); + setTip({ t, x: gx, yPx: pt.y, dayPrecision }); + }, + [xScale, innerW, dayPrecision, margin.left], + ); + + // Unified tooltip rows at crosshair time, sorted desc by price + const tipRows = useMemo(() => { + if (!tip) return []; + return clipped + .map((s) => { + const v = valueAt(s.points, tip.t); + if (!v) return null; + return { + model: s.model, + color: s.color, + price: v.price, + interpolated: v.interpolated, + est: estimatedModels.has(s.model), + exactDate: v.exact ? fmtDate(v.exact.t, v.exact.kind === "recorded") : null, + }; + }) + .filter((r): r is NonNullable => r !== null) + .sort((a, b) => b.price - a.price); + }, [tip, clipped, estimatedModels]); + + const showBands = clipped.length <= 3; + + return ( +
+ { + setTip(null); + onHover(null); + }} + role="img" + aria-label={`GPU rental price history, log scale, ${clipped.length} series`} + > + + {/* y grid + ticks */} + {yTicks.map((v) => ( + + + + ${v >= 10 ? v.toFixed(0) : v} + + + ))} + {/* x ticks on month/quarter boundaries */} + {ticks.map((d) => ( + + + + {fmtDate(+d, false)} + + + ))} + + + {/* current marketplace range bands (<= 3 series) */} + {showBands && + clipped.map((s) => { + const r = ranges[s.model]; + if (!r || !(r.low > 0) || !(r.high > 0)) return null; + const yHi = yScale(r.high); + const yLo = yScale(r.low); + return ( + + ); + })} + + {/* series */} + {clipped.map((s) => { + const op = seriesOpacity(s.model, hovered); + const clippedSpans = spansFromClipped(s.clipped); + return ( + + {clippedSpans.map((span, i) => { + const st = spanDash(span.quality); + return ( + + key={i} + data={span.points} + x={(p) => xScale(p.t)} + y={(p) => yScale(p.price)} + stroke={s.color} + strokeWidth={2} + strokeOpacity={st.opacity} + strokeDasharray={st.dash} + strokeLinecap="round" + /> + ); + })} + {/* single lonely point still draws a dot */} + {s.clipped.filter((p) => !p.edge).map((p) => ( + + ))} + {/* launch marker: ring on the series' true first point when in window */} + {s.launch && s.clipped.some((p) => !p.edge && p.t === s.launch!.t) && ( + + {`${s.model} first tracked price (${fmtDate(s.launch.t, s.launch.kind === "recorded")})`} + + )} + {/* invisible fat hit path per series for hover */} + + data={s.clipped} + x={(p) => xScale(p.t)} + y={(p) => yScale(p.price)} + stroke="transparent" + strokeWidth={12} + onPointerEnter={() => onHover(s.model)} + style={{ cursor: "pointer" }} + /> + + ); + })} + + {/* crosshair */} + {tip && ( + + )} + + {/* right-edge direct labels */} + {clipped.map((s) => { + const ly = labelById.get(s.model); + if (ly === undefined) return null; + const last = s.clipped[s.clipped.length - 1]; + return ( + onHover(s.model)} + style={{ cursor: "pointer", transition: "opacity 120ms" }} + > + + + {s.model} + {!compact && ( + + {" "}${last.price >= 10 ? last.price.toFixed(2) : last.price.toFixed(2)} + + )} + + + ); + })} + + + + {/* corner scale label */} +
+ log scale +
+ {recorderEmpty && ( +
+ daily recorder starts {fmtDate(now, true)} - history before is sourced anchors +
+ )} + + {/* unified tooltip */} + {tip && tipRows.length > 0 && ( +
+
{fmtDate(tip.t, tip.dayPrecision)}
+ {tipRows.map((r) => ( +
+ + + {r.model} + + + ${r.price.toFixed(2)} + + {r.interpolated ? "interp." : r.exactDate ?? ""} + {r.est ? " est." : ""} + + +
+ ))} +
+ )} +
+ ); +} + +/** Re-derive quality spans on the clipped run (edge points inherit span quality). */ +function spansFromClipped(points: ClippedPoint[]): Array<{ quality: "observed" | "interpolated"; points: ClippedPoint[] }> { + if (points.length < 2) return []; + const spans: Array<{ quality: "observed" | "interpolated"; points: ClippedPoint[] }> = []; + let cur: (typeof spans)[number] | null = null; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]; + const b = points[i]; + const observed = + !a.edge && !b.edge && a.kind === "recorded" && b.kind === "recorded" && b.t - a.t <= 2 * 86_400_000; + const quality = observed ? ("observed" as const) : ("interpolated" as const); + if (cur && cur.quality === quality) cur.points.push(b); + else { + cur = { quality, points: [a, b] }; + spans.push(cur); + } + } + return spans; +} + +function SmallMultiples({ + clipped, + start, + now, + width, + hovered, + onHover, + estimatedModels, +}: PriceHistoryChartProps & { clipped: ClippedSeries[]; start: number | null }) { + const cols = width >= 1100 ? 5 : width >= 760 ? 4 : width >= 560 ? 3 : 2; + const panelW = Math.floor(width / cols); + const x0 = start ?? Math.min(...clipped.map((s) => s.clipped[0].t)); + const x1 = now; + + return ( +
+ {clipped.map((s) => ( + + ))} +
+ ); +} + +function Panel({ + series: s, + x0, + x1, + width, + height, + dim, + onHover, + est, +}: { + series: ClippedSeries; + x0: number; + x1: number; + width: number; + height: number; + dim: boolean; + onHover: (m: string | null) => void; + est: boolean; +}) { + const innerW = Math.max(20, width - GRID_MARGIN.left - GRID_MARGIN.right); + const innerH = height - GRID_MARGIN.top - GRID_MARGIN.bottom; + const xScale = scaleUtc({ domain: [x0, x1], range: [0, innerW] }); + const yDomain = logDomain(s.clipped.map((p) => p.price)); + const yScale = scaleLog({ domain: yDomain, range: [innerH, 0] }); + const yTicks = logTicks125(yDomain[0], yDomain[1]).filter((_, i, a) => a.length <= 3 || i % 2 === 0); + const ticks = xTicks(x0, x1, innerW).filter((_, i, a) => a.length <= 3 || i % Math.ceil(a.length / 3) === 0); + const spans = spansFromClipped(s.clipped); + const last = s.clipped[s.clipped.length - 1]; + + return ( +
onHover(s.model)} + onPointerLeave={() => onHover(null)} + data-testid={`ni-panel-${s.model}`} + > +
+ {s.model} + ${last.price.toFixed(2)} + {est && est.} +
+ + + {yTicks.map((v) => ( + + + + ${v >= 10 ? v.toFixed(0) : v} + + + ))} + {ticks.map((d) => ( + + {fmtDate(+d, false)} + + ))} + + {spans.map((span, i) => { + const st = spanDash(span.quality); + return ( + + key={i} + data={span.points} + x={(p) => xScale(p.t)} + y={(p) => yScale(p.price)} + stroke={s.color} + strokeWidth={1.6} + strokeOpacity={st.opacity} + strokeDasharray={st.dash} + strokeLinecap="round" + /> + ); + })} + {s.clipped.filter((p) => !p.edge).map((p) => ( + + ))} + + +
+ ); +} diff --git a/client/src/data/catalyst-config.ts b/client/src/data/catalyst-config.ts index 15ca683..1d799b1 100644 --- a/client/src/data/catalyst-config.ts +++ b/client/src/data/catalyst-config.ts @@ -1,11 +1,16 @@ +import { SERIES } from '@/lib/tokens'; + export type CatalystCategory = 'Regulatory' | 'Policy' | 'Infrastructure' | 'Market' | 'Industry'; +// Categories not in tokens.ts CATEGORY_COLORS: assigned SERIES slots in order +// of appearance so the categories are actually distinguishable (the old map +// put them all on near-identical oranges). export const catalystCategoryColors: Record = { - Regulatory: '#F0A500', - Policy: '#D4A843', - Infrastructure: '#C87533', - Market: '#F07800', - Industry: '#F07800', + Regulatory: SERIES[0], // series slot 1 (blue) + Policy: SERIES[1], // series slot 2 (amber) + Infrastructure: SERIES[2], // series slot 3 (teal) + Market: SERIES[3], // series slot 4 (violet) + Industry: SERIES[4], // series slot 5 (magenta) }; export interface ManualCatalyst { @@ -126,10 +131,13 @@ export const SUPPLY_CHAIN_STAGE_MAP: Record = { IREN: 'End Use', CLSK: 'End Use', MARA: 'End Use', }; +// Supply-chain stages are not in tokens.ts CATEGORY_COLORS: SERIES slots +// continue in order of appearance (slots 6-10) so stage dots and catalyst +// category dots stay distinguishable when they co-occur on the calendar. export const STAGE_COLORS: Record = { - 'Raw Materials': '#C87533', - 'Generation': '#F07800', - 'Transmission': '#D4A843', - 'Distribution': '#B8860B', - 'End Use': '#F0A500', + 'Raw Materials': SERIES[5], // series slot 6 (cyan) + 'Generation': SERIES[6], // series slot 7 (rust) + 'Transmission': SERIES[7], // series slot 8 (green) + 'Distribution': SERIES[8], // series slot 9 (pink) + 'End Use': SERIES[9], // series slot 10 (brown) }; diff --git a/client/src/data/supply-chain-config.ts b/client/src/data/supply-chain-config.ts index c081a99..34c612b 100644 --- a/client/src/data/supply-chain-config.ts +++ b/client/src/data/supply-chain-config.ts @@ -1,3 +1,5 @@ +import { BRAND, SERIES } from "@/lib/tokens"; + export interface SupplyNode { id: string; name: string; @@ -15,12 +17,14 @@ export interface SupplyLink { label?: string; } +// Supply-chain stages are not in CATEGORY_COLORS; per the migration map, +// brand hexes go to BRAND and the rest take SERIES slots in order of appearance. export const STAGE_COLORS: Record = { - 'raw-materials': '#C87533', - 'generation': '#F07800', - 'transmission': '#D4A843', - 'distribution': '#B8860B', - 'end-use': '#F0A500', + 'raw-materials': SERIES[0], // series slot 1 + 'generation': BRAND.primary, + 'transmission': SERIES[1], // series slot 2 + 'distribution': SERIES[2], // series slot 3 + 'end-use': BRAND.secondary, }; export const STAGE_LABELS: { id: string; name: string; index: number }[] = [ diff --git a/client/src/index.css b/client/src/index.css index c8ff07a..203f8ad 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -95,6 +95,76 @@ --destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha); } +/* + * GridTilt data tokens (Lake 1) + * Single source of truth for every color, size, and chart value in the + * dashboard. TS mirror for chart code: client/src/lib/tokens.ts - keep in sync. + * The Swiss homepage (/) has its own isolated contract in styles/anchor.css. + */ +:root { + /* Surface scale - 4 elevations, dark only */ + --surface-sunken: #0A0A08; + --surface-base: #0E0E0C; + --surface-raised: #1A1917; + --surface-overlay: #26241F; + --border-subtle: rgba(255, 255, 255, 0.06); + --border-strong: rgba(255, 255, 255, 0.14); + + /* Brand */ + --brand: #F07800; + --brand-2: #F0A500; + --brand-glow: rgba(240, 120, 0, 0.12); + --brand-wash: rgba(240, 120, 0, 0.05); + + /* Ink scale (text on dark surfaces) */ + --ink: #F2F1ED; + --ink-secondary: #B0AEA6; + --ink-muted: #8E8B84; + --ink-faint: #716E67; + + /* Semantic - state, never series identity */ + --positive: #4ade80; + --positive-deep: #22c55e; + --negative: #f87171; + --negative-deep: #ef4444; + --warning: #eab308; + --critical: #dc2626; + --info: #4dabf7; + + /* Data quality - sourced vs estimated vs synthetic fill */ + --estimate: #d4a843; + --dq-estimated-opacity: 0.55; + --dq-synthetic-opacity: 0.35; + + /* Categorical series - fixed order, CVD-validated (min adjacent dE 13.9 + on both surfaces). Assign in order, never cycle; >4 series need direct + labels. Do not reorder: the order IS the colorblind-safety mechanism. */ + --series-1: #3987e5; /* blue */ + --series-2: #c98500; /* amber */ + --series-3: #199e70; /* teal */ + --series-4: #9085e9; /* violet */ + --series-5: #d55181; /* magenta */ + --series-6: #1f9fb5; /* cyan */ + --series-7: #d95926; /* rust */ + --series-8: #3d9e3d; /* green */ + --series-9: #bd6bce; /* pink */ + --series-10: #b07d3f; /* brown */ + + /* Chart chrome */ + --chart-axis: #55534E; + --chart-tick: #8E8B84; + --chart-grid: rgba(255, 255, 255, 0.05); + --chart-crosshair: rgba(255, 255, 255, 0.25); + --chart-ref-line: rgba(255, 255, 255, 0.18); + + /* Focus + motion */ + --focus-ring-color: #F0A500; + --focus-ring-width: 2px; + --duration-fast: 120ms; + --duration-base: 200ms; + --duration-slow: 350ms; +} + /* Force dark mode always */ html { color-scheme: dark; @@ -108,6 +178,41 @@ html { body { @apply font-sans antialiased bg-background text-foreground; } + + /* Tabular figures on all data type so numbers never jitter on refresh */ + .font-mono, + code, + td, + th { + font-variant-numeric: tabular-nums; + } + + /* Visible keyboard focus on every interactive element (Lake 7E). + :focus-visible only - mouse clicks do not ring. */ + :focus-visible { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: 2px; + border-radius: 2px; + } +} + +/* Reduced motion (Lake 7E): kill decorative animation and long transitions + for users who asked for less motion. Data still updates instantly. */ +@media (prefers-reduced-motion: reduce) { + .ticker-scroll, + .count-up, + .live-pulse::before, + .logo-entrance, + .carousel-card-enter, + .animate-ping, + .animate-pulse { + animation: none !important; + } + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + } } /* Custom scrollbar */ @@ -285,6 +390,14 @@ html { height: 100%; } +/* Embedded inside The Stack's flow view: host owns scroll and page padding */ +.sc-page-embedded { + min-height: 0; + height: auto; + padding: 0; + overflow-y: visible; +} + .sc-topbar { display: flex; align-items: center; @@ -292,8 +405,8 @@ html { flex-wrap: wrap; gap: 8px; padding: 8px 12px; - background: #111110; - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--surface-base); + border: 1px solid var(--border-subtle); border-radius: 3px; margin-bottom: 16px; } @@ -306,15 +419,15 @@ html { } .sc-topbar-sep { - color: #333; + color: var(--ink-faint); font-size: 11px; } .sc-graph-container { position: relative; width: 100%; - background: #0E0E0C; - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--surface-base); + border: 1px solid var(--border-subtle); border-radius: 3px; overflow: hidden; } @@ -345,7 +458,7 @@ html { font-size: 10px; font-weight: 600; letter-spacing: 2px; - fill: #F07800; + fill: var(--brand); } .sc-node-label { @@ -362,7 +475,7 @@ html { .sc-link-label { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 8px; - fill: #888; + fill: var(--ink-muted); } .sc-stage-pill-text { @@ -379,12 +492,12 @@ html { gap: 14px; padding: 8px 12px; margin-top: 12px; - background: #0E0E0C; - border: 1px solid rgba(255, 255, 255, 0.05); + background: var(--surface-base); + border: 1px solid var(--border-subtle); border-radius: 3px; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 10px; - color: #777; + color: var(--ink-faint); } .sc-legend-swatch { display: inline-block; @@ -396,13 +509,13 @@ html { } .sc-legend-hint { margin-left: auto; - color: #555; + color: var(--ink-faint); letter-spacing: 0.5px; } .sc-view-toggle { display: inline-flex; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-subtle); border-radius: 3px; overflow: hidden; } @@ -412,25 +525,40 @@ html { letter-spacing: 1px; padding: 3px 10px; background: transparent; - color: #777; + color: var(--ink-faint); border: none; cursor: pointer; transition: color 0.15s, background 0.15s; } .sc-view-btn:hover { - color: #fff; + color: var(--ink); } .sc-view-btn-active { - background: rgba(240, 120, 0, 0.15); - color: #F07800; + background: var(--brand-glow); + color: var(--brand); } .sc-node-hidden { opacity: 0; } -.sc-node-enter { - animation: scNodeFadeIn 0.4s ease-out forwards; +.sc-link-hidden { + opacity: 0; +} + +/* Entrance animations only run when the user has not requested reduced + motion; otherwise the -enter classes are inert and elements appear + immediately at full opacity. */ +@media (prefers-reduced-motion: no-preference) { + .sc-node-enter { + animation: scNodeFadeIn 0.4s ease-out forwards; + } + + .sc-link-enter { + stroke-dasharray: 1200; + stroke-dashoffset: 1200; + animation: scDrawLine 0.8s ease-out forwards; + } } @keyframes scNodeFadeIn { @@ -438,26 +566,26 @@ html { to { opacity: 1; } } -.sc-link-hidden { - opacity: 0; +@keyframes scDrawLine { + to { stroke-dashoffset: 0; } } -.sc-link-enter { - stroke-dasharray: 1200; - stroke-dashoffset: 1200; - animation: scDrawLine 0.8s ease-out forwards; +/* Keyboard focus for SVG node groups. Outline on the is SVG-safe in + modern browsers; :focus-visible keeps it keyboard-only. */ +.sc-node-focusable:focus { + outline: none; } - -@keyframes scDrawLine { - to { stroke-dashoffset: 0; } +.sc-node-focusable:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 3px; } .sc-detail-panel { width: 100%; max-width: 900px; margin: 16px auto 0; - background: #141412; - border: 1px solid rgba(240, 120, 0, 0.12); + background: var(--surface-base); + border: 1px solid var(--brand-glow); border-radius: 4px; padding: 24px; animation: scSlideDown 0.3s ease-out; @@ -470,7 +598,7 @@ html { .sc-divider { height: 1px; - background: rgba(255, 255, 255, 0.06); + background: var(--border-subtle); } .sc-section-label { @@ -478,7 +606,7 @@ html { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; - color: #444; + color: var(--ink-faint); margin-bottom: 8px; } @@ -486,9 +614,9 @@ html { display: inline-block; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 11px; - color: #ccc; - background: #1A1917; - border: 1px solid rgba(255, 255, 255, 0.05); + color: var(--ink-secondary); + background: var(--surface-raised); + border: 1px solid var(--border-subtle); border-radius: 3px; padding: 3px 8px; } @@ -497,12 +625,12 @@ html { transition: border-color 0.2s, color 0.2s; } .sc-flow-tag-clickable:hover { - border-color: rgba(240, 120, 0, 0.4); - color: #F07800; + border-color: color-mix(in srgb, var(--brand) 40%, transparent); + color: var(--brand); } .sc-stock-table { - border: 1px solid rgba(255, 255, 255, 0.06); + border: 1px solid var(--border-subtle); border-radius: 2px; overflow: hidden; } @@ -512,12 +640,12 @@ html { grid-template-columns: 70px 1fr 90px 80px; gap: 0; padding: 6px 10px; - background: #0E0E0C; + background: var(--surface-base); font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; font-size: 9px; font-weight: 600; letter-spacing: 0.08em; - color: #444; + color: var(--ink-faint); } .sc-stock-row { @@ -527,12 +655,12 @@ html { padding: 7px 10px; font-size: 11px; cursor: pointer; - border-top: 1px solid rgba(255, 255, 255, 0.03); + border-top: 1px solid var(--border-subtle); transition: background 0.12s; } .sc-stock-row:hover { - background: rgba(240, 120, 0, 0.04); + background: var(--brand-wash); } .sc-stock-col-ticker { text-align: left; } diff --git a/client/src/lib/__tests__/gpu-series.test.ts b/client/src/lib/__tests__/gpu-series.test.ts new file mode 100644 index 0000000..c82700e --- /dev/null +++ b/client/src/lib/__tests__/gpu-series.test.ts @@ -0,0 +1,336 @@ +/** + * Lake 8: exhaustive unit coverage for the Neocloud chart transform module. + * These functions decide what renders as sourced vs synthetic on an + * investment chart - a silent bug here misleads real money decisions. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + MAX_OBSERVED_GAP_DAYS, + buildPoints, + buildSeries, + buildSpans, + clipSeries, + fmtDate, + logDomain, + logTicks125, + nearestPoint, + parsePointDate, + rangeStart, + solveLabelCollisions, + sparklineDomain, + valueAt, + type ChartPoint, +} from "../gpu-series"; + +const DAY = 86_400_000; +const p = (t: number, price: number, kind: "anchor" | "recorded" = "recorded"): ChartPoint => ({ + t, + price, + kind, + date: new Date(t).toISOString().slice(0, kind === "anchor" ? 7 : 10), +}); + +describe("parsePointDate", () => { + it("parses YYYY-MM as a month anchor at UTC month start", () => { + const r = parsePointDate("2025-07"); + assert.ok(r); + assert.equal(r.kind, "anchor"); + assert.equal(r.t, Date.UTC(2025, 6, 1)); + }); + it("parses YYYY-MM-DD as a recorded day", () => { + const r = parsePointDate("2026-07-02"); + assert.ok(r); + assert.equal(r.kind, "recorded"); + assert.equal(r.t, Date.UTC(2026, 6, 2)); + }); + it("rejects garbage, empty, and partial dates", () => { + for (const bad of ["", "2025", "07-2025", "2025/07/02", "2025-7", "2025-07-2", "yesterday"]) { + assert.equal(parsePointDate(bad), null, bad); + } + }); +}); + +describe("buildPoints", () => { + it("orders points by time regardless of input order", () => { + const pts = buildPoints([ + { date: "2026-01-05", price: 3 }, + { date: "2025-07", price: 5 }, + { date: "2026-01-04", price: 2 }, + ]); + assert.deepEqual(pts.map((x) => x.price), [5, 2, 3]); + assert.ok(pts[0].t < pts[1].t && pts[1].t < pts[2].t); + }); + it("flags anchors vs recorded points correctly", () => { + const pts = buildPoints([ + { date: "2025-07", price: 12 }, + { date: "2026-07-02", price: 13 }, + ]); + assert.deepEqual(pts.map((x) => x.kind), ["anchor", "recorded"]); + }); + it("dedupes identical timestamps, last write wins", () => { + const pts = buildPoints([ + { date: "2026-07-02", price: 1 }, + { date: "2026-07-02", price: 9 }, + ]); + assert.equal(pts.length, 1); + assert.equal(pts[0].price, 9); + }); + it("drops unparseable dates and non-positive/non-finite prices (log-scale safety)", () => { + const pts = buildPoints([ + { date: "garbage", price: 5 }, + { date: "2026-07-02", price: 0 }, + { date: "2026-07-03", price: -2 }, + { date: "2026-07-04", price: NaN }, + { date: "2026-07-05", price: Infinity }, + { date: "2026-07-06", price: 4 }, + ]); + assert.equal(pts.length, 1); + assert.equal(pts[0].price, 4); + }); + it("handles empty and null-ish input", () => { + assert.deepEqual(buildPoints([]), []); + assert.deepEqual(buildPoints(undefined as never), []); + }); +}); + +describe("buildSpans", () => { + it("returns no spans for 0 or 1 point", () => { + assert.deepEqual(buildSpans([]), []); + assert.deepEqual(buildSpans([p(0, 1)]), []); + }); + it("marks consecutive recorded days within the gap as observed", () => { + const spans = buildSpans([p(0, 1), p(DAY, 2), p(2 * DAY, 3)]); + assert.equal(spans.length, 1); + assert.equal(spans[0].quality, "observed"); + assert.equal(spans[0].points.length, 3); + }); + it("marks anchor-involved segments as interpolated even when close", () => { + const spans = buildSpans([p(0, 1, "anchor"), p(DAY, 2)]); + assert.equal(spans.length, 1); + assert.equal(spans[0].quality, "interpolated"); + }); + it("marks recorded points beyond the max gap as interpolated", () => { + const spans = buildSpans([p(0, 1), p((MAX_OBSERVED_GAP_DAYS + 1) * DAY, 2)]); + assert.equal(spans[0].quality, "interpolated"); + }); + it("boundary: exactly the max gap still counts as observed", () => { + const spans = buildSpans([p(0, 1), p(MAX_OBSERVED_GAP_DAYS * DAY, 2)]); + assert.equal(spans[0].quality, "observed"); + }); + it("splits alternating qualities into spans sharing endpoints", () => { + const a = p(0, 1, "anchor"); + const b = p(30 * DAY, 2); + const c = p(31 * DAY, 3); + const d = p(90 * DAY, 4, "anchor"); + const spans = buildSpans([a, b, c, d]); + assert.deepEqual(spans.map((s) => s.quality), ["interpolated", "observed", "interpolated"]); + // adjacent spans share an endpoint + assert.equal(spans[0].points[spans[0].points.length - 1], spans[1].points[0]); + assert.equal(spans[1].points[spans[1].points.length - 1], spans[2].points[0]); + }); +}); + +describe("buildSeries", () => { + it("assembles points, spans, launch, latest, and color per model", () => { + const out = buildSeries( + [{ model: "H100", vendor: "NVIDIA", series: [{ date: "2025-07", price: 3 }, { date: "2026-07-01", price: 2.79 }] }], + () => "#123456", + ); + assert.equal(out.length, 1); + assert.equal(out[0].color, "#123456"); + assert.equal(out[0].launch?.price, 3); + assert.equal(out[0].latest?.price, 2.79); + assert.equal(out[0].spans.length, 1); + }); + it("empty series produce null launch/latest and no spans", () => { + const out = buildSeries([{ model: "X", vendor: "V", series: [] }], () => "#000"); + assert.equal(out[0].launch, null); + assert.equal(out[0].latest, null); + assert.deepEqual(out[0].spans, []); + }); +}); + +describe("rangeStart", () => { + const now = Date.UTC(2026, 6, 2); + it("computes 3M/6M/1Y starts and null for ALL", () => { + assert.equal(rangeStart("3M", now), Date.UTC(2026, 3, 2)); + assert.equal(rangeStart("6M", now), Date.UTC(2026, 0, 2)); + assert.equal(rangeStart("1Y", now), Date.UTC(2025, 6, 2)); + assert.equal(rangeStart("ALL", now), null); + }); +}); + +describe("clipSeries", () => { + const pts = [p(0, 10), p(10 * DAY, 20), p(20 * DAY, 30)]; + it("passes everything through for ALL (null start)", () => { + assert.deepEqual(clipSeries(pts, null, 20 * DAY), pts); + }); + it("interpolates a synthetic edge point at the window start", () => { + const out = clipSeries(pts, 5 * DAY, 20 * DAY); + assert.equal(out.length, 3); + assert.equal(out[0].edge, true); + assert.equal(out[0].t, 5 * DAY); + assert.equal(out[0].price, 15); // halfway between 10 and 20 + }); + it("no edge point when the window starts before the first point", () => { + const out = clipSeries(pts, -5 * DAY, 20 * DAY); + assert.equal(out[0].edge, undefined); + assert.equal(out.length, 3); + }); + it("window past the data yields empty", () => { + assert.deepEqual(clipSeries(pts, 25 * DAY, 30 * DAY), []); + }); + it("empty input yields empty output", () => { + assert.deepEqual(clipSeries([], 0, DAY), []); + }); +}); + +describe("logTicks125", () => { + it("emits the 1-2-5 progression across decades", () => { + assert.deepEqual(logTicks125(1, 20), [1, 2, 5, 10, 20]); + }); + it("respects fractional decades", () => { + assert.deepEqual(logTicks125(0.5, 5), [0.5, 1, 2, 5]); + }); + it("clips to the domain", () => { + assert.deepEqual(logTicks125(3, 40), [5, 10, 20]); + }); + it("degenerate domains return empty", () => { + assert.deepEqual(logTicks125(0, 10), []); + assert.deepEqual(logTicks125(-1, 10), []); + assert.deepEqual(logTicks125(10, 1), []); + }); +}); + +describe("logDomain", () => { + it("pads in log space", () => { + const [lo, hi] = logDomain([1, 10]); + assert.ok(lo < 1 && lo > 0.5); + assert.ok(hi > 10 && hi < 20); + }); + it("single value gets symmetric padding", () => { + const [lo, hi] = logDomain([5]); + assert.ok(lo < 5 && hi > 5); + }); + it("ignores non-positive values and falls back on empty", () => { + assert.deepEqual(logDomain([]), [1, 10]); + assert.deepEqual(logDomain([-3, 0]), [1, 10]); + }); +}); + +describe("solveLabelCollisions", () => { + it("keeps non-colliding labels in place", () => { + const out = solveLabelCollisions([{ id: "a", y: 10 }, { id: "b", y: 60 }], 0, 100, 15); + assert.deepEqual(out.map((l) => l.labelY), [10, 60]); + }); + it("pushes overlapping labels apart to the gap", () => { + const out = solveLabelCollisions([{ id: "a", y: 50 }, { id: "b", y: 52 }], 0, 200, 15); + assert.equal(out[1].labelY - out[0].labelY, 15); + }); + it("keeps order by target y", () => { + const out = solveLabelCollisions([{ id: "hi", y: 90 }, { id: "lo", y: 10 }], 0, 100, 15); + assert.equal(out[0].id, "lo"); + assert.equal(out[1].id, "hi"); + }); + it("shifts the stack up when it runs past the bottom", () => { + const out = solveLabelCollisions( + [{ id: "a", y: 95 }, { id: "b", y: 97 }, { id: "c", y: 99 }], + 0, + 100, + 15, + ); + assert.equal(out[2].labelY, 100); + assert.equal(out[1].labelY, 85); + assert.equal(out[0].labelY, 70); + }); + it("clamps at top when the stack cannot fit", () => { + const out = solveLabelCollisions( + Array.from({ length: 10 }, (_, i) => ({ id: String(i), y: 50 })), + 0, + 50, + 15, + ); + for (const l of out) { + assert.ok(l.labelY >= 0 && l.labelY <= 50); + } + }); + it("empty input returns empty", () => { + assert.deepEqual(solveLabelCollisions([], 0, 100, 15), []); + }); +}); + +describe("valueAt", () => { + const pts = [p(0, 10), p(10 * DAY, 20)]; + it("returns the exact point when t lands on one", () => { + const v = valueAt(pts, 0); + assert.ok(v); + assert.equal(v.price, 10); + assert.equal(v.interpolated, false); + assert.equal(v.exact, pts[0]); + }); + it("linearly interpolates inside a span and flags it", () => { + const v = valueAt(pts, 5 * DAY); + assert.ok(v); + assert.equal(v.price, 15); + assert.equal(v.interpolated, true); + assert.equal(v.exact, null); + }); + it("returns null outside the series range and for empty series", () => { + assert.equal(valueAt(pts, -DAY), null); + assert.equal(valueAt(pts, 11 * DAY), null); + assert.equal(valueAt([], 0), null); + }); +}); + +describe("nearestPoint", () => { + const pts = [p(0, 1), p(10 * DAY, 2)]; + it("snaps to the closest point", () => { + assert.equal(nearestPoint(pts, 4 * DAY), pts[0]); + assert.equal(nearestPoint(pts, 6 * DAY), pts[1]); + }); + it("empty input returns null", () => { + assert.equal(nearestPoint([], 0), null); + }); +}); + +describe("sparklineDomain", () => { + it("pads the window's own min/max, never zero-based", () => { + const g = sparklineDomain([100, 110]); + assert.ok(g); + assert.equal(g.flat, false); + assert.ok(g.domain[0] > 90 && g.domain[0] < 100); + assert.ok(g.domain[1] > 110 && g.domain[1] < 120); + }); + it("flat window is flagged and padded around the value", () => { + const g = sparklineDomain([50, 50, 50]); + assert.ok(g); + assert.equal(g.flat, true); + assert.ok(g.domain[0] < 50 && g.domain[1] > 50); + }); + it("single point yields a flat, padded domain", () => { + const g = sparklineDomain([42]); + assert.ok(g); + assert.equal(g.flat, true); + }); + it("flat at zero still produces a non-degenerate domain", () => { + const g = sparklineDomain([0, 0]); + assert.ok(g); + assert.ok(g.domain[1] > g.domain[0]); + }); + it("missing data returns null; non-finite values are ignored", () => { + assert.equal(sparklineDomain([]), null); + assert.equal(sparklineDomain([NaN, Infinity]), null); + const g = sparklineDomain([NaN, 5, 6]); + assert.ok(g); + assert.equal(g.flat, false); + }); +}); + +describe("fmtDate", () => { + it("formats month and day precision in UTC", () => { + const t = Date.UTC(2026, 6, 2); + assert.equal(fmtDate(t, false), "Jul '26"); + assert.equal(fmtDate(t, true), "Jul 2 '26"); + }); +}); diff --git a/client/src/lib/__tests__/stack-transforms.test.ts b/client/src/lib/__tests__/stack-transforms.test.ts new file mode 100644 index 0000000..b7f80f4 --- /dev/null +++ b/client/src/lib/__tests__/stack-transforms.test.ts @@ -0,0 +1,238 @@ +/** + * Lake 8: exhaustive unit coverage for The Stack transform module - + * market-cap parsing/sorting, sparkline window math, treemap layout, + * diverging heat colors. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + HEAT_SATURATION_PCT, + blendHex, + buildHeatmapInput, + heatColor, + heatTextColor, + layoutHeatmap, + marketCapOf, + parseMarketCapDisplay, + pctFromSparkline, + sortTableRows, + windowDirection, +} from "../stack-transforms"; +import { INK, SURFACE } from "../tokens"; + +describe("parseMarketCapDisplay", () => { + it("parses T/B/M suffixes into $B units", () => { + assert.equal(parseMarketCapDisplay("$3.2T"), 3200); + assert.equal(parseMarketCapDisplay("850B"), 850); + assert.equal(parseMarketCapDisplay("$500M"), 0.5); + }); + it("the old parseM bug: $50M must NOT equal $50B", () => { + assert.notEqual(parseMarketCapDisplay("$50M"), parseMarketCapDisplay("$50B")); + assert.equal(parseMarketCapDisplay("$50M"), 0.05); + assert.equal(parseMarketCapDisplay("$50B"), 50); + }); + it("tolerates spacing and lowercase units", () => { + assert.equal(parseMarketCapDisplay(" $1.5 t "), 1500); + assert.equal(parseMarketCapDisplay("2b"), 2); + }); + it("returns null for garbage, empty, and missing", () => { + for (const bad of [undefined, null, "", "N/A", "big", "$-3B"]) { + assert.equal(parseMarketCapDisplay(bad as never), null, String(bad)); + } + }); +}); + +describe("marketCapOf", () => { + it("prefers the live numeric field (in dollars) over the display string", () => { + assert.equal(marketCapOf({ marketCap: 4.5e12, marketCapDisplay: "$3.8T" }), 4500); + }); + it("falls back to the display string", () => { + assert.equal(marketCapOf({ marketCapDisplay: "$100B" }), 100); + }); + it("ignores non-positive numeric caps", () => { + assert.equal(marketCapOf({ marketCap: 0, marketCapDisplay: "$1B" }), 1); + assert.equal(marketCapOf({ marketCap: -5 }), null); + }); +}); + +describe("pctFromSparkline", () => { + it("computes first->last % change", () => { + assert.equal(pctFromSparkline([100, 110]), 10); + assert.equal(pctFromSparkline([100, 90]), -10); + }); + it("skips leading zeros/non-finite when picking the base", () => { + assert.equal(pctFromSparkline([0, 100, 110]), 10); + assert.equal(pctFromSparkline([NaN, 100, 110]), 10); + }); + it("null for short or missing windows", () => { + assert.equal(pctFromSparkline([]), null); + assert.equal(pctFromSparkline([5]), null); + assert.equal(pctFromSparkline(undefined), null); + assert.equal(pctFromSparkline(null), null); + }); +}); + +describe("windowDirection", () => { + it("up/down by the window's own net move", () => { + assert.equal(windowDirection([100, 101]), "up"); + assert.equal(windowDirection([101, 100]), "down"); + }); + it("negligible moves are flat", () => { + assert.equal(windowDirection([100, 100.001]), "flat"); + }); + it("null when the window cannot be computed", () => { + assert.equal(windowDirection([]), null); + }); +}); + +describe("sortTableRows", () => { + const rows = [ + { ticker: "A", price: 10, d1: 1, d5: null, m1: 2, mktcap: 100, pe: null, revGrowth: 5 }, + { ticker: "B", price: 20, d1: -1, d5: 3, m1: null, mktcap: null, pe: 30, revGrowth: null }, + { ticker: "C", price: null, d1: 0, d5: 1, m1: 1, mktcap: 50, pe: 10, revGrowth: 0 }, + ]; + it("sorts numerically desc with nulls always last", () => { + const out = sortTableRows(rows, "mktcap", "desc"); + assert.deepEqual(out.map((r) => r.ticker), ["A", "C", "B"]); + }); + it("nulls stay last even ascending", () => { + const out = sortTableRows(rows, "mktcap", "asc"); + assert.deepEqual(out.map((r) => r.ticker), ["C", "A", "B"]); + }); + it("ticker sort is lexicographic and respects direction", () => { + assert.deepEqual(sortTableRows(rows, "ticker", "asc").map((r) => r.ticker), ["A", "B", "C"]); + assert.deepEqual(sortTableRows(rows, "ticker", "desc").map((r) => r.ticker), ["C", "B", "A"]); + }); + it("all-null column falls back to ticker order", () => { + const allNull = rows.map((r) => ({ ...r, pe: null })); + assert.deepEqual(sortTableRows(allNull, "pe", "desc").map((r) => r.ticker), ["A", "B", "C"]); + }); + it("does not mutate the input", () => { + const before = rows.map((r) => r.ticker); + sortTableRows(rows, "price", "desc"); + assert.deepEqual(rows.map((r) => r.ticker), before); + }); +}); + +const LAYERS = [ + { key: "compute", title: "Compute", color: "#123" }, + { key: "nuclear", title: "Nuclear", color: "#456" }, + { key: "etfsBenchmarks", title: "ETFs", color: "#789" }, +]; + +describe("buildHeatmapInput", () => { + it("groups by layer and totals sizes", () => { + const input = buildHeatmapInput(LAYERS, { + compute: [ + { ticker: "NVDA", name: "NVIDIA", changePercent: 1, marketCap: 4e12 }, + { ticker: "AMD", name: "AMD", changePercent: -1, marketCapDisplay: "$300B" }, + ], + nuclear: [{ ticker: "CEG", name: "Constellation", changePercent: 0.5, marketCapDisplay: "$85B" }], + }); + assert.equal(input.groups.length, 2); + const compute = input.groups.find((g) => g.key === "compute")!; + assert.equal(compute.stocks.length, 2); + assert.equal(compute.totalB, 4000 + 300); + }); + it("excludes ETF benchmarks entirely", () => { + const input = buildHeatmapInput(LAYERS, { + etfsBenchmarks: [{ ticker: "SPY", name: "SPDR", changePercent: 1, marketCapDisplay: "$500B" }], + compute: [{ ticker: "NVDA", name: "NVIDIA", changePercent: 1, marketCap: 4e12 }], + }); + assert.equal(input.groups.length, 1); + assert.equal(input.groups[0].key, "compute"); + }); + it("lists unsized tickers instead of silently dropping them", () => { + const input = buildHeatmapInput(LAYERS, { + compute: [ + { ticker: "NVDA", name: "NVIDIA", changePercent: 1, marketCap: 4e12 }, + { ticker: "MYST", name: "Mystery", changePercent: 2 }, + ], + }); + assert.deepEqual(input.unsized, ["MYST"]); + }); + it("drops empty groups", () => { + const input = buildHeatmapInput(LAYERS, { compute: [], nuclear: [] }); + assert.equal(input.groups.length, 0); + }); +}); + +describe("layoutHeatmap", () => { + const input = buildHeatmapInput(LAYERS, { + compute: [ + { ticker: "BIG", name: "Big", changePercent: 1, marketCapDisplay: "$900B" }, + { ticker: "SML", name: "Small", changePercent: -1, marketCapDisplay: "$100B" }, + ], + }); + it("tiles stay inside the canvas and have positive area", () => { + const { tiles } = layoutHeatmap(input, 800, 400); + assert.equal(tiles.length, 2); + for (const t of tiles) { + assert.ok(t.x0 >= 0 && t.y0 >= 0 && t.x1 <= 800 && t.y1 <= 400); + assert.ok(t.x1 > t.x0 && t.y1 > t.y0); + } + }); + it("areas are proportional to market cap (within padding tolerance)", () => { + const { tiles } = layoutHeatmap(input, 800, 400); + const area = (t: (typeof tiles)[number]) => (t.x1 - t.x0) * (t.y1 - t.y0); + const big = tiles.find((t) => t.ticker === "BIG")!; + const sml = tiles.find((t) => t.ticker === "SML")!; + const ratio = area(big) / area(sml); + assert.ok(ratio > 7 && ratio < 11, `ratio ${ratio}`); + }); + it("group rect carries the header band and contains its tiles", () => { + const { tiles, groups } = layoutHeatmap(input, 800, 400); + assert.equal(groups.length, 1); + const g = groups[0]; + for (const t of tiles) { + assert.ok(t.x0 >= g.x0 && t.x1 <= g.x1 && t.y0 >= g.y0 && t.y1 <= g.y1); + } + }); + it("degenerate canvas or empty input yields nothing", () => { + assert.deepEqual(layoutHeatmap(input, 0, 400), { tiles: [], groups: [] }); + assert.deepEqual(layoutHeatmap({ groups: [], unsized: [] }, 800, 400), { tiles: [], groups: [] }); + }); +}); + +describe("blendHex", () => { + it("f=0 returns a, f=1 returns b, midpoint blends", () => { + assert.equal(blendHex("#000000", "#ffffff", 0), "#000000"); + assert.equal(blendHex("#000000", "#ffffff", 1), "#ffffff"); + assert.equal(blendHex("#000000", "#ffffff", 0.5), "#808080"); + }); + it("clamps f outside [0,1]", () => { + assert.equal(blendHex("#000000", "#ffffff", -1), "#000000"); + assert.equal(blendHex("#000000", "#ffffff", 2), "#ffffff"); + }); +}); + +describe("heatColor", () => { + it("null and non-finite get the neutral surface (absence is not a flat day)", () => { + assert.equal(heatColor(null), SURFACE.overlay); + assert.equal(heatColor(NaN), SURFACE.overlay); + }); + it("positive and negative diverge from neutral", () => { + assert.notEqual(heatColor(2), heatColor(-2)); + assert.notEqual(heatColor(2), SURFACE.overlay); + }); + it("magnitude saturates at the cap", () => { + assert.equal(heatColor(HEAT_SATURATION_PCT), heatColor(HEAT_SATURATION_PCT * 3)); + }); + it("larger magnitude moves further from neutral", () => { + const dist = (hex: string) => { + const c = (s: string, i: number) => parseInt(s.slice(i, i + 2), 16); + const n = SURFACE.overlay; + return ( + Math.abs(c(hex, 1) - c(n, 1)) + Math.abs(c(hex, 3) - c(n, 3)) + Math.abs(c(hex, 5) - c(n, 5)) + ); + }; + assert.ok(dist(heatColor(3)) > dist(heatColor(0.5))); + }); +}); + +describe("heatTextColor", () => { + it("muted ink on unknown, primary ink on real values", () => { + assert.equal(heatTextColor(null), INK.muted); + assert.equal(heatTextColor(2.5), INK.primary); + }); +}); diff --git a/client/src/lib/__tests__/state-primitives.test.tsx b/client/src/lib/__tests__/state-primitives.test.tsx new file mode 100644 index 0000000..6bd5200 --- /dev/null +++ b/client/src/lib/__tests__/state-primitives.test.tsx @@ -0,0 +1,47 @@ +/** + * Lake 8: snapshot tests for the shared widget states (loading/empty/error + * primitives). Rendered with react-dom/server - no DOM required, so they run + * in the same node:test harness as everything else. Snapshots live next to + * this file; regenerate with: node --import tsx --test --test-update-snapshots + */ +import { describe, it, type TestContext } from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AsOf, ErrorState, SrChartTable } from "../../components/Freshness"; + +// @types/node 20 predates node 22+'s t.assert.snapshot; the runtime (node 26) +// has it. One typed shim instead of six casts. +const snap = (t: TestContext, html: string) => + (t.assert as unknown as { snapshot: (v: string) => void }).snapshot(html); + +describe("state primitive snapshots", () => { + it("ErrorState with retry", (t) => { + snap(t, renderToStaticMarkup( {}} />)); + }); + it("ErrorState without retry", (t) => { + snap(t, renderToStaticMarkup()); + }); + it("AsOf fresh", (t) => { + // fixed ages relative to a mocked clock so the snapshot is stable + t.mock.timers.enable({ apis: ["Date"], now: 1_000_000_000 }); + snap(t, renderToStaticMarkup()); + }); + it("AsOf stale", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1_000_000_000 }); + snap(t, renderToStaticMarkup()); + }); + it("AsOf hidden when no timestamp", (t) => { + snap(t, renderToStaticMarkup()); + }); + it("SrChartTable", (t) => { + snap(t, + renderToStaticMarkup( + , + ), + ); + }); +}); diff --git a/client/src/lib/__tests__/state-primitives.test.tsx.snapshot b/client/src/lib/__tests__/state-primitives.test.tsx.snapshot new file mode 100644 index 0000000..d7709ea --- /dev/null +++ b/client/src/lib/__tests__/state-primitives.test.tsx.snapshot @@ -0,0 +1,23 @@ +exports[`state primitive snapshots > AsOf fresh 1`] = ` +"as of 2m ago" +`; + +exports[`state primitive snapshots > AsOf hidden when no timestamp 1`] = ` +"" +`; + +exports[`state primitive snapshots > AsOf stale 1`] = ` +"stale · 45m ago" +`; + +exports[`state primitive snapshots > ErrorState with retry 1`] = ` +"

Price index unavailable.

" +`; + +exports[`state primitive snapshots > ErrorState without retry 1`] = ` +"

Catalysts unavailable.

" +`; + +exports[`state primitive snapshots > SrChartTable 1`] = ` +"
NPI gauge history
DateNPI
Jan 2 '24100
Jul 2 '26251.9
" +`; diff --git a/client/src/lib/__tests__/tokens-sync.test.ts b/client/src/lib/__tests__/tokens-sync.test.ts new file mode 100644 index 0000000..c968029 --- /dev/null +++ b/client/src/lib/__tests__/tokens-sync.test.ts @@ -0,0 +1,71 @@ +/** + * Lake 8: tokens.ts promises to mirror the :root vars in index.css. + * This test enforces the sync so the two sources can never drift. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { BORDER, BRAND, CHART_CHROME, DATA_QUALITY, INK, SEMANTIC, SERIES, SURFACE } from "../tokens"; + +const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../index.css"), "utf8"); + +function cssVar(name: string): string { + const m = new RegExp(`--${name}:\\s*([^;]+);`).exec(css); + assert.ok(m, `--${name} missing from index.css`); + return m[1].trim(); +} + +const norm = (s: string) => s.toLowerCase().replace(/\s+/g, ""); + +describe("tokens.ts mirrors index.css", () => { + it("surfaces", () => { + assert.equal(norm(cssVar("surface-sunken")), norm(SURFACE.sunken)); + assert.equal(norm(cssVar("surface-base")), norm(SURFACE.base)); + assert.equal(norm(cssVar("surface-raised")), norm(SURFACE.raised)); + assert.equal(norm(cssVar("surface-overlay")), norm(SURFACE.overlay)); + }); + it("borders", () => { + assert.equal(norm(cssVar("border-subtle")), norm(BORDER.subtle)); + assert.equal(norm(cssVar("border-strong")), norm(BORDER.strong)); + }); + it("brand", () => { + assert.equal(norm(cssVar("brand")), norm(BRAND.primary)); + assert.equal(norm(cssVar("brand-2")), norm(BRAND.secondary)); + assert.equal(norm(cssVar("brand-glow")), norm(BRAND.glow)); + assert.equal(norm(cssVar("brand-wash")), norm(BRAND.wash)); + }); + it("ink scale", () => { + assert.equal(norm(cssVar("ink")), norm(INK.primary)); + assert.equal(norm(cssVar("ink-secondary")), norm(INK.secondary)); + assert.equal(norm(cssVar("ink-muted")), norm(INK.muted)); + assert.equal(norm(cssVar("ink-faint")), norm(INK.faint)); + }); + it("semantic", () => { + assert.equal(norm(cssVar("positive")), norm(SEMANTIC.positive)); + assert.equal(norm(cssVar("positive-deep")), norm(SEMANTIC.positiveDeep)); + assert.equal(norm(cssVar("negative")), norm(SEMANTIC.negative)); + assert.equal(norm(cssVar("negative-deep")), norm(SEMANTIC.negativeDeep)); + assert.equal(norm(cssVar("warning")), norm(SEMANTIC.warning)); + assert.equal(norm(cssVar("critical")), norm(SEMANTIC.critical)); + assert.equal(norm(cssVar("info")), norm(SEMANTIC.info)); + }); + it("data quality", () => { + assert.equal(norm(cssVar("estimate")), norm(DATA_QUALITY.estimateFlag)); + assert.equal(Number(cssVar("dq-estimated-opacity")), DATA_QUALITY.estimatedOpacity); + assert.equal(Number(cssVar("dq-synthetic-opacity")), DATA_QUALITY.syntheticOpacity); + }); + it("all ten categorical series slots, in order", () => { + SERIES.forEach((hex, i) => { + assert.equal(norm(cssVar(`series-${i + 1}`)), norm(hex), `series-${i + 1}`); + }); + }); + it("chart chrome", () => { + assert.equal(norm(cssVar("chart-axis")), norm(CHART_CHROME.axis)); + assert.equal(norm(cssVar("chart-tick")), norm(CHART_CHROME.tick)); + assert.equal(norm(cssVar("chart-grid")), norm(CHART_CHROME.grid)); + assert.equal(norm(cssVar("chart-crosshair")), norm(CHART_CHROME.crosshair)); + assert.equal(norm(cssVar("chart-ref-line")), norm(CHART_CHROME.refLine)); + }); +}); diff --git a/client/src/lib/chart-theme.ts b/client/src/lib/chart-theme.ts new file mode 100644 index 0000000..04c0cbb --- /dev/null +++ b/client/src/lib/chart-theme.ts @@ -0,0 +1,120 @@ +/** + * GridTilt chart theme (Lake 1) - one theme object consumed by every chart. + * Recharts charts spread the prop bundles; d3/SVG/visx charts read the raw + * values. No chart should hardcode an axis color, tick size, or tooltip + * style after this file exists. + */ +import type { CSSProperties } from "react"; +import { utcMonth, utcYear } from "d3-time"; +import { BORDER, CHART_CHROME, FONT, INK, SURFACE } from "./tokens"; + +export const chartTheme = { + axis: { + stroke: CHART_CHROME.axis, + tickFill: CHART_CHROME.tick, + fontSize: 10, + fontFamily: FONT.mono, + }, + grid: { + stroke: CHART_CHROME.grid, + strokeDasharray: "3 3", + }, + crosshair: { + stroke: CHART_CHROME.crosshair, + strokeWidth: 1, + strokeDasharray: "3 3", + }, + refLine: { + stroke: CHART_CHROME.refLine, + strokeDasharray: "4 4", + }, + tooltip: { + background: SURFACE.overlay, + border: BORDER.strong, + labelColor: INK.secondary, + valueColor: INK.primary, + fontSize: 11, + fontFamily: FONT.mono, + radius: 4, + }, + label: { + fontSize: 11, + fontFamily: FONT.mono, + fill: INK.secondary, + }, + line: { + strokeWidth: 2, + dot: false, + activeDotRadius: 3, + }, +} as const; + +/** Spread into Recharts /: {...axisProps} */ +export const axisProps = { + stroke: chartTheme.axis.stroke, + tick: { + fontSize: chartTheme.axis.fontSize, + fill: chartTheme.axis.tickFill, + fontFamily: chartTheme.axis.fontFamily, + }, + tickLine: false as const, + axisLine: { stroke: chartTheme.axis.stroke }, +}; + +/** Spread into Recharts : {...gridProps} */ +export const gridProps = { + stroke: chartTheme.grid.stroke, + strokeDasharray: chartTheme.grid.strokeDasharray, + vertical: false as const, +}; + +/** Pass to Recharts */ +export const tooltipContentStyle: CSSProperties = { + background: chartTheme.tooltip.background, + border: `1px solid ${chartTheme.tooltip.border}`, + borderRadius: chartTheme.tooltip.radius, + fontSize: chartTheme.tooltip.fontSize, + fontFamily: chartTheme.tooltip.fontFamily, + padding: "8px 10px", +}; + +export const tooltipLabelStyle: CSSProperties = { + color: chartTheme.tooltip.labelColor, + fontSize: chartTheme.tooltip.fontSize, + fontFamily: chartTheme.tooltip.fontFamily, + marginBottom: 4, +}; + +export const tooltipItemStyle: CSSProperties = { + color: chartTheme.tooltip.valueColor, + fontSize: chartTheme.tooltip.fontSize, + fontFamily: chartTheme.tooltip.fontFamily, + padding: 0, +}; + +/** Pass to Recharts for the crosshair */ +export const tooltipCursor = { + stroke: chartTheme.crosshair.stroke, + strokeWidth: chartTheme.crosshair.strokeWidth, + strokeDasharray: chartTheme.crosshair.strokeDasharray, +}; + +/** + * Clean month/quarter boundary ticks for a true time axis, densifying as the + * range shortens. Returns UTC Dates; feed `+d` values to Recharts `ticks` or + * map over them in an SVG chart. + */ +export function timeTicks(x0: number, x1: number, width: number): Date[] { + const spanDays = (x1 - x0) / 86_400_000; + const targetCount = Math.max(3, Math.min(10, Math.floor(width / 90))); + let interval; + if (spanDays > 900) { + const months = Math.ceil(spanDays / 30 / targetCount / 3) * 3; + interval = utcMonth.every(Math.max(3, months)) ?? utcYear.every(1); + } else if (spanDays > 240) { + interval = utcMonth.every(Math.max(1, Math.round(spanDays / 30 / targetCount))); + } else { + interval = utcMonth.every(1); + } + return (interval ?? utcMonth.every(1))!.range(new Date(x0), new Date(x1 + 1)); +} diff --git a/client/src/lib/gpu-series.ts b/client/src/lib/gpu-series.ts new file mode 100644 index 0000000..8b8eb69 --- /dev/null +++ b/client/src/lib/gpu-series.ts @@ -0,0 +1,296 @@ +/** + * Pure data transforms for the Neocloud GPU price-history chart. + * No React, no DOM: everything here is unit-testable (Lake 8 covers it). + * + * Data honesty model + * ------------------ + * The API's per-model `series` mixes two kinds of points, distinguishable + * by date format: + * - "YYYY-MM" sourced history anchor (month granularity, blended est.) + * - "YYYY-MM-DD" recorded daily snapshot from the price recorder + * Points render as dots (anchors solid, recorded small). The line BETWEEN + * points is classified per span: + * - "observed" consecutive recorded days (<= MAX_OBSERVED_GAP_DAYS apart) + * - "interpolated" anything else - drawn dashed at reduced opacity because + * the path between those points is synthetic, not price action. + * Interpolation is linear only. No splines: monotone curves invent price + * movement that never happened. + */ + +export type PointKind = "anchor" | "recorded"; +export type SpanQuality = "observed" | "interpolated"; + +export interface SeriesPointIn { + date: string; + price: number; +} + +export interface ChartPoint { + t: number; // UTC ms + price: number; + kind: PointKind; + /** original date string, for tooltips */ + date: string; +} + +export interface ChartSpan { + quality: SpanQuality; + points: ChartPoint[]; // >= 2 points, shares endpoints with neighbors +} + +export interface ChartSeries { + model: string; + vendor: string; + color: string; + points: ChartPoint[]; + spans: ChartSpan[]; + /** first point = product launch/first tracked price */ + launch: ChartPoint | null; + latest: ChartPoint | null; +} + +export type RangeKey = "3M" | "6M" | "1Y" | "ALL"; + +/** Consecutive recorded points at most this far apart count as observed. */ +export const MAX_OBSERVED_GAP_DAYS = 2; + +const DAY_MS = 86_400_000; + +/** "YYYY-MM" -> anchor at month start; "YYYY-MM-DD" -> recorded day. Null on garbage. */ +export function parsePointDate(date: string): { t: number; kind: PointKind } | null { + const mMonth = /^(\d{4})-(\d{2})$/.exec(date); + if (mMonth) { + const t = Date.UTC(Number(mMonth[1]), Number(mMonth[2]) - 1, 1); + return Number.isFinite(t) ? { t, kind: "anchor" } : null; + } + const mDay = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (mDay) { + const t = Date.UTC(Number(mDay[1]), Number(mDay[2]) - 1, Number(mDay[3])); + return Number.isFinite(t) ? { t, kind: "recorded" } : null; + } + return null; +} + +/** + * Points -> time-ordered, deduped (last write wins per timestamp), dropping + * unparseable dates and non-positive prices (invalid on a log scale). + */ +export function buildPoints(series: SeriesPointIn[]): ChartPoint[] { + const byT = new Map(); + for (const p of series ?? []) { + if (typeof p?.price !== "number" || !Number.isFinite(p.price) || p.price <= 0) continue; + const parsed = parsePointDate(p.date); + if (!parsed) continue; + byT.set(parsed.t, { t: parsed.t, price: p.price, kind: parsed.kind, date: p.date }); + } + return Array.from(byT.values()).sort((a, b) => a.t - b.t); +} + +/** Split a point run into quality spans (adjacent spans share an endpoint). */ +export function buildSpans(points: ChartPoint[]): ChartSpan[] { + if (points.length < 2) return []; + const spans: ChartSpan[] = []; + let cur: ChartSpan | null = null; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]; + const b = points[i]; + const observed = + a.kind === "recorded" && + b.kind === "recorded" && + b.t - a.t <= MAX_OBSERVED_GAP_DAYS * DAY_MS; + const quality: SpanQuality = observed ? "observed" : "interpolated"; + if (cur && cur.quality === quality) { + cur.points.push(b); + } else { + cur = { quality, points: [a, b] }; + spans.push(cur); + } + } + return spans; +} + +export function buildSeries( + rows: Array<{ model: string; vendor: string; series: SeriesPointIn[] }>, + colorFor: (model: string) => string, +): ChartSeries[] { + return rows.map((r) => { + const points = buildPoints(r.series); + return { + model: r.model, + vendor: r.vendor, + color: colorFor(r.model), + points, + spans: buildSpans(points), + launch: points[0] ?? null, + latest: points[points.length - 1] ?? null, + }; + }); +} + +export function rangeStart(range: RangeKey, now: number): number | null { + const d = new Date(now); + switch (range) { + case "3M": + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 3, d.getUTCDate()); + case "6M": + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 6, d.getUTCDate()); + case "1Y": + return Date.UTC(d.getUTCFullYear() - 1, d.getUTCMonth(), d.getUTCDate()); + case "ALL": + return null; + } +} + +/** + * Clip a series to [start, now]. If the window edge cuts a span, a synthetic + * entry point is linearly interpolated AT the edge so the line enters the + * frame at its true height. Edge points are not data: they carry + * `edge: true` and must never render a dot or appear in tooltips. + */ +export interface ClippedPoint extends ChartPoint { + edge?: boolean; +} + +export function clipSeries(points: ChartPoint[], start: number | null, now: number): ClippedPoint[] { + if (points.length === 0) return []; + const lo = start ?? -Infinity; + const inWin = points.filter((p) => p.t >= lo && p.t <= now); + if (start === null) return inWin; + const firstIdx = points.findIndex((p) => p.t >= lo); + if (firstIdx > 0) { + const a = points[firstIdx - 1]; + const b = points[firstIdx]; + const f = (lo - a.t) / (b.t - a.t); + const price = a.price + (b.price - a.price) * f; + return [{ t: lo, price, kind: a.kind, date: a.date, edge: true }, ...inWin]; + } + return inWin; +} + +/** Log-scale ticks on the 1-2-5 progression covering [min, max]. */ +export function logTicks125(min: number, max: number): number[] { + if (!(min > 0) || !(max > 0) || min > max) return []; + const ticks: number[] = []; + let decade = Math.pow(10, Math.floor(Math.log10(min))); + // guard against float drift on tiny/huge domains + for (let i = 0; i < 40 && decade <= max * 1.0000001; i++, decade *= 10) { + for (const m of [1, 2, 5]) { + const v = decade * m; + if (v >= min * 0.9999999 && v <= max * 1.0000001) ticks.push(Number(v.toPrecision(12))); + } + } + return ticks; +} + +/** Padded log domain for a set of positive prices. */ +export function logDomain(values: number[], padRatio = 0.12): [number, number] { + const pos = values.filter((v) => v > 0 && Number.isFinite(v)); + if (pos.length === 0) return [1, 10]; + const min = Math.min(...pos); + const max = Math.max(...pos); + if (min === max) return [min / (1 + padRatio * 2), max * (1 + padRatio * 2)]; + const logMin = Math.log10(min); + const logMax = Math.log10(max); + const pad = (logMax - logMin) * padRatio; + return [Math.pow(10, logMin - pad), Math.pow(10, logMax + pad)]; +} + +/** + * 1-D right-edge label de-overlap: labels keep their order by target y, + * are pushed apart to at least `gap`, then shifted back inside [top, bottom]. + */ +export interface LabelIn { + id: string; + y: number; +} +export interface LabelOut extends LabelIn { + labelY: number; +} + +export function solveLabelCollisions(labels: LabelIn[], top: number, bottom: number, gap: number): LabelOut[] { + if (labels.length === 0) return []; + const sorted = [...labels].sort((a, b) => a.y - b.y); + const ys = sorted.map((l) => Math.min(Math.max(l.y, top), bottom)); + // forward pass: push down to enforce spacing + for (let i = 1; i < ys.length; i++) { + if (ys[i] < ys[i - 1] + gap) ys[i] = ys[i - 1] + gap; + } + // if we ran past the bottom, shift the tail up and re-resolve upward + const overflow = ys[ys.length - 1] - bottom; + if (overflow > 0) { + ys[ys.length - 1] = bottom; + for (let i = ys.length - 2; i >= 0; i--) { + if (ys[i] > ys[i + 1] - gap) ys[i] = ys[i + 1] - gap; + } + // clamp at top if the stack is taller than the space; overlap is then unavoidable + for (let i = 0; i < ys.length; i++) if (ys[i] < top) ys[i] = top; + } + return sorted.map((l, i) => ({ ...l, labelY: ys[i] })); +} + +/** + * Value of a series at time t for the unified crosshair: exact point if one + * exists, linear interpolation inside a span, null outside the series' range. + */ +export interface ValueAt { + price: number; + exact: ChartPoint | null; // the exact point if t lands on one + interpolated: boolean; +} + +export function valueAt(points: ChartPoint[], t: number): ValueAt | null { + if (points.length === 0) return null; + if (t < points[0].t || t > points[points.length - 1].t) return null; + for (let i = 0; i < points.length; i++) { + if (points[i].t === t) return { price: points[i].price, exact: points[i], interpolated: false }; + if (points[i].t > t) { + const a = points[i - 1]; + const b = points[i]; + const f = (t - a.t) / (b.t - a.t); + return { price: a.price + (b.price - a.price) * f, exact: null, interpolated: true }; + } + } + return null; +} + +/** Nearest actual point to t among the given points (for snapping). */ +export function nearestPoint(points: ChartPoint[], t: number): ChartPoint | null { + if (points.length === 0) return null; + let best = points[0]; + for (const p of points) if (Math.abs(p.t - t) < Math.abs(best.t - t)) best = p; + return best; +} + +/** + * Sparkline geometry with an honest domain: y-domain is the series' own + * [min, max] with `padRatio` padding - never zero-based, never global. + * Returns null when there is nothing to draw (0 points). A single point + * renders as a centered dot (the caller special-cases `points.length === 1`). + */ +export interface SparkGeom { + domain: [number, number]; + flat: boolean; +} + +export function sparklineDomain(values: number[], padRatio = 0.1): SparkGeom | null { + const pos = values.filter((v) => Number.isFinite(v)); + if (pos.length === 0) return null; + const min = Math.min(...pos); + const max = Math.max(...pos); + if (min === max) { + // flat window: pad around the value so the line sits mid-band, flagged so + // callers can render a reference treatment instead of implying movement + const pad = Math.abs(min) * padRatio || 0.5; + return { domain: [min - pad, max + pad], flat: true }; + } + const pad = (max - min) * padRatio; + return { domain: [min - pad, max + pad], flat: false }; +} + +/** Format a UTC ms timestamp for tooltips/axis: "Jul 2 '26" / "Jul '25". */ +export function fmtDate(t: number, dayPrecision: boolean): string { + const d = new Date(t); + const mon = d.toLocaleString("en-US", { month: "short", timeZone: "UTC" }); + const yy = String(d.getUTCFullYear()).slice(2); + return dayPrecision ? `${mon} ${d.getUTCDate()} '${yy}` : `${mon} '${yy}`; +} diff --git a/client/src/lib/stack-transforms.ts b/client/src/lib/stack-transforms.ts new file mode 100644 index 0000000..35766b6 --- /dev/null +++ b/client/src/lib/stack-transforms.ts @@ -0,0 +1,254 @@ +/** + * Pure data transforms for The Stack (Lake 3). No React, no DOM - unit + * tested in Lake 8. + */ +import { hierarchy, treemap, treemapSquarify, type HierarchyRectangularNode } from "d3-hierarchy"; +import { INK, SEMANTIC, SURFACE } from "./tokens"; + +// ─── Market cap ───────────────────────────────────────────────────────────── + +/** + * Parse a display string like "$3.2T" / "850B" / "$500M" into $B units. + * Fixes the old parseM bug where B and M were treated identically, which + * made a $50M cap sort equal to a $50B cap. + */ +export function parseMarketCapDisplay(s: string | undefined | null): number | null { + if (!s) return null; + const m = /^\$?\s*([\d.]+)\s*([TBM])\b/i.exec(s.trim()); + if (!m) return null; + const n = parseFloat(m[1]); + if (!Number.isFinite(n)) return null; + const unit = m[2].toUpperCase(); + if (unit === "T") return n * 1000; + if (unit === "B") return n; + return n / 1000; // M +} + +/** Market cap in $B: numeric field from the API first, display string as fallback. */ +export function marketCapOf(stock: { marketCap?: number | null; marketCapDisplay?: string }): number | null { + if (typeof stock.marketCap === "number" && Number.isFinite(stock.marketCap) && stock.marketCap > 0) { + return stock.marketCap / 1e9; + } + return parseMarketCapDisplay(stock.marketCapDisplay); +} + +// ─── Sparkline windows ────────────────────────────────────────────────────── + +/** % change across a sparkline window (first -> last). Null when undefined. */ +export function pctFromSparkline(spark: number[] | undefined | null): number | null { + if (!spark || spark.length < 2) return null; + const first = spark.find((v) => Number.isFinite(v) && v !== 0); + const last = [...spark].reverse().find((v) => Number.isFinite(v)); + if (first === undefined || last === undefined || first === 0) return null; + return ((last - first) / first) * 100; +} + +export type WindowDirection = "up" | "down" | "flat"; + +/** Net direction of the sparkline's own window - what the line color encodes. */ +export function windowDirection(spark: number[] | undefined | null): WindowDirection | null { + const pct = pctFromSparkline(spark); + if (pct === null) return null; + if (Math.abs(pct) < 0.005) return "flat"; + return pct > 0 ? "up" : "down"; +} + +// ─── Table sorting ────────────────────────────────────────────────────────── + +export type TableSortKey = "ticker" | "price" | "d1" | "d5" | "m1" | "mktcap" | "pe" | "revGrowth"; + +export interface TableRowValues { + ticker: string; + price: number | null; + d1: number | null; + d5: number | null; + m1: number | null; + mktcap: number | null; + pe: number | null; + revGrowth: number | null; +} + +/** Sort with nulls always last regardless of direction. */ +export function sortTableRows(rows: T[], key: TableSortKey, dir: "asc" | "desc"): T[] { + const mul = dir === "asc" ? 1 : -1; + return [...rows].sort((a, b) => { + if (key === "ticker") return mul * a.ticker.localeCompare(b.ticker); + const av = a[key]; + const bv = b[key]; + if (av === null && bv === null) return a.ticker.localeCompare(b.ticker); + if (av === null) return 1; + if (bv === null) return -1; + return mul * (av - bv); + }); +} + +// ─── Heatmap (treemap) ────────────────────────────────────────────────────── + +export interface HeatStock { + ticker: string; + name: string; + changePercent: number | null; + stale?: boolean; + sizeB: number; // $B +} + +export interface HeatGroup { + key: string; + title: string; + color: string; + stocks: HeatStock[]; + totalB: number; +} + +export interface HeatmapInput { + groups: HeatGroup[]; + /** tickers that could not be sized (no market cap) - listed, not drawn */ + unsized: string[]; +} + +/** + * Group stocks by layer for the treemap. ETF benchmarks are excluded (fund + * AUM is not corporate market cap; sizing funds against operating companies + * would misstate "where the money is"). Unsized tickers are returned for an + * honest caption instead of silently dropped. + */ +export function buildHeatmapInput( + layers: Array<{ key: string; title: string; color: string }>, + stocksByLayer: Record>, +): HeatmapInput { + const unsized: string[] = []; + const groups: HeatGroup[] = []; + for (const layer of layers) { + if (layer.key === "etfsBenchmarks") continue; + const src = stocksByLayer[layer.key] ?? []; + const stocks: HeatStock[] = []; + for (const s of src) { + const sizeB = marketCapOf(s); + if (sizeB === null || sizeB <= 0) { + unsized.push(s.ticker); + continue; + } + stocks.push({ ticker: s.ticker, name: s.name, changePercent: s.changePercent, stale: s.stale, sizeB }); + } + if (stocks.length > 0) { + groups.push({ + key: layer.key, + title: layer.title, + color: layer.color, + stocks, + totalB: stocks.reduce((t, s) => t + s.sizeB, 0), + }); + } + } + return { groups, unsized }; +} + +export interface HeatRect { + ticker: string; + name: string; + changePercent: number | null; + stale?: boolean; + sizeB: number; + x0: number; + y0: number; + x1: number; + y1: number; + groupKey: string; +} + +export interface HeatGroupRect { + key: string; + title: string; + color: string; + totalB: number; + x0: number; + y0: number; + x1: number; + y1: number; +} + +const GROUP_HEADER = 16; + +/** Squarified two-level treemap layout in pixel space. */ +export function layoutHeatmap(input: HeatmapInput, width: number, height: number): { tiles: HeatRect[]; groups: HeatGroupRect[] } { + if (width <= 0 || height <= 0 || input.groups.length === 0) return { tiles: [], groups: [] }; + type Node = { children?: Node[]; group?: HeatGroup; stock?: HeatStock; groupKey?: string }; + const root = hierarchy({ + children: input.groups.map((g) => ({ + group: g, + children: g.stocks.map((s) => ({ stock: s, groupKey: g.key })), + })), + }) + .sum((d) => (d.stock ? Math.max(d.stock.sizeB, 0.001) : 0)) + .sort((a, b) => (b.value ?? 0) - (a.value ?? 0)); + + const laid = treemap() + .tile(treemapSquarify) + .size([width, height]) + .paddingOuter(2) + .paddingTop(GROUP_HEADER) + .paddingInner(2)(root) as HierarchyRectangularNode; + + const groups: HeatGroupRect[] = (laid.children ?? []).map((g) => ({ + key: g.data.group!.key, + title: g.data.group!.title, + color: g.data.group!.color, + totalB: g.data.group!.totalB, + x0: g.x0, + y0: g.y0, + x1: g.x1, + y1: g.y1, + })); + + const tiles: HeatRect[] = laid.leaves().map((l) => ({ + ticker: l.data.stock!.ticker, + name: l.data.stock!.name, + changePercent: l.data.stock!.changePercent, + stale: l.data.stock!.stale, + sizeB: l.data.stock!.sizeB, + x0: l.x0, + y0: l.y0, + x1: l.x1, + y1: l.y1, + groupKey: l.data.groupKey!, + })); + + return { tiles, groups }; +} + +// ─── Diverging change color ───────────────────────────────────────────────── + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +export function blendHex(a: string, b: string, f: number): string { + const t = Math.min(1, Math.max(0, f)); + const [ar, ag, ab] = hexToRgb(a); + const [br, bg, bb] = hexToRgb(b); + const c = (x: number, y: number) => Math.round(x + (y - x) * t); + return `#${[c(ar, br), c(ag, bg), c(ab, bb)].map((v) => v.toString(16).padStart(2, "0")).join("")}`; +} + +/** + * Diverging fill for a % change: neutral surface at 0, semantic green/red + * ramping with magnitude, saturating at +-4%. Null (stale/unknown) gets the + * neutral surface so absence of data never reads as "flat day". + */ +export const HEAT_SATURATION_PCT = 4; + +export function heatColor(pct: number | null): string { + if (pct === null || !Number.isFinite(pct)) return SURFACE.overlay; + const f = Math.min(Math.abs(pct) / HEAT_SATURATION_PCT, 1); + // ease so small moves are visible but don't scream + const eased = Math.pow(f, 0.7); + const target = pct >= 0 ? SEMANTIC.positiveDeep : SEMANTIC.negativeDeep; + return blendHex(SURFACE.overlay, target, 0.12 + eased * 0.7); +} + +/** Text color that stays readable on heatColor() fills. */ +export function heatTextColor(pct: number | null): string { + if (pct === null || !Number.isFinite(pct)) return INK.muted; + return INK.primary; +} diff --git a/client/src/lib/tokens.ts b/client/src/lib/tokens.ts new file mode 100644 index 0000000..85c28c4 --- /dev/null +++ b/client/src/lib/tokens.ts @@ -0,0 +1,125 @@ +/** + * GridTilt data tokens (Lake 1) - TS mirror of the :root vars in index.css. + * Chart code (Recharts/d3/SVG props) needs literal values, so this file is + * the source of truth for anything rendered outside the CSS cascade. + * Keep in sync with index.css; Lake 8 adds a test asserting the sync. + */ + +export const SURFACE = { + sunken: "#0A0A08", + base: "#0E0E0C", + raised: "#1A1917", + overlay: "#26241F", +} as const; + +export const BORDER = { + subtle: "rgba(255, 255, 255, 0.06)", + strong: "rgba(255, 255, 255, 0.14)", +} as const; + +export const BRAND = { + primary: "#F07800", + secondary: "#F0A500", + glow: "rgba(240, 120, 0, 0.12)", + wash: "rgba(240, 120, 0, 0.05)", +} as const; + +export const INK = { + primary: "#F2F1ED", + secondary: "#B0AEA6", + muted: "#8E8B84", + faint: "#716E67", +} as const; + +/** State colors. Never use these for series identity. */ +export const SEMANTIC = { + positive: "#4ade80", + positiveDeep: "#22c55e", + negative: "#f87171", + negativeDeep: "#ef4444", + warning: "#eab308", + critical: "#dc2626", + info: "#4dabf7", +} as const; + +/** + * Data-quality treatment: sourced points render solid at full opacity; + * estimated values keep the series hue at reduced opacity; synthetic fill + * spans (interpolation between anchors) render dashed at low opacity. + */ +export const DATA_QUALITY = { + estimateFlag: "#d4a843", + estimatedOpacity: 0.55, + syntheticOpacity: 0.35, + syntheticDash: "4 3", +} as const; + +/** + * Categorical palette - fixed order, CVD-validated on both dark surfaces + * (min adjacent deltaE 13.9 under protanopia/deuteranopia, all slots >= 3:1 + * contrast). Assign slots in order, never cycle. The ORDER is the + * colorblind-safety mechanism: do not reorder or insert. + * At >4 visible series, direct labels are mandatory (color alone is not + * enough in scatter/treemap contexts where any two slots can be adjacent). + */ +export const SERIES = [ + "#3987e5", // 1 blue + "#c98500", // 2 amber + "#199e70", // 3 teal + "#9085e9", // 4 violet + "#d55181", // 5 magenta + "#1f9fb5", // 6 cyan + "#d95926", // 7 rust + "#3d9e3d", // 8 green + "#bd6bce", // 9 pink + "#b07d3f", // 10 brown +] as const; + +/** + * Stable category -> color mapping. Same category = same color everywhere + * in the app (Tilt Overview mover tags, The Stack layers, Power Map, + * Compute Frontier, TheTrade). Categories that never co-occur in one chart + * may share a slot (solar/power); co-occurring ones never do. + */ +export const CATEGORY_COLORS: Record = { + // Sectors / mover tags + compute: SERIES[0], // blue + datacenters: SERIES[2], // teal + construction: SERIES[9], // brown + power: SERIES[1], // amber + utilities: SERIES[8], // pink (grid owns cyan; both appear on The Stack) + uranium: SERIES[4], // magenta + // Energy sources (TheTrade + Compute Frontier co-occur: must be distinct) + nuclear: SERIES[3], // violet - matches existing purple convention + gas: SERIES[6], // rust + renewables: SERIES[7], // green + grid: SERIES[5], // cyan + solar: SERIES[1], // amber (never co-charts with power) + wind: SERIES[0], // blue (never co-charts with compute) + hydro: SERIES[2], // teal + storage: SERIES[4], // magenta (never co-charts with uranium) + coal: INK.faint, +}; + +/** + * Facility/project status - state, not identity, so it draws on semantic + * steps. Used by Power Map markers and Compute Frontier status charts. + */ +export const STATUS_COLORS = { + operational: SEMANTIC.positiveDeep, + construction: SEMANTIC.warning, + announced: INK.muted, +} as const; + +export const CHART_CHROME = { + axis: "#55534E", + tick: "#8E8B84", + grid: "rgba(255, 255, 255, 0.05)", + crosshair: "rgba(255, 255, 255, 0.25)", + refLine: "rgba(255, 255, 255, 0.18)", +} as const; + +export const FONT = { + mono: '"JetBrains Mono", monospace', + sans: "Inter, sans-serif", +} as const; diff --git a/client/src/lib/use-measured-width.ts b/client/src/lib/use-measured-width.ts new file mode 100644 index 0000000..8096003 --- /dev/null +++ b/client/src/lib/use-measured-width.ts @@ -0,0 +1,16 @@ +import { useLayoutEffect, useRef, useState, type RefObject } from "react"; + +/** Container width via ResizeObserver, for SVG/canvas charts. */ +export function useMeasuredWidth(): [RefObject, number] { + const ref = useRef(null); + const [w, setW] = useState(0); + useLayoutEffect(() => { + if (!ref.current) return; + const ro = new ResizeObserver((entries) => { + for (const e of entries) setW(e.contentRect.width); + }); + ro.observe(ref.current); + return () => ro.disconnect(); + }, []); + return [ref, w]; +} diff --git a/client/src/pages/AdminDatacenters.tsx b/client/src/pages/AdminDatacenters.tsx index 0aa9f40..1a109e0 100644 --- a/client/src/pages/AdminDatacenters.tsx +++ b/client/src/pages/AdminDatacenters.tsx @@ -216,7 +216,7 @@ export default function AdminDatacenters() { const keyCard = (
- +

Admin Key

@@ -237,14 +237,14 @@ export default function AdminDatacenters() { Save
-

+

Status:{" "} {adminKey ? ( - + Key loaded ) : ( - + No key set )} @@ -275,7 +275,7 @@ export default function AdminDatacenters() {

- +

Add New Site

@@ -449,7 +449,7 @@ export default function AdminDatacenters() { }} data-testid={`button-delete-${d.id}`} > - + diff --git a/client/src/pages/AdminSocial.tsx b/client/src/pages/AdminSocial.tsx index 1d5f275..ff05332 100644 --- a/client/src/pages/AdminSocial.tsx +++ b/client/src/pages/AdminSocial.tsx @@ -165,7 +165,7 @@ export default function AdminSocial() {
- +

Admin key required

@@ -219,7 +219,7 @@ export default function AdminSocial() {

- +

Compose

@@ -252,12 +252,12 @@ export default function AdminSocial() { onChange={(e) => setComposeText(e.target.value)} placeholder="What's happening in AI infra today..." rows={8} - className="w-full font-mono text-sm rounded-[0.35rem] border border-border bg-background p-3 text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-[#F07800]" + className="w-full font-mono text-sm rounded-[0.35rem] border border-border bg-background p-3 text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-[var(--brand)]" data-testid="input-compose-text" />
{charCount} / {TWEET_MAX} @@ -269,7 +269,7 @@ export default function AdminSocial() { overLimit || postMutation.isPending } - className="bg-[#F07800] hover:bg-[#D86A00] text-white" + className="bg-brand hover:bg-brand/90 text-white" data-testid="button-post-tweet" > @@ -281,7 +281,7 @@ export default function AdminSocial() {
- +

Delete tweet

@@ -315,7 +315,7 @@ export default function AdminSocial() {

Recent activity

- + {log.length}
@@ -349,22 +349,22 @@ export default function AdminSocial() { >
{entry.ok ? ( - + ok ) : ( - + fail )} {entry.dryRun && ( - dry run + dry run )} {entry.template && ( - {entry.template} + {entry.template} )} {entry.trigger && ( - {entry.trigger} + {entry.trigger} )} {fmtTime(entry.timestamp)} @@ -376,7 +376,7 @@ export default function AdminSocial() { {entry.error && ( -
+                
                   {entry.error}
                 
)} @@ -388,14 +388,14 @@ export default function AdminSocial() { href={`https://x.com/gridtilt/status/${entry.id}`} target="_blank" rel="noopener noreferrer" - className="text-[#F07800] hover:underline inline-flex items-center gap-1" + className="text-brand hover:underline inline-flex items-center gap-1" data-testid={`link-tweet-${entry.id}`} > Open
+ {/* Today's read: the daily brief, self-contained (own loading/error state), + so it can never blank the post list below. */} + + +

+ Long-form posts +

+ {isLoading ? (
{[1, 2, 3].map((i) => )} @@ -53,11 +62,11 @@ export default function BlogIndex() { {new Date(article.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{article.keywords.slice(0, 2).map((kw) => ( - {kw} + {kw} ))}
- +
diff --git a/client/src/pages/BlogPost.tsx b/client/src/pages/BlogPost.tsx index a8f37b4..bf87953 100644 --- a/client/src/pages/BlogPost.tsx +++ b/client/src/pages/BlogPost.tsx @@ -46,7 +46,7 @@ function renderMarkdown(content: string) { } else if (line.startsWith("**")) { const parts = line.split(/(\*\*[^*]+\*\*)/); elements.push( -

+

{parts.map((part, j) => { if (part.startsWith("**") && part.endsWith("**")) { return {part.slice(2, -2)}; @@ -55,7 +55,7 @@ function renderMarkdown(content: string) { return linkParts.map((lp, k) => { const linkMatch = lp.match(/^\[([^\]]+)\]\(([^)]+)\)$/); if (linkMatch) { - return {linkMatch[1]}; + return {linkMatch[1]}; } return lp; }); @@ -69,7 +69,7 @@ function renderMarkdown(content: string) { i++; } elements.push( -

    +
      {items.map((item, j) =>
    1. {renderInlineText(item)}
    2. )}
    ); @@ -79,11 +79,11 @@ function renderMarkdown(content: string) { } else { const linkParts = line.split(/(\[[^\]]+\]\([^)]+\))/); elements.push( -

    +

    {linkParts.map((part, j) => { const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/); if (linkMatch) { - return {linkMatch[1]}; + return {linkMatch[1]}; } return part; })} @@ -136,10 +136,10 @@ export default function BlogPost() {

    - +

    Article Not Found

    - Browse all articles + Browse all articles

    @@ -164,7 +164,7 @@ export default function BlogPost() {
    - + Back to articles
    @@ -188,10 +188,10 @@ export default function BlogPost() { {new Date(article.date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
    {article.keywords.map((kw) => ( - {kw} + {kw} ))}
    -

    {article.description}

    +

    {article.description}

    {toc.length > 2 && ( @@ -202,7 +202,7 @@ export default function BlogPost() {
  1. - + All Articles
diff --git a/client/src/pages/CatalystTracker.tsx b/client/src/pages/CatalystTracker.tsx index 9a45992..67d1b4d 100644 --- a/client/src/pages/CatalystTracker.tsx +++ b/client/src/pages/CatalystTracker.tsx @@ -3,14 +3,17 @@ import { useQuery } from "@tanstack/react-query"; import { useLocation, Link } from "wouter"; import { CalendarDays, ChevronLeft, ChevronRight, Clock, - TrendingUp, ArrowRight, Eye, EyeOff, + TrendingUp, ArrowRight, Eye, EyeOff, AlertTriangle, RotateCw, } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AsOf } from "@/components/Freshness"; import { catalystCategoryColors, STAGE_COLORS, SUPPLY_CHAIN_STAGE_MAP, type CatalystCategory, } from "@/data/catalyst-config"; +import { BRAND, INK, SURFACE, BORDER } from "@/lib/tokens"; interface EarningsItem { id: string; @@ -68,7 +71,7 @@ function formatDateFull(dateStr: string): string { function getStageColor(ticker: string): string { const stage = SUPPLY_CHAIN_STAGE_MAP[ticker]; - return stage ? (STAGE_COLORS[stage] || "#888") : "#888"; + return stage ? (STAGE_COLORS[stage] || INK.muted) : INK.muted; } function CalendarGrid({ @@ -116,24 +119,24 @@ function CalendarGrid({ return (
- - {monthName} -
-
+
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => ( -
+
{d}
))} {cells.map((day, i) => { if (day === null) { - return
; + return
; } const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; const dayItems = itemsByDate[dateStr] || []; @@ -146,25 +149,25 @@ function CalendarGrid({ key={dateStr} className="cursor-pointer transition-all" style={{ - background: isSelected ? "#1E1D1A" : isToday ? "#1C1B18" : "#161614", + background: SURFACE.raised, minHeight: 56, padding: "4px 6px", - borderLeft: isToday ? "2px solid #F07800" : isSelected ? "2px solid rgba(255,255,255,0.2)" : "2px solid transparent", + borderLeft: isToday ? `2px solid ${BRAND.primary}` : isSelected ? `2px solid ${BORDER.strong}` : "2px solid transparent", opacity: isWeekend && dayItems.length === 0 ? 0.6 : 1, }} onClick={() => onDateSelect(isSelected ? null : dateStr)} data-testid={`calendar-day-${dateStr}`} > -
+
{day}
{dayItems.map((item, j) => { - let color = "#888"; + let color: string = INK.muted; if (item.type === "earnings") { color = (item as EarningsItem).stageColor || getStageColor((item as EarningsItem).ticker); } else { - color = catalystCategoryColors[(item as CatalystItem).category] || "#888"; + color = catalystCategoryColors[(item as CatalystItem).category] || INK.muted; } return (
{dayItems.length > 0 && ( -
+
{dayItems.length === 1 ? (dayItems[0].type === "earnings" ? (dayItems[0] as EarningsItem).ticker : "Event") : `${dayItems.length} events`} @@ -190,7 +193,7 @@ function CalendarGrid({ {selectedItems.length > 0 && (
-
+
{formatDateFull(selectedDate!)}
{selectedItems.map((item) => ( @@ -210,24 +213,24 @@ function DayDetailCard({ item }: { item: MergedItem }) { return (
- {e.ticker} - {e.company} + {e.ticker} + {e.company}
{e.time}
-
+
{e.stage} @@ -235,7 +238,7 @@ function DayDetailCard({ item }: { item: MergedItem }) { {e.quarter && {e.quarter}}
) : ( <> @@ -520,12 +557,12 @@ export default function CatalystTracker() { onDateSelect={setSelectedDate} />
-
+
-
+
diff --git a/client/src/pages/ComputeFrontierCompare.tsx b/client/src/pages/ComputeFrontierCompare.tsx index e8f7499..42d4e71 100644 --- a/client/src/pages/ComputeFrontierCompare.tsx +++ b/client/src/pages/ComputeFrontierCompare.tsx @@ -5,6 +5,7 @@ import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { ArrowLeft, GitCompare } from "lucide-react"; +import { STATUS_COLORS } from "@/lib/tokens"; interface Cluster { id: string; @@ -25,11 +26,7 @@ interface Cluster { sources: string[]; } -const STATUS_COLOR: Record = { - operational: "#F07800", - construction: "#F0A500", - announced: "#9a9a9a", -}; +const STATUS_COLOR: Record = STATUS_COLORS; function est(c: Cluster, field: string): string { return c.estimated.includes(field) ? " est." : ""; @@ -56,7 +53,7 @@ export default function ComputeFrontierCompare() { const rows: Array<{ label: string; render: (c: Cluster) => ReactNode }> = [ { label: "Operator", render: (c) => c.operator }, - { label: "Status", render: (c) => {c.status} }, + { label: "Status", render: (c) => {c.status} }, { label: "Location", render: (c) => `${c.location.city}, ${c.location.state}` }, { label: "Grid region", render: (c) => c.gridRegion }, { label: "Chip", render: (c) => c.chipType }, @@ -66,7 +63,7 @@ export default function ComputeFrontierCompare() { { label: "Energy", render: (c) => c.energySource }, { label: "Workload", render: (c) => c.workload }, { label: "Online", render: (c) => `${c.onlineDate}${est(c, "onlineDate")}` }, - { label: "Nuclear deal", render: (c) => (c.linkedDeal ? {c.linkedDeal} : "none") }, + { label: "Nuclear deal", render: (c) => (c.linkedDeal ? {c.linkedDeal} : "none") }, ]; return ( @@ -76,10 +73,10 @@ export default function ComputeFrontierCompare() { Compute Frontier
- +

Compare clusters

-

Put two or three superclusters side by side. Values marked est. are GridTilt estimates or announced targets.

+

Put two or three superclusters side by side. Values marked est. are GridTilt estimates or announced targets.

@@ -97,7 +94,7 @@ export default function ComputeFrontierCompare() { setDcOnly(e.target.checked)} className="accent-[#F07800]" /> + {filtered.length} of {data?.projects?.length ?? 0} @@ -276,7 +334,14 @@ export default function Queue() { {/* Projects table — the page is THIS now */} -
+ {isError ? ( +
+ refetch()} /> +
+ ) : ( +
+
+
@@ -290,34 +355,32 @@ export default function Queue() {
{Array(12).fill(null).map((_, i) => )}
- ) : isError ? ( -
Backlog dataset unavailable.
) : filtered.length === 0 ? (
No projects match the current filters.
) : ( filtered.map((p) => { const Icon = TYPE_ICONS[p.type] ?? Zap; - const statusColor = p.status === "active" ? "#F0A500" : p.status === "operational" ? "#22C55E" : "#6b7280"; + const statusColor = p.status === "active" ? SEMANTIC.warning : p.status === "operational" ? SEMANTIC.positiveDeep : INK.faint; const isAggregate = p.category === "aggregate"; - return ( - - + const hasTooltip = !!p.notes || !!(p.sources && p.sources.length > 0); + const row = (
{p.projectName} {p.status === "operational" && ( - live + live )}
-
+
{p.sponsor}{p.offtaker ? ` → ${p.offtaker}` : ""}
- + {p.type} @@ -326,21 +389,25 @@ export default function Queue() { {p.iso} {p.state} - {p.expectedOnline ?? "—"} + {p.expectedOnline ?? "—"} - + {CATEGORY_LABELS[p.category] ?? p.category} - - {p.dcRelevant ? : } + + {p.dcRelevant ? : }
- + ); + if (!hasTooltip) return row; + return ( + + {row} {p.notes &&

{p.notes}

} {p.sources && p.sources.length > 0 && ( -

+

sources: {p.sources.join(" · ")}

)} @@ -349,13 +416,16 @@ export default function Queue() { ); }) )} +
+
+ )} -

+

Specific projects are verified against SEC filings, utility press releases, FERC dockets, NRC documents, and trade press. Aggregate rows roll up entire ISO cycles or fleet positions; toggle "specific only" to filter them out. The full ~{h?.queueOverallProjects?.toLocaleString() ?? "10,300"}-project LBNL dataset is at{" "} - emp.lbl.gov/queues. + emp.lbl.gov/queues.

@@ -367,9 +437,9 @@ function FreshnessChip({ lastRefreshed }: { lastRefreshed: string }) { const days = Math.max(0, Math.floor((Date.now() - refreshDate.getTime()) / 86400000)); const label = days === 0 ? "refreshed today" : days === 1 ? "refreshed yesterday" : `refreshed ${days} days ago`; const color = - days <= 14 ? "text-green-400/80 border-green-400/30" : - days <= 60 ? "text-[#F0A500] border-[#F0A500]/30" : - "text-red-400 border-red-400/30"; + days <= 14 ? "text-positive border-positive/30" : + days <= 60 ? "text-warning border-warning/30" : + "text-negative border-negative/30"; return ( {label} @@ -379,8 +449,8 @@ function FreshnessChip({ lastRefreshed }: { lastRefreshed: string }) { function ChipSelect({ label, value, onChange, options }: { label: string; value: string; onChange: (v: string) => void; options: { value: string; label: string }[] }) { return ( -

${s.price.toFixed(2)}

- + {up ? : } {up ? "+" : ""}{s.changePercent.toFixed(2)}% @@ -169,7 +169,7 @@ export default function SectorPage() {
{sector.related.map((r) => ( - + {SECTOR_SLUG_LABELS[r] || r} diff --git a/client/src/pages/StockPage.tsx b/client/src/pages/StockPage.tsx index 1897787..2dcfec8 100644 --- a/client/src/pages/StockPage.tsx +++ b/client/src/pages/StockPage.tsx @@ -6,6 +6,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { ArrowLeft, ExternalLink, TrendingUp, TrendingDown, AlertTriangle, Share2, Clock } from "lucide-react"; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip as RTooltip } from "recharts"; import { useToast } from "@/hooks/use-toast"; +import { SEMANTIC } from "@/lib/tokens"; +import { tooltipContentStyle } from "@/lib/chart-theme"; interface StockInfo { ticker: string; @@ -69,14 +71,14 @@ export default function StockPage() { if (isError || !data) { return (
- + Back to The Stack - +

Ticker Not Found

- ${upperTicker} is not tracked on GridTilt. Browse The Stack to see all 60+ tracked equities. + ${upperTicker} is not tracked on GridTilt. Browse The Stack to see all 60+ tracked equities.

@@ -116,17 +118,17 @@ export default function StockPage() { ${data.ticker}
- {data.primarySegment} + {data.primarySegment} {data.stockData && ( <> ${data.stockData.price.toFixed(2)} {hasLiveChg ? ( - + {isUp ? : } {isUp ? "+" : ""}{(data.stockData.changePercent as number).toFixed(2)}% ) : ( - + {isStale ? "delayed" : "—"} @@ -160,16 +162,16 @@ export default function StockPage() {

Thesis Alignment Score

- {data.thesisScore}/100 + {data.thesisScore}/100

{data.explanation}

{Object.entries(data.sectors).map(([key, val]) => (
-
+
-

{key}

+

{key}

{val}

))} @@ -184,11 +186,11 @@ export default function StockPage() { [`$${val.toFixed(2)}`, "Price"]} /> - + @@ -200,7 +202,7 @@ export default function StockPage() {
{data.relatedCatalysts.map((c) => (
- {c.date} + {c.date}

{c.title}

{c.thesisImpact.slice(0, 150)}...

@@ -227,13 +229,13 @@ export default function StockPage() {
Rev Growth YoY - 0 ? "text-green-400" : "text-red-400"}`}> + 0 ? "text-positive" : "text-negative"}`}> {data.stockData.revenueGrowth != null ? `${data.stockData.revenueGrowth > 0 ? "+" : ""}${data.stockData.revenueGrowth.toFixed(1)}%` : "N/A"}
Daily Change - + {data.stockData.change != null ? `${isUp ? "+" : ""}$${data.stockData.change.toFixed(2)}` : "--"}
@@ -243,7 +245,7 @@ export default function StockPage() {

Sector Context

- + {SECTOR_LABELS[data.layerKey] || data.layerKey} Sector
@@ -256,7 +258,7 @@ export default function StockPage() { ${t} @@ -269,9 +271,9 @@ export default function StockPage() {

Tools

- The Stack - Portfolio Overlay - Catalyst Tracker + The Stack + Portfolio Overlay + Catalyst Tracker
diff --git a/client/src/pages/Subscribe.tsx b/client/src/pages/Subscribe.tsx index efa90a0..374ffd4 100644 --- a/client/src/pages/Subscribe.tsx +++ b/client/src/pages/Subscribe.tsx @@ -39,10 +39,10 @@ export default function Subscribe() {
-
- +
+
-

+

The GridTilt Brief

@@ -56,24 +56,24 @@ export default function Subscribe() { { icon: BarChart3, label: "Top Movers", desc: "Best and worst performers by sector" }, { icon: Map, label: "Power Map", desc: "New facilities and capacity updates" }, ].map(({ icon: Icon, label, desc }) => ( -

- -
{label}
-
{desc}
+
+ +
{label}
+
{desc}
))}
{status === "success" ? ( -
- -
You're in
+
+ +
You're in
First brief arrives next month.
) : status === "exists" ? ( -
- -
You're already on the list
+
+ +
You're already on the list
Check your inbox for the next brief.
) : ( @@ -84,22 +84,22 @@ export default function Subscribe() { value={email} onChange={(e) => { setEmail(e.target.value); setStatus("idle"); }} placeholder="you@example.com" - className="flex-1 bg-[#161614] border border-white/[0.08] rounded-lg px-4 py-2.5 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-[#F07800]/40 transition-colors" + className="flex-1 bg-surface-raised border border-subtle rounded-lg px-4 py-2.5 text-sm text-ink placeholder:text-white/30 focus:outline-none focus:border-brand/40 transition-colors" data-testid="input-email" />
{status === "error" && ( -

{errorMsg}

+

{errorMsg}

)} -

+

One email per month. Unsubscribe anytime.

diff --git a/client/src/pages/SupplyChain.tsx b/client/src/pages/SupplyChain.tsx index 48f3529..cedb61d 100644 --- a/client/src/pages/SupplyChain.tsx +++ b/client/src/pages/SupplyChain.tsx @@ -7,14 +7,17 @@ import { Atom, Wrench, Hammer, Gem, Flame, Radiation, Gauge, Sun, FlaskConical, Plug, HardHat, Snowflake, ToggleRight, Building, Battery, Cloud, Warehouse, Cpu, Pickaxe, Workflow, GitBranch, Clock, - MemoryStick, Network, Users, + MemoryStick, Network, Users, RotateCw, } from "lucide-react"; +import { AsOf } from "@/components/Freshness"; import { SiBitcoin } from "react-icons/si"; +import { BRAND, INK, SURFACE, SEMANTIC, DATA_QUALITY, CHART_CHROME } from "@/lib/tokens"; import { Tooltip as UITooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Skeleton } from "@/components/ui/skeleton"; import { supplyNodes, supplyLinks, @@ -69,9 +72,9 @@ const STAGE_KEY_MAP: Record = { }; const STATUS_COLOR: Record = { - Flowing: '#22C55E', - Tightening: '#F0A500', - Bottlenecked: '#EF4444', + Flowing: SEMANTIC.positiveDeep, + Tightening: SEMANTIC.warning, + Bottlenecked: SEMANTIC.negativeDeep, }; interface SimNode extends d3.SimulationNodeDatum { @@ -208,9 +211,15 @@ function NetworkGraph({ }, []); // Drag handlers attached to each node group via React refs. Mutates the - // node position directly and re-renders. Works with both mouse and touch - // because pointer events normalize both. + // node position directly, then schedules a re-render via requestAnimationFrame + // so pointer-move bursts collapse to at most one re-render per frame. + // Works with both mouse and touch because pointer events normalize both. const dragState = useRef<{ id: string | null; dx: number; dy: number }>({ id: null, dx: 0, dy: 0 }); + const dragRaf = useRef(null); + + useEffect(() => () => { + if (dragRaf.current !== null) cancelAnimationFrame(dragRaf.current); + }, []); const onNodePointerDown = (id: string, e: React.PointerEvent) => { e.stopPropagation(); @@ -233,7 +242,12 @@ function NetworkGraph({ if (!node) return; node.x = pt.x - dx; node.y = pt.y - dy; - forceRender((v) => v + 1); + if (dragRaf.current === null) { + dragRaf.current = requestAnimationFrame(() => { + dragRaf.current = null; + forceRender((v) => v + 1); + }); + } }; const onNodePointerUp = (e: React.PointerEvent) => { @@ -264,7 +278,11 @@ function NetworkGraph({ return () => { d3.select(svg).on('.zoom', null); }; }, []); - if (!positionsReady.current) return null; + if (!positionsReady.current) { + // Layout simulation hasn't converged yet: hold the graph box open with a + // shimmer so the container doesn't flash from empty to full. + return ; + } const nodes = nodesRef.current; const links = linksRef.current; @@ -278,6 +296,20 @@ function NetworkGraph({ data-testid="sc-network-graph" > + {/* Stage column guides: the sim pins each node's x to its stage + (forceX strength 0.85), so horizontal position IS the supply + chain stage. These faint verticals make that encoding explicit. */} + {[0.1, 0.3, 0.5, 0.7, 0.9].map((fx, i) => ( + + ))} {STAGE_LABELS.map((s) => { const stageX = [0.1, 0.3, 0.5, 0.7, 0.9]; const x = stageX[s.index] * GRAPH_W; @@ -331,7 +363,7 @@ function NetworkGraph({ y1={36} x2={frac * GRAPH_W} y2={GRAPH_H - 10} - stroke="rgba(255,255,255,0.03)" + stroke={CHART_CHROME.grid} strokeWidth={1} /> ))} @@ -360,7 +392,7 @@ function NetworkGraph({ key={i} d={path} fill="none" - stroke={`rgba(240,120,0,${opacity})`} + stroke={`rgba(${hexToRgb(BRAND.primary)},${opacity})`} strokeWidth={width} className={entrancePhase >= 2 ? "sc-link-enter" : "sc-link-hidden"} style={{ animationDelay: `${delay}s` }} @@ -370,7 +402,7 @@ function NetworkGraph({ })} {nodes.map((node) => { - const color = STAGE_COLORS[node.stage] || '#F07800'; + const color = STAGE_COLORS[node.stage] || BRAND.primary; const isActive = activeNode === node.id; const isConnected = connectedSet?.has(node.id); const isDimmed = connectedSet && !isConnected; @@ -380,11 +412,11 @@ function NetworkGraph({ ? color : isConnected ? color - : 'rgba(240,120,0,0.15)'; + : `rgba(${hexToRgb(BRAND.primary)},0.15)`; const strokeWidth = isActive ? 3 : isConnected ? 2 : 1.5; const fillColor = isActive ? `rgba(${hexToRgb(color)},0.12)` - : '#1C1B18'; + : SURFACE.raised; const filterVal = isActive ? `drop-shadow(0 0 12px ${color})` : isConnected @@ -403,13 +435,23 @@ function NetworkGraph({ { e.stopPropagation(); if (!dragState.current.id) onSelectNode(isActive ? null : node.id); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + if (!dragState.current.id) onSelectNode(isActive ? null : node.id); + } + }} onPointerDown={(e) => onNodePointerDown(node.id, e)} onPointerMove={onNodePointerMove} onPointerUp={onNodePointerUp} onPointerCancel={onNodePointerUp} - className={entrancePhase >= 1 ? "sc-node-enter" : "sc-node-hidden"} + className={`sc-node-focusable ${entrancePhase >= 1 ? "sc-node-enter" : "sc-node-hidden"}`} data-testid={`node-${node.id}`} >
@@ -433,7 +475,7 @@ function NetworkGraph({ y={r + 14} textAnchor="middle" className="sc-node-label" - fill="white" + fill={INK.primary} > {node.name} @@ -441,7 +483,7 @@ function NetworkGraph({ y={r + 26} textAnchor="middle" className="sc-node-sublabel" - fill="#666" + fill={INK.faint} > {node.companies.length} co. @@ -466,8 +508,8 @@ function NetworkGraph({ width={link.label.length * 6.4 + 12} height={16} rx={3} - fill="rgba(14,14,12,0.9)" - stroke="rgba(240,120,0,0.15)" + fill={`rgba(${hexToRgb(SURFACE.base)},0.9)`} + stroke={`rgba(${hexToRgb(BRAND.primary)},0.15)`} strokeWidth={0.5} /> { const pos = layout.positions[node.id]; if (!pos) return null; - const color = STAGE_COLORS[node.stage] || '#F07800'; + const color = STAGE_COLORS[node.stage] || BRAND.primary; const isActive = activeNode === node.id; const isConnected = connectedSet?.has(node.id); const isDimmed = connectedSet && !isConnected; @@ -615,8 +657,19 @@ function FlowView({ { e.stopPropagation(); onSelectNode(isActive ? null : node.id); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + onSelectNode(isActive ? null : node.id); + } + }} data-testid={`flow-node-${node.id}`} > @@ -633,7 +686,7 @@ function FlowView({ x={pos.x + NODE_W + 6} y={pos.y + pos.h / 2 + 3} className="sc-node-label" - fill={isActive || isConnected ? '#fff' : '#aaa'} + fill={isActive || isConnected ? INK.primary : INK.muted} style={{ pointerEvents: 'none' }} > {node.name} @@ -658,7 +711,7 @@ function DetailPanel({ onNavigate: (path: string) => void; onSelectNode: (id: string) => void; }) { - const stageColor = STAGE_COLORS[node.stage] || '#F07800'; + const stageColor = STAGE_COLORS[node.stage] || BRAND.primary; const Icon = ICON_MAP[node.icon] || Zap; const upstreamLinks = supplyLinks.filter((l) => l.target === node.id); @@ -679,31 +732,31 @@ function DetailPanel({
- {node.name} - + {node.name} + {STAGE_LABELS.find((s) => s.id === node.stage)?.name}
-
{node.keyMetric && ( -
+
{node.keyMetric.label}: {node.keyMetric.value}
)}
-

{node.description}

+

{node.description}

{upstreamNodes.length > 0 && (
-
RECEIVES FROM
+
RECEIVES FROM
{upstreamNodes.map((u, i) => ( {u.name} - {u.label && ({u.label})} + {u.label && ({u.label})} ))}
@@ -721,7 +774,7 @@ function DetailPanel({ )} {downstreamNodes.length > 0 && (
-
FEEDS INTO
+
FEEDS INTO
{downstreamNodes.map((d, i) => ( {d.name} - {d.label && ({d.label})} + {d.label && ({d.label})} ))}
@@ -753,10 +806,10 @@ function DetailPanel({ const price = stock?.price; const hasLiveChg = typeof chg === "number" && Number.isFinite(chg); const chgColor = !hasLiveChg - ? "text-[#555]" + ? "text-ink-faint" : chg! >= 0 - ? "text-[#22C55E]" - : "text-[#EF4444]"; + ? "text-positive" + : "text-negative"; return (
onNavigate(`/stock/${c.ticker}`)} data-testid={`company-${c.ticker}`} > - {c.ticker} - {c.name} - {price ? `$${price.toFixed(2)}` : "--"} + {c.ticker} + {c.name} + {price ? `$${price.toFixed(2)}` : "--"} {hasLiveChg ? `${chg! >= 0 ? "+" : ""}${chg!.toFixed(2)}%` : "--"} {stock?.stale && ( @@ -777,7 +830,7 @@ function DetailPanel({ onClick={(e) => e.stopPropagation()} data-testid={`stale-indicator-${c.ticker}`} > - + @@ -794,7 +847,7 @@ function DetailPanel({ ); } -export default function SupplyChain() { +export default function SupplyChain({ embedded = false }: { embedded?: boolean; params?: unknown }) { const [, navigate] = useLocation(); const [activeNode, setActiveNode] = useState(null); const [viewMode, setViewMode] = useState<"network" | "flow">(() => { @@ -809,7 +862,7 @@ export default function SupplyChain() { }; const [entrancePhase, setEntrancePhase] = useState(0); - const { data: apiData } = useQuery<{ stages: StageApiData[] }>({ + const { data: apiData, isError, refetch, dataUpdatedAt } = useQuery<{ stages: StageApiData[] }>({ queryKey: ["/api/supply-chain"], refetchInterval: 5 * 60 * 1000, }); @@ -852,22 +905,37 @@ export default function SupplyChain() { const activeNodeData = activeNode ? supplyNodes.find((n) => n.id === activeNode) : null; return ( -
setActiveNode(null)}> + // Embedded (The Stack flow view): the host owns page scroll/padding. +
setActiveNode(null)}>
- SUPPLY CHAIN + SUPPLY CHAIN | - AI Power Infrastructure + AI Power Infrastructure
- NODES - {supplyNodes.length} + NODES + {supplyNodes.length} | - CONNECTIONS - {supplyLinks.length} + CONNECTIONS + {supplyLinks.length} | - SECURITIES - {totalCompanies} + SECURITIES + {totalCompanies} + | + {isError ? ( + + ) : ( + + )} |
e.stopPropagation()}>
{activeNodeData && ( diff --git a/client/src/pages/TheStack.tsx b/client/src/pages/TheStack.tsx index 8b77a31..c3e212a 100644 --- a/client/src/pages/TheStack.tsx +++ b/client/src/pages/TheStack.tsx @@ -1,5 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; +import { Profiler, Suspense, lazy, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMeasuredWidth } from "@/lib/use-measured-width"; + +// Supply-chain flow view (consolidation): lazy so the d3 sim only loads +// when the flow tab is opened. +const SupplyChainFlow = lazy(() => import("@/pages/SupplyChain")); import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -9,8 +14,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { - LineChart, - Line, ResponsiveContainer, ScatterChart, Scatter, @@ -18,20 +21,37 @@ import { YAxis, CartesianGrid, Tooltip, - Legend, } from "recharts"; -import { Cpu, Server, Zap, TrendingUp, TrendingDown, Info, Clock } from "lucide-react"; +import { Cpu, Server, Zap, TrendingUp, TrendingDown, Info, Clock, ChevronDown, ChevronRight, ArrowUpDown } from "lucide-react"; +import { AsOf, ErrorState } from "@/components/Freshness"; +import { BRAND, CATEGORY_COLORS, CHART_CHROME, INK, SEMANTIC } from "@/lib/tokens"; +import { axisProps, gridProps } from "@/lib/chart-theme"; +import { sparklineDomain } from "@/lib/gpu-series"; +import { + buildHeatmapInput, + heatColor, + heatTextColor, + layoutHeatmap, + marketCapOf, + pctFromSparkline, + sortTableRows, + windowDirection, + type TableSortKey, +} from "@/lib/stack-transforms"; interface StockData { ticker: string; name: string; price: number; - change: number; - changePercent: number; + change: number | null; + changePercent: number | null; pe: number | null; revenueGrowth: number | null; sparkline?: number[]; marketCapDisplay?: string; + marketCap?: number | null; + marketState?: string | null; + previousClose?: number | null; powerMW?: number; vs_sp500?: number; stale?: boolean; @@ -42,6 +62,12 @@ interface CorrelationPoint { ccj: number; } +const LAYER_KEYS = [ + "compute", "nuclear", "uranium", "powerHardware", "utilities", "dataCenters", + "construction", "rawMaterialsMining", "rawMaterialsNatGas", "renewableGeneration", + "transmissionGrid", "cryptoAIDC", "etfsBenchmarks", +] as const; + interface StackData { compute: StockData[]; nuclear: StockData[]; @@ -61,24 +87,100 @@ interface StackData { cegCorrelationCoeff: number; } -function Sparkline({ data, color }: { data: number[] | undefined; color: string }) { - if (!data || data.length === 0) return
; - const chartData = data.map((v, i) => ({ i, v })); +/** + * Honest sparkline (Lake 3A), raw SVG - no Recharts instance per card. + * - y-domain = [windowLow, windowHigh] of the sparkline's own window with + * 10% padding. Never zero-based, never global. + * - Color = net direction of the window itself, not today's quote change. + * - Faint reference line at prior close (1D) / window open (5D, 1M) so shape + * reads as above/below the baseline. Clamped to the edge with a marker + * when the whole window sits beyond it. + * - Defined empty state - no decorative flat lines for missing data. + */ +function Sparkline({ data, refValue, refLabel, height = 40 }: { data: number[] | undefined; refValue: number | null; refLabel: string; height?: number }) { + const W = 220; // viewBox units; SVG stretches to container width + const H = height; + if (!data || data.length === 0) { + return ( +
+ no intraday data +
+ ); + } + const values = data.filter((v) => Number.isFinite(v)); + const geom = sparklineDomain(values); + if (!geom) return
; + const [d0, d1] = geom.domain; + const dir = windowDirection(values); + const color = dir === "up" ? SEMANTIC.positiveDeep : dir === "down" ? SEMANTIC.negativeDeep : INK.muted; + const x = (i: number) => (values.length === 1 ? W / 2 : (i / (values.length - 1)) * W); + const y = (v: number) => H - 2 - ((v - d0) / (d1 - d0)) * (H - 4); + const refInside = refValue !== null && refValue >= d0 && refValue <= d1; + const refY = refValue === null ? null : refInside ? y(refValue) : refValue > d1 ? 2 : H - 2; + return ( - - - - - + + {refY !== null && ( + + {refLabel}{refInside ? "" : refValue! > d1 ? " (above window)" : " (below window)"} + + )} + {values.length === 1 ? ( + + ) : ( + `${x(i)},${y(v)}`).join(" ")} + fill="none" + stroke={color} + strokeWidth={1.5} + strokeLinejoin="round" + strokeLinecap="round" + vectorEffect="non-scaling-stroke" + /> + )} + ); } +/** Reference value + label for the sparkline baseline by timeframe. */ +function sparkRef(stock: StockData, timeframe: Timeframe): { value: number | null; label: string } { + if (timeframe === "1D") { + const prev = + stock.previousClose ?? + (typeof stock.change === "number" && Number.isFinite(stock.price) ? stock.price - stock.change : null); + return { value: prev, label: `prior close ${prev !== null ? `$${prev.toFixed(2)}` : ""}` }; + } + const first = stock.sparkline?.find((v) => Number.isFinite(v)) ?? null; + return { value: first, label: `window open ${first !== null ? `$${first.toFixed(2)}` : ""}` }; +} + function StaleBadge({ ticker }: { ticker: string }) { return ( @@ -92,15 +194,71 @@ function StaleBadge({ ticker }: { ticker: string }) { ); } -function StockCard({ stock, showPower, showVsSP500 }: { stock: StockData; showPower?: boolean; showVsSP500?: boolean }) { +function marketStateLabel(state: string): string { + if (state.startsWith("PRE")) return "pre-market"; + if (state.startsWith("POST")) return "after-hours"; + if (state === "CLOSED") return "market closed"; + return "live"; +} + +/** + * Per-ticker market-state marker, shown ONLY when a ticker's state differs + * from the page majority - the majority state renders once in the header + * instead of repeating on every row. + */ +function MarketStateBadge({ state, majority }: { state: string | null | undefined; majority: string | null }) { + if (!state || state === majority) return null; + const label = state.startsWith("PRE") ? "pre" : state.startsWith("POST") ? "post" : state === "REGULAR" ? "live" : "closed"; + return ( + + {label} + + ); +} + +/** Most common marketState across all tickers, for the page-level chip. */ +function majorityMarketState(data: StackData | undefined, layerKeys: readonly string[]): string | null { + if (!data) return null; + const counts = new Map(); + for (const k of layerKeys) { + for (const s of ((data as any)[k] as StockData[] | undefined) ?? []) { + if (s.marketState) counts.set(s.marketState, (counts.get(s.marketState) ?? 0) + 1); + } + } + let best: string | null = null; + let n = 0; + counts.forEach((v, k) => { + if (v > n) { best = k; n = v; } + }); + return best; +} + +/** P/E cell contents with defined states: N/A and negative are explicit. */ +function peCell(pe: number | null): { text: string; className: string; title: string } { + if (pe === null || !Number.isFinite(pe)) return { text: "—", className: "text-muted-foreground/50", title: "No P/E: unprofitable or not reported" }; + if (pe < 0) return { text: pe.toFixed(1), className: "text-negative", title: "Negative P/E: trailing earnings are negative" }; + return { text: pe.toFixed(1), className: "text-foreground", title: "Trailing P/E" }; +} + +function revGrowthCell(rg: number | null): { text: string; className: string; title: string } { + if (rg === null || !Number.isFinite(rg)) return { text: "—", className: "text-muted-foreground/50", title: "Revenue growth not reported" }; + const cls = rg > 0 ? "text-positive" : rg < 0 ? "text-negative" : "text-muted-foreground"; + return { text: `${rg > 0 ? "+" : ""}${rg.toFixed(1)}%`, className: cls, title: "Revenue growth YoY" }; +} + +function StockCard({ stock, timeframe, majorityState }: { stock: StockData; timeframe: Timeframe; majorityState: string | null }) { if (!stock || stock.price == null) return null; const isStale = stock.stale || stock.changePercent == null; - const isUp = !isStale && stock.changePercent >= 0; - const isDown = !isStale && stock.changePercent < -2; + const isUp = !isStale && (stock.changePercent as number) >= 0; + const isDown = !isStale && (stock.changePercent as number) < -2; + const pe = peCell(stock.pe); + const rg = revGrowthCell(stock.revenueGrowth); + const ref = sparkRef(stock, timeframe); + const liveCapB = marketCapOf(stock); return (
@@ -110,59 +268,50 @@ function StockCard({ stock, showPower, showVsSP500 }: { stock: StockData; showPo {isStale ? ( ) : ( - - {isUp ? "+" : ""}{stock.changePercent.toFixed(2)}% + + {isUp ? "+" : ""}{(stock.changePercent as number).toFixed(2)}% )} +

{stock.name}

- {stock.marketCapDisplay && ( -

{stock.marketCapDisplay} mkt cap

- )} +

+ {liveCapB !== null ? `$${fmtCapB(liveCapB)}` : stock.marketCapDisplay ?? "—"} mkt cap +

${stock.price.toFixed(2)}

-

- {isStale ? "--" : `${isUp ? "+" : ""}${stock.change.toFixed(2)}`} +

+ {isStale || stock.change === null ? "—" : `${isUp ? "+" : ""}${stock.change.toFixed(2)}`}

- +

P/E Ratio

-

{stock.pe ? stock.pe.toFixed(1) : "N/A"}

+

{pe.text}

Rev Growth YoY

-

0 ? "text-green-400" : stock.revenueGrowth ? "text-red-400" : "text-muted-foreground"}`}> - {stock.revenueGrowth ? `${stock.revenueGrowth > 0 ? "+" : ""}${stock.revenueGrowth.toFixed(1)}%` : "N/A"} -

+

{rg.text}

- {showPower && stock.powerMW && ( -
-

Power / Facility

-

{stock.powerMW} MW avg

-
- )} - {showVsSP500 && stock.vs_sp500 !== undefined && ( -
-

vs S&P 500 (1Y)

-

0 ? "text-green-400" : "text-red-400"}`}> - {stock.vs_sp500 > 0 ? : } - {stock.vs_sp500 > 0 ? "+" : ""}{stock.vs_sp500.toFixed(1)}% -

-
- )}
); } +/** $B number -> "3.2T" / "850B" / "500M" style display. */ +function fmtCapB(capB: number): string { + if (capB >= 1000) return `${(capB / 1000).toFixed(1)}T`; + if (capB >= 1) return `${capB.toFixed(0)}B`; + return `${Math.round(capB * 1000)}M`; +} + function StockCardSkeleton() { return ( @@ -227,6 +376,28 @@ function computeRegression(points: { uranium: number; ccj: number }[]) { type Timeframe = "1D" | "5D" | "1M"; type SortBy = "change" | "marketcap" | "alpha"; +/** + * ?perf=1 harness: accumulates React commit durations for this page into + * data-stack-render-ms on , so headless Chrome can read the number + * via --dump-dom. Used for the Lake 3 before/after perf audit. + */ +function PerfProfiler({ children }: { children: ReactNode }) { + const enabled = typeof window !== "undefined" && window.location.search.includes("perf=1"); + if (!enabled) return <>{children}; + return ( + { + const w = window as unknown as { __stackRenderMs?: number }; + w.__stackRenderMs = (w.__stackRenderMs ?? 0) + actualDuration; + document.documentElement.dataset.stackRenderMs = String(Math.round(w.__stackRenderMs)); + }} + > + {children} + + ); +} + function sortStocks(stocks: StockData[], sortBy: SortBy): StockData[] { if (!stocks) return []; const arr = [...stocks]; @@ -236,26 +407,59 @@ function sortStocks(stocks: StockData[], sortBy: SortBy): StockData[] { return bv - av; }); if (sortBy === "alpha") return arr.sort((a, b) => a.ticker.localeCompare(b.ticker)); - if (sortBy === "marketcap") return arr.sort((a, b) => { - const parseM = (s?: string) => { - if (!s) return 0; - const n = parseFloat(s); - if (s.includes("T")) return n * 1000; - return n; - }; - return parseM(b.marketCapDisplay) - parseM(a.marketCapDisplay); - }); + // marketCapOf fixes the old parseM bug where $50M sorted equal to $50B + if (sortBy === "marketcap") return arr.sort((a, b) => (marketCapOf(b) ?? -1) - (marketCapOf(a) ?? -1)); return arr; } +type ViewMode = "cards" | "table" | "heatmap" | "flow"; +const VIEW_LS_KEY = "gridtilt.stack.view"; +const VIEW_MODES: ViewMode[] = ["cards", "table", "heatmap", "flow"]; + +function readStoredView(): ViewMode { + // URL param wins (the /supply-chain redirect lands on /stack?view=flow), + // then the persisted preference. + try { + const q = new URLSearchParams(window.location.search).get("view"); + if (q && (VIEW_MODES as string[]).includes(q)) return q as ViewMode; + const v = window.localStorage.getItem(VIEW_LS_KEY); + return v && (VIEW_MODES as string[]).includes(v) ? (v as ViewMode) : "cards"; + } catch { + return "cards"; + } +} + +const fetchStack = (tf: string) => () => fetch(`/api/stack?timeframe=${tf}`).then((r) => r.json()); + export default function TheStack() { const [timeframe, setTimeframe] = useState("1D"); const [sortBy, setSortBy] = useState("change"); + const [view, setView] = useState(readStoredView); + useEffect(() => { + try { + window.localStorage.setItem(VIEW_LS_KEY, view); + } catch {} + }, [view]); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch, dataUpdatedAt } = useQuery({ queryKey: ["/api/stack", timeframe], - queryFn: () => fetch(`/api/stack?timeframe=${timeframe}`).then((r) => r.json()), + queryFn: fetchStack(timeframe), + refetchInterval: 900000, + }); + + // Table view shows 1D/5D/1M side by side; the extra windows fetch lazily + // (same endpoint the timeframe toggle already hits; server caches 10 min). + const { data: data5D } = useQuery({ + queryKey: ["/api/stack", "5D"], + queryFn: fetchStack("5D"), + refetchInterval: 900000, + enabled: view === "table", + }); + const { data: data1M } = useQuery({ + queryKey: ["/api/stack", "1M"], + queryFn: fetchStack("1M"), refetchInterval: 900000, + enabled: view === "table", }); const regression = useMemo( @@ -263,12 +467,17 @@ export default function TheStack() { [data?.correlation] ); + const majorityState = useMemo( + () => majorityMarketState(data, LAYER_KEYS), + [data], + ); + const layerConfig = [ { key: "compute", title: "Compute Layer", icon: Cpu, - color: "#94a3b8", + color: CATEGORY_COLORS.compute, description: "AI chips, hyperscalers, and the foundries powering model training.", tooltip: "NVIDIA's H100/B200 GPUs power virtually every major AI training cluster. TSMC manufactures all advanced AI chips. Hyperscalers (MSFT, GOOGL, META, AMZN) are both the largest compute consumers and primary drivers of data center power demand.", }, @@ -276,7 +485,7 @@ export default function TheStack() { key: "nuclear", title: "Nuclear Power", icon: Zap, - color: "#F0A500", + color: CATEGORY_COLORS.nuclear, description: "Nuclear operators, SMR developers, and advanced reactor companies.", tooltip: "AI requires uninterruptible clean baseload. Microsoft restarted Three Mile Island. Amazon co-located with Talen's Susquehanna plant. Oklo has a 14 GW DC customer pipeline. BWXT is the sole US naval reactor manufacturer.", }, @@ -284,7 +493,7 @@ export default function TheStack() { key: "uranium", title: "Uranium & Fuel Cycle", icon: Zap, - color: "#fb923c", + color: CATEGORY_COLORS.uranium, description: "Uranium miners and fuel cycle companies supplying the nuclear renaissance.", tooltip: "Uranium spot ~$92/lb (Mar 2026). Cameco is the largest public miner with direct spot beta. NexGen's Rook I is the highest-grade undeveloped uranium deposit. Centrus is the only US-licensed HALEU producer.", }, @@ -292,7 +501,7 @@ export default function TheStack() { key: "powerHardware", title: "Power Hardware", icon: Server, - color: "#F0A500", + color: CATEGORY_COLORS.power, description: "Transformers, switchgear, cooling, and electrical equipment.", tooltip: "GE Vernova's turbine order book leads DC buildout pace. Eaton is at max switchgear/transformer capacity. Vertiv is the fastest-growing power/cooling infrastructure company. Transformer shortages remain the primary bottleneck on DC energization.", }, @@ -300,7 +509,7 @@ export default function TheStack() { key: "utilities", title: "Utilities", icon: Zap, - color: "#34d399", + color: CATEGORY_COLORS.utilities, description: "Utilities signing long-term power agreements with hyperscalers.", tooltip: "Dominion serves Northern Virginia (70% of global internet traffic). NextEra signed a 2.5 GW deal with Meta. Southern Company's Georgia territory is the center of Southeast DC growth. Regulated utilities benefit from structurally rising electricity demand.", }, @@ -308,7 +517,7 @@ export default function TheStack() { key: "dataCenters", title: "Data Centers", icon: Server, - color: "#a855f7", + color: CATEGORY_COLORS.datacenters, description: "REITs and colocation operators. Direct proxies for AI capacity buildout.", tooltip: "Equinix operates 273 data centers across 77 markets. Digital Realty has 300+ facilities globally. IREN is pivoting from Bitcoin mining to GPU-as-a-Service. Power contracts and land-bank are the critical metrics.", }, @@ -316,7 +525,7 @@ export default function TheStack() { key: "construction", title: "Construction & EPC", icon: Server, - color: "#f472b6", + color: CATEGORY_COLORS.construction, description: "Electrical contractors and engineers building grid connections for AI campuses.", tooltip: "Quanta is the largest electrical utility contractor in North America, building transmission lines and substations for DC campuses. EMCOR has a record $4.3B backlog. Sterling Infrastructure has 125% YoY DC revenue growth.", }, @@ -324,7 +533,7 @@ export default function TheStack() { key: "rawMaterialsMining", title: "Raw Materials - Mining & Metals", icon: Server, - color: "#d97706", + color: INK.secondary, // periphery tier: gray = supporting layer, hues are reserved for the 10 thesis layers description: "Copper, steel, and rare earth producers supplying data center and grid buildout.", tooltip: "Copper is the essential conductor in every transformer, busbar, and cable connecting grid to rack. Steel is the structural backbone of data center campuses. Rare earths power wind turbines and EV motors in the energy transition.", }, @@ -332,7 +541,7 @@ export default function TheStack() { key: "rawMaterialsNatGas", title: "Raw Materials - Natural Gas", icon: Zap, - color: "#0ea5e9", + color: CATEGORY_COLORS.gas, description: "Natural gas producers fueling bridge power generation for data centers.", tooltip: "Gas-fired generation is the bridge fuel while nuclear and renewables scale. Appalachian and Haynesville producers benefit from rising gas demand as hyperscalers seek reliable, dispatchable power generation capacity.", }, @@ -340,7 +549,7 @@ export default function TheStack() { key: "renewableGeneration", title: "Renewable Generation", icon: Zap, - color: "#10b981", + color: CATEGORY_COLORS.renewables, description: "Solar manufacturers and renewable energy companies powering clean data center commitments.", tooltip: "Hyperscalers have committed to 100% renewable energy targets. First Solar is the largest US panel maker. AES has signed multi-GW PPAs with Google and Microsoft. Solar and wind are the fastest-growing power sources for data center operations.", }, @@ -348,7 +557,7 @@ export default function TheStack() { key: "transmissionGrid", title: "Transmission & Grid Hardware", icon: Server, - color: "#8b5cf6", + color: CATEGORY_COLORS.grid, description: "Wire, generators, and grid equipment connecting power to data center campuses.", tooltip: "Every data center requires extensive copper wiring (Encore Wire), backup generators (Generac), and electrical infrastructure. Grid interconnection is the bottleneck for new data center energization timelines.", }, @@ -356,7 +565,7 @@ export default function TheStack() { key: "cryptoAIDC", title: "Crypto/AI DC Operators", icon: Cpu, - color: "#ec4899", + color: INK.secondary, // periphery tier (non-adjacent to mining in display order, always labeled) description: "Bitcoin miners pivoting infrastructure and power contracts toward AI/HPC hosting.", tooltip: "CleanSpark and MARA Holdings are the largest public Bitcoin miners exploring AI/HPC hosting. Their existing power contracts, cooling infrastructure, and facility footprints are directly transferable to GPU-as-a-Service operations.", }, @@ -364,13 +573,14 @@ export default function TheStack() { key: "etfsBenchmarks", title: "ETF Benchmarks", icon: TrendingUp, - color: "#6b7280", + color: INK.muted, // benchmarks are neutral, not a category description: "Sector ETFs for uranium, data centers, grid infrastructure, and utilities.", tooltip: "URA and URNM track uranium mining. DTCR tracks data center/digital infrastructure. GRID tracks smart grid companies. XLU tracks utilities. Compare individual picks against these benchmarks.", }, ]; return ( +
@@ -378,112 +588,164 @@ export default function TheStack() {

The Stack

100+ equities across 13 layers of the AI power supply chain. Intraday prices via Yahoo Finance.

- - Yahoo Finance · Live - +
+ {majorityState && majorityState !== "REGULAR" && ( + + {marketStateLabel(majorityState)} + + )} + + Yahoo Finance{majorityState === "REGULAR" ? " · Live" : ""} + + +
- {/* Timeframe toggle */} + {/* View toggle (persisted per user) */}
- {(["1D", "5D", "1M"] as Timeframe[]).map((tf) => ( + {VIEW_MODES.map((v) => ( ))}
-
+ {/* Timeframe applies to price views, not the supply-chain flow */} + {view !== "flow" && ( + <> +
+
+ {(["1D", "5D", "1M"] as Timeframe[]).map((tf) => ( + + ))} +
+ + )} - {/* Sort toggle */} -
- Sort by -
- {([ - { id: "change", label: "% Change" }, - { id: "marketcap", label: "Mkt Cap" }, - { id: "alpha", label: "Alphabetical" }, - ] as { id: SortBy; label: string }[]).map((opt) => ( - - ))} -
-
+ {/* Sort toggle - cards view only; table sorts by column, heatmap by size */} + {view === "cards" && ( + <> +
+
+ Sort by +
+ {([ + { id: "change", label: "% Change" }, + { id: "marketcap", label: "Mkt Cap" }, + { id: "alpha", label: "Alphabetical" }, + ] as { id: SortBy; label: string }[]).map((opt) => ( + + ))} +
+
+ + )}
- {layerConfig.map((layer) => { - const stocks = (data as any)?.[layer.key] as StockData[] | undefined; - return ( -
-
-
- -
-
-
-

{layer.title}

- - - - - -

{layer.tooltip}

-
-
+ {view === "cards" && + layerConfig.map((layer) => { + const stocks = (data as any)?.[layer.key] as StockData[] | undefined; + return ( + // content-visibility virtualizes off-screen layer sections: + // render/layout/paint are skipped until scrolled near. +
+
+
+ +
+
+
+

{layer.title}

+ + + + + +

{layer.tooltip}

+
+
+
+

{layer.description}

-

{layer.description}

-
-
- {isError ? ( -
- -

Unable to load equities data

-
- ) : isLoading - ? Array(4).fill(null).map((_, i) => ) - : (stocks ?? []).length === 0 ? ( -
-

No equities in this layer

-
- ) : sortStocks(stocks ?? [], sortBy).map((stock) => ( - - ))} +
+ {isError ? ( + refetch()} className="col-span-full" /> + ) : isLoading + ? Array(4).fill(null).map((_, i) => ) + : (stocks ?? []).length === 0 ? ( +
+

No equities in this layer

+
+ ) : sortStocks(stocks ?? [], sortBy).map((stock) => ( + + ))} +
-
- ); - })} + ); + })} + + {view === "table" && ( + refetch()} + /> + )} + + {view === "heatmap" && ( + refetch()} /> + )} + + {view === "flow" && ( + }> + + + )} - {/* Uranium vs CCJ Correlation scatter */} -
+ {/* Uranium vs CCJ Correlation scatter (price views only) */} +
@@ -504,7 +766,7 @@ export default function TheStack() { {data?.correlationCoeff !== undefined && (

CCJ Pearson r

-

{data.correlationCoeff.toFixed(3)}

+

{data.correlationCoeff.toFixed(3)}

{data.correlationCoeff > 0.7 ? "Strong" : data.correlationCoeff > 0.4 ? "Moderate" : "Weak"} correlation

@@ -520,39 +782,38 @@ export default function TheStack() {
- {isLoading ? ( - + {isError ? ( + refetch()} /> + ) : isLoading ? ( + ) : ( <> - + } /> {/* Upper confidence band */} null as any} legendType="none" name="Upper Band" @@ -561,7 +822,7 @@ export default function TheStack() { null as any} legendType="none" name="Lower Band" @@ -570,7 +831,7 @@ export default function TheStack() { null as any} legendType="none" name="OLS Fit" @@ -578,7 +839,7 @@ export default function TheStack() { {/* Raw scatter dots */}
-
+
Weekly observation
-
+
OLS trend line
-
+
±1.5σ channel
@@ -604,15 +865,321 @@ export default function TheStack() {

- CCJ (pure miner) has higher uranium spot beta. Its P&L moves directly with U3O8 pricing. + CCJ (pure miner) has higher uranium spot beta. Its P&L moves directly with U3O8 pricing.

- CEG (nuclear utility) is influenced by electricity contracts and regulated returns. Smoother, less volatile nuclear exposure. + CEG (nuclear utility) is influenced by electricity contracts and regulated returns. Smoother, less volatile nuclear exposure.

+ + ); +} + +// ─── Table view (Lake 3B) ─────────────────────────────────────────────────── + +interface LayerDef { + key: string; + title: string; + icon: typeof Cpu; + color: string; + description: string; + tooltip: string; +} + +/** ticker -> % change over a StackData's sparkline windows. */ +function pctMapOf(sd: StackData | undefined, layerKeys: string[]): Record { + const m: Record = {}; + if (!sd) return m; + for (const k of layerKeys) { + for (const s of ((sd as any)[k] as StockData[] | undefined) ?? []) { + m[s.ticker] = pctFromSparkline(s.sparkline); + } + } + return m; +} + +function pctCell(v: number | null): { text: string; className: string } { + if (v === null || !Number.isFinite(v)) return { text: "—", className: "text-muted-foreground/50" }; + const cls = v > 0 ? "text-positive" : v < 0 ? "text-negative" : "text-muted-foreground"; + return { text: `${v > 0 ? "+" : ""}${v.toFixed(2)}%`, className: cls }; +} + +const TABLE_COLS: Array<{ key: TableSortKey; label: string; align: "left" | "right" }> = [ + { key: "ticker", label: "Ticker", align: "left" }, + { key: "price", label: "Price", align: "right" }, + { key: "d1", label: "1D %", align: "right" }, + { key: "d5", label: "5D %", align: "right" }, + { key: "m1", label: "1M %", align: "right" }, + { key: "mktcap", label: "Mkt Cap", align: "right" }, + { key: "pe", label: "P/E", align: "right" }, + { key: "revGrowth", label: "Rev YoY", align: "right" }, +]; + +function StackTable({ + layers, + data, + data5D, + data1M, + isLoading, + isError, + majorityState, + onRetry, +}: { + layers: LayerDef[]; + data: StackData | undefined; + data5D: StackData | undefined; + data1M: StackData | undefined; + isLoading: boolean; + isError: boolean; + majorityState: string | null; + onRetry?: () => void; +}) { + const [sortKey, setSortKey] = useState("mktcap"); + const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + const [collapsed, setCollapsed] = useState>({}); + const layerKeys = layers.map((l) => l.key); + const pct5 = useMemo(() => pctMapOf(data5D, layerKeys), [data5D]); // eslint-disable-line react-hooks/exhaustive-deps + const pct1m = useMemo(() => pctMapOf(data1M, layerKeys), [data1M]); // eslint-disable-line react-hooks/exhaustive-deps + + const toggleSort = (k: TableSortKey) => { + if (sortKey === k) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { + setSortKey(k); + setSortDir(k === "ticker" ? "asc" : "desc"); + } + }; + + if (isError) { + return ( + + + + ); + } + + return ( + + + + + {TABLE_COLS.map((c) => ( + + ))} + + + {isLoading ? ( + + {Array(12).fill(null).map((_, i) => ( + + + + ))} + + ) : ( + layers.map((layer) => { + const stocks = ((data as any)?.[layer.key] as StockData[] | undefined) ?? []; + const isCollapsed = collapsed[layer.key] ?? false; + const rows = sortTableRows( + stocks.map((s) => ({ + ticker: s.ticker, + price: Number.isFinite(s.price) ? s.price : null, + d1: s.stale ? null : s.changePercent, + d5: pct5[s.ticker] ?? null, + m1: pct1m[s.ticker] ?? null, + mktcap: marketCapOf(s), + pe: s.pe, + revGrowth: s.revenueGrowth, + stock: s, + })), + sortKey, + sortDir, + ); + return ( + + setCollapsed((c) => ({ ...c, [layer.key]: !isCollapsed }))} + data-testid={`stack-group-${layer.key}`} + > + + + {!isCollapsed && + rows.map((r) => { + const d1 = pctCell(r.d1); + const d5 = pctCell(r.d5); + const m1 = pctCell(r.m1); + const pe = peCell(r.pe); + const rg = revGrowthCell(r.revGrowth); + return ( + + + + + + + + + + + ); + })} + + ); + }) + )} +
+ +
+ +
+ + {isCollapsed ? : } + + {layer.title} + {stocks.length} + +
+ + {r.ticker} + {r.stock.name} + {r.stock.stale && } + + + {r.price !== null ? `$${r.price.toFixed(2)}` : "—"}{d1.text}{d5.text}{m1.text}{r.mktcap !== null ? `$${fmtCapB(r.mktcap)}` : "—"}{pe.text}{rg.text}
+

+ 5D / 1M columns compute from each window's own price series; — means the window has no data yet. Click a layer to collapse it. +

+
+ ); +} + +// ─── Heatmap view (Lake 3B) ───────────────────────────────────────────────── + +function StackHeatmap({ + layers, + data, + timeframe, + isLoading, + isError, + onRetry, +}: { + layers: LayerDef[]; + data: StackData | undefined; + timeframe: Timeframe; + isLoading: boolean; + isError: boolean; + onRetry?: () => void; +}) { + const [ref, width] = useMeasuredWidth(); + + const input = useMemo(() => { + const byLayer: Record> = {}; + for (const layer of layers) { + const stocks = ((data as any)?.[layer.key] as StockData[] | undefined) ?? []; + byLayer[layer.key] = stocks.map((s) => ({ + ticker: s.ticker, + name: s.name, + // color = % change over the SELECTED window, from that window's own series + changePercent: s.stale ? null : timeframe === "1D" ? s.changePercent : pctFromSparkline(s.sparkline), + stale: s.stale, + marketCap: s.marketCap, + marketCapDisplay: s.marketCapDisplay, + })); + } + return buildHeatmapInput(layers, byLayer); + }, [layers, data, timeframe]); + + const height = Math.max(440, Math.min(660, Math.round(width * 0.52))); + const { tiles, groups } = useMemo(() => layoutHeatmap(input, width, height), [input, width, height]); + + if (isError) { + return ( + + + + ); + } + + return ( + +
+ + Where the money moved · tile = market cap · color = {timeframe} change + +
+ -{4}% + {[-4, -2, -0.75, 0, 0.75, 2, 4].map((v) => ( + + ))} + +{4}% +
+
+
+ {isLoading || width === 0 ? ( + + ) : tiles.length === 0 ? ( +
No sized equities.
+ ) : ( + <> + {groups.map((g) => ( +
+ {g.title} +
+ ))} + {tiles.map((t) => { + const w = t.x1 - t.x0; + const h = t.y1 - t.y0; + const pct = t.changePercent; + const showPct = w > 52 && h > 34; + const showTicker = w > 34 && h > 16; + return ( +
0 ? "+" : ""}${pct.toFixed(2)}%`} · $${fmtCapB(t.sizeB)} cap`} + data-testid={`heat-tile-${t.ticker}`} + > + {showTicker && ( +
+
+ {t.ticker} +
+ {showPct && ( +
+ {pct === null ? "—" : `${pct > 0 ? "+" : ""}${pct.toFixed(2)}%`} +
+ )} +
+ )} +
+ ); + })} + + )} +
+

+ Grouped by layer. ETF benchmarks excluded (fund AUM is not corporate market cap). + {input.unsized.length > 0 && ` Not sized (no market cap data): ${input.unsized.join(", ")}.`} + {" "}Gray tiles = delayed quote, change unknown. +

+
); } diff --git a/client/src/pages/TheTrade.tsx b/client/src/pages/TheTrade.tsx index 1b73da0..81d91b4 100644 --- a/client/src/pages/TheTrade.tsx +++ b/client/src/pages/TheTrade.tsx @@ -31,6 +31,15 @@ import { ChevronDown, AlertTriangle, } from "lucide-react"; +import { SrChartTable } from "@/components/Freshness"; +import { BORDER, CATEGORY_COLORS, SERIES } from "@/lib/tokens"; +import { + axisProps, + gridProps, + tooltipContentStyle, + tooltipItemStyle, + tooltipLabelStyle, +} from "@/lib/chart-theme"; const BASE_POWER_TWH = 4490; const BASE_YEAR = 2025; @@ -69,23 +78,38 @@ const PRESETS: Record, ScenarioInputs> = { }, }; +// One entry per YEARS entry (2025-2030); each ramp sums to its preset's newCapacityGW. const PRESET_RAMPS: Record, number[]> = { Conservative: [2, 5, 7, 8, 8, 5], Base: [3, 7, 10, 13, 12, 5], Aggressive: [5, 11, 15, 18, 16, 10], }; +// Custom scenarios have no hand-tuned ramp, so the timeline uses a fixed +// S-curve (slow start, accelerate, plateau) scaled to the user's total. +// Weights sum to 1.0 across the 6-year horizon; labeled "assumed build +// ramp" in the chart footnote. +const CUSTOM_RAMP_WEIGHTS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.25]; + const YEARS = ["2025", "2026", "2027", "2028", "2029", "2030"]; +// Segment colors: compute/power from CATEGORY_COLORS; Infrastructure has no +// token category, so it takes series slot 3 (teal, shared with datacenters). +const SEGMENT_COLORS: Record = { + Compute: CATEGORY_COLORS.compute, + Infrastructure: SERIES[2], // series slot 3 + Power: CATEGORY_COLORS.power, +}; + const TOP_COMPANIES = [ - { ticker: "NVDA", name: "NVIDIA Corporation", segment: "Compute", thesisScore: 9.5, rationale: "GPU monopoly, >80% AI accelerator share", color: "#94a3b8" }, - { ticker: "EQIX", name: "Equinix Inc", segment: "Infrastructure", thesisScore: 9.0, rationale: "100% DC revenue, highest power density growth", color: "#a855f7" }, - { ticker: "VRT", name: "Vertiv Holdings", segment: "Infrastructure", thesisScore: 8.8, rationale: "Critical thermal mgmt for every AI data center", color: "#a855f7" }, - { ticker: "CEG", name: "Constellation Energy", segment: "Power", thesisScore: 8.2, rationale: "Largest nuclear utility + first AI baseload PPA", color: "#F0A500" }, - { ticker: "CCJ", name: "Cameco Corporation", segment: "Power", thesisScore: 7.5, rationale: "Pure uranium miner, highest spot price beta", color: "#F0A500" }, - { ticker: "TSM", name: "Taiwan Semiconductor", segment: "Compute", thesisScore: 7.2, rationale: "Manufactures all advanced AI chips", color: "#94a3b8" }, - { ticker: "VST", name: "Vistra Corp", segment: "Power", thesisScore: 7.0, rationale: "Merchant power, direct power price beneficiary", color: "#F0A500" }, - { ticker: "AMD", name: "Advanced Micro Devices",segment: "Compute", thesisScore: 6.0, rationale: "GPU inference competition, DC revenue +122% YoY", color: "#94a3b8" }, + { ticker: "NVDA", name: "NVIDIA Corporation", segment: "Compute", thesisScore: 9.5, rationale: "GPU monopoly, >80% AI accelerator share", color: SEGMENT_COLORS.Compute }, + { ticker: "EQIX", name: "Equinix Inc", segment: "Infrastructure", thesisScore: 9.0, rationale: "100% DC revenue, highest power density growth", color: SEGMENT_COLORS.Infrastructure }, + { ticker: "VRT", name: "Vertiv Holdings", segment: "Infrastructure", thesisScore: 8.8, rationale: "Critical thermal mgmt for every AI data center", color: SEGMENT_COLORS.Infrastructure }, + { ticker: "CEG", name: "Constellation Energy", segment: "Power", thesisScore: 8.2, rationale: "Largest nuclear utility + first AI baseload PPA", color: SEGMENT_COLORS.Power }, + { ticker: "CCJ", name: "Cameco Corporation", segment: "Power", thesisScore: 7.5, rationale: "Pure uranium miner, highest spot price beta", color: SEGMENT_COLORS.Power }, + { ticker: "TSM", name: "Taiwan Semiconductor", segment: "Compute", thesisScore: 7.2, rationale: "Manufactures all advanced AI chips", color: SEGMENT_COLORS.Compute }, + { ticker: "VST", name: "Vistra Corp", segment: "Power", thesisScore: 7.0, rationale: "Merchant power, direct power price beneficiary", color: SEGMENT_COLORS.Power }, + { ticker: "AMD", name: "Advanced Micro Devices",segment: "Compute", thesisScore: 6.0, rationale: "GPU inference competition, DC revenue +122% YoY", color: SEGMENT_COLORS.Compute }, ]; const segmentIcons: Record = { @@ -102,7 +126,7 @@ function NumField({
{label} - {unit && {unit}} + {unit && {unit}}
{ const v = parseFloat(e.target.value); if (!isNaN(v)) onChange(Math.max(min, Math.min(max, v))); }} /> - {hint &&

{hint}

} + {hint &&

{hint}

}
); } -export default function TheTrade() { +/** In-place warning shown instead of any output that depends on the supply mix. */ +function MixWarning({ mixSum, testId }: { mixSum: number; testId: string }) { + return ( +
+ + Mix sums to {mixSum.toFixed(0)}% - adjust to 100% +
+ ); +} + +// `params` absorbs wouter's RouteComponentProps when mounted standalone via +// ; only `embedded` is meaningful. +export default function TheTrade({ embedded = false }: { embedded?: boolean; params?: unknown }) { const [activePreset, setActivePreset] = useState("Base"); const [inputs, setInputs] = useState(PRESETS.Base); const [methodologyOpen, setMethodologyOpen] = useState(false); @@ -140,18 +176,17 @@ export default function TheTrade() { const mixSum = inputs.gasPct + inputs.nuclearPct + inputs.renewablesPct + inputs.gridPurchasePct; const mixValid = Math.abs(mixSum - 100) < 0.5; + // GW added per year, aligned 1:1 with YEARS (index i = YEARS[i]). const ramp: number[] = useMemo(() => { - if (activePreset !== "Custom" && PRESET_RAMPS[activePreset as Exclude]) { - return PRESET_RAMPS[activePreset as Exclude]; + if (activePreset !== "Custom") { + return PRESET_RAMPS[activePreset]; } - const perYear = inputs.newCapacityGW / 5; - return YEARS.slice(1).map(() => perYear); + return CUSTOM_RAMP_WEIGHTS.map((w) => inputs.newCapacityGW * w); }, [activePreset, inputs.newCapacityGW]); const buildoutChart = useMemo(() => { - const fullRamp = activePreset === "Custom" ? [0, ...ramp] : [0, ...ramp]; return YEARS.map((year, i) => { - const gwThisYear = i === 0 ? 0 : (ramp[i - 1] ?? 0); + const gwThisYear = ramp[i] ?? 0; return { year, gas: parseFloat((gwThisYear * inputs.gasPct / 100).toFixed(2)), @@ -214,10 +249,10 @@ export default function TheTrade() { }, [inputs.nuclearPct, inputs.aiCagrPct]); const lptColor = outputs.lptRatio < 0.5 - ? "text-green-400" + ? "text-positive" : outputs.lptRatio < 1.0 - ? "text-yellow-400" - : "text-red-400"; + ? "text-warning" + : "text-negative"; const presetButtons: { key: PresetName; label: string }[] = [ { key: "Conservative", label: "Conservative" }, @@ -225,68 +260,90 @@ export default function TheTrade() { { key: "Aggressive", label: "Aggressive" }, ]; - return ( -
- {/* Header */} -
-
-
-

Scenario Calculator

-

- 50 GW of new AI datacenter capacity is projected by 2030. Model capex, grid interconnect timelines, and power supply mix under different assumptions. -

-
- - - - - Sourced + Defensible - - - -

All defaults trace to named sources: IEA, EIA, DOE, McKinsey, hyperscaler earnings calls. See Methodology panel for citations.

-
-
-
+ const sourcedBadge = ( + + + + + Sourced + Defensible + + + +

All defaults trace to named sources: IEA, EIA, DOE, McKinsey, hyperscaler earnings calls. See Methodology panel for citations.

+
+
+ ); - {/* Preset selector */} -
- Scenario: - {presetButtons.map(({ key, label }) => ( - - ))} - {activePreset === "Custom" && ( - - Custom - - )} + const presetSelector = ( +
+ Scenario: + {presetButtons.map(({ key, label }) => ( + + ))} + {activePreset === "Custom" && ( + + Custom + + )} +
+ ); + + // Embedded mode (Analyze tool, scenario tab): the host page owns the hero, + // so render a slim intro (description + presets) instead of the full header. + const intro = embedded ? ( +
+
+

+ 50 GW of new AI datacenter capacity is projected by 2030. Model capex, grid interconnect timelines, and power supply mix under different assumptions. +

+ {sourcedBadge} +
+ {presetSelector} +
+ ) : ( +
+
+
+

Scenario Calculator

+

+ 50 GW of new AI datacenter capacity is projected by 2030. Model capex, grid interconnect timelines, and power supply mix under different assumptions. +

+ {sourcedBadge}
+ {presetSelector} +
+ ); + + return ( +
+ {/* Header */} + {intro} -
+
{/* ---- LEFT: INPUTS ---- */}
-

Scenario Inputs

+

Scenario Inputs

{/* Infrastructure Buildout */}
- +

Infrastructure Buildout

@@ -324,7 +381,7 @@ export default function TheTrade() {
{inputs.interconnectYears}
-

Avg queue-to-energize lead time

+

Avg queue-to-energize lead time

@@ -333,67 +390,67 @@ export default function TheTrade() {
- +

New Power Supply Mix

- + {mixSum.toFixed(0)}% - {!mixValid && } + {!mixValid && }
-
+
Natural Gas
{ const v = parseFloat(e.target.value); if (!isNaN(v)) setField("gasPct", Math.max(0, Math.min(100, v))); }} />
-
+
Nuclear
{ const v = parseFloat(e.target.value); if (!isNaN(v)) setField("nuclearPct", Math.max(0, Math.min(100, v))); }} />
-
+
Renewables + Storage
{ const v = parseFloat(e.target.value); if (!isNaN(v)) setField("renewablesPct", Math.max(0, Math.min(100, v))); }} />
-
+
Grid Purchases
{ const v = parseFloat(e.target.value); if (!isNaN(v)) setField("gridPurchasePct", Math.max(0, Math.min(100, v))); }} />
@@ -402,10 +459,10 @@ export default function TheTrade() { {/* Mix visual */} {mixValid && (
-
-
-
-
+
+
+
+
)} @@ -413,7 +470,7 @@ export default function TheTrade() { {/* Demand Model */}
- +

AI Demand Model

@@ -446,12 +503,12 @@ export default function TheTrade() {
{[ - { label: "2030 AI Grid Share", value: `${outputs.aiShareIn2030.toFixed(1)}%`, color: "text-[#F0A500]" }, + { label: "2030 AI Grid Share", value: `${outputs.aiShareIn2030.toFixed(1)}%`, color: "text-brand-2" }, { label: "2030 US Demand", value: `${(outputs.demandIn2030 / 1000).toFixed(1)}k TWh`, color: "text-foreground" }, { label: "Annual DC Pace", value: `${(inputs.newCapacityGW / 5).toFixed(1)} GW/yr`, color: "text-foreground" }, ].map((s) => (
-

{s.label}

+

{s.label}

{s.value}

))} @@ -461,41 +518,50 @@ export default function TheTrade() { {/* ---- RIGHT: OUTPUTS ---- */}
-

Scenario Outputs

+

Scenario Outputs

{/* 4 KPI output cards */}
-

Total Capex

-

${outputs.totalCapexB.toFixed(0)}B

-

{inputs.newCapacityGW} GW × ${inputs.capexPerMW}M/MW

+

Total Capex

+

${outputs.totalCapexB.toFixed(0)}B

+

{inputs.newCapacityGW} GW × ${inputs.capexPerMW}M/MW

-

Annual LPTs Needed

+

Annual LPTs Needed

{outputs.annualLPT.toFixed(0)}/yr

-

+

vs. {US_LPT_CAPACITY} domestic · {(outputs.lptRatio * 100).toFixed(0)}% of US capacity

-

Nuclear Build by 2030

-

{outputs.nuclearGW.toFixed(1)} GW

-

{inputs.nuclearPct}% of {inputs.newCapacityGW} GW new supply

+

Nuclear Build by 2030

+ {mixValid ? ( + <> +

{outputs.nuclearGW.toFixed(1)} GW

+

{inputs.nuclearPct}% of {inputs.newCapacityGW} GW new supply

+ + ) : ( + <> +

+

Mix sums to {mixSum.toFixed(0)}% - adjust to 100%

+ + )}
-

Grid Interconnect

+

Grid Interconnect

{inputs.interconnectYears}

-

Avg queue-to-energize lead time

+

Avg queue-to-energize lead time

{/* Buildout timeline chart */}
-

Annual Buildout Timeline

+

Annual Buildout Timeline

@@ -506,53 +572,72 @@ export default function TheTrade() {
- - - - - `${v}GW`} /> - { - if (!active || !payload?.length) return null; - const total = payload.reduce((s: number, p: any) => s + (p.value ?? 0), 0); - return ( -
-

{label}: {total.toFixed(1)} GW added

- {payload.map((p: any, i: number) => ( -

- {p.name}: {p.value?.toFixed(2)} GW -

- ))} -
- ); - }} + {mixValid ? ( + <> + + + + + `${v}GW`} /> + { + if (!active || !payload?.length) return null; + const total = payload.reduce((s: number, p: any) => s + (p.value ?? 0), 0); + return ( +
+

{label}: {total.toFixed(1)} GW added

+ {payload.map((p: any, i: number) => ( +

+ {p.name}: {p.value?.toFixed(2)} GW +

+ ))} +
+ ); + }} + /> + + + + +
+
+ [r.year, r.gas, r.nuclear, r.renewables, r.grid])} /> - - - - -
-
-
- {[ - { color: "bg-red-400/75", label: "Natural Gas" }, - { color: "bg-[#F0A500]", label: "Nuclear" }, - { color: "bg-green-400/75", label: "Renewables" }, - { color: "bg-slate-400/60", label: "Grid Purchases" }, - ].map((l) => ( -
-
- {l.label} +
+ {[ + { color: CATEGORY_COLORS.gas, label: "Natural Gas" }, + { color: CATEGORY_COLORS.nuclear, label: "Nuclear" }, + { color: CATEGORY_COLORS.renewables, label: "Renewables" }, + { color: CATEGORY_COLORS.grid, label: "Grid Purchases" }, + ].map((l) => ( +
+
+ {l.label} +
+ ))}
- ))} -
+ {activePreset === "Custom" && ( +

+ assumed build ramp: fixed S-curve over 2025-2030, scaled to your {inputs.newCapacityGW} GW total +

+ )} + + ) : ( +
+ +
+ )}
{/* Company rankings */}
-

Scenario-Adjusted Positions

+

Scenario-Adjusted Positions

@@ -563,11 +648,17 @@ export default function TheTrade() {
+ {!mixValid ? ( +
+ +
+ ) : ( + <>
- # - Position - Score - Delta + # + Position + Score + Delta
{rankedCompanies.map((company, index) => { const SegIcon = segmentIcons[company.segment] ?? DollarSign; @@ -582,7 +673,7 @@ export default function TheTrade() {
{company.ticker} @@ -596,23 +687,25 @@ export default function TheTrade() { style={{ width: `${(company.adjusted / 10) * 100}%`, backgroundColor: company.color, opacity: 0.8 }} />
- {company.rationale} + {company.rationale}
{company.adjusted.toFixed(1)} - /10 + /10
- 0.05 ? "text-green-400" : "text-muted-foreground/40"}`}> + 0.05 ? "text-positive" : "text-muted-foreground/40"}`}> {company.delta > 0.05 ? `+${company.delta.toFixed(2)}` : "base"}
); })} + + )}
@@ -630,7 +723,7 @@ export default function TheTrade() {
Methodology - Sources, formulas, and key sensitivities + Sources, formulas, and key sensitivities
{/* Sources */}
-

Sources

+

Sources

  • - IEA + IEA Electricity 2025: AI data centers projected at 400-1,000 TWh global consumption by 2026
  • - EIA + EIA Annual Energy Outlook 2025: US baseline consumption ~4,490 TWh (2025E); data centers = 6.4% of US load
  • - McKinsey + McKinsey $5.2T global AI infrastructure investment projection through 2030 (2024 Global Technology Report)
  • - DOE + DOE Transformer Supply Chain Study 2023: US domestic large power transformer (LPT) manufacturing capacity ~60 units/year
  • - Hyperscalers + Hyperscalers 2024-2025 earnings calls: all-in capex guidance of $7-12M/MW for hyperscale AI data centers (AWS, Google, Microsoft, Meta)
@@ -668,22 +761,22 @@ export default function TheTrade() { {/* Formulas */}
-

Formulas

+

Formulas

-

Total Capex ($B)

+

Total Capex ($B)

GW × 1,000 (MW/GW) × Capex ($/MW in millions) / 1,000 = GW × Capex/MW. Example: 50 GW × $9M/MW = $450B.

-

Annual LPT Demand

+

Annual LPT Demand

Total GW × LPTs per GW / 5 years. Default 4 LPTs/GW sourced from DOE interconnection studies. Compare against {US_LPT_CAPACITY} units/year domestic manufacturing capacity.

-

Generation Breakdown

+

Generation Breakdown

New Capacity (GW) × Supply Mix %. Annual ramp × mix applied year-by-year in the chart.

-

AI Demand (TWh)

+

AI Demand (TWh)

Base Grid (4,490 TWh) × AI share (4.5% 2025E) × (1 + CAGR)^years × PUE. Compounded annually from 2025 baseline.

@@ -691,7 +784,7 @@ export default function TheTrade() { {/* Sensitivities + Disclaimer */}
-

Key Sensitivities

+

Key Sensitivities

Nuclear % is the highest-leverage input. Each 10pp increase re-rates CEG, CCJ, and VST scores.

Capex per MW drives total capital deployed. At 50 GW, the $7M-$12M range = $150B swing.

diff --git a/client/src/pages/TiltOverview.tsx b/client/src/pages/TiltOverview.tsx index 2585578..2f32865 100644 --- a/client/src/pages/TiltOverview.tsx +++ b/client/src/pages/TiltOverview.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { ComposedChart, Area, @@ -20,8 +20,15 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Zap, TrendingUp, Activity, AlertTriangle, Info, ArrowUp, ArrowDown, Calendar, ChevronRight, ExternalLink, Cpu, BarChart3, Calculator, Layers, Map, Link2, CalendarDays } from "lucide-react"; +import { Zap, TrendingUp, Activity, AlertTriangle, Info, ArrowUp, ArrowDown, Calendar, ChevronRight, ExternalLink, Cpu, Calculator, Layers, Map, Link2, CalendarDays, LineChart } from "lucide-react"; import { EmailCapture, ScrollTriggeredBanner } from "@/components/EmailCapture"; +import { AsOf, ErrorState, SrChartTable } from "@/components/Freshness"; +import { + BRAND, CATEGORY_COLORS as TOKEN_CATEGORY_COLORS, CHART_CHROME, DATA_QUALITY, INK, SEMANTIC, SERIES, +} from "@/lib/tokens"; +import { axisProps, gridProps, timeTicks, tooltipContentStyle, tooltipItemStyle, tooltipLabelStyle } from "@/lib/chart-theme"; +import { fmtDate } from "@/lib/gpu-series"; +import { heatColor, heatTextColor } from "@/lib/stack-transforms"; import stackPreview from "@assets/previews/stack.svg"; import supplyChainPreview from "@assets/previews/supply-chain.svg"; @@ -30,6 +37,11 @@ import catalystPreview from "@assets/previews/catalyst.svg"; import portfolioPreview from "@assets/previews/portfolio.svg"; import calculatorPreview from "@assets/previews/calculator.svg"; +/** Token hex + alpha -> rgba() string, so composed tints stay on token values. */ +function alpha(hex: string, a: number): string { + return `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${a})`; +} + const electricityData = [ { year: "2010", demand: 3879, dcDemand: 140, projected: null, dcProjected: null }, { year: "2011", demand: 3883, dcDemand: 150, projected: null, dcProjected: null }, @@ -45,7 +57,10 @@ const electricityData = [ { year: "2021", demand: 3930, dcDemand: 230, projected: null, dcProjected: null }, { year: "2022", demand: 4050, dcDemand: 260, projected: null, dcProjected: null }, { year: "2023", demand: 4195, dcDemand: 310, projected: null, dcProjected: null }, - { year: "2024", demand: 4380, dcDemand: 420, projected: 4380, dcProjected: 420 }, + { year: "2024", demand: 4380, dcDemand: 420, projected: null, dcProjected: null }, + // 2025 carries both series: the single shared point where the projection + // line takes over from actuals (double-encoding 2024 too drew the amber + // projection on top of a full year of real data). { year: "2025", demand: 4490, dcDemand: 576, projected: 4490, dcProjected: 576 }, { year: "2026", demand: null, dcDemand: null, projected: 4890, dcProjected: 800 }, { year: "2027", demand: null, dcDemand: null, projected: 5180, dcProjected: 1050 }, @@ -55,9 +70,9 @@ const electricityData = [ ]; const annotations = [ - { year: "2020", label: "COVID drop", color: "rgba(239,68,68,0.4)" }, - { year: "2022", label: "IRA signed + ChatGPT", color: "rgba(240,165,0,0.4)" }, - { year: "2024", label: "TMI restart + SMR deal", color: "rgba(240,165,0,0.5)" }, + { year: "2020", label: "COVID drop", color: alpha(SEMANTIC.negativeDeep, 0.4) }, + { year: "2022", label: "IRA signed + ChatGPT", color: alpha(BRAND.secondary, 0.4) }, + { year: "2024", label: "TMI restart + SMR deal", color: alpha(BRAND.secondary, 0.5) }, ]; interface KpiData { @@ -93,13 +108,15 @@ interface SectorPulseItem { avgChange: number; } +// Catalyst categories (brand-accent family, distinct from the sector +// palette in tokens.ts CATEGORY_COLORS). const CATEGORY_COLORS: Record = { - Earnings: "#F0A500", - Regulatory: "#F0A500", - Policy: "#D4A843", - Market: "#F07800", - Infrastructure: "#C87533", - Industry: "#F07800", + Earnings: BRAND.secondary, + Regulatory: BRAND.secondary, + Policy: DATA_QUALITY.estimateFlag, + Market: BRAND.primary, + Infrastructure: TOKEN_CATEGORY_COLORS.construction, + Industry: BRAND.primary, }; function daysUntil(dateStr: string): number { @@ -117,10 +134,10 @@ function formatDateShort(dateStr: string): string { } const SECTOR_DEMAND = [ - { sector: "Residential", twh: 1658, yoy: 2.1, color: "#6b7280" }, - { sector: "Commercial", twh: 1569, yoy: 2.4, color: "#8b5cf6" }, - { sector: "Industrial", twh: 975, yoy: -3.2, color: "#94a3b8" }, - { sector: "Data Centers", twh: 288, yoy: 33.3, color: "#a855f7" }, + { sector: "Residential", twh: 1658, yoy: 2.1, color: INK.muted }, + { sector: "Commercial", twh: 1569, yoy: 2.4, color: SERIES[3] }, // series slot 4 + { sector: "Industrial", twh: 975, yoy: -3.2, color: INK.secondary }, + { sector: "Data Centers", twh: 288, yoy: 33.3, color: TOKEN_CATEGORY_COLORS.datacenters }, ]; const CustomTooltip = ({ active, payload, label }: any) => { @@ -136,7 +153,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {

) ))} - {ann &&

* {ann.label}

} + {ann &&

* {ann.label}

}
); } @@ -148,7 +165,7 @@ function ConstituentRow({ label, value }: { label: string; value: number }) { return (
{label} - + {isUp ? "+" : ""}{value.toFixed(2)}%
@@ -163,7 +180,7 @@ function PerfRow({ label, perf, base }: { label: string; perf: number; base?: st {label}
{base && {base}} - + {isUp ? "+" : ""}{pct}%
@@ -200,16 +217,16 @@ function KpiCard({ value: "text-foreground", }, amber: { - icon: "text-[#F0A500]", - bg: "bg-[#F0A500]/10", - border: "border-[#F0A500]/25", - value: "text-[#F0A500]", + icon: "text-brand-2", + bg: "bg-brand-2/10", + border: "border-brand-2/25", + value: "text-brand-2", }, red: { - icon: "text-orange-400", - bg: "bg-orange-500/10", - border: "border-orange-500/25", - value: "text-orange-400", + icon: "text-negative", + bg: "bg-negative-deep/10", + border: "border-negative-deep/25", + value: "text-negative", }, }; const c = colorMap[color]; @@ -265,8 +282,8 @@ function TiltStatusBar({ aiPower, gridStress, npi }: { aiPower: number | null; g const isElevated = aiPower > 78 && gridStress > 70 && npi > 130; const isEasing = aiPower < 68 && gridStress < 55; const status = isElevated ? "elevated" : isEasing ? "easing" : "tracking baseline"; - const statusColor = isElevated ? "#F07800" : isEasing ? "#6b7280" : "#F0A500"; - const statusBg = isElevated ? "bg-[#F07800]/10 border-[#F07800]/25" : isEasing ? "bg-muted/20 border-card-border" : "bg-[#F0A500]/10 border-[#F0A500]/20"; + const statusColor = isElevated ? BRAND.primary : isEasing ? INK.muted : BRAND.secondary; + const statusBg = isElevated ? "bg-brand/10 border-brand/25" : isEasing ? "bg-muted/20 border-card-border" : "bg-brand-2/10 border-brand-2/20"; const description = isElevated ? `All three gauges elevated. The market is pricing the AI-power complex aggressively today; grid-equity momentum at ${gridStress.toFixed(0)}/100. These read market positioning, not physical grid conditions.` : isEasing @@ -285,7 +302,7 @@ function TiltStatusBar({ aiPower, gridStress, npi }: { aiPower: number | null; g {/* Status + numbers row on mobile, status only on desktop */}
-

Tilt Status

+

Tilt Status

{status} @@ -295,7 +312,7 @@ function TiltStatusBar({ aiPower, gridStress, npi }: { aiPower: number | null; g
{numbers.map(({ label, val }) => (
-

{label}

+

{label}

{val.toFixed(0)}

))} @@ -312,7 +329,7 @@ function TiltStatusBar({ aiPower, gridStress, npi }: { aiPower: number | null; g
{numbers.map(({ label, val }) => (
-

{label}

+

{label}

{val.toFixed(0)}

))} @@ -322,15 +339,17 @@ function TiltStatusBar({ aiPower, gridStress, npi }: { aiPower: number | null; g ); } +// Mover/sector tags draw from the stable category palette so each sector +// keeps one color across the whole app. const SECTOR_COLORS: Record = { - compute: "#94a3b8", - nuclear: "#F0A500", - uranium: "#fb923c", - powerHardware: "#F0A500", - utilities: "#34d399", - dataCenters: "#a855f7", - construction: "#f472b6", - etfsBenchmarks: "#6b7280", + compute: TOKEN_CATEGORY_COLORS.compute, + nuclear: TOKEN_CATEGORY_COLORS.nuclear, + uranium: TOKEN_CATEGORY_COLORS.uranium, + powerHardware: TOKEN_CATEGORY_COLORS.power, + utilities: TOKEN_CATEGORY_COLORS.utilities, + dataCenters: TOKEN_CATEGORY_COLORS.datacenters, + construction: TOKEN_CATEGORY_COLORS.construction, + etfsBenchmarks: INK.muted, // neutral benchmark bucket (no category token) }; const SECTOR_LABEL_SHORT: Record = { @@ -341,6 +360,11 @@ const SECTOR_LABEL_SHORT: Record = { utilities: "Utilities", dataCenters: "Data Ctrs", construction: "Construct", + rawMaterialsMining: "Mining", + rawMaterialsNatGas: "Nat Gas", + renewableGeneration: "Renewables", + transmissionGrid: "Grid HW", + cryptoAIDC: "Crypto DC", etfsBenchmarks: "ETFs", }; @@ -353,11 +377,11 @@ function ErrorCard({ label }: { label: string }) { ); } -function TopMoversSection({ topMovers, pulse, isLoading, isError }: { topMovers: TopMover[]; pulse: SectorPulseItem[]; isLoading: boolean; isError?: boolean }) { +function TopMoversSection({ topMovers, pulse, isLoading, isError, updatedAt, onRetry }: { topMovers: TopMover[]; pulse: SectorPulseItem[]; isLoading: boolean; isError?: boolean; updatedAt?: number; onRetry?: () => void }) { return (
- +

Top Movers Today

@@ -367,9 +391,10 @@ function TopMoversSection({ topMovers, pulse, isLoading, isError }: { topMovers:

Top 5 stocks by absolute % change across all 8 stack layers. Refreshes every 10 min.

+
- {isError ? : isLoading + {isError ? : isLoading ? Array(5).fill(null).map((_, i) => (
@@ -379,19 +404,19 @@ function TopMoversSection({ topMovers, pulse, isLoading, isError }: { topMovers: )) : topMovers.length === 0 ? : topMovers.filter((m) => m.price != null && m.changePercent != null).map((m) => { const isUp = m.changePercent >= 0; - const sc = SECTOR_COLORS[m.sector] ?? "#6b7280"; + const sc = SECTOR_COLORS[m.sector] ?? INK.muted; return (
{m.ticker} {m.name} {SECTOR_LABEL_SHORT[m.sector] ?? m.sector} ${m.price.toFixed(m.price < 10 ? 2 : m.price < 100 ? 2 : 2)} -
+
{isUp ? : } {Math.abs(m.changePercent).toFixed(2)}%
@@ -399,28 +424,29 @@ function TopMoversSection({ topMovers, pulse, isLoading, isError }: { topMovers: ); })}
- {/* Sector averages, absorbed from the old Sector Pulse card: same - "what moved today" fact, one card instead of two. */} + {/* Sector averages (Lake 4C): uniform chip anatomy in an aligned grid, + color purely SEMANTIC - green/red intensity by magnitude on the same + diverging ramp as The Stack heatmap. Sector identity comes from the + label, not a hue. */} {pulse.length > 0 && ( -
- sectors - {[...pulse].sort((a, b) => b.avgChange - a.avgChange).map((p) => { - const isUp = p.avgChange >= 0; - const sc = SECTOR_COLORS[p.sector] ?? "#6b7280"; - return ( +
+ sectors · avg % today +
+ {[...pulse].sort((a, b) => b.avgChange - a.avgChange).map((p) => ( = 0 ? "+" : ""}${p.avgChange.toFixed(2)}% today`} > - {p.label}{" "} - - {isUp ? "+" : ""}{p.avgChange.toFixed(2)}% + {p.label} + + {p.avgChange >= 0 ? "+" : ""}{p.avgChange.toFixed(2)}% - ); - })} + ))} +
)} @@ -445,21 +471,51 @@ function gaugeTickLabel(date: string): string { // inside Top Movers). Plots the daily gauge history that the validation // study reconstructed and the server now records: one consistent series, // our own data, time depth instead of a second "what moved today" list. +// Gauge baselines mirror server/indices.ts (AI_INDEX.BASELINE, GRID_STRESS.BASELINE). +const GAUGE_BASELINES = { ai: 72, gs: 68 } as const; + +type GaugeRange = "3M" | "6M" | "1Y" | "ALL"; +const GAUGE_RANGES: GaugeRange[] = ["3M", "6M", "1Y", "ALL"]; + +function gaugeRangeStart(range: GaugeRange, now: number): number | null { + const d = new Date(now); + if (range === "3M") return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 3, d.getUTCDate()); + if (range === "6M") return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 6, d.getUTCDate()); + if (range === "1Y") return Date.UTC(d.getUTCFullYear() - 1, d.getUTCMonth(), d.getUTCDate()); + return null; +} + function GaugeHistoryCard({ live }: { live: { npi: number; ai: number; gs: number } | null }) { - const { data, isLoading } = useQuery<{ days: IndexHistoryDay[] }>({ + const { data, isLoading, isError, refetch, dataUpdatedAt } = useQuery<{ days: IndexHistoryDay[] }>({ queryKey: ["/api/index-history"], refetchInterval: 30 * 60_000, }); - const series = (data?.days ?? []) - .filter((d) => d.npiEquityLegs != null || d.aiDemand != null) - .map((d) => ({ date: d.date, npi: d.npiEquityLegs ?? null, ai: d.aiDemand, gs: d.gridStress })); + const [range, setRange] = useState("ALL"); + + // True time axis: date strings -> UTC ms so gaps in the recorder render as + // gaps in time, not as one category step. + const series = useMemo( + () => + (data?.days ?? []) + .filter((d) => d.npiEquityLegs != null || d.aiDemand != null) + .map((d) => ({ t: Date.parse(`${d.date}T00:00:00Z`), npi: d.npiEquityLegs ?? null, ai: d.aiDemand, gs: d.gridStress })) + .filter((d) => Number.isFinite(d.t)), + [data], + ); + const now = series.length ? series[series.length - 1].t : Date.now(); + const start = gaugeRangeStart(range, now); + const windowed = useMemo(() => (start === null ? series : series.filter((d) => d.t >= start)), [series, start]); + const ticks = useMemo(() => { + if (windowed.length < 2) return []; + return timeTicks(windowed[0].t, windowed[windowed.length - 1].t, 560).map((d) => +d); + }, [windowed]); return ( // flex-1: fills the left column so it ends flush with the right column.
- -

Gauges Since Jan 2024

+ +

NPI Gauge History

@@ -469,75 +525,97 @@ function GaugeHistoryCard({ live }: { live: { npi: number; ai: number; gs: numbe Daily series reconstructed from public prices with the shipped formulas, then recorded live going forward (one methodology end to end; reproduce with npm run backtest:indices). The NPI line is the equity legs with uranium and policy at par; the headline number is the full live NPI including both. - Sparklines show the two sentiment gauges oscillating around their fixed baselines. + Sparklines show the two sentiment gauges against their fixed formula baselines (dashed).

- {live && ( - - NPI {live.npi.toFixed(1)} - - )} +
+ {GAUGE_RANGES.map((r) => ( + + ))} + {live && ( + + NPI {live.npi.toFixed(1)} + + )} +
-

- line: NPI equity legs · headline: full live NPI · /api/index-history -

- - {isLoading || series.length === 0 ? ( -
- - +
+

+ line: NPI equity legs · Jan 1 2024 = 100 · headline: full live NPI +

+ +
+ + {isError ? ( +
+ refetch()} /> +
+ ) : isLoading ? ( + <> +
+ + +
+
+ + +
+ + ) : windowed.length === 0 ? ( +
+ No recorded days in this window.
) : ( <>
- + - - + + fmtDate(t, false)} /> fmtDate(t, true)} formatter={(value, name) => value == null ? ["n/a", name] : [Number(value).toFixed(1), name] } /> - +
+ d.npi != null) + .map((d) => [fmtDate(d.t, true), (d.npi as number).toFixed(1)])} + />
{( [ - { key: "ai", label: "AI Demand", color: "#F0A500", value: live?.ai ?? null }, - { key: "gs", label: "Grid Stress", color: "#ef4444", value: live?.gs ?? null }, + { key: "ai", label: "AI Demand", color: BRAND.secondary, value: live?.ai ?? null }, + { key: "gs", label: "Grid Stress", color: SEMANTIC.negativeDeep, value: live?.gs ?? null }, ] as const - ).map((g) => ( -
-
- {g.label} - - {g.value != null ? g.value.toFixed(1) : "–"} - -
-
- - - - - + ).map((g) => { + // Honest domain: the window's own values UNION the formula + // baseline, padded - so distance from baseline is real, and a + // one-point wiggle can't fill the full height. + const vals = windowed.map((d) => d[g.key]).filter((v): v is number => v != null); + const base = GAUGE_BASELINES[g.key]; + const lo = Math.min(...vals, base); + const hi = Math.max(...vals, base); + const pad = Math.max((hi - lo) * 0.15, 1); + return ( +
+
+ {g.label} + + {g.value != null ? g.value.toFixed(1) : "–"} + +
+
+ + + + + + + + +
+

baseline {base}

-
- ))} + ); + })}
)} @@ -591,7 +690,7 @@ function CatalystCalendarSection() { const [viewYear, setViewYear] = useState(today.getFullYear()); const [selectedDay, setSelectedDay] = useState(null); - const { data } = useQuery({ + const { data, isLoading, isError, refetch, dataUpdatedAt } = useQuery({ queryKey: ["/api/catalysts/all"], refetchInterval: 900000, }); @@ -617,8 +716,8 @@ function CatalystCalendarSection() { .slice(0, 5); function getItemColor(item: MergedCatalystItem): string { - if (item.type === 'earnings') return item.stageColor || "#F0A500"; - return CATEGORY_COLORS[item.category || ''] ?? "#6b7280"; + if (item.type === 'earnings') return item.stageColor || BRAND.secondary; + return CATEGORY_COLORS[item.category || ''] ?? INK.muted; } function getItemLabel(item: MergedCatalystItem): string { @@ -635,18 +734,23 @@ function CatalystCalendarSection() {
- +

Catalyst Tracker

+
All Catalysts
+ {isError ? ( + refetch()} /> + ) : ( + <>
); + + // Dot days get a hover popover with that day's events, so the dots + // are functional, not decorative (Lake 4B, option picked at review). + if (!hasItems) return {dayButton}; + return ( + + {dayButton} + +

+ {new Date(viewYear, viewMonth, day).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })} + {dayItems.length > 1 ? ` · ${dayItems.length} events` : ""} +

+
+ {dayItems.slice(0, 6).map((item) => ( +
+ + + {getItemLabel(item)} + {getItemCategory(item)} + +
+ ))} + {dayItems.length > 6 && ( +

+{dayItems.length - 6} more - click the day

+ )} +
+
+
+ ); })}
@@ -724,7 +856,7 @@ function CatalystCalendarSection() {

{getItemLabel(item)}

- {getItemCategory(item)} + {getItemCategory(item)}
); @@ -733,23 +865,32 @@ function CatalystCalendarSection() { )}
-

Next 5 Upcoming

+

Next 5 Upcoming

- {upcoming.map((item) => { - const days = daysUntil(item.sortDate); - const cc = getItemColor(item); - return ( -
- - {days === 0 ? "TODAY" : `${days}d`} - -
- {getItemLabel(item)} -
- ); - })} + {isLoading + ? Array(5).fill(null).map((_, i) => ( +
+ + +
+ )) + : upcoming.map((item) => { + const days = daysUntil(item.sortDate); + const cc = getItemColor(item); + return ( +
+ + {days === 0 ? "TODAY" : `${days}d`} + +
+ {getItemLabel(item)} +
+ ); + })}
+ + )} ); } @@ -763,7 +904,7 @@ function XFollowCard() {
- +
@@ -773,7 +914,7 @@ function XFollowCard() { href="https://x.com/gridtilt" target="_blank" rel="noopener noreferrer" - className="flex items-center gap-1 text-xs text-[#F07800] hover:text-[#F0A500] transition-colors font-medium" + className="flex items-center gap-1 text-xs text-brand hover:text-brand-2 transition-colors font-medium" data-testid="link-gridtilt-x" > Follow @@ -806,89 +947,6 @@ interface AllCatalystsResponse { items: MergedCatalystItem[]; } -const MERGED_CATEGORY_COLORS: Record = { - Earnings: "#F0A500", - Regulatory: "#F0A500", - Policy: "#D4A843", - Infrastructure: "#C87533", - Market: "#F07800", - Industry: "#F07800", -}; - -function NextCatalystsWidget() { - const { data } = useQuery({ - queryKey: ["/api/catalysts/all"], - refetchInterval: 900000, - }); - - const upcoming = (data?.items || []) - .filter((c) => daysUntil(c.sortDate) >= 0) - .slice(0, 5); - - if (upcoming.length === 0) return null; - - return ( - -
-
- -

- Next 5 Catalysts -

-
- - View All - -
-
- {upcoming.map((item) => { - const days = daysUntil(item.sortDate); - const isEarnings = item.type === "earnings"; - const label = isEarnings ? `${item.ticker} Earnings` : (item.title || ""); - const catLabel = isEarnings ? item.stage || "Earnings" : item.category || "Event"; - const catColor = isEarnings - ? (item.stageColor || "#F0A500") - : (MERGED_CATEGORY_COLORS[item.category || ""] ?? "#9ca3af"); - - return ( -
-
-

{days === 0 ? "0" : days}

-

{days === 0 ? "TODAY" : "days"}

-
-
-
-

{label}

- {isEarnings && item.time && ( - {item.time} - )} -
-
- - {catLabel} - - {formatDateShort(item.sortDate)} -
-
-
- ); - })} -
-
- ); -} - function relativeTime(updatedAt: number | undefined): string { if (!updatedAt) return "loading"; const seconds = Math.floor((Date.now() - updatedAt) / 1000); @@ -905,23 +963,23 @@ const FEATURE_SLIDES = [ title: "The Stack", description: "60+ equities across 8 supply chain layers. Compute, nuclear, uranium, power hardware, utilities, construction, and more.", href: "/stack", - accent: "#F07800", + accent: BRAND.primary, preview: stackPreview, }, { icon: Link2, - title: "Supply Chain Tracker", - description: "Interactive D3 force network mapping 21 nodes and 44 real supply relationships from raw materials to end-use compute.", - href: "/supply-chain", - accent: "#F0A500", + title: "Supply Chain Flow", + description: "Interactive network of 21 nodes and 44 real supply relationships, staged from raw materials to end-use compute. Lives inside The Stack.", + href: "/stack?view=flow", + accent: BRAND.secondary, preview: supplyChainPreview, }, { icon: Map, - title: "Power Map", - description: "US data center locations, power capacity, and utility interconnection points. See where the load is landing.", + title: "Power", + description: "US data center map, corporate power deals, and the interconnection queue. See where the load is landing.", href: "/power-map", - accent: "#C87533", + accent: SERIES[9], // series slot 10 (copper accent) preview: powerMapPreview, }, { @@ -929,7 +987,7 @@ const FEATURE_SLIDES = [ title: "Compute Frontier", description: "Named AI superclusters by GPUs, chips, and power, tied to the nuclear-for-AI deals that feed them.", href: "/compute-frontier", - accent: "#F07800", + accent: BRAND.primary, preview: powerMapPreview, }, { @@ -937,24 +995,24 @@ const FEATURE_SLIDES = [ title: "Catalyst Tracker", description: "Live earnings calendar with 80+ tickers from Yahoo Finance, plus thesis catalysts. Never miss a market-moving event.", href: "/catalysts", - accent: "#D4A843", + accent: DATA_QUALITY.estimateFlag, preview: catalystPreview, }, { - icon: BarChart3, - title: "Portfolio Overlay", - description: "Score any portfolio for AI power exposure. See how your holdings map to the infrastructure buildout.", - href: "/portfolio", - accent: "#F07800", - preview: portfolioPreview, + icon: LineChart, + title: "GPU Prices", + description: "GPU rental price index across the neoclouds, plus cost-of-compute and training-run economics.", + href: "/neocloud-intel", + accent: BRAND.secondary, + preview: calculatorPreview, }, { icon: Calculator, - title: "Scenario Calculator", - description: "Model scenarios across demand growth, nuclear capacity, and grid stress variables.", - href: "/trade", - accent: "#F0A500", - preview: calculatorPreview, + title: "Analyze", + description: "Score any portfolio for AI power exposure, and model buildout scenarios across demand, nuclear, and grid variables.", + href: "/analyze", + accent: BRAND.primary, + preview: portfolioPreview, }, ]; @@ -963,7 +1021,7 @@ function ModuleGrid() {

Modules

- {FEATURE_SLIDES.length} tools + {FEATURE_SLIDES.length} tools
{FEATURE_SLIDES.map((slide) => { @@ -989,13 +1047,12 @@ function ModuleGrid() { } export default function TiltOverview() { - const { data: kpiData, isLoading, isError: kpiError } = useQuery({ + const { data: kpiData, isLoading, isError: kpiError, dataUpdatedAt: kpiUpdatedAt, refetch: refetchKpis } = useQuery({ queryKey: ["/api/kpis"], refetchInterval: 900000, }); - - const { data: topMovers, isLoading: topMoversLoading, isError: topMoversError } = useQuery({ + const { data: topMovers, isLoading: topMoversLoading, isError: topMoversError, dataUpdatedAt: topMoversUpdatedAt, refetch: refetchTopMovers } = useQuery({ queryKey: ["/api/top-movers"], refetchInterval: 900000, }); @@ -1005,24 +1062,48 @@ export default function TiltOverview() { refetchInterval: 900000, }); - const { dataUpdatedAt: kpiUpdatedAt } = useQuery({ queryKey: ["/api/kpis"] }); + // Shares the cache with GaugeHistoryCard (same key) - powers the header's + // NPI change, labeled by its TRUE span: the recorder has gaps, and calling + // a 22-day move "1D" would lie. + const { data: historyData } = useQuery<{ days: IndexHistoryDay[] }>({ + queryKey: ["/api/index-history"], + refetchInterval: 30 * 60_000, + }); + const npiDelta = useMemo(() => { + const days = (historyData?.days ?? []).filter((d) => d.npi != null); + if (days.length < 2 || !kpiData) return null; + const prev = days[days.length - 2]; + const latest = days[days.length - 1]; + const prevT = Date.parse(`${prev.date}T00:00:00Z`); + const latestT = Date.parse(`${latest.date}T00:00:00Z`); + const gapDays = Math.round((latestT - prevT) / 86_400_000); + const pct = ((latest.npi! - prev.npi!) / prev.npi!) * 100; + return { pct, label: gapDays <= 4 ? "1D" : `vs ${fmtDate(prevT, true)}` }; + }, [historyData, kpiData]); + const c = kpiData?.constituents; return (
-
-
-
-

- Grid information, tilted in your favor -

-

- Live equities, infrastructure, and power data across 60+ tickers tracking the AI power economy. -

-
-
-
- Updated {relativeTime(kpiUpdatedAt)} + {/* Compact header strip - data starts above the fold (Lake 4A) */} +
+
+

Tilt Overview

+ + {kpiData && ( + + NPI + {kpiData.npiValue.toFixed(1)} + {npiDelta && ( + = 0 ? "text-positive" : "text-negative"}`}> + {npiDelta.pct >= 0 ? "+" : ""}{npiDelta.pct.toFixed(1)}% {npiDelta.label} + + )} + + )} +
+
+ Updated {relativeTime(kpiUpdatedAt)}
@@ -1037,6 +1118,8 @@ export default function TiltOverview() { pulse={sectorPulse ?? []} isLoading={topMoversLoading} isError={topMoversError} + updatedAt={topMoversUpdatedAt} + onRetry={() => refetchTopMovers()} />
-
@@ -1068,15 +1150,15 @@ export default function TiltOverview() {
-
+
Total Actual
-
+
GridTilt Projection (2026-2030)
-
+
DC Demand
@@ -1086,40 +1168,36 @@ export default function TiltOverview() { - - + + - - + + - - + + - + `${(v / 1000).toFixed(1)}k`} domain={[3600, 6600]} width={42} /> `${v}`} domain={[0, 2500]} @@ -1131,9 +1209,9 @@ export default function TiltOverview() { {/* Event annotations */} @@ -1151,7 +1229,7 @@ export default function TiltOverview() { + [ + d.year, + d.demand ?? "—", + d.dcDemand ?? d.dcProjected ?? "—", + d.projected ?? "—", + ])} + /> {/* Annotation key */}
- * 2022: IRA signed + ChatGPT launch - * 2024: TMI restart + first commercial SMR contract - --- Grid capacity ceiling + * 2022: IRA signed + ChatGPT launch + * 2024: TMI restart + first commercial SMR contract + --- Grid capacity ceiling
@@ -1217,7 +1305,7 @@ export default function TiltOverview() { the momentum gauges showed no correlation with physical output, so they are presented as market sentiment, not measurements. */}
-
+
market gauges · methodology and validation in each card
@@ -1288,11 +1376,12 @@ export default function TiltOverview() { )} {kpiError && ( - -
- - Live index data unavailable. Showing last known values. -
+ + refetchKpis()} + className="py-4" + /> )}
@@ -1300,9 +1389,9 @@ export default function TiltOverview() { {/* 4-column stat strip */}
{[ - { label: "DC Share of US Demand", value: "~6.4%", sub: "EIA 2025: ~288 TWh, up from 4.4% in 2023. DOE projects 12%+ by 2028.", color: "#a855f7" }, - { label: "Nuclear Power Committed", value: "12+ GW", sub: "Big Tech nuclear PPAs as of Q1 2026. Meta 6.6 GW, Microsoft 1.2 GW, Amazon 2.5+ GW.", color: "#F0A500" }, - { label: "Grid Reserve Margins", value: "Tightening", sub: "MISO 13.4%, ERCOT 15.8% per NERC 2026. Capacity warnings through 2028.", color: "#94a3b8" }, + { label: "DC Share of US Demand", value: "~6.4%", sub: "EIA 2025: ~288 TWh, up from 4.4% in 2023. DOE projects 12%+ by 2028.", color: TOKEN_CATEGORY_COLORS.datacenters }, + { label: "Nuclear Power Committed", value: "12+ GW", sub: "Big Tech nuclear PPAs as of Q1 2026. Meta 6.6 GW, Microsoft 1.2 GW, Amazon 2.5+ GW.", color: BRAND.secondary }, + { label: "Grid Reserve Margins", value: "Tightening", sub: "MISO 13.4%, ERCOT 15.8% per NERC 2026. Capacity warnings through 2028.", color: INK.muted }, ].map((s) => (

{s.label}

@@ -1343,7 +1432,7 @@ export default function TiltOverview() {

{s.twh.toLocaleString()} TWh

-
= 0 ? "text-green-400" : "text-red-400"}`}> +
= 0 ? "text-positive" : "text-negative"}`}> {s.yoy >= 0 ? : } {Math.abs(s.yoy)}% YoY
@@ -1360,7 +1449,7 @@ export default function TiltOverview() { -