06 - Connect the dots

Les puzzles “relier les points” pour enfants comme prétexte au path generator de D3. Chaque shape est un tableau de points {x, y} chargé depuis un JSON ; d3.line() les transforme en chaîne d pour un <path> SVG.

Pattern global : on charge plusieurs shapes, l’utilisateur en choisit une, le notebook dessine les points + la ligne qui les relie, avec une animation de “self-drawing”.

Graphe de dépendances :

  • shapeName (radio) → path + dots
  • curveType (radio) → lineGen → path
  • redrawClick (button) → path (rejoue l’animation)
  • showPath (toggle) → opacity du path
  • showNumbers (toggle) → opacity des numbers

Source : dataset de Williams College CS 326 — Connect The Dots lab. Zip original ici. Fichiers copiés dans /assets/connect-the-dots/.

Loading et normalisation

Les coordonnées de chaque puzzle utilisent un range différent (la leaf fait ~600 px de large, la star ~270). On normalise chaque shape pour qu’elle rentre dans une box 300 × 300, centrée, en préservant l’aspect ratio. Le SVG peut alors render n’importe quelle shape à la même échelle.

function normalize(points, size = 300) {
  // Bounding box : extents min/max de x et de y.
  const xExtent = d3.extent(points, d => d.x);
  const yExtent = d3.extent(points, d => d.y);

  // Plus grand côté de la bbox → facteur d'échelle uniforme.
  const range = Math.max(xExtent[1] - xExtent[0], yExtent[1] - yExtent[0]);
  const scale = size / range;

  // Centre de la bbox, qu'on va décaler vers le centre du carré (size / 2).
  const cx = (xExtent[0] + xExtent[1]) / 2;
  const cy = (yExtent[0] + yExtent[1]) / 2;

  return points.map(p => ({
    x: (p.x - cx) * scale + size / 2,
    y: (p.y - cy) * scale + size / 2
  }));
}
// Top-level await dans une cell type="module" : le runtime attend la promise
// avant de laisser tourner les cells qui dépendent de `shapes`.
const shapes = {
  star:   normalize(await d3.json("data/connect-the-dots/star.json")),
  leaf:   normalize(await d3.json("data/connect-the-dots/leaf.json")),
  tree:   normalize(await d3.json("data/connect-the-dots/tree.json")),
  runner: normalize(await d3.json("data/connect-the-dots/runner.json")),
  crown:  normalize(await d3.json("data/connect-the-dots/crown.json"))
};

SVG canvas (créé une seule fois)

const w = 400;
const h = 400;
const margin = {top: 50, right: 50, bottom: 50, left: 50};
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", "#fafafa");

const chartGroup = svg.append("g")
  .attr("transform", `translate(${margin.left}, ${margin.top})`);

Line generator + curve interpolation

d3.line() connecte les points ; la curve décide comment : segments droits, splines lisses, etc. Les variantes *Closed font que la curve revient du dernier point au premier, pas besoin de "Z".

  • linear : segments droits, le connect-the-dots littéral.
  • catmullRom : passe par tous les points avec une interpolation cubique lisse.
  • cardinal : similaire mais avec des tangentes (tension 0.5 par défaut).
  • basis : B-spline ; la curve ne passe pas par les control points, elle est attirée vers eux. Donne une silhouette plus “blobby”.
const curves = {
  linear:     d3.curveLinearClosed,
  catmullRom: d3.curveCatmullRomClosed,
  cardinal:   d3.curveCardinalClosed,
  basis:      d3.curveBasisClosed
};

// d3.line() avec des accessors `x` et `y` configurés selon la shape de nos données.
// .curve(...) choisit l'algorithme d'interpolation entre les points.
const lineGen = d3.line()
  .x(d => d.x)
  .y(d => d.y)
  .curve(curves[curveType]);

Shape courante

shape est le tableau de points correspondant à shapeName. Quand le radio change, cette cell re-run et tout ce qui suit re-run aussi.

const shape = shapes[shapeName];

Path avec self-drawing animation

L’astuce classique D3 du “drawing in” :

  1. Set le d du path, puis mesurer sa longueur totale avec node().getTotalLength().
  2. Mettre stroke-dasharray et stroke-dashoffset à cette longueur : tout le stroke est maintenant “dashé hors écran” (le gap couvre toute la ligne).
  3. Transitionner stroke-dashoffset vers 0 : le dash glisse en place, comme si la ligne se dessinait.
  4. Faire fade-in le fill ensuite (sinon la silhouette pop tout coloré dès le départ).

La cell dépend de shape, lineGen (qui dépend de curveType), et redrawClick : n’importe lequel qui change rejoue l’animation.

redrawClick;   // déclaration de dépendance : cliquer Redraw re-run la cell

