02 - D3 Data joins
Au programme : d3.select, d3.selectAll, .data(), .join(), key functions, enter / update / exit. Données : un petit tableau de balles. Sources en fin de notebook.
Style CSS partagé
On peut styler des éléments SVG avec du CSS classique, comme du HTML. Ici on définit une classe .ball et on l’applique aux cercles et aux textes des trois charts avec .classed("ball", true) en JS.
<style>
.ball {
fill: lightyellow;
}
</style>Dataset : balles
Diamètres en millimètres (le champ s’appelle mm). À noter : c’est bien le diamètre, pas la circonférence — un baseball fait 73 mm de diamètre, pas les ~230 mm de sa circonférence.
let balls = [
{name: "Ping Pong", mm: 40},
{name: "Golf", mm: 43},
{name: "Squash", mm: 40},
{name: "Billiard", mm: 57},
{name: "Tennis", mm: 67},
{name: "Cricket", mm: 72},
{name: "Baseball", mm: 73},
{name: "Lacrosse", mm: 63},
{name: "Softball", mm: 97},
{name: "Volleyball", mm: 210},
{name: "Soccer", mm: 220},
{name: "Basketball", mm: 240},
{name: "Bowling", mm: 217}
];
const diams = balls.map(b => b.mm);Premier data join : un cercle par diamètre
On bind un tableau de nombres à des éléments <circle> SVG. Sur un cercle :
cx= position horizontale du centre (en pixels SVG)cy= position verticale du centrer= rayon (radius)
cx est calculé à partir de l’index i, r à partir de la valeur d. Le fill vient de la classe CSS .ball (ajoutée via .classed).
const w = 980;
const h = 200;
const svg = d3.create("svg")
.attr("width", w)
.attr("height", h)
.attr("viewBox", `0 0 ${w} ${h}`)
.style("background-color", "tomato");const inset = 10;
const scaleFactor = 0.2;
svg.selectAll("circle")
.data(diams)
.join("circle")
.classed("ball", true)
.attr("cx", (d, i) => inset + (i + 0.5) * (w - 2 * inset) / diams.length)
.attr("cy", h * 0.5)
.attr("r", d => d / 2 * scaleFactor);display(svg.node());Ajoutons des labels et un shuffle
Cette fois on bind les objets balles complets, pas juste les diamètres : on a accès à d.name pour le label et d.mm pour la taille.
Pour <text> : x et y placent l’origine du texte (par défaut le coin bas-gauche du texte), pas son centre. On aligne avec text-anchor ("start" / "middle" / "end").
La key function d => d.name (deuxième argument de .data()) garde l’identité de chaque balle entre les renders. Quand on shuffle, elles glissent vers leur nouvelle position au lieu de juste changer de valeur sur place.
const wShuffle = 980;
const hShuffle = 200;
const svgShuffle = d3.create("svg")
.attr("width", wShuffle)
.attr("height", hShuffle)
.attr("viewBox", `0 0 ${wShuffle} ${hShuffle}`)
.style("background-color", "tomato");const insetShuffle = 30;
const scaleFactorShuffle = 0.2;
const cxShuffle = (d, i) => insetShuffle + (i + 0.5) * (wShuffle - 2 * insetShuffle) / balls.length;
svgShuffle.selectAll("circle")
.data(shuffledBalls, d => d.name)
.join("circle")
.classed("ball", true)
.transition().duration(750)
.attr("cx", cxShuffle)
.attr("cy", hShuffle * 0.5)
.attr("r", d => d.mm / 2 * scaleFactorShuffle);
svgShuffle.selectAll("text")
.data(shuffledBalls, d => d.name)
.join("text")
.classed("ball", true)
.text(d => d.name)
.attr("text-anchor", "middle")
.attr("font-size", 12)
.transition().duration(750)
.attr("x", cxShuffle)
.attr("y", 140);// Le comma operator force la dépendance sur shuffleClick :
// chaque clic recalcule shuffledBalls.
const shuffledBalls = (shuffleClick, d3.shuffle([...balls]));const shuffleClick = view(Inputs.button("Shuffle"));display(svgShuffle.node());Animations Enter / Update / Exit
.join() peut prendre trois fonctions, une par phase de la jointure :
- enter : éléments à créer (présents dans les nouvelles données mais pas dans le DOM)
- update : éléments déjà existants (on les met à jour)
- exit : éléments à retirer (dans le DOM mais plus dans les données)
Un radio filtre les balles. Les enter font fade-in, les exit sont retirés, les update glissent vers leur nouvelle position.
const wFilter = 980;
const hFilter = 200;
const svgFilter = d3.create("svg")
.attr("width", wFilter)
.attr("height", hFilter)
.attr("viewBox", `0 0 ${wFilter} ${hFilter}`)
.style("background-color", "tomato");const filteredBalls = balls.filter(b => {
if (mode === "all") return true;
if (mode === "small") return b.mm < 100;
if (mode === "big") return b.mm >= 100;
});const insetFilter = 50;
const scaleFactorFilter = 0.2;
const cxFilter = (d, i) => insetFilter + (i + 0.5) * (wFilter - 2 * insetFilter) / filteredBalls.length;
svgFilter.selectAll("circle")
.data(filteredBalls, d => d.name)
.join(
enter => enter.append("circle")
.classed("ball", true)
.attr("cx", cxFilter)
.attr("cy", hFilter * 0.5)
.attr("r", 0),
update => update,
exit => exit.transition().duration(500)
.attr("r", 0)
.remove()
)
.transition().duration(750)
.attr("cx", cxFilter)
.attr("cy", hFilter * 0.5)
.attr("r", d => d.mm / 2 * scaleFactorFilter);
svgFilter.selectAll("text")
.data(filteredBalls, d => d.name)
.join(
enter => enter.append("text")
.classed("ball", true)
.text(d => d.name)
.attr("x", cxFilter)
.attr("y", 140)
.style("opacity", 0),
update => update,
exit => exit.transition().duration(500)
.style("opacity", 0)
.remove()
)
.text(d => d.name)
.attr("text-anchor", "middle")
.attr("font-size", 12)
.transition().duration(750)
.style("opacity", 1)
.attr("x", cxFilter)
.attr("y", 140);const mode = view(Inputs.radio(["all", "small", "big"], {value: "all", label: "Show"}));display(svgFilter.node());