FinDendro / app.js
jiminHuang's picture
Rebuild leaderboard around the tree-ring story
e46cb4b verified
Raw History Blame Contribute Delete
24 kB
/* FinDendro leaderboard: every number comes from data.js. */
document.documentElement.classList.add('js');
const D = window.FD, M = D.models, CH = D.meta.chance;
const FRONT = M.filter(m => !m.small);
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const f2 = v => v.toFixed(2).replace('-', '−');
const f3 = v => (v > 0 ? '+' : '') + v.toFixed(3).replace('-', '−');
const pct = v => Math.round(v * 100) + '%';
const by = name => M.find(m => m.model === name);
const qn = s => +s.slice(0, 4) * 4 + +s.slice(5);
const quarters = (a, b) => qn(b) - qn(a);
const qX = s => +s.slice(0, 4) + (+s.slice(5) - .5) / 4;
const ymX = s => { const [y, m] = s.split('-').map(Number); return y + (m - .5) / 12; };
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const NS = 'http://www.w3.org/2000/svg';
const add = (p, tag, attrs, text) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; p.appendChild(e); return e; };
const svg = (w, h, label) => { const s = document.createElementNS(NS, 'svg'); s.setAttribute('viewBox', `0 0 ${w} ${h}`); s.setAttribute('width', '100%'); s.setAttribute('role', 'img'); s.setAttribute('aria-label', label); s.style.display = 'block'; return s; };
const mix = (a, b, t) => '#' + a.map((v, i) => Math.round(v + (b[i] - v) * Math.max(0, Math.min(1, t))).toString(16).padStart(2, '0')).join('');
const WOOD = [243, 236, 220], TEAL = [8, 96, 88], RUST = [201, 82, 28], PALE = [241, 244, 238], HEAT = [10, 125, 116];
/* tooltip */
const tip = $('#tip');
const showTip = (html, e) => { tip.innerHTML = html; tip.classList.add('show'); const w = tip.offsetWidth, h = tip.offsetHeight; tip.style.transform = `translate(${Math.max(8, Math.min(innerWidth - w - 8, e.clientX + 12))}px,${e.clientY - h - 12 < 8 ? e.clientY + 16 : e.clientY - h - 12}px)`; };
const hideTip = () => tip.classList.remove('show');
document.addEventListener('pointermove', e => { const t = e.target.closest && e.target.closest('[data-tip]'); if (t) showTip(t.dataset.tip, e); else if (!(e.target.closest && e.target.closest('.live'))) hideTip(); });
/* scroll, reveal, nav */
const prog = $('#progress'), head = $('#header');
const onScroll = () => { const h = document.documentElement; prog.style.transform = `scaleX(${h.scrollTop / Math.max(1, h.scrollHeight - h.clientHeight)})`; head.classList.toggle('scrolled', h.scrollTop > 12); };
addEventListener('scroll', onScroll, { passive: true }); onScroll();
const io = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }), { rootMargin: '0px 0px -6% 0px' });
$$('.reveal').forEach(el => io.observe(el));
setTimeout(() => $$('.reveal').forEach(el => el.classList.add('in')), 2200);
const nav = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) $$('#navLinks a').forEach(a => a.classList.toggle('active', a.dataset.sec === e.target.id)); }), { rootMargin: '-40% 0px -55% 0px' });
$$('#navLinks a').forEach(a => { const s = document.getElementById(a.dataset.sec); if (s) nav.observe(s); });
/* pixel woodpecker, tapping at the rings */
(function bird() {
const P = { k: '#10201c', r: '#c9521c', w: '#f4f1e6', t: '#0a7d74', y: '#d9962b' };
const A = ['....rr......', '...rrrr.....', '..kkkkkk....', '.kkwkkkk....', 'yykkkkkkk...', '..kkttkkkk..', '...kttttkkk.', '....tttttkk.', '.....kk.ttkk', '.....y.y...k'];
const B = A.map((r, i) => i < 5 ? '.' + r.slice(0, 11) : r);
const c = document.createElement('canvas'); c.width = 12; c.height = 12; $('#bird').appendChild(c);
const g = c.getContext('2d');
const draw = f => { g.clearRect(0, 0, 12, 12); f.forEach((r, y) => [...r].forEach((p, x) => { if (P[p]) { g.fillStyle = P[p]; g.fillRect(x, y + 1, 1, 1); } })); };
draw(A);
if (!reduce) { let n = 0; setInterval(() => { n = (n + 1) % 8; draw(n < 4 && n % 2 ? B : A); }, 170); }
})();
/* hero */
(function stats() {
const items = [[D.meta.facts, 'dated facts', v => v.toLocaleString('en-US')], [D.meta.prompts, 'prompts per model', v => v.toLocaleString('en-US')], [D.meta.to - D.meta.from + 1, 'years of markets', v => v], [M.length + D.pit.length, 'models and vintages', v => v]];
$('#stats').innerHTML = items.map(([v, s, f], i) => `<div class="stat"><b data-i="${i}">${f(v)}</b><span>${s}</span></div>`).join('');
if (reduce) return;
items.forEach(([v, , f], i) => { const el = $(`.stat b[data-i="${i}"]`), t0 = performance.now(); (function step(t) { const p = Math.min(1, (t - t0) / 1100), e = 1 - Math.pow(1 - p, 3); el.textContent = f(Math.round(v * e)); if (p < 1) requestAnimationFrame(step); })(t0); });
})();
(function rotator() {
const el = $('#rotator'), names = M.map(m => m.model); let i = 0; el.textContent = names[0];
if (reduce) return;
setInterval(() => { el.classList.add('off'); setTimeout(() => { i = (i + 1) % names.length; el.textContent = names[i]; el.classList.remove('off'); }, 260); }, 2200);
})();
(function ticker() {
const items = [...M].sort((a, b) => b.knows - a.knows).map((m, i) => `<span class="tk"><i>#${i + 1}</i><b>${m.model}</b>knows <u>${f2(m.knows)}</u> · says <s>${f2(m.says)}</s></span>`).join('');
$('#tickerTrack').innerHTML = items + items + items + items;
})();
/* cross-section: right half is what the model knows, left half what it says it knows */
let xsAuto = null;
function arc(r, a0, a1) { const p = a => [(r * Math.cos(a)).toFixed(2), (r * Math.sin(a)).toFixed(2)]; const [x0, y0] = p(a0), [x1, y1] = p(a1); return `M${x0},${y0}A${r},${r} 0 0 1 ${x1},${y1}`; }
function drawXS(name) {
const m = by(name), S = D.timeline[name], s = svg(500, 500, `${name} cross-section. Knowledge ends ${m.know_end}, claims end ${m.claim_end}.`);
s.setAttribute('viewBox', '-250 -250 500 500'); s.classList.add('live');
let r = 20; add(s, 'circle', { r: 16, fill: mix(WOOD, TEAL, .12) });
const after = (p, end) => p.length > 4 && qn(p) > qn(end);
S.forEach((p, i) => {
const w = p.p.length === 4 ? 6.4 : 3.1, rr = r + w / 2;
const kCol = after(p.p, m.know_end) ? mix(WOOD, TEAL, .05) : mix(WOOD, TEAL, (p.acc - CH) / (1 - CH) * .94 + .06);
const cCol = after(p.p, m.claim_end) ? mix(WOOD, RUST, .05) : mix(WOOD, RUST, p.claim * .94 + .06);
const g = add(s, 'g', { class: 'grow' }); g.style.animationDelay = (i * 15) + 'ms';
add(g, 'path', { d: arc(rr, -Math.PI / 2, Math.PI / 2), fill: 'none', stroke: kCol, 'stroke-width': w - .45 });
add(g, 'path', { d: arc(rr, Math.PI / 2, Math.PI * 1.5), fill: 'none', stroke: cCol, 'stroke-width': w - .45 });
p._r0 = r; p._r1 = r + w; r += w;
});
const ring = (p, a0, a1, col) => { if (p) add(s, 'path', { d: arc(p._r1, a0, a1), fill: 'none', stroke: col, 'stroke-width': 2.2 }); };
ring(S.find(p => p.p === m.know_end), -Math.PI / 2, Math.PI / 2, '#10201c');
ring(S.find(p => p.p === m.claim_end), Math.PI / 2, Math.PI * 1.5, '#10201c');
add(s, 'line', { x1: 0, x2: 0, y1: -r - 2, y2: r + 2, stroke: '#fff', 'stroke-width': 2.5 });
s.addEventListener('pointermove', e => {
const b = s.getBoundingClientRect(), dx = (e.clientX - b.left) / b.width * 500 - 250, dy = (e.clientY - b.top) / b.height * 500 - 250, d = Math.hypot(dx, dy);
const p = S.find(q => d >= q._r0 && d < q._r1);
if (p) showTip(`<b>${p.p}</b><br>Knows ${f2(p.acc)}<br>Says ${f2(p.claim)}`, e); else hideTip();
});
s.addEventListener('pointerleave', hideTip);
$('#xs').replaceChildren(s);
$('#xsEnds').innerHTML = [['Knows', f2(m.knows)], ['Says', f2(m.says)], ['Knowledge ends', m.know_end], ['Claims end', m.claim_end]].map(([a, b]) => `<div><b>${b}</b>${a}</div>`).join('');
$$('#xsChips .chip').forEach(c => c.setAttribute('aria-selected', c.dataset.m === name));
}
$('#xsChips').innerHTML = FRONT.map(m => `<button class="chip" role="tab" data-m="${m.model}" type="button">${m.model}</button>`).join('');
$$('#xsChips .chip').forEach(c => c.onclick = () => { clearInterval(xsAuto); drawXS(c.dataset.m); });
drawXS(FRONT[0].model);
if (!reduce) { let i = 0; xsAuto = setInterval(() => { if (document.hidden) return; i = (i + 1) % FRONT.length; drawXS(FRONT[i].model); }, 6000); }
/* replay: the reader answers, then the recorded answers appear */
(function replay() {
const P = D.pool, tally = { You: [0, 0] }; FRONT.forEach(m => tally[m.model] = [0, 0]);
let i = 0, done = false;
const val = (f, r) => (f.opts.find(o => o[0] === r) || [, ''])[1].replace(/ USD$| JPY per USD$/, '');
const months = ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'];
function score() { const [a, b] = tally.You; $('#aScore').textContent = b ? `You ${a} of ${b}` : ''; }
function rows(f, show) {
$('#aRows').innerHTML = FRONT.map(m => {
const a = f.ans[m.model].r, t = tally[m.model];
if (!show) return `<div class="a-row"><span class="a-m">${m.model}</span><span class="a-v wait">?</span><span class="a-s wait">waiting for your answer</span><span class="a-tally"></span></div>`;
if (!a) return `<div class="a-row"><span class="a-m">${m.model}</span><span class="a-v wait">n/a</span><span class="a-s wait">gave no usable answer</span><span class="a-tally">${t[0]} of ${t[1]}</span></div>`;
const ok = a[1] === f.true;
return `<div class="a-row"><span class="a-m">${m.model}</span><span class="a-v ${ok ? 'ok' : 'no'}">${val(f, a[1])}</span><span class="a-s ${a[0] ? 'know' : 'deny'}">${a[0] ? '“I know this value.”' : '“I do not know this value.”'}</span><span class="a-tally">${t[0]} of ${t[1]}</span></div>`;
}).join('');
}
function load() {
const f = P[i]; done = false;
const [y, mo] = f.period.split('-');
$('#qKick').textContent = `${f.family} · ${months[+mo - 1]} ${y}`;
$('#qN').textContent = `Fact ${i + 1} of ${P.length}`;
$('#qText').textContent = f.q.replace(/ Use FRED series.*$| Round to .*$/, '');
const box = $('#qOpts'); box.replaceChildren();
[...f.opts].sort((a, b) => (+a[1].replace(/[^\d.-]/g, '') || 0) - (+b[1].replace(/[^\d.-]/g, '') || 0)).forEach(o => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'q-opt'; b.dataset.r = o[0];
const t = document.createElement('span'); t.textContent = o[1]; b.appendChild(t);
b.onclick = () => finish(o[0]); box.appendChild(b);
});
$('#qReveal').hidden = false; $('#qNext').hidden = true; $('#aPunch').hidden = true;
rows(f, false); score();
}
function finish(pick) {
if (done) return; done = true;
const f = P[i];
if (pick != null) { tally.You[1]++; if (pick === f.true) tally.You[0]++; }
FRONT.forEach(m => { const a = f.ans[m.model].r; if (a) { tally[m.model][1]++; if (a[1] === f.true) tally[m.model][0]++; } });
$$('.q-opt').forEach(b => { b.disabled = true; const r = +b.dataset.r; if (r === f.true) b.classList.add('right'); if (r === pick) b.classList.add('you'); });
rows(f, true); score();
const leak = FRONT.filter(m => { const a = f.ans[m.model].r; return a && !a[0] && a[1] === f.true; }).map(m => m.model);
const p = $('#aPunch');
if (leak.length) { p.textContent = `${leak.join(', ')} denied knowing it and picked the published value anyway.`; p.hidden = false; }
$('#qReveal').hidden = true; $('#qNext').hidden = false;
}
$('#qReveal').onclick = () => finish(null);
$('#qNext').onclick = () => { i = (i + 1) % P.length; load(); };
load();
})();
/* leaderboard */
const COLS = [['model', 'Model', 'left'], ['knows', 'Knows', 'heat'], ['says', 'Says', 'heat'], ['acc_says', 'Right when claiming', 'heat'], ['acc_denies', 'Right when denying', 'heat'], ['gap', 'Gap [95% CI]'], ['know_end', 'Knowledge ends'], ['claim_end', 'Claims end']];
let sortK = 'knows', sortDir = -1, openM = null;
const heat = v => { const t = Math.max(0, Math.min(1, (v - .15) / .8)); return `background:${mix(PALE, HEAT, t)};color:${t > .55 ? '#fff' : 'var(--ink)'}`; };
function detail(m) {
const fam = D.family[m.model];
const lines = fam ? Object.keys(fam).map(k => `<div class="dt-line"><span>${k}</span><b>knows ${f2(fam[k].know)} · says ${f2(fam[k].says)}</b></div>`).join('') : '<p>Not broken down for the small open models.</p>';
const lag = m.know_end ? quarters(m.know_end, m.claim_end) : null;
const read = m.know_end ? (lag > 1 ? `Keeps claiming knowledge for ${lag} quarters after it stops knowing.` : 'Its claims stop within a quarter of its knowledge.') : 'Accuracy sits too close to chance for the rule to locate an end.';
return `<div class="dt"><div><h4>Reading</h4><p>${read}</p><div class="dt-line" style="margin-top:8px"><span>Says when asked</span><b>${m.self_report || 'n/a'}</b></div><div class="dt-line"><span>Vendor cutoff</span><b>${m.vendor_cutoff || 'n/a'}</b></div></div>
<div><h4>By fact family</h4>${lines}</div>
<div><h4>With a declared date</h4><div class="dt-line"><span>Claims to already know</span><b>${f2(m.claims)} → ${f2(m.claims_declared)}</b></div><div class="dt-line"><span>Accuracy</span><b>${f2(m.acc)} → ${f2(m.acc_declared)}</b></div></div></div>`;
}
function renderLB() {
const rows = [...M].sort((a, b) => { const x = a[sortK], y = b[sortK]; if (x == null && y == null) return 0; if (x == null) return 1; if (y == null) return -1; return (x < y ? -1 : x > y ? 1 : 0) * sortDir; });
const na = '<span class="na">n/a</span>';
const head = `<thead><tr><th>#</th>${COLS.map(([k, t, c]) => `<th data-k="${k}" tabindex="0" role="button" class="${c === 'left' ? 'left' : ''}${k === sortK ? ' on' : ''}">${t}${k === sortK ? (sortDir < 0 ? ' ↓' : ' ↑') : ''}</th>`).join('')}<th></th></tr></thead>`;
const body = rows.map((m, i) => {
const lag = m.know_end && quarters(m.know_end, m.claim_end) > 1, open = openM === m.model;
return `<tr class="row${open ? ' open' : ''}" data-m="${m.model}" tabindex="0" aria-expanded="${open}"><td><span class="pos ${i < 3 ? 'p' + (i + 1) : ''}">${i + 1}</span></td>
<td class="left"><span class="mname">${m.model}</span><span class="mv">${m.vendor}</span>${m.small ? '<span class="tag">small</span>' : ''}</td>
${['knows', 'says', 'acc_says', 'acc_denies'].map(k => `<td><span class="heat" style="${heat(m[k])}">${f2(m[k])}</span></td>`).join('')}
<td>${f2(m.gap)}<span class="ci">[${f2(m.gap_lo)}, ${f2(m.gap_hi)}]</span></td>
<td>${m.know_end || na}</td><td class="${lag ? 'late' : ''}">${m.claim_end || na}</td><td><span class="chev">›</span></td></tr>` +
(open ? `<tr class="detail"><td colspan="${COLS.length + 2}">${detail(m)}</td></tr>` : '');
}).join('');
const t = $('#lb'); t.innerHTML = head + `<tbody>${body}</tbody>`;
$$('th[data-k]', t).forEach(th => { const go = () => { const k = th.dataset.k; if (k === sortK) sortDir = -sortDir; else { sortK = k; sortDir = k === 'model' ? 1 : -1; } renderLB(); }; th.onclick = go; th.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }; });
$$('tr.row', t).forEach(r => { const go = () => { openM = openM === r.dataset.m ? null : r.dataset.m; renderLB(); }; r.onclick = go; r.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } }; });
}
openM = FRONT[0].model; renderLB();
/* cutoff calendar: one row per model */
function drawCalendar() {
const el = $('#calendar'), W = Math.max(340, el.clientWidth || 900), small = W < 560, L = small ? 76 : 118, R = 54, T = 8, rowH = 58, x0 = 2021, x1 = 2025.25, H = T + rowH * FRONT.length + 24;
const s = svg(W, H, 'Knowledge end, claims end, self-reported and vendor cutoffs by model'); const x = v => L + (v - x0) / (x1 - x0) * (W - L - R);
for (let y = 2021; y <= 2025; y++) { add(s, 'line', { x1: x(y), x2: x(y), y1: T, y2: H - 22, stroke: 'rgba(14,84,70,.09)' }); add(s, 'text', { x: x(y), y: H - 6, 'text-anchor': 'middle', 'font-size': 11.5, fill: 'var(--soft)' }, y); }
FRONT.forEach((m, i) => {
const y = T + rowH * (i + .5), kx = x(qX(m.know_end)), cx = x(qX(m.claim_end));
add(s, 'text', { x: L - 14, y: y + 4, 'text-anchor': 'end', 'font-size': small ? 11 : 13, 'font-weight': 500, fill: 'var(--ink)' }, small ? m.model.replace('-70B', '').replace('DeepSeek-', 'DS-') : m.model);
add(s, 'line', { x1: L, x2: W - R, y1: y + rowH / 2, y2: y + rowH / 2, stroke: 'rgba(14,84,70,.07)' });
if (cx > kx) add(s, 'rect', { x: kx, y: y - 18, width: cx - kx, height: 36, style: 'fill:var(--says);opacity:.16', rx: 3 });
const lag = quarters(m.know_end, m.claim_end);
if (lag > 1) add(s, 'text', { x: (kx + cx) / 2, y: y + 4, 'text-anchor': 'middle', 'font-size': 12, 'font-weight': 500, fill: '#b34a17' }, `${lag} quarters`);
const hit = (cxv, label, val, draw) => { draw(); const r = add(s, 'circle', { cx: cxv, cy: y, r: 16, fill: 'transparent' }); r.dataset.tip = `<b>${m.model}</b><br>${label}: ${val}`; };
if (m.vendor_cutoff) { const v = x(ymX(m.vendor_cutoff)); hit(v, 'Vendor cutoff', m.vendor_cutoff, () => add(s, 'rect', { x: v - 5, y: y + 8, width: 10, height: 10, style: 'fill:#fff;stroke:var(--vendor);stroke-width:2' })); }
const sv = x(ymX(m.self_report)); hit(sv, 'Says when asked', m.self_report, () => add(s, 'path', { d: `M${sv},${y - 19}l6,11h-12z`, style: 'fill:var(--ink)' }));
hit(cx, 'Claims end', m.claim_end, () => add(s, 'rect', { x: cx - 5.5, y: y - 5.5, width: 11, height: 11, transform: `rotate(45 ${cx} ${y})`, style: 'fill:var(--says);stroke:#fff;stroke-width:1.5' }));
hit(kx, 'Knowledge ends', m.know_end, () => add(s, 'circle', { cx: kx, cy: kx === cx ? y - 11 : y, r: 6.5, style: 'fill:var(--knows);stroke:#fff;stroke-width:1.5' }));
});
el.replaceChildren(s);
}
/* core chart */
let coreM = FRONT[0].model;
function drawCore() {
const el = $('#coreChart'), m = by(coreM), W = Math.max(320, el.clientWidth || 900), H = W < 560 ? 240 : 310, L = 36, R = 12, T = 12, B = 26, x0 = 2015, x1 = 2026.9;
const pts = D.timeline[coreM].filter(r => r.x >= x0);
const s = svg(W, H, `${coreM}: accuracy and claim rate from ${x0}`); s.classList.add('live');
const x = v => L + (v - x0) / (x1 - x0) * (W - L - R), y = v => T + (1 - v) * (H - T - B);
[0, .25, .5, .75, 1].forEach(v => { add(s, 'line', { x1: L, x2: W - R, y1: y(v), y2: y(v), stroke: 'rgba(14,84,70,.09)' }); add(s, 'text', { x: L - 7, y: y(v) + 4, 'text-anchor': 'end', 'font-size': 11, fill: 'var(--soft)' }, v.toFixed(2)); });
for (let yr = 2016; yr <= 2026; yr += W < 560 ? 2 : 1) add(s, 'text', { x: x(yr + .5), y: H - 7, 'text-anchor': 'middle', 'font-size': 11, fill: 'var(--soft)' }, yr);
add(s, 'line', { x1: L, x2: W - R, y1: y(CH), y2: y(CH), style: 'stroke:var(--chance)', 'stroke-width': 1.3, 'stroke-dasharray': '3 3' });
const kx = x(qX(m.know_end)), cx = x(qX(m.claim_end));
if (cx > kx) add(s, 'rect', { x: kx, y: T, width: cx - kx, height: H - T - B, style: 'fill:var(--says);opacity:.1' });
add(s, 'line', { x1: kx, x2: kx, y1: T, y2: H - B, style: 'stroke:var(--knows)', 'stroke-width': 2 });
add(s, 'line', { x1: cx, x2: cx, y1: T, y2: H - B, style: 'stroke:var(--says)', 'stroke-width': 2, 'stroke-dasharray': m.know_end === m.claim_end ? '5 5' : 'none' });
const path = k => pts.map((r, i) => `${i ? 'L' : 'M'}${x(r.x).toFixed(1)},${y(r[k]).toFixed(1)}`).join('');
[['claim', 'var(--says)'], ['acc', 'var(--knows)']].forEach(([k, c]) => {
const p = add(s, 'path', { d: path(k), fill: 'none', style: `stroke:${c}`, 'stroke-width': 2.4, 'stroke-linejoin': 'round' });
if (!reduce && drawCore.anim) { const n = 2600; p.style.strokeDasharray = n; p.style.strokeDashoffset = n; requestAnimationFrame(() => { p.style.transition = 'stroke-dashoffset 1.1s ease-out'; p.style.strokeDashoffset = 0; }); }
});
drawCore.anim = false;
const cross = add(s, 'line', { y1: T, y2: H - B, stroke: 'rgba(16,32,28,.35)', visibility: 'hidden' });
const dA = add(s, 'circle', { r: 4.5, style: 'fill:var(--knows);stroke:#fff;stroke-width:1.5', visibility: 'hidden' }), dC = add(s, 'circle', { r: 4.5, style: 'fill:var(--says);stroke:#fff;stroke-width:1.5', visibility: 'hidden' });
el.replaceChildren(s);
s.addEventListener('pointermove', e => {
const b = s.getBoundingClientRect(), mx = (e.clientX - b.left) / b.width * W; let r = pts[0]; pts.forEach(p => { if (Math.abs(x(p.x) - mx) < Math.abs(x(r.x) - mx)) r = p; });
cross.setAttribute('x1', x(r.x)); cross.setAttribute('x2', x(r.x)); dA.setAttribute('cx', x(r.x)); dA.setAttribute('cy', y(r.acc)); dC.setAttribute('cx', x(r.x)); dC.setAttribute('cy', y(r.claim));
[cross, dA, dC].forEach(n => n.setAttribute('visibility', 'visible')); showTip(`<b>${r.p}</b> · ${r.n} facts<br>Accuracy ${f2(r.acc)}<br>Claim rate ${f2(r.claim)}`, e);
});
s.addEventListener('pointerleave', () => { [cross, dA, dC].forEach(n => n.setAttribute('visibility', 'hidden')); hideTip(); });
$('#coreNote').textContent = `Yearly up to 2020 and quarterly after. ${coreM} knowledge ends ${m.know_end} and claims end ${m.claim_end}.`;
$$('#coreChips .chip').forEach(c => c.setAttribute('aria-selected', c.dataset.m === coreM));
}
$('#coreChips').innerHTML = FRONT.map(m => `<button class="chip" role="tab" data-m="${m.model}" type="button">${m.model}</button>`).join('');
$$('#coreChips .chip').forEach(c => c.onclick = () => { coreM = c.dataset.m; drawCore.anim = true; drawCore(); });
/* fact families */
(function fam() {
const names = Object.keys(D.family), order = ['Fed decisions', 'Macroeconomic', 'Stock prices'];
$('#fam').innerHTML = order.map(f => `<div class="card"><h3>${f}</h3><div class="fam-sub"><span><i style="background:var(--knows)"></i>Knows</span><span><i style="background:var(--says)"></i>Says</span></div>${names.map(m => {
const v = D.family[m][f]; return `<div class="fr"><span>${m}</span><div class="fr-b"><div class="fb k" data-tip="<b>${m}</b><br>Knows ${f2(v.know)}"><i style="width:${v.know * 100}%"></i>${f2(v.know)}</div><div class="fb c" data-tip="<b>${m}</b><br>Says ${f2(v.says)}"><i style="width:${v.says * 100}%"></i>${f2(v.says)}</div></div></div>`;
}).join('')}</div>`).join('');
})();
/* declared date toggle */
(function declared() {
const row = (m, k, c) => `<div class="hb-r"><span>${m.model}</span><div class="hb-t"><i data-a="${m[k]}" data-b="${m[k + '_declared']}" style="background:${c}"></i></div><span class="hb-v" data-a="${f2(m[k])}" data-b="${f2(m[k + '_declared'])}"></span></div>`;
$('#declC').innerHTML = M.map(m => row(m, 'claims', 'var(--says)')).join('');
$('#declA').innerHTML = M.map(m => row(m, 'acc', 'var(--knows)')).join('');
const sw = $('#dateSw');
const set = on => { sw.setAttribute('aria-checked', on); $('#dateCode').classList.toggle('on', on); $$('.hb-t i').forEach(i => i.style.width = (on ? i.dataset.b : i.dataset.a) * 100 + '%'); $$('.hb-v').forEach(v => v.textContent = on ? v.dataset.b : v.dataset.a); };
sw.onclick = () => set(sw.getAttribute('aria-checked') !== 'true');
set(false);
})();
/* point-in-time */
(function pit() {
const lo = -.08, hi = .12, x = v => (v - lo) / (hi - lo) * 100;
$('#pitT').innerHTML = `<thead><tr><th class="left">Model</th><th>Cutoff</th><th>Up to cutoff</th><th>After</th><th>Difference [95% CI]</th><th class="left">Interval around zero</th></tr></thead><tbody>` +
D.pit.map(r => `<tr><td class="left mname">${r.model}</td><td>${r.cutoff}</td><td>${f2(r.before)}</td><td>${f2(r.after)}</td><td>${f3(r.diff)}<span class="ci">[${f3(r.lo)}, ${f3(r.hi)}]</span></td><td class="left"><span class="forest" data-tip="<b>${r.model} ${r.cutoff}</b><br>Difference ${f3(r.diff)}"><s style="left:${x(0)}%"></s><i style="left:${x(r.lo)}%;width:${x(r.hi) - x(r.lo)}%"></i><b style="left:${x(r.diff)}%"></b></span></td></tr>`).join('') + '</tbody>';
})();
const drawAll = () => { drawCalendar(); drawCore(); };
drawAll();
let rz; addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(drawAll, 120); });