Commit c122e7a5 by PLN (Algolia)

feat(landing): dataviz front door + honest style cloud (#63,#88)

index.html ties the corpus dataviz together (By the numbers · Unwrapped · The
Triangle) and opens with a typographic style cloud sized by TRACK count — read
from PLN's own gig metadata (catalog_view metas[].style via models.norm_style),
never per-sample CLAP genre. dnb 18 · breaks 17 · techno 14 · nujazz 12 = the
TechnoJazz spine. build_landing.py bakes landing.json (cloud + corpus stats).
Ship's Bridge; magenta only on the live-dot + cloud hover.
parent 6351e0ae
#!/usr/bin/env python3
"""build_landing — bake landing.json for the dataviz front door (index.html, #63).
The landing ties the corpus dataviz together (narrative + explorable) and opens with
an HONEST style tag-cloud: the genres PLN actually plays, read from his own gig
metadata (catalog_view metas[].style, canonicalised by models.norm_style) — NOT
hallucinated from one-shots ([[feedback_metadata_vs_mastering]]). Counts are tracks,
not samples; a style is counted once per track even across multiple gigs.
python3 build_landing.py # → landing.json
"""
import json
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
import models as M
HERE = Path(__file__).resolve().parent
CV = HERE / "catalog_view.json"
UNW = HERE / "unwrapped.json"
OUT = HERE / "landing.json"
def main():
cv = json.loads(CV.read_text())
styles, bpms = Counter(), defaultdict(list)
gigs = set()
for t in cv["tracks"]:
seen = set()
for m in (t.get("metas") or []):
if m.get("gig"):
gigs.add(m["gig"])
s = m.get("style")
if not s:
continue
ns = M.norm_style(s)
if ns not in seen:
styles[ns] += 1
seen.add(ns)
if m.get("bpm"):
bpms[ns].append(m["bpm"])
def med(xs):
xs = sorted(xs)
return xs[len(xs) // 2] if xs else None
cloud = [{"style": s, "n": n, "bpm": med(bpms[s])}
for s, n in styles.most_common()]
unw = json.loads(UNW.read_text()) if UNW.exists() else {}
n_samples = unw.get("headline", {}).get("n_files")
n_takes = sum(1 for t in cv["tracks"] for _ in [t] if t.get("n_takes_eda"))
payload = {
"schema": "dataviz landing (style cloud + corpus stats) — #63",
"as_of": date.today().isoformat(),
"stats": {
"tracks": len(cv["tracks"]),
"gigs": len(gigs),
"samples": n_samples,
"takes_eda": sum(int(t.get("n_takes_eda") or 0) for t in cv["tracks"]),
"styles": len(cloud),
},
"style_cloud": cloud,
}
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=1))
print(f"✓ {OUT.name}: {len(cloud)} styles, {payload['stats']['tracks']} tracks, "
f"{payload['stats']['gigs']} gigs")
if __name__ == "__main__":
main()
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ParVagues — the catalog, read from the source</title>
<meta name="description" content="A livecoded TidalCycles catalog, read back from its own .tidal source and audio: the styles played, the corpus by the numbers, and an explorable map of every sample.">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* ───────────────────────────────────────────────────────────────────────────
ParVagues dataviz landing — the front door (#63). Ship's Bridge (DESIGN.md):
dark instrument surface, Geist + Geist Mono, magenta reserved for one accent.
The style cloud is HONEST — track counts from PLN's own gig metadata, never
per-sample CLAP genre. Zero deps; reads landing.json.
─────────────────────────────────────────────────────────────────────────── */
:root{
--surface:#0a0a0a; --raised:#111113; --overlay:#16161a; --hairline:#ffffff1c;
--ink:#e8e8ea; --mute:#9a9aa2; --faint:#67676e; --faintest:#3a3a40;
--magenta:#d900ff; --grid:#ffffff0e; --maxw:1080px;
}
*{box-sizing:border-box}
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
body{margin:0;background:var(--surface);color:var(--ink);
font:400 16px/1.55 Geist,Inter,system-ui,-apple-system,sans-serif;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
.mono{font-family:"Geist Mono",ui-monospace,SFMono-Regular,monospace;font-variant-numeric:tabular-nums}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
a{color:inherit;text-decoration:none}
h1,h2{margin:0;line-height:1.04;letter-spacing:-0.03em;font-weight:600}
header{padding:10vh 0 5vh}
header h1{font-size:clamp(2.4rem,6.5vw,4.4rem);font-weight:700;letter-spacing:-0.045em;text-wrap:balance}
header h1 em{font-style:normal;color:var(--mute)}
.lede{font-size:clamp(1.05rem,1.7vw,1.32rem);color:var(--mute);max-width:60ch;
text-wrap:pretty;margin:1.2rem 0 0}
.lede b{color:var(--ink);font-weight:500}
.stats{display:flex;flex-wrap:wrap;gap:0;margin-top:2.6rem;border-top:1px solid var(--hairline);
border-bottom:1px solid var(--hairline)}
.stat{padding:1.1rem 1.7rem 1.1rem 0;margin-right:1.7rem;border-right:1px solid var(--hairline)}
.stat:last-child{border-right:0;margin-right:0}
.stat .v{font:600 1.9rem/1 "Geist Mono",monospace;letter-spacing:-0.02em}
.stat .l{font-size:.76rem;color:var(--faint);margin-top:.35rem}
section{padding:6vh 0}
.shead{display:flex;align-items:baseline;gap:.8rem;margin-bottom:.4rem}
.shead h2{font-size:clamp(1.4rem,2.6vw,1.9rem)}
.shead .tag{font:500 11px/1 "Geist Mono",monospace;color:var(--faint);letter-spacing:.05em;text-transform:uppercase}
.sdek{font-size:.98rem;color:var(--mute);max-width:74ch;text-wrap:pretty;margin:0 0 1.8rem}
.sdek b{color:var(--ink);font-weight:500}
/* style cloud — typographic, size+brightness by track count, honest */
.cloud{display:flex;flex-wrap:wrap;align-items:baseline;gap:.35em 1.1rem;line-height:1.15}
.cloud .st{position:relative;font-weight:600;letter-spacing:-0.02em;color:var(--ink);
transition:color .15s ease;cursor:default}
.cloud .st .bpm{font:500 .42em/1 "Geist Mono",monospace;color:var(--faint);
vertical-align:super;margin-left:.18em}
.cloud .st:hover{color:var(--magenta)}
.cloud .st:hover .bpm{color:var(--magenta)}
/* pages */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px}
.card{display:block;border:1px solid var(--hairline);border-radius:14px;padding:22px 22px 20px;
background:linear-gradient(180deg,#ffffff06,transparent);transition:.18s cubic-bezier(.2,.7,.3,1)}
.card:hover{border-color:#ffffff38;transform:translateY(-2px);background:linear-gradient(180deg,#ffffff0b,transparent)}
.card .ic{font-size:1.4rem;line-height:1;margin-bottom:.9rem;display:block}
.card h3{font-size:1.18rem;font-weight:600;margin:0 0 .35rem;letter-spacing:-0.02em}
.card p{font-size:.9rem;color:var(--mute);margin:0;line-height:1.5;text-wrap:pretty}
.card .go{margin-top:1rem;font:500 12.5px/1 "Geist Mono",monospace;color:var(--faint);
display:flex;align-items:center;gap:.4rem}
.card:hover .go{color:var(--ink)}
.card.live h3::after{content:"●";color:var(--magenta);font-size:.5em;vertical-align:super;margin-left:.5em}
.card.soon{opacity:.66}
.card.soon .go{color:var(--faintest)}
footer{border-top:1px solid var(--hairline);margin-top:5vh;padding:3vh 0 7vh;color:var(--faint);font-size:.82rem}
footer .mono{color:var(--mute)}
.loading{padding:18vh 0;text-align:center;color:var(--mute);font:500 14px/1 "Geist Mono",monospace}
</style>
</head>
<body>
<div id="app"><div class="loading">reading the catalog…</div></div>
<script id="data" type="application/json"></script>
<script>
"use strict";
const $=(s,r=document)=>r.querySelector(s);
const esc=s=>String(s).replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
const PAGES=[
{ic:"📈",t:"By the numbers",href:"corpus.html",live:1,
d:"A scroll-driven data essay: five years of livecoded sets read straight from the .tidal source — tempo, samples, idioms, the 2024 breakout."},
{ic:"🛰️",t:"Unwrapped",href:"unwrapped.html",live:1,
d:"The explorable. Every sample placed in feature space — pick the axes, recolour by family or audio-cluster, vibe-search by your own words, and click any dot to hear it."},
{ic:"🔺",t:"The Triangle",href:"triangle.html",live:1,
d:"The catalog reconciler: source × metadata × gigs lit into one confirmed grid, with ground-truth validators in the drawer."},
];
async function load(){
let D=null; const inl=$("#data");
if(inl&&inl.textContent.trim()){try{D=JSON.parse(inl.textContent)}catch(_){}}
if(!D)D=await fetch("landing.json").then(r=>r.ok?r.json():null).catch(()=>null);
render(D);
}
function styleCloud(cloud){
if(!cloud||!cloud.length)return"";
const mx=Math.max(...cloud.map(s=>s.n)), mn=Math.min(...cloud.map(s=>s.n));
return cloud.map(s=>{
const t=mx>mn?(s.n-mn)/(mx-mn):1;
const size=(1.15+t*2.6).toFixed(2); // rem
const light=Math.round(60+t*40); // ink brightness by count
const bpm=s.bpm?`<span class="bpm">${s.bpm}bpm</span>`:"";
return `<span class="st" style="font-size:${size}rem;color:hsl(240 4% ${light}%)"
title="${s.n} tracks${s.bpm?` · ~${s.bpm} bpm`:""}">${esc(s.style)}${bpm}</span>`;
}).join("");
}
function render(D){
const st=D?.stats||{};
$("#app").innerHTML=`
<header><div class="wrap">
<h1>ParVagues <em>— the catalog, read from the source</em></h1>
<p class="lede">A livecoded TidalCycles practice, read back from its own <b>.tidal source</b>
and <b>audio</b>. Not a press kit — a set of instruments that turn five years of sets into
something the eye and ear can explore and trust.</p>
<div class="stats">
<div class="stat"><div class="v mono">${st.tracks??"—"}</div><div class="l">tracks</div></div>
<div class="stat"><div class="v mono">${st.gigs??"—"}</div><div class="l">gigs played</div></div>
<div class="stat"><div class="v mono">${(st.samples??0).toLocaleString?.()||st.samples||"—"}</div><div class="l">samples mapped</div></div>
<div class="stat"><div class="v mono">${st.styles??"—"}</div><div class="l">styles</div></div>
</div>
</div></header>
<section><div class="wrap">
<div class="shead"><h2>The styles I play</h2><span class="tag">by track count</span></div>
<p class="sdek">Sized by how many tracks carry each style read from the gig metadata I tagged
myself, not guessed from the audio. <b>Drum &amp; bass and breaks lead, techno and nu-jazz close
behind</b>: the TechnoJazz spine, in one breath.</p>
<div class="cloud" id="cloud">${styleCloud(D?.style_cloud)}</div>
</div></section>
<section><div class="wrap">
<div class="shead"><h2>Explore</h2><span class="tag">instruments</span></div>
<p class="sdek">Three ways in a narrative, a free exploration, and the reconciler that keeps
the catalog honest.</p>
<div class="cards">${PAGES.map(p=>`
<a class="card ${p.live?"live":"soon"}" href="${p.href}">
<span class="ic">${p.ic}</span>
<h3>${esc(p.t)}</h3><p>${esc(p.d)}</p>
<div class="go">open ${esc(p.href)} →</div>
</a>`).join("")}</div>
</div></section>
<footer><div class="wrap">
<span class="mono">${st.tracks??"—"} tracks · ${st.gigs??"—"} gigs · ${st.styles??"—"} styles · generated ${D?.as_of||""}</span><br>
L'Armada — the ParVagues salvage &amp; release operation. Built from <code>catalog_view.json</code>
via <code>build_landing.py</code>. Vibe-search needs <code>serve.py</code> running.
</div></footer>`;
}
load();
</script>
</body></html>
{
"schema": "dataviz landing (style cloud + corpus stats) — #63",
"as_of": "2026-06-07",
"stats": {
"tracks": 73,
"gigs": 23,
"samples": 1485,
"takes_eda": 15,
"styles": 16
},
"style_cloud": [
{
"style": "dnb",
"n": 18,
"bpm": 160
},
{
"style": "breaks",
"n": 17,
"bpm": 124
},
{
"style": "techno",
"n": 14,
"bpm": 120
},
{
"style": "nujazz",
"n": 12,
"bpm": 120
},
{
"style": "lounge",
"n": 6,
"bpm": 120
},
{
"style": "ambient",
"n": 5,
"bpm": 90
},
{
"style": "lofi",
"n": 4,
"bpm": 80
},
{
"style": "punk",
"n": 3,
"bpm": 155
},
{
"style": "hybrid",
"n": 2,
"bpm": 140
},
{
"style": "acid",
"n": 2,
"bpm": 135
},
{
"style": "hiphop",
"n": 2,
"bpm": 102
},
{
"style": "chiptune",
"n": 1,
"bpm": 120
},
{
"style": "collab",
"n": 1,
"bpm": 120
},
{
"style": "downtempo",
"n": 1,
"bpm": 124
},
{
"style": "dance",
"n": 1,
"bpm": 129
},
{
"style": "drill",
"n": 1,
"bpm": 140
}
]
}
\ No newline at end of file
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