Merge branch 'master' of gitlab.com:matteodelabre/wikimedica-disease-search

This commit is contained in:
Rémi Cérès 2019-11-27 03:24:16 -05:00
commit 67405c9004
16 changed files with 668 additions and 14 deletions

View File

@ -1,15 +1,7 @@
Liste des signes, symptômes et maladies :
https://wikimedi.ca/wiki/Concept:Signes_et_sympt%C3%B4mes
<https://wikimedi.ca/wiki/Concept:Signes_et_sympt%C3%B4mes>
Export RDF :
https://wikimedi.ca/wiki/Sp%C3%A9cial:Export_RDF/<NOM_DE_LA_PAGE>
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
<https://wikimedi.ca/wiki/Sp%C3%A9cial:Export_RDF/NOM_DE_LA_PAGE>

View File

@ -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 = () => <div>Je suis rechargé!</div>;
const App = () =>
{
const [terms, setTerms] = useState([]);
return (
<div className="App">
<TermInput terms={terms} setTerms={setTerms} />
<ResultsGraph />
</div>
);
};
export default App;

219
app/Graph.js Normal file
View File

@ -0,0 +1,219 @@
import React, {useState, useRef, useEffect} from 'react';
import Springy from 'springy';
/**
* Crée un identifiant unique pour larête identifiée par ses deux extrémités.
*
* @param from Premier nœud de larête.
* @param to Second nœud de larête.
* @return Identifiant unique pour les deux directions de larête.
*/
const getEdgeId = (from, to) =>
{
if (to < from)
{
return getEdgeId(to, from);
}
return `${from}-${to}`;
};
/**
* Vérifie si une liste darêtes contient une arête donnée, dans un sens ou
* dans lautre.
*
* @param list Liste darêtes.
* @param from Premier nœud de larête.
* @param to Second nœud de larête.
* @return Vrai si et seulement si larête existe.
*/
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 didentifiants de nœuds formant les arêtes du graphe.
* @prop render Fonction de rendu prenant en paramètre lidentifiant dun 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
));
// Narrête jamais lanimation
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 lancien 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 lancienne 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 (
<div ref={graphParent} className="Graph">
<svg className="Graph_edgesContainer">
{edges.map(edge => (
<line data-edge-id={getEdgeId(...edge)} />
))}
</svg>
{nodes.map(id => (
<span
className="Graph_node"
data-node-id={id}
>{render(id)}</span>
))}
</div>
);
};
export default Graph;

176
app/ResultsGraph.js Normal file
View File

@ -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 = () => (
<Graph
nodes={Object.keys(nodes)}
edges={edges}
render={id => nodes[id].name}
/>
);
export default ResultsGraph;

53
app/TermInput.js Normal file
View File

@ -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 (
<div className="TermInput">
{terms.map(term =>
<span
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;

View File

@ -2,7 +2,10 @@
<html>
<head>
<meta charset="utf-8">
<title>Bonjour</title>
<title>Recherche de maladies par symptômes</title>
<link rel="stylesheet" href="style/font/SourceSansPro.css">
<link rel="stylesheet" href="style/style.css">
</head>
<body>
<div id="root"></div>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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;
}

166
app/style/style.css Normal file
View File

@ -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;
}

5
package-lock.json generated
View File

@ -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",

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"react": "^16.12.0",
"react-dom": "^16.12.0"
"react-dom": "^16.12.0",
"springy": "^2.8.0"
}
}