From 99a2d487f3c67b16e6fd5140b74a2d146483c91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matt=C3=A9o=20Delabre?= Date: Tue, 3 Dec 2019 23:20:06 -0500 Subject: [PATCH] app: Ajout/retrait termes par clic --- app/src/components/App.js | 8 +- app/src/components/DiseaseGraph.js | 39 ++++++++- app/src/components/Graph.js | 129 ++++++++++++++++++++--------- app/src/components/TermInput.js | 11 ++- app/src/data/mock.js | 4 +- app/src/fetch.js | 92 ++++++++++---------- 6 files changed, 191 insertions(+), 92 deletions(-) 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]); }; /**