798 lines
22 KiB
Svelte
798 lines
22 KiB
Svelte
|
|
<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>
|