Siphonight be4d26b3e5 v1.9.5 : revue de code — garde cutoffHours ESE, portage optimisations v1.8.2, perf canvas, docs alignées
Corrigé (audit) :
- cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup
  importé ESE + ester non couvert levait TypeError → écran vide
- min/max en une passe : les spreads Math.min(...allPoints) levaient
  RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient
  par frame
- init MCMC non bloquante : data-ready garantit désormais « asset chargé »

Perf (portage v1.8.2 Android, fin de la dérive de miroir) :
- prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman
  paresseux ; computeCurve ne recalcule plus cutoffHours par point
- résultat identique (175 tests verts)

E2E : version lue via data-version (anti-bug v1.9.3)

Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc
arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline),
home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du
zip (export-ignore, −548 Ko)
2026-09-20 12:55:51 +02:00

581 lines
30 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) ;
* - É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) ────
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;
// 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);
}