HormoneTrack-web/js/pk/chart-helpers.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

302 lines
13 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.

/**
* ─────────────────────────────────────────────────────────────────────────────
* Helpers du graphique (portage des fonctions pures de
* `ui/components/CurveChart.kt`) — TOUTES testables en Node
* (web/tests/chart-helpers.test.js).
*
* ⚠️ Chaque fonction porte l'historique du bug qu'elle corrige :
* - panDeltaHours : fix #62 (pan mort sur 24 h — cumul fractionnaire) ;
* - pointHoursBefore : fix #53 (delta « 6 h » qui comparait 24 h) ;
* - clampPanHours : fix #54 (pan futur figé — clamp unique) ;
* - forecastExtensionHrs : fix #54 (chip prévision sans effet visible) ;
* - xLabelTicks : fix #55 (labels à minuit UTC = 02:00 FR).
* ─────────────────────────────────────────────────────────────────────────────
*/
/**
* Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour
* l'axe Y.
* @param {number} value
* @returns {number}
*/
export 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;
}
/**
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
* plus on zoome, plus le pas finit pour des courbes lisses
* (≤ 12 h → 15 min ; ≤ 24 h → 30 min ; sinon 1 h).
*
* @param {number} rangeHours
* @returns {number} ms
*/
export function stepForRange(rangeHours) {
if (rangeHours <= 12) return 900000; // 15 min
if (rangeHours <= 24) return 1800000; // 30 min
return 3600000; // 1 h
}
/**
* Toggles de MODÈLES PK par défaut (v1.4.7, fix #58) : au chargement, seuls
* les modèles RÉELLEMENT utilisés par des traitements à PROFIL PK (injection
* + ester — `usesProfileModel`) sont affichés ; les autres restent
* activables au tap. ⚠️ FILTRER par usesProfileModel : un traitement
* oral/anti-androgène (pkModel stocké = ESE par défaut, sans courbe profil)
* activerait Estrannaise à tort.
*
* GARDE-FOU : si AUCUN modèle profil n'est utilisé (traitements gel/oral
* uniquement), TFS est forcé à ON — les traitements Bateman sont tracés
* IDENTIQUEMENT dans chaque série : sans garde-fou, le graphique serait vide.
*
* @param {Set<string>} pkModelsUsed Ensemble des pkModel des traitements à profil
* @returns {Object<string,boolean>} { ESE, TFS, WHS }
*/
export function defaultModelToggles(pkModelsUsed) {
const toggles = {
ESE: pkModelsUsed.has('ESE'),
TFS: pkModelsUsed.has('TFS'),
WHS: pkModelsUsed.has('WHS'),
};
if (!toggles.ESE && !toggles.TFS && !toggles.WHS) toggles.TFS = true;
return toggles;
}
/**
* Convertit un delta de drag en heures de pan, en CUMULANT le résiduel
* fractionnaire (v1.4.10, fix #62).
*
* ⚠️ BUG #62 : l'ancien code tronquait CHAQUE delta incrémental en heures
* entières — à la plage 24 h, le delta d'un doigt réel (~30 px ≈ 0,67 h)
* était tronqué à 0 → le pan ne bougeait JAMAIS sur la vue 24 h (alors
* qu'il marchait sur 7 j+ : le même delta ≈ 4,7 h). Le fix : le résiduel
* fractionnaire (la part < 1 h) est conservé d'un événement de mouvement au
* suivant — le pan se déclenche dès que le CUMUL franchit une heure entière.
*
* @param {number} panPx Delta horizontal du geste (px, signé)
* @param {number} chartWidthPx Largeur du canvas en px
* @param {number} rangeHours Plage temporelle courante (24 h / 7 j / 30 j…)
* @param {number} residualHours Résiduel fractionnaire précédent (à ré-injecter)
* @returns {{hours:number, residual:number}} heures entières à appliquer + nouveau résiduel
*/
export function panDeltaHours(panPx, chartWidthPx, rangeHours, residualHours) {
if (chartWidthPx <= 0 || rangeHours <= 0) return { hours: 0, residual: residualHours };
const total = (panPx / chartWidthPx) * rangeHours + residualHours;
let whole = Math.trunc(total); // troncature vers ZÉRO (gère les deux signes)
if (whole === 0) whole = 0; // normalise -0 (troncature de petits négatifs)
return { hours: whole, residual: total - whole };
}
/**
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
* dans le FUTUR jusqu'à [-futureHorizonHours ; …] (panHours négatif =
* fenêtre décalée vers le futur). Unique point de clamp partagé par le drag
* et le zoom pour éviter les divergences.
*
* @param {number} panHours
* @param {number} futureHorizonHours
* @param {number} maxPastHours
* @returns {number}
*/
export function clampPanHours(panHours, futureHorizonHours, maxPastHours) {
const lo = -Math.max(futureHorizonHours, 0);
const hi = Math.max(maxPastHours, 0);
if (panHours <= lo) return lo === 0 ? 0 : lo; // normalise -0
if (panHours >= hi) return hi;
return panHours;
}
/**
* Horizon de PRÉVISION en heures (v1.4.1) : quantité de doses projetées
* générées au-delà de « maintenant » quand le chip Prévision est actif.
* 12 × la plus grande Posologie, borné [30 j ; 365 j] — « scroller à
* l'infini » s'arrête à 1 an (compromis calcul maîtrisé).
*
* @param {number|null} maxIntervalDays Plus grande Posologie configurée (jours)
* @returns {number|null} null si aucun traitement n'a de Posologie (chip désactivé)
*/
export function forecastHorizonHours(maxIntervalDays) {
if (maxIntervalDays === null || maxIntervalDays === undefined || maxIntervalDays <= 0.0) return null;
return Math.min(Math.max(Math.trunc(maxIntervalDays * 24.0 * 12.0), 24 * 30), 24 * 365);
}
/**
* EXTENSION de fenêtre à droite quand le chip Prévision est activé
* (v1.4.4, fix #54) : le toggle doit avoir un effet IMMÉDIAT et visible.
* (Historique : `endMs = now` faisait que generateForecastDoses ne générait
* AUCUNE dose — le chip semblait inerte.) Solution : étendre la fenêtre à
* droite SANS déplacer son début (l'historique visible reste à sa place)
* jusqu'à couvrir au moins la prochaine dose projetée, sinon d'un tiers de
* la plage actuelle.
*
* @param {number} rangeHours Plage courante (24 h / 7 j / 30 j…)
* @param {number} horizonHours Horizon de prévision (≤ 0 = pas de prévision)
* @param {number|null} firstSlotGapHours Écart « maintenant → 1ᵉʳ créneau projeté »
* @returns {number} heures à ajouter à endMs (0 = rien)
*/
export function forecastExtensionHours(rangeHours, horizonHours, firstSlotGapHours) {
if (horizonHours === null || horizonHours === undefined || horizonHours <= 0) return 0;
// Montrer AU MOINS la prochaine dose (+12 h de contexte), sinon 1/3 de
// la plage actuelle — jamais au-delà de l'horizon.
const toFirstSlot = (firstSlotGapHours === null || firstSlotGapHours === undefined ? 0 : firstSlotGapHours) + 12;
const ext = Math.max(Math.trunc(rangeHours / 3), toFirstSlot);
return Math.min(Math.max(ext, 0), horizonHours);
}
/**
* Point le plus PROCHE de `hours` heures AVANT le dernier point de la série
* (v1.4.3, fix #53) : utilisé par le delta « vs il y a 6 h » de la carte
* d'accueil.
*
* ⚠️ BUG corrigé ici : l'ancien code faisait `curve.find(p => écart >= 6 h)`
* sur une fenêtre de 24 h → le PREMIER point de la liste (il y a ~24 h !)
* satisfaisait la condition immédiatement, et le delta affiché « vs 6 h »
* comparait en réalité à il y a 24 h (trompeur le lendemain d'une injection
* EEn — cf régression #4). Cette fonction prend le point dont l'écart au
* dernier est le plus PETIT au-dessus de `hours` (par excès — avec une
* grille régulière, c'est le point à ~6 h exactement).
*
* @param {import('./pk-engine.js').LevelPoint[]} points
* @param {number} hours
* @returns {object|null} null si aucun point n'est au-delà de `hours`
*/
export function pointHoursBefore(points, hours) {
const last = points[points.length - 1];
if (!last) return null;
let best = null;
let bestGap = Number.MAX_VALUE;
for (const p of points) {
const gap = (last.timestamp - p.timestamp) / 3600000.0;
if (gap < hours) continue; // seulement AVANT le point de référence
if (gap < bestGap) {
bestGap = gap;
best = p;
}
}
return best;
}
// ── Fuseaux horaires (Intl) ───────────────────────────────────────────────────
/**
* Parties calendaires d'un instant DANS un fuseau donné (via Intl.DateTimeFormat).
* C'est l'équivalent JS de Calendar.getInstance(TimeZone) + get(...).
*
* @param {number} ms
* @param {string} timeZone ID IANA ("Europe/Paris"…)
* @returns {{year:number,month:number,day:number,hour:number,minute:number,second:number}}
*/
export function zonedParts(ms, timeZone) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23',
});
const parts = {};
for (const p of fmt.formatToParts(new Date(ms))) parts[p.type] = p.value;
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
};
}
/**
* Reconstruit un epoch ms depuis des parties calendaires interprétées DANS
* [timeZone] (algorithme de conversion inverse : UTC initial, correction par
* l'offset réel mesuré sur ce point).
*
* @param {{year:number,month:number,day:number,hour:number,minute:number,second:number}} p
* @param {string} timeZone
* @returns {number} epoch ms
*/
export function zonedTimeToMs(p, timeZone) {
// Conventions : offset(ms) = Date.UTC(parties de ms dans la zone) − ms.
// UTC = W − offset(UTC) (W = instant naïf interprété comme UTC) — deux
// passes couvrent les cas de bord DST.
const naive = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
const offsetAt = (ms) => {
const q = zonedParts(ms, timeZone);
return Date.UTC(q.year, q.month - 1, q.day, q.hour, q.minute, q.second) - ms;
};
let guess = naive - offsetAt(naive); // 1ʳᵉ approximation
guess = naive - offsetAt(guess); // correction DST (point fixe)
return guess;
}
/**
* Instants des LABELS X (v1.4.5, fix #55) : alignés sur les frontières
* LOCALES de [timeZone] (minuit local pour les pas en jours, heures rondes
* locales pour les pas horaires) et NON sur des multiples UTC — l'ancien
* alignement plaçait le label « 13/09 » à 02:00 du matin FR, ce qui décalait
* la lecture visuelle des événements (remontée : « la prévision simule mon
* injection le 13 au lieu du 12 »).
*
* @param {number} t0 Début de fenêtre (epoch ms)
* @param {number} t1 Fin de fenêtre (epoch ms)
* @param {number} stepHours Pas des labels (1/3/6/24/120…)
* @param {string} [timeZone] Fuseau de LECTURE (défaut = fuseau du système)
* @returns {number[]} instants de tick croissants dans [t0 ; t1]
*/
export function xLabelTicks(t0, t1, stepHours, timeZone) {
const zone = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
if (t1 <= t0 || stepHours <= 0) return [];
// Premier tick : première frontière locale STRICTEMENT ≥ t0
let first;
if (stepHours >= 24) {
// pas en JOURS : frontières = MINUIT LOCAL dans [zone]
const parts = zonedParts(t0, zone);
const midnightSameDay = zonedTimeToMs(
{ year: parts.year, month: parts.month, day: parts.day, hour: 0, minute: 0, second: 0 },
zone,
);
const days = Math.max(Math.trunc(stepHours / 24), 1);
first = midnightSameDay;
while (first <= t0) {
// avance de N jours CALENDAIRES dans le fuseau (DST-safe)
const p = zonedParts(first, zone);
first = zonedTimeToMs({ year: p.year, month: p.month, day: p.day + days, hour: 0, minute: 0, second: 0 }, zone);
}
} else {
// pas horaire : heures rondes LOCALES dans [zone]
const parts = zonedParts(t0, zone);
const topOfHour = zonedTimeToMs(
{ year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: 0, second: 0 },
zone,
);
first = topOfHour;
while (first <= t0) first += 3600000;
}
const stepMs = stepHours * 3600000;
const out = [];
// Pour les pas ≥ 24 h, on régénère chaque tick par pas calendaire (DST-safe)
if (stepHours >= 24) {
const days = Math.max(Math.trunc(stepHours / 24), 1);
let t = first;
while (t <= t1) {
out.push(t);
const p = zonedParts(t, zone);
t = zonedTimeToMs({ year: p.year, month: p.month, day: p.day + days, hour: 0, minute: 0, second: 0 }, zone);
}
} else {
for (let t = first; t <= t1; t += stepMs) out.push(t);
}
return out;
}