/** * ───────────────────────────────────────────────────────────────────────────── * CurveChart — rendu Canvas pur multi-séries (portage fidèle de * `ui/components/CurveChart.kt`, Canvas Compose → Canvas 2D navigateur). * * - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ; * - toutes les séries partagent les mêmes échelles (comparaison directe) ; * - labs E2 = cercles orange, labs T = carrés orange (T convertie en ng/mL * via convertTToNgMl — les labs peuvent être saisis en ng/dL/nmol/L, bug * #23 : à utiliser aussi au rendu, sinon l'axe T est faux ×100) ; * - ligne verticale « maintenant » ; * - doses PRÉVISIONNELLES : ligne pointillée + triangle ▼ + heure (v1.4.5, * fix #55 — sinon la projection paraît décalée d'un jour) ; * - doses RÉELLES : petits triangles ▲ discrets au bas (peigne des prises) ; * - pics/creux : triangles ▲▼ + valeur estimée (v1.2.6). * * Le composant est PUREMENT DÉCLARATIF : pan/zoom sont gérés PAR LE PARENT * (chart.js) qui modifie la fenêtre et redessine — même séparation que le * Compose (piège Android : un scroll parent avale le drag horizontal, cf * fix #62/§11 — le parent web n'a PAS de scroll horizontal). * ───────────────────────────────────────────────────────────────────────────── */ import { convertTToNgMl, detectExtrema } from '../pk/index.js'; import { xLabelTicks } from '../pk/chart-helpers.js'; import { formatValue } from '../util/format.js'; /** Couleurs (miroir ui/theme/Color.kt). */ export const COLORS = { chartE2: '#4f5bd5', chartT: '#d6589e', chartWhs: '#2e7d32', chartTWhs: '#9c27b0', tealTertiary: '#2fa48d', labDot: '#e67e22', grid: '#cac4d0', label: '#6b7280', nowLine: '#2fa48d', chartLabTrajectory: '#c2185b', // v1.5.0 — Tracé labs (rose foncé) forecastMarker: 'rgba(47,164,141,0.85)', realDoseMarker: 'rgba(73,69,79,0.55)', }; /** * Une série objet : { points, e2Style:{color,dashed}, tStyle|null, showExtrema } * v1.5.0 : `showExtrema=false` → hors pics/creux (la série « Tracé labs » * épouse l'interp ρ, pas la physiologie du modèle). */ /** * Dessine le graphique dans un canvas. * * @param {HTMLCanvasElement} canvas * @param {object[]} series [{ points: LevelPoint[], e2Style:{color,dashed}, tStyle|null }] * @param {object[]} e2Labs Labs E2 (LabResult) * @param {object[]} tLabs Labs T (déjà filtrés par le parent si toggle off) * @param {object} options { showT, showLabs, showExtrema, nowMs } * @param {object[]} [forecastDoses=[]] Doses projetées (marqueurs) * @param {object[]} [realDoses=[]] Doses réelles (marqueurs) * @param {string} [timeZone=null] Fuseau de LECTURE (null = navigateur) */ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses = [], realDoses = [], timeZone = null) { const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth; const cssH = canvas.clientHeight; if (cssW <= 0 || cssH <= 0) return; // HiDPI : le canvas est dimensionné en pixels réels, le dessin en unités CSS canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const allPoints = series.flatMap((s) => s.points); if (allPoints.length < 2) return; // Marges (px) — miroir padLeft/padRight/padTop/padBottom (42/42/26/26 dp). // v1.7.0 : padTop élargi (12 → 26) pour loger les UNITÉS d'axe au sommet // des colonnes de labels (portage de l'Android v1.7.0 — remontée « on // n'a jamais ajouté les unités » : pg/mL à gauche, ng/mL à droite). const padLeft = 42; const padRight = 42; const padTop = 26; const padBottom = 26; const w = cssW - padLeft - padRight; const h = cssH - padTop - padBottom; if (w <= 0 || h <= 0) return; const t0 = Math.min(...allPoints.map((p) => p.timestamp)); const t1 = Math.max(...allPoints.map((p) => p.timestamp)); if (t1 <= t0) return; // Labs T convertis en ng/mL AVANT l'échelle (fix #23) const convertedTLabs = tLabs.map((lab) => ({ ...lab, value: convertTToNgMl(lab.value, lab.unit) })); const e2DataMax = Math.max( Math.max(...allPoints.map((p) => p.e2)), options.showLabs && e2Labs.length ? Math.max(...e2Labs.map((l) => l.value)) : 0, ); const e2Max = niceCeil(Math.max(e2DataMax, 50.0)); const tDataMax = Math.max( Math.max(...allPoints.map((p) => p.t)), options.showLabs && convertedTLabs.length ? Math.max(...convertedTLabs.map((l) => l.value)) : 0, ); const tMax = niceCeil(tDataMax * 1.1); const xOf = (ms) => padLeft + (w * (ms - t0)) / (t1 - t0); const yE2 = (v) => padTop + h * (1 - v / e2Max); const yT = (v) => padTop + h * (1 - v / tMax); // ── Grille 4 lignes + labels Y gauche (E2) / droit (T) ─────────────────── ctx.strokeStyle = COLORS.grid; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { const y = padTop + (h * i) / 4; ctx.beginPath(); ctx.moveTo(padLeft, y); ctx.lineTo(padLeft + w, y); ctx.stroke(); } ctx.fillStyle = COLORS.label; ctx.font = '10px system-ui, sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; // Unités des axes (v1.7.0, portage Android) : au sommet de chaque colonne // de labels, au-dessus du premier tick — universelles, PAS des clés i18n // (elles sont déjà en dur dans les légendes du chart). ctx.fillText('pg/mL', padLeft - 6, padTop - 6); for (let i = 0; i <= 4; i++) { ctx.fillText(formatValue((e2Max * i) / 4), padLeft - 6, padTop + h * (1 - i / 4)); } if (options.showT && series.some((s) => s.tStyle)) { ctx.textAlign = 'left'; ctx.fillText('ng/mL', padLeft + w + 6, padTop - 6); for (let i = 0; i <= 4; i++) { ctx.fillText(formatValue((tMax * i) / 4), padLeft + w + 6, padTop + h * (1 - i / 4)); } } // ── Labels X : pas adaptatif + ticks alignés minuit LOCAL (fix #55) ─────── const spanH = (t1 - t0) / 3600000.0; const stepHours = spanH <= 8 ? 1 : spanH <= 18 ? 3 : spanH <= 25 ? 6 : spanH <= 25 * 7 ? 24 : 120; const fmt = new Intl.DateTimeFormat(undefined, spanH <= 25 ? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: timeZone || undefined } : { day: '2-digit', month: '2-digit', timeZone: timeZone || undefined }); ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; for (const tick of xLabelTicks(t0, t1, stepHours, timeZone)) { const x = padLeft + (w * (tick - t0)) / (t1 - t0); ctx.fillText(fmt.format(new Date(tick)), x, padTop + h + 16); } // ── Doses PRÉVISIONNELLES : ligne pointillée + ▼ + heure (fix #55) ─────── for (const dose of forecastDoses) { if (dose.timestamp < t0 || dose.timestamp > t1) continue; const x = xOf(dose.timestamp); ctx.strokeStyle = COLORS.forecastMarker; ctx.setLineDash([5, 5]); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, padTop + 14); ctx.lineTo(x, padTop + h); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = COLORS.forecastMarker; ctx.beginPath(); ctx.moveTo(x, padTop + 14); ctx.lineTo(x - 4, padTop + 6); ctx.lineTo(x + 4, padTop + 6); ctx.closePath(); ctx.fill(); const hourFmt = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: timeZone || undefined, }); ctx.fillStyle = COLORS.forecastMarker; ctx.textAlign = 'center'; ctx.fillText(hourFmt.format(new Date(dose.timestamp)), x, padTop + 4); } // ── Courbes (E2 pleine + T de la série) ────────────────────────────────── for (const s of series) { if (s.points.length < 2) continue; // E2 de la série ctx.strokeStyle = s.e2Style.color; ctx.lineWidth = 2.5; ctx.setLineDash(s.e2Style.dashed ? [10, 8] : []); ctx.beginPath(); s.points.forEach((p, i) => { const x = xOf(p.timestamp); const y = yE2(p.e2); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.setLineDash([]); // T de la série (si toggle T actif et style présent) if (options.showT && s.tStyle) { ctx.strokeStyle = s.tStyle.color; ctx.lineWidth = 2; ctx.setLineDash(s.tStyle.dashed ? [10, 8] : []); ctx.beginPath(); s.points.forEach((p, i) => { const x = xOf(p.timestamp); const y = yT(p.t); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.setLineDash([]); } // Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée if (options.showExtrema && (s.showExtrema !== false)) { for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) { drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color); } if (options.showT && s.tStyle) { for (const ex of detectExtrema(s.points, (p) => p.t, 0.02)) { drawExtremum(ctx, xOf(ex.timestamp), yT(ex.value), ex, s.tStyle.color); } } } } // ── Labs : cercles (E2) et carrés (T) orange + valeur ──────────────────── if (options.showLabs) { ctx.font = 'bold 9px system-ui, sans-serif'; for (const lab of e2Labs) { if (lab.timestamp < t0 || lab.timestamp > t1) continue; const x = xOf(lab.timestamp); const y = yE2(lab.value); ctx.fillStyle = COLORS.labDot; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); ctx.textAlign = 'center'; ctx.fillText(formatValue(lab.value), x, y - 10); } for (const lab of convertedTLabs) { if (lab.timestamp < t0 || lab.timestamp > t1) continue; const x = xOf(lab.timestamp); const y = yT(lab.value); ctx.fillStyle = COLORS.labDot; ctx.fillRect(x - 4, y - 4, 8, 8); ctx.textAlign = 'center'; ctx.fillText(formatValue(lab.value), x, y - 10); } } // ── Doses RÉELLES : petits triangles ▲ discrets au bas (v1.4.5) ────────── ctx.fillStyle = COLORS.realDoseMarker; for (const dose of realDoses) { if (dose.timestamp < t0 || dose.timestamp > t1) continue; const x = xOf(dose.timestamp); const base = padTop + h; ctx.beginPath(); ctx.moveTo(x, base - 8); ctx.lineTo(x - 3.5, base); ctx.lineTo(x + 3.5, base); ctx.closePath(); ctx.fill(); } // ── Repère « maintenant » ──────────────────────────────────────────────── if (options.nowMs >= t0 && options.nowMs <= t1) { ctx.strokeStyle = COLORS.nowLine; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(xOf(options.nowMs), padTop); ctx.lineTo(xOf(options.nowMs), padTop + h); ctx.stroke(); } } /** * Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous) + la VALEUR * estimée de l'extremum (miroir drawExtremum, v1.2.6). * * @private */ function drawExtremum(ctx, cx, cy, extremum, color) { const r = 5; const offset = 6; let valueY; ctx.fillStyle = color; if (extremum.isPeak) { const y = cy - offset; ctx.beginPath(); ctx.moveTo(cx, y - r); ctx.lineTo(cx - r, y + r * 0.5); ctx.lineTo(cx + r, y + r * 0.5); ctx.closePath(); ctx.fill(); valueY = y - r - 4; } else { const y = cy + offset; ctx.beginPath(); ctx.moveTo(cx, y + r); ctx.lineTo(cx - r, y - r * 0.5); ctx.lineTo(cx + r, y - r * 0.5); ctx.closePath(); ctx.fill(); valueY = y + r + 12; } ctx.fillStyle = color; ctx.font = 'bold 9px system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(formatValue(extremum.value), cx, valueY); } /** * Arrondi « nice » — re-export local (miroir niceCeil de CurveChart.kt) pour * ne pas dépendre de chart-helpers ici (le module pk/index regroupe déjà). * * @param {number} value * @returns {number} */ function niceCeil(value) { if (value <= 0) return 100.0; const exp = Math.pow(10.0, Math.floor(Math.log10(value))); const f = value / exp; let nice; if (f <= 1) nice = 1.0; else if (f <= 2) nice = 2.0; else if (f <= 2.5) nice = 2.5; else if (f <= 5) nice = 5.0; else nice = 10.0; return nice * exp; }