Commit 24606d49 by PLN (Algolia)

feat(tide-table): self-updating corpus essay — template Story 02 from EDA + freshness badge (#17)

PLN flagged the "2024, everything at once" stats as stale. Root cause: the chart
BARS were data-driven but the PROSE around them was frozen string literals
("334 sample folders", "tripled to 14", "2 → 4 → 14 → 14 → 3-so-far", the section
title, the chart tooltips, the magenta-highlight year). They'd drift the moment
the corpus moved. Now every one of them derives from EDA at render:

- breakoutFacts() reads vocabulary_growth + cadence → peak import month/count, the
  breakout year (argmax gigs), the year-over-year multiple, and the gig sequence.
- Story 02's title, dek, legend, note, the magenta band, and both chart tooltips
  are templated from it. parsers-over-copy: no number is typed by hand.
- Honesty fix surfaced by the templating: 4→14 gigs is 3.5×, not "tripled" (the old
  hand-copy undersold) nor "quadrupled" (a naive round oversells). The multiple-word
  only fires when the ratio is within 0.15 of an integer; otherwise it states the
  exact "grew 3.5×". Trust the instrument (DESIGN principle 1).
- New freshness badge in the hero reads EDA.as_of and shows days-since (green ≤14d,
  amber ≤30d) plus the one-line refresh command (python3 tide.py build). Staleness
  is now visible and curable instead of silent — the data is 17 days old today.

Verified in Chromium via Playwright: title/dek/badge all render from data, no
console errors. Follow-up (needs PLN's call): a one-click rebuild from the Bridge
(/api/rebuild → tide.py build) vs the current command hint.
parent 0ca81596
...@@ -70,7 +70,13 @@ h2{font-size:clamp(1.9rem,4.4vw,3rem);text-wrap:balance;margin-bottom:.5rem} ...@@ -70,7 +70,13 @@ h2{font-size:clamp(1.9rem,4.4vw,3rem);text-wrap:balance;margin-bottom:.5rem}
.stat:last-child{border-right:0;margin-right:0} .stat:last-child{border-right:0;margin-right:0}
.stat .v{font:600 1.9rem/1 "Geist Mono",monospace;color:var(--ink);letter-spacing:-0.02em} .stat .v{font:600 1.9rem/1 "Geist Mono",monospace;color:var(--ink);letter-spacing:-0.02em}
.stat .l{font-size:.78rem;color:var(--faint);margin-top:.35rem;letter-spacing:.02em} .stat .l{font-size:.78rem;color:var(--faint);margin-top:.35rem;letter-spacing:.02em}
.scrollcue{margin-top:3.4rem;font:500 12px/1 "Geist Mono",monospace; .fresh{margin-top:1.8rem;display:inline-flex;align-items:center;gap:.6rem;
font:500 11.5px/1 "Geist Mono",monospace;color:var(--faint);letter-spacing:.02em;
border:1px solid var(--hairline);border-radius:99px;padding:6px 12px}
.fresh .dot{width:7px;height:7px;border-radius:50%;background:var(--f-c,#5bc091)}
.fresh b{color:var(--mute);font-weight:500}
.fresh code{font-size:.92em;padding:0 4px}
.scrollcue{margin-top:2.2rem;font:500 12px/1 "Geist Mono",monospace;
color:var(--faint);letter-spacing:.1em;display:flex;align-items:center;gap:.7rem} color:var(--faint);letter-spacing:.1em;display:flex;align-items:center;gap:.7rem}
.scrollcue .arr{animation:bob 2.4s ease-in-out infinite} .scrollcue .arr{animation:bob 2.4s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(4px)}} @keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(4px)}}
...@@ -218,6 +224,7 @@ svg [data-tip]:hover{filter:brightness(1.35) saturate(1.1)} ...@@ -218,6 +224,7 @@ svg [data-tip]:hover{filter:brightness(1.35) saturate(1.1)}
Every tempo, sample and phrase below comes from parsing the <code>.tidal</code> Every tempo, sample and phrase below comes from parsing the <code>.tidal</code>
files themselves. Hover anything to see the tracks behind it.</p> files themselves. Hover anything to see the tracks behind it.</p>
<div class="stats" id="stats"></div> <div class="stats" id="stats"></div>
<div class="fresh" id="fresh" hidden></div>
<div class="scrollcue"><span class="arr"></span> scroll to read</div> <div class="scrollcue"><span class="arr"></span> scroll to read</div>
</div> </div>
</header> </header>
...@@ -470,6 +477,7 @@ function chartHist(el,W,animate){ ...@@ -470,6 +477,7 @@ function chartHist(el,W,animate){
══════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════ */
function chartBreakout(el,W,animate){ function chartBreakout(el,W,animate){
const voc=EDA.vocabulary_growth, cad=EDA.cadence; const voc=EDA.vocabulary_growth, cad=EDA.cadence;
const bf=breakoutFacts();
const H=Math.max(340,Math.min(460,W*0.5)); const H=Math.max(340,Math.min(460,W*0.5));
const m={l:44,r:20,t:26,b:42}; const m={l:44,r:20,t:26,b:42};
const iw=W-m.l-m.r; const iw=W-m.l-m.r;
...@@ -486,8 +494,8 @@ function chartBreakout(el,W,animate){ ...@@ -486,8 +494,8 @@ function chartBreakout(el,W,animate){
const Xy=y=>m.l+((y-minY)*12)/totM*iw; const Xy=y=>m.l+((y-minY)*12)/totM*iw;
const svg=E("svg",{viewBox:`0 0 ${W} ${H}`}); const svg=E("svg",{viewBox:`0 0 ${W} ${H}`});
// magenta breakout band over 2024 // magenta breakout band over the breakout year (data-derived)
const bx=Xy(2024), bw=12/totM*iw; const bx=Xy(Number(bf.breakoutYear)), bw=12/totM*iw;
svg.appendChild(E("rect",{x:bx,y:m.t-6,width:bw,height:H-m.t-m.b+12, svg.appendChild(E("rect",{x:bx,y:m.t-6,width:bw,height:H-m.t-m.b+12,
fill:"var(--magenta)",opacity:0.07})); fill:"var(--magenta)",opacity:0.07}));
svg.appendChild(TX(bx+bw/2,m.t-12,"the breakout","dlabel","middle")).setAttribute("fill","var(--magenta)"); svg.appendChild(TX(bx+bw/2,m.t-12,"the breakout","dlabel","middle")).setAttribute("fill","var(--magenta)");
...@@ -498,12 +506,13 @@ function chartBreakout(el,W,animate){ ...@@ -498,12 +506,13 @@ function chartBreakout(el,W,animate){
for(const[y,n]of Object.entries(cad)){ for(const[y,n]of Object.entries(cad)){
const x0=Xy(Number(y)), x1=Xy(Number(y)+1), cw=(x1-x0)*0.62, cx=x0+(x1-x0-cw)/2; const x0=Xy(Number(y)), x1=Xy(Number(y)+1), cw=(x1-x0)*0.62, cx=x0+(x1-x0-cw)/2;
const bh=Math.max(2,(n/gmx)*topH), yy=topY+topH-bh; const bh=Math.max(2,(n/gmx)*topH), yy=topY+topH-bh;
const hot=Number(y)===2024; const hot=y===bf.breakoutYear;
const lastY=Number(y)===maxY;
gTop.appendChild(tipFor(E("rect",{x:cx,y:yy,width:cw,height:bh,rx:4, gTop.appendChild(tipFor(E("rect",{x:cx,y:yy,width:cw,height:bh,rx:4,
fill:hot?"var(--magenta)":"#ffffff24","data-anim":"bary","data-ox":cx,"data-oy":topY+topH}), fill:hot?"var(--magenta)":"#ffffff24","data-anim":"bary","data-ox":cx,"data-oy":topY+topH}),
`${y}`,`${n} gig${n>1?"s":""} played`, `${y}`,`${n} gig${n>1?"s":""} played`,
hot?"the breakout year: gigs jumped from 4 to 14": hot?(bf.prev?`the breakout year: gigs jumped from ${bf.prev} to ${bf.maxGig}`:"the breakout year"):
(Number(y)===2026?"2026 so far, the year is still running":""))); (lastY?`${y} so far, the year is still running`:"")));
gTop.appendChild(TX(x0+(x1-x0)/2,yy-6,n,"barv","middle")); gTop.appendChild(TX(x0+(x1-x0)/2,yy-6,n,"barv","middle"));
} }
svg.appendChild(gTop); svg.appendChild(gTop);
...@@ -515,13 +524,13 @@ function chartBreakout(el,W,animate){ ...@@ -515,13 +524,13 @@ function chartBreakout(el,W,animate){
const gBot=E("g"); const gBot=E("g");
for(const[ym,n]of Object.entries(voc)){ for(const[ym,n]of Object.entries(voc)){
const x=Xm(mi(ym)), bh=Math.max(1.5,(n/vmx)*botH), yy=botY+botH-bh; const x=Xm(mi(ym)), bh=Math.max(1.5,(n/vmx)*botH), yy=botY+botH-bh;
const hot=ym.startsWith("2024-07"); const hot=ym===bf.breakoutMonth;
const r=E("rect",{x:x-Math.max(cw,5)/2,y:yy,width:Math.max(cw,5),height:bh,rx:2, const r=E("rect",{x:x-Math.max(cw,5)/2,y:yy,width:Math.max(cw,5),height:bh,rx:2,
fill:hot?"var(--magenta)":"#ffffff2e","data-anim":"bary","data-ox":x,"data-oy":botY+botH}); fill:hot?"var(--magenta)":"#ffffff2e","data-anim":"bary","data-ox":x,"data-oy":botY+botH});
const[yr,mo]=ym.split("-"); const[yr,mo]=ym.split("-");
const moName=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][+mo-1]; const moName=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][+mo-1];
tipFor(r,`${moName} ${yr}`,`${n} sample folder${n>1?"s":""} imported`, tipFor(r,`${moName} ${yr}`,`${n} sample folder${n>1?"s":""} imported`,
hot?"the burst: 334 folders linked in one month, a wholesale palette change":""); hot?`the burst: ${bf.peakN} folders linked in one month, a wholesale palette change`:"");
gBot.appendChild(r); gBot.appendChild(r);
if(n>=80)gBot.appendChild(TX(x,yy-6,n,"barv","middle")); if(n>=80)gBot.appendChild(TX(x,yy-6,n,"barv","middle"));
} }
...@@ -718,6 +727,30 @@ function chartStaples(el,W,animate){ ...@@ -718,6 +727,30 @@ function chartStaples(el,W,animate){
if(animate)animBars(g); if(animate)animBars(g);
} }
/* breakout facts — derive every number in Story 02 from the data, so the prose
never goes stale against the charts (parsers-over-copy). */
function breakoutFacts(){
const voc=EDA.vocabulary_growth||{}, cad=EDA.cadence||{};
let peakM=null,peakN=0; for(const[m,n]of Object.entries(voc))if(n>peakN){peakN=n;peakM=m;}
const years=Object.keys(cad).sort();
const vals=years.map(y=>cad[y]);
const maxGig=Math.max(...vals,0);
const bi=vals.indexOf(maxGig), breakoutYear=years[bi];
const prev=bi>0?cad[years[bi-1]]:null;
const mult=prev?maxGig/prev:null;
// only use a round word when the multiple is actually near an integer; else be exact
const words={2:"doubled",3:"tripled",4:"quadrupled"};
const near=mult?Math.round(mult):0;
const multWord=!mult?"climbed":(Math.abs(mult-near)<=0.15&&words[near])?words[near]
:`grew ${(Math.round(mult*10)/10)}×`;
const monthName=peakM?new Date(peakM+"-01T00:00").toLocaleString("en",
{month:"long",year:"numeric"}):"";
const breakoutMonth=peakM?peakM.slice(0,7):null;
const seq=years.map((y,i)=>{const v=cad[y];const s=(i===years.length-1)?`${v}-so-far`:`${v}`;
return v===maxGig?`<b>${s}</b>`:s;}).join(" → ");
return {peakN,monthName,breakoutYear,breakoutMonth,maxGig,prev,multWord,seq};
}
/* ── story scaffolding ────────────────────────────────────────────────────── */ /* ── story scaffolding ────────────────────────────────────────────────────── */
const STORIES=[ const STORIES=[
{id:"creep",n:"01",t:"The slow climb",lens:true, {id:"creep",n:"01",t:"The slow climb",lens:true,
...@@ -736,16 +769,22 @@ const STORIES=[ ...@@ -736,16 +769,22 @@ const STORIES=[
note(s,`Tempo is parsed from each track's <code>setcps</code>. A few sit at exactly half or note(s,`Tempo is parsed from each track's <code>setcps</code>. A few sit at exactly half or
double their tagged BPM: that's half-time feel written one way and counted another, kept honest here.`); double their tagged BPM: that's half-time feel written one way and counted another, kept honest here.`);
}}, }},
{id:"breakout",n:"02",t:"2024, everything at once",lens:false, {id:"breakout",n:"02",t:"Everything at once",lens:false,
dek:`One summer the whole project changed gear. In <b>July 2024</b> I imported dek:`One summer the whole project changed gear.`,
<b>334 sample folders</b> in a single burst; the same year the gigs <b>tripled to 14</b>.`,
build:s=>{ build:s=>{
const bf=breakoutFacts();
// title + dek + legend are templated from the data so they never drift
const h=s.querySelector("h2"); if(h)h.textContent=`${bf.breakoutYear}, everything at once`;
const dek=s.querySelector(".dek");
if(dek)dek.innerHTML=`One summer the whole project changed gear. In <b>${bf.monthName}</b> I
imported <b>${bf.peakN} sample folders</b> in a single burst; the same year the gigs
<b>${bf.multWord}${bf.prev?` to ${bf.maxGig}`:""}</b>.`;
chartBox(s,chartBreakout,true); chartBox(s,chartBreakout,true);
legend(s,[["sample folders / month","#ffffff66","▏"],["gigs / year","#ffffff66","▏"], legend(s,[["sample folders / month","#ffffff66","▏"],["gigs / year","#ffffff66","▏"],
["2024","var(--magenta)","■"]]); [bf.breakoutYear,"var(--magenta)","■"]]);
note(s,`Sample-folder dates come from the Dirt-Samples symlink mtimes, reliable only from note(s,`Sample-folder dates come from the Dirt-Samples symlink mtimes, reliable only from
mid-2024 on, so the import timeline starts there. The gig count spans the whole run and mid-2024 on, so the import timeline starts there. The gig count spans the whole run and
is the steadier signal: 2 → 4 → <b>14</b> → 14 → 3-so-far.`); is the steadier signal: ${bf.seq}.`);
}}, }},
{id:"palette",n:"03",t:"What it's made of",lens:false, {id:"palette",n:"03",t:"What it's made of",lens:false,
dek:`<b>Breaks lead</b>, then synths and snares. Across every track's score, the sound palette dek:`<b>Breaks lead</b>, then synths and snares. Across every track's score, the sound palette
...@@ -820,6 +859,18 @@ function buildStats(){ ...@@ -820,6 +859,18 @@ function buildStats(){
items.forEach(([v,l])=>{const s=div(host,"stat"); items.forEach(([v,l])=>{const s=div(host,"stat");
s.innerHTML=`<div class="v">${v}</div><div class="l">${l}</div>`;}); s.innerHTML=`<div class="v">${v}</div><div class="l">${l}</div>`;});
} }
function buildFresh(){
const el=document.getElementById("fresh"); if(!el||!EDA.as_of)return;
// days since the data was generated (browser Date is fine here, unlike workflow scripts)
const asOf=new Date(EDA.as_of+"T00:00");
const days=Math.max(0,Math.round((Date.now()-asOf)/864e5));
const col=days<=14?"#5bc091":days<=30?"#e0a82e":"#9a9aa2";
const ago=days===0?"today":days===1?"yesterday":`${days} days ago`;
el.style.setProperty("--f-c",col);
el.innerHTML=`<span class="dot"></span><b>data as of ${EDA.as_of}</b> · ${ago}
· refresh with <code>python3 tide.py build</code>`;
el.hidden=false;
}
function buildStories(){ function buildStories(){
const host=document.getElementById("stories"); const host=document.getElementById("stories");
STORIES.forEach(st=>{ STORIES.forEach(st=>{
...@@ -889,7 +940,7 @@ addEventListener("resize",()=>{clearTimeout(rT);rT=setTimeout(()=>{ ...@@ -889,7 +940,7 @@ addEventListener("resize",()=>{clearTimeout(rT);rT=setTimeout(()=>{
/* go */ /* go */
load().then(()=>{ load().then(()=>{
applyTokens(); applyTokens();
buildStats();buildRail();buildStories(); buildStats();buildFresh();buildRail();buildStories();
observe(); observe();
// first viewport charts may already be in view // first viewport charts may already be in view
requestAnimationFrame(()=>CHARTS.forEach(c=>{const r=c.el.getBoundingClientRect(); requestAnimationFrame(()=>CHARTS.forEach(c=>{const r=c.el.getBoundingClientRect();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment