HormoneTrack-web/js/ui/chart-canvas.js
Siphonight d822af2dd2 v1.7.0 : unités des axes du graphique (portage Android) + note auto-backup
- chart-canvas.js : pg/mL (E2, axe gauche) / ng/mL (T, axe droit) au
  sommet des colonnes de labels — miroir exact du fix Android (padTop
  élargi 12 → 26 px). Universelles, PAS des clés i18n.
- WEB_VERSION 1.7.0 (versions sync Android) ; E2E assertions version.
- CHANGELOG [1.7.0] : unités portées ; auto-backup Android NON porté
  (structurellement impossible côté navigateur — cf §12 Limites :
  pas d'OPEN_DOCUMENT_TREE persisté + WorkManager ; l'export/import
  manuel couvre la même donnée).
- Doc : §12 limites, README statut. check.sh vert (140 tests + E2E +
  garde de parité §3.bis).
2026-09-16 19:53:41 +02:00

331 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/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;
}