feat: Creating the base of application

This commit is contained in:
gauvainboiche
2026-08-01 17:34:23 +02:00
parent c84d6f05ec
commit 7a7815f3bd
11 changed files with 773 additions and 0 deletions
+9
View File
@@ -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
View File
View File
+45
View File
@@ -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}`);
});
+69
View File
@@ -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;
}
}
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="fr" class="h-full bg-slate-900 text-slate-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KétaPK - Simulation Pharmacocinétique</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Chart.js + Plugin Annotation -->
<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>
<!-- jsPDF pour Exportation PDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<!-- CSS Personnalisé -->
<link rel="stylesheet" href="css/styles.css">
</head>
<body class="h-full flex flex-col font-sans antialiased selection:bg-amber-500 selection:text-slate-950">
<!-- ==================== HEADER / NAVBAR ==================== -->
<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">
<!-- Logo & Titre -->
<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">
KétaPK
</div>
<div class="flex flex-col">
<span 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>
</div>
</div>
<!-- Selecteur de Mode (Eskétamine / Racémique) -->
<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">
ESKETAMINE
</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">
RACÉMIQUE
</button>
</div>
<!-- Actions Rapides -->
<div class="flex items-center gap-2">
<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 class="hidden sm:inline">Calculateur</span>
</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">
<span>📄</span>
<span class="hidden sm:inline">Exporter PDF</span>
</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">
<span>🔄</span>
<span class="hidden sm:inline">RAZ</span>
</button>
</div>
</div>
</header>
<!-- ==================== CONTENU PRINCIPAL ==================== -->
<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) -->
<aside class="lg:col-span-3 space-y-5">
<!-- Carte : Données Patient -->
<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">
<span>👤</span> Caractéristiques Patient
</h2>
<div class="space-y-3">
<div>
<label for="inputWeight" 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">
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label for="inputAge" 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">
</div>
<div>
<label for="inputHeight" 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">
</div>
</div>
<div>
<label for="inputConc" 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">
</div>
</div>
</div>
<!-- Carte : Sélection Modèles PK -->
<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">
<span>📈</span> Modèles Pharmacocinétiques
</h2>
<div class="space-y-2">
<label class="flex items-center justify-between p-2.5 bg-slate-900/60 border border-slate-800 rounded-xl cursor-pointer hover:bg-slate-900 transition">
<span class="text-xs font-bold text-orange-400">Domino et al. (1982)</span>
<input type="checkbox" id="chkDomino" checked class="w-4 h-4 accent-orange-500 rounded cursor-pointer">
</label>
<label class="flex items-center justify-between p-2.5 bg-slate-900/60 border border-slate-800 rounded-xl cursor-pointer hover:bg-slate-900 transition">
<span class="text-xs font-bold text-emerald-400">Clements et al. (1981)</span>
<input type="checkbox" id="chkClements" checked class="w-4 h-4 accent-emerald-500 rounded cursor-pointer">
</label>
<label class="flex items-center justify-between p-2.5 bg-slate-900/60 border border-slate-800 rounded-xl cursor-pointer hover:bg-slate-900 transition">
<span class="text-xs font-bold text-sky-400">Kamp et al. (2020)</span>
<input type="checkbox" id="chkKamp" checked class="w-4 h-4 accent-sky-500 rounded cursor-pointer">
</label>
</div>
</div>
</aside>
<!-- ZONE PRINCIPALE : GRAPHIQUE & GRILLE TEMPORELLE (9 colonnes lg) -->
<section class="lg:col-span-9 space-y-5 flex flex-col">
<!-- Canvas Graphique Interactif -->
<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">
<canvas id="pkChart"></canvas>
</div>
</div>
<!-- Grille Temporelle Déroulante (0 à 300 minutes) -->
<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">
<h2 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)
</h2>
<span class="text-[10px] text-slate-400 italic">Défilement horizontal 👉</span>
</div>
<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 -->
</div>
</div>
</section>
</main>
<!-- ==================== FENÊTRE MODALE : CALCULATEUR ==================== -->
<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">
<!-- Modal Header -->
<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">
<span>🧮</span> Calculateur & Conversions de Doses
</h3>
<button id="btnCloseCalc" type="button" class="text-slate-400 hover:text-white font-bold text-xl leading-none transition">&times;</button>
</div>
<!-- Modal Body -->
<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>
<label for="calcWeight" class="block text-xs text-slate-400 mb-1">Poids du patient</label>
<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">
<span class="text-xs text-slate-400">kg</span>
</div>
</div>
<div>
<label for="calcConc" class="block text-xs text-slate-400 mb-1">Concentration</label>
<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">
<span class="text-xs text-slate-400">mg/mL</span>
</div>
</div>
</div>
<!-- Conversions Bolus -->
<div>
<h4 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>
<label for="calcMg" 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">
</div>
<div>
<label for="calcMgKg" 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">
</div>
</div>
</div>
<!-- Conversions Perfusion -->
<div>
<h4 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>
<label for="calcMgKgH" 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">
</div>
<div>
<label for="calcUgKgMin" 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">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Entrée Application Javascript Principal (Module ES6) -->
<script type="module" src="js/app.js"></script>
</body>
</html>
+166
View File
@@ -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 = `
<div class="font-bold text-amber-400 border-b border-slate-800 pb-1">${item.time} min</div>
<div>
<span class="text-[10px] text-slate-500 block">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">
</div>
<div>
<span class="text-[10px] text-slate-500 block">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">
</div>
`;
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();
}
+38
View File
@@ -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);
});
}
+30
View File
@@ -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;
}
+52
View File
@@ -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);
}
+135
View File
@@ -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
};