diff --git a/.gitignore b/.gitignore index 1c24555..440822a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,15 @@ # Origin project KetaPK_FR_1_00/ KetaPK_FR_1_00* +code_vba.txt + +# Python-generated files +__pycache__/ +*.py[oc] +*.egg-info + +# Virtual environments +.venv # Virtual environments .venv diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..e69de29 diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..0c92d0d --- /dev/null +++ b/backend/server.js @@ -0,0 +1,45 @@ +const express = require('express'); +const cors = require('cors'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +// Service des fichiers statiques (Frontend) +app.use(express.static(path.join(__dirname, '../frontend'))); + +// Base de données temporaire en mémoire pour les sauvegardes +const savedSimulations = []; + +// API : Enregistrer une simulation +app.post('/api/simulations', (req, res) => { + const { patient, mode, inputs, results, timestamp } = req.body; + + if (!inputs || !results) { + return res.status(400).json({ error: 'Données de simulation incomplètes' }); + } + + const newRecord = { + id: Date.now(), + timestamp: timestamp || new Date().toISOString(), + patient, + mode, + inputs, + results + }; + + savedSimulations.push(newRecord); + res.status(201).json({ message: 'Simulation sauvegardée avec succès', id: newRecord.id }); +}); + +// API : Récupérer toutes les sauvegardes +app.get('/api/simulations', (req, res) => { + res.json(savedSimulations); +}); + +app.listen(PORT, () => { + console.log(`Serveur KétaPK démarré sur http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/frontend/css/styles.css b/frontend/css/styles.css new file mode 100644 index 0000000..7556b5f --- /dev/null +++ b/frontend/css/styles.css @@ -0,0 +1,69 @@ +@charset "UTF-8"; + +/* ========================================================================== + KétaPK Web - Custom Styles & Adjustments + ========================================================================== */ + +/* 1. Suppression des flèches/spinners par défaut sur les inputs de type number */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="number"] { + -moz-appearance: textfield; +} + +/* 2. Personnalisation de la barre de défilement pour la grille temporelle */ +#timelineGrid::-webkit-scrollbar { + height: 8px; +} + +#timelineGrid::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.6); + border-radius: 9999px; +} + +#timelineGrid::-webkit-scrollbar-thumb { + background: rgba(51, 65, 85, 0.8); + border-radius: 9999px; + border: 2px solid rgba(15, 23, 42, 0.6); +} + +#timelineGrid::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 1); +} + +/* 3. Styles d'animation douces pour les fenêtres modales */ +#modalCalc { + transition: opacity 0.2s ease-in-out; +} + +#modalCalc.hidden { + opacity: 0; + pointer-events: none; +} + +#modalCalc:not(.hidden) { + opacity: 1; + pointer-events: auto; +} + +/* 4. Effets au survol des cellules de la timeline */ +#timelineGrid > div { + min-width: 80px; + transition: border-color 0.15s ease, transform 0.15s ease; +} + +#timelineGrid > div:focus-within { + border-color: rgba(245, 158, 11, 0.8); + box-shadow: 0 0 10px rgba(245, 158, 11, 0.15); +} + +/* 5. Optimisations Responsive (écrans verticaux et smartphones) */ +@media (max-width: 640px) { + #pkChart { + min-height: 280px; + } +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..987e9e3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,229 @@ + + + + + + KétaPK - Simulation Pharmacocinétique + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ KétaPK +
+
+ Pharmaco Kinetics + v1.00 • Dr Georges Mion +
+
+ + +
+ + +
+ + +
+ + + + + +
+ +
+
+ + +
+ + + + + +
+ + +
+
+ +
+
+ + +
+
+

+ ⏱️ Saisie Chronologique Doses & Perfusion (0 - 300 min) +

