/** * ───────────────────────────────────────────────────────────────────────────── * Écran Graphiques (portage de `ui/screens/ChartScreen.kt` — l'écran le plus * riche de l'app). * * Fonctions portées à l'identique : * - plages 24 h / 7 j / 30 j + ZOOM −/+ (pinch + molette, borné 6 h → 300 j, * échantillonnage adaptatif stepForRange, focal du pinch maintenu) ; * - PANORAMIQUE : glisser = passé, glisser ← = futur (prévision active), * cumul fractionnaire des deltas (fix #62) + clamp unique clampPanHours ; * - toggles de MODÈLES superposables (ESE / TFS / WHSAH), alignés au * chargement sur les traitements à PROFIL PK (fix #58, garde-fou TFS) ; * - toggles T (masque aussi les labs T) / labs / pics-creux / prévision ; * - PRÉVISION : doses projetées (grille Posologie), horizon 12× Posologie * borné [30 j ; 1 an], extension de fenêtre à droite sans déplacement * (fix #54), retour à maintenant à la désactivation (v1.4.1) ; * - CALIBRATION PAR MODÈLE (fix #60) : une AutoCalibrated par modèle * affiché — chaque courbe colle à ses labs quand l'auto est active ; * - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition * côté Android — côté web, même discipline : endMs ne dérive pas à chaque * frame, il ne change qu'au tick ou sur interaction) ; * - PAS de scroll horizontal parent : le drag horizontal est réservé au pan * (piège Android #62/§11). * ───────────────────────────────────────────────────────────────────────────── */ import { computeCurve, generateForecastDoses, autoCalibrated, TConfig, } from '../pk/index.js'; import { stepForRange, defaultModelToggles, panDeltaHours, clampPanHours, forecastHorizonHours, forecastExtensionHours, } from '../pk/chart-helpers.js'; import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatValue } from '../util/format.js'; import { el, filterChip } from './components.js'; import { drawChart, COLORS } from './chart-canvas.js'; const HOUR_MS = 3600000; // Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j const MIN_RANGE_H = 6; const MAX_RANGE_H = 24 * 300; /** Styles visuels des séries (miroir de la map du ChartScreen Android). */ function seriesStyles(model) { if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } }; if (model === 'WHS') return { e2: { color: COLORS.chartWhs, dashed: false }, t: { color: COLORS.chartTWhs, dashed: true } }; return { e2: { color: COLORS.tealTertiary, dashed: false }, t: { color: COLORS.chartT, dashed: true } }; } /** * Rend l'écran Graphiques dans [container]. * @param {HTMLElement} container * @param {object} ctx { nowMs } */ export function renderChart(container, ctx) { const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis) const doseLogs = store.getDoseLogs(); const labResults = store.getLabResults(); const tConfigRaw = store.getTConfig(); const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k); const settings = store.getSettings(); const chartTimeZone = settings.chartTimezone || null; // ── État d'interaction (miroir des mutableStateOf Compose) ──────────────── const state = { rangeHours: 24, panHours: 0, showT: true, showLabs: true, showEse: true, showTfs: true, showWhs: false, showForecast: false, showExtrema: false, panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) togglesInitialized: false, }; // ── Colonne racine ─────────────────────────────────────────────────────── const col = el('div', {}); container.appendChild(col); // Rangée 1 : plages + zoom const rangeRow = el('div', { class: 'chip-row' }); col.appendChild(rangeRow); // Rangée 2 : toggles affichage const displayRow = el('div', { class: 'chip-row' }); col.appendChild(displayRow); // Rangée 3 : toggles modèles const modelRow = el('div', { class: 'chip-row' }); col.appendChild(modelRow); // Carte graphique + légende const chartCard = el('div', { class: 'card' }); const canvas = el('canvas', { class: 'chart chart-cursor', style: 'height:320px;' }); chartCard.appendChild(canvas); const backBtnWrap = el('div', {}); chartCard.appendChild(backBtnWrap); const legend = el('div', {}); chartCard.appendChild(legend); col.appendChild(chartCard); // ── Calibration PAR MODÈLE (fix #60) : une AutoCalibrated par modèle ───── const autoByModel = new Map(); if (settings.autoCalibrate) { for (const m of ['ESE', 'TFS', 'WHS']) { autoByModel.set(m, autoCalibrated(treatments, doseLogs, labResults, tConfig, m)); } } // ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ──── const maxIntervalDays = treatments .map((tr) => tr.forecastIntervalDays) .filter((x) => x !== null && x !== undefined) .reduce((m, x) => Math.max(m, x), 0) || null; const horizonHours = forecastHorizonHours(maxIntervalDays); // Gap jusqu'au 1ᵉʳ créneau projeté (mémoïsé — même discipline que fix #56 : // ce calcul n'est refait qu'au re-rendu, pas à chaque événement de drag) let firstSlotGapH = null; if (horizonHours !== null) { const at = ctx.nowMs; const horizonMs = horizonHours * HOUR_MS; let minSlot = null; for (const tr of treatments) { for (const d of generateForecastDoses(tr, doseLogs, at + horizonMs, at)) { if (minSlot === null || d.timestamp < minSlot) minSlot = d.timestamp; } } if (minSlot !== null) firstSlotGapH = Math.trunc((minSlot - at) / HOUR_MS); } // ── Fenêtre temporelle ─────────────────────────────────────────────────── function windowRange() { const nowMs = ctx.nowMs; const futurePanHorizon = state.showForecast ? (horizonHours || 0) : 0; const extensionH = (state.showForecast && horizonHours !== null) ? forecastExtensionHours(state.rangeHours, horizonHours, firstSlotGapH) : 0; const endMs = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS; const startMs = endMs - state.rangeHours * HOUR_MS; return { nowMs, futurePanHorizon, extensionH, startMs, endMs }; } // ── Toggle init des modèles : aligné sur les traitements à PROFIL PK ───── // (fix #58 — filtrer par usesProfileModel : un traitement oral polluait le // set avec son pkModel stocké ESE ; garde-fou TFS si aucun profil). if (!state.togglesInitialized && treatments.length > 0) { const used = new Set( treatments .filter((tr) => tr.route === 'INJECTION_IM' || tr.route === 'INJECTION_SUBCUT') .filter((tr) => tr.esterType !== 'NONE') .map((tr) => tr.pkModel), ); const toggles = defaultModelToggles(used); state.showEse = toggles.ESE; state.showTfs = toggles.TFS; state.showWhs = toggles.WHS; state.togglesInitialized = true; } // ── Calcul + dessin ────────────────────────────────────────────────────── function redraw() { const { nowMs, futurePanHorizon, extensionH, startMs, endMs } = windowRange(); // Rangée 1 : plages + zoom rangeRow.textContent = ''; rangeRow.appendChild(filterChip(t('chart_24h'), state.rangeHours === 24, () => { state.rangeHours = 24; state.panHours = clampPan(state.panHours); redraw(); })); rangeRow.appendChild(filterChip(t('chart_7j'), state.rangeHours === 24 * 7, () => { state.rangeHours = 24 * 7; redraw(); })); rangeRow.appendChild(filterChip(t('chart_30j'), state.rangeHours === 24 * 30, () => { state.rangeHours = 24 * 30; redraw(); })); const zoomIn = el('button', { class: 'chip', disabled: state.rangeHours <= MIN_RANGE_H }, '+'); zoomIn.addEventListener('click', () => { // Convention carte : « + » = zoom AVANT (fenêtre plus courte, v1.2.10) state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H); redraw(); }); const zoomOut = el('button', { class: 'chip', disabled: state.rangeHours >= MAX_RANGE_H }, '−'); zoomOut.addEventListener('click', () => { state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H); redraw(); }); rangeRow.appendChild(zoomIn); rangeRow.appendChild(zoomOut); // Rangée 2 : toggles affichage displayRow.textContent = ''; displayRow.appendChild(filterChip('T', state.showT, () => { state.showT = !state.showT; redraw(); })); displayRow.appendChild(filterChip(t('show_labs'), state.showLabs, () => { state.showLabs = !state.showLabs; redraw(); })); displayRow.appendChild(filterChip(t('show_forecast'), state.showForecast, () => { state.showForecast = !state.showForecast; // v1.4.1 : désactiver la prévision pendant un voyage futur → retour // à maintenant (sinon fenêtre vide au-delà de « maintenant ») if (!state.showForecast) state.panHours = Math.max(state.panHours, 0); redraw(); }, horizonHours === null)); displayRow.appendChild(filterChip(t('show_extrema'), state.showExtrema, () => { state.showExtrema = !state.showExtrema; redraw(); })); // Rangée 3 : toggles modèles superposables modelRow.textContent = ''; modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); })); modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); })); modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); })); // ── Courbes par modèle sélectionné ───────────────────────────────────── const curves = []; if (state.showEse || state.showTfs || state.showWhs) { // Home utilise le modèle stocké ; le graphique calibre chaque modèle // avec SA prédiction (fix #60). Référence = auto du modèle TFS (les // traitements/le tConfig y sont identiques — seules échelles/k diffèrent). const refAuto = autoByModel.get('TFS'); const effTreatments = refAuto ? refAuto.treatments : treatments; const effTConfig = refAuto ? refAuto.tConfig : tConfig; // Doses réelles + doses prévisionnelles éventuelles const forecastDoses = state.showForecast && horizonHours !== null ? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs)) : []; const allDoses = doseLogs.concat(forecastDoses); // Pas d'échantillonnage adaptatif au zoom (v1.2.9) const step = stepForRange(state.rangeHours); const addCurve = (model) => { const autoM = autoByModel.get(model); curves.push([model, computeCurve( effTreatments, allDoses, startMs, endMs, step, autoM ? autoM.tConfig : effTConfig, { modelOverride: model, scalePerEster: autoM ? autoM.esterScales : null, tKPerEster: autoM ? autoM.tKPerEster : null, }, )]); }; if (state.showEse) addCurve('ESE'); if (state.showTfs) addCurve('TFS'); if (state.showWhs) addCurve('WHS'); } const allPoints = curves.flatMap(([, pts]) => pts); // Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T let a = 0; let b = 0; if (allPoints.length >= 2) { a = Math.min(...allPoints.map((p) => p.timestamp)); b = Math.max(...allPoints.map((p) => p.timestamp)); } const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b); const tLabs = state.showT ? labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b) : []; // ── Contenu : graphique ou « aucune donnée » ──────────────────────────── backBtnWrap.textContent = ''; legend.textContent = ''; if (curves.length === 0 || allPoints.length < 2) { canvas.style.display = 'none'; legend.appendChild(el('p', {}, t('no_data'))); } else { canvas.style.display = ''; const forecastDosesForMarkers = state.showForecast && horizonHours !== null ? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs)) : []; requestAnimationFrame(() => { drawChart( canvas, curves.map(([model, pts]) => { 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) { 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)); } } 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'))); 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); }