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

357 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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)
{
[to, from] = [from, to];
}
return JSON.stringify([from, to]);
};
/**
* 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.
*
* @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 didentifiants 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 lidentifiant dun 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 lidentifiant 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
));
// Narrête jamais lanimation
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 lanimation 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 (
<div ref={graphParent} className="Graph">
<svg className="Graph_edgesContainer">
<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>
<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
? <span className="Graph_empty">{emptyMessage}</span>
: null}
</div>
);
};
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;