Siphonight fc1b6e1700 v1.9.1 : fix nuage calibré (il entoure la courbe ESE calibrée) + harmonisation arrondi (miroir Android)
- estrannaise-cloud.js : paramètre scalePerEster — le nuage reçoit la
  MÊME calibration que la courbe ESE (autoByModel ESE). Avant : courbe ESE
  calibrée mais nuage BRUT → échelles différentes (remontée v1.9.1 :
  « le nuage ne s'active que autour du tracé, pas autour du modèle
  Estrannaise »).
- chart.js : autoByModel.get('ESE')?.esterScales passé au nuage ;
  DEFAULT_NB_CURVES importé.
- +1 test : nuage calibré ×2 = nuage brut ×2 (167 verts au total).
- CHANGELOG [1.9.1] : fix nuage calibré + HARMONISATION de l'arrondi de
  la médiane de calibration (le web avait la troncature historique — les
  niveaux web/android convergent ; le niveau correct = l'ARRONDI).
- WEB_VERSION 1.9.1.
2026-09-19 21:14:27 +02:00

573 lines
29 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, nowMs,
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
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 === '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);
}