app: Autocomplétion & réécriture moteur de recherche

This commit is contained in:
Mattéo Delabre 2019-12-03 17:00:21 -05:00
parent 6cf7de6426
commit b0f00c68a0
Signed by: matteo
GPG Key ID: AE3FBD02DC583ABB
15 changed files with 1253 additions and 458 deletions

42
app/.eslintrc.json Normal file
View File

@ -0,0 +1,42 @@
{
"env": {
"browser": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
}

View File

@ -1,17 +0,0 @@
import React, {useState} from 'react';
import TermInput from './TermInput.js';
import SearchResults from './SearchResults.js';
const App = () =>
{
const [terms, setTerms] = useState([]);
return (
<div className="App">
<TermInput terms={terms} setTerms={setTerms} />
<SearchResults terms={terms} />
</div>
);
};
export default App;

View File

@ -1,41 +0,0 @@
import React, {useState, useEffect} from 'react';
import Graph from './Graph.js';
import {searchTerms} from './fetch.js';
const useResults = terms =>
{
const [results, setResults] = useState({
nodes: {},
edges: []
});
useEffect(() =>
{
const fetch = async () =>
{
setResults(await searchTerms(terms));
};
fetch();
}, [terms]);
return results;
};
const SearchResults = ({terms}) =>
{
const {nodes, edges} = useResults(terms);
return (
<Graph
nodes={Object.keys(nodes)}
edges={edges}
render={id => <span className={[
'SearchResults_result',
terms.includes(nodes[id].name) ? 'SearchResults_result-term' : '',
nodes[id].types.includes('Maladie') ? 'SearchResults_result-disease' : '',
].join(' ')}>{nodes[id].name}</span>}
/>
);
};
export default SearchResults;

View File

@ -1,63 +0,0 @@
import React, {useState} from 'react';
const enterKey = 13;
const backspaceKey = 8;
const TermInput = ({terms, setTerms}) =>
{
const [value, setValue] = useState('');
const handleKeyDown = ev =>
{
if (ev.keyCode === enterKey && value)
{
ev.preventDefault();
if (!terms.includes(value))
{
setTerms(terms.concat([value]));
}
setValue('');
return;
}
if (ev.keyCode === backspaceKey && !value)
{
ev.preventDefault();
setTerms(terms.slice(0, -1));
return;
}
};
const handleChange = ev =>
{
setValue(ev.target.value);
};
const handleRemove = removedTerm =>
{
console.log(removedTerm);
console.log(terms);
setTerms(terms.filter(term => term !== removedTerm));
};
return (
<div className="TermInput">
{terms.map(term =>
<span
key={term}
className="TermInput_term"
onClick={handleRemove.bind(null, term)}
>{term}</span>
)}
<input
autoFocus={true} type="text" className="TermInput_input"
placeholder="Rechercher un symptôme, un signe ou une maladie…"
value={value}
onChange={handleChange} onKeyDown={handleKeyDown} />
</div>
);
}
export default TermInput;

29
app/src/components/App.js Normal file
View File

@ -0,0 +1,29 @@
import React, {useState} from 'react';
import TermInput from './TermInput.js';
import DiseaseGraph from './DiseaseGraph.js';
import {useAsync} from '../util.js';
import {
diseasesBySymptoms,
exploreSymptoms
} from '../fetch.js';
const App = () =>
{
const [terms, setTerms] = useState([]);
const diseases = useAsync([], diseasesBySymptoms, terms);
const results = useAsync([], exploreSymptoms, diseases);
return (
<div className="App">
<TermInput
terms={terms}
availableTerms={results}
setTerms={setTerms}
/>
<DiseaseGraph terms={terms} results={results} />
</div>
);
};
export default App;

View File

@ -0,0 +1,37 @@
import React 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 {nodes, edges} = useAsync({
nodes: {},
edges: []
}, symptomsSubgraph, results);
const render = id =>
{
const isTerm = terms.some(({id: termId}) => termId === id);
const isDisease = nodes[id].types.includes(types.disease);
return (
<span className={[
'SearchResults_result',
isTerm ? 'SearchResults_result-term' : '',
isDisease ? 'SearchResults_result-disease' : '',
].join(' ')}>{nodes[id].name}</span>
);
};
return (
<Graph
nodes={Object.keys(nodes)}
edges={edges}
render={render}
/>
);
};
export default DiseaseGraph;

View File

