<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-blog-map
  published:     2026-10-08
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Dessiner une carte de mon propre blog

> Construisez une carte interactive de tout votre corpus Docusaurus — chaque article devient une bulle portant la mascotte du site, dimensionnée selon le nombre d'articles qui pointent vers elle, positionnée par un layout force-directed calculé au build dans Node, si bien que le navigateur ne charge jamais d3-force. Inclut le plugin, le composant canvas et le fallback en liste simple qui garde la page utilisable sans JavaScript.

---
![Dessiner une carte de mon propre blog](/img/v2/post_mindmaps.webp)

**TL;DR**

Après 262 articles, je ne savais plus répondre à des questions simples sur mon propre blog : quels billets sont des hubs vers lesquels tout pointe, et lesquels restent seuls dans un coin. J'ai donc construit une page `/map` — un graphe force-directed de tout le corpus, où chaque article est un point dimensionné selon son degré entrant et relié par trois types d'arêtes (vrais liens inline, voisins de série, tags partagés). L'astuce qui rend ça peu coûteux : le layout est calculé **une seule fois, dans Node, au build**, donc le navigateur reçoit des coordonnées `(x, y)` finales et ne charge jamais de moteur physique.

Je publie deux fois par semaine, le lundi et le jeudi, et ça dure depuis un bon moment. Quelque part autour du billet numéro 150, je n'ai plus été capable de répondre à des questions que j'aurais dû connaître par cœur : cet article sur `fzf`, est-il seulement lié depuis quelque part ? Quel est le billet vers lequel tout le reste renvoie ? Est-ce que toute la série Quarto s'est retrouvée isolée du reste du blog ?

J'avais les données — le frontmatter de chaque article, chaque lien inline — mais aucun moyen de les *regarder*. Une liste de 262 lignes, ce n'est pas une façon de regarder quoi que ce soit.

J'ai donc construit [/map](/map) — une page qui dessine le blog sous forme de graphe. Voici comment ça fonctionne et comment en construire une.

