HormoneTrack-web/js/ui/chart-canvas.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

315 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',
forecastMarker: 'rgba(47,164,141,0.85)',
realDoseMarker: 'rgba(73,69,79,0.55)',
};
/**
* 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) {
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;
}