/** * ───────────────────────────────────────────────────────────────────────────── * 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} pkModelsUsed Ensemble des pkModel des traitements à profil * @returns {Object} { 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; }