mirror of
https://github.com/gauvainboiche/ketapk.git
synced 2026-09-02 03:03:11 +02:00
feat: Adding color themes + .ignore files update
This commit is contained in:
+7
-3
@@ -1,6 +1,10 @@
|
||||
node_modules
|
||||
node_modules/
|
||||
.git/
|
||||
|
||||
src-tauri/
|
||||
src-tauri/target/
|
||||
|
||||
README.md
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
README.md
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
# Origin project
|
||||
KetaPK_FR_1_00/
|
||||
KetaPK_FR_1_00*
|
||||
code_vba.txt
|
||||
*.xlsm
|
||||
code_vba*.txt
|
||||
|
||||
# Node files
|
||||
node_modules/
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
**KétaPK** is a modern, cross-platform pharmacokinetic (PK) and pharmacodynamic (PD) modeling application for **Esketamine** and **Racemic Ketamine**. It allows anesthesiologists, intensive care physicians, and clinical researchers to simulate, visualize, and predict plasma drug concentrations ($C_p$) over a 300-minute timeline using multi-exponential models.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
* **Original Creator:** Dr. Georges Mion (Anesthesiologist-Intensivist)
|
||||
@@ -13,8 +11,6 @@
|
||||
* **Clements et al. (1981)** — Two-compartment model ($0.25\text{ mg/kg}$ reference)
|
||||
* **Kamp et al. (2020)** — Meta-analytic multi-compartment model ($0.5\text{ mg/kg}$ reference)
|
||||
|
||||
---
|
||||
|
||||
## 2. Project Origin & AI Generation Transparency
|
||||
|
||||
### The Legacy Application
|
||||
@@ -30,8 +26,6 @@ To make the tool responsive, cross-platform, and independent of Microsoft Office
|
||||
* **Generation Mode:** Context-aware multi-turn prompt engineering & source-code decompilation
|
||||
* **Generation Parameters:** Standard System Instructions, Default Temperature (~0.7), Strict Module Separation (Decoupled Math Engine / Responsive UI / i18n Dictionary)
|
||||
|
||||
---
|
||||
|
||||
## 3. Legacy Excel Implementation
|
||||
|
||||
The original spreadsheet relied on embedded OLE streams, sheet-level event macros (`Worksheet_SelectionChange`), and manual range formulas to calculate 61 time points (0 to 300 minutes in 5-minute increments).
|
||||
@@ -39,8 +33,6 @@ The original spreadsheet relied on embedded OLE streams, sheet-level event macro
|
||||
> **Learn more about the original implementation:**
|
||||
> [View Original Excel/VBA Application Documentation & Walkthrough](https://georgesmion.com/ketapk-simuler-les-concentrations-plasmatiques-de-ketamine/)
|
||||
|
||||
---
|
||||
|
||||
## 4. Running with Docker
|
||||
|
||||
You can run **KétaPK** in an isolated container using Docker or Docker Compose.
|
||||
@@ -57,9 +49,7 @@ docker build -t ketapk-web .
|
||||
docker run -d -p 3000:3000 --name ketapk-app ketapk-web
|
||||
```
|
||||
|
||||
```bash
|
||||
# Access the application in your browser at http://localhost:3000
|
||||
```
|
||||
Access the application in your browser at http://localhost:3000
|
||||
|
||||
### Option B: Using Docker Compose (Recommended)
|
||||
|
||||
@@ -73,8 +63,6 @@ docker compose up -d --build
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Compiling Desktop Apps with Tauri (Windows, macOS, Linux)
|
||||
|
||||
**KétaPK** uses **Tauri v2** to generate ultra-lightweight ($\sim 12\text{ MB}$), native desktop executables (`.exe`, `.dmg`, `.AppImage`) that operate 100% offline without requiring Node.js or Docker on the target medical machine.
|
||||
@@ -108,8 +96,6 @@ npx tauri dev
|
||||
npx tauri build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Output Installation Binaries
|
||||
|
||||
After running `npx tauri build`, your standalone installer will be available at:
|
||||
|
||||
+85
-39
@@ -1,5 +1,86 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
/* ============================================================
|
||||
Thèmes (dark / light / monokai) — variables CSS consommées
|
||||
par des classes Tailwind arbitraires : bg-[var(--kt-bg)] etc.
|
||||
============================================================ */
|
||||
:root,
|
||||
html[data-theme="dark"] {
|
||||
--kt-bg: #0f172a;
|
||||
--kt-surface-1: rgba(30, 41, 59, 0.9);
|
||||
--kt-surface-2: rgba(30, 41, 59, 0.6);
|
||||
--kt-surface-3: rgba(2, 6, 23, 0.8);
|
||||
--kt-surface-modal: #1e293b;
|
||||
--kt-btn-bg: #334155;
|
||||
--kt-btn-bg-hover: #475569;
|
||||
--kt-border: #334155;
|
||||
--kt-border-soft: #1e293b;
|
||||
--kt-text-1: #f1f5f9;
|
||||
--kt-text-2: #cbd5e1;
|
||||
--kt-text-3: #94a3b8;
|
||||
--kt-option-bg: #0f172a;
|
||||
--kt-row-alt-1: rgba(15, 23, 42, 0.3);
|
||||
--kt-row-alt-2: rgba(30, 41, 59, 0.3);
|
||||
--kt-scroll-track: rgba(15, 23, 42, 0.6);
|
||||
--kt-scroll-thumb: rgba(51, 65, 85, 0.8);
|
||||
--kt-scroll-thumb-hover: rgba(100, 116, 139, 1);
|
||||
--kt-chart-grid: rgba(51, 65, 85, 0.4);
|
||||
--kt-chart-text: #94a3b8;
|
||||
--kt-chart-legend: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--kt-bg: #f1f5f9;
|
||||
--kt-surface-1: rgba(255, 255, 255, 0.9);
|
||||
--kt-surface-2: #ffffff;
|
||||
--kt-surface-3: #f8fafc;
|
||||
--kt-surface-modal: #ffffff;
|
||||
--kt-btn-bg: #e2e8f0;
|
||||
--kt-btn-bg-hover: #cbd5e1;
|
||||
--kt-border: #cbd5e1;
|
||||
--kt-border-soft: #e2e8f0;
|
||||
--kt-text-1: #0f172a;
|
||||
--kt-text-2: #334155;
|
||||
--kt-text-3: #64748b;
|
||||
--kt-option-bg: #ffffff;
|
||||
--kt-row-alt-1: rgba(226, 232, 240, 0.35);
|
||||
--kt-row-alt-2: rgba(241, 245, 249, 0.6);
|
||||
--kt-scroll-track: #e2e8f0;
|
||||
--kt-scroll-thumb: #94a3b8;
|
||||
--kt-scroll-thumb-hover: #64748b;
|
||||
--kt-chart-grid: rgba(51, 65, 85, 0.4);
|
||||
--kt-chart-text: #334155;
|
||||
--kt-chart-legend: #0f172a;
|
||||
}
|
||||
|
||||
html[data-theme="monokai"] {
|
||||
--kt-bg: #272822;
|
||||
--kt-surface-1: rgba(39, 40, 34, 0.92);
|
||||
--kt-surface-2: #2d2e27;
|
||||
--kt-surface-3: #1e1f1a;
|
||||
--kt-surface-modal: #2d2e27;
|
||||
--kt-btn-bg: #3e3d32;
|
||||
--kt-btn-bg-hover: #49483e;
|
||||
--kt-border: #49483e;
|
||||
--kt-border-soft: #3e3d32;
|
||||
--kt-text-1: #f8f8f2;
|
||||
--kt-text-2: #cfcfc2;
|
||||
--kt-text-3: #9d9d93;
|
||||
--kt-option-bg: #272822;
|
||||
--kt-row-alt-1: rgba(30, 31, 26, 0.5);
|
||||
--kt-row-alt-2: rgba(45, 46, 39, 0.5);
|
||||
--kt-scroll-track: #1e1f1a;
|
||||
--kt-scroll-thumb: #49483e;
|
||||
--kt-scroll-thumb-hover: #75715e;
|
||||
--kt-chart-grid: rgba(117, 113, 94, 0.35);
|
||||
--kt-chart-text: #a6a599;
|
||||
--kt-chart-legend: #f8f8f2;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--kt-bg);
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
@@ -15,18 +96,18 @@ input[type="number"] {
|
||||
}
|
||||
|
||||
#timelineGrid::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
background: var(--kt-scroll-track);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
#timelineGrid::-webkit-scrollbar-thumb {
|
||||
background: rgba(51, 65, 85, 0.8);
|
||||
background: var(--kt-scroll-thumb);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(15, 23, 42, 0.6);
|
||||
border: 2px solid var(--kt-scroll-track);
|
||||
}
|
||||
|
||||
#timelineGrid::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 1);
|
||||
background: var(--kt-scroll-thumb-hover);
|
||||
}
|
||||
|
||||
#modalCalc {
|
||||
@@ -59,38 +140,3 @@ input[type="number"] {
|
||||
}
|
||||
}
|
||||
|
||||
/* Annulation des flèches sur les numéros */
|
||||
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;
|
||||
}
|
||||
|
||||
/* Scrollbar timeline */
|
||||
#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;
|
||||
}
|
||||
|
||||
/* Transitions modale */
|
||||
#modalCalc {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
#modalCalc.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#modalCalc:not(.hidden) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+83
-56
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" class="min-h-screen bg-slate-900 text-slate-100">
|
||||
<html lang="fr" data-theme="dark" class="min-h-screen bg-[var(--kt-bg)] text-[var(--kt-text-1)]">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -20,7 +20,7 @@
|
||||
<body class="min-h-screen flex flex-col font-sans antialiased selection:bg-amber-500 selection:text-slate-950">
|
||||
|
||||
<!-- 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-[var(--kt-surface-1)] backdrop-blur border-b border-[var(--kt-border)] sticky top-0 z-40 shadow-lg">
|
||||
<div class="max-w-[1800px] w-full mx-auto px-6 py-3 flex flex-wrap items-center justify-between gap-4">
|
||||
|
||||
<!-- Logo -->
|
||||
@@ -30,36 +30,45 @@
|
||||
KétaPK
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span data-i18n="app.title" class="text-sm font-bold text-slate-200 leading-none">Pharmaco Kinetics</span>
|
||||
<span data-i18n="app.version" class="text-[10px] text-slate-400 font-mono">v1.00 • Dr Georges Mion</span>
|
||||
<span data-i18n="app.title" class="text-sm font-bold text-[var(--kt-text-1)] leading-none">Pharmaco Kinetics</span>
|
||||
<span data-i18n="app.version" class="text-[10px] text-[var(--kt-text-3)] font-mono">v1.00 • Dr Georges Mion</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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-[var(--kt-surface-3)] p-1 rounded-xl border border-[var(--kt-border)] shadow-inner">
|
||||
<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
|
||||
</button>
|
||||
<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">
|
||||
class="px-4 py-1.5 rounded-lg text-xs font-bold transition-all text-[var(--kt-text-3)] hover:text-[var(--kt-text-1)]">
|
||||
RACÉMIQUE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions & Language -->
|
||||
<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">
|
||||
<div class="flex items-center gap-1 bg-[var(--kt-surface-3)] border border-[var(--kt-border)] rounded-xl px-2 py-1">
|
||||
<span class="text-xs">🎨</span>
|
||||
<select id="selectTheme" class="bg-transparent text-xs font-bold text-[var(--kt-text-2)] outline-none cursor-pointer">
|
||||
<option value="dark" class="bg-[var(--kt-option-bg)]" data-i18n="theme.dark">Sombre</option>
|
||||
<option value="light" class="bg-[var(--kt-option-bg)]" data-i18n="theme.light">Clair</option>
|
||||
<option value="monokai" class="bg-[var(--kt-option-bg)]" data-i18n="theme.monokai">Monokai</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 bg-[var(--kt-surface-3)] border border-[var(--kt-border)] 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>
|
||||
<option value="cn" class="bg-slate-900">CN</option>
|
||||
<select id="selectLang" class="bg-transparent text-xs font-bold text-[var(--kt-text-2)] outline-none cursor-pointer">
|
||||
<option value="fr" class="bg-[var(--kt-option-bg)]">FR</option>
|
||||
<option value="en" class="bg-[var(--kt-option-bg)]">EN</option>
|
||||
<option value="cn" class="bg-[var(--kt-option-bg)]">CN</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">
|
||||
class="bg-[var(--kt-btn-bg)] hover:bg-[var(--kt-btn-bg-hover)] text-[var(--kt-text-1)] border border-[var(--kt-border-soft)] px-3 py-1.5 rounded-lg text-xs font-medium transition flex items-center gap-1.5 shadow-sm">
|
||||
<span>🧮</span>
|
||||
<span data-i18n="nav.calculator" class="hidden sm:inline">Calculateur</span>
|
||||
</button>
|
||||
@@ -87,21 +96,21 @@
|
||||
<aside class="lg:col-span-3 xl:col-span-2 space-y-5">
|
||||
|
||||
<!-- Panneau Patient -->
|
||||
<div class="bg-slate-800/80 p-4 rounded-xl border border-slate-700/50 shadow-md mb-6">
|
||||
<div class="bg-[var(--kt-surface-2)] p-4 rounded-xl border border-[var(--kt-border)] shadow-md mb-6">
|
||||
<div class="grid grid-cols-1 gap-4 items-center">
|
||||
|
||||
<!-- Nom du Patient -->
|
||||
<div>
|
||||
<label data-i18n="patient.name_label" class="block text-xs text-slate-400 mb-1 font-medium">Nom du patient</label>
|
||||
<label data-i18n="patient.name_label" class="block text-xs text-[var(--kt-text-3)] mb-1 font-medium">Nom du patient</label>
|
||||
<input type="text" id="patientName" data-i18n-placeholder="patient.name_placeholder" placeholder="ex: DUPONT Jean"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 font-bold focus:border-cyan-500 focus:outline-none">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg px-3 py-2 text-[var(--kt-text-1)] font-bold focus:border-cyan-500 focus:outline-none">
|
||||
</div>
|
||||
|
||||
<!-- Poids + Avertissement -->
|
||||
<div>
|
||||
<label data-i18n="patient.weight_label" class="block text-xs text-slate-400 mb-1 font-medium">Poids du patient (kg)</label>
|
||||
<label data-i18n="patient.weight_label" class="block text-xs text-[var(--kt-text-3)] mb-1 font-medium">Poids du patient (kg)</label>
|
||||
<input type="number" id="patientWeight" value="70" min="10" max="250" step="any"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 font-bold focus:border-cyan-500 focus:outline-none">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg px-3 py-2 text-[var(--kt-text-1)] font-bold focus:border-cyan-500 focus:outline-none">
|
||||
|
||||
<div id="weightWarning" class="hidden text-amber-400 text-xs mt-1.5 flex items-center gap-1 font-medium"
|
||||
data-i18n="patient.weight_warning">
|
||||
@@ -111,48 +120,66 @@
|
||||
|
||||
<!-- Choix Unité Perfusion -->
|
||||
<div>
|
||||
<label data-i18n="patient.infusion_unit_label" class="block text-xs text-slate-400 mb-1 font-medium">Unité de perfusion continue</label>
|
||||
<label data-i18n="patient.infusion_unit_label" class="block text-xs text-[var(--kt-text-3)] mb-1 font-medium">Unité de perfusion continue</label>
|
||||
<select id="infusionUnitSelect"
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 font-semibold focus:border-cyan-500 focus:outline-none">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg px-3 py-2 text-[var(--kt-text-1)] font-semibold focus:border-cyan-500 focus:outline-none">
|
||||
<option value="mg_kg_h" data-i18n="units.mg_kg_h">mg / kg / h</option>
|
||||
<option value="mcg_kg_min" data-i18n="units.mcg_kg_min">µg / kg / min</option>
|
||||
<option value="mg_h" data-i18n="units.mg_h">mg / h (dose fixe)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Résolution temporelle de la timeline -->
|
||||
<div>
|
||||
<label data-i18n="patient.timeline_resolution_label" class="block text-xs text-[var(--kt-text-3)] mb-1 font-medium">Résolution de la timeline</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label for="timerInput" data-i18n="patient.timer_label" class="block text-[10px] text-[var(--kt-text-3)] mb-0.5">Timer (min)</label>
|
||||
<input type="number" id="timerInput" value="5" min="1" step="1" inputmode="numeric"
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg px-3 py-2 text-[var(--kt-text-1)] font-bold focus:border-cyan-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label for="timespanInput" data-i18n="patient.timespan_label" class="block text-[10px] text-[var(--kt-text-3)] mb-0.5">Timespan (valeurs)</label>
|
||||
<input type="number" id="timespanInput" value="60" min="1" max="500" step="1" inputmode="numeric"
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg px-3 py-2 text-[var(--kt-text-1)] font-bold focus:border-cyan-500 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
<div id="totalTimespanDisplay" class="text-[10px] text-[var(--kt-text-3)] mt-1.5 font-mono"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton d'exportation CSV Lateral -->
|
||||
<div class="flex justify-end gap-3 mb-4">
|
||||
<button id="btnExportCSV" data-i18n="buttons.export_csv"
|
||||
class="bg-slate-700 hover:bg-slate-600 text-slate-200 text-xs font-semibold px-3 py-2 rounded-lg flex items-center gap-2 transition w-full justify-center">
|
||||
class="bg-[var(--kt-btn-bg)] hover:bg-[var(--kt-btn-bg-hover)] text-[var(--kt-text-1)] text-xs font-semibold px-3 py-2 rounded-lg flex items-center gap-2 transition w-full justify-center">
|
||||
📊 Exporter valeurs CSV (ng/mL)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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-[var(--kt-surface-2)] border border-[var(--kt-border)] rounded-2xl p-4 shadow-md backdrop-blur-sm">
|
||||
<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">
|
||||
class="text-xs font-bold text-[var(--kt-text-3)] uppercase tracking-wider mb-3 border-b border-[var(--kt-border-soft)] 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">
|
||||
class="flex items-center justify-between p-2.5 bg-[var(--kt-surface-3)] border border-[var(--kt-border-soft)] rounded-xl cursor-pointer hover:bg-[var(--kt-btn-bg)] 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">
|
||||
class="flex items-center justify-between p-2.5 bg-[var(--kt-surface-3)] border border-[var(--kt-border-soft)] rounded-xl cursor-pointer hover:bg-[var(--kt-btn-bg)] 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">
|
||||
class="flex items-center justify-between p-2.5 bg-[var(--kt-surface-3)] border border-[var(--kt-border-soft)] rounded-xl cursor-pointer hover:bg-[var(--kt-btn-bg)] 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>
|
||||
@@ -166,38 +193,38 @@
|
||||
|
||||
<!-- Graphique dynamique -->
|
||||
<div
|
||||
class="bg-slate-800/60 border border-slate-700/70 rounded-2xl p-4 shadow-md backdrop-blur-sm flex flex-col h-[calc(100vh-370px)] min-h-[450px] relative">
|
||||
class="bg-[var(--kt-surface-2)] border border-[var(--kt-border)] rounded-2xl p-4 shadow-md backdrop-blur-sm flex flex-col h-[calc(100vh-370px)] min-h-[450px] relative">
|
||||
<div class="flex-1 w-full h-full relative">
|
||||
<canvas id="pkChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Grid -->
|
||||
<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="bg-[var(--kt-surface-2)] border border-[var(--kt-border)] rounded-2xl p-4 shadow-md backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between mb-3 border-b border-[var(--kt-border-soft)] pb-2">
|
||||
<h2 data-i18n="timeline.title"
|
||||
class="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center gap-2">
|
||||
class="text-xs font-bold text-[var(--kt-text-2)] uppercase tracking-wider flex items-center gap-2">
|
||||
<span>⏱️</span> Saisie Chronologique Doses & Perfusion (0 - 300 min)
|
||||
</h2>
|
||||
<span data-i18n="timeline.scroll_hint" class="text-[10px] text-slate-400 italic">Défilement horizontal 👉</span>
|
||||
<span data-i18n="timeline.scroll_hint" class="text-[10px] text-[var(--kt-text-3)] 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">
|
||||
class="flex gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-[var(--kt-scroll-thumb)] scrollbar-track-[var(--kt-scroll-track)]">
|
||||
<!-- Injecté via app.js -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau de contrôle des concentrations (0-300 min) -->
|
||||
<details class="bg-slate-800/50 border border-slate-700/50 rounded-xl mb-6">
|
||||
<details class="bg-[var(--kt-surface-2)] border border-[var(--kt-border)] rounded-xl mb-6">
|
||||
<summary
|
||||
class="p-4 cursor-pointer text-slate-300 font-semibold text-sm hover:text-white flex justify-between items-center">
|
||||
class="p-4 cursor-pointer text-[var(--kt-text-2)] font-semibold text-sm hover:text-[var(--kt-text-1)] flex justify-between items-center">
|
||||
<span data-i18n="table.summary_title">🔍 Tableau de contrôle des concentrations calculées (0 - 300 min)</span>
|
||||
<span data-i18n="table.summary_subtitle" class="text-xs text-slate-500">Cliquer pour afficher/masquer</span>
|
||||
<span data-i18n="table.summary_subtitle" class="text-xs text-[var(--kt-text-3)]">Cliquer pour afficher/masquer</span>
|
||||
</summary>
|
||||
<div class="p-4 border-t border-slate-700/50 overflow-x-auto max-h-80">
|
||||
<table class="w-full text-xs text-left text-slate-300">
|
||||
<thead class="text-slate-400 bg-slate-900/80 uppercase font-mono sticky top-0">
|
||||
<div class="p-4 border-t border-[var(--kt-border)] overflow-x-auto max-h-80">
|
||||
<table class="w-full text-xs text-left text-[var(--kt-text-2)]">
|
||||
<thead class="text-[var(--kt-text-3)] bg-[var(--kt-surface-3)] uppercase font-mono sticky top-0">
|
||||
<tr>
|
||||
<th data-i18n="table.col_time" class="px-3 py-2">Temps</th>
|
||||
<th data-i18n="table.col_bolus" class="px-3 py-2">Bolus (mg)</th>
|
||||
@@ -207,7 +234,7 @@
|
||||
<th data-i18n="table.col_kamp" class="px-3 py-2 text-purple-400">Kamp (ng/mL)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dataTableBody" class="divide-y divide-slate-800 font-mono">
|
||||
<tbody id="dataTableBody" class="divide-y divide-[var(--kt-border-soft)] font-mono">
|
||||
<!-- Généré dynamiquement en JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -220,63 +247,63 @@
|
||||
|
||||
<!-- 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 class="bg-slate-800 border border-slate-700 rounded-2xl w-full max-w-lg p-6 space-y-6 shadow-2xl relative">
|
||||
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 hidden flex items-center justify-center p-4 transition-opacity">
|
||||
<div class="bg-[var(--kt-surface-modal)] border border-[var(--kt-border)] rounded-2xl w-full max-w-lg p-6 space-y-6 shadow-2xl relative">
|
||||
|
||||
<div class="flex justify-between items-center border-b border-slate-700 pb-3">
|
||||
<div class="flex justify-between items-center border-b border-[var(--kt-border)] pb-3">
|
||||
<h3 data-i18n="calculator.title" 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">×</button>
|
||||
class="text-[var(--kt-text-3)] hover:text-[var(--kt-text-1)] font-bold text-xl leading-none transition">×</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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-[var(--kt-surface-3)] p-3 rounded-xl border border-[var(--kt-border)]">
|
||||
<div>
|
||||
<label for="calcWeight" data-i18n="calculator.patient_weight"
|
||||
class="block text-xs text-slate-400 mb-1">Poids du patient</label>
|
||||
class="block text-xs text-[var(--kt-text-3)] mb-1">Poids du patient</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" id="calcWeight" value="70" step="any"
|
||||
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>
|
||||
class="w-full bg-[var(--kt-surface-2)] border border-[var(--kt-border)] rounded-lg p-2 text-sm text-[var(--kt-text-1)] font-bold outline-none">
|
||||
<span class="text-xs text-[var(--kt-text-3)]">kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="calculator.bolus_header"
|
||||
class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Conversion Bolus</h4>
|
||||
class="text-xs font-semibold text-[var(--kt-text-3)] uppercase tracking-wider mb-2">Conversion Bolus</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="calcMg" data-i18n="calculator.total_dose" 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-[var(--kt-text-3)] mb-1">Dose totale (mg)</label>
|
||||
<input type="number" id="calcMg" step="any" 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">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg p-2 text-sm text-[var(--kt-text-1)] focus:ring-1 focus:ring-amber-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label for="calcMgKg" data-i18n="calculator.relative_dose"
|
||||
class="block text-[10px] text-slate-500 mb-1">Dose relative (mg/kg)</label>
|
||||
class="block text-[10px] text-[var(--kt-text-3)] mb-1">Dose relative (mg/kg)</label>
|
||||
<input type="number" id="calcMgKg" step="any" 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">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg p-2 text-sm text-[var(--kt-text-1)] focus:ring-1 focus:ring-amber-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 data-i18n="calculator.infusion_header"
|
||||
class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Conversion Perfusion</h4>
|
||||
class="text-xs font-semibold text-[var(--kt-text-3)] uppercase tracking-wider mb-2">Conversion Perfusion</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="calcMgKgH" data-i18n="calculator.rate_mgkgh"
|
||||
class="block text-[10px] text-slate-500 mb-1">Débit (mg/kg/h)</label>
|
||||
class="block text-[10px] text-[var(--kt-text-3)] mb-1">Débit (mg/kg/h)</label>
|
||||
<input type="number" id="calcMgKgH" step="any" 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">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg p-2 text-sm text-[var(--kt-text-1)] focus:ring-1 focus:ring-amber-500 outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label for="calcUgKgMin" data-i18n="calculator.rate_ugkgmin"
|
||||
class="block text-[10px] text-slate-500 mb-1">Débit (µg/kg/min)</label>
|
||||
class="block text-[10px] text-[var(--kt-text-3)] mb-1">Débit (µg/kg/min)</label>
|
||||
<input type="number" id="calcUgKgMin" step="any" 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">
|
||||
class="w-full bg-[var(--kt-bg)] border border-[var(--kt-border)] rounded-lg p-2 text-sm text-[var(--kt-text-1)] focus:ring-1 focus:ring-amber-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+129
-20
@@ -1,5 +1,5 @@
|
||||
import { MODES, PK_MODELS, CLINICAL_THRESHOLDS, APP_VERSION, generateSimulationData, processTimelineEvents } from './pkCore.js';
|
||||
import { adjustChartYScale } from './chartManager.js';
|
||||
import { adjustChartYScale, updateChartAnnotations } from './chartManager.js';
|
||||
import { initCalculatorUI } from './calculator.js';
|
||||
import { exportSimulationToCSV } from './csvExport.js';
|
||||
import { initI18n, getCurrentLang, t } from './i18n.js';
|
||||
@@ -8,6 +8,22 @@ let currentMode = MODES.ESKETAMINE;
|
||||
let pkChart = null;
|
||||
let currentSimulationData = null;
|
||||
|
||||
const THEME_STORAGE_KEY = 'keta_theme';
|
||||
const THEMES = ['dark', 'light', 'monokai'];
|
||||
|
||||
let timerMin = 5;
|
||||
let timespanCount = 60;
|
||||
|
||||
function buildTimelineInputs(interval, steps) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => ({
|
||||
time: i * interval,
|
||||
bolusMg: 0,
|
||||
perfRate: null
|
||||
}));
|
||||
}
|
||||
|
||||
let timelineInputs = buildTimelineInputs(timerMin, timespanCount);
|
||||
|
||||
function updateVersionDisplay() {
|
||||
const versionEl = document.querySelector('[data-i18n="app.version"]');
|
||||
if (versionEl) {
|
||||
@@ -15,13 +31,73 @@ function updateVersionDisplay() {
|
||||
}
|
||||
}
|
||||
|
||||
const timelineInputs = Array.from({ length: 61 }, (_, i) => ({
|
||||
time: i * 5,
|
||||
bolusMg: 0,
|
||||
perfRate: null
|
||||
}));
|
||||
function updateDynamicLabels() {
|
||||
const total = timerMin * timespanCount;
|
||||
|
||||
const titleEl = document.querySelector('[data-i18n="timeline.title"]');
|
||||
if (titleEl) titleEl.textContent = t('timeline.title', { total });
|
||||
|
||||
const summaryEl = document.querySelector('[data-i18n="table.summary_title"]');
|
||||
if (summaryEl) summaryEl.textContent = t('table.summary_title', { total });
|
||||
|
||||
const totalEl = document.getElementById('totalTimespanDisplay');
|
||||
if (totalEl) totalEl.textContent = t('patient.total_label', { total });
|
||||
}
|
||||
|
||||
function getStoredTheme() {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
return THEMES.includes(stored) ? stored : 'dark';
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const resolved = THEMES.includes(theme) ? theme : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, resolved);
|
||||
refreshChartTheme();
|
||||
}
|
||||
|
||||
function refreshChartTheme() {
|
||||
if (!pkChart) return;
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const gridColor = styles.getPropertyValue('--kt-chart-grid').trim();
|
||||
const textColor = styles.getPropertyValue('--kt-chart-text').trim();
|
||||
const legendColor = styles.getPropertyValue('--kt-chart-legend').trim();
|
||||
|
||||
pkChart.options.scales.y.title.color = textColor;
|
||||
pkChart.options.scales.y.grid.color = gridColor;
|
||||
pkChart.options.scales.x.title.color = textColor;
|
||||
pkChart.options.scales.x.grid.color = gridColor;
|
||||
pkChart.options.plugins.legend.labels.color = legendColor;
|
||||
|
||||
// Les couleurs de texte des zones cliniques dépendent aussi du thème (cf. chartManager.js)
|
||||
updateChartAnnotations(pkChart, currentMode, pkChart.options.scales.y.max);
|
||||
pkChart.update();
|
||||
}
|
||||
|
||||
function readPositiveInt(el, fallback, max = Infinity) {
|
||||
const parsed = parseInt(el.value.trim(), 10);
|
||||
if (isNaN(parsed) || parsed < 1) return fallback;
|
||||
return Math.min(parsed, max);
|
||||
}
|
||||
|
||||
function enforceIntegerInput(el) {
|
||||
el?.addEventListener('input', () => {
|
||||
const cleaned = el.value.replace(/[^\d]/g, '');
|
||||
if (cleaned !== el.value) el.value = cleaned;
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildTimeline() {
|
||||
timelineInputs = buildTimelineInputs(timerMin, timespanCount);
|
||||
pkChart.data.labels = timelineInputs.map(d => `${d.time}'`);
|
||||
renderTimelineGrid();
|
||||
updateDynamicLabels();
|
||||
updateSimulation();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
applyTheme(getStoredTheme());
|
||||
|
||||
await initI18n();
|
||||
updateVersionDisplay();
|
||||
|
||||
@@ -32,12 +108,41 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await initI18n(e.target.value);
|
||||
updateVersionDisplay();
|
||||
renderTimelineGrid();
|
||||
updateDynamicLabels();
|
||||
updateSimulation();
|
||||
});
|
||||
}
|
||||
|
||||
const selectTheme = document.getElementById('selectTheme');
|
||||
if (selectTheme) {
|
||||
selectTheme.value = getStoredTheme();
|
||||
selectTheme.addEventListener('change', (e) => applyTheme(e.target.value));
|
||||
}
|
||||
|
||||
const timerInput = document.getElementById('timerInput');
|
||||
const timespanInput = document.getElementById('timespanInput');
|
||||
if (timerInput) {
|
||||
timerInput.value = timerMin;
|
||||
enforceIntegerInput(timerInput);
|
||||
timerInput.addEventListener('change', () => {
|
||||
timerMin = readPositiveInt(timerInput, timerMin);
|
||||
timerInput.value = timerMin;
|
||||
rebuildTimeline();
|
||||
});
|
||||
}
|
||||
if (timespanInput) {
|
||||
timespanInput.value = timespanCount;
|
||||
enforceIntegerInput(timespanInput);
|
||||
timespanInput.addEventListener('change', () => {
|
||||
timespanCount = readPositiveInt(timespanInput, timespanCount, 500);
|
||||
timespanInput.value = timespanCount;
|
||||
rebuildTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
initChart();
|
||||
renderTimelineGrid();
|
||||
updateDynamicLabels();
|
||||
initCalculatorUI();
|
||||
setupEventListeners();
|
||||
updateSimulation();
|
||||
@@ -45,6 +150,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
function initChart() {
|
||||
const ctx = document.getElementById('pkChart').getContext('2d');
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const gridColor = styles.getPropertyValue('--kt-chart-grid').trim();
|
||||
const textColor = styles.getPropertyValue('--kt-chart-text').trim();
|
||||
const legendColor = styles.getPropertyValue('--kt-chart-legend').trim();
|
||||
|
||||
pkChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
@@ -59,16 +168,16 @@ function initChart() {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: CLINICAL_THRESHOLDS[currentMode].maxScale,
|
||||
title: { display: true, text: 'Cp (ng/mL)', color: '#94a3b8' },
|
||||
grid: { color: 'rgba(51, 65, 85, 0.4)' }
|
||||
title: { display: true, text: 'Cp (ng/mL)', color: textColor },
|
||||
grid: { color: gridColor }
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: 'Temps (minutes)', color: '#94a3b8' },
|
||||
grid: { color: 'rgba(51, 65, 85, 0.4)' }
|
||||
title: { display: true, text: 'Temps (minutes)', color: textColor },
|
||||
grid: { color: gridColor }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { labels: { color: '#f8fafc' } },
|
||||
legend: { labels: { color: legendColor } },
|
||||
annotation: { annotations: {} }
|
||||
}
|
||||
}
|
||||
@@ -156,7 +265,7 @@ function renderDataTable(simulationData) {
|
||||
|
||||
simulationData.timeline.forEach((item, index) => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = index % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/30';
|
||||
row.className = index % 2 === 0 ? 'bg-[var(--kt-row-alt-1)]' : 'bg-[var(--kt-row-alt-2)]';
|
||||
|
||||
const dominoCp = (simulationData.domino && simulationData.domino[index]) ? Math.round(simulationData.domino[index].cp) : '-';
|
||||
const clementsCp = (simulationData.clements && simulationData.clements[index]) ? Math.round(simulationData.clements[index].cp) : '-';
|
||||
@@ -182,16 +291,16 @@ function renderTimelineGrid() {
|
||||
|
||||
timelineInputs.forEach((item, idx) => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'flex-1 bg-slate-900 border border-slate-700/60 rounded-xl p-2 text-center text-xs flex flex-col gap-1.5 min-w-[85px]';
|
||||
col.className = 'flex-1 bg-[var(--kt-surface-3)] border border-[var(--kt-border-soft)] rounded-xl p-2 text-center text-xs flex flex-col gap-1.5 min-w-[85px]';
|
||||
|
||||
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-[var(--kt-border-soft)] pb-1">${item.time} min</div>
|
||||
<div>
|
||||
<span class="text-[10px] text-slate-400 block mb-0.5">${t('timeline.bolus')}</span>
|
||||
<input type="number" step="any" data-idx="${idx}" data-field="bolusMg" value="" 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">
|
||||
<span class="text-[10px] text-[var(--kt-text-3)] block mb-0.5">${t('timeline.bolus')}</span>
|
||||
<input type="number" step="any" data-idx="${idx}" data-field="bolusMg" value="" placeholder="0" class="w-full bg-[var(--kt-surface-2)] border border-[var(--kt-border)] text-center rounded text-[var(--kt-text-1)] py-1 outline-none focus:border-amber-500">
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[10px] text-slate-400 block mb-0.5">${t('timeline.perf')}</span>
|
||||
<span class="text-[10px] text-[var(--kt-text-3)] block mb-0.5">${t('timeline.perf')}</span>
|
||||
<input type="number" step="any" data-idx="${idx}" data-field="perfRate" value="" placeholder="0" class="w-full border text-center rounded py-1 outline-none transition-colors">
|
||||
</div>
|
||||
`;
|
||||
@@ -223,7 +332,7 @@ function updateTimelineGridStyles() {
|
||||
input.value = timelineInputs[idx].perfRate !== null ? timelineInputs[idx].perfRate : '';
|
||||
}
|
||||
} else {
|
||||
input.className = 'w-full border text-center rounded py-1 outline-none transition-colors bg-slate-950/80 border-slate-800/50 text-slate-500 font-normal italic focus:border-amber-500';
|
||||
input.className = 'w-full border text-center rounded py-1 outline-none transition-colors bg-[var(--kt-surface-3)] border-[var(--kt-border-soft)] text-[var(--kt-text-3)] font-normal italic focus:border-amber-500';
|
||||
if (!isFocused) {
|
||||
input.value = event.infusionRate > 0 ? event.infusionRate : '';
|
||||
}
|
||||
@@ -309,10 +418,10 @@ function setMode(mode) {
|
||||
|
||||
if (mode === MODES.ESKETAMINE) {
|
||||
btnEsk.className = 'px-4 py-1.5 rounded-lg text-xs font-bold bg-amber-500 text-slate-950 shadow-md shadow-amber-500/30';
|
||||
btnRac.className = 'px-4 py-1.5 rounded-lg text-xs font-bold text-slate-400 hover:text-slate-200';
|
||||
btnRac.className = 'px-4 py-1.5 rounded-lg text-xs font-bold text-[var(--kt-text-3)] hover:text-[var(--kt-text-1)]';
|
||||
} else {
|
||||
btnRac.className = 'px-4 py-1.5 rounded-lg text-xs font-bold bg-fuchsia-500 text-slate-950 shadow-md shadow-fuchsia-500/30';
|
||||
btnEsk.className = 'px-4 py-1.5 rounded-lg text-xs font-bold text-slate-400 hover:text-slate-200';
|
||||
btnEsk.className = 'px-4 py-1.5 rounded-lg text-xs font-bold text-[var(--kt-text-3)] hover:text-[var(--kt-text-1)]';
|
||||
}
|
||||
updateSimulation();
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import { CLINICAL_THRESHOLDS } from './pkCore.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
// Le texte des zones cliniques (zone.textHex) est calibré pour un fond sombre.
|
||||
// Sur le thème clair, il faut un texte foncé pour rester lisible sur le même fond pastel.
|
||||
const LIGHT_THEME_ZONE_TEXT = {
|
||||
inactive: '#854d0e',
|
||||
anti_hyperalgesic: '#3f6212',
|
||||
analgesia: '#92400e',
|
||||
psychedelic: '#9d174d',
|
||||
emergence: '#075985',
|
||||
narcosis: '#1e3a8a',
|
||||
excessive: '#7f1d1d'
|
||||
};
|
||||
|
||||
export function updateChartAnnotations(chart, mode, currentYMax) {
|
||||
const thresholds = CLINICAL_THRESHOLDS[mode];
|
||||
const theme = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
const annotations = {};
|
||||
|
||||
thresholds.zones.forEach((zone, index) => {
|
||||
@@ -18,7 +31,7 @@ export function updateChartAnnotations(chart, mode, currentYMax) {
|
||||
label: {
|
||||
display: true,
|
||||
content: t(`zones.${zone.zoneKey}`),
|
||||
color: zone.textHex,
|
||||
color: theme === 'light' ? (LIGHT_THEME_ZONE_TEXT[zone.zoneKey] || '#1e293b') : zone.textHex,
|
||||
font: { size: 11, weight: 'bold' },
|
||||
position: 'center'
|
||||
}
|
||||
|
||||
+51
-16
@@ -8,7 +8,7 @@
|
||||
/**
|
||||
* Version unique de l'application (Centralisée)
|
||||
*/
|
||||
export const APP_VERSION = '0.16.0';
|
||||
export const APP_VERSION = '0.18.0';
|
||||
|
||||
export const MODES = {
|
||||
ESKETAMINE: 'ESKETAMINE',
|
||||
@@ -142,32 +142,67 @@ export function processTimelineEvents(timelineInputs, weightKg, infusionUnit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Superposition Linéaire des Bolus et Perfusions sur 300 min (ng/mL arrondis à l'entier)
|
||||
* Convertit le débit de perfusion courant en mg/kg/min, quelle que soit l'unité saisie
|
||||
*/
|
||||
function infusionRateToMgKgPerMin(rate, unit, weightKg) {
|
||||
if (!rate || rate <= 0) return 0;
|
||||
|
||||
switch (unit) {
|
||||
case INFUSION_UNITS.MG_KG_H:
|
||||
return rate / 60;
|
||||
case INFUSION_UNITS.MCG_KG_MIN:
|
||||
return rate / 1000;
|
||||
case INFUSION_UNITS.MG_H:
|
||||
return weightKg > 0 ? (rate / weightKg) / 60 : 0;
|
||||
default:
|
||||
return rate / 60;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résolution exacte (méthode récursive discrétisée, cf. formule KétaPK) des bolus et
|
||||
* perfusions (à débit variable) sur la timeline, ng/mL arrondis à l'entier.
|
||||
*/
|
||||
export function generateSimulationData({ model, weightKg, timelineInputs, infusionUnit = INFUSION_UNITS.MG_KG_H }) {
|
||||
const processedTimeline = processTimelineEvents(timelineInputs, weightKg, infusionUnit);
|
||||
const { A, alpha, B, beta, C, gamma, refDose } = model;
|
||||
const cpResults = [];
|
||||
|
||||
processedTimeline.forEach((targetEvent, targetIndex) => {
|
||||
let totalCp = 0;
|
||||
if (weightKg <= 0 || !refDose) {
|
||||
return processedTimeline.map(event => ({ time: event.time, cp: 0 }));
|
||||
}
|
||||
|
||||
for (let i = 0; i <= targetIndex; i++) {
|
||||
const sourceEvent = processedTimeline[i];
|
||||
const deltaTime = targetEvent.time - sourceEvent.time;
|
||||
let X1 = 0, X2 = 0, X3 = 0;
|
||||
|
||||
if (sourceEvent.bolusMg > 0) {
|
||||
totalCp += calculateBolusCp(model, sourceEvent.bolusMg, weightKg, deltaTime);
|
||||
}
|
||||
|
||||
if (sourceEvent.infusionMg5Min > 0) {
|
||||
totalCp += calculateBolusCp(model, sourceEvent.infusionMg5Min, weightKg, deltaTime);
|
||||
}
|
||||
processedTimeline.forEach((event, index) => {
|
||||
if (event.bolusMg > 0) {
|
||||
const scaledDose = (event.bolusMg / weightKg) / refDose;
|
||||
X1 += A * scaledDose;
|
||||
X2 += B * scaledDose;
|
||||
X3 += gamma ? C * scaledDose : 0;
|
||||
}
|
||||
|
||||
cpResults.push({
|
||||
time: targetEvent.time,
|
||||
cp: Math.round(totalCp)
|
||||
time: event.time,
|
||||
cp: Math.round(X1 + X2 + X3)
|
||||
});
|
||||
|
||||
const nextEvent = processedTimeline[index + 1];
|
||||
if (!nextEvent) return;
|
||||
|
||||
const deltaT = nextEvent.time - event.time;
|
||||
const Rn = infusionRateToMgKgPerMin(event.infusionRate, infusionUnit, weightKg) / refDose;
|
||||
|
||||
const Falpha = Math.exp(-alpha * deltaT);
|
||||
const Fbeta = Math.exp(-beta * deltaT);
|
||||
|
||||
X1 = Falpha * X1 + (A / alpha) * (1 - Falpha) * Rn;
|
||||
X2 = Fbeta * X2 + (B / beta) * (1 - Fbeta) * Rn;
|
||||
|
||||
if (gamma) {
|
||||
const Fgamma = Math.exp(-gamma * deltaT);
|
||||
X3 = Fgamma * X3 + (C / gamma) * (1 - Fgamma) * Rn;
|
||||
}
|
||||
});
|
||||
|
||||
return cpResults;
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
"name_placeholder": "例如:张三",
|
||||
"weight_label": "患者体重 (kg)",
|
||||
"weight_warning": "⚠️ 药代动力学模型仅在 30 - 120 kg 范围内保证准确性。",
|
||||
"infusion_unit_label": "连续输注单位"
|
||||
"infusion_unit_label": "连续输注单位",
|
||||
"timeline_resolution_label": "时间轴分辨率",
|
||||
"timer_label": "计时器(分钟)",
|
||||
"timespan_label": "数值个数",
|
||||
"total_label": "总时长:{total} 分钟"
|
||||
},
|
||||
"units": {
|
||||
"mg_kg_h": "mg / kg / h",
|
||||
@@ -38,7 +42,7 @@
|
||||
"export_csv": "📊 导出 CSV 数据 (ng/mL)"
|
||||
},
|
||||
"table": {
|
||||
"summary_title": "🔍 浓度计算控制表 (0 - 300 min)",
|
||||
"summary_title": "🔍 浓度计算控制表 (0 - {total} min)",
|
||||
"summary_subtitle": "点击展开/折叠",
|
||||
"col_time": "时间",
|
||||
"col_bolus": "负荷剂量 (mg)",
|
||||
@@ -60,7 +64,7 @@
|
||||
"title": "📈 药代动力学模型"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "⏱️ 给药与输注按时间录入 (0 - 300 min)",
|
||||
"title": "⏱️ 给药与输注按时间录入 (0 - {total} min)",
|
||||
"scroll_hint": "横向滚动 👉",
|
||||
"bolus": "负荷剂量 (mg)",
|
||||
"perf": "输注速率"
|
||||
@@ -75,5 +79,10 @@
|
||||
"infusion_header": "输注速率转换",
|
||||
"rate_mgkgh": "速率 (mg/kg/h)",
|
||||
"rate_ugkgmin": "速率 (µg/kg/min)"
|
||||
},
|
||||
"theme": {
|
||||
"dark": "深色",
|
||||
"light": "浅色",
|
||||
"monokai": "Monokai"
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,11 @@
|
||||
"name_placeholder": "e.g. SMITH John",
|
||||
"weight_label": "Patient Weight (kg)",
|
||||
"weight_warning": "⚠️ Pharmacokinetic models are not guaranteed outside the 30 - 120 kg range.",
|
||||
"infusion_unit_label": "Continuous infusion unit"
|
||||
"infusion_unit_label": "Continuous infusion unit",
|
||||
"timeline_resolution_label": "Timeline resolution",
|
||||
"timer_label": "Timer (min)",
|
||||
"timespan_label": "Timespan (values)",
|
||||
"total_label": "Total duration: {total} min"
|
||||
},
|
||||
"units": {
|
||||
"mg_kg_h": "mg / kg / h",
|
||||
@@ -38,7 +42,7 @@
|
||||
"export_csv": "📊 Export CSV values (ng/mL)"
|
||||
},
|
||||
"table": {
|
||||
"summary_title": "🔍 Concentration Control Table (0 - 300 min)",
|
||||
"summary_title": "🔍 Concentration Control Table (0 - {total} min)",
|
||||
"summary_subtitle": "Click to expand/collapse",
|
||||
"col_time": "Time",
|
||||
"col_bolus": "Bolus (mg)",
|
||||
@@ -60,7 +64,7 @@
|
||||
"title": "Pharmacokinetic Models"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronological Doses & Infusion Entry (0 - 300 min)",
|
||||
"title": "Chronological Doses & Infusion Entry (0 - {total} min)",
|
||||
"scroll_hint": "Horizontal scroll 👉",
|
||||
"bolus": "Bolus (mg)",
|
||||
"perf": "Infusion rate"
|
||||
@@ -75,5 +79,10 @@
|
||||
"infusion_header": "Infusion Conversion",
|
||||
"rate_mgkgh": "Rate (mg/kg/h)",
|
||||
"rate_ugkgmin": "Rate (µg/kg/min)"
|
||||
},
|
||||
"theme": {
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"monokai": "Monokai"
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,11 @@
|
||||
"name_placeholder": "ex: DUPONT Jean",
|
||||
"weight_label": "Poids du patient (kg)",
|
||||
"weight_warning": "⚠️ Modèles pharmacocinétiques non garantis hors de la plage 30 - 120 kg.",
|
||||
"infusion_unit_label": "Unité de perfusion continue"
|
||||
"infusion_unit_label": "Unité de perfusion continue",
|
||||
"timeline_resolution_label": "Résolution de la timeline",
|
||||
"timer_label": "Intervalles (min)",
|
||||
"timespan_label": "Nombre d'Intervalles",
|
||||
"total_label": "Durée totale : {total} min"
|
||||
},
|
||||
"units": {
|
||||
"mg_kg_h": "mg / kg / h",
|
||||
@@ -38,7 +42,7 @@
|
||||
"export_csv": "📊 Exporter valeurs CSV (ng/mL)"
|
||||
},
|
||||
"table": {
|
||||
"summary_title": "🔍 Tableau de contrôle des concentrations calculées (0 - 300 min)",
|
||||
"summary_title": "🔍 Tableau de contrôle des concentrations calculées (0 - {total} min)",
|
||||
"summary_subtitle": "Cliquer pour afficher/masquer",
|
||||
"col_time": "Temps",
|
||||
"col_bolus": "Bolus (mg)",
|
||||
@@ -60,7 +64,7 @@
|
||||
"title": "Modèles Pharmacocinétiques"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Saisie Chronologique Doses & Perfusion (0 - 300 min)",
|
||||
"title": "Saisie Chronologique Doses & Perfusion (0 - {total} min)",
|
||||
"scroll_hint": "Défilement horizontal 👉",
|
||||
"bolus": "Bolus (mg)",
|
||||
"perf": "Débit perf."
|
||||
@@ -75,5 +79,10 @@
|
||||
"infusion_header": "Conversion Perfusion",
|
||||
"rate_mgkgh": "Débit (mg/kg/h)",
|
||||
"rate_ugkgmin": "Débit (µg/kg/min)"
|
||||
},
|
||||
"theme": {
|
||||
"dark": "Sombre",
|
||||
"light": "Clair",
|
||||
"monokai": "Monokai"
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -4,4 +4,6 @@ version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"oletools>=0.60.2",
|
||||
]
|
||||
|
||||
Generated
+1
-1
@@ -1616,7 +1616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ketapk"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "ketapk"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
description = "KétaPK"
|
||||
authors = ["Gauvain BOICHÉ", "Pr. Georges MION"]
|
||||
authors = ["Pr. Georges MION", "Gauvain BOICHÉ"]
|
||||
license = "Proprietary"
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ketapk",
|
||||
"version": "0.16.0",
|
||||
"version": "0.18.0",
|
||||
"identifier": "com.ketapk.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "",
|
||||
|
||||
Reference in New Issue
Block a user