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
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
const center = () => new Springy.Vector(
|
||||
window.innerWidth / 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 = {
|
||||
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 d’arêtes contient une arête donnée, dans un sens ou
|
||||
* dans l’autre.
|
||||
* Recherche l’ensemble des maladies liées par une relation « a pour symptôme »
|
||||
* avec un ensemble de termes donné.
|
||||
*
|
||||
* @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.
|
||||
* @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 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 = {};
|
||||
|
||||
// Si l’un 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 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 ReactDOM from 'react-dom';
|
||||
import App from './App.js';
|
||||
import App from './components/App.js';
|
||||
|
||||
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-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
|
||||
*/
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue