177 lines
6.3 KiB
JavaScript
177 lines
6.3 KiB
JavaScript
|
|
#!/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)}`);
|