@ -153,7 +153,6 @@ const Graph = ({nodes, edges, render}) =>
// Rendu de lanimation du graphe
useEffect(() =>
{
const center = () => new Springy.Vector(
window.innerWidth / 2,
window.innerHeight / 2

View File

@ -0,0 +1,193 @@
import React, {useState, useRef} from 'react';
import * as fetch from '../fetch.js';
/**
* Codes des touches du clavier par nom.
*/
const keys = Object.assign(Object.create(null), {
'backspace': 8,
'enter': 13,
'left': 37,
'up': 38,
'right': 39,
'down': 40,
});
/**
* Zone de saisie des termes de recherche.
*
* @prop terms Ensemble de termes déjà validés.
* @param availableTerms Termes pouvant être ajoutés par lutilisateur.
* @prop setTerms Fonction de rappel utilisée pour modifier lensemble des
* termes validés par lutilisateur.
*/
const TermInput = ({terms, availableTerms, setTerms}) =>
{
// Valeur actuellement saisie dans le champ de recherche de termes
const [value, setValue] = useState('');
// Liste des termes suggérés par autocomplétion
const [suggestions, setSuggestions] = useState([]);
// Terme ayant le focus parmi les suggestions
const [focusedSuggestion, setFocusedSuggestion] = useState(0);
// Référence au champ de saisie
const inputRef = useRef(null);
/**
* Ajoute un terme à lensemble des termes sélectionnés.
*
* @param term Terme à ajouter.
*/
const addTerm = term =>
{
if (terms.some(({id}) => id === term.id))
{
// Najoute pas les termes déjà sélectionnés
return;
}
setTerms(terms.concat([term]));
setValue('');
setSuggestions([]);
setFocusedSuggestion(0);
if (inputRef.current !== null)
{
inputRef.current.focus();
}
};
/**
* Supprime un terme des termes sélectionnés.
*
* @param index Indice du terme à supprimer dans la liste.
*/
const removeTerm = index =>
{
setTerms([
...terms.slice(0, index),
...terms.slice(index + 1)
]);
};
/**
* Gère lappui sur une touche dans le champ de saisie.
*
* @param ev Informations sur lévénement dappui.
*/
const handleInputKeyDown = ev =>
{
if (ev.keyCode === keys.enter && value)
{
// Touche Entrée: ajout de la suggestion ayant le focus
ev.preventDefault();
if (focusedSuggestion < suggestions.length)
{
addTerm(suggestions[focusedSuggestion]);
}
}
else if (
ev.keyCode === keys.backspace
&& !value
&& terms.length !== 0
)
{
// 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
ev.preventDefault();
setFocusedSuggestion(focusedSuggestion - 1);
}
else if (
ev.keyCode === keys.down
&& focusedSuggestion < suggestions.length - 1
)
{
// Touche Bas: focus de la suggestion suivante
ev.preventDefault();
setFocusedSuggestion(focusedSuggestion + 1);
}
};
/**
* Gère la modification du contenu du champ de saisie.
*
* @param ev Informations sur lévénement de modification.
*/
const handleInputChange = ev =>
{
const nextValue = ev.target.value;
setValue(nextValue);
if (nextValue.length === 0)
{
// On ne fait pas de suggestion si la valeur saisie est trop courte
setSuggestions([]);
}
else
{
setSuggestions(availableTerms
// Sélectionne les termes qui nont pas déjà été choisis
.filter(({id: suggestionId}) =>
!terms.some(({id: termId}) =>
termId === suggestionId
)
)
// Filtre ceux dont le nom correspond à la saisie
.filter(({name}) =>
name.toLowerCase().startsWith(nextValue.toLowerCase())
)
);
}
};
return (
<div className="TermInput">
{terms.map((term, index) =>
<span
key={term.id}
className="TermInput_term"
onClick={removeTerm.bind(null, index)}
>
{term.name}
</span>
)}
<input
autoFocus={true}
type="text" className="TermInput_input"
placeholder="Rechercher un symptôme, un signe ou une maladie…"
value={value} ref={inputRef}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown} />
<ul className="TermInput_suggestions">
{suggestions.map((suggestion, index) =>
<li
key={suggestion.id}
className={[
'TermInput_suggestion',
index === focusedSuggestion
? 'TermInput_suggestion-focus'
: ''
].join(' ')}
onMouseEnter={setFocusedSuggestion.bind(null, index)}
onClick={addTerm.bind(null, suggestion)}
>
{suggestion.name}
</li>
)}
</ul>
</div>
);
}
export default TermInput;

170
app/src/data/mock.js Normal file
View File

@ -0,0 +1,170 @@
/**
* Types de termes de la base.
*/
export const types = {
disease: 'Maladie',
symptom: 'Symptôme',
sign: 'Signe'
};
/**
* Liste des termes de la base de données, contenant des maladies, des signes
* et des symptômes.
*/
export const terms = {
Q2840: {
id: 'Q2840',
name: 'Grippe',
types: [types.disease],
weight: 0.000035
},
Q154882: {
id: 'Q154882',
name: 'Légionellose',
types: [types.disease],
weight: 0.000015
},
Q155098: {
id: 'Q155098',
name: 'Leptospirose',
types: [types.disease],
weight: 0.00001
},
Q326663: {
id: 'Q326663',
name: 'Encéphalite à tiques',
types: [types.disease],
weight: 0.000001
},
Q133780: {
id: 'Q133780',
name: 'Peste',
types: [types.disease],
weight: 0.000032
},
Q38933: {
id: 'Q38933',
name: 'Fièvre',
types: [types.symptom]
},
Q474959: {
id: 'Q474959',
name: 'Myalgie',
types: [types.symptom]
},
Q86: {
id: 'Q86',
name: 'Céphalée',
types: [types.sign]
},
Q1115038: {
id: 'Q1115038',
name: 'Rhinorrhée',
types: [types.symptom]
},
Q9690: {
id: 'Q9690',
name: 'Fatigue',
types: [types.symptom]
},
Q127076: {
id: 'Q127076',
name: 'Vomissement',
types: [types.symptom, types.sign]
},
Q178061: {
id: 'Q178061',
name: 'Choc circulatoire',
types: [types.disease],
weight: 0.000038
},
Q35805: {
id: 'Q35805',
name: 'Toux',
types: [types.symptom, types.sign]
},
Q647099: {
id: 'Q647099',
name: 'Hémoptysie',
types: [types.symptom]
},
Q653197: {
id: 'Q653197',
name: 'Rash',
types: [types.symptom, types.sign]
},
Q160796: {
id: 'Q160796',
name: 'Syndrome confusionnel',
types: [types.disease],
weight: 0.000004
},
Q186235: {
id: 'Q186235',
name: 'Myocardite',
types: [types.disease],
weight: 0.0000075
},
Q476921: {
id: 'Q476921',
name: 'Insuffisance rénale',
types: [types.disease],
weight: 0.0000046
},
Q281289: {
id: 'Q281289',
name: 'Photophobie',
types: [types.sign]
},
Q159557: {
id: 'Q159557',
name: 'Coma',
types: [types.sign]
}
};
/**
* Liste des relations de type «a pour symptôme» entre deux termes.
*/
export const hasSymptom = [
['Q2840', 'Q38933'],
['Q2840', 'Q1115038'],
['Q2840', 'Q474959'],
['Q2840', 'Q86'],
['Q2840', 'Q9690'],
['Q154882', 'Q86'],
['Q154882', 'Q38933'],
['Q154882', 'Q35805'],
['Q154882', 'Q474959'],
['Q155098', 'Q38933'],
['Q155098', 'Q474959'],
['Q155098', 'Q86'],
['Q155098', 'Q476921'],
['Q155098', 'Q186235'],
['Q155098', 'Q653197'],
['Q326663', 'Q86'],
['Q326663', 'Q474959'],
['Q326663', 'Q38933'],
['Q326663', 'Q9690'],
['Q326663', 'Q281289'],
['Q326663', 'Q159557'],
['Q326663', 'Q127076'],
['Q133780', 'Q38933'],
['Q133780', 'Q86'],
['Q133780', 'Q127076'],
['Q133780', 'Q474959'],
['Q133780', 'Q178061'],
['Q133780', 'Q35805'],
['Q133780', 'Q647099'],
['Q133780', 'Q653197'],
['Q133780', 'Q160796']
];
/**
* 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,230 +1,138 @@
const mockNodes = {
Q2840: {
id: 'Q2840',
name: 'Grippe',
types: ['Maladie'],
weight: 0.000035
},
Q154882: {
id: 'Q154882',
name: 'Légionellose',
types: ['Maladie'],
weight: 0.000015
},
Q155098: {
id: 'Q155098',
name: 'Leptospirose',
types: ['Maladie'],
weight: 0.00001
},
Q326663: {
id: 'Q326663',
name: 'Encéphalite à tiques',
types: ['Maladie'],
weight: 0.000001
},
Q133780: {
id: 'Q133780',
name: 'Peste',
types: ['Maladie'],
weight: 0.000032
},
Q38933: {
id: 'Q38933',
name: 'Fièvre',
types: ['Symptôme']
},
Q474959: {
id: 'Q474959',
name: 'Myalgie',
types: ['Symptôme']
},
Q86: {
id: 'Q86',
name: 'Céphalée',
types: ['Signe']
},
Q1115038: {
id: 'Q1115038',
name: 'Rhinorrhée',
types: ['Symptôme']
},
Q9690: {
id: 'Q9690',
name: 'Fatigue',
types: ['Symptôme']
},
Q127076: {
id: 'Q127076',
name: 'Vomissement',
types: ['Symptôme', 'Signe']
},
Q178061: {
id: 'Q178061',
name: 'Choc circulatoire',
types: ['Maladie'],
weight: 0.000038
},
Q35805: {
id: 'Q35805',
name: 'Toux',
types: ['Symptôme', 'Signe']
},
Q647099: {
id: 'Q647099',
name: 'Hémoptysie',
types: ['Symptôme']
},
Q653197: {
id: 'Q653197',
name: 'Rash',
types: ['Symptôme', 'Signe']
},
Q160796: {
id: 'Q160796',
name: 'Syndrome confusionnel',
types: ['Maladie'],
weight: 0.000004
},
Q186235: {
id: 'Q186235',
name: 'Myocardite',
types: ['Maladie'],
weight: 0.0000075
},
Q476921: {
id: 'Q476921',
name: 'Insuffisance rénale',
types: ['Maladie'],
weight: 0.0000046
},
Q281289: {
id: 'Q281289',
name: 'Photophobie',
types: ['Signe']
},
Q159557: {
id: 'Q159557',
name: 'Coma',
types: ['Signe']
}
};
const mockEdges = [
['Q2840', 'Q38933'],
['Q2840', 'Q1115038'],
['Q2840', 'Q474959'],
['Q2840', 'Q86'],
['Q2840', 'Q9690'],
['Q154882', 'Q86'],
['Q154882', 'Q38933'],
['Q154882', 'Q35805'],
['Q154882', 'Q474959'],
['Q155098', 'Q38933'],
['Q155098', 'Q474959'],
['Q155098', 'Q86'],
['Q155098', 'Q476921'],
['Q155098', 'Q186235'],
['Q155098', 'Q653197'],
['Q326663', 'Q86'],
['Q326663', 'Q474959'],
['Q326663', 'Q38933'],
['Q326663', 'Q9690'],
['Q326663', 'Q281289'],
['Q326663', 'Q159557'],
['Q326663', 'Q127076'],
['Q133780', 'Q38933'],
['Q133780', 'Q86'],
['Q133780', 'Q127076'],
['Q133780', 'Q474959'],
['Q133780', 'Q178061'],
['Q133780', 'Q35805'],
['Q133780', 'Q647099'],
['Q133780', 'Q653197'],
['Q133780', 'Q160796']
];
import * as mock from './data/mock.js';
/**
* Vérifie si une liste darêtes contient une arête donnée, dans un sens ou
* dans lautre.
* Recherche lensemble des maladies liées par une relation «a pour symptôme»
* avec un ensemble de termes donné.
*
* @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.
* @param query Ensemble de termes attendus.
* @return Liste des maladies correspondantes.
*/
const includesEdge = (list, from, to) =>
list.some(([source, target]) => (
(source === from && target === to)
|| (source === to && target === from)
));
export const searchTerms = terms => new Promise((res, rej) =>
export const diseasesBySymptoms = async query =>
{
// Fait attendre artificiellement pour simuler une requête
setTimeout(() =>
// Si aucun terme dans la requête, toutes les maladies correspondent
if (!query.length)
{
const nodes = {};
const edges = [];
return Object.values(mock.terms);
}
// Récupération des identifiants correspondant aux termes de la requête
const termIds = terms.map(term =>
Object.keys(mockNodes)
.find(id => mockNodes[id].name === term)
);
// 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 = {};
// Si lun des termes est inconnu, aucun résultat
if (!termIds.includes(undefined))
// 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];
while (stack.length)
{
// Sélection des termes de la requête
for (let termId of termIds)
{
nodes[termId] = mockNodes[termId];
}
const current = stack.pop();
const neighbors = mock.symptomOf.filter(
([from]) => from === current
).map(
([, to]) => to
);
// Sélection des nœuds liés à tous les éléments de la requête
const resultIds = [];
for (let [id, data] of Object.entries(mockNodes))
for (let neighbor of neighbors)
{
if (termIds.every(
termId => includesEdge(mockEdges, id, termId)
))
if (!(neighbor in matchingSymptoms))
{
nodes[id] = data;
resultIds.push(id);
matchingSymptoms[neighbor] = [];
}
}
// Sélection des voisins des résultats de la requête
for (let [id, data] of Object.entries(mockNodes))
{
for (let resultId of resultIds)
if (!matchingSymptoms[neighbor].includes(queryTerm.id))
{
if (includesEdge(mockEdges, resultId, id))
{
nodes[id] = data;
}
}
}
// Sélection des arêtes liant les nœuds sélectionnés
for (let [from, to] of mockEdges)
{
if (
(from in nodes && to in nodes)
&& !includesEdge(edges, from, to)
)
{
edges.push([from, to]);
matchingSymptoms[neighbor].push(queryTerm.id);
stack.push(neighbor);
}
}
}
}
res({nodes, edges});
}, 500);
});
// 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]
);
};
/**
* 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.
*/
export const exploreSymptoms = async terms =>
{
// Voisins des termes de la requête
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»
const stack = [...selected];
while (stack.length)
{
const current = stack.pop();
const neighbors = mock.hasSymptom.filter(
([from]) => from === current
).map(
([, to]) => to
);
for (let neighbor of neighbors)
{
if (!selected.includes(neighbor))
{
selected.push(neighbor);
stack.push(neighbor);
}
}
}
return selected.map(id => mock.terms[id]);;
};
/**
* Calcule le sous-graphe issu dun ensemble de termes.
*
* @param terms Ensemble de termes à mettre dans le sous-graphe.
* @return Objet contenant les nœuds et les arêtes du graphe.
*/
export const symptomsSubgraph = async terms =>
{
const termsIds = terms.map(({id}) => id);
// Construction du graphe constitué des éléments sélectionnés et des
// arêtes qui les lient
const nodes = {};
const edges = [];
for (let term of terms)
{
nodes[term.id] = term;
}
// Sélection des arêtes liant les nœuds sélectionnés
for (let [from, to] of mock.hasSymptom)
{
if (
termsIds.includes(from)
&& termsIds.includes(to)
)
{
edges.push([from, to]);
}
}
return {nodes, edges};
};

