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

677 lines
18 KiB
Svelte
Raw Normal View History

<script lang="ts">
/* Carte 2D de l'Ă©cosystĂšme — SVG dĂ©terministe, zĂ©ro WebGL.
Positions pré-calculées au build par scripts/build-layout.mjs
(network-layout.json) : aucune simulation au runtime, aucun aléa.
Pan/zoom piloté par le viewBox (état local), filtres = atténuation
sans re-layout. Chaque nƓud est un vrai lien focusable (navigation
clavier native Tab / Entrée) qui ouvre la fiche du catalogue via
l'état partagé $lib/software-modal.svelte (repli : ancre #catalog). */
import { onMount } from 'svelte';
import { categories, categoryById, networkData, softwares, stats } from '$lib/data';
import type { NetworkNode } from '$lib/data';
import layoutJson from '$lib/data/network-layout.json';
import { reveal } from '$lib/motion/reveal';
import KineticText from '$lib/components/motion/KineticText.svelte';
import { requestSoftwareDetails } from '$lib/software-modal.svelte';
interface Layout {
viewBox: { width: number; height: number };
nodes: Record<string, { x: number; y: number }>;
}
const layout = layoutJson as Layout;
const VB_W = layout.viewBox.width;
const VB_H = layout.viewBox.height;
const MIN_W = VB_W / 8; // zoom maximal (×8)
const MAX_W = VB_W; // zoom minimal = vue d'ensemble
/** Rayon visuel d'un nƓud (repris par forceCollide dans build-layout). */
const radius = (node: NetworkNode) => 4 + (node.val ?? 5) * 0.6;
// DegrĂ© de chaque nƓud (nombre de liens) — sert Ă  choisir les labels permanents.
const degree = new Map<string, number>();
for (const link of networkData.links) {
const s = link.source as string;
const tgt = link.target as string;
degree.set(s, (degree.get(s) ?? 0) + 1);
degree.set(tgt, (degree.get(tgt) ?? 0) + 1);
}
const LABELED = new Set(
[...degree.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([id]) => id)
);
interface MapNode {
node: NetworkNode;
x: number;
y: number;
r: number;
labeled: boolean;
categoryName: string;
softwareId?: string;
}
// network.json indexe par nom (« Mastodon »), software.json par slug (« mastodon »).
const softwareIdByName = new Map(softwares.map((s) => [s.name, s.id]));
const mapNodes: MapNode[] = networkData.nodes.map((node) => {
const pos = layout.nodes[node.id] ?? { x: VB_W / 2, y: VB_H / 2 };
return {
node,
x: pos.x,
y: pos.y,
r: radius(node),
labeled: LABELED.has(node.id),
categoryName: categoryById.get(node.category)?.name ?? node.category,
softwareId: softwareIdByName.get(node.id)
};
});
const nodeById = new Map(mapNodes.map((n) => [n.node.id, n]));
interface MapLink {
x1: number;
y1: number;
x2: number;
y2: number;
strong: boolean;
sourceId: string;
targetId: string;
}
const mapLinks: MapLink[] = networkData.links.flatMap((link) => {
const a = nodeById.get(link.source as string);
const b = nodeById.get(link.target as string);
if (!a || !b) return [];
return [
{
x1: a.x,
y1: a.y,
x2: b.x,
y2: b.y,
strong: link.type === 'strong',
sourceId: a.node.id,
targetId: b.node.id
}
];
});
// ————— Filtres par catĂ©gorie (attĂ©nuation, jamais de re-layout) —————
let filters = $state<Record<string, boolean>>(
Object.fromEntries(categories.map((c) => [c.id, true]))
);
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
const nodeDimmed = (n: MapNode) => !filters[n.node.category];
const linkDimmed = (l: MapLink) =>
!filters[nodeById.get(l.sourceId)!.node.category] ||
!filters[nodeById.get(l.targetId)!.node.category];
function toggleFilter(categoryId: string) {
filters = { ...filters, [categoryId]: !filters[categoryId] };
tooltip = null;
}
// ————— Pan / zoom pilotĂ© par le viewBox —————
let vb = $state({ x: 0, y: 0, w: VB_W, h: VB_H });
const round = (v: number) => Math.round(v * 100) / 100;
const vbAttr = $derived(
`${round(vb.x)} ${round(vb.y)} ${round(vb.w)} ${round(vb.h)}`
);
const canZoomIn = $derived(vb.w > MIN_W * 1.001);
const canZoomOut = $derived(vb.w < MAX_W * 0.999);
const zoomed = $derived(vb.w < MAX_W * 0.999);
function clampView(next: { x: number; y: number; w: number; h: number }) {
const padX = next.w * 0.25;
const padY = next.h * 0.25;
next.x = Math.min(Math.max(next.x, -padX), VB_W - next.w + padX);
next.y = Math.min(Math.max(next.y, -padY), VB_H - next.h + padY);
return next;
}
/** Zoom d'un facteur donné, centré sur (cx, cy) en coordonnées SVG. */
function zoomAt(factor: number, cx: number, cy: number) {
const w = Math.min(Math.max(vb.w * factor, MIN_W), MAX_W);
const h = (w * VB_H) / VB_W;
const k = w / vb.w;
vb = clampView({
x: cx - (cx - vb.x) * k,
y: cy - (cy - vb.y) * k,
w,
h
});
tooltip = null;
}
const zoomIn = () => zoomAt(1 / 1.4, vb.x + vb.w / 2, vb.y + vb.h / 2);
const zoomOut = () => zoomAt(1.4, vb.x + vb.w / 2, vb.y + vb.h / 2);
const resetView = () => {
vb = { x: 0, y: 0, w: VB_W, h: VB_H };
tooltip = null;
};
let svgEl = $state<SVGSVGElement | null>(null);
// Conversion client (px) → coordonnĂ©es SVG courantes.
function clientToSvg(clientX: number, clientY: number) {
const rect = svgEl!.getBoundingClientRect();
return {
x: vb.x + ((clientX - rect.left) / rect.width) * vb.w,
y: vb.y + ((clientY - rect.top) / rect.height) * vb.h
};
}
// Drag-to-pan : uniquement depuis le fond (pas depuis un nƓud, qui reste cliquable).
let panning = $state(false);
let lastPointer = { x: 0, y: 0 };
function onPointerDown(event: PointerEvent) {
if ((event.target as Element).closest('.node')) return;
if (event.button !== 0 && event.pointerType === 'mouse') return;
panning = true;
lastPointer = { x: event.clientX, y: event.clientY };
svgEl?.setPointerCapture(event.pointerId);
}
function onPointerMove(event: PointerEvent) {
if (!panning || !svgEl) return;
const rect = svgEl.getBoundingClientRect();
const dx = ((event.clientX - lastPointer.x) / rect.width) * vb.w;
const dy = ((event.clientY - lastPointer.y) / rect.height) * vb.h;
lastPointer = { x: event.clientX, y: event.clientY };
vb = clampView({ x: vb.x - dx, y: vb.y - dy, w: vb.w, h: vb.h });
tooltip = null;
}
function onPointerUp(event: PointerEvent) {
panning = false;
svgEl?.releasePointerCapture(event.pointerId);
}
// Molette : zoom vers le curseur (listener natif non passif pour preventDefault).
onMount(() => {
const svg = svgEl;
if (!svg) return;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 1.15 : 1 / 1.15;
const p = clientToSvg(event.clientX, event.clientY);
zoomAt(factor, p.x, p.y);
};
svg.addEventListener('wheel', onWheel, { passive: false });
return () => svg.removeEventListener('wheel', onWheel);
});
// ————— Tooltip (hover souris / focus clavier) —————
let tooltip = $state<{ n: MapNode; left: number; top: number } | null>(null);
function showTooltip(n: MapNode) {
if (!svgEl) return;
const rect = svgEl.getBoundingClientRect();
const px = ((n.x - vb.x) / vb.w) * rect.width;
const py = ((n.y - vb.y) / vb.h) * rect.height;
tooltip = {
n,
left: Math.min(Math.max(px + 14, 8), Math.max(rect.width - 280, 8)),
top: Math.min(Math.max(py + 14, 8), Math.max(rect.height - 160, 8))
};
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') tooltip = null;
}
/** Clic/EntrĂ©e sur un nƓud : ouvre la fiche du catalogue (modale fixe,
sans effet de bord si le logiciel n'est pas référencé). Le href
« #catalog » reste le repli sans JS. */
function openSoftware(n: MapNode, event: MouseEvent) {
if (!n.softwareId) return;
event.preventDefault();
tooltip = null;
requestSoftwareDetails(n.softwareId);
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<section id="map" aria-labelledby="map-title">
<div class="center section-head stack" use:reveal={3}>
<p class="kicker">Cartographie</p>
<KineticText id="map-title" text="L'Écosystùme" />
<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">
<svg
bind:this={svgEl}
class="map"
class:panning
viewBox={vbAttr}
role="img"
aria-label="Carte des {stats.totalSoftwares} logiciels du Fédiverse, colorés par catégorie et reliés par compatibilité. Utilisez les boutons pour zoomer, glissez pour déplacer la vue. Le détail de chaque logiciel est aussi disponible dans le catalogue."
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<g class="links" aria-hidden="true">
{#each mapLinks as link, i (i)}
<line
x1={link.x1}
y1={link.y1}
x2={link.x2}
y2={link.y2}
class="link"
class:strong={link.strong}
class:dimmed={linkDimmed(link)}
/>
{/each}
</g>
<g class="nodes">
{#each mapNodes as n, i (n.node.id)}
<a
href="#catalog"
class="node"
class:dimmed={nodeDimmed(n)}
aria-label="{n.node.id}, {n.categoryName} — ouvrir la fiche dans le catalogue"
onclick={(e) => openSoftware(n, e)}
onfocus={() => showTooltip(n)}
onblur={() => (tooltip = null)}
onpointerenter={(e) => e.pointerType === 'mouse' && showTooltip(n)}
onpointerleave={(e) => e.pointerType === 'mouse' && (tooltip = null)}
>
<g class="enter" style:--d="{(i % 8) * 60}ms">
<rect
class="halo"
x={n.x - n.r - 4}
y={n.y - n.r - 4}
width={(n.r + 4) * 2}
height={(n.r + 4) * 2}
aria-hidden="true"
/>
<rect
class="shape"
x={n.x - n.r}
y={n.y - n.r}
width={n.r * 2}
height={n.r * 2}
fill={n.node.color}
/>
<text
class="label"
class:persistent={n.labeled}
x={n.x}
y={n.y - n.r - 7}
text-anchor="middle"
aria-hidden="true">{n.node.id}</text
>
</g>
</a>
{/each}
</g>
</svg>
<div class="zoom-controls" role="group" aria-label="ContrĂŽles de la vue">
<button type="button" onclick={zoomIn} disabled={!canZoomIn} aria-label="Zoom avant">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="square" />
</svg>
</button>
<button type="button" onclick={zoomOut} disabled={!canZoomOut} aria-label="Zoom arriĂšre">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="square" />
</svg>
</button>
<button type="button" onclick={resetView} disabled={!zoomed} aria-label="Réinitialiser la vue">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 10a8 8 0 1 1 2 6.9M4 10V4m0 6h6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="square"
/>
</svg>
</button>
</div>
<noscript>
<p class="noscript-note">
La carte est statique sans JavaScript.
<a href="#catalog">Consulter le catalogue des logiciels</a>
</p>
</noscript>
</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.left}px" style:top="{tooltip.top}px" aria-hidden="true">
<div class="tooltip-head">
<span class="dot" style:--cat-color={tooltip.n.node.color} aria-hidden="true"></span>
<h4>{tooltip.n.node.id}</h4>
</div>
<p class="tooltip-cat">{tooltip.n.categoryName}</p>
<p class="tooltip-desc">{tooltip.n.node.description}</p>
</div>
{/if}
</div>
<!-- Miroir sémantique de la carte : liste accessible des logiciels. -->
<ul class="visually-hidden">
{#each mapNodes as n (n.node.id)}
<li>
<a href="#catalog">{n.node.id}</a> — {n.categoryName}. {n.node.description}
</li>
{/each}
</ul>
</section>
<style>
section {
background: var(--noir-oki, 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(--or-oki, var(--accent-text));
}
.subtitle {
color: var(--muted);
}
.graph-wrap {
position: relative;
container-type: inline-size;
}
/* ————— Carte SVG ————— */
.viewport {
position: relative;
border: 1px solid var(--line, var(--border));
border-radius: var(--radius-md, var(--radius-2));
background: var(--noir-profond, var(--surface-2));
overflow: hidden;
}
.map {
display: block;
inline-size: 100%;
block-size: clamp(30rem, 85vh, 56rem);
cursor: grab;
touch-action: none;
}
.map.panning {
cursor: grabbing;
}
.link {
stroke: var(--line, var(--border-strong));
stroke-width: 0.6;
opacity: 0.35;
transition: opacity var(--dur-tanbou, 120ms) ease;
}
.link.strong {
stroke-width: 1.1;
opacity: 0.55;
}
.link.dimmed {
opacity: 0.05;
}
.node {
transition: opacity var(--dur-tanbou, 120ms) ease;
}
.node.dimmed {
opacity: 0.15;
}
.node .halo {
fill: none;
stroke: var(--or-oki, #fdb813);
stroke-width: 2;
opacity: 0;
transition: opacity var(--dur-tanbou, 120ms) ease;
}
.node:hover .halo,
.node:focus-visible .halo {
opacity: 1;
}
.node:focus-visible {
outline: none;
}
.node .label {
font-family: var(--font-mono);
font-size: 11px;
fill: var(--blanc-creme, var(--ink));
paint-order: stroke;
stroke: var(--noir-profond, #0d0d0d);
stroke-width: 3px;
opacity: 0;
pointer-events: none;
transition: opacity var(--dur-tanbou, 120ms) ease;
}
.node .label.persistent,
.node:hover .label,
.node:focus-visible .label {
opacity: 0.95;
}
/* Apparition syncopĂ©e des nƓuds (gwoka) : dĂ©lai --d par index modulo 8.
Portée par le <g class="enter"> interne pour ne pas verrouiller
l'opacité du <a> (fill-mode forwards), qui reste pilotée par les
filtres. Désactivée si l'utilisateur préfÚre réduire les animations. */
@media (prefers-reduced-motion: no-preference) {
.node .enter {
opacity: 0;
animation: node-in var(--dur-mesure, 480ms) var(--ease-ka, ease-out) forwards;
animation-delay: var(--d);
}
@keyframes node-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
/* ————— Contrîles zoom ————— */
.zoom-controls {
position: absolute;
inset-block-end: var(--space-3);
inset-inline-end: var(--space-3);
display: flex;
gap: var(--space-1);
}
.zoom-controls button {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: var(--tap-target);
block-size: var(--tap-target);
border: 1px solid var(--line, var(--border));
border-radius: var(--radius-sm, var(--radius-1));
background: var(--card-bg, var(--glass));
color: var(--blanc-creme, var(--ink));
}
.zoom-controls button:hover:not(:disabled) {
border-color: var(--or-oki, #fdb813);
color: var(--or-oki, #fdb813);
}
.zoom-controls button:disabled {
opacity: 0.35;
cursor: default;
}
.noscript-note {
padding: var(--space-3);
text-align: center;
color: var(--muted);
}
/* ————— Panneau de filtres ————— */
.filters {
margin-block-start: var(--space-3);
background: var(--card-bg, var(--glass));
border: 1px solid var(--line, var(--glass-border));
border-radius: var(--radius-md, 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-sm, var(--radius-1));
color: var(--blanc-creme, var(--ink));
text-align: start;
}
.filter-btn:hover {
background: var(--noir-profond, var(--surface-2));
}
.filter-btn .dot {
inline-size: 0.75rem;
block-size: 0.75rem;
background: var(--cat-color);
flex-shrink: 0;
}
.filter-btn.inactive .dot {
background: var(--muted);
}
.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 ————— */
.tooltip {
position: absolute;
z-index: 20;
max-inline-size: 17rem;
background: var(--card-bg, var(--glass));
border: 1px solid var(--or-oki, var(--glass-border));
border-radius: var(--radius-md, var(--radius-2));
padding: var(--space-3);
box-shadow: var(--shadow-2);
pointer-events: none;
}
.tooltip-head {
display: flex;
align-items: center;
gap: var(--space-2);
}
.tooltip-head h4 {
font-size: var(--text-1);
flex: 1;
}
.tooltip .dot {
inline-size: 0.75rem;
block-size: 0.75rem;
background: var(--cat-color);
flex-shrink: 0;
}
.tooltip-cat {
font-family: var(--font-mono);
font-size: var(--text-small);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--or-clair, var(--muted));
margin-block: var(--space-1);
}
.tooltip-desc {
font-size: var(--text-small);
color: var(--blanc-creme, var(--ink-soft));
}
</style>