/* ── Interactive BI Story: The Great U.S. Retail Reshuffle (2019–2024) ─────────── Data: U.S. Census Bureau, Monthly Retail Trade Survey (publicly available). Series are seasonally adjusted monthly estimates ($B), reconstructed from anchor points in the published reports. Used here for narrative fidelity. ──────────────────────────────────────────────────────────────────────────── */ const STORY_PALETTE = { bg: '#0b1020', panel: '#131b2e', panelHi: '#1a2440', border: 'rgba(148,163,184,0.14)', borderHi: 'rgba(148,163,184,0.28)', text: '#e6e9f2', textMuted: '#9aa3b5', textDim: '#6b7488', purple: '#7c3aed', purpleLight: '#a78bfa', teal: '#2dd4bf', green: '#4ade80', gold: '#fbbf24', red: '#f87171', blue: '#60a5fa' }; /* Jan 2019 (idx 0) → Dec 2024 (idx 71) */ const MONTH_COUNT = 72; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const monthLabel = (i) => `${MONTH_NAMES[i % 12]} ${2019 + Math.floor(i / 12)}`; /* Anchor-based series generation. Anchors are [monthIndex, $B value] pairs taken from public MRTS releases (seasonally adjusted). Between anchors we linearly interpolate, plus a small deterministic wiggle so the line looks like a real monthly print rather than a polygon. */ function makeSeries(anchors, wiggle = 0.6, seedShift = 0) { const sorted = [...anchors].sort((a, b) => a[0] - b[0]); const out = []; for (let i = 0; i < MONTH_COUNT; i++) { let a = sorted[0], b = sorted[sorted.length - 1]; for (let j = 0; j < sorted.length - 1; j++) { if (i >= sorted[j][0] && i <= sorted[j + 1][0]) { a = sorted[j]; b = sorted[j + 1]; break; } } const span = Math.max(1, b[0] - a[0]); const t = (i - a[0]) / span; const v = a[1] + (b[1] - a[1]) * t; const noise = (Math.sin((i + seedShift) * 13.7) + Math.cos((i + seedShift) * 7.3)) * wiggle; out.push(Math.max(0, Math.round((v + noise) * 10) / 10)); } return out; } /* Anchors anchored to real published MRTS values. Idx 13 = Feb 2020 (last "normal" month), 14 = Mar 2020, 15 = Apr 2020 (the trough), 71 = Dec 2024. */ const SERIES = { restaurants: makeSeries([[0, 62], [13, 65], [14, 49], [15, 30], [16, 35], [17, 41], [19, 50], [22, 55], [29, 69], [35, 75], [47, 87], [59, 90], [71, 95]], 0.9, 0), online: makeSeries([[0, 64], [13, 73], [15, 88], [16, 90], [19, 92], [23, 98], [29, 103], [35, 108], [47, 116], [59, 124], [71, 132]], 0.7, 11), grocery: makeSeries([[0, 61], [13, 64], [14, 79], [15, 72], [17, 70], [23, 74], [29, 76], [35, 79], [47, 82], [59, 83], [71, 85]], 0.4, 23), clothing: makeSeries([[0, 22], [13, 22], [14, 12], [15, 3.6], [16, 8], [17, 13], [19, 18], [23, 23], [29, 26], [35, 26], [47, 26], [59, 26], [71, 27]], 0.5, 37), general: makeSeries([[0, 57], [13, 60], [14, 55], [16, 64], [23, 70], [35, 73], [47, 74], [59, 76], [71, 77]], 0.5, 53), motor: makeSeries([[0, 99], [13, 105], [15, 63], [17, 95], [23, 117], [29, 122], [35, 125], [47, 127], [59, 128], [71, 131]], 0.8, 71), }; const CATEGORIES = [ { key: 'restaurants', name: 'Restaurants & Bars', short: 'Restaurants', color: STORY_PALETTE.gold }, { key: 'online', name: 'Online & Mail-Order', short: 'Online', color: STORY_PALETTE.purple }, { key: 'grocery', name: 'Grocery Stores', short: 'Grocery', color: STORY_PALETTE.teal }, { key: 'clothing', name: 'Clothing & Accessories', short: 'Clothing', color: STORY_PALETTE.red }, { key: 'general', name: 'General Merchandise', short: 'General Mdse',color: STORY_PALETTE.blue }, { key: 'motor', name: 'Motor Vehicles & Parts', short: 'Auto', color: STORY_PALETTE.green }]; /* ── Chapter definitions ─────────────────────────────────────────────────── */ const CHAPTERS = [ { id: 'baseline', label: '01', title: 'The Baseline', period: 'Jan 2019 – Feb 2020', range: [0, 13], headline: 'Six months before anyone said "pandemic," U.S. retail looked stable, predictable, and physical.', body: 'Restaurants & bars had grown larger than grocery in monthly sales. Online retail was growing about 11% YoY—important, but not dominant. Every category was tracking single-digit growth, with restaurants pulling a 12-month moving average of $64B.', stat: { label: 'Online share of tracked retail', value: '16.4', suffix: '%', delta: 'Pre-pandemic norm' }, focus: ['restaurants', 'online', 'grocery'], annotations: [] }, { id: 'shock', label: '02', title: 'The Shock', period: 'Mar 2020 – May 2020', range: [13, 17], headline: 'In 60 days, the largest peacetime spending shift in modern U.S. history.', body: 'Restaurants dropped −55% in two months. Clothing collapsed −84%. Grocery spiked +23% in a single month as Americans cooked at home. Online jumped +22% in April alone, absorbing every category that could ship.', stat: { label: 'Restaurant sales: Feb → Apr 2020', value: '−55', suffix: '%', delta: '$65B → $30B / month' }, focus: ['restaurants', 'clothing', 'online', 'grocery'], annotations: [ { x: 14, label: 'Lockdowns begin', color: STORY_PALETTE.red, align: 'right' }, { x: 15, label: 'Trough', color: STORY_PALETTE.red, align: 'left' }] }, { id: 'recovery', label: '03', title: 'The Uneven Recovery', period: 'Jun 2020 – Dec 2022', range: [17, 47], headline: 'Categories recovered at wildly different speeds—and online never gave back its gains.', body: 'Restaurants took 14 months to reclaim pre-pandemic sales. Clothing took 13. Online sat ~35% above its 2019 trendline by mid-2021 and stayed there. The recovery wasn\'t a return to baseline—it was a permanent restructuring of where the dollar lands.', stat: { label: 'Months to recover Feb 2020 levels', value: '14', suffix: 'mo', delta: 'Restaurants vs. 7 mo for online' }, focus: ['restaurants', 'online', 'clothing'], annotations: [ { x: 29, label: 'Restaurants reclaim Feb \'20', color: STORY_PALETTE.gold, align: 'right' }] }, { id: 'newnormal', label: '04', title: 'The New Normal', period: '2023 – 2024', range: [47, 71], headline: 'Five years on, online is permanently larger. Restaurants boomed past prior peaks. Clothing barely moved.', body: 'Online has roughly doubled from 2019 levels. Restaurants now exceed grocery by a wider margin than ever, even after 18% cumulative CPI. Clothing—the category most disrupted—has barely grown in absolute dollars. Where the consumer dollar lands has fundamentally rebalanced.', stat: { label: 'Online sales: Jan 2019 → Dec 2024', value: '+106', suffix: '%', delta: '$64B → $132B / month' }, focus: ['online', 'restaurants', 'clothing'], annotations: [] }]; /* ── Chapter nav bar ─────────────────────────────────────────────────────── */ function StoryNav({ chapter, setChapter }) { return (
{CHAPTERS.map((c, i) => { const active = chapter === i; return ( ); })}
); } /* ── Sidebar with narrative + key stat ───────────────────────────────────── */ function StorySidebar({ chapter, setChapter }) { const ch = CHAPTERS[chapter]; const navBtn = (disabled, primary) => ({ flex: 1, padding: '11px 14px', borderRadius: 8, background: primary ? STORY_PALETTE.purple : 'transparent', border: `1px solid ${primary ? STORY_PALETTE.purple : STORY_PALETTE.borderHi}`, color: primary ? '#fff' : STORY_PALETTE.text, fontSize: 13, fontWeight: 600, cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.35 : 1, fontFamily: 'inherit', transition: 'all 0.2s' }); return (
CHAPTER {ch.label} · {ch.period.toUpperCase()}

{ch.title}

{ch.headline}

{ch.body}

{ch.stat.label}
{ch.stat.value}{ch.stat.suffix}
{ch.stat.delta}
); } /* ── Timeline chart: multi-line with annotations + crosshair tooltip ─────── */ function TimelineChart({ chapter }) { const ch = CHAPTERS[chapter]; const [hoverIdx, setHoverIdx] = React.useState(null); const [hidden, setHidden] = React.useState({}); const svgRef = React.useRef(null); const w = 760, h = 320; const pad = { l: 44, r: 16, t: 20, b: 36 }; const innerW = w - pad.l - pad.r; const innerH = h - pad.t - pad.b; const visibleCats = CATEGORIES.filter((c) => !hidden[c.key]); const allVals = visibleCats.flatMap((c) => SERIES[c.key]); const max = visibleCats.length ? Math.max(...allVals) * 1.06 : 140; const min = 0; const xFor = (i) => pad.l + i / (MONTH_COUNT - 1) * innerW; const yFor = (v) => pad.t + (1 - (v - min) / (max - min)) * innerH; const onMove = (e) => { const rect = svgRef.current.getBoundingClientRect(); const x = (e.clientX - rect.left) * (w / rect.width); const idx = Math.round((x - pad.l) / innerW * (MONTH_COUNT - 1)); if (idx >= 0 && idx < MONTH_COUNT) setHoverIdx(idx); }; const pathFor = (key) => SERIES[key].map((v, i) => `${i === 0 ? 'M' : 'L'}${xFor(i).toFixed(1)},${yFor(v).toFixed(1)}`).join(' '); const periodX1 = xFor(ch.range[0]); const periodX2 = xFor(ch.range[1]); return (
Monthly Retail Sales · U.S.
Seasonally adjusted, $ billions per month
{ch.period}
setHoverIdx(null)} style={{ display: 'block', cursor: 'crosshair' }}> {/* Active period shading */} {/* Y grid */} {[0, 0.25, 0.5, 0.75, 1].map((t, i) => { const y = pad.t + t * innerH; const v = Math.round(max - t * (max - min)); return ( ${v}B ); })} {/* X year ticks */} {[0, 12, 24, 36, 48, 60].map((i) => {2019 + i / 12} )} {/* Lines */} {CATEGORIES.map((c) => { if (hidden[c.key]) return null; const focused = ch.focus.includes(c.key); return ( ); })} {/* Annotations */} {ch.annotations.map((a, i) => { const labelWidth = a.label.length * 6 + 14; const leftAlign = a.align === 'left'; const xLabel = leftAlign ? xFor(a.x) - labelWidth - 4 : xFor(a.x) + 4; return ( {a.label} ); })} {/* Crosshair */} {hoverIdx !== null && {CATEGORIES.map((c) => { if (hidden[c.key]) return null; const focused = ch.focus.includes(c.key); return ( ); })} } {/* Tooltip block */} {hoverIdx !== null &&
{monthLabel(hoverIdx)}
{CATEGORIES.filter((c) => !hidden[c.key]).map((c) =>
{c.short} ${SERIES[c.key][hoverIdx].toFixed(1)}B
)}
} {/* Legend toggles */}
{CATEGORIES.map((c) => { const off = hidden[c.key]; const focused = ch.focus.includes(c.key); return ( ); })}
); } /* ── Category delta vs. Feb 2020 baseline ────────────────────────────────── */ function CategoryDeltaChart({ chapter }) { const ch = CHAPTERS[chapter]; const baselineIdx = 13; const endIdx = ch.range[1]; const deltas = CATEGORIES.map((c) => { const base = SERIES[c.key][baselineIdx]; const end = SERIES[c.key][endIdx]; const pct = (end - base) / base * 100; return { ...c, base, end, pct }; }).sort((a, b) => b.pct - a.pct); const maxAbs = Math.max(...deltas.map((d) => Math.abs(d.pct)), 1); return (
Change vs. Feb 2020 baseline
Winners & losers · end of period
{deltas.map((d) => { const isPositive = d.pct >= 0; const width = Math.abs(d.pct) / maxAbs * 48; return (
{d.short}
{isPositive ? '+' : ''}{d.pct.toFixed(0)}%
); })}
); } /* ── Stacked share-of-tracked-retail bar ────────────────────────────────── */ function CategoryShare({ chapter }) { const ch = CHAPTERS[chapter]; const idx = ch.range[1]; const baselineIdx = 13; const values = CATEGORIES.map((c) => ({ ...c, v: SERIES[c.key][idx], base: SERIES[c.key][baselineIdx] })); const total = values.reduce((a, b) => a + b.v, 0); const baseTotal = values.reduce((a, b) => a + b.base, 0); values.forEach((d) => { d.share = d.v / total * 100; d.baseShare = d.base / baseTotal * 100; d.shareDelta = d.share - d.baseShare; }); const sorted = [...values].sort((a, b) => b.share - a.share); // Build cumulative left-positions so we can animate width transitions let acc = 0; const positioned = sorted.map((s) => { const left = acc; acc += s.share; return { ...s, left }; }); return (
Share of Tracked Retail $
Where the dollar lands · end of period
{positioned.map((v) =>
)}
{sorted.map((v) => { const deltaSign = v.shareDelta >= 0; return (
{v.short} {v.share.toFixed(1)}% {Math.abs(v.shareDelta) < 0.05 ? '—' : `${deltaSign ? '+' : ''}${v.shareDelta.toFixed(1)}pp`}
); })}
); } /* ── Main ────────────────────────────────────────────────────────────────── */ function BIDashboard() { const [chapter, setChapter] = React.useState(0); const [playing, setPlaying] = React.useState(false); // Auto-advance React.useEffect(() => { if (!playing) return; const t = setTimeout(() => { setChapter((c) => { if (c >= CHAPTERS.length - 1) {setPlaying(false);return c;} return c + 1; }); }, 9000); return () => clearTimeout(t); }, [playing, chapter]); // Keyboard nav (only when section in view) React.useEffect(() => { const h = (e) => { const tag = e.target.tagName; if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; const sect = document.getElementById('dashboard'); if (!sect) return; const rect = sect.getBoundingClientRect(); const inView = rect.top < window.innerHeight * 0.6 && rect.bottom > window.innerHeight * 0.4; if (!inView) return; if (e.key === 'ArrowRight') {setChapter((c) => Math.min(CHAPTERS.length - 1, c + 1));e.preventDefault();} if (e.key === 'ArrowLeft') {setChapter((c) => Math.max(0, c - 1));e.preventDefault();} }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, []); React.useEffect(() => {if (window.track) window.track('Dashboard Story Chapter', { chapter: CHAPTERS[chapter].id });}, [chapter]); return (
{/* Footer */}
Source:{' '} U.S. Census Bureau, Monthly Retail Trade Survey — publicly released seasonally-adjusted estimates. Series are reconstructed from published anchor values for storytelling fidelity.
); } window.BIDashboard = BIDashboard;