diff --git a/app/src/components/App.js b/app/src/components/App.js
index b181659..c577152 100644
--- a/app/src/components/App.js
+++ b/app/src/components/App.js
@@ -18,10 +18,14 @@ const App = () =>
+
-
);
};
diff --git a/app/src/components/DiseaseGraph.js b/app/src/components/DiseaseGraph.js
index d7baf4b..189f66d 100644
--- a/app/src/components/DiseaseGraph.js
+++ b/app/src/components/DiseaseGraph.js
@@ -1,16 +1,21 @@
-import React from 'react';
+import React, {useEffect} from 'react';
import Graph from './Graph.js';
import {types} from '../data/mock.js';
import {useAsync} from '../util.js';
import {symptomsSubgraph} from '../fetch.js';
-const DiseaseGraph = ({terms, results}) =>
+const DiseaseGraph = ({terms, setTerms, results}) =>
{
const {nodes, edges} = useAsync({
nodes: {},
edges: []
}, symptomsSubgraph, results);
+ /**
+ * Rendu d’un nœud du graphe.
+ *
+ * @param id Identifiant du nœud à afficher.
+ */
const render = id =>
{
const isTerm = terms.some(({id: termId}) => termId === id);
@@ -25,11 +30,41 @@ const DiseaseGraph = ({terms, results}) =>
);
};
+ /**
+ * Gère le clic sur un nœud du graphe.
+ *
+ * @param id Identifiant du nœud cliqué.
+ */
+ const handleNodeClick = id =>
+ {
+ const result = results.find(({id: termId}) => termId === id);
+ const termIndex = terms.findIndex(({id: termId}) => termId === id);
+
+ if (result !== undefined)
+ {
+ if (termIndex >= 0)
+ {
+ // Retrait d’un terme déjà dans la requête
+ setTerms([
+ ...terms.slice(0, termIndex),
+ ...terms.slice(termIndex + 1)
+ ]);
+ }
+ else
+ {
+ // Ajout d’un nouveau terme dans la requête
+ setTerms(terms.concat([result]));
+ }
+ }
+ };
+
return (
);
};
diff --git a/app/src/components/Graph.js b/app/src/components/Graph.js
index bcb5a76..8179a03 100644
--- a/app/src/components/Graph.js
+++ b/app/src/components/Graph.js
@@ -27,21 +27,6 @@ const getEdgeId = (from, to) =>
return JSON.stringify([from, to]);
};
-/**
- * Vérifie si une liste d’arêtes contient une arête donnée, dans un sens ou
- * dans l’autre.
- *
- * @param list Liste d’arêtes.
- * @param from Premier nœud de l’arête.
- * @param to Second nœud de l’arête.
- * @return Vrai si et seulement si l’arête existe.
- */
-const includesEdge = (list, from, to) =>
- list.some(([source, target]) => (
- (source === from && target === to)
- || (source === to && target === from)
- ));
-
/**
* Recherche parmi les descendants d’un élément celui qui représente un
* nœud donnée, s’il existe.
@@ -85,15 +70,55 @@ const findParentNode = 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 d’identifiants 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 l’identifiant d’un 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 l’identifiant du nœud correspondant.
*/
-const Graph = ({nodes, edges, render}) =>
+const Graph = ({
+ nodes,
+ edges,
+ emptyMessage,
+ render,
+ onNodeClick
+}) =>
{
const [graph,] = useState(new Springy.Graph());
const [layout,] = useState(new Springy.Layout.ForceDirected(
@@ -153,15 +178,6 @@ const Graph = ({nodes, edges, render}) =>
// Rendu de l’animation 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}) =>
@@ -187,26 +203,48 @@ const Graph = ({nodes, edges, render}) =>
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;
- let dragDelta = new Springy.Vector(0, 0);
+
+ // 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 =>
{
- 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;
+ 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');
-
- dragDelta = dragPoint.p.subtract(screenToCoords(screen));
+ dragPoint.m = Infinity;
}
};
@@ -216,22 +254,37 @@ const Graph = ({nodes, edges, render}) =>
{
const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y);
+ const coords = screenToCoords(screen);
- dragPoint.p = dragDelta.add(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 =>
+ const mouseUp = () =>
{
if (dragElement !== null)
{
+ if (dragClick)
+ {
+ onNodeClick(dragNode);
+ }
+
+ dragNode = -1;
+
dragPoint.m = 1;
dragPoint = null;
dragElement.classList.remove('Graph_node-dragging');
dragElement = null;
- dragDelta = new Springy.Vector(0, 0);
+ dragDelta = null;
+ dragOrigin = null;
}
};
@@ -245,7 +298,7 @@ const Graph = ({nodes, edges, render}) =>
document.body.removeEventListener('mousemove', mouseMove);
document.body.removeEventListener('mouseup', mouseUp);
};
- }, []);
+ }, [onNodeClick]);
return (
@@ -284,7 +337,7 @@ const Graph = ({nodes, edges, render}) =>
{nodes.length === 0
- ? Aucun résultat
+ ? {emptyMessage}
: null}
);
diff --git a/app/src/components/TermInput.js b/app/src/components/TermInput.js
index 125bd81..60346f3 100644
--- a/app/src/components/TermInput.js
+++ b/app/src/components/TermInput.js
@@ -1,5 +1,4 @@
import React, {useState, useRef} from 'react';
-import * as fetch from '../fetch.js';
/**
* Codes des touches du clavier par nom.
@@ -81,7 +80,7 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
{
if (ev.keyCode === keys.enter && value)
{
- // Touche Entrée : ajout de la suggestion ayant le focus
+ // Touche Entrée : ajout de la suggestion ayant le focus
ev.preventDefault();
if (focusedSuggestion < suggestions.length)
@@ -95,14 +94,14 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
&& terms.length !== 0
)
{
- // Touche Retour alors que le champ de saisie est vide :
+ // Touche Retour alors que le champ de saisie est vide :
// retrait du dernier terme
ev.preventDefault();
setTerms(terms.slice(0, -1));
}
else if (ev.keyCode === keys.up && focusedSuggestion > 0)
{
- // Touche Haut : focus de la suggestion précédente
+ // Touche Haut : focus de la suggestion précédente
ev.preventDefault();
setFocusedSuggestion(focusedSuggestion - 1);
}
@@ -111,7 +110,7 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
&& focusedSuggestion < suggestions.length - 1
)
{
- // Touche Bas : focus de la suggestion suivante
+ // Touche Bas : focus de la suggestion suivante
ev.preventDefault();
setFocusedSuggestion(focusedSuggestion + 1);
}
@@ -188,6 +187,6 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
);
-}
+};
export default TermInput;
diff --git a/app/src/data/mock.js b/app/src/data/mock.js
index 2a4a984..602190c 100644
--- a/app/src/data/mock.js
+++ b/app/src/data/mock.js
@@ -124,7 +124,7 @@ export const terms = {
};
/**
- * Liste des relations de type « a pour symptôme » entre deux termes.
+ * Liste des relations de type « a pour symptôme » entre deux termes.
*/
export const hasSymptom = [
['Q2840', 'Q38933'],
@@ -165,6 +165,6 @@ export const hasSymptom = [
];
/**
- * Liste des relations de type « est un symptôme de » entre deux termes.
+ * Liste des relations de type « est un symptôme de » entre deux termes.
*/
export const symptomOf = hasSymptom.map(([from, to]) => [to, from]);
diff --git a/app/src/fetch.js b/app/src/fetch.js
index 2bf1ea9..e772e5b 100644
--- a/app/src/fetch.js
+++ b/app/src/fetch.js
@@ -1,7 +1,7 @@
import * as mock from './data/mock.js';
/**
- * Recherche l’ensemble des maladies liées par une relation « a pour symptôme »
+ * Recherche l’ensemble des maladies liées par une relation « a pour symptôme »
* avec un ensemble de termes donné.
*
* @param query Ensemble de termes attendus.
@@ -9,63 +9,71 @@ import * as mock from './data/mock.js';
*/
export const diseasesBySymptoms = async query =>
{
- // Si aucun terme dans la requête, toutes les maladies correspondent
+ let allMatches = [];
+
if (!query.length)
{
- return Object.values(mock.terms);
+ // Si aucun terme dans la requête, tout correspond
+ allMatches = Object.values(mock.terms).map(({id}) => id);
}
-
- // Marqueurs indiquant pour chaque terme l’ensemble des éléments de la
- // requête qu’il a pour symptôme au travers d’une relation directe ou
- // transitive
- const matchingSymptoms = {};
-
- // Réalise un parcours en profondeur du graphe en partant de chaque terme
- // de la requête pour marquer les résultats
- for (let queryTerm of query)
+ else
{
- const stack = [queryTerm.id];
+ // Marqueurs indiquant pour chaque terme l’ensemble des éléments de la
+ // requête qu’il a pour symptôme au travers d’une relation directe ou
+ // transitive
+ const matchingSymptoms = {};
- while (stack.length)
+ // Réalise un parcours en profondeur du graphe en partant de chaque terme
+ // de la requête pour marquer les résultats
+ for (let queryTerm of query)
{
- const current = stack.pop();
- const neighbors = mock.symptomOf.filter(
- ([from]) => from === current
- ).map(
- ([, to]) => to
- );
+ const stack = [queryTerm.id];
- for (let neighbor of neighbors)
+ while (stack.length)
{
- if (!(neighbor in matchingSymptoms))
- {
- matchingSymptoms[neighbor] = [];
- }
+ const current = stack.pop();
+ const neighbors = mock.symptomOf.filter(
+ ([from]) => from === current
+ ).map(
+ ([, to]) => to
+ );
- if (!matchingSymptoms[neighbor].includes(queryTerm.id))
+ for (let neighbor of neighbors)
{
- matchingSymptoms[neighbor].push(queryTerm.id);
- stack.push(neighbor);
+ if (!(neighbor in matchingSymptoms))
+ {
+ matchingSymptoms[neighbor] = [];
+ }
+
+ if (!matchingSymptoms[neighbor].includes(queryTerm.id))
+ {
+ matchingSymptoms[neighbor].push(queryTerm.id);
+ stack.push(neighbor);
+ }
}
}
}
+
+ // Seuls les termes ayant été visités par tous les éléments de la
+ // requête sont des résultats valides
+ allMatches = Object.entries(matchingSymptoms).filter(
+ ([, matches]) => matches.length === query.length
+ ).map(
+ ([id]) => id
+ );
}
- // Seuls les termes ayant été visités par tous les éléments de la requête
- // et qui sont des maladies constituent des résultats valides
- return Object.entries(matchingSymptoms).filter(
- ([id, matches]) => (
- matches.length === query.length
- && mock.terms[id].types.includes(mock.types.disease)
- )
- ).map(
- ([id]) => mock.terms[id]
+ // On ne garde que les maladies
+ return allMatches.map(
+ id => mock.terms[id]
+ ).filter(
+ term => term.types.includes(mock.types.disease)
);
};
/**
- * Récupère tous les termes liés à un ensemble de termes par une relation « a
- * pour symptôme », directe ou transitive.
+ * Récupère tous les termes liés à un ensemble de termes par une relation « a
+ * pour symptôme », directe ou transitive.
*
* @param terms Ensemble de termes initiaux.
* @return Termes de `terms` ainsi que leurs voisins par la relation symptôme.
@@ -76,8 +84,8 @@ export const exploreSymptoms = async terms =>
const selected = terms.map(({id}) => id);
// Fait un parcours en profondeur issu de chaque résultat pour obtenir
- // tous les voisins directs ou transitifs par la relation « a pour
- // symptôme »
+ // tous les voisins directs ou transitifs par la relation « a pour
+ // symptôme »
const stack = [...selected];
while (stack.length)
@@ -99,7 +107,7 @@ export const exploreSymptoms = async terms =>
}
}
- return selected.map(id => mock.terms[id]);;
+ return selected.map(id => mock.terms[id]);
};
/**