HormoneTrack-web/js/ui/chart-canvas.js
Siphonight dfef8e90ef v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)
Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) :
- js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les
  labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors
  fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab],
  < 2 labs → vide, indépendante de la calibration (sinon double
  correction). JAMAIS dans levelAt/alertes.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles +
  scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ;
  couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE
  de la boucle légende (sinon légende TFS dupliquée).
- 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total).
- WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README).

Détail d'exécution épingle: les deux problèmes du portage ont été
attrapés par node --check/E2E avant release (imports ESM faux —
labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
2026-09-11 16:17:36 +02:00

323 lines
12 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.

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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/12/26 dp)
const padLeft = 42;
const padRight = 42;
const padTop = 12;
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';
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';
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;
}