refacto: Adding i18n support with FR and EN as base

This commit is contained in:
gauvainboiche
2026-08-01 18:32:46 +02:00
parent 1cd6061d39
commit 683105fa3b
12 changed files with 290 additions and 200 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ketapk-backend", "name": "ketapk-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "Serveur Backend et API pour KétaPK Web", "description": "Serveur Backend pour KétaPK Web",
"main": "server.js", "main": "server.js",
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+3 -31
View File
@@ -7,41 +7,13 @@ const PORT = process.env.PORT || 3000;
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// Servir le fichier pkCore.js situé à la racine
// Service du moteur de calcul situé à la racine
app.use('/pkCore.js', express.static(path.join(__dirname, '../pkCore.js'))); app.use('/pkCore.js', express.static(path.join(__dirname, '../pkCore.js')));
// Servir les fichiers statiques du dossier frontend // Service des fichiers statiques frontend
app.use(express.static(path.join(__dirname, '../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, () => { app.listen(PORT, () => {
console.log(`Serveur KétaPK démarré sur http://localhost:${PORT}`); console.log(`Serveur KétaPK démarré sur http://localhost:${PORT}`);
}); });
-9
View File
@@ -1,10 +1,5 @@
@charset "UTF-8"; @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-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button { input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
@@ -15,7 +10,6 @@ input[type="number"] {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
/* 2. Personnalisation de la barre de défilement pour la grille temporelle */
#timelineGrid::-webkit-scrollbar { #timelineGrid::-webkit-scrollbar {
height: 8px; height: 8px;
} }
@@ -35,7 +29,6 @@ input[type="number"] {
background: rgba(100, 116, 139, 1); background: rgba(100, 116, 139, 1);
} }
/* 3. Styles d'animation douces pour les fenêtres modales */
#modalCalc { #modalCalc {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
@@ -50,7 +43,6 @@ input[type="number"] {
pointer-events: auto; pointer-events: auto;
} }
/* 4. Effets au survol des cellules de la timeline */
#timelineGrid > div { #timelineGrid > div {
min-width: 80px; min-width: 80px;
transition: border-color 0.15s ease, transform 0.15s ease; transition: border-color 0.15s ease, transform 0.15s ease;
@@ -61,7 +53,6 @@ input[type="number"] {
box-shadow: 0 0 10px rgba(245, 158, 11, 0.15); box-shadow: 0 0 10px rgba(245, 158, 11, 0.15);
} }
/* 5. Optimisations Responsive (écrans verticaux et smartphones) */
@media (max-width: 640px) { @media (max-width: 640px) {
#pkChart { #pkChart {
min-height: 280px; min-height: 280px;
+53 -52
View File
@@ -5,106 +5,114 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KétaPK - Simulation Pharmacocinétique</title> <title>KétaPK - Simulation Pharmacocinétique</title>
<!-- Tailwind CSS CDN --> <!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<!-- Chart.js + Plugin Annotation --> <!-- Chart.js + Plugins -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
<!-- jsPDF pour Exportation PDF --> <!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<!-- CSS Personnalisé --> <!-- Custom CSS -->
<link rel="stylesheet" href="css/styles.css"> <link rel="stylesheet" href="css/styles.css">
</head> </head>
<body class="h-full flex flex-col font-sans antialiased selection:bg-amber-500 selection:text-slate-950"> <body class="h-full flex flex-col font-sans antialiased selection:bg-amber-500 selection:text-slate-950">
<!-- ==================== HEADER / NAVBAR ==================== --> <!-- NAVBAR -->
<header class="bg-slate-800/90 backdrop-blur border-b border-slate-700/80 sticky top-0 z-40 shadow-lg"> <header class="bg-slate-800/90 backdrop-blur border-b border-slate-700/80 sticky top-0 z-40 shadow-lg">
<div class="max-w-7xl mx-auto px-4 py-3 flex flex-wrap items-center justify-between gap-4"> <div class="max-w-7xl mx-auto px-4 py-3 flex flex-wrap items-center justify-between gap-4">
<!-- Logo & Titre --> <!-- Logo -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-gradient-to-r from-amber-500 to-amber-600 text-slate-950 font-black px-3.5 py-1 rounded-xl text-xl tracking-wider shadow-md shadow-amber-500/20"> <div class="bg-gradient-to-r from-amber-500 to-amber-600 text-slate-950 font-black px-3.5 py-1 rounded-xl text-xl tracking-wider shadow-md shadow-amber-500/20">
KétaPK KétaPK
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span class="text-sm font-bold text-slate-200 leading-none">Pharmaco Kinetics</span> <span data-i18n="app.title" class="text-sm font-bold text-slate-200 leading-none">Pharmaco Kinetics</span>
<span class="text-[10px] text-slate-400 font-mono">v1.00 • Dr Georges Mion</span> <span data-i18n="app.version" class="text-[10px] text-slate-400 font-mono">v1.00 • Dr Georges Mion</span>
</div> </div>
</div> </div>
<!-- Selecteur de Mode (Eskétamine / Racémique) --> <!-- Mode Selector -->
<div class="flex items-center bg-slate-950/80 p-1 rounded-xl border border-slate-700/80 shadow-inner"> <div class="flex items-center bg-slate-950/80 p-1 rounded-xl border border-slate-700/80 shadow-inner">
<button id="btnEsk" type="button" class="px-4 py-1.5 rounded-lg text-xs font-bold transition-all bg-amber-500 text-slate-950 shadow-md shadow-amber-500/30"> <button id="btnEsk" type="button" data-i18n="nav.mode_esk" class="px-4 py-1.5 rounded-lg text-xs font-bold transition-all bg-amber-500 text-slate-950 shadow-md shadow-amber-500/30">
ESKETAMINE ESKETAMINE
</button> </button>
<button id="btnRac" type="button" class="px-4 py-1.5 rounded-lg text-xs font-bold transition-all text-slate-400 hover:text-slate-200"> <button id="btnRac" type="button" data-i18n="nav.mode_rac" class="px-4 py-1.5 rounded-lg text-xs font-bold transition-all text-slate-400 hover:text-slate-200">
RACÉMIQUE RACÉMIQUE
</button> </button>
</div> </div>
<!-- Actions Rapides --> <!-- Actions & Language -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex items-center gap-1 bg-slate-900 border border-slate-700/70 rounded-xl px-2 py-1">
<span class="text-xs">🌐</span>
<select id="selectLang" class="bg-transparent text-xs font-bold text-slate-300 outline-none cursor-pointer">
<option value="fr" class="bg-slate-900">FR</option>
<option value="en" class="bg-slate-900">EN</option>
</select>
</div>
<button id="btnOpenCalc" type="button" class="bg-slate-700/80 hover:bg-slate-700 text-slate-200 border border-slate-600/60 px-3 py-1.5 rounded-lg text-xs font-medium transition flex items-center gap-1.5 shadow-sm"> <button id="btnOpenCalc" type="button" class="bg-slate-700/80 hover:bg-slate-700 text-slate-200 border border-slate-600/60 px-3 py-1.5 rounded-lg text-xs font-medium transition flex items-center gap-1.5 shadow-sm">
<span>🧮</span> <span>🧮</span>
<span class="hidden sm:inline">Calculateur</span> <span data-i18n="nav.calculator" class="hidden sm:inline">Calculateur</span>
</button> </button>
<button id="btnExportPdf" type="button" class="bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-500/50 px-3 py-1.5 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-md shadow-indigo-600/20"> <button id="btnExportPdf" type="button" class="bg-indigo-600 hover:bg-indigo-500 text-white border border-indigo-500/50 px-3 py-1.5 rounded-lg text-xs font-semibold transition flex items-center gap-1.5 shadow-md shadow-indigo-600/20">
<span>📄</span> <span>📄</span>
<span class="hidden sm:inline">Exporter PDF</span> <span data-i18n="nav.export_pdf" class="hidden sm:inline">Exporter PDF</span>
</button> </button>
<button id="btnRaz" type="button" class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 px-3 py-1.5 rounded-lg text-xs font-medium transition flex items-center gap-1.5"> <button id="btnRaz" type="button" class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 px-3 py-1.5 rounded-lg text-xs font-medium transition flex items-center gap-1.5">
<span>🔄</span> <span>🔄</span>
<span class="hidden sm:inline">RAZ</span> <span data-i18n="nav.raz" class="hidden sm:inline">RAZ</span>
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<!-- ==================== CONTENU PRINCIPAL ==================== --> <!-- MAIN -->
<main class="flex-1 max-w-7xl w-full mx-auto p-4 grid grid-cols-1 lg:grid-cols-12 gap-6"> <main class="flex-1 max-w-7xl w-full mx-auto p-4 grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- PANNEAU LATÉRAL (3 colonnes lg) --> <!-- SIDEBAR -->
<aside class="lg:col-span-3 space-y-5"> <aside class="lg:col-span-3 space-y-5">
<!-- Carte : Données Patient --> <!-- Patient Form -->
<div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm"> <div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm">
<h2 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-4 border-b border-slate-700/60 pb-2 flex items-center gap-2"> <h2 data-i18n="patient.title" class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-4 border-b border-slate-700/60 pb-2 flex items-center gap-2">
<span>👤</span> Caractéristiques Patient <span>👤</span> Caractéristiques Patient
</h2> </h2>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label for="inputWeight" class="block text-xs text-slate-300 font-medium mb-1">Poids (kg)</label> <label for="inputWeight" data-i18n="patient.weight" class="block text-xs text-slate-300 font-medium mb-1">Poids (kg)</label>
<input type="number" id="inputWeight" value="70" min="1" max="250" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-white font-semibold focus:ring-2 focus:ring-amber-500 focus:border-amber-500 outline-none transition"> <input type="number" id="inputWeight" value="70" min="1" max="250" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-white font-semibold focus:ring-2 focus:ring-amber-500 outline-none">
</div> </div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div> <div>
<label for="inputAge" class="block text-xs text-slate-300 font-medium mb-1">Âge (ans)</label> <label for="inputAge" data-i18n="patient.age" class="block text-xs text-slate-300 font-medium mb-1">Âge (ans)</label>
<input type="number" id="inputAge" value="50" min="1" max="120" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-white font-semibold focus:ring-2 focus:ring-amber-500 outline-none transition"> <input type="number" id="inputAge" value="50" min="1" max="120" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-white font-semibold focus:ring-2 focus:ring-amber-500 outline-none">
</div> </div>
<div> <div>
<label for="inputHeight" class="block text-xs text-slate-300 font-medium mb-1">Taille (cm)</label> <label for="inputHeight" data-i18n="patient.height" class="block text-xs text-slate-300 font-medium mb-1">Taille (cm)</label>
<input type="number" id="inputHeight" value="170" min="40" max="230" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-white font-semibold focus:ring-2 focus:ring-amber-500 outline-none transition"> <input type="number" id="inputHeight" value="170" min="40" max="230" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-white font-semibold focus:ring-2 focus:ring-amber-500 outline-none">
</div> </div>
</div> </div>
<div> <div>
<label for="inputConc" class="block text-xs text-slate-300 font-medium mb-1">Concentration Produit (mg/mL)</label> <label for="inputConc" data-i18n="patient.concentration" class="block text-xs text-slate-300 font-medium mb-1">Concentration Produit (mg/mL)</label>
<input type="number" id="inputConc" value="5" step="0.1" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-amber-400 font-bold focus:ring-2 focus:ring-amber-500 outline-none transition"> <input type="number" id="inputConc" value="5" step="0.1" class="w-full bg-slate-900/90 border border-slate-700 rounded-xl px-3 py-2 text-sm text-amber-400 font-bold focus:ring-2 focus:ring-amber-500 outline-none">
</div> </div>
</div> </div>
</div> </div>
<!-- Carte : Sélection Modèles PK --> <!-- Models Selection -->
<div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm"> <div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm">
<h2 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 border-b border-slate-700/60 pb-2 flex items-center gap-2"> <h2 data-i18n="models.title" class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 border-b border-slate-700/60 pb-2 flex items-center gap-2">
<span>📈</span> Modèles Pharmacocinétiques <span>📈</span> Modèles Pharmacocinétiques
</h2> </h2>
@@ -128,27 +136,27 @@
</aside> </aside>
<!-- ZONE PRINCIPALE : GRAPHIQUE & GRILLE TEMPORELLE (9 colonnes lg) --> <!-- CONTENT -->
<section class="lg:col-span-9 space-y-5 flex flex-col"> <section class="lg:col-span-9 space-y-5 flex flex-col">
<!-- Canvas Graphique Interactif --> <!-- Chart -->
<div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm flex flex-col h-[420px] relative"> <div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm flex flex-col h-[420px] relative">
<div class="flex-1 w-full h-full relative"> <div class="flex-1 w-full h-full relative">
<canvas id="pkChart"></canvas> <canvas id="pkChart"></canvas>
</div> </div>
</div> </div>
<!-- Grille Temporelle Déroulante (0 à 300 minutes) --> <!-- Timeline Grid -->
<div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm"> <div class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm">
<div class="flex items-center justify-between mb-3 border-b border-slate-700/60 pb-2"> <div class="flex items-center justify-between mb-3 border-b border-slate-700/60 pb-2">
<h2 class="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2"> <h2 data-i18n="timeline.title" class="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
<span>⏱️</span> Saisie Chronologique Doses & Perfusion (0 - 300 min) <span>⏱️</span> Saisie Chronologique Doses & Perfusion (0 - 300 min)
</h2> </h2>
<span class="text-[10px] text-slate-400 italic">Défilement horizontal 👉</span> <span data-i18n="timeline.scroll_hint" class="text-[10px] text-slate-400 italic">Défilement horizontal 👉</span>
</div> </div>
<div id="timelineGrid" class="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-900"> <div id="timelineGrid" class="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-900">
<!-- Les 61 blocs (pas de 5 min) sont injectés dynamiquement par app.js --> <!-- Injected via app.js -->
</div> </div>
</div> </div>
@@ -156,32 +164,28 @@
</main> </main>
<!-- ==================== FENÊTRE MODALE : CALCULATEUR ==================== --> <!-- MODAL CALCULATOR -->
<div id="modalCalc" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4 transition-opacity"> <div id="modalCalc" class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4 transition-opacity">
<div class="bg-slate-800 border border-slate-700 rounded-2xl w-full max-w-lg p-6 space-y-6 shadow-2xl relative"> <div class="bg-slate-800 border border-slate-700 rounded-2xl w-full max-w-lg p-6 space-y-6 shadow-2xl relative">
<!-- Modal Header -->
<div class="flex justify-between items-center border-b border-slate-700 pb-3"> <div class="flex justify-between items-center border-b border-slate-700 pb-3">
<h3 class="text-base font-bold text-amber-400 flex items-center gap-2"> <h3 data-i18n="calculator.title" class="text-base font-bold text-amber-400 flex items-center gap-2">
<span>🧮</span> Calculateur & Conversions de Doses <span>🧮</span> Calculateur & Conversions de Doses
</h3> </h3>
<button id="btnCloseCalc" type="button" class="text-slate-400 hover:text-white font-bold text-xl leading-none transition">&times;</button> <button id="btnCloseCalc" type="button" class="text-slate-400 hover:text-white font-bold text-xl leading-none transition">&times;</button>
</div> </div>
<!-- Modal Body -->
<div class="space-y-4"> <div class="space-y-4">
<!-- Poids / Concentration Références -->
<div class="grid grid-cols-2 gap-3 bg-slate-900/80 p-3 rounded-xl border border-slate-700/50"> <div class="grid grid-cols-2 gap-3 bg-slate-900/80 p-3 rounded-xl border border-slate-700/50">
<div> <div>
<label for="calcWeight" class="block text-xs text-slate-400 mb-1">Poids du patient</label> <label for="calcWeight" data-i18n="calculator.patient_weight" class="block text-xs text-slate-400 mb-1">Poids du patient</label>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<input type="number" id="calcWeight" value="70" class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2 text-sm text-white font-bold outline-none"> <input type="number" id="calcWeight" value="70" class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2 text-sm text-white font-bold outline-none">
<span class="text-xs text-slate-400">kg</span> <span class="text-xs text-slate-400">kg</span>
</div> </div>
</div> </div>
<div> <div>
<label for="calcConc" class="block text-xs text-slate-400 mb-1">Concentration</label> <label for="calcConc" data-i18n="calculator.drug_conc" class="block text-xs text-slate-400 mb-1">Concentration</label>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<input type="number" id="calcConc" value="10" class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2 text-sm text-amber-400 font-bold outline-none"> <input type="number" id="calcConc" value="10" class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2 text-sm text-amber-400 font-bold outline-none">
<span class="text-xs text-slate-400">mg/mL</span> <span class="text-xs text-slate-400">mg/mL</span>
@@ -189,31 +193,29 @@
</div> </div>
</div> </div>
<!-- Conversions Bolus -->
<div> <div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Conversion Bolus</h4> <h4 data-i18n="calculator.bolus_header" class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Conversion Bolus</h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label for="calcMg" class="block text-[10px] text-slate-500 mb-1">Dose totale (mg)</label> <label for="calcMg" data-i18n="calculator.total_dose" class="block text-[10px] text-slate-500 mb-1">Dose totale (mg)</label>
<input type="number" id="calcMg" placeholder="ex: 35" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none"> <input type="number" id="calcMg" placeholder="ex: 35" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none">
</div> </div>
<div> <div>
<label for="calcMgKg" class="block text-[10px] text-slate-500 mb-1">Dose relative (mg/kg)</label> <label for="calcMgKg" data-i18n="calculator.relative_dose" class="block text-[10px] text-slate-500 mb-1">Dose relative (mg/kg)</label>
<input type="number" id="calcMgKg" placeholder="ex: 0.5" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none"> <input type="number" id="calcMgKg" placeholder="ex: 0.5" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none">
</div> </div>
</div> </div>
</div> </div>
<!-- Conversions Perfusion -->
<div> <div>
<h4 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Conversion Perfusion</h4> <h4 data-i18n="calculator.infusion_header" class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Conversion Perfusion</h4>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label for="calcMgKgH" class="block text-[10px] text-slate-500 mb-1">Débit (mg/kg/h)</label> <label for="calcMgKgH" data-i18n="calculator.rate_mgkgh" class="block text-[10px] text-slate-500 mb-1">Débit (mg/kg/h)</label>
<input type="number" id="calcMgKgH" placeholder="ex: 0.3" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none"> <input type="number" id="calcMgKgH" placeholder="ex: 0.3" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none">
</div> </div>
<div> <div>
<label for="calcUgKgMin" class="block text-[10px] text-slate-500 mb-1">Débit (µg/kg/min)</label> <label for="calcUgKgMin" data-i18n="calculator.rate_ugkgmin" class="block text-[10px] text-slate-500 mb-1">Débit (µg/kg/min)</label>
<input type="number" id="calcUgKgMin" placeholder="ex: 5" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none"> <input type="number" id="calcUgKgMin" placeholder="ex: 5" class="w-full bg-slate-900 border border-slate-700 rounded-lg p-2 text-sm text-white focus:ring-1 focus:ring-amber-500 outline-none">
</div> </div>
</div> </div>
@@ -223,7 +225,6 @@
</div> </div>
</div> </div>
<!-- Entrée Application Javascript Principal (Module ES6) -->
<script type="module" src="js/app.js"></script> <script type="module" src="js/app.js"></script>
</body> </body>
</html> </html>
+18 -8
View File
@@ -1,7 +1,8 @@
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 { adjustChartYScale } from './chartManager.js';
import { initCalculatorUI } from './calculator.js'; import { initCalculatorUI } from './calculator.js';
import { generatePDFReport } from './pdfExport.js'; import { generatePDFReport } from './pdfExport.js';
import { initI18n, getCurrentLang, t } from './i18n.js';
let currentMode = MODES.ESKETAMINE; let currentMode = MODES.ESKETAMINE;
let pkChart = null; let pkChart = null;
@@ -12,10 +13,22 @@ const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
perfRateMgKgH: 0 perfRateMgKgH: 0
})); }));
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
await initI18n();
const selectLang = document.getElementById('selectLang');
if (selectLang) {
selectLang.value = getCurrentLang();
selectLang.addEventListener('change', async (e) => {
await initI18n(e.target.value);
renderTimelineGrid();
updateSimulation();
});
}
initChart(); initChart();
renderTimelineGrid(); renderTimelineGrid();
initCalculatorUI(); // Initialise la modale et les événements du calculateur initCalculatorUI();
setupEventListeners(); setupEventListeners();
updateSimulation(); updateSimulation();
}); });
@@ -87,10 +100,7 @@ function updateSimulation() {
} }
pkChart.data.datasets = datasets; pkChart.data.datasets = datasets;
// Ajustement dynamique de l'échelle Y à 110 % si besoin
adjustChartYScale(pkChart, currentMode, datasets); adjustChartYScale(pkChart, currentMode, datasets);
pkChart.update(); pkChart.update();
} }
@@ -104,11 +114,11 @@ function renderTimelineGrid() {
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-400 block mb-0.5">Bolus (mg)</span> <span class="text-[10px] text-slate-400 block mb-0.5">${t('timeline.bolus')}</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 focus:border-amber-500"> <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-400 block mb-0.5">Perf (mg/kg/h)</span> <span class="text-[10px] text-slate-400 block mb-0.5">${t('timeline.perf')}</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 focus:border-amber-500"> <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>
`; `;
+1 -9
View File
@@ -1,4 +1,4 @@
import { CalculatorEngine } from '../../pkCore.js'; import { CalculatorEngine } from '/pkCore.js';
export function initCalculatorUI() { export function initCalculatorUI() {
const modal = document.getElementById('modalCalc'); const modal = document.getElementById('modalCalc');
@@ -7,7 +7,6 @@ export function initCalculatorUI() {
if (!modal || !btnOpen || !btnClose) return; if (!modal || !btnOpen || !btnClose) return;
// 1. Ouverture de la modale & synchronisation avec le formulaire principal
btnOpen.addEventListener('click', () => { btnOpen.addEventListener('click', () => {
const mainWeight = document.getElementById('inputWeight')?.value || 70; const mainWeight = document.getElementById('inputWeight')?.value || 70;
const mainConc = document.getElementById('inputConc')?.value || 5; const mainConc = document.getElementById('inputConc')?.value || 5;
@@ -18,19 +17,16 @@ export function initCalculatorUI() {
modal.classList.remove('hidden'); modal.classList.remove('hidden');
}); });
// 2. Fermeture via le bouton X
btnClose.addEventListener('click', () => { btnClose.addEventListener('click', () => {
modal.classList.add('hidden'); modal.classList.add('hidden');
}); });
// 3. Fermeture au clic sur le fond sombre
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal) { if (e.target === modal) {
modal.classList.add('hidden'); modal.classList.add('hidden');
} }
}); });
// 4. Logique de conversion en temps réel
const calcWeightInput = document.getElementById('calcWeight'); 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');
@@ -39,25 +35,21 @@ export function initCalculatorUI() {
const getWeight = () => parseFloat(calcWeightInput.value) || 70; const getWeight = () => parseFloat(calcWeightInput.value) || 70;
// Bolus : mg -> mg/kg
mgInput.addEventListener('input', () => { mgInput.addEventListener('input', () => {
const mg = parseFloat(mgInput.value); const mg = parseFloat(mgInput.value);
mgKgInput.value = isNaN(mg) ? '' : (mg / getWeight()).toFixed(2); mgKgInput.value = isNaN(mg) ? '' : (mg / getWeight()).toFixed(2);
}); });
// Bolus : mg/kg -> mg
mgKgInput.addEventListener('input', () => { mgKgInput.addEventListener('input', () => {
const mgKg = parseFloat(mgKgInput.value); const mgKg = parseFloat(mgKgInput.value);
mgInput.value = isNaN(mgKg) ? '' : (mgKg * getWeight()).toFixed(1); mgInput.value = isNaN(mgKg) ? '' : (mgKg * getWeight()).toFixed(1);
}); });
// Perfusion : mg/kg/h -> µg/kg/min
mgKgHInput.addEventListener('input', () => { mgKgHInput.addEventListener('input', () => {
const mgKgH = parseFloat(mgKgHInput.value); const mgKgH = parseFloat(mgKgHInput.value);
ugKgMinInput.value = isNaN(mgKgH) ? '' : 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 ug = parseFloat(ugKgMinInput.value); const ug = parseFloat(ugKgMinInput.value);
mgKgHInput.value = isNaN(ug) ? '' : CalculatorEngine.microgKgMinToMgKgH(ug).toFixed(2); mgKgHInput.value = isNaN(ug) ? '' : CalculatorEngine.microgKgMinToMgKgH(ug).toFixed(2);
+3 -12
View File
@@ -1,8 +1,6 @@
import { CLINICAL_THRESHOLDS } from '../../pkCore.js'; import { CLINICAL_THRESHOLDS } from '/pkCore.js';
import { t } from './i18n.js';
/**
* Recalcule et applique les zones de couleur en fonction du mode et du Y max actuel
*/
export function updateChartAnnotations(chart, mode, currentYMax) { export function updateChartAnnotations(chart, mode, currentYMax) {
const thresholds = CLINICAL_THRESHOLDS[mode]; const thresholds = CLINICAL_THRESHOLDS[mode];
const annotations = {}; const annotations = {};
@@ -19,7 +17,7 @@ export function updateChartAnnotations(chart, mode, currentYMax) {
borderWidth: 0, borderWidth: 0,
label: { label: {
display: true, display: true,
content: zone.name, content: t(`zones.${zone.zoneKey}`),
color: zone.textHex, color: zone.textHex,
font: { size: 11, weight: 'bold' }, font: { size: 11, weight: 'bold' },
position: 'center' position: 'center'
@@ -31,23 +29,16 @@ export function updateChartAnnotations(chart, mode, currentYMax) {
chart.options.plugins.annotation.annotations = 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) { export function adjustChartYScale(chart, mode, datasets) {
const defaultMax = CLINICAL_THRESHOLDS[mode].maxScale; const defaultMax = CLINICAL_THRESHOLDS[mode].maxScale;
let highestValue = 0; let highestValue = 0;
// Trouve la valeur la plus haute parmi toutes les courbes actives
datasets.forEach(ds => { datasets.forEach(ds => {
ds.data.forEach(val => { ds.data.forEach(val => {
if (val > highestValue) highestValue = 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; let targetYMax = defaultMax;
if (highestValue > defaultMax) { if (highestValue > defaultMax) {
targetYMax = Math.ceil(highestValue * 1.1); targetYMax = Math.ceil(highestValue * 1.1);
+46
View File
@@ -0,0 +1,46 @@
let currentLang = localStorage.getItem('keta_lang') || (navigator.language.startsWith('en') ? 'en' : 'fr');
let translations = {};
export async function initI18n(lang = currentLang) {
currentLang = lang;
localStorage.setItem('keta_lang', lang);
try {
const response = await fetch(`./locales/${lang}.json`);
translations = await response.json();
translatePageDOM();
document.documentElement.lang = lang;
} catch (error) {
console.error(`Erreur de chargement de la langue ${lang}:`, error);
}
}
export function translatePageDOM() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const text = getNestedTranslation(key);
if (text) el.textContent = text;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const text = getNestedTranslation(key);
if (text) el.placeholder = text;
});
}
export function t(key, params = {}) {
let val = getNestedTranslation(key) || key;
Object.keys(params).forEach(p => {
val = val.replace(`{${p}}`, params[p]);
});
return val;
}
function getNestedTranslation(key) {
return key.split('.').reduce((obj, i) => (obj ? obj[i] : null), translations);
}
export function getCurrentLang() {
return currentLang;
}
+19 -32
View File
@@ -1,52 +1,39 @@
/** import { t } from './i18n.js';
* 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 export function generatePDFReport(pkChart, patient, mode) {
const { jsPDF } = window.jspdf;
const doc = new jsPDF('landscape', 'mm', 'a4'); const doc = new jsPDF('landscape', 'mm', 'a4');
// --- 1. En-tête du document --- // En-tête
doc.setFillColor(30, 41, 59); // Couleur bleue nuit (slate-800) doc.setFillColor(30, 41, 59);
doc.rect(0, 0, 297, 25, 'F'); doc.rect(0, 0, 297, 25, 'F');
doc.setTextColor(255, 255, 255); doc.setTextColor(255, 255, 255);
doc.setFontSize(18); doc.setFontSize(16);
doc.setFont("helvetica", "bold"); doc.setFont("helvetica", "bold");
doc.text("KétaPK - Rapport de Simulation Pharmacocinétique", 14, 16); doc.text(t('pdf.title'), 14, 16);
// --- 2. Informations de la simulation --- // Informations Patient
doc.setTextColor(50, 50, 50); doc.setTextColor(50, 50, 50);
doc.setFontSize(11); doc.setFontSize(10);
doc.setFont("helvetica", "normal"); doc.setFont("helvetica", "normal");
const dateStr = new Date().toLocaleString('fr-FR'); const dateStr = new Date().toLocaleString();
// Colonne de gauche (Patient) doc.text(`${t('pdf.date')}${dateStr}`, 14, 35);
doc.text(`Date de simulation : ${dateStr}`, 14, 35);
doc.setFont("helvetica", "bold"); doc.setFont("helvetica", "bold");
doc.text(`Mode sélectionné : ${mode}`, 14, 42); doc.text(`${t('pdf.mode')}${mode}`, 14, 42);
doc.text(`Patient : Poids ${patient.weight} kg | Âge ${patient.age} ans | Taille ${patient.height} cm`, 14, 49); doc.text(t('pdf.patient_info', patient), 14, 49);
// --- 3. Capture et insertion du Graphique --- // Image du Graphique
// On récupère l'image du graphique sur fond blanc
const chartImage = pkChart.toBase64Image('image/jpeg', 1.0); const chartImage = pkChart.toBase64Image('image/jpeg', 1.0);
doc.addImage(chartImage, 'JPEG', 14, 55, 268, 120);
// Placement : x=14mm, y=60mm, largeur=260mm, hauteur=110mm // Pied de page
doc.addImage(chartImage, 'JPEG', 14, 60, 260, 110);
// --- 4. Pied de page ---
doc.setFont("helvetica", "italic"); doc.setFont("helvetica", "italic");
doc.setFontSize(9); doc.setFontSize(8);
doc.setTextColor(150, 150, 150); 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); doc.text(t('pdf.disclaimer'), 14, 192);
// --- 5. Téléchargement --- doc.save(`KetaPK_Simulation_${patient.weight}kg_${mode}.pdf`);
const fileName = `KetaPK_Rapport_${patient.weight}kg_${mode}.pdf`;
doc.save(fileName);
} }
+56
View File
@@ -0,0 +1,56 @@
{
"app": {
"title": "Pharmaco Kinetics",
"version": "v1.00 • Dr Georges Mion"
},
"nav": {
"mode_esk": "ESKETAMINE",
"mode_rac": "RACEMIC",
"calculator": "Calculator",
"export_pdf": "Export PDF",
"raz": "Reset"
},
"patient": {
"title": "Patient Parameters",
"weight": "Weight (kg)",
"age": "Age (years)",
"height": "Height (cm)",
"concentration": "Drug Concentration (mg/mL)"
},
"models": {
"title": "PK Models"
},
"timeline": {
"title": "Chronological Dosing & Infusion (0 - 300 min)",
"scroll_hint": "Horizontal scroll 👉",
"bolus": "Bolus (mg)",
"perf": "Infusion (mg/kg/h)"
},
"calculator": {
"title": "Calculator & Dose Conversions",
"patient_weight": "Patient Weight",
"drug_conc": "Concentration",
"bolus_header": "Bolus Conversion",
"total_dose": "Total Dose (mg)",
"relative_dose": "Relative Dose (mg/kg)",
"infusion_header": "Infusion Conversion",
"rate_mgkgh": "Rate (mg/kg/h)",
"rate_ugkgmin": "Rate (µg/kg/min)"
},
"zones": {
"inactive": "Inactive",
"anti_hyperalgesic": "Anti-Hyperalgesic",
"analgesia": "Analgesia",
"psychedelic": "Psychedelic",
"emergence": "Emergence",
"narcosis": "Narcosis",
"excessive": "Excessive"
},
"pdf": {
"title": "KetaPK - Pharmacokinetic Simulation Report",
"date": "Simulation Date: ",
"mode": "Selected Mode: ",
"patient_info": "Patient: Weight {weight} kg | Age {age} yrs | Height {height} cm",
"disclaimer": "Generated by KetaPK Web. For educational purposes only."
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"app": {
"title": "Pharmaco Kinetics",
"version": "v1.00 • Dr Georges Mion"
},
"nav": {
"mode_esk": "ESKETAMINE",
"mode_rac": "RACÉMIQUE",
"calculator": "Calculateur",
"export_pdf": "Exporter PDF",
"raz": "RAZ"
},
"patient": {
"title": "Caractéristiques Patient",
"weight": "Poids (kg)",
"age": "Âge (ans)",
"height": "Taille (cm)",
"concentration": "Concentration Produit (mg/mL)"
},
"models": {
"title": "Modèles Pharmacocinétiques"
},
"timeline": {
"title": "Saisie Chronologique Doses & Perfusion (0 - 300 min)",
"scroll_hint": "Défilement horizontal 👉",
"bolus": "Bolus (mg)",
"perf": "Perf (mg/kg/h)"
},
"calculator": {
"title": "Calculateur & Conversions de Doses",
"patient_weight": "Poids du patient",
"drug_conc": "Concentration",
"bolus_header": "Conversion Bolus",
"total_dose": "Dose totale (mg)",
"relative_dose": "Dose relative (mg/kg)",
"infusion_header": "Conversion Perfusion",
"rate_mgkgh": "Débit (mg/kg/h)",
"rate_ugkgmin": "Débit (µg/kg/min)"
},
"zones": {
"inactive": "Inactif",
"anti_hyperalgesic": "Anti-Hyperalgésique",
"analgesia": "Analgésie",
"psychedelic": "Psychédélique",
"emergence": "Émergence",
"narcosis": "Narcose",
"excessive": "Excessif"
},
"pdf": {
"title": "KétaPK - Rapport de Simulation Pharmacocinétique",
"date": "Date de simulation : ",
"mode": "Mode sélectionné : ",
"patient_info": "Patient : Poids {weight} kg | Âge {age} ans | Taille {height} cm",
"disclaimer": "Ce document est généré par KétaPK Web. Destiné uniquement à l'enseignement."
}
}
+30 -42
View File
@@ -1,6 +1,6 @@
/** /**
* ============================================================================ * ============================================================================
* KetaPK - Moteur Pharmacocinétique (PK/PD) * KétaPK - Moteur Pharmacocinétique (PK/PD)
* Modèles : Domino (1982), Clements (1981), Kamp (2020) * Modèles : Domino (1982), Clements (1981), Kamp (2020)
* ============================================================================ * ============================================================================
*/ */
@@ -10,69 +10,66 @@ export const MODES = {
RACEMIQUE: 'RACEMIQUE' RACEMIQUE: 'RACEMIQUE'
}; };
// 1. Modèles Pharmacocinétiques (Multi-exponentiels) // Modèles Multi-exponentiels
// Cp(t) = (Dose / Poids) * [ A * e^(-alpha * t) + B * e^(-beta * t) + C * e^(-gamma * t) ]
export const PK_MODELS = { export const PK_MODELS = {
DOMINO: { DOMINO: {
name: "Domino (1982)", name: "Domino (1982)",
refDose: 2.0, // mg/kg refDose: 2.0,
A: 14300, alpha: 1.35167, A: 14300, alpha: 1.35167,
B: 2340, beta: 0.09517, B: 2340, beta: 0.09517,
C: 197, gamma: 0.00378, C: 197, gamma: 0.00378,
color: '#ff9933' // Orange color: '#ff9933'
}, },
CLEMENTS: { CLEMENTS: {
name: "Clements (1981)", name: "Clements (1981)",
refDose: 0.25, // mg/kg refDose: 0.25,
A: 108, alpha: 0.03940, A: 108, alpha: 0.03940,
B: 39, beta: 0.00380, B: 39, beta: 0.00380,
C: 0, gamma: 0, C: 0, gamma: 0,
color: '#6ec46c' // Vert color: '#6ec46c'
}, },
KAMP: { KAMP: {
name: "Kamp (2020)", name: "Kamp (2020)",
refDose: 0.5, // mg/kg refDose: 0.5,
A: 1332, alpha: 0.24658, A: 1332, alpha: 0.24658,
B: 155, beta: 0.03845, B: 155, beta: 0.03845,
C: 69, gamma: 0.00409, C: 69, gamma: 0.00409,
color: '#4fafe3' // Bleu color: '#4fafe3'
} }
}; };
// 2. Seuils d'effets cliniques (ng/mL) // Zones d'effets cliniques et seuils (ng/mL)
export const CLINICAL_THRESHOLDS = { export const CLINICAL_THRESHOLDS = {
[MODES.ESKETAMINE]: { [MODES.ESKETAMINE]: {
maxScale: 500, maxScale: 500,
defaultConcentration: 5, // mg/mL defaultConcentration: 5,
zones: [ zones: [
{ name: "Inactif", min: 0, max: 10, color: "rgba(249, 252, 126, 0.4)", textHex: "#8a8d00" }, { zoneKey: "inactive", 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" }, { zoneKey: "anti_hyperalgesic", 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" }, { zoneKey: "analgesia", 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" }, { zoneKey: "psychedelic", 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" }, { zoneKey: "emergence", 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" }, { zoneKey: "narcosis", 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" } { zoneKey: "excessive", min: 1000, max: Infinity, color: "rgba(255, 87, 51, 0.4)", textHex: "#a81300" }
] ]
}, },
[MODES.RACEMIQUE]: { [MODES.RACEMIQUE]: {
maxScale: 1000, maxScale: 1000,
defaultConcentration: 10, // mg/mL defaultConcentration: 10,
zones: [ zones: [
{ name: "Inactif", min: 0, max: 20, color: "rgba(249, 252, 126, 0.4)", textHex: "#8a8d00" }, { zoneKey: "inactive", 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" }, { zoneKey: "anti_hyperalgesic", 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" }, { zoneKey: "analgesia", 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" }, { zoneKey: "psychedelic", 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" }, { zoneKey: "emergence", 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" }, { zoneKey: "narcosis", 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" } { zoneKey: "excessive", 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. * Calcul d'un bolus unique à l'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) { export function calculateBolusCp(model, doseMg, weightKg, timeElapsedMin) {
if (timeElapsedMin < 0 || weightKg <= 0) return 0; if (timeElapsedMin < 0 || weightKg <= 0) return 0;
@@ -88,12 +85,9 @@ export function calculateBolusCp(model, doseMg, weightKg, timeElapsedMin) {
} }
/** /**
* Superposition Linéaire des effets (Bolus multiples + Perfusions) sur 300 minutes (pas de 5 min) * Superposition Linéaire des Bolus et Perfusions sur 300 min
*/ */
export function generateSimulationData({ model, weightKg, timelineInputs }) { 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 timeGrid = Array.from({ length: 61 }, (_, i) => i * 5);
const cpResults = new Array(61).fill(0); const cpResults = new Array(61).fill(0);
@@ -101,18 +95,14 @@ export function generateSimulationData({ model, weightKg, timelineInputs }) {
let totalCp = 0; let totalCp = 0;
for (let i = 0; i <= targetIndex; i++) { for (let i = 0; i <= targetIndex; i++) {
const inputTime = timeGrid[i]; const deltaTime = targetTime - timeGrid[i];
const deltaTime = targetTime - inputTime;
const input = timelineInputs[i] || { bolusMg: 0, perfRateMgKgH: 0 }; const input = timelineInputs[i] || { bolusMg: 0, perfRateMgKgH: 0 };
// Contribution du Bolus
if (input.bolusMg > 0) { if (input.bolusMg > 0) {
totalCp += calculateBolusCp(model, input.bolusMg, weightKg, deltaTime); 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) { 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); const perfDoseMg = (input.perfRateMgKgH * weightKg) * (5 / 60);
totalCp += calculateBolusCp(model, perfDoseMg, weightKg, deltaTime); totalCp += calculateBolusCp(model, perfDoseMg, weightKg, deltaTime);
} }
@@ -124,12 +114,10 @@ export function generateSimulationData({ model, weightKg, timelineInputs }) {
return cpResults; return cpResults;
} }
// 4. Fonctions de Calculateur Médical (Conversions d'unités) // Moteur de conversion de doses
export const CalculatorEngine = { export const CalculatorEngine = {
mgToMgKg: (mg, weight) => (weight > 0 ? mg / weight : 0), mgToMgKg: (mg, weight) => (weight > 0 ? mg / weight : 0),
mgKgToMg: (mgKg, weight) => mgKg * weight, mgKgToMg: (mgKg, weight) => mgKg * weight,
mgKgHToMicrogKgMin: (mgKgH) => (mgKgH * 1000) / 60, mgKgHToMicrogKgMin: (mgKgH) => (mgKgH * 1000) / 60,
microgKgMinToMgKgH: (microg) => (microg * 60) / 1000, microgKgMinToMgKgH: (microg) => (microg * 60) / 1000
mgToMl: (mg, concMgMl) => (concMgMl > 0 ? mg / concMgMl : 0),
mlToMg: (ml, concMgMl) => ml * concMgMl
}; };