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