+ Défilement horizontal 👉 +
+ +
+ +
+
+ +
+ +
+ + + + + + + + \ No newline at end of file diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..534adb3 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,166 @@ +import { MODES, PK_MODELS, CLINICAL_THRESHOLDS, generateSimulationData } from '../../pkCore.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, + perfRateMgKgH: 0 +})); + +document.addEventListener('DOMContentLoaded', () => { + initChart(); + renderTimelineGrid(); + setupEventListeners(); + updateSimulation(); +}); + +function initChart() { + const ctx = document.getElementById('pkChart').getContext('2d'); + + pkChart = new Chart(ctx, { + type: 'line', + data: { + labels: timelineInputs.map(d => `${d.time}'`), + datasets: [] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + max: CLINICAL_THRESHOLDS[currentMode].maxScale, + title: { display: true, text: 'Cp (ng/mL)', color: '#94a3b8' }, + grid: { color: 'rgba(51, 65, 85, 0.3)' } + }, + x: { + title: { display: true, text: 'Temps (minutes)', color: '#94a3b8' }, + grid: { color: 'rgba(51, 65, 85, 0.3)' } + } + }, + plugins: { + legend: { labels: { color: '#f8fafc' } } + } + } + }); +} + +function updateSimulation() { + const weight = parseFloat(document.getElementById('inputWeight').value) || 70; + + const datasets = []; + + if (document.getElementById('chkDomino').checked) { + datasets.push({ + label: PK_MODELS.DOMINO.name, + data: generateSimulationData({ model: PK_MODELS.DOMINO, weightKg: weight, timelineInputs }), + borderColor: PK_MODELS.DOMINO.color, + borderWidth: 3, + tension: 0.2 + }); + } + + if (document.getElementById('chkClements').checked) { + datasets.push({ + label: PK_MODELS.CLEMENTS.name, + data: generateSimulationData({ model: PK_MODELS.CLEMENTS, weightKg: weight, timelineInputs }), + borderColor: PK_MODELS.CLEMENTS.color, + borderWidth: 3, + tension: 0.2 + }); + } + + if (document.getElementById('chkKamp').checked) { + datasets.push({ + label: PK_MODELS.KAMP.name, + data: generateSimulationData({ model: PK_MODELS.KAMP, weightKg: weight, timelineInputs }), + borderColor: PK_MODELS.KAMP.color, + borderWidth: 3, + tension: 0.2 + }); + } + + pkChart.data.datasets = datasets; + pkChart.options.scales.y.max = CLINICAL_THRESHOLDS[currentMode].maxScale; + pkChart.update(); +} + +function renderTimelineGrid() { + const container = document.getElementById('timelineGrid'); + container.innerHTML = ''; + + 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.innerHTML = ` +
${item.time} min
+
+ Bolus (mg) + +
+
+ Perf (mg/kg/h) + +
+ `; + container.appendChild(col); + }); +} + +function setupEventListeners() { + document.getElementById('timelineGrid').addEventListener('input', (e) => { + const idx = e.target.dataset.idx; + const field = e.target.dataset.field; + if (idx !== undefined && field) { + timelineInputs[idx][field] = parseFloat(e.target.value) || 0; + updateSimulation(); + } + }); + + document.getElementById('btnEsk').addEventListener('click', () => setMode(MODES.ESKETAMINE)); + document.getElementById('btnRac').addEventListener('click', () => setMode(MODES.RACEMIQUE)); + + ['inputWeight', 'chkDomino', 'chkClements', 'chkKamp'].forEach(id => { + document.getElementById(id).addEventListener('change', updateSimulation); + }); + + document.getElementById('btnRaz').addEventListener('click', () => { + timelineInputs.forEach(item => { item.bolusMg = 0; item.perfRateMgKgH = 0; }); + renderTimelineGrid(); + updateSimulation(); + }); + + 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); +}); +} + +function setMode(mode) { + currentMode = mode; + const btnEsk = document.getElementById('btnEsk'); + const btnRac = document.getElementById('btnRac'); + 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'; + 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'; + concInput.value = CLINICAL_THRESHOLDS[MODES.RACEMIQUE].defaultConcentration; + } + updateSimulation(); +} \ No newline at end of file diff --git a/frontend/js/calculator.js b/frontend/js/calculator.js new file mode 100644 index 0000000..c170291 --- /dev/null +++ b/frontend/js/calculator.js @@ -0,0 +1,38 @@ +import { CalculatorEngine } from '../pkCore.js'; + +export function initCalculatorUI() { + const weightInput = document.getElementById('calcWeight'); + const concInput = document.getElementById('calcConc'); + + // Conversion Bolus + 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 + mgInput.addEventListener('input', () => { + const w = parseFloat(weightInput.value) || 70; + const mg = parseFloat(mgInput.value) || 0; + mgKgInput.value = CalculatorEngine.mgToMgKg(mg, w).toFixed(2); + }); + + mgKgInput.addEventListener('input', () => { + const w = parseFloat(weightInput.value) || 70; + const mgKg = parseFloat(mgKgInput.value) || 0; + mgInput.value = CalculatorEngine.mgKgToMg(mgKg, w).toFixed(1); + }); + + // 2. Vitesse mg/kg/h <-> µg/kg/min + mgKgHInput.addEventListener('input', () => { + const mgKgH = parseFloat(mgKgHInput.value) || 0; + ugKgMinInput.value = CalculatorEngine.mgKgHToMicrogKgMin(mgKgH).toFixed(1); + }); + + ugKgMinInput.addEventListener('input', () => { + const ugKgMin = parseFloat(ugKgMinInput.value) || 0; + mgKgHInput.value = CalculatorEngine.microgKgMinToMgKgH(ugKgMin).toFixed(2); + }); +} \ No newline at end of file diff --git a/frontend/js/chartManager.js b/frontend/js/chartManager.js new file mode 100644 index 0000000..26f5c6f --- /dev/null +++ b/frontend/js/chartManager.js @@ -0,0 +1,30 @@ +import { CLINICAL_THRESHOLDS } from '../pkCore.js'; + +export function createChartAnnotations(mode) { + 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 < thresholds.maxScale) { + annotations[`zone_${index}`] = { + type: 'box', + yMin: zone.min, + yMax: Math.min(yMax, thresholds.maxScale), + backgroundColor: zone.color, + borderWidth: 0, + label: { + display: true, + content: zone.name, + color: zone.textHex, + font: { size: 14, weight: 'bold' }, + position: 'center' + } + }; + } + }); + + return annotations; +} \ No newline at end of file diff --git a/frontend/js/pdfExport.js b/frontend/js/pdfExport.js new file mode 100644 index 0000000..8f253bd --- /dev/null +++ b/frontend/js/pdfExport.js @@ -0,0 +1,52 @@ +/** + * Génère et télécharge un rapport PDF de la simulation. + * @param {Object} pkChart - L'instance Chart.js actuelle + * @param {Object} patient - Les infos du patient { weight, age, height } + * @param {String} mode - ESKETAMINE ou RACEMIQUE + */ +export function generatePDFReport(pkChart, patient, mode) { + // jsPDF est chargé globalement via le CDN dans index.html + const { jsPDF } = window.jspdf; + + // Format A4, orientation Paysage (landscape) idéal pour les graphiques + const doc = new jsPDF('landscape', 'mm', 'a4'); + + // --- 1. En-tête du document --- + doc.setFillColor(30, 41, 59); // Couleur bleue nuit (slate-800) + doc.rect(0, 0, 297, 25, 'F'); + + doc.setTextColor(255, 255, 255); + doc.setFontSize(18); + doc.setFont("helvetica", "bold"); + doc.text("KétaPK - Rapport de Simulation Pharmacocinétique", 14, 16); + + // --- 2. Informations de la simulation --- + doc.setTextColor(50, 50, 50); + doc.setFontSize(11); + doc.setFont("helvetica", "normal"); + + const dateStr = new Date().toLocaleString('fr-FR'); + + // Colonne de gauche (Patient) + doc.text(`Date de simulation : ${dateStr}`, 14, 35); + doc.setFont("helvetica", "bold"); + doc.text(`Mode sélectionné : ${mode}`, 14, 42); + doc.text(`Patient : Poids ${patient.weight} kg | Âge ${patient.age} ans | Taille ${patient.height} cm`, 14, 49); + + // --- 3. Capture et insertion du Graphique --- + // On récupère l'image du graphique sur fond blanc + const chartImage = pkChart.toBase64Image('image/jpeg', 1.0); + + // Placement : x=14mm, y=60mm, largeur=260mm, hauteur=110mm + doc.addImage(chartImage, 'JPEG', 14, 60, 260, 110); + + // --- 4. Pied de page --- + doc.setFont("helvetica", "italic"); + doc.setFontSize(9); + doc.setTextColor(150, 150, 150); + doc.text("Ce document est généré par KétaPK Web. Il est destiné uniquement à l'enseignement et ne doit pas être utilisé pour la conduite thérapeutique.", 14, 190); + + // --- 5. Téléchargement --- + const fileName = `KetaPK_Rapport_${patient.weight}kg_${mode}.pdf`; + doc.save(fileName); +} \ No newline at end of file diff --git a/pkCore.js b/pkCore.js new file mode 100644 index 0000000..95e48b9 --- /dev/null +++ b/pkCore.js @@ -0,0 +1,135 @@ +/** + * ============================================================================ + * KetaPK - Moteur Pharmacocinétique (PK/PD) + * Modèles : Domino (1982), Clements (1981), Kamp (2020) + * ============================================================================ + */ + +export const MODES = { + ESKETAMINE: 'ESKETAMINE', + RACEMIQUE: 'RACEMIQUE' +}; + +// 1. Modèles Pharmacocinétiques (Multi-exponentiels) +// Cp(t) = (Dose / Poids) * [ A * e^(-alpha * t) + B * e^(-beta * t) + C * e^(-gamma * t) ] +export const PK_MODELS = { + DOMINO: { + name: "Domino (1982)", + refDose: 2.0, // mg/kg + A: 14300, alpha: 1.35167, + B: 2340, beta: 0.09517, + C: 197, gamma: 0.00378, + color: '#ff9933' // Orange + }, + CLEMENTS: { + name: "Clements (1981)", + refDose: 0.25, // mg/kg + A: 108, alpha: 0.03940, + B: 39, beta: 0.00380, + C: 0, gamma: 0, + color: '#6ec46c' // Vert + }, + KAMP: { + name: "Kamp (2020)", + refDose: 0.5, // mg/kg + A: 1332, alpha: 0.24658, + B: 155, beta: 0.03845, + C: 69, gamma: 0.00409, + color: '#4fafe3' // Bleu + } +}; + +// 2. Seuils d'effets cliniques (ng/mL) +export const CLINICAL_THRESHOLDS = { + [MODES.ESKETAMINE]: { + maxScale: 500, + defaultConcentration: 5, // mg/mL + zones: [ + { name: "Inactif", min: 0, max: 10, color: "rgba(249, 252, 126, 0.4)", textHex: "#8a8d00" }, + { name: "Anti-Hyperalgésique", min: 10, max: 50, color: "rgba(218, 247, 166, 0.4)", textHex: "#417505" }, + { name: "Analgésie", min: 50, max: 150, color: "rgba(255, 195, 0, 0.4)", textHex: "#b78100" }, + { name: "Psychédélique", min: 150, max: 350, color: "rgba(242, 160, 223, 0.4)", textHex: "#a2137f" }, + { name: "Émergence", min: 350, max: 500, color: "rgba(89, 196, 240, 0.4)", textHex: "#0c6796" }, + { name: "Narcose", min: 500, max: 1000, color: "rgba(43, 122, 244, 0.4)", textHex: "#0a3c8a" }, + { name: "Excessif", min: 1000, max: Infinity, color: "rgba(255, 87, 51, 0.4)", textHex: "#a81300" } + ] + }, + [MODES.RACEMIQUE]: { + maxScale: 1000, + defaultConcentration: 10, // mg/mL + zones: [ + { name: "Inactif", min: 0, max: 20, color: "rgba(249, 252, 126, 0.4)", textHex: "#8a8d00" }, + { name: "Anti-Hyperalgésique", min: 20, max: 100, color: "rgba(218, 247, 166, 0.4)", textHex: "#417505" }, + { name: "Analgésie", min: 100, max: 300, color: "rgba(255, 195, 0, 0.4)", textHex: "#b78100" }, + { name: "Psychédélique", min: 300, max: 700, color: "rgba(242, 160, 223, 0.4)", textHex: "#a2137f" }, + { name: "Émergence", min: 700, max: 1000, color: "rgba(89, 196, 240, 0.4)", textHex: "#0c6796" }, + { name: "Narcose", min: 1000, max: 2000, color: "rgba(43, 122, 244, 0.4)", textHex: "#0a3c8a" }, + { name: "Excessif", min: 2000, max: Infinity, color: "rgba(255, 87, 51, 0.4)", textHex: "#a81300" } + ] + } +}; + +// 3. Équation Fondamentale du Calcul de la Concentration Plasmatique Cp(t) +/** + * Calcule la réponse impulsionnelle pour un bolus unique à un instant t. + * $C_p(t) = \frac{\text{dose}}{\text{poids}} \times \left( A \cdot e^{-\alpha t} + B \cdot e^{-\beta t} + C \cdot e^{-\gamma t} \right)$ + */ +export function calculateBolusCp(model, doseMg, weightKg, timeElapsedMin) { + if (timeElapsedMin < 0 || weightKg <= 0) return 0; + + const dosePerKg = doseMg / weightKg; + const { A, alpha, B, beta, C, gamma } = model; + + const termA = A * Math.exp(-alpha * timeElapsedMin); + const termB = B * Math.exp(-beta * timeElapsedMin); + const termC = C * (gamma ? Math.exp(-gamma * timeElapsedMin) : 0); + + return dosePerKg * (termA + termB + termC); +} + +/** + * Superposition Linéaire des effets (Bolus multiples + Perfusions) sur 300 minutes (pas de 5 min) + */ +export function generateSimulationData({ model, weightKg, timelineInputs }) { + // timelineInputs : tableau de 61 pas de temps (0 à 300 min par pas de 5 min) + // { time: 0..300, bolusMg: number, perfRateMgKgH: number } + + const timeGrid = Array.from({ length: 61 }, (_, i) => i * 5); + const cpResults = new Array(61).fill(0); + + timeGrid.forEach((targetTime, targetIndex) => { + let totalCp = 0; + + for (let i = 0; i <= targetIndex; i++) { + const inputTime = timeGrid[i]; + const deltaTime = targetTime - inputTime; + const input = timelineInputs[i] || { bolusMg: 0, perfRateMgKgH: 0 }; + + // Contribution du Bolus + if (input.bolusMg > 0) { + totalCp += calculateBolusCp(model, input.bolusMg, weightKg, deltaTime); + } + + // Contribution de la perfusion continue (discrétisée sur le pas de 5 min) + if (input.perfRateMgKgH > 0) { + // mg/kg/h converti en mg sur l'intervalle de 5 min (5/60 heure) + const perfDoseMg = (input.perfRateMgKgH * weightKg) * (5 / 60); + totalCp += calculateBolusCp(model, perfDoseMg, weightKg, deltaTime); + } + } + + cpResults[targetIndex] = Math.round(totalCp * 100) / 100; + }); + + return cpResults; +} + +// 4. Fonctions de Calculateur Médical (Conversions d'unités) +export const CalculatorEngine = { + mgToMgKg: (mg, weight) => (weight > 0 ? mg / weight : 0), + mgKgToMg: (mgKg, weight) => mgKg * weight, + mgKgHToMicrogKgMin: (mgKgH) => (mgKgH * 1000) / 60, + microgKgMinToMgKgH: (microg) => (microg * 60) / 1000, + mgToMl: (mg, concMgMl) => (concMgMl > 0 ? mg / concMgMl : 0), + mlToMg: (ml, concMgMl) => ml * concMgMl +}; \ No newline at end of file