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">
<TermInput
terms={terms}
availableTerms={results}
setTerms={setTerms}
availableTerms={results}
/>
<DiseaseGraph
terms={terms}
setTerms={setTerms}
results={results}
/>
<DiseaseGraph terms={terms} results={results} />
</div>
);
};

View File

@ -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 dun 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 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 (
<Graph
nodes={Object.keys(nodes)}
edges={edges}
emptyMessage="Aucune maladie ne corresond à ces symptômes"
render={render}
onNodeClick={handleNodeClick}
/>
);
};

View File

@ -27,21 +27,6 @@ const getEdgeId = (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.
@ -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 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, 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 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}) =>
@ -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 (
<div ref={graphParent} className="Graph">
@ -284,7 +337,7 @@ const Graph = ({nodes, edges, render}) =>
</TransitionGroup>
{nodes.length === 0
? <span className="Graph_empty">Aucun résultat</span>
? <span className="Graph_empty">{emptyMessage}</span>
: null}
</div>
);

View File

@ -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}) =>
</ul>
</div>
);
}
};
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 = [
['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]);

View File

@ -1,7 +1,7 @@
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é.
*
* @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 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)
else
{
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 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]);
};
/**