Files
oki-atlas-fediverse/atlas-fediverse/scripts/build-layout.mjs
T
sucupira 9b574d918c feat: refonte OKI — constellation, carte SVG déterministe, timeline interactive
- Background : shader Three.js remplacé par la constellation zetwal (DOM/SVG, zéro WebGL)
- Carte : graphe 3D aléatoire remplacé par un SVG 2D au layout baké au build
  (scripts/build-layout.mjs, positions déterministes), nœuds focusables, pan/zoom, filtres sans re-layout
- Timeline : 4 ères, reveal syncopé gwoka, liens événement→fiche catalogue (module software-modal),
  mois affichés, corrections factuelles (Twitter 2022, Mastodon 2016, PeerTube 2017, Bluesky 2024, 105 logiciels)
- Charte OKI : tokens + bridge, Archivo/Inter self-hébergées, flag-bar, KineticText, footer fédéré
- Dette : three retiré (JS initial ~828 Ko → 77 Ko), OG/Twitter/JSON-LD/robots/sitemap,
  licence CC BY-SA 4.0, fediverse.json mort supprimé, icônes PWA 121 Ko → 9 Ko
- Lighthouse mobile : 91/97/100/100 — tests vitest 6/6, svelte-check 0/0
2026-07-21 13:39:38 -04:00

177 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)}`);