**Quick Jump:** [Ce que la page carte vous montre](#what-the-map-page-shows-you) | [La construire](#building-it)

## Ce que la page carte vous montre {#what-the-map-page-shows-you}

Chaque article publié est une bulle. Plus elle est grosse, plus d'autres articles pointent vers elle. Des lignes relient les billets réellement liés entre eux — survolez-en un et tout ce qui n'est pas son voisin direct s'estompe :

```plaintext title="/map"
┌─ Blog Map ──────────────────────────────────────────────────────────────────┐
│                                                                             │
│  Filter by topic  [ Top 120 most-linked articles  ▾ ]                       │
│  262 articles · 25 series · 680 internal links                              │
│                                                                             │
│                           Running Docusaurus with Docker                    │
│         ·   ·                     ●                                         │
│       ·  ╲  │ ╱  ·               ╱ ╲                                        │
│     ·──── (◉) ────·             ●   ●───────● Docker-out-of-Docker          │
│       ·  ╱  │ ╲  ·               ╲ ╱                                        │
│         ·   ·                     ●                                         │
│                                                       🦫                    │
│                                              (drawn bigger than its size:   │
│                                               nothing links to this one yet)│
│                                                                             │
│  ▸ View as list instead                                                     │
└─────────────────────────────────────────────────────────────────────────────┘
```

Trois choses à faire sur cette page :

* **Filtrer par sujet** avec la liste déroulante pour restreindre le graphe à un seul `mainTag`. Les articles qui partagent le tag mais ne sont liés à rien autour d'eux restent visibles — c'est exactement le genre d'orphelin que vous ne remarqueriez jamais autrement.
* **Survoler un nœud** pour voir son titre et mettre en évidence ses connexions directes. Un cluster qui paraît dense de loin se révèle souvent être deux ou trois hubs avec beaucoup de satellites qui ne pointent que vers l'intérieur.
* **Passer en liste** en bas de page si vous préférez le texte au canvas, ou si vous avez désactivé JavaScript : chaque article est toujours là, groupé par série, entièrement indexé.

## Pourquoi calculer le layout au build change tout {#why-computing-the-layout-at-build-time-changes-everything}

Un graphe force-directed, c'est normalement une affaire de navigateur : vous embarquez une bibliothèque physique, vous y déposez les nœuds et vous les regardez se stabiliser pendant quelques secondes. Cette approche échoue sur trois exigences qui comptaient vraiment pour moi, et déplacer la simulation dans Node règle les trois d'un coup.

* **La page doit fonctionner sans JavaScript.** Si les positions sont déjà finales dans le HTML, la seule chose qu'ajoute JavaScript est le dessin — le chemin sans JS peut donc retomber sur une liste simple, rendue côté serveur et entièrement indexable, de tous les articles, au lieu d'une boîte vide.
* **Rien ne doit s'animer tout seul.** Il n'y a pas de branche `prefers-reduced-motion` à écrire, parce qu'il n'y a aucun mouvement à supprimer : le graphe est déjà au repos à l'instant où il apparaît.
* **`d3-force` n'atteint jamais le lecteur.** Il reste une dépendance de build. Le navigateur reçoit des nombres, pas un solveur.
* **Le même build produit la même carte.** L'ordre des nœuds est trié avant le démarrage de la simulation, et `d3-force` place les nœuds à partir de leur index de tableau plutôt que de `Math.random()` : un rebuild sans changement de contenu reproduit exactement la même image.

Le coût de tout ça, c'est une seconde de plus dans `yarn build`. C'est le meilleur arbitrage que j'aie fait sur ce blog.

## La construire {#building-it}

Deux morceaux : un plugin qui calcule le graphe, et un composant qui le dessine.

### Le plugin {#the-plugin}

Il lit le corpus via le même loader que celui déjà utilisé par mon [outillage de tags](/blog/docusaurus-tags), construit les arêtes, colore chaque nœud, exécute 300 ticks de simulation et passe le résultat à `setGlobalData` :

**plugins/blog-graph-plugin/index.mjs**

```mjs
/**
 * Docusaurus Plugin: blog-graph
 *
 * Purpose:
 * Builds the corpus-wide node/edge graph (link, series and tag relationships between every
 * published article) that powers the `/map` page's `BlogGraph` component — see
 * `.todos/DONE/DONE_0081-blog-knowledge-graph-map-page.md` for the full spec.
 *
 * Why the force-directed layout is computed *here*, at build time, instead of in the
 * browser:
 * `/map`'s non-negotiable constraints (works with JS disabled, static rendering under
 * `prefers-reduced-motion`, indexable without hydration) all fall out for free once the
 * *positions* are already final by the time the page reaches the browser — so the
 * simulation runs once, in Node, and only the converged `(x, y)` per node ships to the
 * client. Nothing in the spec asks for live drag-to-reposition, so the client never needs
 * `d3-force` at all: the ~20 Ko dependency stays a build-time-only cost, out of the browser
 * bundle entirely.
 *
 * Data flow:
 * - `loadContent()` runs on every `yarn start` reload and once for `yarn build`. It reads
 *   the same corpus `scripts/internal-link-opportunities.mjs` uses
 *   (`scripts/lib/blog-corpus.mjs`, extracted for exactly this reason — see that file's
 *   header), builds nodes/edges, colors each node, then runs a deterministic `d3-force`
 *   simulation to converge `(x, y)`.
 * - `contentLoaded()` exposes the result as global plugin data via `setGlobalData`, so
 *   `src/pages/map.js` reads it with `usePluginData("blog-graph-plugin")` — available
 *   during SSR (baked into the static HTML) and during hydration, no client-side fetch.
 *
 * Node color — *not* an invented palette:
 * A post's node reuses whichever accent color the site already assigns it elsewhere: the
 * hand-picked `series.color` (`src/data/series.js`) for posts that belong to a series —
 * the same color `SeriesArticlesPage` themes its hero with — else the post's own
 * auto-extracted banner accent (`src/data/postColors.generated.js`, keyed by the post's
 * `image` frontmatter), the same color `BlogPostItem/Content` themes its hero with. See
 * `src/components/Blog/utils/color.md`. A post with neither falls back to a neutral gray.
 *
 * Edge types:
 * - "link"   — a real inline link from one post's prose to another (heaviest pull).
 * - "series" — two consecutive posts (by date) within the same series.
 * - "tag"    — shared tags between two posts. Only pairs sharing >= 2 tags are emitted at
 *   all: at this corpus size almost every pair of posts shares at least one tag, so a
 *   weight-1 threshold would connect everything to everything and defeat both the layout
 *   and the "don't draw the weak edges" rule from the spec. There is no separate
 *   simulate-but-don't-draw step — dropping weight-1 pairs before the layout runs is a
 *   simplification of the spec's threshold, made possible by the layout itself running
 *   once at build time rather than live in the browser.
 */

import path from "node:path";
import { fileURLToPath } from "node:url";
import {
  forceCenter,
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
} from "d3-force";
import { loadPosts } from "../../scripts/lib/blog-corpus.mjs";
import translationsManifest from "../translations-manifest-plugin/index.cjs";

const { collectTranslations, collectTranslatedFrontMatter } = translationsManifest;
import SERIES_DATA from "../../src/data/series.js";
import POST_COLORS from "../../src/data/postColors.generated.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Virtual coordinate space the layout is computed in. The client normalizes/scales this to
// whatever the actual canvas size is, so these are arbitrary but must stay fixed for the
// simulation's forces (distance, center) to make sense relative to one another.
const WIDTH = 1600;
const HEIGHT = 1000;

const MIN_NODE_RADIUS = 3;
const MAX_NODE_RADIUS = 20;

// Below this many shared tags, a pair of posts is not connected at all — see the "tag" bullet
// in the module comment above.
const MIN_SHARED_TAGS = 2;

// The layout (computeLayout, below) uses every edge down to MIN_SHARED_TAGS for spatial
// pull, but the client never runs a simulation and only ever draws a "tag" edge above a
// stricter threshold — shipping the weight-2 pairs (the large majority: ~1150 of ~1350 tag
// edges in this corpus) would triple the payload for edges nothing ever renders. This is the
// build-time equivalent of the spec's "simulate but don't draw below a threshold" rule.
const SHIP_MIN_SHARED_TAGS = 3;

const FALLBACK_COLOR = "#94a3b8"; // Neutral slate — only used when a post has neither a
// series color nor a banner accent (no image, or an image `generate-post-colors.mjs`
// couldn't extract a vivid color from).

const AVERAGE_READING_WPM = 200;

/** Approximate reading time from the already-prose-stripped body text. */
function readingTimeMinutes(prose) {
  const words = prose.split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.round(words / AVERAGE_READING_WPM));
}

/** Builds the "link" / "series" / "tag" edges between posts. */
function buildEdges(posts) {
  const known = new Set(posts.map((post) => post.permalink));
  const edges = [];

  // "link" — real inline links, the strongest signal of relatedness.
  for (const post of posts) {
    for (const target of post.links) {
      if (target !== post.permalink && known.has(target)) {
        edges.push({ source: post.permalink, target, type: "link", weight: 3 });
      }
    }
  }

  // "series" — consecutive posts (by date) within the same series.
  const bySeries = new Map();
  for (const post of posts) {
    if (!post.series) continue;
    if (!bySeries.has(post.series)) bySeries.set(post.series, []);
    bySeries.get(post.series).push(post);
  }
  for (const seriesPosts of bySeries.values()) {
    seriesPosts.sort((a, b) => a.date.localeCompare(b.date));
    for (let i = 0; i < seriesPosts.length - 1; i += 1) {
      edges.push({
        source: seriesPosts[i].permalink,
        target: seriesPosts[i + 1].permalink,
        type: "series",
        weight: 2,
      });
    }
  }

  // "tag" — shared tags, weight = number of shared tags. See MIN_SHARED_TAGS above.
  for (let i = 0; i < posts.length; i += 1) {
    for (let j = i + 1; j < posts.length; j += 1) {
      const shared = posts[i].tags.filter((tag) => posts[j].tags.includes(tag));
      if (shared.length >= MIN_SHARED_TAGS) {
        edges.push({
          source: posts[i].permalink,
          target: posts[j].permalink,
          type: "tag",
          weight: shared.length,
        });
      }
    }
  }

  return edges;
}

/** In-degree (how many *other* articles link to this one) per permalink, from "link" edges only. */
function computeInDegree(posts, edges) {
  const inDegree = new Map(posts.map((post) => [post.permalink, 0]));
  for (const edge of edges) {
    if (edge.type === "link" && inDegree.has(edge.target)) {
      inDegree.set(edge.target, inDegree.get(edge.target) + 1);
    }
  }
  return inDegree;
}

/** Reuses the site's own already-established accent colors — never an invented palette. */
function buildColorPicker() {
  const seriesColorByName = new Map(
    SERIES_DATA.map((series) => [series.name, series.color]),
  );

  return (post) => {
    if (post.series && seriesColorByName.has(post.series)) {
      return seriesColorByName.get(post.series);
    }
    if (post.image && POST_COLORS[post.image]) {
      return POST_COLORS[post.image];
    }
    return FALLBACK_COLOR;
  };
}

/** Radius grows with the square root of in-degree — linear would let hubs swallow the map. */
function buildRadiusScale(maxInDegree) {
  const safeMax = Math.max(maxInDegree, 1);
  return (inDegree) =>
    MIN_NODE_RADIUS + (MAX_NODE_RADIUS - MIN_NODE_RADIUS) * Math.sqrt(inDegree / safeMax);
}

const EDGE_TUNING = {
  link: { distance: 55, strength: 0.85 },
  series: { distance: 70, strength: 0.5 },
  tag: { distance: 110, strength: 0.08 }, // scaled further by weight below
};

/**
 * Runs the force simulation to convergence and returns `{ permalink, x, y }` for every node.
 * Deterministic: node order is sorted before the simulation starts (d3-force's default
 * initial placement is a function of array index, not `Math.random()`), so re-running the
 * build without any corpus change reproduces the same layout.
 */
function computeLayout(nodes, edges) {
  const sortedNodes = [...nodes].sort((a, b) => a.permalink.localeCompare(b.permalink));
  const radiusByPermalink = new Map(
    sortedNodes.map((node) => [node.permalink, node.radius]),
  );

  const simNodes = sortedNodes.map((node) => ({ id: node.permalink }));
  const simLinks = edges.map((edge) => ({ ...edge }));

  const simulation = forceSimulation(simNodes)
    .force(
      "link",
      forceLink(simLinks)
        .id((d) => d.id)
        .distance((edge) => EDGE_TUNING[edge.type].distance)
        .strength((edge) => {
          const { strength } = EDGE_TUNING[edge.type];
          return edge.type === "tag" ? Math.min(strength * edge.weight, 0.6) : strength;
        }),
    )
    .force("charge", forceManyBody().strength(-45))
    .force("center", forceCenter(WIDTH / 2, HEIGHT / 2))
    .force(
      "collide",
      forceCollide((d) => radiusByPermalink.get(d.id) + 2),
    )
    .stop();

  // Pre-converge synchronously instead of animating in the browser — this is the "simulation
  // pré-convergée au build" the reduced-motion/no-JS requirements depend on.
  const TICKS = 300;
  for (let i = 0; i < TICKS; i += 1) {
    simulation.tick();
  }

  return new Map(simNodes.map((node) => [node.id, { x: node.x, y: node.y }]));
}

/**
 * Builds the full graph — nodes, edges, layout and corpus-wide counters.
 *
 * The graph is a listing surface like any other, and a non-default locale needs **two** things
 * from it, not one. `loadPosts()` reads `blog/`, i.e. the English corpus, and Docusaurus's i18n
 * fallback gives every one of those articles a live `/fr/` route:
 *
 *   1. filter — show only articles that are really translated, or a French reader clicks a node
 *      and lands on English prose;
 *   2. localize — label the survivors with their French title, or the French map reads entirely
 *      in English. Filtering alone is what shipped: the four translated articles were correctly
 *      the only four nodes on `/fr/map/`, each carrying its English title.
 *
 * Both the canvas labels and the `<GroupedList>` fallback read `node.title`, so the overlay
 * belongs here — once — rather than in either component. See TODO 0119.
 */
function buildGraph({ siteDir, currentLocale, defaultLocale } = {}) {
  let posts = loadPosts();

  /** slug -> localized front matter. Empty on the default locale, where titles are the source. */
  let localized = {};

  if (currentLocale && defaultLocale && currentLocale !== defaultLocale) {
    const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []);
    posts = posts.filter((post) => {
      const match = String(post.permalink ?? "").match(/blog\/([^/]+)\/?$/);
      return match ? translated.has(match[1]) : false;
    });
    localized = collectTranslatedFrontMatter(siteDir)[currentLocale] ?? {};
  }
  const edges = buildEdges(posts);
  const inDegreeByPermalink = computeInDegree(posts, edges);
  const maxInDegree = Math.max(0, ...inDegreeByPermalink.values());
  const radiusScale = buildRadiusScale(maxInDegree);
  const colorForPost = buildColorPicker();

  const nodes = posts.map((post) => {
    const inDegree = inDegreeByPermalink.get(post.permalink) ?? 0;
    return {
      slug: post.slug,
      // The node label — the only text a reader sees on the map. `post.title` is the English
      // source; in a non-default locale the corpus above has already been narrowed to articles
      // that DO have a translation, so the overlay finds one for every surviving node.
      title: localized[post.slug]?.title || post.title,
      permalink: post.permalink,
      date: post.date,
      mainTag: post.mainTag,
      series: post.series,
      readingTime: readingTimeMinutes(post.prose),
      inDegree,
      radius: radiusScale(inDegree),
      color: colorForPost(post),
    };
  });

  const layout = computeLayout(nodes, edges);
  for (const node of nodes) {
    const position = layout.get(node.permalink);
    node.x = position.x;
    node.y = position.y;
  }

  const seriesCount = new Set(posts.map((post) => post.series).filter(Boolean)).size;
  const linkEdgeCount = edges.filter((edge) => edge.type === "link").length;

  // See SHIP_MIN_SHARED_TAGS above: the layout already used the full tag-edge set, this only
  // trims what ships to the browser.
  const shippedEdges = edges.filter(
    (edge) => edge.type !== "tag" || edge.weight >= SHIP_MIN_SHARED_TAGS,
  );

  return {
    // No build timestamp here on purpose. This object ships inside the client
    // bundle, so a wall-clock value changes main.js's content hash on every
    // build, which renames the file, which changes every HTML page that
    // references it — ~44 MB re-uploaded on each FTP deploy for nothing.
    width: WIDTH,
    height: HEIGHT,
    nodes,
    edges: shippedEdges,
    meta: {
      articleCount: posts.length,
      seriesCount,
      linkCount: linkEdgeCount,
    },
  };
}

export default function blogGraphPlugin(context) {
  return {
    name: "blog-graph-plugin",

    async loadContent() {
      return buildGraph({
        siteDir: context.siteDir,
        currentLocale: context.i18n?.currentLocale,
        defaultLocale: context.i18n?.defaultLocale,
      });
    },

    async contentLoaded({ content, actions }) {
      actions.setGlobalData(content);
    },

    getPathsToWatch() {
      // Rebuild the graph in `yarn start` whenever an article's frontmatter/body or the
      // corpus-loading lib itself changes — not just on a hard restart.
      return [
        path.join(__dirname, "../../blog/**/index.{md,mdx}"),
        path.join(__dirname, "../../scripts/lib/blog-corpus.mjs"),
        path.join(__dirname, "../../src/data/series.js"),
        path.join(__dirname, "../../src/data/postColors.generated.js"),
      ];
    },
  };
}
```

L'exécuter en standalone montre à quoi ressemble la charge utile :

```bash
$ node --input-type=module -e "
import('./plugins/blog-graph-plugin/index.mjs').then(async m => {
  const graph = await m.default().loadContent();
  const byType = {};
  for (const edge of graph.edges) byType[edge.type] = (byType[edge.type] ?? 0) + 1;
  console.log('nodes  ', graph.nodes.length);
  console.log('edges  ', graph.edges.length, byType);
  console.log('meta   ', graph.meta);
  console.log('payload', JSON.stringify(graph).length, 'bytes');
});"

nodes   262
edges   1026 { link: 680, series: 144, tag: 202 }
meta    { articleCount: 262, seriesCount: 25, linkCount: 680 }
payload 183292 bytes
```

262 nœuds, 1026 arêtes, 179 Ko de JSON — calculés une fois au build, plus jamais touchés par le navigateur.

Une décision mérite d'être soulignée : **les couleurs des nœuds ne sont pas une nouvelle palette.** Un billet appartenant à une série réutilise la couleur d'accent de cette série depuis `src/data/series.js` — la même que celle dont la [page des séries](/blog/docusaurus-series) habille son hero. Un billet sans série retombe sur la couleur d'accent extraite automatiquement de son image de bannière. Seul un billet qui n'a ni l'un ni l'autre obtient un gris neutre. La carte ressemble donc au reste du site gratuitement, et un lecteur qui reconnaît déjà « la bleue, c'est la série Quarto » la reconnaît ici aussi.

### Le composant {#the-component}

`BlogGraph` lit ces données globales, les dessine sur un `<canvas>` et se remplace par une liste simple en dessous de 768px :

**Project: src/components/BlogGraph**

**src/components/BlogGraph/index.tsx**

```typescript
/**
 * BlogGraph — the interactive corpus map rendered on `/map`.
 *
 * Reads the pre-computed graph (nodes, edges, build-time force layout) from
 * `plugins/blog-graph-plugin` via `usePluginData`, then:
 * - draws it on a `<canvas>` (248 nodes + edges in the DOM would crawl on hover/drag —
 *   see 0081's "Solution" §2), never more than the top ~120 articles by in-degree, or a
 *   single mainTag's subgraph once one is picked from the filter;
 * - degrades to `GroupedList` (an SSR'd, fully indexable plain list) with JavaScript
 *   disabled and on narrow viewports, where a force-directed graph has no room to be
 *   legible.
 *
 * The layout itself is already static (computed once, in Node — see the plugin's header
 * comment) so there is no `prefers-reduced-motion` branch to build here: nothing animates
 * on its own in the first place. The only motion is the hover crossfade, which the
 * stylesheet already turns off under that media query.
 *
 * No client-side simulation, no drag-to-reposition — out of scope per the TODO (only hover
 * and click are specified), and dropping it keeps `d3-force` a build-time-only dependency.
 *
 * The nodes themselves are drawn as this blog's own meerkat mascot rather than as flat dots —
 * one sticker per mainTag, so a topic reads as a family at a glance. See ./meerkats for the
 * mapping, and MEERKAT_MIN_DRAWN_RADIUS in ./utils for the size below which a face stops being
 * legible and the node falls back to the colored dot it always was.
 */

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { useHistory } from "@docusaurus/router";
import { usePluginData } from "@docusaurus/useGlobalData";
import { useBaseUrlUtils } from "@docusaurus/useBaseUrl";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n";
import GroupedList from "./GroupedList";
import {
  DEFAULT_TOP_N,
  MEERKAT_MIN_DRAWN_RADIUS,
  PERMANENT_LABEL_COUNT,
  computeCanvasHeight,
  displayRadius,
  fitTransform,
  neighborsOf,
  pickOrphanNodes,
  selectVisibleEdges,
  selectVisibleNodes,
  toCanvasSpace,
  type BlogGraphData,
  type BlogGraphNode,
  type Transform,
} from "./utils";
import { meerkatPathFor } from "./meerkats";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";

/**
 * Sticker path (as `meerkatPathFor` returns it, before `withBaseUrl`) -> the decoded image, for
 * the stickers the current view actually needs.
 */
type MeerkatImages = Record<string, HTMLImageElement>;

interface LabelCandidate {
  node: BlogGraphNode;
  x: number;
  y: number;
  dimmed: boolean;
}

const MOBILE_BREAKPOINT = 768;
const DIMMED_ALPHA = 0.15;
const HOVER_HIT_PADDING = 3;
const LABEL_FONT =
  "12px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
const EDGE_ALPHA: Record<string, number> = { link: 0.55, series: 0.4, tag: 0.18 };
const EDGE_WIDTH: Record<string, number> = { link: 1.4, series: 1, tag: 0.7 };
const LABEL_HEIGHT = 14;
const LABEL_PADDING = 2;

/**
 * Greedily keeps only the labels that don't overlap an already-placed one, in priority
 * order (the caller sorts `candidates` — hovered node first, then its neighbors, then the
 * permanently-labeled hubs). A crowded mainTag filter can otherwise stack a dozen titles on
 * top of each other, which is exactly the "hairball" outcome the spec says must not ship.
 */
function selectNonOverlappingLabels(
  ctx: CanvasRenderingContext2D,
  candidates: LabelCandidate[],
): LabelCandidate[] {
  const placed: LabelCandidate[] = [];
  const drawnRects: { left: number; right: number; top: number; bottom: number }[] = [];

  for (const candidate of candidates) {
    const width = ctx.measureText(candidate.node.title).width;
    const rect = {
      left: candidate.x - LABEL_PADDING,
      right: candidate.x + width + LABEL_PADDING,
      top: candidate.y - LABEL_HEIGHT / 2,
      bottom: candidate.y + LABEL_HEIGHT / 2,
    };
    const overlapsExisting = drawnRects.some(
      (drawn) =>
        rect.left < drawn.right &&
        rect.right > drawn.left &&
        rect.top < drawn.bottom &&
        rect.bottom > drawn.top,
    );

    if (!overlapsExisting) {
      drawnRects.push(rect);
      placed.push(candidate);
    }
  }

  return placed;
}

/** Reads the current theme's own colors so the canvas never hardcodes a hex value. */
function readThemeColors(): { edge: string; label: string } {
  const style = getComputedStyle(document.documentElement);
  return {
    edge: style.getPropertyValue("--ifm-color-emphasis-500").trim() || "#a2917a",
    label: style.getPropertyValue("--ifm-font-color-base").trim() || "#1c1e21",
  };
}

/**
 * The sticker to draw inside a node, or null when it shouldn't wear one: too small for a face
 * to read (see MEERKAT_MIN_DRAWN_RADIUS), or simply not downloaded yet — in both cases the
 * caller falls back to the flat colored dot.
 */
function resolveMeerkatImage(
  node: BlogGraphNode,
  radius: number,
  images: MeerkatImages,
): HTMLImageElement | null {
  if (radius < MEERKAT_MIN_DRAWN_RADIUS) return null;
  const path = meerkatPathFor(node);
  return (path && images[path]) || null;
}

/** Draws `image` centered on (cx, cy), scaled to cover a `size` × `size` box (crop, not stretch). */
function drawImageCover(
  ctx: CanvasRenderingContext2D,
  image: HTMLImageElement,
  cx: number,
  cy: number,
  size: number,
) {
  const naturalWidth = image.naturalWidth || image.width;
  const naturalHeight = image.naturalHeight || image.height;
  const scale = Math.max(size / naturalWidth, size / naturalHeight);
  const drawWidth = naturalWidth * scale;
  const drawHeight = naturalHeight * scale;
  ctx.drawImage(image, cx - drawWidth / 2, cy - drawHeight / 2, drawWidth, drawHeight);
}

export default function BlogGraph() {
  const graph = usePluginData("blog-graph-plugin") as BlogGraphData | undefined;
  const history = useHistory();
  const { withBaseUrl } = useBaseUrlUtils();
  const { i18n } = useDocusaurusContext();
  const tagLabel = useTagLabel();

  const wrapRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const [mounted, setMounted] = useState(false);
  const [isMobile, setIsMobile] = useState(false);
  const [mainTag, setMainTag] = useState("");
  const [hovered, setHovered] = useState<string | null>(null);
  const [containerWidth, setContainerWidth] = useState(0);
  // Bumped on theme toggle so the draw effect re-reads the (now different) CSS variables —
  // canvas has no way to react to a CSS variable change on its own.
  const [themeVersion, setThemeVersion] = useState(0);
  // Starts empty, so the draw effect just falls back to a flat color until (and unless) an
  // image is ready.
  const [meerkatImages, setMeerkatImages] = useState<MeerkatImages>({});
  // Every URL a load has already been started for — including the ones that failed, which are
  // deliberately never retried: a node that can't get its sticker keeps its colored dot, and a
  // broken asset must not turn into one request per redraw.
  const requestedRef = useRef(new Set<string>());

  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- SSR-safe mount detection, same pattern as ScrollToTopButton/reactions-dashboard
    setMounted(true);
    const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
    const updateIsMobile = () => setIsMobile(mediaQuery.matches);
    updateIsMobile();
    mediaQuery.addEventListener("change", updateIsMobile);
    return () => mediaQuery.removeEventListener("change", updateIsMobile);
  }, []);

  useEffect(() => {
    const observer = new MutationObserver(() => setThemeVersion((v) => v + 1));
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["data-theme"],
    });
    return () => observer.disconnect();
  }, []);

  // Progressive enhancement only: canvas graph once mounted on a wide-enough viewport,
  // the plain grouped list everywhere else (no JS at all, or a narrow screen).
  const showCanvas = mounted && !isMobile;

  useEffect(() => {
    if (!showCanvas || !wrapRef.current) return undefined;
    const el = wrapRef.current;
    const updateWidth = () => setContainerWidth(el.clientWidth);
    updateWidth();
    const resizeObserver = new ResizeObserver(updateWidth);
    resizeObserver.observe(el);
    return () => resizeObserver.disconnect();
  }, [showCanvas]);

  const mainTags = useMemo(() => {
    if (!graph) return [];
    const keys = [
      ...new Set(graph.nodes.map((node) => node.mainTag).filter(Boolean)),
    ] as string[];

    // Sorted on the DISPLAYED label, not on the key: under `fr`, "ai" reads "Intelligence
    // artificielle (IA)" and sorting by key would order the dropdown by invisible values.
    // `currentLocale` is passed explicitly — the runtime default differs between the Node
    // render and the browser, which would reorder the list at hydration.
    return keys.sort((a, b) =>
      tagLabel(a).localeCompare(tagLabel(b), i18n.currentLocale),
    );
  }, [graph, tagLabel, i18n.currentLocale]);

  const visibleNodes = useMemo(() => {
    if (!graph) return [];
    return selectVisibleNodes(graph.nodes, {
      mainTag: mainTag || null,
      topN: DEFAULT_TOP_N,
    });
  }, [graph, mainTag]);

  // Height fits the *currently visible* nodes, not a constant tuned for the full 120-node
  // view — see computeCanvasHeight() for why a two-node mainTag filter needs two independent
  // signals (shape *and* count) to reliably get a short canvas instead of inheriting the
  // default view's height with almost everything empty.
  const canvasSize = useMemo(
    () => ({
      width: containerWidth,
      height: computeCanvasHeight(visibleNodes, containerWidth),
    }),
    [containerWidth, visibleNodes],
  );

  const visiblePermalinks = useMemo(
    () => new Set(visibleNodes.map((node) => node.permalink)),
    [visibleNodes],
  );

  const nodeByPermalink = useMemo(
    () => new Map(visibleNodes.map((node) => [node.permalink, node])),
    [visibleNodes],
  );

  const visibleEdges = useMemo(() => {
    if (!graph) return [];
    return selectVisibleEdges(graph.edges, visiblePermalinks);
  }, [graph, visiblePermalinks]);

  const orphanNodes = useMemo(
    () => pickOrphanNodes(visibleNodes, visibleEdges),
    [visibleNodes, visibleEdges],
  );

  // Only the stickers this view needs, not all 68: the default view uses one per mainTag among
  // its 120 nodes, and a filtered view exactly one. `.sort().join()` keeps the dependency a
  // stable string, so switching filters back and forth doesn't re-run the effect for a set
  // that is already loaded.
  const meerkatPaths = useMemo(() => {
    const paths = new Set<string>();
    for (const node of visibleNodes) {
      const path = meerkatPathFor(node);
      if (path) paths.add(path);
    }
    return [...paths].sort().join(" ");
  }, [visibleNodes]);

  useEffect(() => {
    const missing = meerkatPaths
      .split(" ")
      .filter((path) => path && !requestedRef.current.has(path));
    if (missing.length === 0) return undefined;
    for (const path of missing) requestedRef.current.add(path);

    let cancelled = false;
    const load = (path: string) =>
      new Promise<[string, HTMLImageElement | null]>((resolve) => {
        const image = new Image();
        image.onload = () => resolve([path, image]);
        // A failed load (offline, blocked asset, a sticker renamed since the last build) just
        // means that node keeps its flat color — never a reason to break the graph.
        image.onerror = () => resolve([path, null]);
        // The images live in `static/`, which each locale build copies under its own prefix:
        // without withBaseUrl, /fr/map/ would request /img/... and get the SPA fallback.
        image.src = withBaseUrl(path);
      });

    Promise.all(missing.map(load)).then((entries) => {
      if (cancelled) return;
      const loaded: MeerkatImages = {};
      for (const [path, image] of entries) {
        if (image) loaded[path] = image;
      }
      if (Object.keys(loaded).length > 0) {
        setMeerkatImages((previous) => ({ ...previous, ...loaded }));
      }
    });

    return () => {
      cancelled = true;
    };
  }, [meerkatPaths, withBaseUrl]);

  // The list fallback (mobile, no JS, or the "View as list" disclosure) ignores the top-N
  // cutoff — a plain list has no legibility ceiling the way a canvas does — but must still
  // respect the mainTag filter: otherwise picking a topic from the <select> does nothing
  // visible whenever the canvas itself isn't shown (mobile), which reads as a broken control.
  const listNodes = useMemo(() => {
    if (!graph) return [];
    return mainTag ? graph.nodes.filter((node) => node.mainTag === mainTag) : graph.nodes;
  }, [graph, mainTag]);

  const permanentLabelSet = useMemo(
    () =>
      new Set(
        [...visibleNodes]
          .sort((a, b) => b.inDegree - a.inDegree)
          .slice(0, PERMANENT_LABEL_COUNT)
          .map((node) => node.permalink),
      ),
    [visibleNodes],
  );

  const neighborSet = useMemo(
    () => (hovered ? neighborsOf(hovered, visibleEdges) : null),
    [hovered, visibleEdges],
  );

  const transform: Transform = useMemo(
    () => fitTransform(visibleNodes, canvasSize.width || 1, canvasSize.height || 1),
    [visibleNodes, canvasSize],
  );

  // Draws the whole graph. Re-runs on every filter/hover/resize/theme change — cheap enough
  // at this node/edge count (never more than ~120 nodes) to just redraw from scratch.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!showCanvas || !canvas || canvasSize.width === 0) return;

    const dpr = window.devicePixelRatio || 1;
    canvas.width = canvasSize.width * dpr;
    canvas.height = canvasSize.height * dpr;
    canvas.style.width = `${canvasSize.width}px`;
    canvas.style.height = `${canvasSize.height}px`;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, canvasSize.width, canvasSize.height);

    const colors = readThemeColors();

    for (const edge of visibleEdges) {
      const source = nodeByPermalink.get(edge.source);
      const target = nodeByPermalink.get(edge.target);
      if (!source || !target) continue;

      const touchesHovered =
        hovered && (edge.source === hovered || edge.target === hovered);
      const dimmed = Boolean(hovered) && !touchesHovered;
      const p1 = toCanvasSpace(source, transform);
      const p2 = toCanvasSpace(target, transform);

      ctx.globalAlpha = (EDGE_ALPHA[edge.type] ?? 0.2) * (dimmed ? DIMMED_ALPHA : 1);
      ctx.strokeStyle = colors.edge;
      ctx.lineWidth = EDGE_WIDTH[edge.type] ?? 1;
      ctx.beginPath();
      ctx.moveTo(p1.x, p1.y);
      ctx.lineTo(p2.x, p2.y);
      ctx.stroke();
    }

    // Pass 1: every visible node's circle — independent of which labels end up drawn. Each
    // wears its mainTag's meerkat sticker, clipped to the circle, with a thin ring in the
    // node's own color so the mainTag/series signal isn't lost; nodes too small for a face to
    // read keep the flat dot.
    for (const node of visibleNodes) {
      const point = toCanvasSpace(node, transform);
      const isHovered = node.permalink === hovered;
      const isNeighbor = neighborSet?.has(node.permalink) ?? false;
      const dimmed = Boolean(hovered) && !isHovered && !isNeighbor;
      const radius = displayRadius(node, transform, orphanNodes.has(node.permalink));
      const image = resolveMeerkatImage(node, radius, meerkatImages);

      ctx.globalAlpha = dimmed ? DIMMED_ALPHA : 1;

      if (image) {
        ctx.save();
        ctx.beginPath();
        ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
        ctx.clip();
        drawImageCover(ctx, image, point.x, point.y, radius * 2);
        ctx.restore();

        ctx.beginPath();
        ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
        ctx.lineWidth = 1.5;
        ctx.strokeStyle = node.color;
        ctx.stroke();
      } else {
        ctx.fillStyle = node.color;
        ctx.beginPath();
        ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    // Pass 2: labels, in priority order — hovered node, then its neighbors, then the
    // permanently-labeled hubs — dropping whichever would overlap a higher-priority one
    // already placed. See selectNonOverlappingLabels() for why this is not optional.
    ctx.textBaseline = "middle";
    ctx.font = LABEL_FONT;

    const candidates: LabelCandidate[] = [];
    const queued = new Set<string>();
    const queueCandidate = (node: BlogGraphNode | undefined | null) => {
      if (!node || queued.has(node.permalink)) return;
      queued.add(node.permalink);
      const point = toCanvasSpace(node, transform);
      const isHovered = node.permalink === hovered;
      const isNeighbor = neighborSet?.has(node.permalink) ?? false;
      const radius = displayRadius(node, transform, orphanNodes.has(node.permalink));
      candidates.push({
        node,
        x: point.x + radius + 4,
        y: point.y,
        dimmed: Boolean(hovered) && !isHovered && !isNeighbor,
      });
    };

    queueCandidate(hovered ? nodeByPermalink.get(hovered) : null);
    if (neighborSet) {
      for (const permalink of neighborSet) {
        queueCandidate(nodeByPermalink.get(permalink));
      }
    }
    for (const permalink of permanentLabelSet) {
      queueCandidate(nodeByPermalink.get(permalink));
    }

    for (const candidate of selectNonOverlappingLabels(ctx, candidates)) {
      ctx.globalAlpha = candidate.dimmed ? DIMMED_ALPHA : 1;
      ctx.fillStyle = colors.label;
      ctx.fillText(candidate.node.title, candidate.x, candidate.y);
    }

    ctx.globalAlpha = 1;
  }, [
    showCanvas,
    canvasSize,
    visibleNodes,
    visibleEdges,
    nodeByPermalink,
    transform,
    hovered,
    neighborSet,
    permanentLabelSet,
    orphanNodes,
    meerkatImages,
    themeVersion,
  ]);

  const handleMouseMove = useCallback(
    (event: React.MouseEvent<HTMLCanvasElement>) => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const rect = canvas.getBoundingClientRect();
      const x = event.clientX - rect.left;
      const y = event.clientY - rect.top;

      let found: string | null = null;
      for (const node of visibleNodes) {
        const point = toCanvasSpace(node, transform);
        const radius = displayRadius(node, transform, orphanNodes.has(node.permalink));
        const hitRadius = radius + HOVER_HIT_PADDING;
        if ((x - point.x) ** 2 + (y - point.y) ** 2 <= hitRadius ** 2) {
          found = node.permalink;
          break;
        }
      }

      setHovered(found);
      canvas.style.cursor = found ? "pointer" : "default";
    },
    [visibleNodes, transform, orphanNodes],
  );

  const handleMouseLeave = useCallback(() => setHovered(null), []);

  // `withBaseUrl` because `history.push` does not add the locale prefix the way `<Link>` does —
  // and the graph's nodes carry bare, locale-agnostic permalinks (`/blog/x`, built by
  // `scripts/lib/blog-corpus.mjs`). Under `fr` a click pushed `/blog/x`, which is not a route in
  // the French build at all, so every bubble landed on the 404 — the `<GroupedList>` fallback
  // below was never affected because `<Link>` prefixes on its own. Idempotent, so an already
  // prefixed path is left alone. See .claude/rules/i18n-locale-safety.md.
  const handleClick = useCallback(() => {
    if (hovered) history.push(withBaseUrl(hovered));
  }, [hovered, history, withBaseUrl]);

  if (!graph) return null;

  return (
    <div className={styles.wrap} ref={wrapRef}>
      <p className={clsx(styles.intro, "text--center")}>
        {showCanvas
          ? translate({
              id: "blog.graph.intro.canvas",
              message:
                "Every published article, plotted by how it links to, shares a series with, or shares tags with the rest of the corpus. Hover a dot to see its neighbors, click to open the article.",
            })
          : translate({
              id: "blog.graph.intro.list",
              message:
                "Every published article, grouped by topic below. Tap a title to open it.",
            })}
      </p>

      <div className={styles.controls}>
        <label className={styles.selectLabel} htmlFor="blog-graph-maintag">
          <Translate id="blog.graph.filterByTopic">Filter by topic</Translate>
          <select
            id="blog-graph-maintag"
            className={styles.select}
            value={mainTag}
            onChange={(event) => setMainTag(event.target.value)}
          >
            <option value="">
              {translate(
                { id: "blog.graph.topN", message: "Top {count} most-linked articles" },
                // The real number of nodes this option draws, not the ceiling. `DEFAULT_TOP_N` is
                // a cap; `meta.articleCount` is the corpus AFTER the plugin's locale filter, so
                // /fr/map/ was advertising "Top 120" over a canvas holding 4.
                { count: Math.min(DEFAULT_TOP_N, graph.meta.articleCount) },
              )}
            </option>
            {mainTags.map((tag) => (
              <option key={tag} value={tag}>
                {tagLabel(tag)}
              </option>
            ))}
          </select>
        </label>
        <p className={styles.counter}>
          <Translate
            id="blog.graph.stats"
            values={{
              articles: graph.meta.articleCount,
              series: graph.meta.seriesCount,
              links: graph.meta.linkCount,
            }}
          >
            {"{articles} articles · {series} series · {links} internal links"}
          </Translate>
        </p>
      </div>

      {showCanvas && (
        <canvas
          ref={canvasRef}
          className={styles.canvas}
          role="img"
          aria-label={translate(
            {
              id: "blog.graph.canvas.ariaLabel",
              message:
                "Force-directed map of {nodes} articles and {edges} connections. Hover a node to see its title and neighbors, click to open the article.",
            },
            { nodes: visibleNodes.length, edges: visibleEdges.length },
          )}
          onMouseMove={handleMouseMove}
          onMouseLeave={handleMouseLeave}
          onClick={handleClick}
        >
          <Translate id="blog.graph.canvas.fallback">
            Your browser does not support the canvas element — see the list below instead.
          </Translate>
        </canvas>
      )}

      {showCanvas ? (
        <details className={styles.listFallback}>
          <summary>
            <Translate id="blog.graph.viewAsList">View as list instead</Translate>
          </summary>
          <GroupedList nodes={listNodes} />
        </details>
      ) : (
        <GroupedList nodes={listNodes} />
      )}
    </div>
  );
}
```

**src/components/BlogGraph/utils.ts**

```typescript
/**
 * Pure helpers for BlogGraph — kept separate from the canvas/React code so the filtering,
 * fit-to-viewport and labeling logic can be read (and changed) without wading through event
 * handlers.
 */

// Default view: the corpus is never shown all at once (248 nodes is a hairball) — only the
// top articles by in-degree, unless a mainTag is picked instead. See 0081's "Solution" §2.
export const DEFAULT_TOP_N = 120;

// How many of the most-connected *visible* nodes keep a permanently drawn label — the rest
// only label on hover, per the spec's "pas 248 labels" rule.
export const PERMANENT_LABEL_COUNT = 8;

// Nodes are drawn as the site's own mascot (see ./meerkats for which sticker) rather than as
// flat-colored dots — but only where the sticker still says something. Measured on the
// rendered sizes: at a 16px diameter the meerkat is recognizable, at 12px it is a warm blob
// with a colored ring, and below that the 1.5px ring eats a third of it and a flat dot reads
// better. In the default view every node clears this at any desktop width (drawn radii run
// 6-18px); the leftovers are the marginal satellites of a filtered view.
export const MEERKAT_MIN_DRAWN_RADIUS = 6;

// A node with no visible connection sits at MIN_NODE_RADIUS (a few px) — under the threshold
// above, so it would be the one node the reader's eye lands on and the only one still a dot.
// Only those get this floor; everything else keeps its data-driven size, which is what the
// build-time collision layout was computed against.
export const MEERKAT_MIN_RADIUS = 13;

/** One article, as shipped by `plugins/blog-graph-plugin` (build-time layout already computed). */
export interface BlogGraphNode {
  slug: string;
  title: string;
  permalink: string;
  date: string;
  mainTag: string | null;
  series: string | null;
  readingTime: number;
  inDegree: number;
  radius: number;
  color: string;
  x: number;
  y: number;
}

export type BlogGraphEdgeType = "link" | "series" | "tag";

export interface BlogGraphEdge {
  source: string;
  target: string;
  type: BlogGraphEdgeType;
  weight: number;
}

export interface BlogGraphData {
  width: number;
  height: number;
  nodes: BlogGraphNode[];
  edges: BlogGraphEdge[];
  meta: {
    articleCount: number;
    seriesCount: number;
    linkCount: number;
  };
}

/** Maps the fixed virtual coordinate space onto the actual canvas pixel size. */
export interface Transform {
  scale: number;
  offsetX: number;
  offsetY: number;
}

/**
 * The nodes to draw: either every node under the chosen mainTag, or — by default — the top
 * `topN` articles by in-degree across the whole corpus. Never the full corpus at once.
 */
export function selectVisibleNodes(
  nodes: BlogGraphNode[],
  { mainTag, topN = DEFAULT_TOP_N }: { mainTag?: string | null; topN?: number } = {},
): BlogGraphNode[] {
  if (mainTag) {
    return nodes.filter((node) => node.mainTag === mainTag);
  }
  return [...nodes].sort((a, b) => b.inDegree - a.inDegree).slice(0, topN);
}

/** Edges whose two endpoints are both currently visible. */
export function selectVisibleEdges(
  edges: BlogGraphEdge[],
  visiblePermalinks: Set<string>,
): BlogGraphEdge[] {
  return edges.filter(
    (edge) => visiblePermalinks.has(edge.source) && visiblePermalinks.has(edge.target),
  );
}

/**
 * Maps the (fixed, build-time) virtual coordinate space onto the actual canvas size,
 * fitting the *visible* nodes' bounding box so a single mainTag's cluster — which only
 * occupies a fraction of the full layout — isn't drawn tiny in a mostly-empty canvas.
 */
export function fitTransform(
  nodes: BlogGraphNode[],
  canvasWidth: number,
  canvasHeight: number,
  padding = 40,
): Transform {
  if (nodes.length === 0) {
    return { scale: 1, offsetX: 0, offsetY: 0 };
  }

  let minX = Infinity;
  let maxX = -Infinity;
  let minY = Infinity;
  let maxY = -Infinity;

  for (const node of nodes) {
    minX = Math.min(minX, node.x - node.radius);
    maxX = Math.max(maxX, node.x + node.radius);
    minY = Math.min(minY, node.y - node.radius);
    maxY = Math.max(maxY, node.y + node.radius);
  }

  const spanX = Math.max(maxX - minX, 1);
  const spanY = Math.max(maxY - minY, 1);
  const availableWidth = Math.max(canvasWidth - padding * 2, 1);
  const availableHeight = Math.max(canvasHeight - padding * 2, 1);
  const scale = Math.min(availableWidth / spanX, availableHeight / spanY, 4);

  const drawnWidth = spanX * scale;
  const drawnHeight = spanY * scale;
  const offsetX = (canvasWidth - drawnWidth) / 2 - minX * scale;
  const offsetY = (canvasHeight - drawnHeight) / 2 - minY * scale;

  return { scale, offsetX, offsetY };
}

/**
 * The height/width ratio of the visible nodes' own bounding box, in the fixed virtual
 * coordinate space (so it's independent of the actual canvas pixel size). `null` when there
 * aren't enough nodes to infer a meaningful shape (0 or 1).
 */
function contentAspectRatio(nodes: BlogGraphNode[]): number | null {
  if (nodes.length < 2) {
    return null;
  }

  let minX = Infinity;
  let maxX = -Infinity;
  let minY = Infinity;
  let maxY = -Infinity;

  for (const node of nodes) {
    minX = Math.min(minX, node.x - node.radius);
    maxX = Math.max(maxX, node.x + node.radius);
    minY = Math.min(minY, node.y - node.radius);
    maxY = Math.max(maxY, node.y + node.radius);
  }

  const spanX = Math.max(maxX - minX, 1);
  const spanY = Math.max(maxY - minY, 1);
  return spanY / spanX;
}

/**
 * The canvas element's own height for the current container width and visible node set.
 * Blends two independent estimates and keeps whichever is *smaller*, because either one
 * alone has a blind spot that reproduces the "gigantic canvas for two elements" bug:
 *
 * - **aspect-ratio** fits the visible nodes' own bounding-box shape — works well when a
 *   filtered mainTag's articles happen to cluster together spatially, but a couple of nodes
 *   that are simply far apart *in the shared, whole-corpus layout* (they don't need to link
 *   to each other to share a mainTag) produce an extreme ratio that clamps right back up to
 *   the default max height, i.e. no visible shrink at all.
 * - **density** scales down purely from how few nodes are visible relative to the default
 *   top-N view — catches exactly that case, but on its own would happily give a tall, tightly
 *   clustered pair of nodes a squashed canvas it didn't need.
 *
 * Taking the minimum means neither blind spot alone can produce an oversized canvas.
 */
export function computeCanvasHeight(
  nodes: BlogGraphNode[],
  containerWidth: number,
  {
    defaultTopN = DEFAULT_TOP_N,
    minHeight = 220,
    maxRatio = 0.62,
    padding = 80,
  }: {
    defaultTopN?: number;
    minHeight?: number;
    maxRatio?: number;
    padding?: number;
  } = {},
): number {
  if (containerWidth <= 0) {
    return 0;
  }

  const maxHeight = Math.round(containerWidth * maxRatio);
  const clamp = (height: number) => Math.min(Math.max(height, minHeight), maxHeight);

  const ratio = contentAspectRatio(nodes) ?? maxRatio;
  const aspectHeight = clamp(Math.round(containerWidth * ratio) + padding);

  const density = Math.min(nodes.length / defaultTopN, 1);
  const densityHeight = clamp(
    Math.round(minHeight + (maxHeight - minHeight) * Math.sqrt(density)),
  );

  return Math.min(aspectHeight, densityHeight);
}

/** Applies a fitTransform to a single point. */
export function toCanvasSpace(
  node: BlogGraphNode,
  transform: Transform,
): { x: number; y: number } {
  return {
    x: node.x * transform.scale + transform.offsetX,
    y: node.y * transform.scale + transform.offsetY,
  };
}

/** Permalinks of a node's direct neighbors, across every visible edge. */
export function neighborsOf(
  permalink: string,
  visibleEdges: BlogGraphEdge[],
): Set<string> {
  const neighbors = new Set<string>();
  for (const edge of visibleEdges) {
    if (edge.source === permalink) neighbors.add(edge.target);
    else if (edge.target === permalink) neighbors.add(edge.source);
  }
  return neighbors;
}

/** Degree (any edge type) of each node, counted only from the currently visible edges. */
export function computeVisibleDegree(
  nodes: BlogGraphNode[],
  edges: BlogGraphEdge[],
): Map<string, number> {
  const degree = new Map(nodes.map((node) => [node.permalink, 0]));
  for (const edge of edges) {
    if (degree.has(edge.source)) degree.set(edge.source, degree.get(edge.source)! + 1);
    if (degree.has(edge.target)) degree.set(edge.target, degree.get(edge.target)! + 1);
  }
  return degree;
}

/**
 * The visible nodes with no visible connection at all — degree zero in the *currently drawn*
 * edge set, which is not the same as `inDegree === 0` (a post can be linked to by an article
 * the top-N cutoff or the mainTag filter left out). They are the only nodes whose drawn size
 * is not purely data-driven; see MEERKAT_MIN_RADIUS.
 */
export function pickOrphanNodes(
  nodes: BlogGraphNode[],
  edges: BlogGraphEdge[],
): Set<string> {
  const degree = computeVisibleDegree(nodes, edges);
  return new Set(
    nodes
      .filter((node) => (degree.get(node.permalink) ?? 0) === 0)
      .map((node) => node.permalink),
  );
}

/** The on-canvas radius for a node, floored for orphans only (see MEERKAT_MIN_RADIUS). */
export function displayRadius(
  node: BlogGraphNode,
  transform: Transform,
  isOrphan: boolean,
): number {
  const base = node.radius * transform.scale;
  return isOrphan ? Math.max(base, MEERKAT_MIN_RADIUS) : base;
}

/** Groups posts by mainTag for the no-JS / mobile fallback list, each group sorted by date desc. */
export function groupByMainTag(nodes: BlogGraphNode[]): [string, BlogGraphNode[]][] {
  const groups = new Map<string, BlogGraphNode[]>();

  for (const node of nodes) {
    const key = node.mainTag ?? "other";
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key)!.push(node);
  }

  for (const group of groups.values()) {
    group.sort((a, b) => b.date.localeCompare(a.date));
  }

  return [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
}
```

**src/components/BlogGraph/GroupedList.tsx**

```typescript
/**
 * The graph's fallback rendering — a plain list of every article grouped by mainTag.
 *
 * Used in two situations, per 0081's accessibility requirements:
 * - Always server-rendered, so the page stays indexable and useful with JavaScript
 *   disabled (the canvas graph is a progressive enhancement on top of this, not a
 *   replacement for it).
 * - Kept as the *only* rendering on narrow viewports, where a force-directed graph has no
 *   room to be legible (BlogGraph swaps to this below the mobile breakpoint).
 */

import Link from "@docusaurus/Link";
import Translate from "@docusaurus/Translate";
import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n";
import { groupByMainTag, type BlogGraphNode } from "./utils";
import styles from "./styles.module.css";

interface Props {
  nodes: BlogGraphNode[];
}

export default function GroupedList({ nodes }: Props) {
  const groups = groupByMainTag(nodes);
  const tagLabel = useTagLabel();

  return (
    <div className={styles.groupedList}>
      {groups.map(([mainTag, posts]) => (
        <section key={mainTag} className={styles.group}>
          <h3 className={styles.groupTitle}>
            {/* "other" is `groupByMainTag`'s bucket for articles with no mainTag — it is not a
                tags.yml key, so it has no label to look up and needs its own UI string. */}
            {mainTag === "other" ? (
              <Translate id="blog.graph.group.other">Other</Translate>
            ) : (
              tagLabel(mainTag)
            )}{" "}
            <span className={styles.groupCount}>({posts.length})</span>
          </h3>
          <ul className={styles.groupItems}>
            {posts.map((post) => (
              <li key={post.permalink}>
                <Link to={post.permalink}>{post.title}</Link>
              </li>
            ))}
          </ul>
        </section>
      ))}
    </div>
  );
}
```

**src/components/BlogGraph/styles.module.css**

```css
.wrap {
  margin: 1.5rem 0;
}

.intro {
  color: var(--mk-ink-soft);
  margin-bottom: 1rem;
}

.controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 0.75rem;
}

.selectLabel {
  composes: mk-eyebrow from global;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.select {
  padding: 0.45rem 0.6rem;
  font-family: inherit;
  font-size: 0.95rem;
  letter-spacing: 0;
  text-transform: none;
  border: 1px solid var(--mk-border-strong);
  border-radius: var(--mk-radius-sm);
  background-color: var(--mk-surface);
  color: var(--mk-ink);
  min-width: 260px;
}

.select:focus {
  outline: none;
  border-color: var(--mk-primary);
  box-shadow: 0 0 0 3px var(--mk-key-bg);
}

.counter {
  margin: 0;
  color: var(--mk-ink-soft);
  font-size: 0.9rem;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/*
 * NOTE for whoever tokenises index.tsx: it reads
 * --ifm-color-emphasis-500 from getComputedStyle for the canvas edge colour,
 * with a "#999999" fallback. That variable is remapped now, so the edges
 * follow the palette on their own — but the cold-grey fallback is still there
 * and should become #a2917a (--mk-n-500) for the case where the read fails.
 */
.canvas {
  display: block;
  width: 100%;
  border: 1px solid var(--mk-border);
  border-radius: var(--mk-radius-md);
  background-color: var(--mk-surface);
}

.listFallback {
  margin-top: 1rem;
  border: 1px solid var(--mk-border);
  border-radius: var(--mk-radius-md);
  padding: 0.5rem 0.9rem;
}

.listFallback summary {
  cursor: pointer;
  color: var(--mk-primary);
  font-weight: 600;
  padding: 0.4rem 0;
  transition: color 0.15s ease;
}

.listFallback summary:hover {
  color: var(--ifm-color-primary-dark);
}

.listFallback[open] summary {
  margin-bottom: 0.5rem;
}

.groupedList {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 1.25rem 2rem;
}

.group {
  min-width: 0;
}

.groupTitle {
  font-family: var(--mk-font-display);
  font-size: 1rem;
  margin: 0 0 0.4rem;
  color: var(--mk-primary);
}

.groupCount {
  color: var(--mk-ink-soft);
  font-weight: 400;
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
}

.groupItems {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.groupItems li {
  font-size: 0.9rem;
  line-height: 1.35;
}

@media (max-width: 768px) {
  .controls {
    flex-direction: column;
    align-items: stretch;
  }

  .select {
    min-width: 0;
    width: 100%;
  }

  .counter {
    white-space: normal;
  }
}

@media (prefers-reduced-motion: reduce) {
  .listFallback summary {
    transition: none;
  }
}
```

Canvas, pas SVG, et ce n'est pas une préférence esthétique : 262 nœuds plus un millier d'arêtes en éléments DOM, et chaque survol devient un recalcul de layout sur un millier de nœuds. Sur un canvas, un survol, c'est un redessin complet de quelques centaines de formes, autant dire rien.

Enregistrez le plugin dans `docusaurus.config.js` et donnez-lui une page :

```javascript title="docusaurus.config.js"
plugins: [
  "./plugins/blog-graph-plugin/index.mjs",
  // ...
],
```

```mdx title="src/pages/map.mdx"
---
title: "Blog Map"
description: "An interactive map of the whole blog."
hide_table_of_contents: true
---

import BlogGraph from "@site/src/components/BlogGraph";

<BlogGraph />
```

## Les trois types de connexion {#the-three-kinds-of-connection}

Une arête signifie « ces deux billets sont liés », mais la parenté a des intensités, et les mélanger à poids égal produit une pelote de laine. Mon corpus émet trois types, chacun avec sa propre traction sur le layout :

| Type     | Ce que ça signifie                                     | Nombre | Traction sur le layout                               |
| -------- | ------------------------------------------------------ | ------ | ---------------------------------------------------- |
| `link`   | Le texte d'un billet pointe vraiment vers un autre     | 680    | La plus forte — distance 55, force 0.85              |
| `series` | Deux billets consécutifs (par date) dans la même série | 144    | Moyenne — distance 70, force 0.5                     |
| `tag`    | Deux billets partageant plusieurs tags                 | 202    | La plus faible — distance 110, pondérée par le poids |

La ligne `tag` est l'endroit où se cache l'échec intéressant. À cette taille de corpus, **presque chaque paire de billets partage au moins un tag** — les relier toutes reviendrait à tout relier à tout et détruirait à la fois le layout et l'image. Monter la barre à deux tags partagés laisse encore 1 355 paires, dont 1 153 sont du type le plus faible possible :

```plaintext
shared tags:   2      3     4    5
pairs:       1153    159    40    3
```

Le plugin fait donc deux choses différentes avec cet ensemble. Le **layout** utilise toutes les paires jusqu'à deux tags partagés, parce que cette faible traction spatiale est précisément ce qui regroupe « tous mes billets Docker ». La **charge utile** n'embarque que les paires à trois tags ou plus, parce que rien ne dessine jamais le reste — embarquer la majorité à poids 2 aurait triplé le JSON pour des lignes invisibles. Mêmes données, deux seuils, un pour la physique et un pour le réseau.

## Sous le capot (passez si vous voulez juste la carte) {#under-the-hood-skip-this-if-you-just-want-the-map}

Quatre problèmes qui n'étaient pas évidents avant que la page soit réellement à l'écran.

### Un filtre à deux nœuds donnait un canvas géant et vide {#a-two-node-filter-got-a-giant-empty-canvas}

Choisissez un sujet de niche dans le filtre et vous obtiendrez peut-être trois articles. Le canvas, lui, restait dimensionné pour 120. Mon premier correctif a été de le dimensionner selon le ratio d'aspect de la bounding box des nœuds visibles — ce qui a échoué, parce que deux articles partageant un `mainTag` ne sont pas obligés d'être proches dans le layout partagé calculé sur tout le corpus. Deux nœuds aux extrémités opposées produisent un ratio extrême qui revient directement se caler sur la hauteur maximale.

Le deuxième signal, le nombre de nœuds relatif à la vue par défaut, attrape ce cas mais écraserait un cluster réellement haut et compact. `computeCanvasHeight()` calcule donc les deux et garde **le plus petit**. Aucun des deux angles morts ne peut produire un canvas surdimensionné à lui seul :

```typescript title="src/components/BlogGraph/utils.ts"
const ratio = contentAspectRatio(nodes) ?? maxRatio;
const aspectHeight = clamp(Math.round(containerWidth * ratio) + padding);

const density = Math.min(nodes.length / defaultTopN, 1);
const densityHeight = clamp(
  Math.round(minHeight + (maxHeight - minHeight) * Math.sqrt(density)),
);

return Math.min(aspectHeight, densityHeight);
```

### Des libellés qui s'empilent les uns sur les autres {#labels-stacking-on-top-of-each-other}

Dessiner 262 titres est illisible : seuls les huit nœuds visibles les plus connectés gardent un libellé permanent, auxquels s'ajoutent le nœud survolé et ses voisins directs. Même ça s'empile quand un sujet filtré est chargé. `selectNonOverlappingLabels()` parcourt les candidats par ordre de priorité — le nœud survolé d'abord, puis les voisins, puis les hubs — mesure chacun avec `ctx.measureText()` et écarte tout libellé dont la boîte croiserait une boîte déjà placée. Les libellés les moins prioritaires perdent ; celui du nœud survolé gagne toujours.

### Un canvas n'entend pas un changement de thème {#canvas-cannot-hear-a-theme-change}

Les couleurs des nœuds et des arêtes sont lues depuis les variables CSS du site, pour que rien ne soit en dur — mais un canvas est un bitmap, et basculer en mode sombre ne repeint rien. La solution : un `MutationObserver` sur l'attribut `data-theme` de `<html>` qui incrémente un compteur, lequel figure dans le tableau de dépendances de l'effet de redessin :

```typescript title="src/components/BlogGraph/index.tsx"
useEffect(() => {
  const observer = new MutationObserver(() => setThemeVersion((v) => v + 1));
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ["data-theme"],
  });
  return () => observer.disconnect();
}, []);
```

### Les suricates {#the-meerkats}

Ce blog a une mascotte — elle se cache dans le [code source de la page](/blog/docusaurus-ascii-art), chevauche le [bouton de retour en haut](/blog/docusaurus-go-top) et apparaît sur la page 404. Sur la carte, ce n'est pas une garniture sur quelques nœuds : **chaque bulle est un suricate**, portant le sticker que lui a valu son `mainTag`. Les articles PHP tiennent un petit éléphant violet, les articles Bash portent un sweat à capuche, `code-quality` arrive avec une coche verte. Une trentaine de tags ont un sticker explicite et le reste en tire un de façon déterministe parmi ce qui reste, si bien qu'un sujet a toujours la même tête et que ses articles se lisent comme une famille sur le canvas.

Ce qui fait marcher tout ça, ce sont les images, pas le code : ce sont des portraits carrés, centrés sur le visage. Les poses en pied avec lesquelles j'avais commencé perdaient leur tête dans un recadrage circulaire de 20px, il a donc fallu les choisir et les recadrer une par une ; un portrait centré survit à un simple dessin « cover » sans réglage image par image.

Deux règles empêchent que ça tourne à la bouillie :

* **un sticker a besoin de place.** En dessous de 12px de diamètre, l'anneau mange un tiers de l'image et un point coloré plat en dit davantage — c'est donc ce que gardent ces nœuds.
* **un orphelin est dessiné plus gros.** Un nœud avec **zéro connexion visible** se retrouve au rayon minimal, c'est-à-dire juste sous ce seuil. Il obtient un plancher de 13px à la place, pour que l'unique article vers lequel rien ne pointe soit celui que vous ne pouvez pas rater.

L'anneau autour de chaque bulle conserve la couleur d'accent de l'article, donc rien du signal série/tag n'est perdu au profit de l'illustration.

## Conclusion {#conclusion}

La carte a fait exactement ce que j'espérais : cinq minutes après son premier rendu, j'avais la liste des billets vers lesquels rien ne pointait, et je voyais qu'une série avait dérivé loin de tout le reste. C'est un outil de maintenance déguisé en jolie image.

La partie qui mérite d'être reprise, pourtant, ce n'est pas le graphe — c'est l'endroit où le travail se fait. Chaque exigence dure de cette page (pas de JavaScript, pas de mouvement, indexable, charge utile légère) s'est dissoute à l'instant où la simulation est passée du navigateur à `loadContent()`. Les plugins Docusaurus tournent dans Node, avec tout le corpus sous la main et une étape de build que personne n'attend ; ça laisse beaucoup de marge pour calculer les choses correctement, une fois, plutôt que de demander au portable de chaque visiteur de recommencer.

La prochaine fois que vous êtes sur le point d'attraper une bibliothèque côté client, vérifiez si la réponse qu'elle calcule n'aurait pas pu être une constante dans votre sortie de build.
