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

411 lines
19 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.

/**
* ─────────────────────────────────────────────────────────────────────────────
* Écran Graphiques (portage de `ui/screens/ChartScreen.kt` — l'écran le plus
* riche de l'app).
*
* Fonctions portées à l'identique :
* - plages 24 h / 7 j / 30 j + ZOOM −/+ (pinch + molette, borné 6 h → 300 j,
* échantillonnage adaptatif stepForRange, focal du pinch maintenu) ;
* - PANORAMIQUE : glisser = passé, glisser ← = futur (prévision active),
* cumul fractionnaire des deltas (fix #62) + clamp unique clampPanHours ;
* - toggles de MODÈLES superposables (ESE / TFS / WHSAH), alignés au
* chargement sur les traitements à PROFIL PK (fix #58, garde-fou TFS) ;
* - toggles T (masque aussi les labs T) / labs / pics-creux / prévision ;
* - PRÉVISION : doses projetées (grille Posologie), horizon 12× Posologie
* borné [30 j ; 1 an], extension de fenêtre à droite sans déplacement
* (fix #54), retour à maintenant à la désactivation (v1.4.1) ;
* - CALIBRATION PAR MODÈLE (fix #60) : une AutoCalibrated par modèle
* affiché — chaque courbe colle à ses labs quand l'auto est active ;
* - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition
* côté Android — côté web, même discipline : endMs ne dérive pas à chaque
* frame, il ne change qu'au tick ou sur interaction) ;
* - PAS de scroll horizontal parent : le drag horizontal est réservé au pan
* (piège Android #62/§11).
* ─────────────────────────────────────────────────────────────────────────────
*/
import {
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
} from '../pk/index.js';
import {
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
forecastHorizonHours, forecastExtensionHours,
} from '../pk/chart-helpers.js';
import { store } from '../data/store.js';
import { t } from '../util/i18n.js';
import { formatValue } from '../util/format.js';
import { el, filterChip } from './components.js';
import { drawChart, COLORS } from './chart-canvas.js';
const HOUR_MS = 3600000;
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
const MIN_RANGE_H = 6;
const MAX_RANGE_H = 24 * 300;
/** Styles visuels des séries (miroir de la map du ChartScreen Android). */
function seriesStyles(model) {
if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } };
if (model === 'WHS') return { e2: { color: COLORS.chartWhs, dashed: false }, t: { color: COLORS.chartTWhs, dashed: true } };
return { e2: { color: COLORS.tealTertiary, dashed: false }, t: { color: COLORS.chartT, dashed: true } };
}
/**
* Rend l'écran Graphiques dans [container].
* @param {HTMLElement} container
* @param {object} ctx { nowMs }
*/
export function renderChart(container, ctx) {
const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis)
const doseLogs = store.getDoseLogs();
const labResults = store.getLabResults();
const tConfigRaw = store.getTConfig();
const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k);
const settings = store.getSettings();
const chartTimeZone = settings.chartTimezone || null;
// ── État d'interaction (miroir des mutableStateOf Compose) ────────────────
const state = {
rangeHours: 24,
panHours: 0,
showT: true,
showLabs: true,
showEse: true,
showTfs: true,
showWhs: false,
showForecast: false,
showExtrema: false,
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false,
};
// ── Colonne racine ───────────────────────────────────────────────────────
const col = el('div', {});
container.appendChild(col);
// Rangée 1 : plages + zoom
const rangeRow = el('div', { class: 'chip-row' });
col.appendChild(rangeRow);
// Rangée 2 : toggles affichage
const displayRow = el('div', { class: 'chip-row' });
col.appendChild(displayRow);
// Rangée 3 : toggles modèles
const modelRow = el('div', { class: 'chip-row' });
col.appendChild(modelRow);
// Carte graphique + légende
const chartCard = el('div', { class: 'card' });
const canvas = el('canvas', { class: 'chart chart-cursor', style: 'height:320px;' });
chartCard.appendChild(canvas);
const backBtnWrap = el('div', {});
chartCard.appendChild(backBtnWrap);
const legend = el('div', {});
chartCard.appendChild(legend);
col.appendChild(chartCard);
// ── Calibration PAR MODÈLE (fix #60) : une AutoCalibrated par modèle ─────
const autoByModel = new Map();
if (settings.autoCalibrate) {
for (const m of ['ESE', 'TFS', 'WHS']) {
autoByModel.set(m, autoCalibrated(treatments, doseLogs, labResults, tConfig, m));
}
}
// ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ────
const maxIntervalDays = treatments
.map((tr) => tr.forecastIntervalDays)
.filter((x) => x !== null && x !== undefined)
.reduce((m, x) => Math.max(m, x), 0) || null;
const horizonHours = forecastHorizonHours(maxIntervalDays);
// Gap jusqu'au 1ᵉʳ créneau projeté (mémoïsé — même discipline que fix #56 :
// ce calcul n'est refait qu'au re-rendu, pas à chaque événement de drag)
let firstSlotGapH = null;
if (horizonHours !== null) {
const at = ctx.nowMs;
const horizonMs = horizonHours * HOUR_MS;
let minSlot = null;
for (const tr of treatments) {
for (const d of generateForecastDoses(tr, doseLogs, at + horizonMs, at)) {
if (minSlot === null || d.timestamp < minSlot) minSlot = d.timestamp;
}
}
if (minSlot !== null) firstSlotGapH = Math.trunc((minSlot - at) / HOUR_MS);
}
// ── Fenêtre temporelle ───────────────────────────────────────────────────
function windowRange() {
const nowMs = ctx.nowMs;
const futurePanHorizon = state.showForecast ? (horizonHours || 0) : 0;
const extensionH = (state.showForecast && horizonHours !== null)
? forecastExtensionHours(state.rangeHours, horizonHours, firstSlotGapH)
: 0;
const endMs = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS;
const startMs = endMs - state.rangeHours * HOUR_MS;
return { nowMs, futurePanHorizon, extensionH, startMs, endMs };
}
// ── Toggle init des modèles : aligné sur les traitements à PROFIL PK ─────
// (fix #58 — filtrer par usesProfileModel : un traitement oral polluait le
// set avec son pkModel stocké ESE ; garde-fou TFS si aucun profil).
if (!state.togglesInitialized && treatments.length > 0) {
const used = new Set(
treatments
.filter((tr) => tr.route === 'INJECTION_IM' || tr.route === 'INJECTION_SUBCUT')
.filter((tr) => tr.esterType !== 'NONE')
.map((tr) => tr.pkModel),
);
const toggles = defaultModelToggles(used);
state.showEse = toggles.ESE;
state.showTfs = toggles.TFS;
state.showWhs = toggles.WHS;
state.togglesInitialized = true;
}
// ── Calcul + dessin ──────────────────────────────────────────────────────
function redraw() {
const { nowMs, futurePanHorizon, extensionH, startMs, endMs } = windowRange();
// Rangée 1 : plages + zoom
rangeRow.textContent = '';
rangeRow.appendChild(filterChip(t('chart_24h'), state.rangeHours === 24, () => { state.rangeHours = 24; state.panHours = clampPan(state.panHours); redraw(); }));
rangeRow.appendChild(filterChip(t('chart_7j'), state.rangeHours === 24 * 7, () => { state.rangeHours = 24 * 7; redraw(); }));
rangeRow.appendChild(filterChip(t('chart_30j'), state.rangeHours === 24 * 30, () => { state.rangeHours = 24 * 30; redraw(); }));
const zoomIn = el('button', { class: 'chip', disabled: state.rangeHours <= MIN_RANGE_H }, '+');
zoomIn.addEventListener('click', () => {
// Convention carte : « + » = zoom AVANT (fenêtre plus courte, v1.2.10)
state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H);
redraw();
});
const zoomOut = el('button', { class: 'chip', disabled: state.rangeHours >= MAX_RANGE_H }, '−');
zoomOut.addEventListener('click', () => {
state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H);
redraw();
});
rangeRow.appendChild(zoomIn);
rangeRow.appendChild(zoomOut);
// Rangée 2 : toggles affichage
displayRow.textContent = '';
displayRow.appendChild(filterChip('T', state.showT, () => { state.showT = !state.showT; redraw(); }));
displayRow.appendChild(filterChip(t('show_labs'), state.showLabs, () => { state.showLabs = !state.showLabs; redraw(); }));
displayRow.appendChild(filterChip(t('show_forecast'), state.showForecast, () => {
state.showForecast = !state.showForecast;
// v1.4.1 : désactiver la prévision pendant un voyage futur → retour
// à maintenant (sinon fenêtre vide au-delà de « maintenant »)
if (!state.showForecast) state.panHours = Math.max(state.panHours, 0);
redraw();
}, horizonHours === null));
displayRow.appendChild(filterChip(t('show_extrema'), state.showExtrema, () => { state.showExtrema = !state.showExtrema; redraw(); }));
// Rangée 3 : toggles modèles superposables
modelRow.textContent = '';
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
// ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = [];
if (state.showEse || state.showTfs || state.showWhs) {
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
// traitements/le tConfig y sont identiques — seules échelles/k diffèrent).
const refAuto = autoByModel.get('TFS');
const effTreatments = refAuto ? refAuto.treatments : treatments;
const effTConfig = refAuto ? refAuto.tConfig : tConfig;
// Doses réelles + doses prévisionnelles éventuelles
const forecastDoses = state.showForecast && horizonHours !== null
? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs))
: [];
const allDoses = doseLogs.concat(forecastDoses);
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
const step = stepForRange(state.rangeHours);
const addCurve = (model) => {
const autoM = autoByModel.get(model);
curves.push([model, computeCurve(
effTreatments, allDoses, startMs, endMs, step, autoM ? autoM.tConfig : effTConfig,
{
modelOverride: model,
scalePerEster: autoM ? autoM.esterScales : null,
tKPerEster: autoM ? autoM.tKPerEster : null,
},
)]);
};
if (state.showEse) addCurve('ESE');
if (state.showTfs) addCurve('TFS');
if (state.showWhs) addCurve('WHS');
}
const allPoints = curves.flatMap(([, pts]) => pts);
// Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T
let a = 0;
let b = 0;
if (allPoints.length >= 2) {
a = Math.min(...allPoints.map((p) => p.timestamp));
b = Math.max(...allPoints.map((p) => p.timestamp));
}
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
const tLabs = state.showT
? labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b)
: [];
// ── Contenu : graphique ou « aucune donnée » ────────────────────────────
backBtnWrap.textContent = '';
legend.textContent = '';
if (curves.length === 0 || allPoints.length < 2) {
canvas.style.display = 'none';
legend.appendChild(el('p', {}, t('no_data')));
} else {
canvas.style.display = '';
const forecastDosesForMarkers = state.showForecast && horizonHours !== null
? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs))
: [];
requestAnimationFrame(() => {
drawChart(
canvas,
curves.map(([model, pts]) => {
const st = seriesStyles(model);
return { points: pts, e2Style: st.e2, tStyle: st.t };
}),
e2Labs,
tLabs,
{ showT: state.showT, showLabs: state.showLabs, showExtrema: state.showExtrema, nowMs },
forecastDosesForMarkers,
doseLogs,
chartTimeZone,
);
});
// Bouton « Revenir à maintenant » dès qu'on a voyagé (passé OU futur)
if (state.panHours !== 0) {
backBtnWrap.appendChild(el('button', {
class: 'btn text',
onclick: () => { state.panHours = 0; state.panResidualHours = 0; redraw(); },
}, t('back_to_present')));
}
// Légende dynamique (miroir du bloc Compose)
for (const [model] of curves) {
const st = seriesStyles(model);
if (model === 'ESE') {
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
if (state.showT) legend.appendChild(legendLine(t('legend_ese_t'), st.t.color));
} else if (model === 'WHS') {
legend.appendChild(legendLine(t('legend_whs_e2'), st.e2.color));
if (state.showT) legend.appendChild(legendLine(t('legend_whs_t'), st.t.color));
} else {
legend.appendChild(legendLine(t('legend_tfs_e2'), st.e2.color));
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
}
}
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
}
// Re-clamp du pan avec l'horizon courant
function clampPan(pan) {
const nowMs = ctx.nowMs;
const earliestDoseMs = doseLogs.length ? Math.min(...doseLogs.map((d) => d.timestamp)) : nowMs;
const maxPanHours = Math.max(Math.trunc((nowMs - earliestDoseMs) / HOUR_MS) + state.rangeHours / HOUR_MS, 0);
return clampPanHours(pan, state.showForecast ? (horizonHours || 0) : 0, maxPanHours);
}
}
// ── Gestes : pan (1 pointeur) + pinch (2 pointeurs) + molette ────────────
// ⚠️ FIX #62 : les deltas < 1 h sont CUMULÉS via panResidualHours (pan
// fonctionnel sur la vue 24 h). Un seul handler pour pan+pinch (miroir
// detectTransformGestures — un handler = pas de conflit de consommation).
const activePointers = new Map();
let pinchStart = null; // { dist, centroidX, rangeHoursAtStart }
function maxPanHoursNow() {
const nowMs = ctx.nowMs;
const earliestDoseMs = doseLogs.length ? Math.min(...doseLogs.map((d) => d.timestamp)) : nowMs;
return Math.max(Math.trunc((nowMs - earliestDoseMs) / HOUR_MS) + state.rangeHours / HOUR_MS, 0);
}
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId);
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
const [p1, p2] = [...activePointers.values()];
pinchStart = {
dist: Math.abs(p1.x - p2.x) || 1,
centroidX: (p1.x + p2.x) / 2,
rangeHoursAtStart: state.rangeHours,
};
}
});
canvas.addEventListener('pointermove', (e) => {
if (!activePointers.has(e.pointerId)) return;
const prev = activePointers.get(e.pointerId);
const cur = { x: e.clientX, y: e.clientY };
activePointers.set(e.pointerId, cur);
if (activePointers.size === 2 && pinchStart) {
// ── Pinch : zoom autour du centroid (le temps sous les doigts reste fixe)
const [p1, p2] = [...activePointers.values()];
const dist = Math.abs(p1.x - p2.x) || 1;
const zoom = dist / pinchStart.dist; // > 1 = doigts écartés = zoom avant
const rect = canvas.getBoundingClientRect();
const f = Math.min(Math.max((pinchStart.centroidX - rect.left) / rect.width, 0), 1);
const newRange = Math.min(Math.max(Math.trunc(pinchStart.rangeHoursAtStart / zoom), MIN_RANGE_H), MAX_RANGE_H);
if (newRange !== state.rangeHours) {
const { nowMs, extensionH } = windowRange();
const endAnchor = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS;
const tFocal = endAnchor - Math.trunc((1 - f) * state.rangeHours * HOUR_MS);
const newEnd = tFocal + Math.trunc((1 - f) * newRange * HOUR_MS);
const newPan = Math.trunc((nowMs - newEnd) / HOUR_MS);
state.panHours = clampPanHours(newPan, state.showForecast ? (horizonHours || 0) : 0, maxPanHoursNow());
state.rangeHours = newRange;
redraw();
}
return;
}
// ── Pan 1 pointeur : glisser à DROITE = passé (panHours ↑), GAUCHE = futur
const dx = cur.x - prev.x;
const rect = canvas.getBoundingClientRect();
// fix #62 : cumul fractionnaire (le résiduel < 1 h est conservé)
const { hours, residual } = panDeltaHours(dx, rect.width, state.rangeHours, state.panResidualHours);
state.panResidualHours = residual;
if (hours !== 0) {
state.panHours = clampPanHours(state.panHours + hours, state.showForecast ? (horizonHours || 0) : 0, maxPanHoursNow());
redraw();
}
});
const releasePointer = (e) => {
activePointers.delete(e.pointerId);
if (activePointers.size < 2) pinchStart = null;
};
canvas.addEventListener('pointerup', releasePointer);
canvas.addEventListener('pointercancel', releasePointer);
// Molette = zoom (utilisateur desktop — le pinch tactile est déjà couvert)
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.deltaY < 0) {
state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H);
} else {
state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H);
}
redraw();
}, { passive: false });
redraw();
}
/** @private Ligne de légende colorée. */
function legendLine(text, color) {
return el('div', { class: 'legend-text', style: `color:${color};` }, text);
}