app: Autocomplétion & réécriture moteur de recherche
This commit is contained in:
parent
6cf7de6426
commit
b0f00c68a0
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -153,7 +153,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(
|
const center = () => new Springy.Vector(
|
||||||
window.innerWidth / 2,
|
window.innerWidth / 2,
|
||||||
window.innerHeight / 2
|
window.innerHeight / 2
|
|
@ -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 l’utilisateur.
|
||||||
|
* @prop setTerms Fonction de rappel utilisée pour modifier l’ensemble des
|
||||||
|
* termes validés par l’utilisateur.
|
||||||
|
*/
|
||||||
|
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 à l’ensemble des termes sélectionnés.
|
||||||
|
*
|
||||||
|
* @param term Terme à ajouter.
|
||||||
|
*/
|
||||||
|
const addTerm = term =>
|
||||||
|
{
|
||||||
|
if (terms.some(({id}) => id === term.id))
|
||||||
|
{
|
||||||
|
// N’ajoute 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 l’appui sur une touche dans le champ de saisie.
|
||||||
|
*
|
||||||
|
* @param ev Informations sur l’événement d’appui.
|
||||||
|
*/
|
||||||
|
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 n’ont 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;
|
|
@ -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]);
|
330
app/src/fetch.js
330
app/src/fetch.js
|
@ -1,230 +1,138 @@
|
||||||
const mockNodes = {
|
import * as mock from './data/mock.js';
|
||||||
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']
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie si une liste d’arêtes contient une arête donnée, dans un sens ou
|
* Recherche l’ensemble des maladies liées par une relation « a pour symptôme »
|
||||||
* dans l’autre.
|
* avec un ensemble de termes donné.
|
||||||
*
|
*
|
||||||
* @param list Liste d’arêtes.
|
* @param query Ensemble de termes attendus.
|
||||||
* @param from Premier nœud de l’arête.
|
* @return Liste des maladies correspondantes.
|
||||||
* @param to Second nœud de l’arête.
|
|
||||||
* @return Vrai si et seulement si l’arête existe.
|
|
||||||
*/
|
*/
|
||||||
const includesEdge = (list, from, to) =>
|
export const diseasesBySymptoms = async query =>
|
||||||
list.some(([source, target]) => (
|
|
||||||
(source === from && target === to)
|
|
||||||
|| (source === to && target === from)
|
|
||||||
));
|
|
||||||
|
|
||||||
export const searchTerms = terms => new Promise((res, rej) =>
|
|
||||||
{
|
{
|
||||||
// Fait attendre artificiellement pour simuler une requête
|
// Si aucun terme dans la requête, toutes les maladies correspondent
|
||||||
setTimeout(() =>
|
if (!query.length)
|
||||||
{
|
{
|
||||||
const nodes = {};
|
return Object.values(mock.terms);
|
||||||
const edges = [];
|
}
|
||||||
|
|
||||||
// Récupération des identifiants correspondant aux termes de la requête
|
// Marqueurs indiquant pour chaque terme l’ensemble des éléments de la
|
||||||
const termIds = terms.map(term =>
|
// requête qu’il a pour symptôme au travers d’une relation directe ou
|
||||||
Object.keys(mockNodes)
|
// transitive
|
||||||
.find(id => mockNodes[id].name === term)
|
const matchingSymptoms = {};
|
||||||
);
|
|
||||||
|
|
||||||
// Si l’un des termes est inconnu, aucun résultat
|
// Réalise un parcours en profondeur du graphe en partant de chaque terme
|
||||||
if (!termIds.includes(undefined))
|
// 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
|
const current = stack.pop();
|
||||||
for (let termId of termIds)
|
const neighbors = mock.symptomOf.filter(
|
||||||
{
|
([from]) => from === current
|
||||||
nodes[termId] = mockNodes[termId];
|
).map(
|
||||||
}
|
([, to]) => to
|
||||||
|
);
|
||||||
|
|
||||||
// Sélection des nœuds liés à tous les éléments de la requête
|
for (let neighbor of neighbors)
|
||||||
const resultIds = [];
|
|
||||||
|
|
||||||
for (let [id, data] of Object.entries(mockNodes))
|
|
||||||
{
|
{
|
||||||
if (termIds.every(
|
if (!(neighbor in matchingSymptoms))
|
||||||
termId => includesEdge(mockEdges, id, termId)
|
|
||||||
))
|
|
||||||
{
|
{
|
||||||
nodes[id] = data;
|
matchingSymptoms[neighbor] = [];
|
||||||
resultIds.push(id);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Sélection des voisins des résultats de la requête
|
if (!matchingSymptoms[neighbor].includes(queryTerm.id))
|
||||||
for (let [id, data] of Object.entries(mockNodes))
|
|
||||||
{
|
|
||||||
for (let resultId of resultIds)
|
|
||||||
{
|
{
|
||||||
if (includesEdge(mockEdges, resultId, id))
|
matchingSymptoms[neighbor].push(queryTerm.id);
|
||||||
{
|
stack.push(neighbor);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res({nodes, edges});
|
// Seuls les termes ayant été visités par tous les éléments de la requête
|
||||||
}, 500);
|
// 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 d’un 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};
|
||||||
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import App from './App.js';
|
import App from './components/App.js';
|
||||||
|
|
||||||
ReactDOM.render(<App />, document.querySelector('#root'));
|
ReactDOM.render(<App />, document.querySelector('#root'));
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {useState, useEffect} from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook permettant d’obtenir des résultats asynchrones.
|
||||||
|
*
|
||||||
|
* @param initial Valeur initiale lorsque la promesse n’a 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;
|
||||||
|
};
|
|
@ -3,6 +3,7 @@
|
||||||
--color-accent: #D80C49;
|
--color-accent: #D80C49;
|
||||||
--color-secondary: #AE1246;
|
--color-secondary: #AE1246;
|
||||||
--color-light: #EEEEEE;
|
--color-light: #EEEEEE;
|
||||||
|
--color-light-darker: #E0E0E0;
|
||||||
--color-dark-lighter: #6A6A6A;
|
--color-dark-lighter: #6A6A6A;
|
||||||
--color-dark: #1D1D1D;
|
--color-dark: #1D1D1D;
|
||||||
|
|
||||||
|
@ -53,8 +54,9 @@ input
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
font-size: var(--font-size);
|
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));
|
height: calc(2 * var(--base-size));
|
||||||
|
line-height: calc(2 * var(--base-size));
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after
|
*, *::before, *::after
|
||||||
|
@ -137,6 +139,32 @@ input
|
||||||
border: none;
|
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
|
* Graph
|
||||||
*/
|
*/
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -2,6 +2,7 @@
|
||||||
"name": "wikimedica-disease-search",
|
"name": "wikimedica-disease-search",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "parcel app/index.html"
|
"dev": "parcel app/index.html"
|
||||||
|
@ -15,6 +16,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.7.4",
|
"@babel/runtime": "^7.7.4",
|
||||||
|
"eslint": "^6.7.2",
|
||||||
|
"eslint-plugin-react": "^7.17.0",
|
||||||
"react": "^16.12.0",
|
"react": "^16.12.0",
|
||||||
"react-dom": "^16.12.0",
|
"react-dom": "^16.12.0",
|
||||||
"react-transition-group": "^4.3.0",
|
"react-transition-group": "^4.3.0",
|
||||||
|
|
Loading…
Reference in New Issue