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
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
#!/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)}`);
|
||||
Reference in New Issue
Block a user