fix: hardcode PGPASSWORD default so loaders work without local .env
This commit is contained in:
53
.github/workflows/deploy.yml
vendored
Normal file
53
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: hello-framework/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: hello-framework
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: hello-framework
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: hello-framework/dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,8 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.observablehq/cache/
|
|
||||||
docs/.observablehq/
|
|
||||||
docs/.observablehq/cache/
|
|
||||||
.observablehq/
|
|
||||||
.cache/
|
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.observablehq/cache/
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Antonin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
159
README.md
159
README.md
@@ -1,137 +1,58 @@
|
|||||||
# Observable Workspace — In-N-Out x Séismes x OVNIs (Californie)
|
# Spotify Analytics — Observable Framework
|
||||||
|
|
||||||
Workspace **Observable Framework** orienté data storytelling avec deux analyses géospatiales en Californie :
|
Visualization dashboard built with [Observable Framework](https://observablehq.com/framework/) exploring a Spotify dataset (genres, languages, release years).
|
||||||
|
|
||||||
- **In-N-Out Seismic Challenge** : classement des restaurants In‑N‑Out selon une exposition aux séismes.
|
**Live demo:** https://arussac-perso.github.io/observable_spotify/history-of-music
|
||||||
- **Paradoxe de l’Animal Style** : corrélation exploratoire entre observations OVNI et proximité d’un In‑N‑Out.
|
|
||||||
|
|
||||||
## Objectifs
|
## Pages
|
||||||
|
- **Spotify Analytics** — bar charts and stacked area by genre/language with dual-range year filter.
|
||||||
|
- **History of Music** — stacked area chart + donut chart showing genre evolution over time, filterable by language, period, and genre.
|
||||||
|
|
||||||
- Démontrer une chaîne complète de visualisation interactive avec Observable Framework.
|
## Run locally
|
||||||
- Croiser des données hétérogènes (OSM/Overpass, USGS, jeu OVNI) avec un traitement géospatial léger.
|
|
||||||
- Produire des pages analytiques reproductibles, versionnables et publiables statiquement.
|
|
||||||
|
|
||||||
## Stack technique
|
|
||||||
|
|
||||||
- **Runtime / Build** : `@observablehq/framework` (v1.x)
|
|
||||||
- **Langage** : JavaScript ESM (`"type": "module"`)
|
|
||||||
- **Parsing CSV** : `d3-dsv`
|
|
||||||
- **Visualisation** : `Plot` (via Observable), `d3`, `topojson-client`
|
|
||||||
- **Topologie US** : `us-atlas@3` (chargement CDN)
|
|
||||||
|
|
||||||
## Architecture du projet
|
|
||||||
|
|
||||||
```text
|
|
||||||
observablehq.config.js # Configuration Observable Framework
|
|
||||||
package.json # Scripts npm + dépendances
|
|
||||||
docs/
|
|
||||||
index.md # Page d'accueil
|
|
||||||
in-n-out-california.md # Analyse séismes ↔ In-N-Out
|
|
||||||
ufo-animal-style-paradox.md # Analyse OVNI ↔ In-N-Out
|
|
||||||
data/
|
|
||||||
in-n-out-ca-overpass.json.js
|
|
||||||
ca-earthquakes-last365d.json.js
|
|
||||||
in-n-out-ca-csv.json.js
|
|
||||||
ufo-ca-sightings-v2.json.js
|
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Observable
|
|
||||||
|
|
||||||
Le fichier `observablehq.config.js` définit :
|
|
||||||
|
|
||||||
- `root: "docs"` : toutes les pages et données vivent sous `docs/`
|
|
||||||
- `title: "Observable Workspace"`
|
|
||||||
- `pager: false`
|
|
||||||
- `toc: true`
|
|
||||||
- `theme: "dashboard"`
|
|
||||||
|
|
||||||
## Pipeline de données
|
|
||||||
|
|
||||||
### 1) In-N-Out (source primaire Overpass)
|
|
||||||
|
|
||||||
Le module `docs/data/in-n-out-ca-overpass.json.js` :
|
|
||||||
|
|
||||||
- interroge Overpass API pour les POI In‑N‑Out (`node/way/relation`),
|
|
||||||
- normalise les attributs (`id`, `latitude`, `longitude`, `name`, `city`, `address`, `postcode`),
|
|
||||||
- déduplique par coordonnée arrondie,
|
|
||||||
- tente plusieurs endpoints Overpass (fallback de résilience),
|
|
||||||
- bascule sur un fallback CSV GitHub en cas d’échec.
|
|
||||||
|
|
||||||
### 2) Séismes Californie
|
|
||||||
|
|
||||||
Le dataset `ca-earthquakes-last365d.json` est utilisé dans la page `in-n-out-california.md` pour :
|
|
||||||
|
|
||||||
- filtrer les séismes dans une bounding box Californie,
|
|
||||||
- calculer la distance haversine de chaque séisme à chaque magasin,
|
|
||||||
- agréger des métriques locales (`quakeCount`, `maxMagnitude`, `avgMagnitude`, `nearestKm`),
|
|
||||||
- produire un **Shake Index** composite.
|
|
||||||
|
|
||||||
Formule utilisée :
|
|
||||||
|
|
||||||
`shakeIndex = quakeCount * 1.5 + maxMagnitude * 12 + max(0, radiusKm - min(nearestKm, radiusKm)) * 0.25`
|
|
||||||
|
|
||||||
### 3) OVNI Californie
|
|
||||||
|
|
||||||
La page `ufo-animal-style-paradox.md` :
|
|
||||||
|
|
||||||
- charge les observations OVNI et les points In‑N‑Out,
|
|
||||||
- calcule le magasin le plus proche pour chaque observation (distance haversine),
|
|
||||||
- marque `closeToBurger` selon un rayon interactif,
|
|
||||||
- agrège des hotspots via regroupement par magasin le plus proche,
|
|
||||||
- expose des KPIs interactifs (taux de proximité, volume, durée moyenne, etc.).
|
|
||||||
|
|
||||||
## Visualisations
|
|
||||||
|
|
||||||
- Bar charts classés (top/bottom stores)
|
|
||||||
- Carte Californie (projection Albers)
|
|
||||||
- Nuages de points et outils de filtrage (`Inputs.select`, `Inputs.range`)
|
|
||||||
- Tooltips détaillés pour exploration ad hoc
|
|
||||||
|
|
||||||
## Exécution locale
|
|
||||||
|
|
||||||
Pré-requis :
|
|
||||||
|
|
||||||
- Node.js 18+ recommandé
|
|
||||||
- npm
|
|
||||||
|
|
||||||
Installation :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
npm install
|
||||||
```
|
|
||||||
|
|
||||||
Développement (live preview) :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
# → http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
Build statique :
|
## Project structure
|
||||||
|
|
||||||
```bash
|
A typical Framework project looks like this:
|
||||||
npm run build
|
|
||||||
|
```ini
|
||||||
|
.
|
||||||
|
├─ src
|
||||||
|
│ ├─ components
|
||||||
|
│ │ └─ timeline.js # an importable module
|
||||||
|
│ ├─ data
|
||||||
|
│ │ ├─ launches.csv.js # a data loader
|
||||||
|
│ │ └─ events.json # a static data file
|
||||||
|
│ ├─ example-dashboard.md # a page
|
||||||
|
│ ├─ example-report.md # another page
|
||||||
|
│ └─ index.md # the home page
|
||||||
|
├─ .gitignore
|
||||||
|
├─ observablehq.config.js # the app config file
|
||||||
|
├─ package.json
|
||||||
|
└─ README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sorties et dossiers générés
|
**`src`** - This is the “source root” — where your source files live. Pages go here. Each page is a Markdown file. Observable Framework uses [file-based routing](https://observablehq.com/framework/project-structure#routing), which means that the name of the file controls where the page is served. You can create as many pages as you like. Use folders to organize your pages.
|
||||||
|
|
||||||
- `dist/` : artefacts de build statique (à ne pas versionner)
|
**`src/index.md`** - This is the home page for your app. You can have as many additional pages as you’d like, but you should always have a home page, too.
|
||||||
- `docs/.observablehq/` : cache/framework interne (à ne pas versionner)
|
|
||||||
|
|
||||||
Le `.gitignore` est configuré pour ignorer ces dossiers de cache/génération.
|
**`src/data`** - You can put [data loaders](https://observablehq.com/framework/data-loaders) or static data files anywhere in your source root, but we recommend putting them here.
|
||||||
|
|
||||||
## Qualité, limites et reproductibilité
|
**`src/components`** - You can put shared [JavaScript modules](https://observablehq.com/framework/imports) anywhere in your source root, but we recommend putting them here. This helps you pull code out of Markdown files and into JavaScript modules, making it easier to reuse code across pages, write tests and run linters, and even share code with vanilla web applications.
|
||||||
|
|
||||||
- Les données externes (Overpass, CDN, sources tierces) peuvent varier dans le temps.
|
**`observablehq.config.js`** - This is the [app configuration](https://observablehq.com/framework/config) file, such as the pages and sections in the sidebar navigation, and the app’s title.
|
||||||
- Les métriques proposées sont exploratoires et non causales.
|
|
||||||
- Le pipeline privilégie robustesse et disponibilité (fallback réseau) plutôt qu’une ETL lourde.
|
|
||||||
|
|
||||||
## Scripts npm
|
## Command reference
|
||||||
|
|
||||||
- `npm run dev` → `observable preview`
|
| Command | Description |
|
||||||
- `npm run build` → `observable build`
|
| ----------------- | -------------------------------------------------------- |
|
||||||
|
| `npm install` | Install or reinstall dependencies |
|
||||||
## Publication Git
|
| `npm run dev` | Start local preview server |
|
||||||
|
| `npm run build` | Build your static site, generating `./dist` |
|
||||||
Ce dépôt est prévu pour être poussé sur un remote Gitea.
|
| `npm run deploy` | Deploy your app to Observable |
|
||||||
Le README sert de documentation technique de référence pour maintenance, reprise et extension du projet.
|
| `npm run clean` | Clear the local data loader cache |
|
||||||
|
| `npm run observable` | Run commands like `observable help` |
|
||||||
|
|||||||
5
hello-framework/.gitignore
vendored
Normal file
5
hello-framework/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.DS_Store
|
||||||
|
/dist/
|
||||||
|
node_modules/
|
||||||
|
yarn-error.log
|
||||||
|
.env
|
||||||
15
hello-framework/README.md
Normal file
15
hello-framework/README.md
Normal 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
|
||||||
|
```
|
||||||
18
hello-framework/observablehq.config.js
Normal file
18
hello-framework/observablehq.config.js
Normal 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
3733
hello-framework/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
hello-framework/package.json
Normal file
24
hello-framework/package.json
Normal 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
1
hello-framework/src/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/.observablehq/cache/
|
||||||
385
hello-framework/src/audio-features.md
Normal file
385
hello-framework/src/audio-features.md
Normal 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 (1970–2025)
|
||||||
|
|
||||||
|
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 (0–1)", 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 (0–1)", 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 (1970–2025)
|
||||||
|
|
||||||
|
```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);
|
||||||
|
```
|
||||||
44
hello-framework/src/data/_db.js
Normal file
44
hello-framework/src/data/_db.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
hello-framework/src/data/audio_features_genre.json.js
Normal file
37
hello-framework/src/data/audio_features_genre.json.js
Normal 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));
|
||||||
23
hello-framework/src/data/audio_features_lang_year.json.js
Normal file
23
hello-framework/src/data/audio_features_lang_year.json.js
Normal 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));
|
||||||
24
hello-framework/src/data/audio_features_year.json.js
Normal file
24
hello-framework/src/data/audio_features_year.json.js
Normal 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));
|
||||||
10
hello-framework/src/data/genre_language_year.json.js
Normal file
10
hello-framework/src/data/genre_language_year.json.js
Normal 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));
|
||||||
10
hello-framework/src/data/genre_year.json.js
Normal file
10
hello-framework/src/data/genre_year.json.js
Normal 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));
|
||||||
11
hello-framework/src/data/inspect_db.py
Normal file
11
hello-framework/src/data/inspect_db.py
Normal 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)
|
||||||
10
hello-framework/src/data/language_year.json.js
Normal file
10
hello-framework/src/data/language_year.json.js
Normal 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));
|
||||||
BIN
hello-framework/src/data/spotify_analytics_compact.sqlite3
Normal file
BIN
hello-framework/src/data/spotify_analytics_compact.sqlite3
Normal file
Binary file not shown.
466
hello-framework/src/history-of-music.md
Normal file
466
hello-framework/src/history-of-music.md
Normal 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);
|
||||||
|
```
|
||||||
264
hello-framework/src/index.md
Normal file
264
hello-framework/src/index.md
Normal 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 (1970–2025)
|
||||||
|
|
||||||
|
```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)}`
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
```
|
||||||
302
hello-framework/src/language-trends.md
Normal file
302
hello-framework/src/language-trends.md
Normal 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 · 1970–2025</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);
|
||||||
|
```
|
||||||
BIN
hello-framework/src/observable.png
Normal file
BIN
hello-framework/src/observable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 B |
Reference in New Issue
Block a user