Files

177 lines
6.3 KiB
JavaScript
Raw Permalink Normal View History

#!/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)}`);