View File

@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
import App from './components/App.js';
ReactDOM.render(<App />, document.querySelector('#root'));

26
app/src/util.js Normal file
View File

@ -0,0 +1,26 @@
import {useState, useEffect} from 'react';
/**
* Hook permettant dobtenir des résultats asynchrones.
*
* @param initial Valeur initiale lorsque la promesse na pas répondu.
* @param func Fonction renvoyant une promesse.
* @param ...args Arguments à passer à `func` qui sont susceptibles de changer.
* @return Résultats de la promesse.
*/
export const useAsync = (initial, func, ...args) =>
{
const [results, setResults] = useState(initial);
useEffect(() =>
{
const fetcher = async () =>
{
setResults(await func(...args));
};
fetcher();
}, args);
return results;
};

View File

@ -3,6 +3,7 @@
--color-accent: #D80C49;
--color-secondary: #AE1246;
--color-light: #EEEEEE;
--color-light-darker: #E0E0E0;
--color-dark-lighter: #6A6A6A;
--color-dark: #1D1D1D;
@ -53,8 +54,9 @@ input
font-family: var(--font-family);
font-size: var(--font-size);
padding: 0 calc(.6 * var(--base-size));
padding: 0 calc(.5 * var(--base-size));
height: calc(2 * var(--base-size));
line-height: calc(2 * var(--base-size));
}
*, *::before, *::after
@ -137,6 +139,32 @@ input
border: none;
}
.TermInput_suggestions
{
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.TermInput_suggestion
{
height: calc(2 * var(--base-size));
padding: 0 calc(.5 * var(--base-size));
cursor: pointer;
line-height: calc(2 * var(--base-size));
transition: background var(--animation-short) var(--animation-ease-out);
}
.TermInput_suggestion-focus
{
background: var(--color-light-darker);
}
/**
* Graph
*/

727
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@
"name": "wikimedica-disease-search",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "index.js",
"scripts": {
"dev": "parcel app/index.html"
@ -15,6 +16,8 @@
},
"dependencies": {
"@babel/runtime": "^7.7.4",
"eslint": "^6.7.2",
"eslint-plugin-react": "^7.17.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-transition-group": "^4.3.0",