import React, {useState, useRef, useEffect} from 'react'; import PropTypes from 'prop-types'; import {TransitionGroup, CSSTransition} from 'react-transition-group'; import Springy from 'springy'; import {Relation} from '../data/model.js'; /** * É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]); }; /** * 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. * * @param start Élément de départ. * @return Élément trouvé ou null sinon. */ const findParentNode = start => { if (start instanceof window.HTMLDocument) { return null; } if (start.hasAttribute('data-node-id')) { return start; } return findParentNode(start.parentNode); }; /** * Récupère le point central du graphe. */ const graphCenter = () => new Springy.Vector( window.innerWidth / 2, window.innerHeight / 2 ); /** * Échelle du graphe. */ const graphScale = 50; /** * Convertit des coordonnées dans le référentiel du graphe vers les coordonnées * correspondantes dans le référentiel de l’écran. * * @param vec Coordonnées dans le référentiel du graphe. * @return Coordonnées dans le référentiel de l’écran. */ const coordsToScreen = vec => vec.multiply(graphScale).add(graphCenter()); /** * Convertit des coordonnées dans le référentiel de l’écran vers les * coordonnées correspondantes dans le référentiel du graphe. * * @param vec Coordonnées dans le référentiel de l’écran. * @return Coordonnées dans le référentiel du graphe. */ const screenToCoords = vec => vec.subtract(graphCenter()).divide(graphScale); /** * 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 emptyMessage Message affiché lorsque le graphe est vide. * @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. * @prop onNodeClick Fonction de rappel appelée lors du clic sur un nœud du * graphe avec l’identifiant du nœud correspondant. */ const Graph = ({ nodes, edges, emptyMessage, render, onNodeClick, }) => { 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}); } } // 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}); } } // Rendu de l’animation du graphe useEffect(() => { 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); }); }); }, []); // Gestion du déplacement des nœuds à la souris useEffect(() => { // Identifiant du nœud en cours de déplacement let dragNode = -1; // Point physique correspondant au nœud en cours de déplacement let dragPoint = null; // Élément du DOM correspondant au nœud en cours de déplacement let dragElement = null; // Décalage entre le centre du nœud et le pointeur au moment du début // du déplacement du nœud courant let dragDelta = null; // Vrai si le déplacement compte comme un clic sur le nœud let dragClick = false; // Position originelle du nœud en cours de déplacement let dragOrigin = null; const mouseDown = ev => { dragElement = findParentNode(ev.target); if (dragElement !== null) { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); const coords = screenToCoords(screen); dragNode = dragElement.getAttribute('data-node-id'); dragPoint = layout.nodePoints[dragNode]; dragDelta = dragPoint.p.subtract(coords); dragClick = true; dragOrigin = screen; dragElement.classList.add('Graph_node-dragging'); dragPoint.m = Infinity; } }; const mouseMove = ev => { if (dragElement !== null) { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); const coords = screenToCoords(screen); dragPoint.p = dragDelta.add(coords); if (dragOrigin.subtract(screen).magnitude() >= 5) { // Déplacement de plus de 5 px ⇒ pas de clic dragClick = false; } } }; const mouseUp = ev => { if (dragElement !== null) { if (dragClick) { onNodeClick(dragNode, ev); } dragNode = -1; dragPoint.m = 1; dragPoint = null; dragElement.classList.remove('Graph_node-dragging'); dragElement = null; dragDelta = null; dragOrigin = 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); }; }, [onNodeClick]); return (
{edges.map(edge => ( ))} {nodes.map(id => ( {render(id)} ))} {nodes.length === 0 ? {emptyMessage} : null}
); }; Graph.propTypes = { nodes: PropTypes.arrayOf(PropTypes.any).isRequired, edges: PropTypes.arrayOf(Relation).isRequired, emptyMessage: PropTypes.string.isRequired, render: PropTypes.func.isRequired, onNodeClick: PropTypes.func.isRequired, }; export default Graph;