Files
oki-atlas-fediverse/atlas-fediverse/src/lib/components/NetworkGraph.svelte
T

798 lines
22 KiB
Svelte
Raw Normal View History

<script lang="ts">
/* Graphe 3D de l'écosystème — Three.js + d3-force.
Les deux libs sont chargées paresseusement (await import dans onMount) :
aucun octet de Three/D3 dans le bundle initial. Topologie déterministe
issue de networkData (network.json pré-calculé, pas de Math.random de liens). */
import { onMount } from 'svelte';
import { categories, networkData, softwares } from '$lib/data';
import type { NetworkNode, Software } from '$lib/data';
import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from 'd3-force';
import { t } from '$lib/i18n/i18n.svelte';
type SimNode = NetworkNode & SimulationNodeDatum;
type SimLink = SimulationLinkDatum<SimNode> & { type: 'strong' | 'weak' };
interface TooltipState {
node: NetworkNode;
software?: Software;
x: number;
y: number;
pinned: boolean;
}
let viewport = $state<HTMLDivElement | null>(null);
// SSR / sans-JS : booting reste false → message de repli lisible (pas de
// squelette éternel). onMount le passe à true avant de charger les chunks.
let booting = $state(false);
let loading = $state(true);
let unavailable = $state(false);
let tooltip = $state<TooltipState | null>(null);
let filters = $state<Record<string, boolean>>(
Object.fromEntries(categories.map((c) => [c.id, true]))
);
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
// Assigné par onMount une fois les libs chargées.
let rebuildGraph: (() => void) | null = null;
function toggleFilter(categoryId: string) {
filters = { ...filters, [categoryId]: !filters[categoryId] };
tooltip = null;
rebuildGraph?.();
}
function closeTooltip() {
tooltip = null;
}
onMount(() => {
if (!viewport) return;
booting = true;
const container = viewport;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let cancelled = false;
let disposeGraph: (() => void) | null = null;
let setPaused: ((paused: boolean) => void) | null = null;
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') tooltip = null;
};
window.addEventListener('keydown', onKeydown);
let ioPaused = false;
const io = new IntersectionObserver(
(entries) => {
ioPaused = !entries[0]?.isIntersecting;
setPaused?.(ioPaused || document.hidden);
},
{ threshold: 0.05 }
);
const onVisibility = () => setPaused?.(document.hidden || ioPaused);
(async () => {
try {
const THREE = await import('three');
const d3 = await import('d3-force');
if (cancelled) return;
// ————— Helpers couleurs : var(--…) → rgb utilisable par Three.js.
// THREE.Color ne comprend ni oklch() ni light-dark() : conversion manuelle.
const oklchToSrgb = (L: number, C: number, H: number): [number, number, number] => {
const hRad = (H * Math.PI) / 180;
const a = C * Math.cos(hRad);
const b = C * Math.sin(hRad);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
const to255 = (v: number): number => {
const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
return Math.round(Math.min(Math.max(c, 0), 1) * 255);
};
return [
to255(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
to255(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
to255(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
];
};
const toRgb = (color: string, fallback: string): string => {
const oklchMatch = color.match(
/oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*[\d.]+%?)?\s*\)/
);
if (oklchMatch) {
const lightness = parseFloat(oklchMatch[1]) / (oklchMatch[2] ? 100 : 1);
const [r, g, b] = oklchToSrgb(
lightness,
parseFloat(oklchMatch[3]),
parseFloat(oklchMatch[4])
);
return `rgb(${r}, ${g}, ${b})`;
}
if (/^(#|rgb)/.test(color)) return color;
return fallback;
};
const cssColor = (varName: string, fallback: string): string => {
const probe = document.createElement('span');
probe.style.color = `var(${varName}, ${fallback})`;
probe.style.display = 'none';
container.appendChild(probe);
const computed = getComputedStyle(probe).color;
probe.remove();
return toRgb(computed, fallback);
};
const inkColor = cssColor('--ink', '#e8eef4');
const lineColor = cssColor('--border-strong', '#8a97a8');
// ————— Scène persistante —————
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60,
container.offsetWidth / Math.max(container.offsetHeight, 1),
0.1,
2000
);
camera.position.z = 100;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.offsetWidth, container.offsetHeight);
renderer.setClearColor(0x000000, 0);
renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const directional = new THREE.DirectionalLight(0xffffff, 1.4);
directional.position.set(40, 60, 80);
scene.add(directional);
const graphGroup = new THREE.Group();
scene.add(graphGroup);
// ————— État du sous-graphe courant —————
let instancedMesh: InstanceType<typeof THREE.InstancedMesh> | null = null;
let simulation: Simulation<SimNode, undefined> | null = null;
let currentNodes: SimNode[] = [];
const labelSprites = new Map<string, InstanceType<typeof THREE.Sprite>>();
const linkLines: InstanceType<typeof THREE.Line>[] = [];
const nodePositions = new Map<string, InstanceType<typeof THREE.Vector3>>();
const teardownSubgraph = () => {
simulation?.stop();
simulation = null;
graphGroup.traverse((obj) => {
if (obj instanceof THREE.InstancedMesh) {
obj.geometry.dispose();
(obj.material as import('three').Material).dispose();
} else if (obj instanceof THREE.Sprite) {
obj.material.map?.dispose();
obj.material.dispose();
} else if (obj instanceof THREE.Line) {
obj.geometry.dispose();
(obj.material as import('three').Material).dispose();
}
});
graphGroup.clear();
labelSprites.clear();
linkLines.length = 0;
nodePositions.clear();
instancedMesh = null;
currentNodes = [];
};
const applyPositions = (dummy: InstanceType<typeof THREE.Object3D>) => {
const mesh = instancedMesh;
if (!mesh) return;
currentNodes.forEach((node, i) => {
const x = node.x ?? 0;
const y = node.y ?? 0;
const z = node.z ?? 0;
const scale = (node.val || 5) * 0.4;
dummy.position.set(x, y, z);
dummy.scale.set(scale, scale, scale);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
labelSprites.get(node.id)?.position.set(x, y + scale + 2, z);
nodePositions.set(node.id, new THREE.Vector3(x, y, z));
});
mesh.instanceMatrix.needsUpdate = true;
for (const line of linkLines) {
const src = nodePositions.get(line.userData.source as string);
const tgt = nodePositions.get(line.userData.target as string);
if (!src || !tgt) continue;
const attr = line.geometry.getAttribute(
'position'
) as import('three').BufferAttribute;
const arr = attr.array as Float32Array;
arr[0] = src.x; arr[1] = src.y; arr[2] = src.z;
arr[3] = tgt.x; arr[4] = tgt.y; arr[5] = tgt.z;
attr.needsUpdate = true;
}
};
const dummy = new THREE.Object3D();
const buildSubgraph = () => {
teardownSubgraph();
tooltip = null;
const activeCats = new Set(
Object.entries(filters)
.filter(([, v]) => v)
.map(([k]) => k)
);
currentNodes = networkData.nodes
.filter((n) => activeCats.has(n.category))
.map((n) => ({
...n,
x: (Math.random() - 0.5) * 60,
y: (Math.random() - 0.5) * 60,
z: (Math.random() - 0.5) * 30
}));
if (currentNodes.length === 0) {
renderer.render(scene, camera);
return;
}
const nodeIds = new Set(currentNodes.map((n) => n.id));
const links: SimLink[] = networkData.links
.filter(
(l) =>
nodeIds.has(l.source as string) && nodeIds.has(l.target as string)
)
.map((l) => ({
source: l.source as string,
target: l.target as string,
type: l.type
}));
// Nœuds (instanciés)
const geometry = new THREE.SphereGeometry(1, 16, 16);
const material = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
metalness: 0.1,
roughness: 0.3,
clearcoat: 1.0,
clearcoatRoughness: 0.1
});
instancedMesh = new THREE.InstancedMesh(geometry, material, currentNodes.length);
instancedMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
currentNodes.forEach((node, i) => {
instancedMesh!.setColorAt(i, new THREE.Color(node.color || '#888888'));
});
if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true;
graphGroup.add(instancedMesh);
// Labels (un canvas par sprite — le partage de canvas entre
// CanvasTexture afficherait le dernier label partout)
for (const node of currentNodes) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.font = '500 24px "Space Grotesk", sans-serif';
ctx.fillStyle = inkColor;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(node.id, 128, 32);
}
const texture = new THREE.CanvasTexture(canvas);
const spriteMaterial = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.85
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.scale.set(12, 3, 1);
graphGroup.add(sprite);
labelSprites.set(node.id, sprite);
}
// Liens
const lineMaterial = new THREE.LineBasicMaterial({
color: new THREE.Color(lineColor),
transparent: true,
opacity: 0.3
});
for (const link of links) {
const positions = new Float32Array(6);
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const line = new THREE.Line(lineGeometry, lineMaterial);
line.userData = {
source: link.source,
target: link.target
};
graphGroup.add(line);
linkLines.push(line);
}
// Simulation (2D, z statique — parité avec la version React)
simulation = d3
.forceSimulation<SimNode>(currentNodes)
.force(
'link',
d3
.forceLink<SimNode, SimLink>(links)
.id((d) => d.id)
.distance((d) => (d.type === 'strong' ? 15 : 35))
)
.force('charge', d3.forceManyBody().strength(-100))
.force('center', d3.forceCenter(0, 0))
.force(
'collide',
d3
.forceCollide<SimNode>()
.radius((d) => (d.group === 1 ? 6 : 3) + 2)
.iterations(2)
)
.force('x', d3.forceX(0).strength(0.04))
.force('y', d3.forceY(0).strength(0.04))
.alphaDecay(0.02);
if (reducedMotion) {
// Convergence synchrone, une seule frame rendue.
simulation.stop();
for (let i = 0; i < 300; i++) simulation.tick();
applyPositions(dummy);
renderer.render(scene, camera);
} else {
simulation.on('tick', () => applyPositions(dummy));
}
};
// ————— Interactions —————
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const pick = (clientX: number, clientY: number): SimNode | null => {
if (!instancedMesh) return null;
const rect = container.getBoundingClientRect();
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObject(instancedMesh);
const id = hits[0]?.instanceId;
return id !== undefined ? (currentNodes[id] ?? null) : null;
};
const showTooltip = (node: SimNode, clientX: number, clientY: number, pinned: boolean) => {
const rect = container.getBoundingClientRect();
tooltip = {
node,
software: softwares.find((s) => s.name === node.id),
x: Math.min(clientX - rect.left + 20, rect.width - 280),
y: Math.min(clientY - rect.top + 20, rect.height - 220),
pinned
};
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== 'mouse' || tooltip?.pinned) return;
const node = pick(event.clientX, event.clientY);
if (node) {
showTooltip(node, event.clientX, event.clientY, false);
container.style.cursor = 'pointer';
} else {
tooltip = null;
container.style.cursor = 'default';
}
};
const onClick = (event: MouseEvent) => {
const node = pick(event.clientX, event.clientY);
if (node) {
showTooltip(node, event.clientX, event.clientY, true);
} else {
tooltip = null;
}
};
// ————— Boucle de rendu & pause —————
let rafId = 0;
let paused = false;
const animate = () => {
rafId = requestAnimationFrame(animate);
renderer.render(scene, camera);
};
setPaused = (next: boolean) => {
if (reducedMotion) return;
if (next === paused) return;
paused = next;
if (paused) {
cancelAnimationFrame(rafId);
} else {
rafId = requestAnimationFrame(animate);
}
};
const ro = new ResizeObserver(() => {
const w = container.offsetWidth;
const h = Math.max(container.offsetHeight, 1);
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (reducedMotion || paused) renderer.render(scene, camera);
});
ro.observe(container);
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('click', onClick);
document.addEventListener('visibilitychange', onVisibility);
buildSubgraph();
rebuildGraph = buildSubgraph;
loading = false;
if (!reducedMotion) animate();
disposeGraph = () => {
setPaused = null;
rebuildGraph = null;
cancelAnimationFrame(rafId);
ro.disconnect();
container.removeEventListener('pointermove', onPointerMove);
container.removeEventListener('click', onClick);
document.removeEventListener('visibilitychange', onVisibility);
teardownSubgraph();
renderer.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
};
} catch {
if (!cancelled) {
unavailable = true;
loading = false;
}
}
})();
io.observe(container);
return () => {
cancelled = true;
io.disconnect();
window.removeEventListener('keydown', onKeydown);
disposeGraph?.();
};
});
</script>
<section id="map" aria-labelledby="map-title">
<div class="center section-head stack">
<p class="kicker">Cartographie</p>
<h2 id="map-title">L'Écosystème</h2>
<p class="subtitle">
{activeCount} catégories actives · Survolez ou touchez un nœud pour plus d'infos
</p>
</div>
<div class="graph-wrap center">
<div
class="viewport"
bind:this={viewport}
role="img"
aria-label="Graphe 3D des 105 logiciels du Fédiverse, colorés par catégorie. Le détail de chaque logiciel est aussi disponible dans le catalogue."
>
{#if !booting}
<div class="fallback stack">
<p>La carte 3D interactive nécessite JavaScript.</p>
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
</div>
{:else if loading}
<div class="skeleton" role="status">
<span>Chargement de la carte…</span>
</div>
{:else if unavailable}
<div class="fallback stack">
<p>La carte 3D n'est pas disponible dans ce navigateur.</p>
<a href="#catalog" class="fallback-link">Consulter le catalogue des logiciels</a>
</div>
{/if}
</div>
<aside class="filters" aria-label="Filtres par catégorie">
<h3>Filtres</h3>
<ul class="stack" role="list">
{#each categories as cat (cat.id)}
<li>
<button
type="button"
class="filter-btn"
class:inactive={!filters[cat.id]}
aria-pressed={filters[cat.id]}
style:--cat-color={cat.color}
onclick={() => toggleFilter(cat.id)}
>
<span class="dot" aria-hidden="true"></span>
<span class="cat-name">{cat.name}</span>
</button>
</li>
{/each}
</ul>
</aside>
{#if tooltip}
<div
class="tooltip"
style:left="{tooltip.x}px"
style:top="{tooltip.y}px"
role={tooltip.pinned ? 'dialog' : 'status'}
aria-label={tooltip.pinned ? `Détails de ${tooltip.node.id}` : undefined}
>
<div class="tooltip-head">
<span class="dot glow" style:--cat-color={tooltip.node.color} aria-hidden="true"></span>
<h4>{tooltip.node.id}</h4>
{#if tooltip.pinned}
<button type="button" class="tooltip-close" onclick={closeTooltip} aria-label={t('common.close')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
{/if}
</div>
<p class="tooltip-desc">{tooltip.node.description}</p>
<dl class="tooltip-meta mono">
<div><dt>Licence</dt><dd>{tooltip.node.licence}</dd></div>
{#if tooltip.software}
<div><dt>Langage</dt><dd>{tooltip.software.language}</dd></div>
{/if}
<div><dt>État</dt><dd>{tooltip.node.etat}</dd></div>
<div><dt>ActivityPub</dt><dd>{tooltip.node.compatibiliteAP}</dd></div>
</dl>
</div>
{/if}
</div>
</section>
<style>
section {
background: var(--surface);
padding-block: var(--space-6);
scroll-margin-top: var(--header-h);
}
.section-head {
--stack-gap: var(--space-2);
margin-block-end: var(--space-4);
}
.kicker {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent-text);
}
.subtitle {
color: var(--muted);
}
.graph-wrap {
position: relative;
container-type: inline-size;
}
.viewport {
block-size: clamp(30rem, 85vh, 56rem);
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--surface-2);
overflow: hidden;
}
.skeleton {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.45;
}
}
.fallback {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: var(--space-3);
padding: var(--space-4);
text-align: center;
}
.fallback-link {
display: inline-flex;
align-items: center;
min-block-size: var(--tap-target);
padding-inline: var(--space-3);
border-radius: var(--radius-pill);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
text-decoration: none;
}
/* ————— Panneau de filtres ————— */
.filters {
margin-block-start: var(--space-3);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
padding: var(--space-3);
}
.filters h3 {
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-block-end: var(--space-2);
}
.filters ul {
--stack-gap: var(--space-1);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
gap: var(--space-1);
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
inline-size: 100%;
min-block-size: var(--tap-target);
padding-inline: var(--space-2);
border-radius: var(--radius-1);
color: var(--ink);
text-align: start;
}
.filter-btn:hover {
background: var(--surface-2);
}
.filter-btn .dot {
inline-size: 0.75rem;
block-size: 0.75rem;
border-radius: 50%;
background: var(--cat-color);
box-shadow: 0 0 0.5rem var(--cat-color);
flex-shrink: 0;
}
.filter-btn.inactive .dot {
background: var(--muted);
box-shadow: none;
}
.filter-btn.inactive .cat-name {
color: var(--muted);
text-decoration: line-through;
}
.cat-name {
font-size: var(--text-small);
}
@container (min-width: 56rem) {
.filters {
position: absolute;
inset-block-start: var(--space-3);
inset-inline-end: var(--space-3);
margin-block-start: 0;
max-inline-size: 15rem;
}
.filters ul {
display: flex;
flex-direction: column;
}
}
/* ————— Tooltip / panneau détail ————— */
.tooltip {
position: absolute;
z-index: 20;
max-inline-size: 17rem;
background: var(--glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2);
padding: var(--space-3);
box-shadow: var(--shadow-2);
pointer-events: auto;
}
.tooltip-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-block-end: var(--space-1);
}
.tooltip-head h4 {
font-size: var(--text-1);
flex: 1;
}
.dot.glow {
inline-size: 0.75rem;
block-size: 0.75rem;
border-radius: 50%;
background: var(--cat-color);
box-shadow: 0 0 0.5rem var(--cat-color);
flex-shrink: 0;
}
.tooltip-close {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 2rem;
block-size: 2rem;
border-radius: var(--radius-1);
color: var(--ink);
}
.tooltip-desc {
font-size: var(--text-small);
color: var(--ink-soft);
margin-block-end: var(--space-2);
}
.tooltip-meta {
display: grid;
gap: var(--space-1);
font-size: var(--text-small);
}
.tooltip-meta div {
display: flex;
justify-content: space-between;
gap: var(--space-2);
}
.tooltip-meta dt {
color: var(--muted);
}
.tooltip-meta dd {
color: var(--ink);
text-align: end;
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
</style>