import React, {useState, useRef, useEffect} from 'react'; import Springy from 'springy'; /** * Échappe une valeur utilisée dans un sélecteur d’attributs CSS. * * @param value Valeur à échapper. * @return Valeur échappée. */ const escapeAttrValue = value => value.replace(/"/g, '\\"'); /** * Crée un identifiant unique pour l’arête identifiée par ses deux extrémités. * * @param from Premier nœud de l’arête. * @param to Second nœud de l’arête. * @return Identifiant unique pour les deux directions de l’arête. */ const getEdgeId = (from, to) => { if (to < from) { [to, from] = [from, to]; } return JSON.stringify([from, to]); }; /** * Vérifie si une liste d’arêtes contient une arête donnée, dans un sens ou * dans l’autre. * * @param list Liste d’arêtes. * @param from Premier nœud de l’arête. * @param to Second nœud de l’arête. * @return Vrai si et seulement si l’arête existe. */ const includesEdge = (list, from, to) => list.some(([source, target]) => ( (source === from && target === to) || (source === to && target === from) )); /** * Recherche parmi les descendants d’un élément celui qui représente un * nœud donnée, s’il existe. * * @param parent Élément parent. * @param id Identifiant du nœud recherché. * @return Élément trouvé ou null sinon. */ const findNode = (parent, id) => parent.querySelector(`[data-node-id="${escapeAttrValue(id)}"]`); /** * Recherche parmi les descendants d’un élément celui qui représente une * arête donnée, s’il existe. * * @param parent Élément parent. * @param id Identifiant de l’arête recherchée. * @return Élément trouvé ou null sinon. */ const findEdge = (parent, id) => parent.querySelector(`[data-edge-id="${escapeAttrValue(id)}"]`); /** * Recherche le premier élément parent représentant un nœud et renvoie * l’identifiant de ce nœud. * * @param start Élément de départ. * @return Identifiant de l’élément trouvé ou null sinon. */ const findParentNode = start => { if (start instanceof window.HTMLDocument) { return null; } if (start.hasAttribute('data-node-id')) { return start.getAttribute('data-node-id'); } return findParentNode(start.parentNode); }; /** * Affiche un graphe. * * @prop nodes Liste des identifiants de nœuds du graphe. * @prop edges Couples d’identifiants de nœuds formant les arêtes du graphe. * @prop render Fonction de rendu prenant en paramètre l’identifiant d’un nœud * du graphe et renvoyant un élément à afficher pour le représenter. */ const Graph = ({nodes, edges, render}) => { const [graph,] = useState(new Springy.Graph()); const [layout,] = useState(new Springy.Layout.ForceDirected( graph, /* rigidité des arêtes = */ 400, /* répulsion des nœuds = */ 450, /* amortissement de vitesse = */ 0.4 )); // N’arrête jamais l’animation layout.minEnergyThreshold = 0; // Pointeur sur l’élément englobant le graphe const graphParent = useRef(null); // Ajout des nouveaux nœuds et retrait des anciens const oldNodes = new Set(Object.keys(graph.nodeSet)); for (let node of nodes) { if (!oldNodes.has(node)) { graph.addNode(new Springy.Node(node)); } } for (let node of oldNodes) { if (!nodes.includes(node)) { graph.removeNode({id: node}); } } const oldNodePoints = new Set(Object.keys(layout.nodePoints)); for (let node of oldNodePoints) { if (!nodes.includes(node)) { delete layout.nodePoints[node]; } } // Ajout des nouvelles arêtes et retrait des anciennes const newEdges = new Set(edges.map(edge => getEdgeId(...edge))); const oldEdges = new Set(graph.edges.map(edge => edge.id)); for (let [from, to] of edges) { const edgeId = getEdgeId(from, to); if (!oldEdges.has(edgeId)) { graph.addEdge(new Springy.Edge(edgeId, {id: from}, {id: to})); } } for (let edge of oldEdges) { if (!newEdges.has(edge)) { graph.removeEdge({id: edge}); } } const oldEdgeSprings = new Set(Object.keys(layout.edgeSprings)); for (let edge of oldEdgeSprings) { if (!newEdges.has(edge)) { delete layout.edgeSprings[edge]; } } // Rendu de l’animation du graphe useEffect(() => { const center = () => new Springy.Vector( window.innerWidth / 2, window.innerHeight / 2 ); const scale = 50; const coordsToScreen = vec => vec.multiply(scale).add(center()); const screenToCoords = vec => vec.subtract(center()).divide(scale); layout.start(() => { layout.eachNode(({id}, {p}) => { const element = findNode(graphParent.current, id); const {x, y} = coordsToScreen(p); element.style.transform = `translate( calc(${x}px - 50%), calc(${y}px - 50%) )`; }); layout.eachEdge(({id}, {point1: {p: p1}, point2: {p: p2}}) => { const element = findEdge(graphParent.current, id); const {x: x1, y: y1} = coordsToScreen(p1); const {x: x2, y: y2} = coordsToScreen(p2); element.setAttribute('x1', x1); element.setAttribute('y1', y1); element.setAttribute('x2', x2); element.setAttribute('y2', y2); }); }); let dragging = null; const mouseDown = ev => { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); const clickedNode = findParentNode(ev.target); if (clickedNode !== null) { dragging = layout.nodePoints[clickedNode]; dragging.m = Infinity; } }; const mouseMove = ev => { if (dragging !== null) { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); dragging.p = screenToCoords(screen); } }; const mouseUp = ev => { if (dragging !== null) { dragging.m = 1; dragging = null; } }; graphParent.current.addEventListener('mousedown', mouseDown); document.body.addEventListener('mousemove', mouseMove); document.body.addEventListener('mouseup', mouseUp); return () => { graphParent.current.removeEventListener('mousedown', mouseDown); document.body.removeEventListener('mousemove', mouseMove); document.body.removeEventListener('mouseup', mouseUp); }; }, []); return (
{edges.map(edge => ( ))} {nodes.map(id => ( {render(id)} ))}
); }; export default Graph;