fix: hardcode PGPASSWORD default so loaders work without local .env

This commit is contained in:
Kerboul
2026-04-07 09:33:25 +02:00
parent c390caf2e2
commit 838665001e
24 changed files with 5497 additions and 125 deletions

5
hello-framework/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
/dist/
node_modules/
yarn-error.log
.env

15
hello-framework/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Spotify Analytics <20> Observable Framework
Visualization dashboard built with [Observable Framework](https://observablehq.com/framework/) exploring a Spotify dataset (genres, languages, release years).
**Live demo:** https://arussac-perso.github.io/observable_spotify/history-of-music
## Pages
- **Spotify Analytics** <20> bar charts and stacked area by genre/language with dual-range year filter.
- **History of Music** <20> stacked area chart + donut chart showing genre evolution over time, filterable by language, period, and genre.
## Run locally
```
npm install
npm run dev # ? http://localhost:3000
```

View File

@@ -0,0 +1,18 @@
// See https://observablehq.com/framework/config for documentation.
export default {
title: "Spotify Analytics",
pages: [
{ name: "Vue d'ensemble", path: "/" },
{ name: "History of Music", path: "/history-of-music" },
{ name: "Tendances par Langue", path: "/language-trends" },
{ name: "Audio Features", path: "/audio-features" },
],
head: '<link rel="icon" href="observable.png" type="image/png" sizes="32x32">',
root: "src",
// Base path for GitHub Pages deployment
base: "/observable_spotify/",
};

3733
hello-framework/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"type": "module",
"private": true,
"scripts": {
"clean": "rimraf src/.observablehq/cache",
"build": "observable build",
"dev": "observable preview",
"deploy": "observable deploy",
"observable": "observable"
},
"dependencies": {
"@observablehq/framework": "^1.13.4",
"d3-dsv": "^3.0.1",
"d3-time-format": "^4.1.0",
"dotenv": "^17.4.1",
"pg": "^8.20.0"
},
"devDependencies": {
"rimraf": "^5.0.5"
},
"engines": {
"node": ">=18"
}
}

1
hello-framework/src/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/.observablehq/cache/

View File

