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

219 lines
6.2 KiB
JavaScript
Raw Normal View History

import React, {useState, useRef, useEffect} from 'react';
import Springy from 'springy';
/**
* 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)
{
return getEdgeId(to, from);
}
return `${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)
));
/**
* 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 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é = */ 400,
/* répulsion = */ 400,
/* amortissement = */ 0.5
));
// Narrête jamais lanimation
layout.minEnergyThreshold = 0;
const graphParent = useRef(null);
// Ajout des nouveaux nœuds
for (let node of nodes)
{
if (!(node in graph.nodeSet))
{
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 =>
{
if (!nodes.includes(node.id))
{
console.info(`Retrait de lancien nœud ${node.id}`);
return false;
}
return true;
});
// Ajout des nouvelles arêtes
for (let [from, to] of edges)
{
const edgeId = getEdgeId(from, to);
if (graph.edges.every(edge => edge.id !== 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}}) =>
{
if (!includesEdge(edges, from, to))
{
console.info(`Retrait de lancienne arête ${from}-${to}`);
return false;
}
return true;
});
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 = graphParent.current.querySelector(
`[data-node-id="${id.replace('"', '\\"')}"]`
);
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 = graphParent.current.querySelector(
`[data-edge-id="${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);
});
}, () => {
console.info(`Fin du rendu du graphe`);
}, () => {
console.info(`Démarrage du rendu du graphe`);
});
let dragging = null;
const mouseDown = ev =>
{
const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y);
const position = screenToCoords(screen);
const nearest = layout.nearest(position);
if (nearest.distance <= scale / 50)
{
dragging = nearest;
dragging.point.m = 1000;
}
};
const mouseMove = ev =>
{
if (dragging !== null)
{
const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y);
dragging.point.p = screenToCoords(screen);
}
};
const mouseUp = ev =>
{
if (dragging !== null)
{
dragging.point.m = 1;
dragging = 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);
};
});
return (
<div ref={graphParent} className="Graph">
<svg className="Graph_edgesContainer">
{edges.map(edge => (
<line data-edge-id={getEdgeId(...edge)} />
))}
</svg>
{nodes.map(id => (
<span
className="Graph_node"
data-node-id={id}
>{render(id)}</span>
))}
</div>
);
};
export default Graph;