feat: Adding color themes + .ignore files update

This commit is contained in:
gauvainboiche
2026-08-29 15:09:50 +02:00
parent 257e187964
commit 786b87270c
16 changed files with 419 additions and 169 deletions
+131 -22
View File
@@ -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,16 +31,76 @@ 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();
const selectLang = document.getElementById('selectLang');
if (selectLang) {
selectLang.value = getCurrentLang();
@@ -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();
}
+14 -1
View File
@@ -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
View File
@@ -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;