app: Ajout/retrait termes par clic

This commit is contained in:
Mattéo Delabre 2019-12-03 23:20:06 -05:00
parent b0f00c68a0
commit 99a2d487f3
Signed by: matteo
GPG Key ID: AE3FBD02DC583ABB
6 changed files with 191 additions and 92 deletions

View File

@ -18,10 +18,14 @@ const App = () =>
<div className="App"> <div className="App">
<TermInput <TermInput
terms={terms} terms={terms}
availableTerms={results}
setTerms={setTerms} setTerms={setTerms}
availableTerms={results}
/>
<DiseaseGraph
terms={terms}
setTerms={setTerms}
results={results}
/> />
<DiseaseGraph terms={terms} results={results} />
</div> </div>
); );
}; };

View File

@ -1,16 +1,21 @@
import React from 'react'; import React, {useEffect} from 'react';
import Graph from './Graph.js'; import Graph from './Graph.js';
import {types} from '../data/mock.js'; import {types} from '../data/mock.js';
import {useAsync} from '../util.js'; import {useAsync} from '../util.js';
import {symptomsSubgraph} from '../fetch.js'; import {symptomsSubgraph} from '../fetch.js';
const DiseaseGraph = ({terms, results}) => const DiseaseGraph = ({terms, setTerms, results}) =>
{ {
const {nodes, edges} = useAsync({ const {nodes, edges} = useAsync({
nodes: {}, nodes: {},
edges: [] edges: []
}, symptomsSubgraph, results); }, symptomsSubgraph, results);
/**
* Rendu dun nœud du graphe.
*
* @param id Identifiant du nœud à afficher.
*/
const render = id => const render = id =>
{ {
const isTerm = terms.some(({id: termId}) => termId === 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 dun terme déjà dans la requête
setTerms([
...terms.slice(0, termIndex),
...terms.slice(termIndex + 1)
]);
}
else
{
// Ajout dun nouveau terme dans la requête
setTerms(terms.concat([result]));
}
}
};
return ( return (
<Graph <Graph
nodes={Object.keys(nodes)} nodes={Object.keys(nodes)}
edges={edges} edges={edges}
emptyMessage="Aucune maladie ne corresond à ces symptômes"
render={render} render={render}
onNodeClick={handleNodeClick}
/> />
); );
}; };

View File

