diff --git a/app/src/Graph.js b/app/src/Graph.js index 9925522..40b2def 100644 --- a/app/src/Graph.js +++ b/app/src/Graph.js @@ -1,6 +1,14 @@ 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. * @@ -12,10 +20,10 @@ const getEdgeId = (from, to) => { if (to < from) { - return getEdgeId(to, from); + [to, from] = [from, to]; } - return `${from}-${to}`; + return JSON.stringify([from, to]); }; /** @@ -33,11 +41,54 @@ const includesEdge = (list, from, 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. Chaque nœud doit - * avoir un identifiant unique ne contenant pas de tiret ('-'). + * @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. @@ -47,64 +98,82 @@ const Graph = ({nodes, edges, render}) => const [graph,] = useState(new Springy.Graph()); const [layout,] = useState(new Springy.Layout.ForceDirected( graph, - /* rigidité = */ 400, - /* répulsion = */ 450, - /* amortissement = */ 0.5 + /* 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 + // Ajout des nouveaux nœuds et retrait des anciens + const oldNodes = new Set(Object.keys(graph.nodeSet)); + for (let node of nodes) { - if (!(node in graph.nodeSet)) + if (!oldNodes.has(node)) { - 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 => + for (let node of oldNodes) { - if (!nodes.includes(node.id)) + if (!nodes.includes(node)) { - console.info(`Retrait de l’ancien nœud ${node.id}`); - return false; + graph.removeNode({id: node}); } + } - return true; - }); + 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)); - // Ajout des nouvelles arêtes for (let [from, to] of edges) { const edgeId = getEdgeId(from, to); - if (graph.edges.every(edge => edge.id !== edgeId)) + if (!oldEdges.has(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}}) => + for (let edge of oldEdges) { - if (!includesEdge(edges, from, to)) + if (!newEdges.has(edge)) { - console.info(`Retrait de l’ancienne arête ${from}-${to}`); - return false; + graph.removeEdge({id: edge}); } + } - return true; - }); + 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 @@ -118,10 +187,7 @@ const Graph = ({nodes, edges, render}) => { layout.eachNode(({id}, {p}) => { - const element = graphParent.current.querySelector( - `[data-node-id="${id.replace('"', '\\"')}"]` - ); - + const element = findNode(graphParent.current, id); const {x, y} = coordsToScreen(p); element.style.transform = `translate( @@ -132,10 +198,7 @@ const Graph = ({nodes, edges, render}) => layout.eachEdge(({id}, {point1: {p: p1}, point2: {p: p2}}) => { - const element = graphParent.current.querySelector( - `[data-edge-id="${id}"]` - ); - + const element = findEdge(graphParent.current, id); const {x: x1, y: y1} = coordsToScreen(p1); const {x: x2, y: y2} = coordsToScreen(p2); @@ -144,10 +207,6 @@ const Graph = ({nodes, edges, render}) => 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; @@ -156,13 +215,12 @@ const Graph = ({nodes, edges, render}) => { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); - const position = screenToCoords(screen); - const nearest = layout.nearest(position); + const clickedNode = findParentNode(ev.target); - if (nearest.distance <= scale / 50) + if (clickedNode !== null) { - dragging = nearest; - dragging.point.m = 1000; + dragging = layout.nodePoints[clickedNode]; + dragging.m = Infinity; } }; @@ -172,7 +230,7 @@ const Graph = ({nodes, edges, render}) => { const {clientX: x, clientY: y} = ev; const screen = new Springy.Vector(x, y); - dragging.point.p = screenToCoords(screen); + dragging.p = screenToCoords(screen); } }; @@ -180,7 +238,7 @@ const Graph = ({nodes, edges, render}) => { if (dragging !== null) { - dragging.point.m = 1; + dragging.m = 1; dragging = null; } }; @@ -195,20 +253,24 @@ const Graph = ({nodes, edges, render}) => document.body.removeEventListener('mousemove', mouseMove); document.body.removeEventListener('mouseup', mouseUp); }; - }); + }, []); return (