2026-07-21 13:10:12 -04:00
|
|
|
|
<script lang="ts">
|
2026-07-21 13:39:38 -04:00
|
|
|
|
/* 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). */
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
|
|
|
|
|
import { onMount } from 'svelte';
|
2026-07-21 13:39:38 -04:00
|
|
|
|
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)
|
|
|
|
|
|
);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
interface MapNode {
|
2026-07-21 13:10:12 -04:00
|
|
|
|
node: NetworkNode;
|
|
|
|
|
|
x: number;
|
|
|
|
|
|
y: number;
|
2026-07-21 13:39:38 -04:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
});
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
// âââââ Filtres par catĂ©gorie (attĂ©nuation, jamais de re-layout) âââââ
|
2026-07-21 13:10:12 -04:00
|
|
|
|
let filters = $state<Record<string, boolean>>(
|
|
|
|
|
|
Object.fromEntries(categories.map((c) => [c.id, true]))
|
|
|
|
|
|
);
|
|
|
|
|
|
const activeCount = $derived(Object.values(filters).filter(Boolean).length);
|
2026-07-21 13:39:38 -04:00
|
|
|
|
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];
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
|
|
|
|
|
function toggleFilter(categoryId: string) {
|
|
|
|
|
|
filters = { ...filters, [categoryId]: !filters[categoryId] };
|
|
|
|
|
|
tooltip = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
// âââââ 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
|
|
|
|
|
|
});
|
2026-07-21 13:10:12 -04:00
|
|
|
|
tooltip = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
let svgEl = $state<SVGSVGElement | null>(null);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
// 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
|
2026-07-21 13:10:12 -04:00
|
|
|
|
};
|
2026-07-21 13:39:38 -04:00
|
|
|
|
}
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
// 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 };
|
2026-07-21 13:10:12 -04:00
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
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);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
};
|
2026-07-21 13:39:38 -04:00
|
|
|
|
svg.addEventListener('wheel', onWheel, { passive: false });
|
|
|
|
|
|
return () => svg.removeEventListener('wheel', onWheel);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
});
|
2026-07-21 13:39:38 -04:00
|
|
|
|
|
|
|
|
|
|
// âââââ 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);
|
|
|
|
|
|
}
|
2026-07-21 13:10:12 -04:00
|
|
|
|
</script>
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<svelte:window onkeydown={onWindowKeydown} />
|
|
|
|
|
|
|
2026-07-21 13:10:12 -04:00
|
|
|
|
<section id="map" aria-labelledby="map-title">
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<div class="center section-head stack" use:reveal={3}>
|
2026-07-21 13:10:12 -04:00
|
|
|
|
<p class="kicker">Cartographie</p>
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<KineticText id="map-title" text="L'ĂcosystĂšme" />
|
2026-07-21 13:10:12 -04:00
|
|
|
|
<p class="subtitle">
|
|
|
|
|
|
{activeCount} catĂ©gories actives · Survolez ou touchez un nĆud pour plus d'infos
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="graph-wrap center">
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<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>
|
2026-07-21 13:10:12 -04:00
|
|
|
|
</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}
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<div class="tooltip" style:left="{tooltip.left}px" style:top="{tooltip.top}px" aria-hidden="true">
|
2026-07-21 13:10:12 -04:00
|
|
|
|
<div class="tooltip-head">
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<span class="dot" style:--cat-color={tooltip.n.node.color} aria-hidden="true"></span>
|
|
|
|
|
|
<h4>{tooltip.n.node.id}</h4>
|
2026-07-21 13:10:12 -04:00
|
|
|
|
</div>
|
2026-07-21 13:39:38 -04:00
|
|
|
|
<p class="tooltip-cat">{tooltip.n.categoryName}</p>
|
|
|
|
|
|
<p class="tooltip-desc">{tooltip.n.node.description}</p>
|
2026-07-21 13:10:12 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
2026-07-21 13:39:38 -04:00
|
|
|
|
|
|
|
|
|
|
<!-- 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>
|
2026-07-21 13:10:12 -04:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
section {
|
2026-07-21 13:39:38 -04:00
|
|
|
|
background: var(--noir-oki, var(--surface));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
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;
|
2026-07-21 13:39:38 -04:00
|
|
|
|
color: var(--or-oki, var(--accent-text));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.subtitle {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-wrap {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
container-type: inline-size;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
/* âââââ Carte SVG âââââ */
|
2026-07-21 13:10:12 -04:00
|
|
|
|
.viewport {
|
2026-07-21 13:39:38 -04:00
|
|
|
|
position: relative;
|
|
|
|
|
|
border: 1px solid var(--line, var(--border));
|
|
|
|
|
|
border-radius: var(--radius-md, var(--radius-2));
|
|
|
|
|
|
background: var(--noir-profond, var(--surface-2));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
.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;
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
/* âââââ ContrĂŽles zoom âââââ */
|
|
|
|
|
|
.zoom-controls {
|
2026-07-21 13:10:12 -04:00
|
|
|
|
position: absolute;
|
2026-07-21 13:39:38 -04:00
|
|
|
|
inset-block-end: var(--space-3);
|
|
|
|
|
|
inset-inline-end: var(--space-3);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--space-1);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
.zoom-controls button {
|
2026-07-21 13:10:12 -04:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
2026-07-21 13:39:38 -04:00
|
|
|
|
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);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* âââââ Panneau de filtres âââââ */
|
|
|
|
|
|
.filters {
|
|
|
|
|
|
margin-block-start: var(--space-3);
|
2026-07-21 13:39:38 -04:00
|
|
|
|
background: var(--card-bg, var(--glass));
|
|
|
|
|
|
border: 1px solid var(--line, var(--glass-border));
|
|
|
|
|
|
border-radius: var(--radius-md, var(--radius-2));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
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);
|
2026-07-21 13:39:38 -04:00
|
|
|
|
border-radius: var(--radius-sm, var(--radius-1));
|
|
|
|
|
|
color: var(--blanc-creme, var(--ink));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
text-align: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.filter-btn:hover {
|
2026-07-21 13:39:38 -04:00
|
|
|
|
background: var(--noir-profond, var(--surface-2));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
/* âââââ Tooltip âââââ */
|
2026-07-21 13:10:12 -04:00
|
|
|
|
.tooltip {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
z-index: 20;
|
|
|
|
|
|
max-inline-size: 17rem;
|
2026-07-21 13:39:38 -04:00
|
|
|
|
background: var(--card-bg, var(--glass));
|
|
|
|
|
|
border: 1px solid var(--or-oki, var(--glass-border));
|
|
|
|
|
|
border-radius: var(--radius-md, var(--radius-2));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
padding: var(--space-3);
|
|
|
|
|
|
box-shadow: var(--shadow-2);
|
2026-07-21 13:39:38 -04:00
|
|
|
|
pointer-events: none;
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tooltip-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tooltip-head h4 {
|
|
|
|
|
|
font-size: var(--text-1);
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
.tooltip .dot {
|
2026-07-21 13:10:12 -04:00
|
|
|
|
inline-size: 0.75rem;
|
|
|
|
|
|
block-size: 0.75rem;
|
|
|
|
|
|
background: var(--cat-color);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
.tooltip-cat {
|
|
|
|
|
|
font-family: var(--font-mono);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
font-size: var(--text-small);
|
2026-07-21 13:39:38 -04:00
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
|
color: var(--or-clair, var(--muted));
|
|
|
|
|
|
margin-block: var(--space-1);
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 13:39:38 -04:00
|
|
|
|
.tooltip-desc {
|
2026-07-21 13:10:12 -04:00
|
|
|
|
font-size: var(--text-small);
|
2026-07-21 13:39:38 -04:00
|
|
|
|
color: var(--blanc-creme, var(--ink-soft));
|
2026-07-21 13:10:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|