2019-11-27 05:49:19 +00:00
|
|
|
|
import React, {useState, useRef, useEffect} from 'react';
|
2019-12-02 06:20:27 +00:00
|
|
|
|
import {TransitionGroup, CSSTransition} from 'react-transition-group';
|
2019-11-27 05:49:19 +00:00
|
|
|
|
import Springy from 'springy';
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
/**
|
|
|
|
|
* É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, '\\"');
|
|
|
|
|
|
2019-11-27 05:49:19 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
[to, from] = [from, to];
|
2019-11-27 05:49:19 +00:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
return JSON.stringify([from, to]);
|
2019-11-27 05:49:19 +00:00
|
|
|
|
};
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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)}"]`);
|
|
|
|
|
|
|
|
|
|
/**
|
2019-12-02 05:40:32 +00:00
|
|
|
|
* Recherche le premier élément parent représentant un nœud.
|
2019-12-02 05:18:52 +00:00
|
|
|
|
*
|
|
|
|
|
* @param start Élément de départ.
|
2019-12-02 05:40:32 +00:00
|
|
|
|
* @return Élément trouvé ou null sinon.
|
2019-12-02 05:18:52 +00:00
|
|
|
|
*/
|
|
|
|
|
const findParentNode = start =>
|
|
|
|
|
{
|
|
|
|
|
if (start instanceof window.HTMLDocument)
|
|
|
|
|
{
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (start.hasAttribute('data-node-id'))
|
|
|
|
|
{
|
2019-12-02 05:40:32 +00:00
|
|
|
|
return start;
|
2019-12-02 05:18:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return findParentNode(start.parentNode);
|
|
|
|
|
};
|
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
|
2019-11-27 05:49:19 +00:00
|
|
|
|
/**
|
|
|
|
|
* Affiche un graphe.
|
|
|
|
|
*
|
2019-12-02 05:18:52 +00:00
|
|
|
|
* @prop nodes Liste des identifiants de nœuds du graphe.
|
2019-11-27 05:49:19 +00:00
|
|
|
|
* @prop edges Couples d’identifiants de nœuds formant les arêtes du graphe.
|
2019-12-04 04:20:06 +00:00
|
|
|
|
* @prop emptyMessage Message affiché lorsque le graphe est vide.
|
2019-11-27 05:49:19 +00:00
|
|
|
|
* @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.
|
2019-12-04 04:20:06 +00:00
|
|
|
|
* @prop onNodeClick Fonction de rappel appelée lors du clic sur un nœud du
|
|
|
|
|
* graphe avec l’identifiant du nœud correspondant.
|
2019-11-27 05:49:19 +00:00
|
|
|
|
*/
|
2019-12-04 04:20:06 +00:00
|
|
|
|
const Graph = ({
|
|
|
|
|
nodes,
|
|
|
|
|
edges,
|
|
|
|
|
emptyMessage,
|
|
|
|
|
render,
|
|
|
|
|
onNodeClick
|
|
|
|
|
}) =>
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
|
|
|
|
const [graph,] = useState(new Springy.Graph());
|
|
|
|
|
const [layout,] = useState(new Springy.Layout.ForceDirected(
|
|
|
|
|
graph,
|
2019-12-02 05:18:52 +00:00
|
|
|
|
/* rigidité des arêtes = */ 400,
|
|
|
|
|
/* répulsion des nœuds = */ 450,
|
|
|
|
|
/* amortissement de vitesse = */ 0.4
|
2019-11-27 05:49:19 +00:00
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
// N’arrête jamais l’animation
|
|
|
|
|
layout.minEnergyThreshold = 0;
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
// Pointeur sur l’élément englobant le graphe
|
2019-11-27 05:49:19 +00:00
|
|
|
|
const graphParent = useRef(null);
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
// Ajout des nouveaux nœuds et retrait des anciens
|
|
|
|
|
const oldNodes = new Set(Object.keys(graph.nodeSet));
|
|
|
|
|
|
2019-11-27 05:49:19 +00:00
|
|
|
|
for (let node of nodes)
|
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
if (!oldNodes.has(node))
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
|
|
|
|
graph.addNode(new Springy.Node(node));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
for (let node of oldNodes)
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
if (!nodes.includes(node))
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
graph.removeNode({id: node});
|
2019-11-27 05:49:19 +00:00
|
|
|
|
}
|
2019-12-02 05:18:52 +00:00
|
|
|
|
}
|
2019-11-27 05:49:19 +00:00
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
// 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));
|
2019-11-27 05:49:19 +00:00
|
|
|
|
|
|
|
|
|
for (let [from, to] of edges)
|
|
|
|
|
{
|
|
|
|
|
const edgeId = getEdgeId(from, to);
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
if (!oldEdges.has(edgeId))
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
|
|
|
|
graph.addEdge(new Springy.Edge(edgeId, {id: from}, {id: to}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
for (let edge of oldEdges)
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
if (!newEdges.has(edge))
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
graph.removeEdge({id: edge});
|
2019-11-27 05:49:19 +00:00
|
|
|
|
}
|
2019-12-02 05:18:52 +00:00
|
|
|
|
}
|
2019-11-27 05:49:19 +00:00
|
|
|
|
|
2019-12-02 05:18:52 +00:00
|
|
|
|
// Rendu de l’animation du graphe
|
2019-11-27 05:49:19 +00:00
|
|
|
|
useEffect(() =>
|
|
|
|
|
{
|
|
|
|
|
layout.start(() =>
|
|
|
|
|
{
|
|
|
|
|
layout.eachNode(({id}, {p}) =>
|
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
const element = findNode(graphParent.current, id);
|
2019-11-27 05:49:19 +00:00
|
|
|
|
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}}) =>
|
|
|
|
|
{
|
2019-12-02 05:18:52 +00:00
|
|
|
|
const element = findEdge(graphParent.current, id);
|
2019-11-27 05:49:19 +00:00
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
2019-12-04 04:20:06 +00:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Gestion du déplacement des nœuds à la souris
|
|
|
|
|
useEffect(() =>
|
|
|
|
|
{
|
|
|
|
|
// Identifiant du nœud en cours de déplacement
|
|
|
|
|
let dragNode = -1;
|
2019-11-27 05:49:19 +00:00
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
// Point physique correspondant au nœud en cours de déplacement
|
2019-12-02 05:40:32 +00:00
|
|
|
|
let dragPoint = null;
|
2019-12-04 04:20:06 +00:00
|
|
|
|
|
|
|
|
|
// Élément du DOM correspondant au nœud en cours de déplacement
|
2019-12-02 05:40:32 +00:00
|
|
|
|
let dragElement = null;
|
2019-12-04 04:20:06 +00:00
|
|
|
|
|
|
|
|
|
// 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;
|
2019-11-27 05:49:19 +00:00
|
|
|
|
|
|
|
|
|
const mouseDown = ev =>
|
|
|
|
|
{
|
2019-12-02 05:40:32 +00:00
|
|
|
|
dragElement = findParentNode(ev.target);
|
|
|
|
|
|
|
|
|
|
if (dragElement !== null)
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-04 04:20:06 +00:00
|
|
|
|
const {clientX: x, clientY: y} = ev;
|
|
|
|
|
const screen = new Springy.Vector(x, y);
|
|
|
|
|
const coords = screenToCoords(screen);
|
2019-12-02 05:40:32 +00:00
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
dragNode = dragElement.getAttribute('data-node-id');
|
|
|
|
|
dragPoint = layout.nodePoints[dragNode];
|
|
|
|
|
dragDelta = dragPoint.p.subtract(coords);
|
|
|
|
|
dragClick = true;
|
|
|
|
|
dragOrigin = screen;
|
2019-12-02 05:40:32 +00:00
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
dragElement.classList.add('Graph_node-dragging');
|
|
|
|
|
dragPoint.m = Infinity;
|
2019-11-27 05:49:19 +00:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const mouseMove = ev =>
|
|
|
|
|
{
|
2019-12-02 05:40:32 +00:00
|
|
|
|
if (dragElement !== null)
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
|
|
|
|
const {clientX: x, clientY: y} = ev;
|
|
|
|
|
const screen = new Springy.Vector(x, y);
|
2019-12-04 04:20:06 +00:00
|
|
|
|
const coords = screenToCoords(screen);
|
|
|
|
|
|
|
|
|
|
dragPoint.p = dragDelta.add(coords);
|
2019-12-02 05:40:32 +00:00
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
if (dragOrigin.subtract(screen).magnitude() >= 5)
|
|
|
|
|
{
|
|
|
|
|
// Déplacement de plus de 5 px ⇒ pas de clic
|
|
|
|
|
dragClick = false;
|
|
|
|
|
}
|
2019-11-27 05:49:19 +00:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
const mouseUp = () =>
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-02 05:40:32 +00:00
|
|
|
|
if (dragElement !== null)
|
2019-11-27 05:49:19 +00:00
|
|
|
|
{
|
2019-12-04 04:20:06 +00:00
|
|
|
|
if (dragClick)
|
|
|
|
|
{
|
|
|
|
|
onNodeClick(dragNode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dragNode = -1;
|
|
|
|
|
|
2019-12-02 05:40:32 +00:00
|
|
|
|
dragPoint.m = 1;
|
|
|
|
|
dragPoint = null;
|
|
|
|
|
|
2019-12-02 06:20:27 +00:00
|
|
|
|
dragElement.classList.remove('Graph_node-dragging');
|
2019-12-02 05:40:32 +00:00
|
|
|
|
dragElement = null;
|
|
|
|
|
|
2019-12-04 04:20:06 +00:00
|
|
|
|
dragDelta = null;
|
|
|
|
|
dragOrigin = null;
|
2019-11-27 05:49:19 +00:00
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
2019-12-04 04:20:06 +00:00
|
|
|
|
}, [onNodeClick]);
|
2019-11-27 05:49:19 +00:00
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={graphParent} className="Graph">
|
|
|
|
|
<svg className="Graph_edgesContainer">
|
2019-12-02 06:20:27 +00:00
|
|
|
|
<TransitionGroup component={null}>
|
|
|
|
|
{edges.map(edge => (
|
|
|
|
|
<CSSTransition
|
|
|
|
|
key={getEdgeId(...edge)}
|
|
|
|
|
timeout={500}
|
|
|
|
|
classNames="Graph_element"
|
|
|
|
|
>
|
|
|
|
|
<line
|
|
|
|
|
data-edge-id={getEdgeId(...edge)}
|
|
|
|
|
className="Graph_edge"
|
|
|
|
|
/>
|
|
|
|
|
</CSSTransition>
|
|
|
|
|
))}
|
|
|
|
|
</TransitionGroup>
|
2019-11-27 05:49:19 +00:00
|
|
|
|
</svg>
|
|
|
|
|
|
2019-12-02 06:20:27 +00:00
|
|
|
|
<TransitionGroup component={null}>
|
|
|
|
|
{nodes.map(id => (
|
|
|
|
|
<CSSTransition
|
|
|
|
|
key={id}
|
|
|
|
|
timeout={500}
|
|
|
|
|
classNames="Graph_element"
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
data-node-id={id}
|
|
|
|
|
className="Graph_node"
|
|
|
|
|
>
|
|
|
|
|
{render(id)}
|
|
|
|
|
</span>
|
|
|
|
|
</CSSTransition>
|
|
|
|
|
))}
|
|
|
|
|
</TransitionGroup>
|
2019-12-02 05:19:07 +00:00
|
|
|
|
|
|
|
|
|
{nodes.length === 0
|
2019-12-04 04:20:06 +00:00
|
|
|
|
? <span className="Graph_empty">{emptyMessage}</span>
|
2019-12-02 05:19:07 +00:00
|
|
|
|
: null}
|
2019-11-27 05:49:19 +00:00
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default Graph;
|