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)
337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* 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;
|
||
}
|