Merge branch 'master' of gitlab.com:matteodelabre/wikimedica-disease-search
This commit is contained in:
commit
67405c9004
12
README.md
12
README.md
|
@ -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>
|
||||
|
|
16
app/App.js
16
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 = () => <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;
|
||||
|
|
|
@ -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 (
|
||||
<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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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.
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0"
|
||||
"react-dom": "^16.12.0",
|
||||
"springy": "^2.8.0"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue