/* Baby Names Almanac — chart, sparkline, name card, raw pivot.
Adapted from the design bundle. Operates on series passed in as props
(no global window.BABY_DATA — data is fetched via API and injected by App). */
const PALETTES = {
F: ['#a23a4a', '#c97a55', '#7c5e3c', '#5a7159', '#8a4d6b', '#b89455'],
M: ['#2f4a6b', '#5a7159', '#7c5e3c', '#8a4d4d', '#3d6b73', '#5e4f7a'],
};
function fmt(n) {
if (n == null || isNaN(n)) return '—';
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
return Math.round(n).toLocaleString();
}
function fmtFull(n) { return n == null ? '—' : Math.round(n).toLocaleString(); }
function ord(n) {
if (n == null) return '—';
const s = ['th','st','nd','rd'], v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
// ----- Trend Chart -----
function TrendChart({ series, metric, yearRange, mode, chartStyle }) {
const [hover, setHover] = React.useState(null);
const wrapRef = React.useRef(null);
const W = 1100, H = 360;
const PAD = { l: 56, r: 24, t: 18, b: 36 };
const innerW = W - PAD.l - PAD.r;
const innerH = H - PAD.t - PAD.b;
const [y0, y1] = yearRange;
const palette = PALETTES[mode];
const filtered = series.map((s, i) => ({
name: s.name,
color: s.color || palette[i % palette.length],
points: (s.series || [])
.filter(p => p.year >= y0 && p.year <= y1)
.map(p => ({ year: p.year, val: p[metric.key] }))
.filter(p => p.val != null && !isNaN(p.val)),
}));
let yMin = Infinity, yMax = -Infinity;
filtered.forEach(s => s.points.forEach(p => {
if (p.val < yMin) yMin = p.val;
if (p.val > yMax) yMax = p.val;
}));
if (!isFinite(yMin)) { yMin = 0; yMax = 1; }
if (metric.key === 'rank') {
yMin = Math.max(1, Math.floor(yMin * 0.7));
yMax = Math.ceil(yMax * 1.05);
} else {
yMin = 0;
yMax = yMax * 1.08 || 1;
}
const xScale = year => PAD.l + ((year - y0) / Math.max(1, (y1 - y0))) * innerW;
const yScale = val => {
if (metric.key === 'rank') {
return PAD.t + ((val - yMin) / (yMax - yMin)) * innerH;
}
return PAD.t + (1 - (val - yMin) / (yMax - yMin)) * innerH;
};
const xTicks = [];
const yearSpan = y1 - y0;
const step = yearSpan > 100 ? 20 : yearSpan > 50 ? 10 : yearSpan > 20 ? 5 : 2;
for (let y = Math.ceil(y0 / step) * step; y <= y1; y += step) xTicks.push(y);
const yTicks = [];
const yTickCount = 5;
for (let i = 0; i <= yTickCount; i++) {
const v = yMin + (yMax - yMin) * (i / yTickCount);
yTicks.push(metric.key === 'rank' ? Math.round(v) : v);
}
function pathFor(points) {
if (points.length === 0) return '';
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xScale(p.year).toFixed(2)} ${yScale(p.val).toFixed(2)}`).join(' ');
}
function areaFor(points) {
if (points.length === 0) return '';
const baseY = metric.key === 'rank' ? yScale(yMax) : H - PAD.b;
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xScale(p.year).toFixed(2)} ${yScale(p.val).toFixed(2)}`).join(' ')
+ ` L ${xScale(points[points.length - 1].year).toFixed(2)} ${baseY} L ${xScale(points[0].year).toFixed(2)} ${baseY} Z`;
}
function onMove(e) {
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const x = (e.clientX - rect.left) * (W / rect.width);
if (x < PAD.l || x > W - PAD.r) { setHover(null); return; }
const yearF = y0 + ((x - PAD.l) / innerW) * (y1 - y0);
const year = Math.round(yearF);
setHover({ year, clientX: e.clientX - rect.left, clientY: e.clientY - rect.top, rect });
}
function fmtVal(v) {
if (v == null) return '—';
if (metric.key === 'count') return fmtFull(v);
if (metric.key === 'rank') return '#' + Math.round(v);
if (metric.key === 'share') return (v * 100).toFixed(3) + '%';
if (metric.key === 'per10k') return v.toFixed(1) + ' / 10k';
if (metric.key === 'percentile') return v.toFixed(1) + '%';
return v;
}
const hoverData = hover ? filtered.map(s => {
const pt = s.points.find(p => p.year === hover.year);
return pt ? { name: s.name, color: s.color, val: pt.val } : { name: s.name, color: s.color, val: null };
}) : null;
return (
{hover && hoverData && (
{hover.year}
{hoverData.map((h, i) => (
{h.name}
{fmtVal(h.val)}
))}
)}
);
}
// ----- Sparkline -----
function Sparkline({ points, color }) {
if (!points || points.length === 0) return null;
const W = 280, H = 50;
let max = -Infinity, min = Infinity;
points.forEach(p => { if (p.val > max) max = p.val; if (p.val < min) min = p.val; });
const yr0 = points[0].year, yr1 = points[points.length - 1].year;
const xs = y => ((y - yr0) / Math.max(1, yr1 - yr0)) * W;
const ys = v => H - 4 - ((v - min) / Math.max(1, (max - min))) * (H - 8);
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xs(p.year).toFixed(1)} ${ys(p.val).toFixed(1)}`).join(' ');
const area = path + ` L ${xs(points[points.length - 1].year).toFixed(1)} ${H} L ${xs(points[0].year).toFixed(1)} ${H} Z`;
return (
);
}
// ----- Trend label -----
function computeTrendLabel(series) {
if (!series || series.length < 2) return { label: 'Rarely used', cls: 'rare' };
const last = series[series.length - 1];
const peakRow = series.reduce((a, b) => (b.count > a.count ? b : a), series[0]);
const recent = series.slice(-10);
const earlier = series.slice(-25, -10);
const recentAvg = recent.reduce((a, r) => a + r.count, 0) / Math.max(1, recent.length);
const earlierAvg = earlier.reduce((a, r) => a + r.count, 0) / Math.max(1, earlier.length);
if (last.count < 100 && peakRow.count > 5000 && peakRow.year < 1960) {
if (recentAvg > earlierAvg * 1.3) return { label: 'Coming back', cls: 'vintage' };
return { label: 'Rarely used', cls: 'rare' };
}
if (peakRow.year < 1960 && last.count > peakRow.count * 0.4 && (recentAvg > earlierAvg * 1.4)) {
return { label: 'Coming back', cls: 'vintage' };
}
if (recentAvg > earlierAvg * 1.25) return { label: 'Getting more popular', cls: 'rising' };
if (recentAvg < earlierAvg * 0.75) return { label: 'Getting less popular', cls: 'falling' };
if (last.count < 200) return { label: 'Rarely used', cls: 'rare' };
return { label: 'Holding steady', cls: 'stable' };
}
// ----- Name card -----
function NameOrigin({ name, info }) {
if (!info) {
return (
Origin & meaning
Loading origin details…
);
}
if (!info.available) {
return (
Origin & meaning
Origin and meaning are not available yet.
);
}
const originLabel = (info.origins || []).join(', ') || 'Origin uncertain';
return (
Origin & meaning
{info.description || `${name} is commonly linked to ${originLabel}.`}
Origin{originLabel}
Meaning{info.meaning || 'Meaning uncertain'}
LLM-generated note · {info.confidence || 'medium'} confidence
);
}
function NameCard({ name, series, color, ord: idx, nameInfo, showSparkline }) {
if (!series || series.length === 0) {
return (
No. {String(idx + 1).padStart(2, '0')}
{name}
Not in dataset
);
}
const last = series[series.length - 1];
const peak = series.reduce((a, b) => (b.count > a.count ? b : a), series[0]);
const bestRank = series.reduce((a, b) => (b.rank < a.rank ? b : a), series[0]);
const trend = computeTrendLabel(series);
const sparkPts = series.map(s => ({ year: s.year, val: s.count }));
return (
No. {String(idx + 1).padStart(2, '0')} · {last.year}
{trend.label}
{name}
Current popularity
{last.rank ? '#' + last.rank : '—'}
Babies named {name}
{fmt(last.count)} babies
Biggest year
{peak.year}
{fmt(peak.count)} babies
Best popularity#{bestRank.rank} {bestRank.year}
More popular than{last.percentile != null ? last.percentile.toFixed(1) + '%' : '—'}
{showSparkline !== false && (
)}
);
}
// ----- Raw data pivot (selected names only) -----
function RawTable({ series, mode, density }) {
const valid = series.filter(s => s.series && s.series.length);
const [search, setSearch] = React.useState('');
const [yearStart, setYearStart] = React.useState(2000);
const [yearEnd, setYearEnd] = React.useState(2025);
const [sortBy, setSortBy] = React.useState('recent');
const years = [];
for (let y = yearEnd; y >= yearStart; y--) years.push(y);
const allRows = React.useMemo(() => {
return valid.map(s => {
const counts = {};
let total = 0, peak = 0, peakYear = null, bestRank = Infinity, latestCount = 0;
s.series.forEach(p => {
if (p.year >= yearStart && p.year <= yearEnd) {
counts[p.year] = p.count;
total += p.count;
if (p.count > peak) { peak = p.count; peakYear = p.year; }
if (p.rank < bestRank) bestRank = p.rank;
if (p.year === yearEnd) latestCount = p.count;
}
});
return { name: s.name, counts, total, peak, peakYear, bestRank, latestCount };
});
}, [valid, yearStart, yearEnd]);
const filtered = React.useMemo(() => {
let r = allRows;
if (search.trim()) {
const q = search.toLowerCase();
r = r.filter(x => x.name.toLowerCase().includes(q));
}
r = [...r];
if (sortBy === 'recent') r.sort((a, b) => b.latestCount - a.latestCount);
else if (sortBy === 'peak') r.sort((a, b) => b.peak - a.peak);
else if (sortBy === 'best-rank') r.sort((a, b) => a.bestRank - b.bestRank);
else if (sortBy === 'total') r.sort((a, b) => b.total - a.total);
else if (sortBy === 'name') r.sort((a, b) => a.name.localeCompare(b.name));
return r;
}, [allRows, search, sortBy]);
const maxCount = React.useMemo(() => {
let m = 0;
filtered.forEach(r => Object.values(r.counts).forEach(v => { if (v > m) m = v; }));
return m;
}, [filtered]);
const accent = mode === 'F' ? '162, 58, 74' : '47, 74, 107';
function exportCSV() {
const head = ['name', ...years].join(',');
const lines = filtered.map(r => [r.name, ...years.map(y => r.counts[y] || '')].join(','));
const csv = [head, ...lines].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `baby-names-${mode === 'F' ? 'girls' : 'boys'}-${yearStart}-${yearEnd}.csv`;
a.click();
URL.revokeObjectURL(url);
}
return (
{valid.length === 0 ? (
Add a name above to see yearly counts.
) : (
| Name |
{years.map(y => {y} | )}
{filtered.map(row => (
| {row.name} |
{years.map(y => {
const c = row.counts[y];
const intensity = c ? Math.min(1, c / (maxCount * 0.5)) : 0;
return (
{c ? c.toLocaleString() : '·'}
|
);
})}
))}
)}
The Social Security list does not include names given to fewer than 5 babies in a year. Dots mean the name was not listed for that year.
);
}
Object.assign(window, { TrendChart, Sparkline, NameCard, RawTable, computeTrendLabel, fmt, fmtFull, ord, PALETTES });