@@ -0,0 +1,385 @@
---
toc: false
---
<style>
.page-title { font-family:var(--sans-serif); text-align:center; margin:2rem 0 1.5rem; }
.page-title h1 {
font-size:2.2rem; font-weight:900; margin:0 0 .3rem;
background:linear-gradient(90deg,#e84040,#f5a623,#1DB954);
-webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text;
}
.page-title p { color:var(--theme-foreground-muted); font-size:.88rem; margin:0; }
.chart-card { background:var(--theme-background-alt); border-radius:12px; padding:16px; margin-bottom:16px; }
.chart-card h3 { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--theme-foreground-muted); margin:0 0 12px; }
.feat-legend {
display:flex; flex-wrap:wrap; gap:8px 20px;
font-family:var(--sans-serif); font-size:12px; margin-top:10px;
}
.feat-legend-item { display:flex; align-items:center; gap:6px; }
.feat-dot { width:12px; height:4px; border-radius:2px; flex-shrink:0; }
.controls-bar {
display:flex; flex-wrap:wrap; gap:14px; align-items:flex-start;
background:var(--theme-background-alt); border-radius:12px;
padding:12px 16px; margin-bottom:18px;
font-family:var(--sans-serif); font-size:12px;
}
.ctrl-label {
font-weight:700; font-size:10px; text-transform:uppercase;
letter-spacing:.06em; color:var(--theme-foreground-muted); margin-bottom:4px;
}
.drs-wrap { position:relative; height:34px; width:100%; min-width:200px; }
.drs-track { position:absolute; left:0; right:0; top:16px; height:2px; background:#ccc; }
.drs-fill { position:absolute; top:16px; height:2px; background:#f5a623; }
.drs-wrap input[type=range] {
-webkit-appearance:none; position:absolute; left:0; width:100%; top:0;
background:transparent; pointer-events:none; margin:0; height:34px;
}
.drs-wrap input[type=range]::-webkit-slider-thumb {
-webkit-appearance:none; width:15px; height:15px; border-radius:50%;
background:#f5a623; cursor:pointer; pointer-events:all; border:none; margin-top:-6.5px;
}
.drs-wrap input[type=range]::-moz-range-thumb {
width:15px; height:15px; border-radius:50%; background:#f5a623;
cursor:pointer; pointer-events:all; border:none;
}
</style>
<div class="page-title">
<h1>Audio Features</h1>
<p>DNA sonore de la musique Spotify · danceability, energy, valence, acousticness, tempo…</p>
</div>
```js
const audioYear = await FileAttachment("data/audio_features_year.json").json();
const audioLang = await FileAttachment("data/audio_features_lang_year.json").json();
const audioGenre = await FileAttachment("data/audio_features_genre.json").json();
```
## Évolution globale du son (19702025)
Les 4 axes principaux de l'identité sonore d'un titre, moyennés sur l'ensemble du catalogue.
<div class="chart-card">
<h3>Danceability · Energy · Valence · Acousticness — index 0→1</h3>
```js
const features4 = [
{key:"danceability", label:"Danceability", color:"#1DB954"},
{key:"energy", label:"Energy", color:"#e84040"},
{key:"valence", label:"Valence", color:"#f5a623"},
{key:"acousticness", label:"Acousticness", color:"#1a75cc"},
];
const flat4 = audioYear.flatMap(d =>
features4.map(f => ({year:+d.release_year, feature:f.label, value:+d[f.key]}))
);
const f4colors = features4.map(f=>f.color);
const f4domain = features4.map(f=>f.label);
display(Plot.plot({
width, height:300, marginLeft:50, marginBottom:40,
x:{label:"Année"},
y:{label:"Valeur (01)", grid:true, domain:[0,1]},
color:{domain:f4domain, range:f4colors, legend:true, columns:4},
marks:[
Plot.line(flat4,{
x:"year", y:"value", stroke:"feature",
strokeWidth:2, curve:"monotone-x",
tip:true, title:d=>`${d.feature} · ${d.year}\n${d.value.toFixed(3)}`
}),
Plot.ruleY([0.5],{stroke:"#ccc",strokeDasharray:"4"})
]
}));
```
</div>
<div class="chart-card">
<h3>Speechiness · Instrumentalness · Liveness</h3>
```js
const features3 = [
{key:"speechiness", label:"Speechiness", color:"#8e44ad"},
{key:"instrumentalness", label:"Instrumentalness", color:"#16a085"},
{key:"liveness", label:"Liveness", color:"#d35400"},
];
const flat3 = audioYear.flatMap(d =>
features3.map(f => ({year:+d.release_year, feature:f.label, value:+d[f.key]}))
);
display(Plot.plot({
width, height:240, marginLeft:50, marginBottom:40,
x:{label:"Année"},
y:{label:"Valeur (01)", grid:true},
color:{domain:features3.map(f=>f.label), range:features3.map(f=>f.color), legend:true, columns:3},
marks:[
Plot.line(flat3,{
x:"year", y:"value", stroke:"feature",
strokeWidth:2, curve:"monotone-x",
tip:true, title:d=>`${d.feature} · ${d.year}\n${d.value.toFixed(3)}`
})
]
}));
```
</div>
<div class="chart-card">
<h3>Tempo moyen (BPM)</h3>
```js
display(Plot.plot({
width, height:200, marginLeft:55, marginBottom:40,
x:{label:"Année"},
y:{label:"Tempo (BPM)", grid:true},
marks:[
Plot.line(audioYear,{
x:d=>+d.release_year, y:d=>+d.tempo,
stroke:"#f5a623", strokeWidth:2.5, curve:"monotone-x"
}),
Plot.dot(audioYear,{
x:d=>+d.release_year, y:d=>+d.tempo,
fill:"#f5a623", r:3, tip:true,
title:d=>`${d.release_year}\nTempo: ${(+d.tempo).toFixed(1)} BPM`
}),
Plot.ruleY([120],{stroke:"#ccc",strokeDasharray:"4"})
]
}));
```
</div>
## Comparaison par genre (top 20)
```js
const genres = audioGenre.map(d=>d.genre);
const gPalette = ["#1DB954","#1a75cc","#e84040","#f5a623","#9b59b6","#e91e8c",
"#16a085","#d35400","#2980b9","#27ae60","#8e44ad","#c0392b",
"#17a589","#d68910","#884ea0","#2e86c1","#a93226","#1e8449",
"#117a65","#6e2f1a"];
const gColor = Object.fromEntries(genres.map((g,i)=>[g,gPalette[i%gPalette.length]]));
```
<div class="chart-card">
<h3>Scatter : Energy vs Danceability (bulles = volume de titres)</h3>
```js
display(Plot.plot({
width, height:420, marginLeft:55, marginBottom:50,
x:{label:"Danceability →", grid:true, domain:[0,1]},
y:{label:"Energy →", grid:true, domain:[0,1]},
color:{domain:genres, range:genres.map(g=>gColor[g]), legend:true, columns:4},
r:{range:[4,28]},
marks:[
Plot.dot(audioGenre,{
x:d=>+d.danceability, y:d=>+d.energy,
r:d=>+d.track_count, fill:"genre", fillOpacity:0.7,
stroke:"white", strokeWidth:0.5,
tip:true,
title:d=>`${d.genre}\nDanceability: ${(+d.danceability).toFixed(3)}\nEnergy: ${(+d.energy).toFixed(3)}\nTitres: ${Number(d.track_count).toLocaleString()}`
}),
Plot.text(audioGenre,{
x:d=>+d.danceability, y:d=>+d.energy,
text:"genre", fontSize:9, fill:"var(--theme-foreground)", fontWeight:"600",
dy:-12
})
]
}));
```
</div>
<div class="chart-card">
<h3>Valence vs Acousticness (humeur × acoustique)</h3>
```js
display(Plot.plot({
width, height:400, marginLeft:55, marginBottom:50,
x:{label:"Acousticness →", grid:true, domain:[0,1]},
y:{label:"Valence (positivité) →", grid:true, domain:[0,1]},
color:{domain:genres, range:genres.map(g=>gColor[g]), legend:false},
r:{range:[4,26]},
marks:[
Plot.dot(audioGenre,{
x:d=>+d.acousticness, y:d=>+d.valence,
r:d=>+d.track_count, fill:"genre", fillOpacity:0.75,
stroke:"white", strokeWidth:0.5,
tip:true,
title:d=>`${d.genre}\nAcousticness: ${(+d.acousticness).toFixed(3)}\nValence: ${(+d.valence).toFixed(3)}\nTitres: ${Number(d.track_count).toLocaleString()}`
}),
Plot.text(audioGenre,{
x:d=>+d.acousticness, y:d=>+d.valence,
text:"genre", fontSize:9, fill:"var(--theme-foreground)", fontWeight:"600", dy:-11
}),
Plot.ruleX([0.5],{stroke:"#ccc",strokeDasharray:"4"}),
Plot.ruleY([0.5],{stroke:"#ccc",strokeDasharray:"4"})
]
}));
```
</div>
## Audio features par langue (19702025)
```js
const langMeta = {
en:{label:"Anglais", color:"#1a75cc"}, es:{label:"Espagnol", color:"#e84040"},
fr:{label:"Français", color:"#9b59b6"}, de:{label:"Allemand", color:"#f5a623"},
pt:{label:"Portugais",color:"#e91e8c"}, ja:{label:"Japonais", color:"#16a085"},
it:{label:"Italien", color:"#d35400"}, ko:{label:"Coréen", color:"#2980b9"},
};
const mainLangs = Object.keys(langMeta);
const getLang = c => langMeta[c]?.label ?? c.toUpperCase();
const getColor = c => langMeta[c]?.color ?? "#888";
```
```js
// Year slider
const yr = view((() => {
const min=1970,max=2025;
const c=document.createElement("div"); c.style.cssText="display:flex;flex-direction:column;gap:4px;max-width:300px;font-family:var(--sans-serif);font-size:12px;";
const top=document.createElement("div"); top.style.cssText="display:flex;justify-content:space-between;";
const lbl=document.createElement("span"); lbl.style.cssText="font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--theme-foreground-muted)"; lbl.textContent="Période";
const out=document.createElement("span"); out.style.cssText="font-weight:600;color:#f5a623;font-size:12px;"; out.textContent=`${min} ${max}`; top.append(lbl,out);
const tw=document.createElement("div"); tw.className="drs-wrap";
const tr=document.createElement("div"); tr.className="drs-track";
const fi=document.createElement("div"); fi.className="drs-fill";
const lo=document.createElement("input"); lo.type="range"; lo.min=min; lo.max=max; lo.value=min;
const hi=document.createElement("input"); hi.type="range"; hi.min=min; hi.max=max; hi.value=max;
function u(){const l=Math.min(+lo.value,+hi.value),h=Math.max(+lo.value,+hi.value);fi.style.left=(l-min)/(max-min)*100+"%";fi.style.width=(h-l)/(max-min)*100+"%";out.textContent=`${l} ${h}`;c.value=[l,h];c.dispatchEvent(new Event("input",{bubbles:true}));}
lo.addEventListener("input",()=>{if(+lo.value>+hi.value)lo.value=hi.value;u();});
hi.addEventListener("input",()=>{if(+hi.value<+lo.value)hi.value=lo.value;u();});
tw.append(tr,fi,lo,hi); c.append(top,tw); u(); return c;
})());
```
```js
// Feature selector
const selFeature = view(Inputs.select(
["danceability","energy","valence","acousticness","loudness","tempo"],
{label:"Feature", value:"danceability",
format:f=>({danceability:"Danceability",energy:"Energy",valence:"Valence",acousticness:"Acousticness",loudness:"Loudness (dB)",tempo:"Tempo (BPM)"})[f]??f}
));
```
<div class="controls-bar">
<div class="ctrl-label" style="align-self:center;margin-bottom:0;">Filtres :</div>
<div><div class="ctrl-label">Période</div>${yr}</div>
<div><div class="ctrl-label">Feature</div>${selFeature}</div>
</div>
<div class="chart-card">
<h3>${selFeature} par langue · ${yr[0]}${yr[1]}</h3>
```js
const filtLang = audioLang.filter(d =>
mainLangs.includes(d.language_code) &&
+d.release_year >= yr[0] &&
+d.release_year <= yr[1]
);
const yLabel = {
danceability:"Danceability", energy:"Energy", valence:"Valence",
acousticness:"Acousticness", loudness:"Loudness (dB)", tempo:"Tempo (BPM)"
}[selFeature] ?? selFeature;
display(Plot.plot({
width, height:300, marginLeft:58, marginBottom:40,
x:{label:"Année"},
y:{label:yLabel, grid:true},
color:{
domain:mainLangs, range:mainLangs.map(getColor),
legend:true, tickFormat:getLang, columns:4
},
marks:[
Plot.line(filtLang,{
x:d=>+d.release_year, y:d=>+d[selFeature], stroke:"language_code",
strokeWidth:2, curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\n${yLabel}: ${(+d[selFeature]).toFixed(3)}`
})
]
}));
```
</div>
## Profil radar par genre (top 8)
```js
// Radar chart — manual SVG (Observable Plot ne supporte pas nativement le radar)
const radarGenres = audioGenre.slice(0,8);
const radarFeats = ["danceability","energy","valence","acousticness","speechiness","instrumentalness"];
const radarLabels = ["Dance","Energy","Valence","Acoustic","Speech","Instrumental"];
const rW=480, rH=420, rCx=rW/2, rCy=rH/2-10, rR=140;
const NS="http://www.w3.org/2000/svg";
const svg=document.createElementNS(NS,"svg");
svg.setAttribute("viewBox",`0 0 ${rW} ${rH}`);
svg.setAttribute("width",Math.min(width,rW)); svg.setAttribute("height",rH);
svg.style.display="block";
// Grid circles
[0.25,0.5,0.75,1].forEach(r=>{
const c=document.createElementNS(NS,"circle");
c.setAttribute("cx",rCx); c.setAttribute("cy",rCy);
c.setAttribute("r",rR*r);
c.setAttribute("fill","none"); c.setAttribute("stroke","#ddd"); c.setAttribute("stroke-width","1");
svg.appendChild(c);
});
// Axes + labels
radarFeats.forEach((feat,i)=>{
const angle=-Math.PI/2 + (i/radarFeats.length)*2*Math.PI;
const x=rCx+rR*Math.cos(angle), y=rCy+rR*Math.sin(angle);
const line=document.createElementNS(NS,"line");
line.setAttribute("x1",rCx); line.setAttribute("y1",rCy);
line.setAttribute("x2",x); line.setAttribute("y2",y);
line.setAttribute("stroke","#ddd"); line.setAttribute("stroke-width","1");
svg.appendChild(line);
const lx=rCx+(rR+18)*Math.cos(angle), ly=rCy+(rR+18)*Math.sin(angle);
const txt=document.createElementNS(NS,"text");
txt.setAttribute("x",lx); txt.setAttribute("y",ly);
txt.setAttribute("text-anchor","middle"); txt.setAttribute("dominant-baseline","middle");
txt.setAttribute("font-size","11"); txt.setAttribute("font-weight","700");
txt.setAttribute("font-family","var(--sans-serif)"); txt.setAttribute("fill","var(--theme-foreground-muted)");
txt.textContent=radarLabels[i]; svg.appendChild(txt);
});
// Genre polygons
radarGenres.forEach((g,gi)=>{
const col=gColor[g.genre]??gPalette[gi%gPalette.length];
const pts=radarFeats.map((feat,i)=>{
const angle=-Math.PI/2+(i/radarFeats.length)*2*Math.PI;
const val=Math.min(1,Math.max(0,+g[feat]));
return [rCx+rR*val*Math.cos(angle), rCy+rR*val*Math.sin(angle)];
});
const poly=document.createElementNS(NS,"polygon");
poly.setAttribute("points",pts.map(p=>p.join(",")).join(" "));
poly.setAttribute("fill",col); poly.setAttribute("fill-opacity","0.15");
poly.setAttribute("stroke",col); poly.setAttribute("stroke-width","2");
const t=document.createElementNS(NS,"title");
t.textContent=g.genre+"\n"+radarFeats.map((f,i)=>`${radarLabels[i]}: ${(+g[f]).toFixed(3)}`).join("\n");
poly.appendChild(t); svg.appendChild(poly);
});
// Legend
const legY=rH-80;
radarGenres.forEach((g,i)=>{
const col=gColor[g.genre]??gPalette[i%gPalette.length];
const x=(i%4)*120+20, y=legY+Math.floor(i/4)*20;
const r=document.createElementNS(NS,"rect");
r.setAttribute("x",x); r.setAttribute("y",y-7);
r.setAttribute("width",12); r.setAttribute("height",5);
r.setAttribute("fill",col); r.setAttribute("rx","2");
const t=document.createElementNS(NS,"text");
t.setAttribute("x",x+16); t.setAttribute("y",y);
t.setAttribute("font-size","10"); t.setAttribute("font-family","var(--sans-serif)");
t.setAttribute("fill","var(--theme-foreground)"); t.setAttribute("dominant-baseline","middle");
t.textContent=g.genre.length>14?g.genre.slice(0,13)+"…":g.genre;
svg.appendChild(r); svg.appendChild(t);
});
display(svg);
```

View File

@@ -0,0 +1,44 @@
import { createRequire } from "module";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
// Manually load .env to stderr to avoid polluting stdout (used by Observable loaders)
const __dirname = dirname(fileURLToPath(import.meta.url));
try {
const envPath = join(__dirname, "../../.env");
const envContent = readFileSync(envPath, "utf-8");
for (const line of envContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "");
if (!(key in process.env)) process.env[key] = val;
}
} catch (_) {
// .env not present — rely on shell environment
}
const require = createRequire(import.meta.url);
const pg = require("pg");
const { Client } = pg;
export async function query(sql, params = []) {
const client = new Client({
host: process.env.PGHOST || "kerboul.me",
port: parseInt(process.env.PGPORT || "15433"),
user: process.env.PGUSER || "spotify",
password: process.env.PGPASSWORD || "Sp0tify-DB-2026!Kerboul",
database: process.env.PGDATABASE || "spotify",
statement_timeout: 120000,
});
await client.connect();
try {
const result = await client.query(sql, params);
return result.rows;
} finally {
await client.end();
}
}

View File

@@ -0,0 +1,37 @@
import { query } from "./_db.js";
// Get top 20 genres from agg table (fast), then compute audio features with TABLESAMPLE (fast)
const rows = await query(`
WITH top_genres AS (
SELECT genre
FROM (
SELECT genre, SUM(track_count) AS total
FROM agg_genre_year
WHERE release_year >= 1970
GROUP BY genre
ORDER BY total DESC
LIMIT 20
) t
)
SELECT
TRIM(g) AS genre,
COUNT(*) AS track_count,
ROUND(AVG(danceability)::numeric, 4) AS danceability,
ROUND(AVG(energy)::numeric, 4) AS energy,
ROUND(AVG(valence)::numeric, 4) AS valence,
ROUND(AVG(tempo)::numeric, 2) AS tempo,
ROUND(AVG(acousticness)::numeric, 4) AS acousticness,
ROUND(AVG(loudness)::numeric, 3) AS loudness,
ROUND(AVG(speechiness)::numeric, 4) AS speechiness,
ROUND(AVG(instrumentalness)::numeric, 4) AS instrumentalness
FROM track_details TABLESAMPLE SYSTEM(15),
LATERAL unnest(string_to_array(genres, ' | ')) AS g
WHERE genres IS NOT NULL
AND release_year >= 1970
AND TRIM(g) IN (SELECT genre FROM top_genres)
GROUP BY TRIM(g)
HAVING COUNT(*) > 20
ORDER BY track_count DESC
`);
process.stdout.write(JSON.stringify(rows));

View File

@@ -0,0 +1,23 @@
import { query } from "./_db.js";
// TABLESAMPLE SYSTEM(15) for speed — averages are statistically stable at this scale
const rows = await query(`
SELECT
language_code,
release_year,
COUNT(*) AS track_count,
ROUND(AVG(danceability)::numeric, 4) AS danceability,
ROUND(AVG(energy)::numeric, 4) AS energy,
ROUND(AVG(valence)::numeric, 4) AS valence,
ROUND(AVG(tempo)::numeric, 2) AS tempo,
ROUND(AVG(acousticness)::numeric, 4) AS acousticness,
ROUND(AVG(loudness)::numeric, 3) AS loudness
FROM track_details TABLESAMPLE SYSTEM(15)
WHERE release_year BETWEEN 1970 AND 2025
AND language_code IN ('en','fr','es','de','pt','ja','it','ko','tr','ru','pl','nl','ar','sv','hi')
GROUP BY language_code, release_year
HAVING COUNT(*) > 5
ORDER BY language_code, release_year
`);
process.stdout.write(JSON.stringify(rows));

View File

@@ -0,0 +1,24 @@
import { query } from "./_db.js";
// TABLESAMPLE SYSTEM(15) : ~1.5M rows instead of 9.8M — statistically equivalent averages
const rows = await query(`
SELECT
release_year,
COUNT(*) AS track_count,
ROUND(AVG(danceability)::numeric, 4) AS danceability,
ROUND(AVG(energy)::numeric, 4) AS energy,
ROUND(AVG(valence)::numeric, 4) AS valence,
ROUND(AVG(tempo)::numeric, 2) AS tempo,
ROUND(AVG(acousticness)::numeric, 4) AS acousticness,
ROUND(AVG(loudness)::numeric, 3) AS loudness,
ROUND(AVG(speechiness)::numeric, 4) AS speechiness,
ROUND(AVG(instrumentalness)::numeric, 4) AS instrumentalness,
ROUND(AVG(liveness)::numeric, 4) AS liveness
FROM track_details TABLESAMPLE SYSTEM(15)
WHERE release_year BETWEEN 1970 AND 2025
GROUP BY release_year
HAVING COUNT(*) > 10
ORDER BY release_year
`);
process.stdout.write(JSON.stringify(rows));

View File

@@ -0,0 +1,10 @@
import { query } from "./_db.js";
const rows = await query(`
SELECT genre, language_code, release_year, track_count, avg_duration_ms, avg_track_popularity
FROM agg_genre_language_year
WHERE release_year BETWEEN 1970 AND 2025
ORDER BY release_year, genre, language_code
`);
process.stdout.write(JSON.stringify(rows));

View File

@@ -0,0 +1,10 @@
import { query } from "./_db.js";
const rows = await query(`
SELECT genre, release_year, track_count, avg_duration_ms, avg_track_popularity
FROM agg_genre_year
WHERE release_year BETWEEN 1970 AND 2025
ORDER BY release_year, genre
`);
process.stdout.write(JSON.stringify(rows));

View File

@@ -0,0 +1,11 @@
import sqlite3
conn = sqlite3.connect('spotify_analytics_compact.sqlite3')
cur = conn.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [t[0] for t in cur.fetchall()]
print('TABLES:', tables)
for t in tables:
cols = [d[0] for d in conn.execute('PRAGMA table_info(' + t + ')').fetchall()]
print(t, '->', cols)
row = conn.execute('SELECT * FROM ' + t + ' LIMIT 2').fetchall()
print(' sample:', row)

View File

@@ -0,0 +1,10 @@
import { query } from "./_db.js";
const rows = await query(`
SELECT language_code, release_year, track_count, avg_duration_ms, avg_track_popularity
FROM agg_language_year
WHERE release_year BETWEEN 1960 AND 2025
ORDER BY release_year, language_code
`);
process.stdout.write(JSON.stringify(rows));

View File

@@ -0,0 +1,466 @@
---
toc: false
---
<style>
.hom-title { font-family:var(--sans-serif); text-align:center; margin:2rem 0 0.25rem; }
.hom-title h1 {
font-size:2.6rem; font-weight:900; margin:0;
background:linear-gradient(90deg,#1DB954,#1a75cc);
-webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text;
}
.hom-title p { color:var(--theme-foreground-muted); font-size:.9rem; margin:.3rem 0 1.5rem; }
.hom-controls {
display:flex; flex-wrap:wrap; gap:18px; align-items:flex-start;
background:var(--theme-background-alt); border-radius:12px;
padding:16px 20px; margin-bottom:20px;
font-family:var(--sans-serif); font-size:13px;
}
.hom-ctrl-group { display:flex; flex-direction:column; gap:6px; }
.hom-ctrl-label {
font-weight:700; font-size:11px; text-transform:uppercase;
letter-spacing:.05em; color:var(--theme-foreground-muted);
}
.lang-pills { display:flex; flex-wrap:wrap; gap:6px; }
.lang-pill {
padding:4px 12px; border-radius:16px; cursor:pointer;
font-size:12px; font-family:var(--sans-serif); border:2px solid;
transition:all .15s; background:transparent;
}
.drs-wrap { position:relative; height:36px; width:100%; min-width:220px; }
.drs-track { position:absolute; left:0; right:0; top:17px; height:2px; background:#ccc; border-radius:2px; }
.drs-fill { position:absolute; top:17px; height:2px; background:#1DB954; }
.drs-wrap input[type=range] {
-webkit-appearance:none; appearance:none;
position:absolute; left:0; width:100%; top:0;
background:transparent; pointer-events:none; margin:0; height:36px;
}
.drs-wrap input[type=range]::-webkit-slider-thumb {
-webkit-appearance:none; width:16px; height:16px; border-radius:50%;
background:#1DB954; cursor:pointer; pointer-events:all;
border:none; box-shadow:0 1px 4px #0003; margin-top:-7px;
}
.drs-wrap input[type=range]::-moz-range-thumb {
width:16px; height:16px; border-radius:50%;
background:#1DB954; cursor:pointer; pointer-events:all; border:none;
}
.mode-btns {
display:flex; border-radius:8px; overflow:hidden; border:1.5px solid #1DB954;
}
.mode-btn {
padding:4px 14px; border:none; background:transparent; cursor:pointer;
font-size:12px; font-family:inherit; color:#1DB954; transition:all .15s;
}
.mode-btn[data-on="1"] { background:#1DB954; color:#fff; }
.mode-btn:not(:first-child) { border-left:1.5px solid #1DB954; }
.hom-charts { display:grid; grid-template-columns:1fr 260px; gap:20px; align-items:start; }
@media(max-width:860px){ .hom-charts{grid-template-columns:1fr;} }
.hom-card {
background:var(--theme-background-alt); border-radius:12px; padding:16px;
}
.hom-card h3 {
font-size:11px; font-weight:700; text-transform:uppercase;
letter-spacing:.05em; color:var(--theme-foreground-muted); margin:0 0 10px;
}
.hom-detail { background:var(--theme-background-alt); border-radius:12px; padding:16px 20px; margin-top:16px; }
.hom-detail h3 { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--theme-foreground-muted); margin:0 0 10px; }
.detail-grid { display:flex; flex-wrap:wrap; gap:10px 28px; }
.detail-item { display:flex; flex-direction:column; gap:2px; }
.detail-key { font-size:10px; color:var(--theme-foreground-muted); text-transform:uppercase; letter-spacing:.04em; }
.detail-val { font-size:14px; font-weight:700; }
.mini-charts { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-top:14px; }
@media(max-width:700px){ .mini-charts{grid-template-columns:1fr;} }
</style>
<div class="hom-title">
<h1>History of Music</h1>
<p>Évolution des genres musicaux par langue · données Spotify · Anna's Archive 2025</p>
</div>
```js
const rawData = await FileAttachment("data/genre_language_year.json").json();
const langYearData = await FileAttachment("data/language_year.json").json();
const topGenresQ = [...d3.rollup(rawData, v => d3.sum(v, d=>+d.track_count), d=>d.genre).entries()]
.sort((a,b)=>b[1]-a[1]).slice(0,12).map(d=>d[0]);
// Discover top languages from data
const topLangsQ = [...d3.rollup(rawData, v=>d3.sum(v,d=>+d.track_count), d=>d.language_code).entries()]
.sort((a,b)=>b[1]-a[1]).slice(0,10).map(d=>d[0]);
const langMeta = {
en:{label:"Anglais", color:"#1a75cc"},
es:{label:"Espagnol", color:"#e84040"},
fr:{label:"Français", color:"#9b59b6"},
de:{label:"Allemand", color:"#f5a623"},
pt:{label:"Portugais", color:"#e91e8c"},
ja:{label:"Japonais", color:"#16a085"},
it:{label:"Italien", color:"#d35400"},
ko:{label:"Coréen", color:"#2980b9"},
tr:{label:"Turc", color:"#8e44ad"},
ru:{label:"Russe", color:"#c0392b"},
pl:{label:"Polonais", color:"#27ae60"},
nl:{label:"Néerlandais",color:"#1abc9c"},
};
const getLang = code => langMeta[code]?.label ?? code.toUpperCase();
const getLangColor = code => langMeta[code]?.color ?? "#888";
```
```js
// ① Year range slider
const yearRange = view((() => {
const min=1970, max=2025;
const c = document.createElement("div");
c.style.cssText="display:flex;flex-direction:column;gap:4px;min-width:240px;max-width:320px;font-family:var(--sans-serif);font-size:13px;";
const top = document.createElement("div");
top.style.cssText="display:flex;justify-content:space-between;align-items:center;";
const lbl=document.createElement("span");
lbl.style.cssText="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--theme-foreground-muted)";
lbl.textContent="① Période";
const out=document.createElement("span");
out.style.cssText="font-weight:600;color:#1DB954;font-size:13px;";
out.textContent=`${min} ${max}`;
top.append(lbl,out);
const tw=document.createElement("div"); tw.className="drs-wrap";
const track=document.createElement("div"); track.className="drs-track";
const fill=document.createElement("div"); fill.className="drs-fill";
const lo=document.createElement("input"); lo.type="range"; lo.min=min; lo.max=max; lo.value=min;
const hi=document.createElement("input"); hi.type="range"; hi.min=min; hi.max=max; hi.value=max;
function upd(){
const l=Math.min(+lo.value,+hi.value), h=Math.max(+lo.value,+hi.value);
fill.style.left=(l-min)/(max-min)*100+"%"; fill.style.width=(h-l)/(max-min)*100+"%";
out.textContent=`${l} ${h}`; c.value=[l,h]; c.dispatchEvent(new Event("input",{bubbles:true}));
}
lo.addEventListener("input",()=>{if(+lo.value>+hi.value)lo.value=hi.value;upd();});
hi.addEventListener("input",()=>{if(+hi.value<+lo.value)hi.value=lo.value;upd();});
tw.append(track,fill,lo,hi); c.append(top,tw); upd(); return c;
})());
```
```js
// ② Language pills
const selectedLangs = view((() => {
const defaultOn=["en","fr","es","de"];
const wrap=document.createElement("div");
wrap.style.cssText="display:flex;flex-direction:column;gap:6px;";
wrap.value=[...defaultOn];
const lbl=document.createElement("div");
lbl.style.cssText="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--theme-foreground-muted)";
lbl.textContent="② Langues";
const row=document.createElement("div"); row.className="lang-pills";
topLangsQ.forEach(code=>{
const on=defaultOn.includes(code);
const pill=document.createElement("button"); pill.className="lang-pill";
pill.textContent=getLang(code); pill.dataset.code=code; pill.dataset.on=on?"1":"0";
const col=getLangColor(code);
const sty=v=>{pill.style.borderColor=col;pill.style.background=v?col:"transparent";pill.style.color=v?"#fff":col;};
sty(on);
pill.addEventListener("click",()=>{
pill.dataset.on=pill.dataset.on==="1"?"0":"1"; sty(pill.dataset.on==="1");
wrap.value=Array.from(row.querySelectorAll(".lang-pill[data-on='1']")).map(b=>b.dataset.code);
wrap.dispatchEvent(new Event("input",{bubbles:true}));
});
row.appendChild(pill);
});
wrap.append(lbl,row); return wrap;
})());
```
```js
// ③ Genre filter
const genreFilter = view((() => {
const opts=["Tous",...topGenresQ];
const sel=document.createElement("select");
sel.style.cssText="padding:4px 10px;border-radius:8px;border:1.5px solid #ccc;font-family:var(--sans-serif);font-size:12px;background:var(--theme-background);color:var(--theme-foreground);cursor:pointer;";
opts.forEach(g=>{
const opt=document.createElement("option");
opt.value=g; opt.textContent=g==="Tous"?"Tous les genres":g; sel.appendChild(opt);
});
const wrap=document.createElement("div"); wrap.style.cssText="display:flex;flex-direction:column;gap:6px;";
const lbl=document.createElement("div");
lbl.style.cssText="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--theme-foreground-muted)";
lbl.textContent="③ Genre";
sel.addEventListener("change",()=>{wrap.value=sel.value;wrap.dispatchEvent(new Event("input",{bubbles:true}));});
wrap.value="Tous"; wrap.append(lbl,sel); return wrap;
})());
```
```js
// ④ Display mode
const normalised = view((() => {
const wrap=document.createElement("div"); wrap.style.cssText="display:flex;flex-direction:column;gap:6px;"; wrap.value=false;
const lbl=document.createElement("div");
lbl.style.cssText="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--theme-foreground-muted)";
lbl.textContent="④ Affichage";
const btns=document.createElement("div"); btns.className="mode-btns";
const b1=document.createElement("button"); b1.className="mode-btn"; b1.textContent="Absolu"; b1.dataset.on="1";
const b2=document.createElement("button"); b2.className="mode-btn"; b2.textContent="Normalisé (%)"; b2.dataset.on="0";
b1.addEventListener("click",()=>{b1.dataset.on="1";b2.dataset.on="0";wrap.value=false;wrap.dispatchEvent(new Event("input",{bubbles:true}));});
b2.addEventListener("click",()=>{b1.dataset.on="0";b2.dataset.on="1";wrap.value=true; wrap.dispatchEvent(new Event("input",{bubbles:true}));});
btns.append(b1,b2); wrap.append(lbl,btns); return wrap;
})());
```
<div class="hom-controls">
<div>${yearRange}</div>
<div>${selectedLangs}</div>
<div>${genreFilter}</div>
<div>${normalised}</div>
</div>
```js
const raw = rawData;
const filtered = raw.filter(d =>
selectedLangs.includes(d.language_code) &&
+d.release_year >= yearRange[0] &&
+d.release_year <= yearRange[1] &&
(genreFilter === "Tous" ? topGenresQ.includes(d.genre) : d.genre === genreFilter)
);
// Aggregate by genre + year
const areaMap = new Map();
const yearsSet = new Set();
const genresPresent = new Set();
for (const d of filtered) {
const key = `${d.genre}|${d.release_year}`;
areaMap.set(key, (areaMap.get(key) ?? 0) + +d.track_count);
yearsSet.add(+d.release_year);
genresPresent.add(d.genre);
}
const genreList = genreFilter==="Tous"
? topGenresQ.filter(g=>genresPresent.has(g))
: [genreFilter];
const palette = ["#1DB954","#1a75cc","#e84040","#f5a623","#9b59b6","#e91e8c",
"#16a085","#d35400","#2980b9","#27ae60","#8e44ad","#c0392b"];
const gColor = Object.fromEntries(genreList.map((g,i)=>[g,palette[i%palette.length]]));
let plotData = [];
for (const year of [...yearsSet].sort((a,b)=>a-b)) {
for (const genre of genresPresent) {
plotData.push({ year, genre, count: areaMap.get(`${genre}|${year}`) ?? 0 });
}
}
if (normalised) {
const yt = new Map();
for (const d of plotData) yt.set(d.year,(yt.get(d.year)??0)+d.count);
plotData = plotData.map(d=>({...d, count: yt.get(d.year)>0 ? d.count/yt.get(d.year)*100 : 0}));
}
// Pie data
const langPieData = topLangsQ.map(code => {
const total = raw.filter(d=>d.language_code===code && +d.release_year>=yearRange[0] && +d.release_year<=yearRange[1])
.reduce((s,d)=>s+(+d.track_count),0);
return {lang:code, label:getLang(code), count:total, color:getLangColor(code)};
}).filter(d=>d.count>0);
const pieTotal = langPieData.reduce((s,d)=>s+d.count,0);
```
<div class="hom-charts">
<div class="hom-card">
<h3>Évolution des genres${normalised ? " (normalisé, %)" : " (nombre de titres)"}</h3>
```js
display(Plot.plot({
width: Math.max(400, width - 320),
height: 380,
marginLeft: 55,
marginBottom: 45,
x: { label: "Année →", tickFormat: d=>String(d) },
y: {
label: normalised ? "Part (%)" : "Nombre de titres →",
grid: true,
tickFormat: normalised ? d=>d.toFixed(0)+"%" : "s"
},
color: {
domain: genreList,
range: genreList.map(g=>gColor[g]),
legend: true, columns: 4
},
marks: [
Plot.areaY(plotData, {
x: "year", y: "count", fill: "genre", order: genreList,
curve: "monotone-x", tip: true,
title: d=>`${d.genre} · ${d.year}\n${normalised ? d.count.toFixed(1)+"%" : Number(d.count).toLocaleString()+" titres"}`
}),
Plot.ruleY([0])
]
}));
```
</div>
<div class="hom-card">
<h3>Part des langues · ${yearRange[0]}${yearRange[1]}</h3>
```js
const PW=220, PH=220, R=80, r=40, cx=PW/2, cy=PH/2;
let cum=-Math.PI/2;
const arcs=langPieData.map(d=>{
const a=(d.count/pieTotal)*2*Math.PI;
const a0=cum, a1=cum+a; cum=a1;
return {...d, a0, a1, mid:(a0+a1)/2, pct:(d.count/pieTotal*100).toFixed(1)};
});
function arcPath(a0,a1,R,r){
const x0=cx+R*Math.cos(a0),y0=cy+R*Math.sin(a0);
const x1=cx+R*Math.cos(a1),y1=cy+R*Math.sin(a1);
const x2=cx+r*Math.cos(a1),y2=cy+r*Math.sin(a1);
const x3=cx+r*Math.cos(a0),y3=cy+r*Math.sin(a0);
const lg=(a1-a0)>Math.PI?1:0;
return `M${x0} ${y0} A${R} ${R} 0 ${lg} 1 ${x1} ${y1} L${x2} ${y2} A${r} ${r} 0 ${lg} 0 ${x3} ${y3}Z`;
}
const NS="http://www.w3.org/2000/svg";
const svg=document.createElementNS(NS,"svg");
svg.setAttribute("viewBox",`0 0 ${PW} ${PH}`);
svg.setAttribute("width",PW); svg.setAttribute("height",PH);
svg.style.cssText="display:block;margin:0 auto;";
arcs.forEach(a=>{
const p=document.createElementNS(NS,"path");
p.setAttribute("d",arcPath(a.a0,a.a1,R,r));
p.setAttribute("fill",a.color);
p.setAttribute("stroke",selectedLangs.includes(a.lang)?"#fff":"var(--theme-background)");
p.setAttribute("stroke-width",selectedLangs.includes(a.lang)?"3":"1.5");
p.style.opacity=selectedLangs.includes(a.lang)?"1":"0.3";
const t=document.createElementNS(NS,"title");
t.textContent=`${a.label}: ${a.pct}% (${a.count.toLocaleString()} titres)`;
p.appendChild(t); svg.appendChild(p);
if(a.a1-a.a0>0.3){
const lx=cx+(R*0.7)*Math.cos(a.mid), ly=cy+(R*0.7)*Math.sin(a.mid);
const txt=document.createElementNS(NS,"text");
txt.setAttribute("x",lx); txt.setAttribute("y",ly);
txt.setAttribute("text-anchor","middle"); txt.setAttribute("dominant-baseline","middle");
txt.setAttribute("fill","#fff"); txt.setAttribute("font-size","10");
txt.setAttribute("font-weight","700"); txt.setAttribute("font-family","var(--sans-serif)");
txt.textContent=a.pct+"%"; svg.appendChild(txt);
}
});
const ct=document.createElementNS(NS,"text");
ct.setAttribute("x",cx); ct.setAttribute("y",cy-7); ct.setAttribute("text-anchor","middle");
ct.setAttribute("dominant-baseline","middle"); ct.setAttribute("font-size","12");
ct.setAttribute("font-weight","700"); ct.setAttribute("font-family","var(--sans-serif)");
ct.setAttribute("fill","var(--theme-foreground)");
ct.textContent=pieTotal.toLocaleString(); svg.appendChild(ct);
const cs=document.createElementNS(NS,"text");
cs.setAttribute("x",cx); cs.setAttribute("y",cy+10); cs.setAttribute("text-anchor","middle");
cs.setAttribute("font-size","9"); cs.setAttribute("font-family","var(--sans-serif)");
cs.setAttribute("fill","var(--theme-foreground-muted)"); cs.textContent="titres"; svg.appendChild(cs);
display(svg);
```
```js
const leg=document.createElement("div");
leg.style.cssText="margin-top:8px;font-family:var(--sans-serif);font-size:11px;display:flex;flex-direction:column;gap:3px;";
// arcs has pct; langPieData does not — iterate arcs for the legend
arcs.forEach(d=>{
const row=document.createElement("div"); row.style.cssText="display:flex;align-items:center;gap:6px;";
const dot=document.createElement("span");
dot.style.cssText=`width:10px;height:10px;border-radius:2px;background:${d.color};flex-shrink:0;opacity:${selectedLangs.includes(d.lang)?1:0.3};`;
const info=document.createElement("span");
info.style.cssText=`color:${selectedLangs.includes(d.lang)?"var(--theme-foreground)":"var(--theme-foreground-muted)"};`;
info.textContent=`${d.label}${d.pct}%`;
row.append(dot,info); leg.appendChild(row);
});
display(leg);
```
</div>
</div>
<div class="hom-detail">
<h3>Synthèse · sélection courante</h3>
```js
const byG = new Map();
for (const d of filtered) byG.set(d.genre,(byG.get(d.genre)??0)+(+d.track_count));
const sorted=[...byG.entries()].sort((a,b)=>b[1]-a[1]);
const totalSel=sorted.reduce((s,[,v])=>s+v,0);
if(sorted.length>0){
const detailDiv=document.createElement("div"); detailDiv.className="detail-grid";
[
{k:"Langues",v:selectedLangs.map(getLang).join(", ")||"—"},
{k:"Période",v:`${yearRange[0]} ${yearRange[1]}`},
{k:"Genre dominant",v:sorted[0][0]},
{k:"Part dom.",v:totalSel>0 ? (sorted[0][1]/totalSel*100).toFixed(1)+" %" : "—"},
{k:"Titres (dom.)",v:sorted[0][1].toLocaleString()},
{k:"Total titres",v:totalSel.toLocaleString()},
].forEach(({k,v})=>{
const it=document.createElement("div"); it.className="detail-item";
const kk=document.createElement("span"); kk.className="detail-key"; kk.textContent=k;
const vv=document.createElement("span"); vv.className="detail-val"; vv.textContent=v;
it.append(kk,vv); detailDiv.appendChild(it);
});
const barDiv=document.createElement("div"); barDiv.style.cssText="width:100%;margin-top:12px;";
const bt=document.createElement("div");
bt.style.cssText="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--theme-foreground-muted);margin-bottom:6px;";
bt.textContent="Top 5 genres"; barDiv.appendChild(bt);
sorted.slice(0,5).forEach(([g,cnt],i)=>{
const row=document.createElement("div"); row.style.cssText="display:flex;align-items:center;gap:8px;margin-bottom:4px;font-size:11px;";
const nm=document.createElement("span"); nm.style.cssText="min-width:120px;"; nm.textContent=g;
const bw=document.createElement("div"); bw.style.cssText="flex:1;background:#eee;border-radius:3px;height:8px;overflow:hidden;";
const b=document.createElement("div");
const barPct = sorted[0][1] > 0 ? (cnt/sorted[0][1]*100).toFixed(1) : "0";
b.style.cssText=`width:${barPct}%;height:100%;background:${gColor[g]??palette[i%palette.length]};border-radius:3px;`;
bw.appendChild(b);
const cs=document.createElement("span"); cs.style.cssText="min-width:55px;text-align:right;color:var(--theme-foreground-muted);"; cs.textContent=cnt.toLocaleString();
row.append(nm,bw,cs); barDiv.appendChild(row);
});
display(detailDiv); display(barDiv);
} else {
const p=document.createElement("p"); p.style.cssText="color:var(--theme-foreground-muted);font-style:italic;margin:0;";
p.textContent="Aucune donnée. Sélectionnez au moins une langue."; display(p);
}
```
</div>
## Popularité et durée moyenne par langue
```js
const lyFiltered = langYearData.filter(d =>
selectedLangs.includes(d.language_code) &&
+d.release_year >= yearRange[0] &&
+d.release_year <= yearRange[1]
);
const langDomain = selectedLangs;
const langRange = selectedLangs.map(l => getLangColor(l));
const popChart = Plot.plot({
width: Math.floor(width/2)-8,
height: 200, marginLeft: 50, marginBottom: 40,
x: {label:"Année"},
y: {label:"Popularité moy.", grid:true},
color: {domain:langDomain, range:langRange, legend:false,
tickFormat: l=>getLang(l)},
marks:[
Plot.line(lyFiltered,{x:d=>+d.release_year,y:d=>+d.avg_track_popularity,
stroke:"language_code",strokeWidth:2,curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\nPop: ${(+d.avg_track_popularity).toFixed(1)}`}),
Plot.dot(lyFiltered,{x:d=>+d.release_year,y:d=>+d.avg_track_popularity,
stroke:"language_code",r:2})
]
});
const durChart = Plot.plot({
width: Math.floor(width/2)-8,
height: 200, marginLeft: 58, marginBottom: 40,
x: {label:"Année"},
y: {label:"Durée moy. (min)", grid:true},
color: {domain:langDomain, range:langRange,
tickFormat: l=>getLang(l), legend:true, columns:4},
marks:[
Plot.line(lyFiltered,{x:d=>+d.release_year,y:d=>+d.avg_duration_ms/60000,
stroke:"language_code",strokeWidth:2,curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\nDurée: ${(+d.avg_duration_ms/60000).toFixed(2)} min`}),
Plot.dot(lyFiltered,{x:d=>+d.release_year,y:d=>+d.avg_duration_ms/60000,
stroke:"language_code",r:2})
]
});
const grid=document.createElement("div");
grid.style.cssText="display:grid;grid-template-columns:1fr 1fr;gap:16px;";
grid.append(popChart,durChart);
display(grid);
```

View File

@@ -0,0 +1,264 @@
---
toc: false
---
<div class="hero">
<h1>Spotify Analytics</h1>
<h2>9,8 millions de titres · 4 tables · données Anna's Archive 2025</h2>
</div>
<style>
.hero {
display: flex;
flex-direction: column;
align-items: center;
font-family: var(--sans-serif);
margin: 3rem 0 2rem;
text-align: center;
}
.hero h1 {
margin: 0.5rem 0;
padding: 0.5rem 0;
max-width: none;
font-size: clamp(2.5rem, 10vw, 7rem);
font-weight: 900;
line-height: 1;
background: linear-gradient(30deg, #1DB954, #1a75cc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero h2 {
margin: 0;
max-width: 34em;
font-size: 1rem;
font-weight: 500;
color: var(--theme-foreground-muted);
}
.nav-cards {
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: center;
margin: 1.5rem 0 2rem;
}
.nav-card {
display: flex;
flex-direction: column;
gap: 5px;
padding: 18px 24px;
border-radius: 12px;
text-decoration: none;
background: var(--theme-background-alt);
border: 2px solid transparent;
transition: border-color .15s, box-shadow .15s;
font-family: var(--sans-serif);
min-width: 180px;
max-width: 240px;
}
.nav-card:hover { border-color: #1DB954; box-shadow: 0 4px 18px #1DB95422; }
.nav-card-icon { font-size: 1.8rem; }
.nav-card-title { font-weight: 700; font-size: 1rem; color: var(--theme-foreground); }
.nav-card-desc { font-size: 0.78rem; color: var(--theme-foreground-muted); }
.kpi-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 2rem;
}
.kpi-card {
flex: 1;
min-width: 140px;
background: var(--theme-background-alt);
border-radius: 10px;
padding: 14px 20px;
font-family: var(--sans-serif);
}
.kpi-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--theme-foreground-muted);
margin-bottom: 4px;
}
.kpi-val {
font-size: 1.7rem;
font-weight: 900;
color: #1DB954;
line-height: 1;
}
.kpi-sub {
font-size: 0.72rem;
color: var(--theme-foreground-muted);
margin-top: 2px;
}
</style>
<div class="nav-cards">
<a class="nav-card" href="./">
<span class="nav-card-icon">📊</span>
<span class="nav-card-title">Vue d'ensemble</span>
<span class="nav-card-desc">Genres dominants, évolution globale</span>
</a>
<a class="nav-card" href="./history-of-music">
<span class="nav-card-icon">🎵</span>
<span class="nav-card-title">History of Music</span>
<span class="nav-card-desc">Genres × langues × temps · stacked area</span>
</a>
<a class="nav-card" href="./language-trends">
<span class="nav-card-icon">🌍</span>
<span class="nav-card-title">Tendances par langue</span>
<span class="nav-card-desc">Volume, popularité, durée par langue</span>
</a>
<a class="nav-card" href="./audio-features">
<span class="nav-card-icon">🎛️</span>
<span class="nav-card-title">Audio Features</span>
<span class="nav-card-desc">DNA sonore des genres · énergie, danceability…</span>
</a>
</div>
```js
const genreYear = await FileAttachment("data/genre_year.json").json();
const langYear = await FileAttachment("data/language_year.json").json();
```
```js
// KPI computations
// Use langYear for total tracks (one language per track = no double-counting)
const totalTracks = d3.sum(langYear, d => +d.track_count);
const byGenre = d3.rollup(genreYear, v => d3.sum(v, d => +d.track_count), d => d.genre);
const genreEntries = [...byGenre.entries()].sort((a,b) => b[1]-a[1]);
const topGenre = genreEntries[0] ?? ["—", 0];
const byLang = d3.rollup(langYear, v => d3.sum(v, d => +d.track_count), d => d.language_code);
const langEntries = [...byLang.entries()].sort((a,b) => b[1]-a[1]);
const topLang = langEntries[0] ?? ["—", 0];
const yearRange = d3.extent(genreYear, d => +d.release_year);
const langLabel = {en:"Anglais",fr:"Français",es:"Espagnol",de:"Allemand",pt:"Portugais",
it:"Italien",ja:"Japonais",ko:"Coréen",ar:"Arabe",ru:"Russe",tr:"Turc",nl:"Néerlandais",
pl:"Polonais",sv:"Suédois",hi:"Hindi"};
```
<div class="kpi-row">
<div class="kpi-card">
<div class="kpi-label">Titres indexés</div>
<div class="kpi-val">${(totalTracks/1e6).toFixed(1)}M</div>
<div class="kpi-sub">track_details</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Genre dominant</div>
<div class="kpi-val" style="font-size:1.2rem">${topGenre[0]}</div>
<div class="kpi-sub">${(topGenre[1]/1e3).toFixed(0)}k titres</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Langue dominante</div>
<div class="kpi-val" style="font-size:1.2rem">${langLabel[topLang[0]] ?? topLang[0]}</div>
<div class="kpi-sub">${(topLang[1]/1e3).toFixed(0)}k titres</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Période couverte</div>
<div class="kpi-val" style="font-size:1.1rem">${yearRange[0]}${yearRange[1]}</div>
<div class="kpi-sub">${yearRange[1]-yearRange[0]+1} ans</div>
</div>
</div>
## Top 20 genres (toutes années)
```js
const top20 = [...byGenre.entries()]
.sort((a,b) => b[1]-a[1])
.slice(0, 20)
.map(([genre, total]) => ({ genre, total }));
display(Plot.plot({
marginLeft: 150,
marginRight: 10,
width,
height: 480,
x: { label: "Nombre de titres", tickFormat: "s" },
y: { label: null },
marks: [
Plot.barX(top20, {
x: "total",
y: "genre",
sort: { y: "-x" },
fill: "#1DB954",
tip: true,
title: d => `${d.genre}\n${d.total.toLocaleString()} titres`
}),
Plot.ruleX([0])
]
}));
```
## Évolution des top 12 genres (1970 2025)
```js
const top12genres = [...byGenre.entries()].sort((a,b)=>b[1]-a[1]).slice(0,12).map(d=>d[0]);
const filteredGY = genreYear.filter(d => top12genres.includes(d.genre) && +d.release_year >= 1970);
const palette12 = ["#1DB954","#1a75cc","#e84040","#f5a623","#9b59b6","#e91e8c",
"#16a085","#d35400","#2980b9","#27ae60","#8e44ad","#c0392b"];
const genreColor = Object.fromEntries(top12genres.map((g,i) => [g, palette12[i]]));
display(Plot.plot({
width,
height: 340,
marginLeft: 55,
marginBottom: 40,
y: { label: "Titres", grid: true, tickFormat: "s" },
color: { domain: top12genres, range: top12genres.map(g=>genreColor[g]), legend: true, columns: 4 },
marks: [
Plot.areaY(filteredGY, {
x: d => +d.release_year,
y: d => +d.track_count,
fill: "genre",
order: top12genres,
curve: "monotone-x",
tip: true,
title: d => `${d.genre} · ${d.release_year}\n${Number(d.track_count).toLocaleString()} titres`
}),
Plot.ruleY([0])
]
}));
```
## Popularité moyenne par langue (19702025)
```js
const topLangs = [...byLang.entries()].sort((a,b)=>b[1]-a[1]).slice(0,8).map(d=>d[0]);
const langColors = ["#1DB954","#1a75cc","#e84040","#f5a623","#9b59b6","#e91e8c","#16a085","#d35400"];
const lc = Object.fromEntries(topLangs.map((l,i)=>[l,langColors[i]]));
const lyFiltered = langYear.filter(d => topLangs.includes(d.language_code) && +d.release_year >= 1970);
display(Plot.plot({
width,
height: 260,
marginLeft: 55,
marginBottom: 40,
y: { label: "Popularité moy.", grid: true },
color: {
domain: topLangs,
range: topLangs.map(l=>lc[l]),
legend: true,
tickFormat: l => langLabel[l] ?? l.toUpperCase()
},
marks: [
Plot.line(lyFiltered, {
x: d => +d.release_year,
y: d => +d.avg_track_popularity,
stroke: "language_code",
curve: "monotone-x",
strokeWidth: 2,
tip: true,
title: d => `${langLabel[d.language_code]??d.language_code} · ${d.release_year}\nPopularité: ${(+d.avg_track_popularity).toFixed(1)}`
})
]
}));
```

View File

@@ -0,0 +1,302 @@
---
toc: false
---
<style>
.page-title { font-family:var(--sans-serif); text-align:center; margin:2rem 0 1.5rem; }
.page-title h1 {
font-size:2.2rem; font-weight:900; margin:0 0 0.3rem;
background:linear-gradient(90deg,#1a75cc,#1DB954);
-webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text;
}
.page-title p { color:var(--theme-foreground-muted); font-size:.88rem; margin:0; }
.controls-bar {
display:flex; flex-wrap:wrap; gap:16px; align-items:flex-start;
background:var(--theme-background-alt); border-radius:12px;
padding:14px 18px; margin-bottom:20px;
font-family:var(--sans-serif); font-size:12px;
}
.ctrl-group { display:flex; flex-direction:column; gap:5px; }
.ctrl-label {
font-weight:700; font-size:10px; text-transform:uppercase;
letter-spacing:.06em; color:var(--theme-foreground-muted);
}
.lang-pills { display:flex; flex-wrap:wrap; gap:5px; }
.lang-pill {
padding:3px 11px; border-radius:14px; cursor:pointer;
font-size:11px; font-family:var(--sans-serif); border:2px solid;
transition:all .15s;
}
.drs-wrap { position:relative; height:34px; width:100%; min-width:200px; }
.drs-track { position:absolute; left:0; right:0; top:16px; height:2px; background:#ccc; border-radius:2px; }
.drs-fill { position:absolute; top:16px; height:2px; background:#1a75cc; }
.drs-wrap input[type=range] {
-webkit-appearance:none; appearance:none;
position:absolute; left:0; width:100%; top:0;
background:transparent; pointer-events:none; margin:0; height:34px;
}
.drs-wrap input[type=range]::-webkit-slider-thumb {
-webkit-appearance:none; width:15px; height:15px; border-radius:50%;
background:#1a75cc; cursor:pointer; pointer-events:all; border:none; margin-top:-6.5px;
}
.drs-wrap input[type=range]::-moz-range-thumb {
width:15px; height:15px; border-radius:50%; background:#1a75cc;
cursor:pointer; pointer-events:all; border:none;
}
.chart-card { background:var(--theme-background-alt); border-radius:12px; padding:16px; margin-bottom:16px; }
.chart-card h3 { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--theme-foreground-muted); margin:0 0 12px; }
.stats-table {
width:100%; border-collapse:collapse;
font-family:var(--sans-serif); font-size:12px;
}
.stats-table th {
text-align:left; padding:6px 10px;
font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em;
color:var(--theme-foreground-muted); border-bottom:1.5px solid var(--theme-background-alt);
}
.stats-table td {
padding:6px 10px; border-bottom:1px solid var(--theme-background-alt);
color:var(--theme-foreground);
}
.stats-table tr:last-child td { border-bottom:none; }
.bar-inline {
display:inline-block; height:8px; border-radius:2px; vertical-align:middle; margin-right:6px;
}
</style>
<div class="page-title">
<h1>Tendances par Langue</h1>
<p>Évolution du volume, de la popularité et de la durée moyenne par langue · 19702025</p>
</div>
```js
const langYear = await FileAttachment("data/language_year.json").json();
const langMeta = {
en:{label:"Anglais", color:"#1a75cc"},
es:{label:"Espagnol", color:"#e84040"},
fr:{label:"Français", color:"#9b59b6"},
de:{label:"Allemand", color:"#f5a623"},
pt:{label:"Portugais", color:"#e91e8c"},
ja:{label:"Japonais", color:"#16a085"},
it:{label:"Italien", color:"#d35400"},
ko:{label:"Coréen", color:"#2980b9"},
tr:{label:"Turc", color:"#8e44ad"},
ru:{label:"Russe", color:"#c0392b"},
pl:{label:"Polonais", color:"#27ae60"},
nl:{label:"Néerlandais",color:"#1abc9c"},
ar:{label:"Arabe", color:"#f39c12"},
sv:{label:"Suédois", color:"#3498db"},
hi:{label:"Hindi", color:"#e74c3c"},
};
const getLang = c => langMeta[c]?.label ?? c.toUpperCase();
const getColor = c => langMeta[c]?.color ?? "#888";
// All languages sorted by total
const allLangs = [...d3.rollup(langYear, v=>d3.sum(v,d=>+d.track_count), d=>d.language_code).entries()]
.sort((a,b)=>b[1]-a[1]).map(d=>d[0]);
```
```js
// Year slider
const yearRange = view((() => {
const min=1970, max=2025;
const c=document.createElement("div");
c.style.cssText="display:flex;flex-direction:column;gap:4px;min-width:220px;max-width:300px;";
const top=document.createElement("div"); top.style.cssText="display:flex;justify-content:space-between;";
const lbl=document.createElement("span");
lbl.style.cssText="font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--theme-foreground-muted)";
lbl.textContent="① Période";
const out=document.createElement("span"); out.style.cssText="font-weight:600;color:#1a75cc;font-size:12px;";
out.textContent=`${min} ${max}`; top.append(lbl,out);
const tw=document.createElement("div"); tw.className="drs-wrap";
const tr=document.createElement("div"); tr.className="drs-track";
const fi=document.createElement("div"); fi.className="drs-fill";
const lo=document.createElement("input"); lo.type="range"; lo.min=min; lo.max=max; lo.value=min;
const hi=document.createElement("input"); hi.type="range"; hi.min=min; hi.max=max; hi.value=max;
function u(){
const l=Math.min(+lo.value,+hi.value), h=Math.max(+lo.value,+hi.value);
fi.style.left=(l-min)/(max-min)*100+"%"; fi.style.width=(h-l)/(max-min)*100+"%";
out.textContent=`${l} ${h}`; c.value=[l,h]; c.dispatchEvent(new Event("input",{bubbles:true}));
}
lo.addEventListener("input",()=>{if(+lo.value>+hi.value)lo.value=hi.value;u();});
hi.addEventListener("input",()=>{if(+hi.value<+lo.value)hi.value=lo.value;u();});
tw.append(tr,fi,lo,hi); c.append(top,tw); u(); return c;
})());
```
```js
// Language pills (top 12)
const selectedLangs = view((() => {
const top12=allLangs.slice(0,12);
const def=["en","fr","es","de","pt","ja"];
const wrap=document.createElement("div"); wrap.style.cssText="display:flex;flex-direction:column;gap:5px;"; wrap.value=[...def];
const lbl=document.createElement("div");
lbl.style.cssText="font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--theme-foreground-muted)";
lbl.textContent="② Langues";
const row=document.createElement("div"); row.className="lang-pills";
top12.forEach(code=>{
const on=def.includes(code);
const pill=document.createElement("button"); pill.className="lang-pill";
pill.textContent=getLang(code); pill.dataset.code=code; pill.dataset.on=on?"1":"0";
const col=getColor(code);
const sty=v=>{pill.style.borderColor=col;pill.style.background=v?col:"transparent";pill.style.color=v?"#fff":col;};
sty(on);
pill.addEventListener("click",()=>{
pill.dataset.on=pill.dataset.on==="1"?"0":"1"; sty(pill.dataset.on==="1");
wrap.value=Array.from(row.querySelectorAll(".lang-pill[data-on='1']")).map(b=>b.dataset.code);
wrap.dispatchEvent(new Event("input",{bubbles:true}));
});
row.appendChild(pill);
});
wrap.append(lbl,row); return wrap;
})());
```
<div class="controls-bar">
<div>${yearRange}</div>
<div>${selectedLangs}</div>
</div>
```js
const filtered = langYear.filter(d =>
selectedLangs.includes(d.language_code) &&
+d.release_year >= yearRange[0] &&
+d.release_year <= yearRange[1]
);
const langDomain = selectedLangs;
const langColors = selectedLangs.map(getColor);
```
<div class="chart-card">
<h3>Volume de titres par langue (absolu)</h3>
```js
display(Plot.plot({
width, height:300, marginLeft:58, marginBottom:40,
x:{label:"Année"},
y:{label:"Nombre de titres", grid:true, tickFormat:"s"},
color:{domain:langDomain, range:langColors, legend:true,
tickFormat:getLang, columns:6},
marks:[
Plot.line(filtered,{
x:d=>+d.release_year, y:d=>+d.track_count, stroke:"language_code",
strokeWidth:2, curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\n${Number(d.track_count).toLocaleString()} titres`
}),
Plot.dot(filtered,{x:d=>+d.release_year,y:d=>+d.track_count,stroke:"language_code",r:2})
]
}));
```
</div>
<div class="chart-card">
<h3>Part relative par langue (normalisée, %)</h3>
```js
// Compute share per year
const yearTotals = d3.rollup(
langYear.filter(d=>+d.release_year>=yearRange[0]&&+d.release_year<=yearRange[1]),
v=>d3.sum(v,d=>+d.track_count), d=>+d.release_year
);
const filteredPct = filtered.map(d=>({
...d,
pct: yearTotals.get(+d.release_year) > 0
? +d.track_count / yearTotals.get(+d.release_year) * 100
: 0
}));
display(Plot.plot({
width, height:300, marginLeft:50, marginBottom:40,
x:{label:"Année"},
y:{label:"Part (%)", grid:true, tickFormat:d=>d.toFixed(0)+"%"},
color:{domain:langDomain, range:langColors, legend:false},
marks:[
Plot.areaY(filteredPct,{
x:d=>+d.release_year, y:"pct", fill:"language_code",
order:langDomain, curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\n${d.pct.toFixed(1)}%`
}),
Plot.ruleY([0])
]
}));
```
</div>
<div class="chart-card">
<h3>Popularité moyenne & durée moyenne par langue</h3>
```js
const popPlot = Plot.plot({
width:Math.floor(width/2)-8, height:220, marginLeft:52, marginBottom:40,
x:{label:"Année"},
y:{label:"Popularité moy.", grid:true},
color:{domain:langDomain, range:langColors, legend:false},
marks:[
Plot.line(filtered,{
x:d=>+d.release_year, y:d=>+d.avg_track_popularity,
stroke:"language_code", strokeWidth:2, curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\nPop: ${(+d.avg_track_popularity).toFixed(1)}`
})
]
});
const durPlot = Plot.plot({
width:Math.floor(width/2)-8, height:220, marginLeft:58, marginBottom:40,
x:{label:"Année"},
y:{label:"Durée moy. (min)", grid:true},
color:{domain:langDomain, range:langColors, legend:true, tickFormat:getLang, columns:4},
marks:[
Plot.line(filtered,{
x:d=>+d.release_year, y:d=>+d.avg_duration_ms/60000,
stroke:"language_code", strokeWidth:2, curve:"monotone-x",
tip:true, title:d=>`${getLang(d.language_code)} · ${d.release_year}\nDurée: ${(+d.avg_duration_ms/60000).toFixed(2)} min`
})
]
});
const g=document.createElement("div");
g.style.cssText="display:grid;grid-template-columns:1fr 1fr;gap:16px;";
g.append(popPlot,durPlot); display(g);
```
</div>
## Tableau récapitulatif · ${yearRange[0]}${yearRange[1]}
```js
const summary = allLangs.slice(0,15).map(code => {
const rows = langYear.filter(d=>d.language_code===code && +d.release_year>=yearRange[0] && +d.release_year<=yearRange[1]);
if (!rows.length) return null;
const total = d3.sum(rows, d=>+d.track_count);
const avgPop = d3.mean(rows, d=>+d.avg_track_popularity);
const avgDur = d3.mean(rows, d=>+d.avg_duration_ms);
const popTrend = rows.length > 4
? (+(rows[rows.length-1].avg_track_popularity) - +(rows[0].avg_track_popularity)).toFixed(1)
: "—";
return {code, label:getLang(code), total, avgPop, avgDur, popTrend, color:getColor(code)};
}).filter(Boolean);
const maxTotal = Math.max(...summary.map(d=>d.total));
const table = document.createElement("table");
table.className="stats-table";
table.innerHTML=`<thead><tr>
<th>Langue</th><th>Total titres</th><th>Pop. moy.</th><th>Durée moy.</th><th>Tendance pop.</th>
</tr></thead>`;
const tbody=document.createElement("tbody");
summary.forEach(d=>{
const tr=document.createElement("tr");
tr.innerHTML=`
<td><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${d.color};margin-right:6px;vertical-align:middle;"></span>${d.label}</td>
<td><span class="bar-inline" style="width:${(d.total/maxTotal*100).toFixed(0)}px;background:${d.color};"></span>${d.total.toLocaleString()}</td>
<td>${d.avgPop ? d.avgPop.toFixed(1) : "—"}</td>
<td>${d.avgDur ? (d.avgDur/60000).toFixed(2)+" min" : "—"}</td>
<td style="color:${d.popTrend>0?"#1DB954":d.popTrend<0?"#e84040":"var(--theme-foreground-muted)"}">${d.popTrend!=="—"?(d.popTrend>0?"+":"")+d.popTrend:d.popTrend}</td>
`;
tbody.appendChild(tr);
});
table.appendChild(tbody);
display(table);
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B