app: Ajout/retrait termes par clic
This commit is contained in:
parent
b0f00c68a0
commit
99a2d487f3
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 d’un 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 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 (
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,21 +27,6 @@ const getEdgeId = (from, to) =>
|
||||||
return JSON.stringify([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
|
* Recherche parmi les descendants d’un élément celui qui représente un
|
||||||
* nœud donnée, s’il existe.
|
* nœud donnée, s’il 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 d’identifiants de nœuds formant les arêtes 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
|
* @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.
|
* 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 [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 l’animation du graphe
|
// Rendu de l’animation 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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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]);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import * as mock from './data/mock.js';
|
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é.
|
* avec un ensemble de termes donné.
|
||||||
*
|
*
|
||||||
* @param query Ensemble de termes attendus.
|
* @param query Ensemble de termes attendus.
|
||||||
|
@ -9,12 +9,15 @@ 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 l’ensemble des éléments de la
|
// 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
|
// requête qu’il a pour symptôme au travers d’une relation directe ou
|
||||||
// transitive
|
// transitive
|
||||||
|
@ -51,21 +54,26 @@ export const diseasesBySymptoms = async query =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seuls les termes ayant été visités par tous les éléments de la requête
|
// Seuls les termes ayant été visités par tous les éléments de la
|
||||||
// et qui sont des maladies constituent des résultats valides
|
// requête sont des résultats valides
|
||||||
return Object.entries(matchingSymptoms).filter(
|
allMatches = Object.entries(matchingSymptoms).filter(
|
||||||
([id, matches]) => (
|
([, matches]) => matches.length === query.length
|
||||||
matches.length === query.length
|
|
||||||
&& mock.terms[id].types.includes(mock.types.disease)
|
|
||||||
)
|
|
||||||
).map(
|
).map(
|
||||||
([id]) => mock.terms[id]
|
([id]) => 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
|
* 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]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue