mirror of
https://github.com/gauvainboiche/ketapk.git
synced 2026-09-02 11:13:11 +02:00
fix: Docker deployment
This commit is contained in:
+5
-5
@@ -4,13 +4,13 @@ FROM node:20-alpine
|
|||||||
# Définition du répertoire de travail
|
# Définition du répertoire de travail
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copie des manifests de dépendances en attribuant la propriété à l'utilisateur 'node'
|
# Copie des manifests de dépendances avec la bonne propriété utilisateur
|
||||||
COPY --chown=node:node backend/package*.json ./backend/
|
COPY --chown=node:node backend/package*.json ./backend/
|
||||||
|
|
||||||
# Installation propre des dépendances de production
|
# Installation des dépendances (npm install fonctionne même sans package-lock.json)
|
||||||
RUN cd backend && npm ci --omit=dev
|
RUN cd backend && npm install --omit=dev
|
||||||
|
|
||||||
# Copie de l'ensemble du projet avec la bonne propriété utilisateur
|
# Copie de l'ensemble du projet
|
||||||
COPY --chown=node:node . .
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
# Passage à l'utilisateur non-privilégié 'node'
|
# Passage à l'utilisateur non-privilégié 'node'
|
||||||
@@ -20,7 +20,7 @@ USER node
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|
||||||
# Exposition du port (3000 est > 1024, donc autorisé sans privilèges root)
|
# Exposition du port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Démarrage du serveur Node.js
|
# Démarrage du serveur Node.js
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "ketapk-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Serveur Backend et API pour KétaPK Web",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
keta-pk:
|
keta-pk:
|
||||||
build: .
|
build: .
|
||||||
|
|||||||
+27
-25
@@ -1,10 +1,11 @@
|
|||||||
import { MODES, PK_MODELS, CLINICAL_THRESHOLDS, generateSimulationData } from '../../pkCore.js';
|
import { MODES, PK_MODELS, CLINICAL_THRESHOLDS, generateSimulationData } from '../../pkCore.js';
|
||||||
|
import { adjustChartYScale } from './chartManager.js';
|
||||||
|
import { initCalculatorUI } from './calculator.js';
|
||||||
import { generatePDFReport } from './pdfExport.js';
|
import { generatePDFReport } from './pdfExport.js';
|
||||||
|
|
||||||
let currentMode = MODES.ESKETAMINE;
|
let currentMode = MODES.ESKETAMINE;
|
||||||
let pkChart = null;
|
let pkChart = null;
|
||||||
|
|
||||||
// Initialisation de la Grille Saisie (61 intervalles de 5 min)
|
|
||||||
const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
|
const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
|
||||||
time: i * 5,
|
time: i * 5,
|
||||||
bolusMg: 0,
|
bolusMg: 0,
|
||||||
@@ -14,6 +15,7 @@ const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initChart();
|
initChart();
|
||||||
renderTimelineGrid();
|
renderTimelineGrid();
|
||||||
|
initCalculatorUI(); // Initialise la modale et les événements du calculateur
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
updateSimulation();
|
updateSimulation();
|
||||||
});
|
});
|
||||||
@@ -35,15 +37,16 @@ function initChart() {
|
|||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
max: CLINICAL_THRESHOLDS[currentMode].maxScale,
|
max: CLINICAL_THRESHOLDS[currentMode].maxScale,
|
||||||
title: { display: true, text: 'Cp (ng/mL)', color: '#94a3b8' },
|
title: { display: true, text: 'Cp (ng/mL)', color: '#94a3b8' },
|
||||||
grid: { color: 'rgba(51, 65, 85, 0.3)' }
|
grid: { color: 'rgba(51, 65, 85, 0.4)' }
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
title: { display: true, text: 'Temps (minutes)', color: '#94a3b8' },
|
title: { display: true, text: 'Temps (minutes)', color: '#94a3b8' },
|
||||||
grid: { color: 'rgba(51, 65, 85, 0.3)' }
|
grid: { color: 'rgba(51, 65, 85, 0.4)' }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { labels: { color: '#f8fafc' } }
|
legend: { labels: { color: '#f8fafc' } },
|
||||||
|
annotation: { annotations: {} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -51,7 +54,6 @@ function initChart() {
|
|||||||
|
|
||||||
function updateSimulation() {
|
function updateSimulation() {
|
||||||
const weight = parseFloat(document.getElementById('inputWeight').value) || 70;
|
const weight = parseFloat(document.getElementById('inputWeight').value) || 70;
|
||||||
|
|
||||||
const datasets = [];
|
const datasets = [];
|
||||||
|
|
||||||
if (document.getElementById('chkDomino').checked) {
|
if (document.getElementById('chkDomino').checked) {
|
||||||
@@ -85,7 +87,10 @@ function updateSimulation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pkChart.data.datasets = datasets;
|
pkChart.data.datasets = datasets;
|
||||||
pkChart.options.scales.y.max = CLINICAL_THRESHOLDS[currentMode].maxScale;
|
|
||||||
|
// Ajustement dynamique de l'échelle Y à 110 % si besoin
|
||||||
|
adjustChartYScale(pkChart, currentMode, datasets);
|
||||||
|
|
||||||
pkChart.update();
|
pkChart.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +100,16 @@ function renderTimelineGrid() {
|
|||||||
|
|
||||||
timelineInputs.forEach((item, idx) => {
|
timelineInputs.forEach((item, idx) => {
|
||||||
const col = document.createElement('div');
|
const col = document.createElement('div');
|
||||||
col.className = 'flex-1 bg-slate-900 border border-slate-700/60 rounded-xl p-2 text-center text-xs flex flex-col gap-1.5';
|
col.className = 'flex-1 bg-slate-900 border border-slate-700/60 rounded-xl p-2 text-center text-xs flex flex-col gap-1.5 min-w-[85px]';
|
||||||
col.innerHTML = `
|
col.innerHTML = `
|
||||||
<div class="font-bold text-amber-400 border-b border-slate-800 pb-1">${item.time} min</div>
|
<div class="font-bold text-amber-400 border-b border-slate-800 pb-1">${item.time} min</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-[10px] text-slate-500 block">Bolus (mg)</span>
|
<span class="text-[10px] text-slate-400 block mb-0.5">Bolus (mg)</span>
|
||||||
<input type="number" data-idx="${idx}" data-field="bolusMg" value="${item.bolusMg || ''}" placeholder="0" class="w-full bg-slate-800 border border-slate-700 text-center rounded text-white py-1 outline-none">
|
<input type="number" data-idx="${idx}" data-field="bolusMg" value="${item.bolusMg || ''}" placeholder="0" class="w-full bg-slate-800 border border-slate-700 text-center rounded text-white py-1 outline-none focus:border-amber-500">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-[10px] text-slate-500 block">Perf (mg/kg/h)</span>
|
<span class="text-[10px] text-slate-400 block mb-0.5">Perf (mg/kg/h)</span>
|
||||||
<input type="number" data-idx="${idx}" data-field="perfRateMgKgH" value="${item.perfRateMgKgH || ''}" placeholder="0" class="w-full bg-slate-800 border border-slate-700 text-center rounded text-white py-1 outline-none">
|
<input type="number" data-idx="${idx}" data-field="perfRateMgKgH" value="${item.perfRateMgKgH || ''}" placeholder="0" class="w-full bg-slate-800 border border-slate-700 text-center rounded text-white py-1 outline-none focus:border-amber-500">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(col);
|
container.appendChild(col);
|
||||||
@@ -135,16 +140,13 @@ function setupEventListeners() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btnExportPdf').addEventListener('click', () => {
|
document.getElementById('btnExportPdf').addEventListener('click', () => {
|
||||||
// On rassemble les données actuelles du formulaire
|
const patientData = {
|
||||||
const patientData = {
|
weight: document.getElementById('inputWeight').value || 70,
|
||||||
weight: document.getElementById('inputWeight').value || 70,
|
age: document.getElementById('inputAge').value || 50,
|
||||||
age: document.getElementById('inputAge').value || 50,
|
height: document.getElementById('inputHeight').value || 170
|
||||||
height: document.getElementById('inputHeight').value || 170
|
};
|
||||||
};
|
generatePDFReport(pkChart, patientData, currentMode);
|
||||||
|
});
|
||||||
// On lance l'exportation (pkChart et currentMode sont des variables globales de app.js)
|
|
||||||
generatePDFReport(pkChart, patientData, currentMode);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMode(mode) {
|
function setMode(mode) {
|
||||||
@@ -154,12 +156,12 @@ function setMode(mode) {
|
|||||||
const concInput = document.getElementById('inputConc');
|
const concInput = document.getElementById('inputConc');
|
||||||
|
|
||||||
if (mode === MODES.ESKETAMINE) {
|
if (mode === MODES.ESKETAMINE) {
|
||||||
btnEsk.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold bg-amber-500 text-slate-950 shadow';
|
btnEsk.className = 'px-4 py-1.5 rounded-lg text-xs font-bold bg-amber-500 text-slate-950 shadow-md shadow-amber-500/30';
|
||||||
btnRac.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold text-slate-300 hover:text-white';
|
btnRac.className = 'px-4 py-1.5 rounded-lg text-xs font-bold text-slate-400 hover:text-slate-200';
|
||||||
concInput.value = CLINICAL_THRESHOLDS[MODES.ESKETAMINE].defaultConcentration;
|
concInput.value = CLINICAL_THRESHOLDS[MODES.ESKETAMINE].defaultConcentration;
|
||||||
} else {
|
} else {
|
||||||
btnRac.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold bg-fuchsia-500 text-slate-950 shadow';
|
btnRac.className = 'px-4 py-1.5 rounded-lg text-xs font-bold bg-fuchsia-500 text-slate-950 shadow-md shadow-fuchsia-500/30';
|
||||||
btnEsk.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold text-slate-300 hover:text-white';
|
btnEsk.className = 'px-4 py-1.5 rounded-lg text-xs font-bold text-slate-400 hover:text-slate-200';
|
||||||
concInput.value = CLINICAL_THRESHOLDS[MODES.RACEMIQUE].defaultConcentration;
|
concInput.value = CLINICAL_THRESHOLDS[MODES.RACEMIQUE].defaultConcentration;
|
||||||
}
|
}
|
||||||
updateSimulation();
|
updateSimulation();
|
||||||
|
|||||||
+45
-18
@@ -1,38 +1,65 @@
|
|||||||
import { CalculatorEngine } from '../pkCore.js';
|
import { CalculatorEngine } from '../../pkCore.js';
|
||||||
|
|
||||||
export function initCalculatorUI() {
|
export function initCalculatorUI() {
|
||||||
const weightInput = document.getElementById('calcWeight');
|
const modal = document.getElementById('modalCalc');
|
||||||
const concInput = document.getElementById('calcConc');
|
const btnOpen = document.getElementById('btnOpenCalc');
|
||||||
|
const btnClose = document.getElementById('btnCloseCalc');
|
||||||
|
|
||||||
// Conversion Bolus
|
if (!modal || !btnOpen || !btnClose) return;
|
||||||
|
|
||||||
|
// 1. Ouverture de la modale & synchronisation avec le formulaire principal
|
||||||
|
btnOpen.addEventListener('click', () => {
|
||||||
|
const mainWeight = document.getElementById('inputWeight')?.value || 70;
|
||||||
|
const mainConc = document.getElementById('inputConc')?.value || 5;
|
||||||
|
|
||||||
|
document.getElementById('calcWeight').value = mainWeight;
|
||||||
|
document.getElementById('calcConc').value = mainConc;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Fermeture via le bouton X
|
||||||
|
btnClose.addEventListener('click', () => {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Fermeture au clic sur le fond sombre
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Logique de conversion en temps réel
|
||||||
|
const calcWeightInput = document.getElementById('calcWeight');
|
||||||
const mgInput = document.getElementById('calcMg');
|
const mgInput = document.getElementById('calcMg');
|
||||||
const mgKgInput = document.getElementById('calcMgKg');
|
const mgKgInput = document.getElementById('calcMgKg');
|
||||||
|
|
||||||
// Conversion Perfusion
|
|
||||||
const mgKgHInput = document.getElementById('calcMgKgH');
|
const mgKgHInput = document.getElementById('calcMgKgH');
|
||||||
const ugKgMinInput = document.getElementById('calcUgKgMin');
|
const ugKgMinInput = document.getElementById('calcUgKgMin');
|
||||||
|
|
||||||
// 1. Bolus mg <-> mg/kg
|
const getWeight = () => parseFloat(calcWeightInput.value) || 70;
|
||||||
|
|
||||||
|
// Bolus : mg -> mg/kg
|
||||||
mgInput.addEventListener('input', () => {
|
mgInput.addEventListener('input', () => {
|
||||||
const w = parseFloat(weightInput.value) || 70;
|
const mg = parseFloat(mgInput.value);
|
||||||
const mg = parseFloat(mgInput.value) || 0;
|
mgKgInput.value = isNaN(mg) ? '' : (mg / getWeight()).toFixed(2);
|
||||||
mgKgInput.value = CalculatorEngine.mgToMgKg(mg, w).toFixed(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bolus : mg/kg -> mg
|
||||||
mgKgInput.addEventListener('input', () => {
|
mgKgInput.addEventListener('input', () => {
|
||||||
const w = parseFloat(weightInput.value) || 70;
|
const mgKg = parseFloat(mgKgInput.value);
|
||||||
const mgKg = parseFloat(mgKgInput.value) || 0;
|
mgInput.value = isNaN(mgKg) ? '' : (mgKg * getWeight()).toFixed(1);
|
||||||
mgInput.value = CalculatorEngine.mgKgToMg(mgKg, w).toFixed(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Vitesse mg/kg/h <-> µg/kg/min
|
// Perfusion : mg/kg/h -> µg/kg/min
|
||||||
mgKgHInput.addEventListener('input', () => {
|
mgKgHInput.addEventListener('input', () => {
|
||||||
const mgKgH = parseFloat(mgKgHInput.value) || 0;
|
const mgKgH = parseFloat(mgKgHInput.value);
|
||||||
ugKgMinInput.value = CalculatorEngine.mgKgHToMicrogKgMin(mgKgH).toFixed(1);
|
ugKgMinInput.value = isNaN(mgKgH) ? '' : CalculatorEngine.mgKgHToMicrogKgMin(mgKgH).toFixed(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Perfusion : µg/kg/min -> mg/kg/h
|
||||||
ugKgMinInput.addEventListener('input', () => {
|
ugKgMinInput.addEventListener('input', () => {
|
||||||
const ugKgMin = parseFloat(ugKgMinInput.value) || 0;
|
const ug = parseFloat(ugKgMinInput.value);
|
||||||
mgKgHInput.value = CalculatorEngine.microgKgMinToMgKgH(ugKgMin).toFixed(2);
|
mgKgHInput.value = isNaN(ug) ? '' : CalculatorEngine.microgKgMinToMgKgH(ug).toFixed(2);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,58 @@
|
|||||||
import { CLINICAL_THRESHOLDS } from '../pkCore.js';
|
import { CLINICAL_THRESHOLDS } from '../../pkCore.js';
|
||||||
|
|
||||||
export function createChartAnnotations(mode) {
|
/**
|
||||||
|
* Recalcule et applique les zones de couleur en fonction du mode et du Y max actuel
|
||||||
|
*/
|
||||||
|
export function updateChartAnnotations(chart, mode, currentYMax) {
|
||||||
const thresholds = CLINICAL_THRESHOLDS[mode];
|
const thresholds = CLINICAL_THRESHOLDS[mode];
|
||||||
const annotations = {};
|
const annotations = {};
|
||||||
|
|
||||||
thresholds.zones.forEach((zone, index) => {
|
thresholds.zones.forEach((zone, index) => {
|
||||||
// Évite d'étendre la dernière zone à l'infini sur le graphe
|
if (zone.min < currentYMax) {
|
||||||
const yMax = zone.max === Infinity ? thresholds.maxScale : zone.max;
|
const zoneMax = zone.max === Infinity ? currentYMax : zone.max;
|
||||||
|
|
||||||
if (zone.min < thresholds.maxScale) {
|
|
||||||
annotations[`zone_${index}`] = {
|
annotations[`zone_${index}`] = {
|
||||||
type: 'box',
|
type: 'box',
|
||||||
yMin: zone.min,
|
yMin: zone.min,
|
||||||
yMax: Math.min(yMax, thresholds.maxScale),
|
yMax: Math.min(zoneMax, currentYMax),
|
||||||
backgroundColor: zone.color,
|
backgroundColor: zone.color,
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
label: {
|
label: {
|
||||||
display: true,
|
display: true,
|
||||||
content: zone.name,
|
content: zone.name,
|
||||||
color: zone.textHex,
|
color: zone.textHex,
|
||||||
font: { size: 14, weight: 'bold' },
|
font: { size: 11, weight: 'bold' },
|
||||||
position: 'center'
|
position: 'center'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return annotations;
|
chart.options.plugins.annotation.annotations = annotations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajuste l'échelle Y de manière dynamique :
|
||||||
|
* - Échelle par défaut (500 ou 1000)
|
||||||
|
* - Si un pic dépasse cette valeur, l'axe monte à 110 % du pic max.
|
||||||
|
*/
|
||||||
|
export function adjustChartYScale(chart, mode, datasets) {
|
||||||
|
const defaultMax = CLINICAL_THRESHOLDS[mode].maxScale;
|
||||||
|
let highestValue = 0;
|
||||||
|
|
||||||
|
// Trouve la valeur la plus haute parmi toutes les courbes actives
|
||||||
|
datasets.forEach(ds => {
|
||||||
|
ds.data.forEach(val => {
|
||||||
|
if (val > highestValue) highestValue = val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcul du Y max : 110 % de la valeur max si dépassement, sinon valeur par défaut
|
||||||
|
let targetYMax = defaultMax;
|
||||||
|
if (highestValue > defaultMax) {
|
||||||
|
targetYMax = Math.ceil(highestValue * 1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.options.scales.y.max = targetYMax;
|
||||||
|
updateChartAnnotations(chart, mode, targetYMax);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user