wikimedica-disease-search/app/src/components/Graph.js

357 lines
10 KiB
JavaScript
Raw Normal View History

import React, {useState, useRef, useEffect} from 'react';
2019-12-04 23:54:44 +00:00
import PropTypes from 'prop-types';
2019-12-02 06:20:27 +00:00
import {TransitionGroup, CSSTransition} from 'react-transition-group';
import Springy from 'springy';
2019-12-04 23:54:44 +00:00
import {Relation} from '../data/model.js';
2019-12-02 05:18:52 +00:00
/**
* Échappe une valeur utilisée dans un sélecteur dattributs CSS.
*
* @param value Valeur à échapper.
* @return Valeur échappée.
*/
const escapeAttrValue = value => value.replace(/"/g, '\\"');
/**
* Crée un identifiant unique pour larête identifiée par ses deux extrémités.
*
* @param from Premier nœud de larête.
* @param to Second nœud de larête.
* @return Identifiant unique pour les deux directions de larête.
*/
const getEdgeId = (from, to) =>
{
if (to < from)
{
2019-12-02 05:18:52 +00:00
[to, from] = [from, to];
}
2019-12-02 05:18:52 +00:00
return JSON.stringify([from, to]);
};
2019-12-02 05:18:52 +00:00
/**
* Recherche parmi les descendants dun élément celui qui représente un
* nœud donnée, sil 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 dun élément celui qui représente une
* arête donnée, sil existe.
*
* @param parent Élément parent.
* @param id Identifiant de larê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.
2019-12-02 05:18:52 +00:00
*
* @param start Élément de départ.
* @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'))
{
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);
/**
* Affiche un graphe.
*
2019-12-02 05:18:52 +00:00
* @prop nodes Liste des identifiants de nœuds du graphe.
* @prop edges Couples didentifiants 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.
* @prop render Fonction de rendu prenant en paramètre lidentifiant dun 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 lidentifiant du nœud correspondant.
*/
2019-12-04 04:20:06 +00:00
const Graph = ({
nodes,
edges,
emptyMessage,
render,
onNodeClick,
2019-12-04 04:20:06 +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
));
// Narrête jamais lanimation
layout.minEnergyThreshold = 0;
2019-12-02 05:18:52 +00:00
// Pointeur sur lélément englobant le graphe
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));
for (let node of nodes)
{
2019-12-02 05:18:52 +00:00
if (!oldNodes.has(node))
{
graph.addNode(new Springy.Node(node));
}
}
2019-12-02 05:18:52 +00:00
for (let node of oldNodes)
{
2019-12-02 05:18:52 +00:00
if (!nodes.includes(node))
{
2019-12-02 05:18:52 +00:00
graph.removeNode({id: node});
}
2019-12-02 05:18:52 +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));
for (let [from, to] of edges)
{
const edgeId = getEdgeId(from, to);
2019-12-02 05:18:52 +00:00
if (!oldEdges.has(edgeId))
{
graph.addEdge(new Springy.Edge(edgeId, {id: from}, {id: to}));
}
}
2019-12-02 05:18:52 +00:00
for (let edge of oldEdges)
{
2019-12-02 05:18:52 +00:00
if (!newEdges.has(edge))
{
2019-12-02 05:18:52 +00:00
graph.removeEdge({id: edge});
}
2019-12-02 05:18:52 +00:00
}
2019-12-02 05:18:52 +00:00
// Rendu de lanimation du graphe
useEffect(() =>
{
layout.start(() =>
{
layout.eachNode(({id}, {p}) =>
{
2019-12-02 05:18:52 +00:00
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}}) =>
{
2019-12-02 05:18:52 +00:00
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);
});
});
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-12-04 04:20:06 +00:00
// Point physique correspondant au nœud en cours de déplacement
let dragPoint = null;
2019-12-04 04:20:06 +00:00
// Élément du DOM correspondant au nœud en cours de déplacement
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;
const mouseDown = ev =>
{
dragElement = findParentNode(ev.target);
if (dragElement !== null)
{
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-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-04 04:20:06 +00:00
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);
2019-12-04 04:20:06 +00:00
const coords = screenToCoords(screen);
dragPoint.p = dragDelta.add(coords);
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-12-05 02:06:19 +00:00
const mouseUp = ev =>
{
if (dragElement !== null)
{
2019-12-04 04:20:06 +00:00
if (dragClick)
{
2019-12-05 02:06:19 +00:00
onNodeClick(dragNode, ev);
2019-12-04 04:20:06 +00:00
}
dragNode = -1;
dragPoint.m = 1;
dragPoint = null;
2019-12-02 06:20:27 +00:00
dragElement.classList.remove('Graph_node-dragging');
dragElement = null;
2019-12-04 04:20:06 +00:00
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);
};
2019-12-04 04:20:06 +00:00
}, [onNodeClick]);
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>
</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>
{nodes.length === 0
2019-12-04 04:20:06 +00:00
? <span className="Graph_empty">{emptyMessage}</span>
: null}
</div>
);
};
2019-12-04 23:54:44 +00:00
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;