diff --git a/README.md b/README.md index 7e2bfcf..83d2167 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,7 @@ Liste des signes, symptômes et maladies : -https://wikimedi.ca/wiki/Concept:Signes_et_sympt%C3%B4mes + Export RDF : -https://wikimedi.ca/wiki/Sp%C3%A9cial:Export_RDF/ - -Visu : - -* https://js.cytoscape.org/ -* https://www.npmjs.com/package/react-graph-vis -* http://sigmajs.org/ -* http://getspringy.com/ -* https://github.com/danielcaldas/react-d3-graph + diff --git a/app/App.js b/app/App.js index 2fe009d..0521762 100644 --- a/app/App.js +++ b/app/App.js @@ -1,5 +1,17 @@ -import React from 'react'; +import React, {useState} from 'react'; +import TermInput from './TermInput.js'; +import ResultsGraph from './ResultsGraph.js'; -const App = () =>
Je suis rechargé !
; +const App = () => +{ + const [terms, setTerms] = useState([]); + + return ( +
+ + +
+ ); +}; export default App; diff --git a/app/Graph.js b/app/Graph.js new file mode 100644 index 0000000..2c55ece --- /dev/null +++ b/app/Graph.js @@ -0,0 +1,219 @@ +import React, {useState, useRef, useEffect} from 'react'; +import Springy from 'springy'; + +/** + * Crée un identifiant unique pour l’arête identifiée par ses deux extrémités. + * + * @param from Premier nœud de l’arête. + * @param to Second nœud de l’arête. + * @return Identifiant unique pour les deux directions de l’arête. + */ +const getEdgeId = (from, to) => +{ + if (to < from) + { + return getEdgeId(to, from); + } + + return `${from}-${to}`; +}; + +/** + * Vérifie si une liste d’arêtes contient une arête donnée, dans un sens ou + * dans l’autre. + * + * @param list Liste d’arêtes. + * @param from Premier nœud de l’arête. + * @param to Second nœud de l’arête. + * @return Vrai si et seulement si l’arête existe. + */ +const includesEdge = (list, from, to) => + list.some(([source, target]) => ( + (source === from && target === to) + || (source === to && target === from) + )); + +/** + * Affiche un graphe. + * + * @prop nodes Liste des identifiants de nœuds du graphe. Chaque nœud doit + * avoir un identifiant unique ne contenant pas de tiret ('-'). + * @prop edges Couples d’identifiants de nœuds formant les arêtes du graphe. + * @prop render Fonction de rendu prenant en paramètre l’identifiant d’un nœud + * du graphe et renvoyant un élément à afficher pour le représenter. + */ +const Graph = ({nodes, edges, render}) => +{ + const [graph,] = useState(new Springy.Graph()); + const [layout,] = useState(new Springy.Layout.ForceDirected( + graph, + /* rigidité = */ 400, + /* répulsion = */ 400, + /* amortissement = */ 0.5 + )); + + // N’arrête jamais l’animation + layout.minEnergyThreshold = 0; + + const graphParent = useRef(null); + + // Ajout des nouveaux nœuds + for (let node of nodes) + { + if (!(node in graph.nodeSet)) + { + console.info(`Ajout du nouveau nœud ${node}`); + graph.addNode(new Springy.Node(node)); + } + } + + // Retrait des anciens nœuds et de leurs arêtes adjacentes + graph.filterNodes(node => + { + if (!nodes.includes(node.id)) + { + console.info(`Retrait de l’ancien nœud ${node.id}`); + return false; + } + + return true; + }); + + // Ajout des nouvelles arêtes + for (let [from, to] of edges) + { + const edgeId = getEdgeId(from, to); + + if (graph.edges.every(edge => edge.id !== edgeId)) + { + console.info(`Ajout de la nouvelle arête ${edgeId}`); + graph.addEdge(new Springy.Edge(edgeId, {id: from}, {id: to})); + } + } + + // Retrait des anciennes arêtes + graph.filterEdges(({source: {id: from}, target: {id: to}}) => + { + if (!includesEdge(edges, from, to)) + { + console.info(`Retrait de l’ancienne arête ${from}-${to}`); + return false; + } + + return true; + }); + + useEffect(() => + { + const center = () => new Springy.Vector( + window.innerWidth / 2, + window.innerHeight / 2 + ); + + const scale = 50; + const coordsToScreen = vec => vec.multiply(scale).add(center()); + const screenToCoords = vec => vec.subtract(center()).divide(scale); + + layout.start(() => + { + layout.eachNode(({id}, {p}) => + { + const element = graphParent.current.querySelector( + `[data-node-id="${id.replace('"', '\\"')}"]` + ); + + const {x, y} = coordsToScreen(p); + + element.style.transform = `translate( + calc(${x}px - 50%), + calc(${y}px - 50%) + )`; + }); + + layout.eachEdge(({id}, {point1: {p: p1}, point2: {p: p2}}) => + { + const element = graphParent.current.querySelector( + `[data-edge-id="${id}"]` + ); + + const {x: x1, y: y1} = coordsToScreen(p1); + const {x: x2, y: y2} = coordsToScreen(p2); + + element.setAttribute('x1', x1); + element.setAttribute('y1', y1); + element.setAttribute('x2', x2); + element.setAttribute('y2', y2); + }); + }, () => { + console.info(`Fin du rendu du graphe`); + }, () => { + console.info(`Démarrage du rendu du graphe`); + }); + + let dragging = null; + + const mouseDown = ev => + { + const {clientX: x, clientY: y} = ev; + const screen = new Springy.Vector(x, y); + const position = screenToCoords(screen); + const nearest = layout.nearest(position); + + if (nearest.distance <= scale / 50) + { + dragging = nearest; + dragging.point.m = 1000; + } + }; + + const mouseMove = ev => + { + if (dragging !== null) + { + const {clientX: x, clientY: y} = ev; + const screen = new Springy.Vector(x, y); + dragging.point.p = screenToCoords(screen); + } + }; + + const mouseUp = ev => + { + if (dragging !== null) + { + dragging.point.m = 1; + dragging = null; + } + }; + + graphParent.current.addEventListener('mousedown', mouseDown); + document.body.addEventListener('mousemove', mouseMove); + document.body.addEventListener('mouseup', mouseUp); + + return () => + { + layout.stop(); + graphParent.current.removeEventListener('mousedown', mouseDown); + document.body.removeEventListener('mousemove', mouseMove); + document.body.removeEventListener('mouseup', mouseUp); + }; + }); + + return ( +
+ + {edges.map(edge => ( + + ))} + + + {nodes.map(id => ( + {render(id)} + ))} +
+ ); +}; + +export default Graph; diff --git a/app/ResultsGraph.js b/app/ResultsGraph.js new file mode 100644 index 0000000..3845d12 --- /dev/null +++ b/app/ResultsGraph.js @@ -0,0 +1,176 @@ +import React from 'react'; +import Graph from './Graph.js'; + +// const config = { +// automaticRearrangeAfterDropNode: true, +// node: { +// color: 'lightgreen', +// size: 240, +// fontSize: 14, +// highlightStrokeColor: 'blue', +// labelProperty: 'name' +// }, +// link: { +// highlightColor: 'lightblue' +// } +// }; + +const nodes = { + 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 edges = [ + ['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'] +]; + +const ResultsGraph = () => ( + nodes[id].name} + /> +); + +export default ResultsGraph; diff --git a/app/TermInput.js b/app/TermInput.js new file mode 100644 index 0000000..397667f --- /dev/null +++ b/app/TermInput.js @@ -0,0 +1,53 @@ +import React, {useState} from 'react'; + +const enterKey = 13; + +const TermInput = ({terms, setTerms}) => +{ + const [value, setValue] = useState(''); + + const handleKeyDown = ev => + { + if (ev.keyCode === enterKey) + { + ev.preventDefault(); + + if (!terms.includes(value)) + { + setTerms(terms.concat([value])); + } + + setValue(''); + } + }; + + const handleChange = ev => + { + setValue(ev.target.value); + }; + + const handleRemove = removedTerm => + { + console.log(removedTerm); + console.log(terms); + setTerms(terms.filter(term => term !== removedTerm)); + }; + + return ( +
+ {terms.map(term => + {term} + )} + +
+ ); +} + +export default TermInput; diff --git a/app/index.html b/app/index.html index 96f32de..a5720c5 100644 --- a/app/index.html +++ b/app/index.html @@ -2,7 +2,10 @@ - Bonjour + Recherche de maladies par symptômes + + +
diff --git a/app/style/font/SourceSansPro-It.woff b/app/style/font/SourceSansPro-It.woff new file mode 100644 index 0000000..4d29fd6 Binary files /dev/null and b/app/style/font/SourceSansPro-It.woff differ diff --git a/app/style/font/SourceSansPro-It.woff2 b/app/style/font/SourceSansPro-It.woff2 new file mode 100644 index 0000000..f71eee7 Binary files /dev/null and b/app/style/font/SourceSansPro-It.woff2 differ diff --git a/app/style/font/SourceSansPro-Regular.woff b/app/style/font/SourceSansPro-Regular.woff new file mode 100644 index 0000000..0027c8f Binary files /dev/null and b/app/style/font/SourceSansPro-Regular.woff differ diff --git a/app/style/font/SourceSansPro-Regular.woff2 b/app/style/font/SourceSansPro-Regular.woff2 new file mode 100644 index 0000000..2dee49f Binary files /dev/null and b/app/style/font/SourceSansPro-Regular.woff2 differ diff --git a/app/style/font/SourceSansPro-Semibold.woff b/app/style/font/SourceSansPro-Semibold.woff new file mode 100644 index 0000000..2963d0e Binary files /dev/null and b/app/style/font/SourceSansPro-Semibold.woff differ diff --git a/app/style/font/SourceSansPro-Semibold.woff2 b/app/style/font/SourceSansPro-Semibold.woff2 new file mode 100644 index 0000000..0451728 Binary files /dev/null and b/app/style/font/SourceSansPro-Semibold.woff2 differ diff --git a/app/style/font/SourceSansPro.css b/app/style/font/SourceSansPro.css new file mode 100644 index 0000000..ec300b5 --- /dev/null +++ b/app/style/font/SourceSansPro.css @@ -0,0 +1,27 @@ +@font-face { + font-family: 'Source Sans Pro'; + src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'), + url('SourceSansPro-Semibold.woff2') format('woff2'), + url('SourceSansPro-Semibold.woff') format('woff'); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: 'Source Sans Pro'; + src: local('Source Sans Pro Italic'), local('SourceSansPro-It'), + url('SourceSansPro-It.woff2') format('woff2'), + url('SourceSansPro-It.woff') format('woff'); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: 'Source Sans Pro'; + src: local('Source Sans Pro'), local('SourceSansPro-Regular'), + url('SourceSansPro-Regular.woff2') format('woff2'), + url('SourceSansPro-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; +} + diff --git a/app/style/style.css b/app/style/style.css new file mode 100644 index 0000000..d41761b --- /dev/null +++ b/app/style/style.css @@ -0,0 +1,166 @@ +:root +{ + --color-accent: #D80C49; + --color-secondary: #AE1246; + --color-light: #EEEEEE; + --color-dark: #1D1D1D; + + --font-family: 'Source Sans Pro'; + --font-size: 18px; + --font-color: var(--color-dark); + + --base-size: 25px; + + --animation-ease-out: cubic-bezier(0.075, 0.82, 0.165, 1); + --animation-short: .3s; +} + +body, html +{ + padding: 0; + margin: 0; + outline: 0; + height: 100%; + + background-color: var(--color-light); + + font-family: var(--font-family); + font-size: var(--font-size); + line-height: var(--base-size); + color: var(--font-color); +} + +body, html, #root +{ + width: 100%; + height: 100%; +} + +input +{ + font-family: var(--font-family); + font-size: var(--font-size); + + padding: 0 calc(.6 * var(--base-size)); + height: calc(2 * var(--base-size)); +} + +*, *::before, *::after +{ + box-sizing: border-box; +} + +/** + * App + */ + +.App +{ + width: 100%; + height: 100%; +} + +.App .TermInput +{ + position: absolute; + z-index: 2; + + width: 600px; + + top: calc(2 * var(--base-size)); + left: 50%; + transform: translateX(-50%); +} + +/** + * TermInput + */ + +.TermInput +{ + display: flex; + align-items: center; + + border: 2px solid var(--color-dark); + border-radius: 2px; + background: white; + + transition: box-shadow var(--animation-short) var(--animation-ease-out); +} + +.TermInput:focus-within +{ + box-shadow: + -1px -1px 0px var(--color-dark), + 1px -1px 0px var(--color-dark), + 1px 1px 0px var(--color-dark), + -1px 1px 0px var(--color-dark); +} + +.TermInput_term +{ + display: inline-block; + background: var(--color-secondary); + color: var(--color-light); + + border-radius: 2px; + padding: calc(.2 * var(--base-size)); + margin-left: calc(.3 * var(--base-size)); + cursor: pointer; + + transition: background var(--animation-short) var(--animation-ease-out); +} + +.TermInput_term:hover +{ + background: var(--color-accent); +} + +.TermInput_input +{ + flex: 1; + border: none; +} + +/** + * Graph + */ + +.Graph +{ + position: relative; + display: block; + width: 100%; + height: 100%; + + overflow: hidden; + user-select: none; +} + +.Graph_edgesContainer +{ + position: absolute; + z-index: 0; + + top: 0; + left 0; + width: 100%; + height: 100%; +} + +.Graph_edgesContainer line +{ + stroke: var(--color-dark); +} + +.Graph_node +{ + position: absolute; + z-index: 1; + + display: block; + transform: translate(-50%, -50%); + + background: white; + padding: 4px 8px; +} diff --git a/package-lock.json b/package-lock.json index 5bf3e4c..993e674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6561,6 +6561,11 @@ "extend-shallow": "^3.0.0" } }, + "springy": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/springy/-/springy-2.8.0.tgz", + "integrity": "sha512-PXtwxjww53H/8c+ng3zxMJwNRN/KPv6DKA8ITHi6lW57gfzty0wV/N8ZeTmgfO0ElfQip8W/1iVZk1d5ne4L+g==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", diff --git a/package.json b/package.json index 983dfec..54f5fd4 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "react": "^16.12.0", - "react-dom": "^16.12.0" + "react-dom": "^16.12.0", + "springy": "^2.8.0" } }