04 - Layout transitions

Mêmes données, trois layouts (line, grid, circle). Un seul radio input (layout) pilote à la fois le chart et le titre.

Pattern global : une seule source d’état, plusieurs cells qui s’y abonnent. Quand layout change, plusieurs cells re-runnent en parallèle.

Graphe de dépendances :

  • layout (radio) → titre, chart
  • orderedBalls (button) → chart
  • showLabels (toggle) → labels du chart

Pour ajouter un nouvel élément coordonné (sous-titre, anneau coloré, compteur), il suffit d’écrire une nouvelle cell qui lit layout. Les cells existantes ne bougent pas.

Dataset

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}
];

SVG canvas (créé une seule fois)

On définit le SVG et un inner group chartGroup pour le contenu, translaté avec la convention de marges. Cette cell n’a aucune dépendance réactive : elle ne re-run jamais.

const w = 800;
const h = 600;
const margin = {top: 80, right: 30, bottom: 40, left: 30};
const innerW = w - margin.left - margin.right;
const innerH = h - margin.top - margin.bottom;

const svg = d3.create("svg")
  .attr("width", w)
  .attr("height", h)
  .attr("viewBox", `0 0 ${w} ${h}`)
  .style("background-color", "tomato");

// Inner group : le contenu du chart vit ici, dans un système 0..innerW × 0..innerH.
const chartGroup = svg.append("g")
  .attr("transform", `translate(${margin.left}, ${margin.top})`);

Scale de taille

On dérive la taille de chaque ball de son mm. d3.extent(balls, d => d.mm) retourne [min, max] (la plus petite et la plus grosse balle), utilisé comme domain de la scale.

const sizeScale = d3.scaleLinear(d3.extent(balls, d => d.mm), [10, 30]);

Layout functions

Chaque layout est une fonction i → [x, y] : on lui donne l’index d’une ball, elle retourne sa position. Changer de layout = swap de fonction.

Les positions sont en coords locales dans chartGroup : origin (0, 0) = haut-gauche de la zone de dessin, extents innerW × innerH.

Layout 1 : line

Une rangée horizontale, balls espacées régulièrement, centrées verticalement.

function linePos(i) {
  return [
    (i + 0.5) * innerW / balls.length,
    innerH * 0.5
  ];
}

Layout 2 : grid

Une grille de cols colonnes. Le nombre de rows est calculé pour contenir toutes les balls.

const cols = 5;
const rows = Math.ceil(balls.length / cols);

function gridPos(i) {
  const col = i % cols;
  const row = Math.floor(i / cols);
  return [
    (col + 0.5) * innerW / cols,
    (row + 0.5) * innerH / rows
  ];
}

Layout 3 : circle

Les balls sont placées à intervalles réguliers sur un cercle. Math.cos / Math.sin convertissent un angle en coordonnées (x, y).

Le - Math.PI / 2 décale l’angle pour que la première ball soit en haut (12h) au lieu d’à droite (3h, le 0 par défaut de cos/sin).

function circlePos(i) {
  const angle = (i / balls.length) * Math.PI * 2 - Math.PI / 2;
  const radius = Math.min(innerW, innerH) * 0.42;
  return [
    innerW / 2 + Math.cos(angle) * radius,
    innerH / 2 + Math.sin(angle) * radius
  ];
}

Dictionnaire des layouts

On regroupe les trois fonctions dans un objet pour pouvoir les sélectionner par nom.

const layouts = {
  line:   linePos,
  grid:   gridPos,
  circle: circlePos
};

Position courante

position est la fonction layout actuelle, sélectionnée par la valeur du radio. Quand layout change, cette cell re-run et tous les cells qui utilisent position re-runnent à leur tour.

const position = layouts[layout];

Titre

Sa propre cell, dépend de layout. Le titre est un <text> SVG, on le rend idempotent avec .data([null]).join("text") : un seul <text> qui se réutilise au lieu d’en empiler de nouveaux à chaque re-run.

// titlesByLayout : dictionnaire {layout → titre}
const titlesByLayout = {
  line:   "Balls in a line",
  grid:   "Balls in a grid",
  circle: "Balls on a circle"
};

svg.selectAll("text.title")
  .data([null])              // un seul élément, donc un seul <text>
  .join("text")              // crée le <text> s'il n'existe pas, sinon réutilise
    .attr("class", "title")
    .attr("x", w / 2)
    .attr("y", 40)
    .attr("text-anchor", "middle")
    .attr("dominant-baseline", "hanging")
    .attr("font-size", 28)
    .style("fill", "lightyellow")
    .text(titlesByLayout[layout]);

Chart : cercles

Data join classique. La transition interpole les cx / cy entre l’ancienne et la nouvelle valeur. easeCubicInOut = accélère puis ralentit, courbe organique.

chartGroup.selectAll("circle")
  .data(orderedBalls, d => d.name)
  .join("circle")
    .style("fill", "lightyellow")
    .attr("r", d => sizeScale(d.mm))
  .transition().duration(1200).ease(d3.easeCubicInOut)
    .attr("cx", (d, i) => position(i)[0])
    .attr("cy", (d, i) => position(i)[1]);

Chart : labels

Même pattern, sur les <text>. La position est juste sous le cercle. L’opacity est binaire selon le toggle showLabels.

chartGroup.selectAll("text.label")
  .data(orderedBalls, d => d.name)
  .join("text")
    .attr("class", "label")
    .text(d => d.name)
    .attr("text-anchor", "middle")
    .attr("dominant-baseline", "hanging")
    .attr("font-size", 12)
    .style("fill", "black")
  .transition().duration(1200).ease(d3.easeCubicInOut)
    .attr("x", (d, i) => position(i)[0])
    .attr("y", (d, i) => position(i)[1] + sizeScale(d.mm) + 6)
    .style("opacity", showLabels ? 1 : 0);

Controls

Le radio choisit le layout. Le bouton réordonne les balls (shuffle aléatoire ou tri par taille). Le toggle affiche / cache les labels.

const layout = view(Inputs.radio(["line", "grid", "circle"], {value: "line", label: "Layout"}));
// Inputs.button avec [label, reducer] : chaque clic applique le reducer
// sur la valeur courante. value initial = balls (ordre original).
const orderedBalls = view(Inputs.button([
  ["Shuffle", () => d3.shuffle([...balls])],
  ["By size", () => [...balls].sort((a, b) => a.mm - b.mm)]
], {value: balls, label: "Order"}));
const showLabels = view(Inputs.toggle({label: "Show labels", value: true}));
display(svg.node());