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

295 lines
8.4 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 {TransitionGroup, CSSTransition} from 'react-transition-group';
import Springy from 'springy';
/**
* É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]);
};
/**
* Vérifie si une liste darêtes contient une arête donnée, dans un sens ou
* dans lautre.
*
* @param list Liste darêtes.
* @param from Premier nœud de larête.
* @param to Second nœud de larête.
* @return Vrai si et seulement si larête existe.
*/
const includesEdge = (list, from, to) =>
list.some(([source, target]) => (
(source === from && target === to)
|| (source === to && target === from)
));
/**
* 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);
};
/**
* 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 render Fonction de rendu prenant en paramètre lidentifiant dun nœud
* du graphe et renvoyant un élément à afficher pour le représenter.
*/
const Graph = ({nodes, edges, render}) =>
{
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(() =>
{
const center = () => new Springy.Vector(
window.innerWidth / 2,
window.innerHeight / 2
);
const scale = 50;
const coordsToScreen = vec => vec.multiply(scale).add(center());
const screenToCoords = vec => vec.subtract(center()).divide(scale);
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);
});
});
let dragPoint = null;
let dragElement = null;
let dragDelta = new Springy.Vector(0, 0);
const mouseDown = ev =>
{
const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y);
dragElement = findParentNode(ev.target);
if (dragElement !== null)
{
dragPoint = layout.nodePoints[dragElement.getAttribute('data-node-id')];
dragPoint.m = Infinity;
dragElement.classList.add('Graph_node-dragging');
dragDelta = dragPoint.p.subtract(screenToCoords(screen));
}
};
const mouseMove = ev =>
{
if (dragElement !== null)
{
const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y);
dragPoint.p = dragDelta.add(screenToCoords(screen));
}
};
const mouseUp = ev =>
{
if (dragElement !== null)
{
dragPoint.m = 1;
dragPoint = null;
dragElement.classList.remove('Graph_node-dragging');
dragElement = null;
dragDelta = new Springy.Vector(0, 0);
}
};
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);
};
}, []);
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">Aucun résultat</span>
: null}
</div>
);
};
export default Graph;