- FIX #70 : generateForecastDoses garde !isActive — un traitement archivé à Posologie ne projette plus de créneaux ; l'horizon du chip Prévision (chart.js) ne considère que les Posologies ACTIVES. §6.bis inchangé : l'historique reste simulé, seul le FUTUR s'arrête. 3 tests miroirs. - Page Doses : routeGlyph (💊💉💧🩹🧴, miroir routeIcon) + encarts « prise de sang le même jour » 🧪↑/🧪↓ (labMarkersForDose, miroir) ; i18n FR/EN ; 10 tests doses-extras.test.js (⚠️ makeDose(ts, amount, overrides)). 194 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.12.0.
584 lines
30 KiB
JavaScript
584 lines
30 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* É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) ;
|
||
* - ÉTAT D'INTERACTION PERSISTANT entre re-rendus (v1.6.1) : le tick minute
|
||
* de app.js, une mutation du store ou un resize re-créent l'écran entier —
|
||
* l'état (chips, zoom, pan, Tracé labs, Prolonger…) est réutilisé quand
|
||
* on re-rend le même écran (miroir `remember` Compose) et réinitialisé
|
||
* à l'entrée depuis la navigation (miroir du reset Android) ;
|
||
* - PAS de scroll horizontal parent : le drag horizontal est réservé au pan
|
||
* (piège Android #62/§11).
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
|
||
import {
|
||
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
|
||
computeLabAnchoredCurve, computeEstrannaiseCloud, DEFAULT_NB_CURVES,
|
||
} 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;
|
||
|
||
// ── État d'interaction du graphique (v1.6.1 — fix du bug « options qui se
|
||
// reset toutes seules ») ──────────────────────────────────────────────────────
|
||
// ⚠️ DIVERGENCE DE PORTAGE corrigée : côté Android, les options du graphique
|
||
// vivent dans des `remember { mutableStateOf }` — elles SURVIVENT à toutes
|
||
// les recompositions (tick minute, nouvelles données, resize) tant qu'on
|
||
// reste sur l'écran. Côté web, `renderRoute()` (app.js) re-crée l'écran
|
||
// ENTIER à chaque tick minute (60 s), mutation du store ou resize — et un
|
||
// `const state = {...}` local à renderChart retombait donc aux DÉFAUTS
|
||
// toutes les 60 s pendant qu'on regarde le graphique (remontée : « les
|
||
// options se réinitialisent sans qu'on change de menu »).
|
||
//
|
||
// FIX : l'état vit AU NIVEAU MODULE. `renderChart` le réutilise quand
|
||
// l'appelant signale un re-rendu DU MÊME écran (`preserveState: true` —
|
||
// miroir de `remember` qui survit aux recompositions) et le RECRÉE aux
|
||
// défauts à l'ENTRÉE sur l'écran (navigation depuis un autre onglet —
|
||
// miroir du reset d'un `remember` quitté, PAS un rememberSaveable :
|
||
// quitter Charts puis revenir réinitialise, comme l'Android).
|
||
//
|
||
// Un seul objet à la fois : le graphique est un écran unique, le DOM
|
||
// précédent est jeté à chaque re-rendu (les closures recaptent `state`,
|
||
// qui reste le MÊME objet d'un re-rendu à l'autre quand il est préservé).
|
||
let chartUiState = null;
|
||
|
||
/** 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 }
|
||
* @param {object} [opts]
|
||
* @param {boolean} [opts.preserveState=false] (v1.6.1) `true` quand on
|
||
* re-rend le MÊME écran graphique (tick minute, mutation du store,
|
||
* resize — cf app.js renderRoute) : l'état d'interaction [state]
|
||
* est réutilisé au lieu d'être recréé aux défauts (miroir Compose :
|
||
* `remember` survit aux recompositions). `false` = entrée sur
|
||
* l'écran (navigation) : état neuf aux défauts (miroir du reset
|
||
* d'un `remember` Android quand on quitte l'écran).
|
||
*/
|
||
export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||
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) ────────────────
|
||
// v1.6.1 : réutilisé entre les re-rendus du même écran (cf bloc
|
||
// « État d'interaction » au-dessus — sans ça, le tick minute de app.js
|
||
// réinitialisait toutes les options toutes les 60 s).
|
||
if (!preserveState || !chartUiState) {
|
||
chartUiState = {
|
||
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)
|
||
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
|
||
// dernier lab constant × modèle, bornée par l'extinction du modèle —
|
||
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
|
||
// partie prolongée est une ESTIMATION non ancrée, elle doit être
|
||
// demandée explicitement ; le chip n'est actif que si Lab track est ON
|
||
// (miroir ChartScreen.kt).
|
||
showLabTrackExtend: false,
|
||
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (posterior MCMC de
|
||
// estrannaise.js) — OFF par défaut, activable à volonté, ACTIVABLE
|
||
// SEULEMENT si le modèle ESE est affiché (exclusif à ce modèle — les
|
||
// fits TFS/WHSAH ne publient pas de posterior). Éteindre ESE coupe le
|
||
// nuage automatiquement (miroir ChartScreen.kt).
|
||
showCloud: false,
|
||
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
|
||
togglesInitialized: false,
|
||
};
|
||
}
|
||
const state = chartUiState;
|
||
|
||
// ── 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) ────
|
||
// ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : SEULS les traitements ACTIFS
|
||
// comptent — la Posologie d'un archivé n'active plus le chip Prévision.
|
||
const maxIntervalDays = treatments
|
||
.filter((tr) => tr.isActive)
|
||
.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;
|
||
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE éteint le nuage
|
||
// (sinon il flotterait sans sa courbe)
|
||
if (!state.showEse) state.showCloud = false;
|
||
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(); }));
|
||
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (off par
|
||
// défaut, SANS EFFET — donc désactivé — tant que Lab track est off :
|
||
// la prolongation n'existe que comme extension de CETTE courbe)
|
||
modelRow.appendChild(filterChip(t('show_lab_track_extend'), state.showLabTrackExtend, () => { state.showLabTrackExtend = !state.showLabTrackExtend; redraw(); }, !state.showLabTrack));
|
||
// v1.9.0 : nuage d'incertitude (off par défaut, activable à volonté,
|
||
// SEULEMENT si ESE est affiché — même pattern que Prolonger/Tracé labs)
|
||
modelRow.appendChild(filterChip(t('show_cloud'), state.showCloud, () => { state.showCloud = !state.showCloud; redraw(); }, !state.showEse));
|
||
|
||
// ── 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é).
|
||
//
|
||
// ── v1.6.0 : split ancré / prolongé ──────────────────────────────────────
|
||
// Avec « Prolonger », la série est SPLITTÉE au dernier lab significatif
|
||
// (lastAnchorMs) : "LAB" = partie ancrée (passe par les labs), "LABX" =
|
||
// partie prolongée (M(t) × ρ_last — une estimation). Deux traits
|
||
// distincts (LABX en atténué) préservent l'honnêteté de l'affichage.
|
||
// ⚠️ Les DEUX clés doivent être SKIPPÉES par la boucle de légende
|
||
// (leçon #63 Android : sinon le catchall imprime la légende TFS en
|
||
// doublon) et avoir leurs entrées dédiées plus bas.
|
||
const step = stepForRange(state.rangeHours); // partagé : LAB + modèles (v1.9.0)
|
||
if (state.showLabTrack) {
|
||
const labCurve = computeLabAnchoredCurve(
|
||
treatments, doseLogs, labResults, startMs, endMs, step, null,
|
||
state.showLabTrackExtend,
|
||
);
|
||
if (state.showLabTrackExtend && labCurve.lastAnchorMs !== null) {
|
||
const anchored = labCurve.points.filter((p) => p.timestamp <= labCurve.lastAnchorMs);
|
||
const extended = labCurve.points.filter((p) => p.timestamp > labCurve.lastAnchorMs);
|
||
curves.push(['LAB', anchored]);
|
||
if (extended.length > 0) curves.push(['LABX', extended]);
|
||
} else {
|
||
curves.push(['LAB', labCurve.points]);
|
||
}
|
||
}
|
||
// v1.9.0 : NUAGE D'INCERTITUDE ESE — INDÉPENDANT du tracé labs (il ne
|
||
// dépend que de showCloud && showEse) : M courbes du posterior MCMC, cf
|
||
// EstrannaiseCloud. Clé 'CLOUD' skippée des légendes (leçon #63 miroir)
|
||
// et dessinée en alpha faible.
|
||
if (state.showCloud) {
|
||
const cloud = computeEstrannaiseCloud(
|
||
treatments, doseLogs, startMs, endMs, step,
|
||
DEFAULT_NB_CURVES, autoByModel.get('ESE')?.esterScales ?? null,
|
||
);
|
||
if (cloud.length > 0) curves.push(['CLOUD', cloud.flat()]);
|
||
}
|
||
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 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.
|
||
// v1.9.5 (audit) : min/max en UNE passe (boucle) — les spreads
|
||
// `Math.min(...arr)` lèvent RangeError au-delà de ~10⁵ arguments
|
||
// (atteignable : Nuage actif × zoom max ≈ 230 000 points) et
|
||
// allouent un tableau intermédiaire par appel × frame de pan.
|
||
let a = 0;
|
||
let b = 0;
|
||
if (allPoints.length >= 2) {
|
||
a = allPoints[0].timestamp;
|
||
b = a;
|
||
for (const p of allPoints) {
|
||
if (p.timestamp < a) a = p.timestamp;
|
||
if (p.timestamp > b) b = 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 === 'CLOUD') {
|
||
// v1.9.0 : nuage MCMC — chaque courbe individuelle est tracée
|
||
// en alpha faible ; PAS de labels/extrema (incertitude, pas des
|
||
// courbes nommées). Ne participe pas aux échelles via allPoints
|
||
// ? Si : allPoints inclut CLOUD — les échelles s'adaptent donc
|
||
// au nuage (cohérent : le nuage montre le range réel possible).
|
||
return { points: pts, e2Style: { color: 'rgba(194,24,91,0.12)', dashed: true }, tStyle: null, showExtrema: false };
|
||
}
|
||
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 };
|
||
}
|
||
if (model === 'LABX') {
|
||
// v1.6.0 : partie PROLONGÉE — même couleur ATTÉNUÉE (alpha 0,55,
|
||
// = #c2185b en rgba) pour distinguer l'estimation non ancrée de
|
||
// la partie qui passe par les labs. Mêmes exclusions (pas de T,
|
||
// pas d'extrema).
|
||
return {
|
||
points: pts,
|
||
e2Style: { color: 'rgba(194,24,91,0.55)', 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) {
|
||
// ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES
|
||
// (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
|
||
// catchall imprime la légende TFS en doublon)
|
||
if (model === 'LAB' || model === 'LABX' || model === 'CLOUD') 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));
|
||
}
|
||
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT — seulement
|
||
// si la série "LABX" existe (chip Prolonger actif ET horizon atteint
|
||
// dans la fenêtre visible). Même condition pour les deux (factorisée) :
|
||
// il ne peut jamais y avoir de courbe prolongée sans son avertissement.
|
||
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible tant que
|
||
// le nuage est affiché (l'alpha faible le rend nettement secondaire)
|
||
if (state.showCloud && state.showEse
|
||
&& curves.some(([m, pts]) => m === 'CLOUD' && pts.length >= 2)) {
|
||
legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_cloud')));
|
||
}
|
||
const labExtendVisible = state.showLabTrack && state.showLabTrackExtend
|
||
&& curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2);
|
||
if (labExtendVisible) {
|
||
legend.appendChild(legendLine(t('legend_lab_track_extend'), 'rgba(194,24,91,0.55)'));
|
||
legend.appendChild(el('div', { class: 'legend-muted' }, t('lab_track_extend_warning')));
|
||
}
|
||
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);
|
||
}
|