HormoneTrack-web/js/ui/chart-canvas.js
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

337 lines
13 KiB
JavaScript
Raw Permalink 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.

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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;
// v1.9.5 (audit) : t0/t1/e2Max/tMax en UNE passe (RangeError possible au-delà
// de ~10⁵ arguments de spread — atteignable avec le Nuage actif ; et 5
// allocations intermédiaires par frame de pan évitées).
let t0 = Infinity;
let t1 = -Infinity;
let e2DataMax = 0;
let tDataMax = 0;
for (const p of allPoints) {
if (p.timestamp < t0) t0 = p.timestamp;
if (p.timestamp > t1) t1 = p.timestamp;
if (p.e2 > e2DataMax) e2DataMax = p.e2;
if (p.t > tDataMax) tDataMax = p.t;
}
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) }));
if (options.showLabs) {
for (const l of e2Labs) if (l.value > e2DataMax) e2DataMax = l.value;
for (const l of convertedTLabs) if (l.value > tDataMax) tDataMax = l.value;
}
const e2Max = niceCeil(Math.max(e2DataMax, 50.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;
}