/* ── 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 (
setChapter(i)} style={{
flex: '1 1 180px', padding: '12px 14px',
background: active ? 'rgba(124,58,237,0.18)' : 'transparent',
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit',
textAlign: 'left', transition: 'all 0.25s', position: 'relative' }}>
CHAPTER {c.label}
{c.title}
{c.period}
{active &&
}
);
})}
);
}
/* ── 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}
setChapter(Math.max(0, chapter - 1))} disabled={chapter === 0}
style={navBtn(chapter === 0, false)}>← Previous
setChapter(Math.min(CHAPTERS.length - 1, chapter + 1))} disabled={chapter === CHAPTERS.length - 1}
style={navBtn(chapter === CHAPTERS.length - 1, true)}>Next →
);
}
/* ── 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
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 (
setHidden((h) => ({ ...h, [c.key]: !h[c.key] }))}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px', borderRadius: 6,
background: off ? 'transparent' : focused ? 'rgba(148,163,184,0.07)' : 'rgba(148,163,184,0.03)',
border: `1px solid ${off ? STORY_PALETTE.border : focused ? STORY_PALETTE.borderHi : STORY_PALETTE.border}`,
color: off ? STORY_PALETTE.textDim : STORY_PALETTE.text,
fontSize: 11, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
opacity: off ? 0.5 : 1, transition: 'all 0.2s'
}}>
{c.name}
);
})}
);
}
/* ── 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.
setPlaying((p) => !p)} style={{
padding: '9px 16px', borderRadius: 8,
background: playing ? STORY_PALETTE.purple : 'transparent',
border: `1px solid ${playing ? STORY_PALETTE.purple : STORY_PALETTE.borderHi}`,
color: playing ? '#fff' : STORY_PALETTE.text,
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 8
}}>
{playing ? ⏸ Pause auto-play : ▶ Auto-play story }
);
}
window.BIDashboard = BIDashboard;