Siphonight dfef8e90ef v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)
Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) :
- js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les
  labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors
  fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab],
  < 2 labs → vide, indépendante de la calibration (sinon double
  correction). JAMAIS dans levelAt/alertes.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles +
  scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ;
  couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE
  de la boucle légende (sinon légende TFS dupliquée).
- 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total).
- WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README).

Détail d'exécution épingle: les deux problèmes du portage ont été
attrapés par node --check/E2E avant release (imports ESM faux —
labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
2026-09-11 16:17:36 +02:00

440 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ─────────────────────────────────────────────────────────────────────────────
* Écran Graphiques (portage de `ui/screens/ChartScreen.kt` — l'écran le plus
* riche de l'app).
*
* Fonctions portées à l'identique :
* - plages 24 h / 7 j / 30 j + ZOOM −/+ (pinch + molette, borné 6 h → 300 j,
* échantillonnage adaptatif stepForRange, focal du pinch maintenu) ;
* - PANORAMIQUE : glisser = passé, glisser ← = futur (prévision active),
* cumul fractionnaire des deltas (fix #62) + clamp unique clampPanHours ;
* - toggles de MODÈLES superposables (ESE / TFS / WHSAH), alignés au
* chargement sur les traitements à PROFIL PK (fix #58, garde-fou TFS) ;
* - toggles T (masque aussi les labs T) / labs / pics-creux / prévision ;
* - PRÉVISION : doses projetées (grille Posologie), horizon 12× Posologie
* borné [30 j ; 1 an], extension de fenêtre à droite sans déplacement
* (fix #54), retour à maintenant à la désactivation (v1.4.1) ;
* - CALIBRATION PAR MODÈLE (fix #60) : une AutoCalibrated par modèle
* affiché — chaque courbe colle à ses labs quand l'auto est active ;
* - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition
* côté Android — côté web, même discipline : endMs ne dérive pas à chaque
* frame, il ne change qu'au tick ou sur interaction) ;
* - PAS de scroll horizontal parent : le drag horizontal est réservé au pan
* (piège Android #62/§11).
* ─────────────────────────────────────────────────────────────────────────────
*/
import {
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
computeLabAnchoredCurve,
} from '../pk/index.js';
import {
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
forecastHorizonHours, forecastExtensionHours,
} from '../pk/chart-helpers.js';
import { store } from '../data/store.js';
import { t } from '../util/i18n.js';
import { formatValue } from '../util/format.js';
import { el, filterChip } from './components.js';
import { drawChart, COLORS } from './chart-canvas.js';
const HOUR_MS = 3600000;
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
const MIN_RANGE_H = 6;
const MAX_RANGE_H = 24 * 300;
/** Styles visuels des séries (miroir de la map du ChartScreen Android). */
function seriesStyles(model) {
if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } };
if (model === 'WHS') return { e2: { color: COLORS.chartWhs, dashed: false }, t: { color: COLORS.chartTWhs, dashed: true } };
return { e2: { color: COLORS.tealTertiary, dashed: false }, t: { color: COLORS.chartT, dashed: true } };
}
/**
* Rend l'écran Graphiques dans [container].
* @param {HTMLElement} container
* @param {object} ctx { nowMs }
*/
export function renderChart(container, ctx) {
const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis)
const doseLogs = store.getDoseLogs();
const labResults = store.getLabResults();
const tConfigRaw = store.getTConfig();
const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k);
const settings = store.getSettings();
const chartTimeZone = settings.chartTimezone || null;
// ── État d'interaction (miroir des mutableStateOf Compose) ────────────────
const state = {
rangeHours: 24,
panHours: 0,
showT: true,
showLabs: true,
showEse: true,
showTfs: true,
showWhs: false,
showForecast: false,
showExtrema: false,
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false,
};
// ── Colonne racine ───────────────────────────────────────────────────────
const col = el('div', {});
container.appendChild(col);
// Rangée 1 : plages + zoom
const rangeRow = el('div', { class: 'chip-row' });
col.appendChild(rangeRow);
// Rangée 2 : toggles affichage
const displayRow = el('div', { class: 'chip-row' });
col.appendChild(displayRow);
// Rangée 3 : toggles modèles
const modelRow = el('div', { class: 'chip-row' });
col.appendChild(modelRow);
// Carte graphique + légende
const chartCard = el('div', { class: 'card' });
const canvas = el('canvas', { class: 'chart chart-cursor', style: 'height:320px;' });
chartCard.appendChild(canvas);
const backBtnWrap = el('div', {});
chartCard.appendChild(backBtnWrap);
const legend = el('div', {});
chartCard.appendChild(legend);
col.appendChild(chartCard);
// ── Calibration PAR MODÈLE (fix #60) : une AutoCalibrated par modèle ─────
const autoByModel = new Map();
if (settings.autoCalibrate) {
for (const m of ['ESE', 'TFS', 'WHS']) {
autoByModel.set(m, autoCalibrated(treatments, doseLogs, labResults, tConfig, m));
}
}
// ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ────
const maxIntervalDays = treatments
.map((tr) => tr.forecastIntervalDays)
.filter((x) => x !== null && x !== undefined)
.reduce((m, x) => Math.max(m, x), 0) || null;
const horizonHours = forecastHorizonHours(maxIntervalDays);
// Gap jusqu'au 1ᵉʳ créneau projeté (mémoïsé — même discipline que fix #56 :
// ce calcul n'est refait qu'au re-rendu, pas à chaque événement de drag)
let firstSlotGapH = null;
if (horizonHours !== null) {
const at = ctx.nowMs;
const horizonMs = horizonHours * HOUR_MS;
let minSlot = null;
for (const tr of treatments) {
for (const d of generateForecastDoses(tr, doseLogs, at + horizonMs, at)) {
if (minSlot === null || d.timestamp < minSlot) minSlot = d.timestamp;
}
}
if (minSlot !== null) firstSlotGapH = Math.trunc((minSlot - at) / HOUR_MS);
}
// ── Fenêtre temporelle ───────────────────────────────────────────────────
function windowRange() {
const nowMs = ctx.nowMs;
const futurePanHorizon = state.showForecast ? (horizonHours || 0) : 0;
const extensionH = (state.showForecast && horizonHours !== null)
? forecastExtensionHours(state.rangeHours, horizonHours, firstSlotGapH)
: 0;
const endMs = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS;
const startMs = endMs - state.rangeHours * HOUR_MS;
return { nowMs, futurePanHorizon, extensionH, startMs, endMs };
}
// ── Toggle init des modèles : aligné sur les traitements à PROFIL PK ─────
// (fix #58 — filtrer par usesProfileModel : un traitement oral polluait le
// set avec son pkModel stocké ESE ; garde-fou TFS si aucun profil).
if (!state.togglesInitialized && treatments.length > 0) {
const used = new Set(
treatments
.filter((tr) => tr.route === 'INJECTION_IM' || tr.route === 'INJECTION_SUBCUT')
.filter((tr) => tr.esterType !== 'NONE')
.map((tr) => tr.pkModel),
);
const toggles = defaultModelToggles(used);
state.showEse = toggles.ESE;
state.showTfs = toggles.TFS;
state.showWhs = toggles.WHS;
state.togglesInitialized = true;
}
// ── Calcul + dessin ──────────────────────────────────────────────────────
function redraw() {
const { nowMs, futurePanHorizon, extensionH, startMs, endMs } = windowRange();
// Rangée 1 : plages + zoom
rangeRow.textContent = '';
rangeRow.appendChild(filterChip(t('chart_24h'), state.rangeHours === 24, () => { state.rangeHours = 24; state.panHours = clampPan(state.panHours); redraw(); }));
rangeRow.appendChild(filterChip(t('chart_7j'), state.rangeHours === 24 * 7, () => { state.rangeHours = 24 * 7; redraw(); }));
rangeRow.appendChild(filterChip(t('chart_30j'), state.rangeHours === 24 * 30, () => { state.rangeHours = 24 * 30; redraw(); }));
const zoomIn = el('button', { class: 'chip', disabled: state.rangeHours <= MIN_RANGE_H }, '+');
zoomIn.addEventListener('click', () => {
// Convention carte : « + » = zoom AVANT (fenêtre plus courte, v1.2.10)
state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H);
redraw();
});
const zoomOut = el('button', { class: 'chip', disabled: state.rangeHours >= MAX_RANGE_H }, '−');
zoomOut.addEventListener('click', () => {
state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H);
redraw();
});
rangeRow.appendChild(zoomIn);
rangeRow.appendChild(zoomOut);
// Rangée 2 : toggles affichage
displayRow.textContent = '';
displayRow.appendChild(filterChip('T', state.showT, () => { state.showT = !state.showT; redraw(); }));
displayRow.appendChild(filterChip(t('show_labs'), state.showLabs, () => { state.showLabs = !state.showLabs; redraw(); }));
displayRow.appendChild(filterChip(t('show_forecast'), state.showForecast, () => {
state.showForecast = !state.showForecast;
// v1.4.1 : désactiver la prévision pendant un voyage futur → retour
// à maintenant (sinon fenêtre vide au-delà de « maintenant »)
if (!state.showForecast) state.panHours = Math.max(state.panHours, 0);
redraw();
}, horizonHours === null));
displayRow.appendChild(filterChip(t('show_extrema'), state.showExtrema, () => { state.showExtrema = !state.showExtrema; redraw(); }));
// Rangée 3 : toggles modèles superposables
modelRow.textContent = '';
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); }));
// ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = [];
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
// de la calibration (elle EST sa propre calibration continue — épinglé).
if (state.showLabTrack) {
const step = stepForRange(state.rangeHours);
const anchored = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null,
);
curves.push(['LAB', anchored.points]);
}
if (state.showEse || state.showTfs || state.showWhs) {
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
// traitements/le tConfig y sont identiques — seules échelles/k diffèrent).
const refAuto = autoByModel.get('TFS');
const effTreatments = refAuto ? refAuto.treatments : treatments;
const effTConfig = refAuto ? refAuto.tConfig : tConfig;
// Doses réelles + doses prévisionnelles éventuelles
const forecastDoses = state.showForecast && horizonHours !== null
? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs))
: [];
const allDoses = doseLogs.concat(forecastDoses);
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
const step = stepForRange(state.rangeHours);
const addCurve = (model) => {
const autoM = autoByModel.get(model);
curves.push([model, computeCurve(
effTreatments, allDoses, startMs, endMs, step, autoM ? autoM.tConfig : effTConfig,
{
modelOverride: model,
scalePerEster: autoM ? autoM.esterScales : null,
tKPerEster: autoM ? autoM.tKPerEster : null,
},
)]);
};
if (state.showEse) addCurve('ESE');
if (state.showTfs) addCurve('TFS');
if (state.showWhs) addCurve('WHS');
}
const allPoints = curves.flatMap(([, pts]) => pts);
// Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T
let a = 0;
let b = 0;
if (allPoints.length >= 2) {
a = Math.min(...allPoints.map((p) => p.timestamp));
b = Math.max(...allPoints.map((p) => p.timestamp));
}
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
const tLabs = state.showT
? labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b)
: [];
// ── Contenu : graphique ou « aucune donnée » ────────────────────────────
backBtnWrap.textContent = '';
legend.textContent = '';
if (curves.length === 0 || allPoints.length < 2) {
canvas.style.display = 'none';
legend.appendChild(el('p', {}, t('no_data')));
} else {
canvas.style.display = '';
const forecastDosesForMarkers = state.showForecast && horizonHours !== null
? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs))
: [];
requestAnimationFrame(() => {
drawChart(
canvas,
curves.map(([model, pts]) => {
if (model === 'LAB') {
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
// (série E2-only) et hors pics/creux (showExtrema=false)
return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false };
}
const st = seriesStyles(model);
return { points: pts, e2Style: st.e2, tStyle: st.t };
}),
e2Labs,
tLabs,
{ showT: state.showT, showLabs: state.showLabs, showExtrema: state.showExtrema, nowMs },
forecastDosesForMarkers,
doseLogs,
chartTimeZone,
);
});
// Bouton « Revenir à maintenant » dès qu'on a voyagé (passé OU futur)
if (state.panHours !== 0) {
backBtnWrap.appendChild(el('button', {
class: 'btn text',
onclick: () => { state.panHours = 0; state.panResidualHours = 0; redraw(); },
}, t('back_to_present')));
}
// Légende dynamique (miroir du bloc Compose)
for (const [model] of curves) {
// ⚠️ La clé 'LAB' est SKIPPÉE (légende dédiée plus bas, fix #63
// Android-miroir : sinon le `else` imprime la légende TFS en doublon)
if (model === 'LAB') continue;
const st = seriesStyles(model);
if (model === 'ESE') {
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
if (state.showT) legend.appendChild(legendLine(t('legend_ese_t'), st.t.color));
} else if (model === 'WHS') {
legend.appendChild(legendLine(t('legend_whs_e2'), st.e2.color));
if (state.showT) legend.appendChild(legendLine(t('legend_whs_t'), st.t.color));
} else {
legend.appendChild(legendLine(t('legend_tfs_e2'), st.e2.color));
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
}
}
// Légende modèles : la clé 'LAB' est SKIPPÉE (la boucle for-of ne
// gère que ESE/TFS/WHS — pas de catchall pour LAB, sinon légende
// dupliquée : miroir de la leçon #63 Android)
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
// v1.5.0 : légende dédiée « Tracé labs » (génère rien si la série vide)
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory));
}
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
}
// Re-clamp du pan avec l'horizon courant
function clampPan(pan) {
const nowMs = ctx.nowMs;
const earliestDoseMs = doseLogs.length ? Math.min(...doseLogs.map((d) => d.timestamp)) : nowMs;
const maxPanHours = Math.max(Math.trunc((nowMs - earliestDoseMs) / HOUR_MS) + state.rangeHours / HOUR_MS, 0);
return clampPanHours(pan, state.showForecast ? (horizonHours || 0) : 0, maxPanHours);
}
}
// ── Gestes : pan (1 pointeur) + pinch (2 pointeurs) + molette ────────────
// ⚠️ FIX #62 : les deltas < 1 h sont CUMULÉS via panResidualHours (pan
// fonctionnel sur la vue 24 h). Un seul handler pour pan+pinch (miroir
// detectTransformGestures — un handler = pas de conflit de consommation).
const activePointers = new Map();
let pinchStart = null; // { dist, centroidX, rangeHoursAtStart }
function maxPanHoursNow() {
const nowMs = ctx.nowMs;
const earliestDoseMs = doseLogs.length ? Math.min(...doseLogs.map((d) => d.timestamp)) : nowMs;
return Math.max(Math.trunc((nowMs - earliestDoseMs) / HOUR_MS) + state.rangeHours / HOUR_MS, 0);
}
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId);
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
const [p1, p2] = [...activePointers.values()];
pinchStart = {
dist: Math.abs(p1.x - p2.x) || 1,
centroidX: (p1.x + p2.x) / 2,
rangeHoursAtStart: state.rangeHours,
};
}
});
canvas.addEventListener('pointermove', (e) => {
if (!activePointers.has(e.pointerId)) return;
const prev = activePointers.get(e.pointerId);
const cur = { x: e.clientX, y: e.clientY };
activePointers.set(e.pointerId, cur);
if (activePointers.size === 2 && pinchStart) {
// ── Pinch : zoom autour du centroid (le temps sous les doigts reste fixe)
const [p1, p2] = [...activePointers.values()];
const dist = Math.abs(p1.x - p2.x) || 1;
const zoom = dist / pinchStart.dist; // > 1 = doigts écartés = zoom avant
const rect = canvas.getBoundingClientRect();
const f = Math.min(Math.max((pinchStart.centroidX - rect.left) / rect.width, 0), 1);
const newRange = Math.min(Math.max(Math.trunc(pinchStart.rangeHoursAtStart / zoom), MIN_RANGE_H), MAX_RANGE_H);
if (newRange !== state.rangeHours) {
const { nowMs, extensionH } = windowRange();
const endAnchor = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS;
const tFocal = endAnchor - Math.trunc((1 - f) * state.rangeHours * HOUR_MS);
const newEnd = tFocal + Math.trunc((1 - f) * newRange * HOUR_MS);
const newPan = Math.trunc((nowMs - newEnd) / HOUR_MS);
state.panHours = clampPanHours(newPan, state.showForecast ? (horizonHours || 0) : 0, maxPanHoursNow());
state.rangeHours = newRange;
redraw();
}
return;
}
// ── Pan 1 pointeur : glisser à DROITE = passé (panHours ↑), GAUCHE = futur
const dx = cur.x - prev.x;
const rect = canvas.getBoundingClientRect();
// fix #62 : cumul fractionnaire (le résiduel < 1 h est conservé)
const { hours, residual } = panDeltaHours(dx, rect.width, state.rangeHours, state.panResidualHours);
state.panResidualHours = residual;
if (hours !== 0) {
state.panHours = clampPanHours(state.panHours + hours, state.showForecast ? (horizonHours || 0) : 0, maxPanHoursNow());
redraw();
}
});
const releasePointer = (e) => {
activePointers.delete(e.pointerId);
if (activePointers.size < 2) pinchStart = null;
};
canvas.addEventListener('pointerup', releasePointer);
canvas.addEventListener('pointercancel', releasePointer);
// Molette = zoom (utilisateur desktop — le pinch tactile est déjà couvert)
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.deltaY < 0) {
state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H);
} else {
state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H);
}
redraw();
}, { passive: false });
redraw();
}
/** @private Ligne de légende colorée. */
function legendLine(text, color) {
return el('div', { class: 'legend-text', style: `color:${color};` }, text);
}