multi-harness-rl-slides / source /src /embeds /d3-posttraining-timeline.html
sergiopaniego's picture
sergiopaniego HF Staff
Publish the deck source under source/ so the next deck can start from it
3bc016b verified
Raw History Blame Contribute Delete
10.4 kB
<div class="posttraining-timeline" style="width:100%;margin:14px 0;"></div>
<style>
.posttraining-timeline { position: relative; }
.posttraining-timeline svg { display:block; width:100%; height:auto; overflow:visible; }
.posttraining-timeline .pt-bar { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.posttraining-timeline .pt-btn { appearance:none; cursor:pointer; font:inherit; font-size:12px; line-height:1; padding:7px 12px;
border-radius:999px; border:1px solid var(--border-color); background:transparent; color:var(--muted-color); }
.posttraining-timeline .pt-btn:hover { color:var(--text-color); }
.posttraining-timeline .pt-btn:focus-visible { outline:2px solid var(--text-color); outline-offset:2px; }
.posttraining-timeline .pt-cap { min-height:40px; margin-top:6px; font-size:13px; line-height:1.45; color:var(--text-color); }
.posttraining-timeline .pt-cap b { font-weight:600; }
.posttraining-timeline .pt-cap span { color:var(--muted-color); }
.posttraining-timeline .pt-cap .lv { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; border:1px solid currentColor; margin-left:6px; vertical-align:1px; }
</style>
<script>
(() => {
const C = 'posttraining-timeline';
const boot = () => {
const scriptEl = document.currentScript;
let r = scriptEl ? scriptEl.previousElementSibling : null;
while (r && !(r.classList && r.classList.contains(C))) r = r.previousElementSibling;
if (!r) { const n = [...document.querySelectorAll('.'+C)].filter(e => e.dataset.mounted !== 'true'); r = n[n.length-1]; }
if (!r || r.dataset.mounted === 'true') return;
r.dataset.mounted = 'true';
const V = (k,f) => (getComputedStyle(r).getPropertyValue(k).trim() || f);
const esc = (t) => String(t).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const P = (i) => {
if (window.ColorPalettes && window.ColorPalettes.categorical) {
try { return window.ColorPalettes.categorical(4)[i%4]; } catch(e) {}
}
return ['rgb(78,165,183)','rgb(146,176,234)','rgb(206,192,250)','rgb(232,137,171)'][i%4];
};
const grab = async (f) => {
for (const u of ['/data/'+f, 'assets/data/'+f, '../assets/data/'+f]) {
try { const x = await fetch(u, {cache:'no-cache'}); if (x.ok) return await x.json(); } catch(e) {}
}
throw new Error('could not load '+f);
};
const mIndex = (d) => { const [y, m] = d.split('-').map(Number); return y*12 + (m-1); };
const mLabel = (i) => new Date(Date.UTC(Math.floor(i/12), i%12, 1)).toLocaleString('en', { month:'short', timeZone:'UTC' });
grab('harness-disclosure.json').then(D => {
const L = D.levels, items = D.items;
const m0 = mIndex(items[0].date) - 1, m1 = mIndex(items[items.length-1].date) + 1;
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
r.innerHTML = `<div class="pt-bar"><button type="button" class="pt-btn" data-act="play"></button></div>`
+ `<div data-svg></div><div class="pt-cap" aria-live="polite"></div>`;
const svgHost = r.querySelector('[data-svg]'), cap = r.querySelector('.pt-cap'), play = r.querySelector('[data-act="play"]');
// The playhead moves in "release steps": each release gets the same time on
// screen, however many months separate it from the last one.
const STEP = 1700, HOLD = 3500;
const TOTAL = items.length*STEP + HOLD;
let clock = reduce ? TOTAL - 1 : 0, last = 0, playing = !reduce, visible = false, pinned = -1;
const draw = () => {
const W = Math.max(320, r.clientWidth);
const narrow = W < 560;
const mu = V('--muted-color','rgba(128,128,128,.8)'), bd = V('--border-color','rgba(128,128,128,.3)');
const tx = V('--text-color','#16181d'), sf = V('--surface-bg','#fff');
const padL = narrow ? 8 : 112, padR = 10, top = 8, band = narrow ? 34 : 40, H = top + band*L.length + 30;
const x = (m) => padL + (m - m0) / (m1 - m0) * (W - padL - padR);
const y = (lv) => top + band*(L.length - 1 - lv) + band/2;
const t = clock % TOTAL;
const shown = Math.min(items.length, Math.floor(t / STEP) + 1); // releases revealed so far
const cur = pinned >= 0 ? pinned : shown - 1;
const k = Math.min(1, (t % STEP) / 420); // pop-in of the newest dot
let s = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${items.length} releases from ${esc(items[0].date)} to ${esc(items[items.length-1].date)}, rising from naming no harness to training across several">`;
// level bands
L.forEach((lv, i) => {
const yy = y(i);
s += `<line x1="${padL}" x2="${W-padR}" y1="${yy}" y2="${yy}" stroke="${bd}" stroke-dasharray="2 4"/>`;
if (!narrow) s += `<text x="${padL-10}" y="${yy+4}" text-anchor="end" font-size="11" fill="${i===3 ? P(3) : mu}">${esc(lv)}</text>`;
else s += `<text x="${padL}" y="${yy-7}" font-size="9.5" fill="${mu}">${esc(lv)}</text>`;
});
// month axis, labelled every quarter, years under January
for (let m = m0 + 1; m < m1; m++) {
const mm = m % 12;
if (mm % 3 !== 0 && m !== m0 + 1) continue;
const xx = x(m);
s += `<line x1="${xx}" x2="${xx}" y1="${H-26}" y2="${H-22}" stroke="${mu}"/>`;
s += `<text x="${xx}" y="${H-10}" text-anchor="middle" font-size="10" fill="${mu}">${mLabel(m)}${mm === 0 || m === m0 + 1 ? ' ' + Math.floor(m/12) : ''}</text>`;
}
// the highest level reached so far, as a rising step line
let sp = '', b = -1, px = null;
items.slice(0, shown).forEach(it => {
const xx = x(mIndex(it.date));
if (b < 0) { b = it.level; sp = `M ${xx} ${y(b)}`; }
else if (it.level > b) { sp += ` L ${xx} ${y(b)} L ${xx} ${y(it.level)}`; b = it.level; }
px = xx;
});
const headX = x(m0 + (shown >= items.length ? m1 - m0 : (mIndex(items[Math.min(shown, items.length-1)].date) - m0) * Math.min(1, (t % STEP)/STEP) + (mIndex(items[shown-1].date) - m0) * (1 - Math.min(1, (t % STEP)/STEP))));
if (b >= 0) sp += ` L ${Math.max(px, Math.min(headX, W-padR))} ${y(b)}`;
s += `<path d="${sp}" fill="none" stroke="${P(3)}" stroke-opacity=".55" stroke-width="2"/>`;
// playhead
if (playing && pinned < 0 && shown < items.length) s += `<line x1="${headX}" x2="${headX}" y1="${top}" y2="${H-26}" stroke="${tx}" stroke-opacity=".25"/>`;
// dots, stacked slightly when two releases share a month and level
const seen = {};
items.forEach((it, i) => {
if (i >= shown) return;
const key = it.date + '|' + it.level; const n = seen[key] = (seen[key] || 0) + 1;
const xx = x(mIndex(it.date)) + (n - 1) * 9, yy = y(it.level) - (n - 1) * 4;
const isCur = i === cur, grow = i === shown - 1 && pinned < 0 ? (reduce ? 1 : k) : 1;
const c = P(it.level === 3 ? 3 : it.level === 2 ? 2 : it.level === 1 ? 1 : 0);
s += `<circle cx="${xx}" cy="${yy}" r="${(isCur ? 7 : 5) * grow}" fill="${c}" stroke="${sf}" stroke-width="1.5" data-i="${i}" style="cursor:pointer"/>`;
if (isCur) s += `<circle cx="${xx}" cy="${yy}" r="11" fill="none" stroke="${c}" stroke-opacity=".6"/>`;
if (isCur && !narrow) {
const right = xx < W*0.7;
s += `<text x="${xx + (right ? 14 : -14)}" y="${yy+4}" text-anchor="${right ? 'start' : 'end'}" font-size="11.5" font-weight="600" fill="${tx}">${esc(it.label)}</text>`;
}
});
s += `</svg>`;
svgHost.innerHTML = s;
svgHost.querySelectorAll('circle[data-i]').forEach(el => {
el.addEventListener('pointerenter', () => { const i = +el.dataset.i; if (pinned !== i) { pinned = i; draw(); } });
el.addEventListener('click', () => { pinned = +el.dataset.i; playing = false; draw(); });
});
const it = items[cur], lc = P(it.level === 3 ? 3 : it.level);
cap.innerHTML = `<b>${esc(it.label)}</b> <span>${esc(new Date(it.date + '-01T00:00:00Z').toLocaleString('en', { month:'short', year:'numeric', timeZone:'UTC' }))}</span>`
+ `<span class="lv" style="color:${lc}">${esc(L[it.level])}</span><br>${esc(it.detail)}`;
play.textContent = playing ? 'Pause' : (shown >= items.length && pinned < 0 ? 'Replay' : 'Play');
};
play.addEventListener('click', () => {
if (!playing && (clock % TOTAL) >= items.length*STEP) clock = 0;
playing = !playing; pinned = -1; draw();
});
svgHost.addEventListener('pointerleave', () => { if (pinned >= 0 && playing) { pinned = -1; draw(); } });
const tick = (ts) => {
if (!last) last = ts;
const dt = Math.min(64, ts - last); last = ts;
if (playing && visible && pinned < 0 && !document.hidden) {
clock += dt;
// Stop on the full picture at the end rather than looping back to empty.
if (clock >= items.length*STEP) { clock = items.length*STEP; playing = false; }
draw();
}
requestAnimationFrame(tick);
};
draw();
if (!reduce) requestAnimationFrame(tick);
if ('IntersectionObserver' in window) {
new IntersectionObserver((es) => { visible = es.some(e => e.isIntersecting); }, { threshold: .35 }).observe(r);
} else visible = true;
// Colours are read when drawing, so redraw when the reader switches theme.
new MutationObserver(draw).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
if ('ResizeObserver' in window) { let tm; new ResizeObserver(() => { clearTimeout(tm); tm = setTimeout(draw, 100); }).observe(r); }
}).catch(e => { r.innerHTML = `<pre style="color:#c33;font-size:12px">${esc(e.message)}</pre>`; });
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); else boot();
})();
</script>