#!/usr/bin/env node /* build-layout.mjs — pré-calcule les positions 2D de la carte de l'atlas. * * ⚠️ REJOUER CE SCRIPT À CHAQUE MODIFICATION de `src/lib/data/network.json` : * node scripts/build-layout.mjs * puis committer `src/lib/data/network-layout.json` avec les données. * * La simulation d3-force est entièrement déterministe : * - positions initiales dérivées d'un hash stable de l'id (FNV-1a), pas de * Math.random() — même entrée ⇒ même sortie, bit pour bit ; * - ticks synchrones à nombre fixe (simulation.stop() + tick()), indépendants * de l'horloge et de l'environnement. * * Sortie : src/lib/data/network-layout.json * { viewBox: { width, height }, nodes: { [id]: { x, y } } } * coordonnées normalisées dans le viewBox, arrondies à 0.1 près. */ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { forceSimulation, forceLink, forceManyBody, forceCollide, forceX, forceY } from 'd3-force'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const networkPath = join(root, 'src/lib/data/network.json'); const outPath = join(root, 'src/lib/data/network-layout.json'); const network = JSON.parse(readFileSync(networkPath, 'utf8')); /** Hash FNV-1a 32 bits — graine stable par id, indépendante de la plateforme. */ function hashId(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } /* ————— Positions initiales déterministes ————— Chaque catégorie occupe un secteur d'un grand cercle ; les nœuds sont dispersés autour du centre de leur catégorie selon leur hash. La simulation part ainsi d'un état proche de la topologie finale (convergence rapide, clusters lisibles). */ const categories = [...new Set(network.nodes.map((n) => n.category))].sort(); const catIndex = new Map(categories.map((c, i) => [c, i])); // Ellipse large (ratio proche du viewBox 1200×800) : les clusters de // catégories s'y répartissent et la carte remplit le cadre. const CLUSTER_RX = 340; const CLUSTER_RY = 210; const JITTER_RADIUS = 60; /** Cible de placement d'une catégorie sur l'ellipse. */ function categoryTarget(category) { const angle = (2 * Math.PI * catIndex.get(category)) / categories.length; return { x: CLUSTER_RX * Math.cos(angle), y: CLUSTER_RY * Math.sin(angle) }; } const nodes = network.nodes.map((n, i) => { const h = hashId(n.id); const target = categoryTarget(n.category); const jAngle = ((h % 1000) / 1000) * 2 * Math.PI; const jRadius = (((h >> 10) % 1000) / 1000) * JITTER_RADIUS; return { ...n, index: i, x: target.x + jRadius * Math.cos(jAngle), y: target.y + jRadius * Math.sin(jAngle) }; }); const links = network.links.map((l) => ({ source: l.source, target: l.target, type: l.type })); /* ————— Paramètres de forces ————— - liens « strong » courts (30) pour souder les communautés, « weak » longs (70) pour espacer les ponts inter-catégories ; - répulsion modérée (-45) : le graphe est dense (873 liens / 105 nœuds), une charge trop forte éclaterait les clusters ; - collision = rayon visuel du nœud + marge, pour éviter les superpositions une fois rendu en SVG ; - forceX/forceY par catégorie vers la cible elliptique (0.08) : ancre chaque cluster sans écraser la structure interne. */ const simulation = forceSimulation(nodes) .force( 'link', forceLink(links) .id((d) => d.id) .distance((d) => (d.type === 'strong' ? 30 : 70)) .strength((d) => (d.type === 'strong' ? 0.7 : 0.2)) ) .force('charge', forceManyBody().strength(-45)) .force('collide', forceCollide().radius((d) => 4 + (d.val ?? 5) * 0.6 + 6).iterations(2)) .force('x', forceX((d) => categoryTarget(d.category).x).strength(0.08)) .force('y', forceY((d) => categoryTarget(d.category).y).strength(0.08)) .alpha(1) .alphaDecay(0.02) .stop(); // Convergence synchrone à nombre de ticks fixe (≈ 3× le temps de stabilisation // théorique log(0.001)/log(1-0.02) ≈ 342) : reproductible à l'identique. const TICKS = 600; for (let i = 0; i < TICKS; i++) simulation.tick(); /* ————— Normalisation dans le viewBox 1200×800 (marge 60) ————— */ const VIEW_W = 1200; const VIEW_H = 800; const MARGIN = 60; const xs = nodes.map((n) => n.x); const ys = nodes.map((n) => n.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const scale = Math.min( (VIEW_W - 2 * MARGIN) / Math.max(maxX - minX, 1e-9), (VIEW_H - 2 * MARGIN) / Math.max(maxY - minY, 1e-9) ); // Centrage du nuage dans le viewBox. const offX = MARGIN + (VIEW_W - 2 * MARGIN - (maxX - minX) * scale) / 2; const offY = MARGIN + (VIEW_H - 2 * MARGIN - (maxY - minY) * scale) / 2; const positions = {}; for (const n of nodes) { const x = Math.round((offX + (n.x - minX) * scale) * 10) / 10; const y = Math.round((offY + (n.y - minY) * scale) * 10) / 10; if (!Number.isFinite(x) || !Number.isFinite(y)) { throw new Error(`Position non finie pour « ${n.id} » — ajuster les forces.`); } positions[n.id] = { x, y }; } /* ————— Contrôles qualité ————— */ const ids = Object.keys(positions); if (ids.length !== network.nodes.length) { throw new Error(`${ids.length} positions pour ${network.nodes.length} nœuds.`); } let minDist = Infinity; let closest = null; for (let i = 0; i < ids.length; i++) { for (let j = i + 1; j < ids.length; j++) { const a = positions[ids[i]]; const b = positions[ids[j]]; const d = Math.hypot(a.x - b.x, a.y - b.y); if (d < minDist) { minDist = d; closest = [ids[i], ids[j]]; } } } if (minDist < 8) { console.warn( `⚠️ distance minimale ${minDist.toFixed(1)}px entre « ${closest[0]} » et « ${closest[1]} » — envisager forceCollide plus large.` ); } const layout = { viewBox: { width: VIEW_W, height: VIEW_H }, nodes: positions }; writeFileSync(outPath, JSON.stringify(layout, null, 2) + '\n'); console.log(`✔ ${ids.length} positions écrites dans src/lib/data/network-layout.json`); console.log(` distance minimale entre nœuds : ${minDist.toFixed(1)}px (${closest[0]} ↔ ${closest[1]})`); console.log(` alpha résiduel après ${TICKS} ticks : ${simulation.alpha().toFixed(5)}`);