@ -27,21 +27,6 @@ const getEdgeId = (from, to) =>
return JSON.stringify([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 * Recherche parmi les descendants dun élément celui qui représente un
* nœud donnée, sil existe. * nœud donnée, sil existe.
@ -85,15 +70,55 @@ const findParentNode = start =>
return findParentNode(start.parentNode); 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. * Affiche un graphe.
* *
* @prop nodes Liste des identifiants de nœuds du 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 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 * @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. * 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, render}) => const Graph = ({
nodes,
edges,
emptyMessage,
render,
onNodeClick
}) =>
{ {
const [graph,] = useState(new Springy.Graph()); const [graph,] = useState(new Springy.Graph());
const [layout,] = useState(new Springy.Layout.ForceDirected( const [layout,] = useState(new Springy.Layout.ForceDirected(
@ -153,15 +178,6 @@ const Graph = ({nodes, edges, render}) =>
// Rendu de lanimation du graphe // Rendu de lanimation du graphe
useEffect(() => 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.start(() =>
{ {
layout.eachNode(({id}, {p}) => layout.eachNode(({id}, {p}) =>
@ -187,26 +203,48 @@ const Graph = ({nodes, edges, render}) =>
element.setAttribute('y2', y2); 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; let dragPoint = null;
// Élément du DOM correspondant au nœud en cours de déplacement
let dragElement = null; 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 mouseDown = ev =>
{ {
const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y);
dragElement = findParentNode(ev.target); dragElement = findParentNode(ev.target);
if (dragElement !== null) if (dragElement !== null)
{ {
dragPoint = layout.nodePoints[dragElement.getAttribute('data-node-id')]; const {clientX: x, clientY: y} = ev;
dragPoint.m = Infinity; 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'); dragElement.classList.add('Graph_node-dragging');
dragPoint.m = Infinity;
dragDelta = dragPoint.p.subtract(screenToCoords(screen));
} }
}; };
@ -216,22 +254,37 @@ const Graph = ({nodes, edges, render}) =>
{ {
const {clientX: x, clientY: y} = ev; const {clientX: x, clientY: y} = ev;
const screen = new Springy.Vector(x, y); 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 (dragElement !== null)
{ {
if (dragClick)
{
onNodeClick(dragNode);
}
dragNode = -1;
dragPoint.m = 1; dragPoint.m = 1;
dragPoint = null; dragPoint = null;
dragElement.classList.remove('Graph_node-dragging'); dragElement.classList.remove('Graph_node-dragging');
dragElement = null; 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('mousemove', mouseMove);
document.body.removeEventListener('mouseup', mouseUp); document.body.removeEventListener('mouseup', mouseUp);
}; };
}, []); }, [onNodeClick]);
return ( return (
<div ref={graphParent} className="Graph"> <div ref={graphParent} className="Graph">
@ -284,7 +337,7 @@ const Graph = ({nodes, edges, render}) =>
</TransitionGroup> </TransitionGroup>
{nodes.length === 0 {nodes.length === 0
? <span className="Graph_empty">Aucun résultat</span> ? <span className="Graph_empty">{emptyMessage}</span>
: null} : null}
</div> </div>
); );

View File

@ -1,5 +1,4 @@
import React, {useState, useRef} from 'react'; import React, {useState, useRef} from 'react';
import * as fetch from '../fetch.js';
/** /**
* Codes des touches du clavier par nom. * Codes des touches du clavier par nom.
@ -81,7 +80,7 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
{ {
if (ev.keyCode === keys.enter && value) 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(); ev.preventDefault();
if (focusedSuggestion < suggestions.length) if (focusedSuggestion < suggestions.length)
@ -95,14 +94,14 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
&& terms.length !== 0 && 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 // retrait du dernier terme
ev.preventDefault(); ev.preventDefault();
setTerms(terms.slice(0, -1)); setTerms(terms.slice(0, -1));
} }
else if (ev.keyCode === keys.up && focusedSuggestion > 0) 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(); ev.preventDefault();
setFocusedSuggestion(focusedSuggestion - 1); setFocusedSuggestion(focusedSuggestion - 1);
} }
@ -111,7 +110,7 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
&& focusedSuggestion < suggestions.length - 1 && focusedSuggestion < suggestions.length - 1
) )
{ {
// Touche Bas: focus de la suggestion suivante // Touche Bas : focus de la suggestion suivante
ev.preventDefault(); ev.preventDefault();
setFocusedSuggestion(focusedSuggestion + 1); setFocusedSuggestion(focusedSuggestion + 1);
} }
@ -188,6 +187,6 @@ const TermInput = ({terms, availableTerms, setTerms}) =>
</ul> </ul>
</div> </div>
); );
} };
export default TermInput; export default TermInput;

View File

@ -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 = [ export const hasSymptom = [
['Q2840', 'Q38933'], ['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]); export const symptomOf = hasSymptom.map(([from, to]) => [to, from]);

View File

@ -1,7 +1,7 @@
import * as mock from './data/mock.js'; import * as mock from './data/mock.js';
/** /**
* Recherche lensemble des maladies liées par une relation «a pour symptôme» * Recherche lensemble des maladies liées par une relation « a pour symptôme »
* avec un ensemble de termes donné. * avec un ensemble de termes donné.
* *
* @param query Ensemble de termes attendus. * @param query Ensemble de termes attendus.
@ -9,63 +9,71 @@ import * as mock from './data/mock.js';
*/ */
export const diseasesBySymptoms = async query => export const diseasesBySymptoms = async query =>
{ {
// Si aucun terme dans la requête, toutes les maladies correspondent let allMatches = [];
if (!query.length) 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);
} }
else
// Marqueurs indiquant pour chaque terme lensemble des éléments de la
// requête quil a pour symptôme au travers dune 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)
{ {
const stack = [queryTerm.id]; // Marqueurs indiquant pour chaque terme lensemble des éléments de la
// requête quil a pour symptôme au travers dune 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 stack = [queryTerm.id];
const neighbors = mock.symptomOf.filter(
([from]) => from === current
).map(
([, to]) => to
);
for (let neighbor of neighbors) while (stack.length)
{ {
if (!(neighbor in matchingSymptoms)) const current = stack.pop();
{ const neighbors = mock.symptomOf.filter(
matchingSymptoms[neighbor] = []; ([from]) => from === current
} ).map(
([, to]) => to
);
if (!matchingSymptoms[neighbor].includes(queryTerm.id)) for (let neighbor of neighbors)
{ {
matchingSymptoms[neighbor].push(queryTerm.id); if (!(neighbor in matchingSymptoms))
stack.push(neighbor); {
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 // On ne garde que les maladies
// et qui sont des maladies constituent des résultats valides return allMatches.map(
return Object.entries(matchingSymptoms).filter( id => mock.terms[id]
([id, matches]) => ( ).filter(
matches.length === query.length term => term.types.includes(mock.types.disease)
&& mock.terms[id].types.includes(mock.types.disease)
)
).map(
([id]) => mock.terms[id]
); );
}; };
/** /**
* Récupère tous les termes liés à un ensemble de termes par une relation «a * Récupère tous les termes liés à un ensemble de termes par une relation « a
* pour symptôme», directe ou transitive. * pour symptôme », directe ou transitive.
* *
* @param terms Ensemble de termes initiaux. * @param terms Ensemble de termes initiaux.
* @return Termes de `terms` ainsi que leurs voisins par la relation symptôme. * @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); const selected = terms.map(({id}) => id);
// Fait un parcours en profondeur issu de chaque résultat pour obtenir // Fait un parcours en profondeur issu de chaque résultat pour obtenir
// tous les voisins directs ou transitifs par la relation «a pour // tous les voisins directs ou transitifs par la relation « a pour
// symptôme» // symptôme »
const stack = [...selected]; const stack = [...selected];
while (stack.length) 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]);
}; };
/** /**