import React, {useState, useRef, useEffect} from 'react'; import Springy from 'springy'; /** * 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) { return getEdgeId(to, from); } return `${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) )); /** * Affiche un graphe. * * @prop nodes Liste des identifiants de nœuds du graphe. Chaque nœud doit * avoir un identifiant unique ne contenant pas de tiret ('-'). * @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é = */ 400, /* répulsion = */ 400, /* amortissement = */ 0.5 )); // N’arrête jamais l’animation layout.minEnergyThreshold = 0; const graphParent = useRef(null); // Ajout des nouveaux nœuds for (let node of nodes) { if (!(node in graph.nodeSet)) { console.info(`Ajout du nouveau nœud ${node}`); graph.addNode(new Springy.Node(node)); } } // Retrait des anciens nœuds et de leurs arêtes adjacentes graph.filterNodes(node => { if (!nodes.includes(node.id)) { console.info(`Retrait de l’ancien nœud ${node.id}`); return false; } return true; }); // Ajout des nouvelles arêtes for (let [from, to] of edges) { const edgeId = getEdgeId(from, to); if (graph.edges.every(edge => edge.id !== edgeId)) { console.info(`Ajout de la nouvelle arête ${edgeId}`); graph.addEdge(new Springy.Edge(edgeId, {id: from}, {id: to})); } } // Retrait des anciennes arêtes graph.filterEdges(({source: {id: from}, target: {id: to}}) => { if (!includesEdge(edges, from, to)) { console.info(`Retrait de l’ancienne arête ${from}-${to}`); return false; } return true; }); 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 = graphParent.current.querySelector( `[data-node-id="${id.replace('"', '\\"')}"]` ); 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 = graphParent.current.querySelector( `[data-edge-id="${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); }); }, () => { console.info(`Fin du rendu du graphe`); }, () => { console.info(`Démarrage du rendu du graphe`); }); let dragging = null; const mouseDown = ev => { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); const position = screenToCoords(screen); const nearest = layout.nearest(position); if (nearest.distance <= scale / 50) { dragging = nearest; dragging.point.m = 1000; } }; const mouseMove = ev => { if (dragging !== null) { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); dragging.point.p = screenToCoords(screen); } }; const mouseUp = ev => { if (dragging !== null) { dragging.point.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;