const pathSel = chartGroup.selectAll("path.shape")
  .data([null])              // un seul élément, donc un seul <path>
  .join("path")              // crée s'il n'existe pas, sinon réutilise
    .attr("class", "shape")
    .style("fill", "tomato")
    .style("stroke", "tomato")
    .style("stroke-width", 1.5);

// 1. set le d du path avec le line generator courant
pathSel.attr("d", lineGen(shape));

// 2. mesurer la longueur du path en pixels
const pathLength = pathSel.node().getTotalLength();

// 3. dash trick : un seul dash de la longueur totale, offset de la même valeur
//    → la ligne est "dessinée hors écran" au départ
pathSel
  .attr("stroke-dasharray", pathLength)
  .attr("stroke-dashoffset", pathLength)
  .style("fill-opacity", 0)
  .style("opacity", showPath ? 1 : 0);

// 4. animer offset vers 0 → la ligne se dessine. Puis fade-in du fill.
pathSel.transition().duration(2000).ease(d3.easeQuadInOut)
  .attr("stroke-dashoffset", 0)
  .transition().duration(500)
  .style("fill-opacity", showPath ? 0.15 : 0);

Dots

Un cercle par point, keyed par index. La taille s’adapte au nombre de points : gros dots quand il y en a peu, petits dots pour les puzzles denses (~90 pts).

const dotRadius = shape.length < 30 ? 8 : 3;

chartGroup.selectAll("circle.dot")
  .data(shape, (d, i) => i)
  .join(
    enter => enter.append("circle")
      .attr("class", "dot")
      .style("fill", "white")
      .style("stroke", "black")
      .style("stroke-width", 1)
      .attr("r", 0)
      .attr("cx", d => d.x)
      .attr("cy", d => d.y),
    update => update,
    exit => exit.transition().duration(400)
      .attr("r", 0)
      .remove()
  )
  .transition().duration(750)
    .attr("cx", d => d.x)
    .attr("cy", d => d.y)
    .attr("r", dotRadius);

Numbers

Étiquettes numériques (1, 2, 3…) à côté de chaque point. Pour les puzzles sparses (star, leaf), on les affiche au centre du cercle ; pour les denses, on les décale en haut à droite pour qu’ils ne se masquent pas entre eux.

const isSparse = shape.length < 30;
const numberFontSize = isSparse ? 9 : 7;
const numberOffsetX = isSparse ? 0 : 8;
const numberOffsetY = isSparse ? 0 : -8;

chartGroup.selectAll("text.number")
  .data(shape, (d, i) => i)
  .join(
    enter => enter.append("text")
      .attr("class", "number")
      .attr("text-anchor", "middle")
      .attr("dominant-baseline", "middle")
      .style("font-family", "sans-serif")
      .style("fill", "black")
      .style("opacity", 0)
      .attr("x", d => d.x)
      .attr("y", d => d.y),
    update => update,
    exit => exit.transition().duration(400)
      .style("opacity", 0)
      .remove()
  )
  .text((d, i) => i + 1)
  .attr("font-size", numberFontSize)
  .transition().duration(750)
    .attr("x", d => d.x + numberOffsetX)
    .attr("y", d => d.y + numberOffsetY)
    .style("opacity", showNumbers ? 1 : 0);

Controls

Star (6 pts) et leaf (24 pts) sont assez sparses pour lire chaque numbered dot. Tree, runner et crown (~90 pts chacun) sont denses : c’est le path qui raconte l’histoire.

const shapeName = view(Inputs.radio(Object.keys(shapes), {value: "leaf", label: "Shape"}));
const curveType = view(Inputs.radio(
  ["linear", "catmullRom", "cardinal", "basis"],
  {value: "linear", label: "Curve"}
));
const redrawClick = view(Inputs.button("Redraw"));
const showPath = view(Inputs.toggle({label: "Show connecting path", value: true}));
const showNumbers = view(Inputs.toggle({label: "Show numbers", value: false}));
display(svg.node());

À propos de getTotalLength()

Tout SVG path element expose .getTotalLength() : longueur stroked du path en user-space units. Combiné à .getPointAtLength(t), on peut faire plein d’effets de path-following (markers qui tracent une ligne, text along a curve, particles le long d’un contour). À retenir.

Pour aller plus loin

  • Curve along an SVG path : path.getPointAtLength(t) permet de placer des markers, des caractères ou des sub-shapes à des positions fractionnaires le long de la ligne. Animer t de 0 à length, attacher un cercle, et on a un tracer.
  • d3.drag() sur les dots : muter le tableau sous-jacent, regarder le path suivre. Un éditeur de shape en 20 lignes.
  • Plus de puzzles : drop d’autres JSON files dans /assets/connect-the-dots/ au format [{x, y}, ...] et les ajouter à la cell shapes.