diff --git a/Dockerfile b/Dockerfile
index 7aaf2e7..e4cbb70 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,13 +4,13 @@ FROM node:20-alpine
# Définition du répertoire de travail
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/
-# Installation propre des dépendances de production
-RUN cd backend && npm ci --omit=dev
+# Installation des dépendances (npm install fonctionne même sans package-lock.json)
+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 . .
# Passage à l'utilisateur non-privilégié 'node'
@@ -20,7 +20,7 @@ USER node
ENV NODE_ENV=production
ENV PORT=3000
-# Exposition du port (3000 est > 1024, donc autorisé sans privilèges root)
+# Exposition du port
EXPOSE 3000
# Démarrage du serveur Node.js
diff --git a/backend/package.json b/backend/package.json
index e69de29..28715a3 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index a909065..606c830 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
keta-pk:
build: .
diff --git a/frontend/js/app.js b/frontend/js/app.js
index 534adb3..5b129c1 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -1,10 +1,11 @@
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';
let currentMode = MODES.ESKETAMINE;
let pkChart = null;
-// Initialisation de la Grille Saisie (61 intervalles de 5 min)
const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
time: i * 5,
bolusMg: 0,
@@ -14,13 +15,14 @@ const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
document.addEventListener('DOMContentLoaded', () => {
initChart();
renderTimelineGrid();
+ initCalculatorUI(); // Initialise la modale et les événements du calculateur
setupEventListeners();
updateSimulation();
});
function initChart() {
const ctx = document.getElementById('pkChart').getContext('2d');
-
+
pkChart = new Chart(ctx, {
type: 'line',
data: {
@@ -35,15 +37,16 @@ function initChart() {
beginAtZero: true,
max: CLINICAL_THRESHOLDS[currentMode].maxScale,
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: {
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: {
- legend: { labels: { color: '#f8fafc' } }
+ legend: { labels: { color: '#f8fafc' } },
+ annotation: { annotations: {} }
}
}
});
@@ -51,7 +54,6 @@ function initChart() {
function updateSimulation() {
const weight = parseFloat(document.getElementById('inputWeight').value) || 70;
-
const datasets = [];
if (document.getElementById('chkDomino').checked) {
@@ -85,7 +87,10 @@ function updateSimulation() {
}
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();
}
@@ -95,16 +100,16 @@ function renderTimelineGrid() {
timelineInputs.forEach((item, idx) => {
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 = `
${item.time} min
- Bolus (mg)
-
+ Bolus (mg)
+
- Perf (mg/kg/h)
-
+ Perf (mg/kg/h)
+
`;
container.appendChild(col);
@@ -135,16 +140,13 @@ function setupEventListeners() {
});
document.getElementById('btnExportPdf').addEventListener('click', () => {
- // On rassemble les données actuelles du formulaire
- const patientData = {
- weight: document.getElementById('inputWeight').value || 70,
- age: document.getElementById('inputAge').value || 50,
- height: document.getElementById('inputHeight').value || 170
- };
-
- // On lance l'exportation (pkChart et currentMode sont des variables globales de app.js)
- generatePDFReport(pkChart, patientData, currentMode);
-});
+ const patientData = {
+ weight: document.getElementById('inputWeight').value || 70,
+ age: document.getElementById('inputAge').value || 50,
+ height: document.getElementById('inputHeight').value || 170
+ };
+ generatePDFReport(pkChart, patientData, currentMode);
+ });
}
function setMode(mode) {
@@ -154,12 +156,12 @@ function setMode(mode) {
const concInput = document.getElementById('inputConc');
if (mode === MODES.ESKETAMINE) {
- btnEsk.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold bg-amber-500 text-slate-950 shadow';
- btnRac.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 bg-amber-500 text-slate-950 shadow-md shadow-amber-500/30';
+ 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;
} else {
- btnRac.className = 'px-4 py-1.5 rounded-lg text-sm font-semibold bg-fuchsia-500 text-slate-950 shadow';
- btnEsk.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 bg-fuchsia-500 text-slate-950 shadow-md shadow-fuchsia-500/30';
+ 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;
}
updateSimulation();
diff --git a/frontend/js/calculator.js b/frontend/js/calculator.js
index c170291..0fdf275 100644
--- a/frontend/js/calculator.js
+++ b/frontend/js/calculator.js
@@ -1,38 +1,65 @@
-import { CalculatorEngine } from '../pkCore.js';
+import { CalculatorEngine } from '../../pkCore.js';
export function initCalculatorUI() {
- const weightInput = document.getElementById('calcWeight');
- const concInput = document.getElementById('calcConc');
+ const modal = document.getElementById('modalCalc');
+ 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 mgKgInput = document.getElementById('calcMgKg');
-
- // Conversion Perfusion
const mgKgHInput = document.getElementById('calcMgKgH');
const ugKgMinInput = document.getElementById('calcUgKgMin');
- // 1. Bolus mg <-> mg/kg
+ const getWeight = () => parseFloat(calcWeightInput.value) || 70;
+
+ // Bolus : mg -> mg/kg
mgInput.addEventListener('input', () => {
- const w = parseFloat(weightInput.value) || 70;
- const mg = parseFloat(mgInput.value) || 0;
- mgKgInput.value = CalculatorEngine.mgToMgKg(mg, w).toFixed(2);
+ const mg = parseFloat(mgInput.value);
+ mgKgInput.value = isNaN(mg) ? '' : (mg / getWeight()).toFixed(2);
});
+ // Bolus : mg/kg -> mg
mgKgInput.addEventListener('input', () => {
- const w = parseFloat(weightInput.value) || 70;
- const mgKg = parseFloat(mgKgInput.value) || 0;
- mgInput.value = CalculatorEngine.mgKgToMg(mgKg, w).toFixed(1);
+ const mgKg = parseFloat(mgKgInput.value);
+ mgInput.value = isNaN(mgKg) ? '' : (mgKg * getWeight()).toFixed(1);
});
- // 2. Vitesse mg/kg/h <-> µg/kg/min
+ // Perfusion : mg/kg/h -> µg/kg/min
mgKgHInput.addEventListener('input', () => {
- const mgKgH = parseFloat(mgKgHInput.value) || 0;
- ugKgMinInput.value = CalculatorEngine.mgKgHToMicrogKgMin(mgKgH).toFixed(1);
+ const mgKgH = parseFloat(mgKgHInput.value);
+ ugKgMinInput.value = isNaN(mgKgH) ? '' : CalculatorEngine.mgKgHToMicrogKgMin(mgKgH).toFixed(1);
});
+ // Perfusion : µg/kg/min -> mg/kg/h
ugKgMinInput.addEventListener('input', () => {
- const ugKgMin = parseFloat(ugKgMinInput.value) || 0;
- mgKgHInput.value = CalculatorEngine.microgKgMinToMgKgH(ugKgMin).toFixed(2);
+ const ug = parseFloat(ugKgMinInput.value);
+ mgKgHInput.value = isNaN(ug) ? '' : CalculatorEngine.microgKgMinToMgKgH(ug).toFixed(2);
});
}
\ No newline at end of file
diff --git a/frontend/js/chartManager.js b/frontend/js/chartManager.js
index 26f5c6f..88da0c7 100644
--- a/frontend/js/chartManager.js
+++ b/frontend/js/chartManager.js
@@ -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 annotations = {};
thresholds.zones.forEach((zone, index) => {
- // Évite d'étendre la dernière zone à l'infini sur le graphe
- const yMax = zone.max === Infinity ? thresholds.maxScale : zone.max;
+ if (zone.min < currentYMax) {
+ const zoneMax = zone.max === Infinity ? currentYMax : zone.max;
- if (zone.min < thresholds.maxScale) {
annotations[`zone_${index}`] = {
type: 'box',
yMin: zone.min,
- yMax: Math.min(yMax, thresholds.maxScale),
+ yMax: Math.min(zoneMax, currentYMax),
backgroundColor: zone.color,
borderWidth: 0,
label: {
display: true,
content: zone.name,
color: zone.textHex,
- font: { size: 14, weight: 'bold' },
+ font: { size: 11, weight: 'bold' },
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);
}
\ No newline at end of file