app: Ajout/retrait termes par clic
This commit is contained in:
parent
b0f00c68a0
commit
99a2d487f3
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<Graph
|
||||
nodes={Object.keys(nodes)}
|
||||
edges={edges}
|
||||
emptyMessage="Aucune maladie ne corresond à ces symptômes"
|
||||
render={render}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue