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