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