/* ============================================================================ * HFChart — moteur de chart canvas haute fréquence, from scratch (zéro dépendance) * Author : Simon-Pierre Boucher — contact@spboucher.ai * ==========================================================================*/ (function () { 'use strict'; const HF = (window.HF = window.HF || {}); const MONTHS = ['janv', 'févr', 'mars', 'avr', 'mai', 'juin', 'juil', 'août', 'sept', 'oct', 'nov', 'déc']; const DAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']; function pad(n) { return n < 10 ? '0' + n : '' + n; } function fmtPrice(v, dec) { if (v == null || !isFinite(v)) return '—'; const d = dec != null ? dec : 2; return v.toLocaleString('fr-CA', { minimumFractionDigits: d, maximumFractionDigits: d }); } function fmtVol(v) { if (v == null || !isFinite(v) || v === 0) return '0'; const a = Math.abs(v); if (a >= 1e9) return (v / 1e9).toFixed(2) + ' G'; if (a >= 1e6) return (v / 1e6).toFixed(2) + ' M'; if (a >= 1e3) return (v / 1e3).toFixed(1) + ' k'; return String(Math.round(v)); } function fmtTimeFull(t) { const d = new Date(t * 1000); return `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} · ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } function hexToRgb(hex) { const h = hex.replace('#', ''); const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } function niceStep(raw) { if (!(raw > 0)) return 1; const p = Math.pow(10, Math.floor(Math.log10(raw))); const m = raw / p; return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p; } /* Échelle de temps candidate (secondes) pour l'axe X. */ const TIME_LADDER = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400, 2 * 86400, 7 * 86400, 14 * 86400, 'M', '3M', '6M', 'Y', '2Y', '5Y', '10Y']; class HFChart { constructor(container, opts) { this.el = container; this.opts = opts || {}; this.base = document.createElement('canvas'); this.over = document.createElement('canvas'); for (const c of [this.base, this.over]) { c.style.position = 'absolute'; c.style.inset = '0'; container.appendChild(c); } this.over.style.cursor = 'crosshair'; this.over.style.touchAction = 'none'; // pan/pinch gérés par le chart, pas par la page this.spec = null; this.view = { first: 0, count: 200 }; this.cursor = null; // {x, y} en px CSS this.axisW = 72; this.axisH = 26; this._bindEvents(); this._ro = new ResizeObserver(() => this._resize()); this._ro.observe(container); this.readTheme(); this._resize(); } /* ----- thème : lit les custom properties CSS du container ----- */ readTheme() { const cs = getComputedStyle(this.el); const g = name => (cs.getPropertyValue(name) || '').trim(); const alpha = (hex, a) => { const [r, gg, b] = hexToRgb(hex); return `rgba(${r},${gg},${b},${a})`; }; const t = { surface: g('--surface') || '#fcfcfb', up: g('--up') || '#0ca30c', dn: g('--dn') || '#d03b3b', accent: g('--series-1') || '#2a78d6', ink: g('--ink') || '#0b0b0b', ink2: g('--ink2') || '#52514e', muted: g('--muted') || '#898781', grid: g('--grid') || '#e1e0d9', baselineAxis: g('--axis') || '#c3c2b7', chip: g('--chip') || '#0b0b0b', chipText: g('--chip-text') || '#ffffff', series: [1, 2, 3, 4, 5, 6, 7, 8].map(i => g('--series-' + i) || '#2a78d6'), alpha, }; t.accentA25 = alpha(t.accent, 0.25); t.accentA0 = alpha(t.accent, 0.0); this.th = t; } _resize() { const r = this.el.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; this.w = Math.max(50, r.width); this.h = Math.max(50, r.height); // axisW recalculé à chaque render (largeur du dernier prix mesurée) for (const c of [this.base, this.over]) { c.width = Math.round(this.w * dpr); c.height = Math.round(this.h * dpr); c.style.width = this.w + 'px'; c.style.height = this.h + 'px'; c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); } this.render(); } /* ----- données ----- */ setSeries(spec, keepView) { const prevN = this.spec ? this.spec.items.length : 0; this.spec = spec; const n = spec.items.length; if (!keepView || !prevN) { const def = Math.min(n, spec.defaultVisible || 220); // marge de respiration à droite (~6 %), comme sur les plateformes pro this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) }; } else { // conserve la fenêtre relative à la fin (nouvelles données à gauche) const shift = n - prevN; this.view.first += shift; } this._clampView(); this.render(); } _clampView() { const n = this.spec ? this.spec.items.length : 0; this.view.count = Math.max(8, Math.min(this.view.count, Math.max(16, n * 1.25))); const minFirst = -this.view.count * 0.5; const maxFirst = Math.max(minFirst, n - this.view.count * 0.15); this.view.first = Math.max(minFirst, Math.min(this.view.first, maxFirst)); } resetView() { if (!this.spec) return; const n = this.spec.items.length; const def = Math.min(n, this.spec.defaultVisible || 220); this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) }; this.render(); } visibleRange() { const n = this.spec ? this.spec.items.length : 0; const i0 = Math.max(0, Math.floor(this.view.first)); const i1 = Math.min(n - 1, Math.ceil(this.view.first + this.view.count)); return [i0, i1]; } /* ----- layout des panes ----- */ _layout() { const spec = this.spec; const plotW = this.w - this.axisW; const plotH = this.h - this.axisH; const panes = []; const subs = (spec.panes || []); const showVol = spec.showVolume; let subH = 0; const volH = showVol ? Math.max(52, Math.min(110, plotH * 0.16)) : 0; const indH = subs.length ? Math.max(72, Math.min(140, plotH * 0.18)) : 0; subH = volH + indH * subs.length; const mainH = Math.max(80, plotH - subH); let y = 0; panes.push({ id: 'main', kind: 'price', rect: { x: 0, y, w: plotW, h: mainH } }); y += mainH; if (showVol) { panes.push({ id: 'volume', kind: 'volume', rect: { x: 0, y, w: plotW, h: volH } }); y += volH; } for (const s of subs) { panes.push({ ...s, rect: { x: 0, y, w: plotW, h: indH } }); y += indH; } return panes; } _xScale(rect) { const bw = rect.w / this.view.count; const first = this.view.first; return { bw, X: i => rect.x + (i - first + 0.5) * bw }; } _iAtX(x, rect) { const bw = rect.w / this.view.count; return Math.round(this.view.first + (x - rect.x) / bw - 0.5); } /* ----- échelle Y d'un pane ----- */ _yScale(pane, i0, i1) { const spec = this.spec; const items = spec.items; const pad = 0.08; if (pane.kind === 'price') { let lo = Infinity, hi = -Infinity; for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b) continue; if (b.l < lo) lo = b.l; if (b.h > hi) hi = b.h; } const extraArrs = []; for (const ov of (spec.overlays || [])) { if (ov.band) extraArrs.push(ov.band.up, ov.band.lo); else extraArrs.push(ov.vals); } for (const cs of (spec.compare || [])) extraArrs.push(cs.vals); for (const arr of extraArrs) { if (!arr) continue; for (let i = i0; i <= i1; i++) { const v = arr[i]; if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; } } if (spec.anchor != null) { lo = Math.min(lo, spec.anchor); hi = Math.max(hi, spec.anchor); } if (!isFinite(lo) || !isFinite(hi)) { lo = 0; hi = 1; } if (hi - lo < 1e-9) { hi += 1; lo -= 1; } const r = pane.rect; if (spec.logScale && lo > 0) { const llo = Math.log(lo), lhi = Math.log(hi); const span = (lhi - llo) || 1; const p = span * pad; return { Y: v => r.y + r.h - ((Math.log(Math.max(v, 1e-12)) - (llo - p)) / (span + 2 * p)) * r.h, lo, hi, log: true }; } const span = hi - lo; const p = span * pad; return { Y: v => r.y + r.h - ((v - (lo - p)) / (span + 2 * p)) * r.h, lo, hi, log: false }; } if (pane.kind === 'volume') { let maxV = 0; for (let i = i0; i <= i1; i++) { const b = items[i]; if (b && b.v > maxV) maxV = b.v; } return { maxV: maxV || 1 }; } if (pane.kind === 'rsi') { const r = pane.rect; return { Y: v => r.y + r.h - (v / 100) * r.h, lo: 0, hi: 100 }; } // macd / atr : autoscale sur les valeurs du pane let lo = Infinity, hi = -Infinity; const arrs = pane.kind === 'macd' ? [pane.data.line, pane.data.signal, pane.data.hist] : [pane.data]; for (const arr of arrs) { for (let i = i0; i <= i1; i++) { const v = arr[i]; if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; } } if (!isFinite(lo)) { lo = 0; hi = 1; } if (pane.kind === 'macd') { const m = Math.max(Math.abs(lo), Math.abs(hi), 1e-9); lo = -m; hi = m; } if (hi - lo < 1e-9) { hi += 1; lo -= 1; } const r = pane.rect; const p = (hi - lo) * 0.12; return { Y: v => r.y + r.h - ((v - (lo - p)) / ((hi - lo) + 2 * p)) * r.h, lo, hi }; } /* ----- ticks de temps ----- */ _timeTicks(i0, i1, bw) { const items = this.spec.items; if (i1 <= i0) return []; // tf médian const deltas = []; for (let i = Math.max(1, i0); i <= Math.min(i1, i0 + 60); i++) { if (items[i] && items[i - 1]) deltas.push(items[i].t - items[i - 1].t); } deltas.sort((a, b) => a - b); const tf = deltas[Math.floor(deltas.length / 2)] || 60; const targetSec = (92 / bw) * tf; let unit = TIME_LADDER[TIME_LADDER.length - 1]; for (const u of TIME_LADDER) { const sec = typeof u === 'number' ? u : u === 'M' ? 30 * 86400 : u === '3M' ? 91 * 86400 : u === '6M' ? 182 * 86400 : u === 'Y' ? 365 * 86400 : u === '2Y' ? 730 * 86400 : u === '5Y' ? 1826 * 86400 : 3652 * 86400; if (sec >= targetSec) { unit = u; break; } } const ticks = []; let lastKey = null, lastPx = -1e9; const keyOf = t => { const d = new Date(t * 1000); if (typeof unit === 'number') return Math.floor(t / unit); const y = d.getUTCFullYear(), m = d.getUTCMonth(); if (unit === 'M') return y * 12 + m; if (unit === '3M') return y * 4 + Math.floor(m / 3); if (unit === '6M') return y * 2 + Math.floor(m / 6); if (unit === 'Y') return y; if (unit === '2Y') return Math.floor(y / 2); if (unit === '5Y') return Math.floor(y / 5); return Math.floor(y / 10); }; for (let i = Math.max(0, i0); i <= i1; i++) { const b = items[i]; if (!b) continue; const k = keyOf(b.t); if (k === lastKey) continue; const px = (i - this.view.first + 0.5) * bw; const d = new Date(b.t * 1000); const prev = items[i - 1] ? new Date(items[i - 1].t * 1000) : null; // force des frontières : année > mois > reste (une année ne saute jamais // au profit d'un simple tick de mois trop proche) const strength = !prev ? 0 : prev.getUTCFullYear() !== d.getUTCFullYear() ? 2 : prev.getUTCMonth() !== d.getUTCMonth() ? 1 : 0; if (lastKey !== null && px - lastPx < 68) { lastKey = k; const lastTick = ticks[ticks.length - 1]; if (!(lastTick && strength > lastTick.strength)) continue; ticks.pop(); // la frontière forte remplace le tick faible trop proche } lastKey = k; lastPx = px; if (ticks.length === 0 && px < 8) continue; let label; if (typeof unit === 'number' && unit < 86400) { label = (!prev || prev.getUTCDate() !== d.getUTCDate()) ? `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}` : `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } else if (typeof unit === 'number') { label = (!prev || prev.getUTCFullYear() !== d.getUTCFullYear()) ? String(d.getUTCFullYear()) : (prev.getUTCMonth() !== d.getUTCMonth()) ? MONTHS[d.getUTCMonth()] : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; } else if (unit === 'M' || unit === '3M' || unit === '6M') { label = d.getUTCMonth() === 0 ? String(d.getUTCFullYear()) : MONTHS[d.getUTCMonth()]; } else { label = String(d.getUTCFullYear()); } ticks.push({ i, label, strength }); } return ticks; } _priceTicks(scale, rect) { const ticks = []; const n = Math.max(2, Math.floor(rect.h / 56)); if (scale.log && scale.hi / scale.lo > 3) { // décades 1-2-5 en échelle log let p = Math.pow(10, Math.floor(Math.log10(scale.lo))); const mults = [1, 2, 5]; for (let dec = 0; dec < 20 && p < scale.hi * 10; dec++, p *= 10) { for (const m of mults) { const v = m * p; if (v >= scale.lo && v <= scale.hi) ticks.push(v); } } return ticks; } const step = niceStep((scale.hi - scale.lo) / n); for (let v = Math.ceil(scale.lo / step) * step; v <= scale.hi + step * 1e-6; v += step) ticks.push(v); return ticks; } /* ----- rendu principal ----- */ render() { const ctx = this.base.getContext('2d'); ctx.clearRect(0, 0, this.w, this.h); const th = this.th; ctx.fillStyle = th.surface; ctx.fillRect(0, 0, this.w, this.h); if (!this.spec || !this.spec.items.length) { ctx.fillStyle = th.muted; ctx.font = '13px system-ui, -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Chargement…', this.w / 2, this.h / 2); this._renderOverlay(); return; } const spec = this.spec; // largeur d'axe adaptée au prix courant (jamais de label rogné) { const lastBar = spec.items[spec.items.length - 1]; ctx.font = '600 11px system-ui, -apple-system, sans-serif'; const w = lastBar ? ctx.measureText(fmtPrice(lastBar.c, spec.decimals) + (spec.percent ? ' %' : '')).width : 50; this.axisW = Math.max(46, Math.min(96, Math.ceil(w) + 16)); } const panes = this._layout(); this.panes = panes; const [i0, i1] = this.visibleRange(); const main = panes[0]; const { bw, X } = this._xScale(main.rect); this._bw = bw; this._X = X; const font = '11px system-ui, -apple-system, sans-serif'; // ticks temps (partagés) const tticks = this._timeTicks(i0, i1, bw); for (const pane of panes) { const r = pane.rect; const scale = this._yScale(pane, i0, i1); pane.scale = scale; ctx.save(); ctx.beginPath(); ctx.rect(r.x, r.y, r.w, r.h); ctx.clip(); // grille verticale (plus discrète que l'horizontale) ctx.strokeStyle = th.alpha(th.grid, 0.6); ctx.lineWidth = 1; ctx.beginPath(); for (const tk of tticks) { const x = Math.round(X(tk.i)) + 0.5; ctx.moveTo(x, r.y); ctx.lineTo(x, r.y + r.h); } ctx.stroke(); const env = { ctx, items: spec.items, i0, i1, X, Y: scale.Y, rect: r, bw, th, anchor: spec.anchor }; if (pane.kind === 'price') { // grille horizontale + labels const pticks = this._priceTicks(scale, r); ctx.beginPath(); ctx.strokeStyle = th.grid; for (const v of pticks) { const y = Math.round(scale.Y(v)) + 0.5; ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); } ctx.stroke(); pane.pticks = pticks; // filigrane symbole (repère d'identité, très en retrait) if (spec.meta && spec.meta.symbol && r.h > 90) { ctx.save(); const size = Math.min(r.h * 0.30, r.w * 0.13, 110); ctx.font = `700 ${size}px system-ui, -apple-system, sans-serif`; ctx.fillStyle = th.alpha(th.ink, 0.045); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const wm = spec.meta.tfLabel ? `${spec.meta.symbol} · ${spec.meta.tfLabel}` : spec.meta.symbol; ctx.fillText(wm, r.x + r.w / 2, r.y + r.h / 2); ctx.restore(); } // bandes (Bollinger / VWAP) sous la série for (const ov of (spec.overlays || [])) { if (ov.band) HF.render.bandFill(env, ov.band.up, ov.band.lo, th.alpha(ov.color, 0.10)); } // profil de volume (avant la série pour rester en fond) if (spec.volumeProfile) this._drawProfile(ctx, env, i0, i1); // série principale ('none' = mode comparaison, lignes uniquement) if (spec.draw !== 'none') (HF.render[spec.draw] || HF.render.candles)(env); // lignes d'overlays for (const ov of (spec.overlays || [])) { if (ov.band) { HF.render.indicatorLine(env, ov.band.up, ov.color, 1, [3, 3]); HF.render.indicatorLine(env, ov.band.lo, ov.color, 1, [3, 3]); if (ov.band.mid) HF.render.indicatorLine(env, ov.band.mid, ov.color, 1.5); } else if (ov.vals) { HF.render.indicatorLine(env, ov.vals, ov.color, ov.width || 1.75, ov.dash); } } // lignes de comparaison if (spec.compare) { for (const cs of spec.compare) { HF.render.indicatorLine(env, cs.vals, cs.color, 2); } } } else if (pane.kind === 'volume') { HF.render.volumeColumns(env, scale.maxV); } else if (pane.kind === 'rsi') { for (const lvl of [30, 70]) { ctx.strokeStyle = th.grid; ctx.setLineDash([3, 3]); ctx.beginPath(); const y = Math.round(scale.Y(lvl)) + 0.5; ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); ctx.stroke(); ctx.setLineDash([]); } ctx.fillStyle = th.alpha(th.series[0], 0.06); ctx.fillRect(r.x, scale.Y(70), r.w, scale.Y(30) - scale.Y(70)); HF.render.indicatorLine(env, pane.data, th.series[0], 2); } else if (pane.kind === 'macd') { const zero = Math.round(scale.Y(0)) + 0.5; ctx.strokeStyle = th.baselineAxis; ctx.beginPath(); ctx.moveTo(r.x, zero); ctx.lineTo(r.x + r.w, zero); ctx.stroke(); const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.7))); for (const pos of [true, false]) { ctx.fillStyle = th.alpha(pos ? th.up : th.dn, 0.45); ctx.beginPath(); for (let i = i0; i <= i1; i++) { const v = pane.data.hist[i]; if (v == null || (v >= 0) !== pos) continue; const y = scale.Y(v); ctx.rect(Math.round(X(i) - w / 2), Math.min(y, zero), w, Math.abs(y - zero) || 1); } ctx.fill(); } HF.render.indicatorLine(env, pane.data.line, th.series[0], 1.75); HF.render.indicatorLine(env, pane.data.signal, th.series[1], 1.75); } else if (pane.kind === 'atr') { HF.render.indicatorLine(env, pane.data, th.series[2], 2); } ctx.restore(); // séparateur de pane if (pane !== panes[0]) { ctx.strokeStyle = th.baselineAxis; ctx.beginPath(); ctx.moveTo(0, Math.round(r.y) + 0.5); ctx.lineTo(this.w, Math.round(r.y) + 0.5); ctx.stroke(); } } // ---- axe des prix (pane principal) + labels des sous-panes ---- ctx.font = font; ctx.textBaseline = 'middle'; ctx.textAlign = 'left'; const axisX = this.w - this.axisW; ctx.strokeStyle = th.baselineAxis; ctx.beginPath(); ctx.moveTo(Math.round(axisX) + 0.5, 0); ctx.lineTo(Math.round(axisX) + 0.5, this.h - this.axisH); ctx.stroke(); ctx.fillStyle = th.muted; const pctSuffix = spec.percent ? ' %' : ''; for (const v of (main.pticks || [])) { const y = main.scale.Y(v); if (y < main.rect.y + 8 || y > main.rect.y + main.rect.h - 8) continue; ctx.fillText(fmtPrice(v, this._tickDecimals(main.pticks)) + pctSuffix, axisX + 6, y); } // labels min/max + titre des sous-panes for (const pane of panes.slice(1)) { const r = pane.rect; ctx.fillStyle = th.muted; if (pane.kind === 'volume') { ctx.fillText(fmtVol(pane.scale.maxV), axisX + 6, r.y + 10); } else if (pane.scale && pane.scale.Y) { ctx.fillText(fmtPrice(pane.scale.hi, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + 10); ctx.fillText(fmtPrice(pane.scale.lo, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + r.h - 10); } const title = pane.kind === 'volume' ? 'Volume' : (pane.label || pane.kind.toUpperCase()); ctx.font = '600 10px system-ui, -apple-system, sans-serif'; ctx.fillStyle = th.ink2; ctx.textAlign = 'left'; ctx.fillText(title, 8, r.y + 11); ctx.font = font; } // ---- axe du temps ---- const axisY = Math.round(this.h - this.axisH) + 0.5; ctx.strokeStyle = th.baselineAxis; ctx.beginPath(); ctx.moveTo(0, axisY); ctx.lineTo(this.w, axisY); for (const tk of tticks) { // petites graduations const x = Math.round(X(tk.i)) + 0.5; if (x > 4 && x < axisX - 4) { ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); } } ctx.stroke(); ctx.fillStyle = th.muted; ctx.textAlign = 'center'; for (const tk of tticks) { const x = X(tk.i); if (x > 4 && x < axisX - 4) ctx.fillText(tk.label, x, this.h - this.axisH / 2 + 2); } // ---- dernier prix (chip sur l'axe, sans objet en mode comparaison) ---- const last = spec.items[spec.items.length - 1]; if (last && main.scale && !spec.compare) { const prev = spec.items[spec.items.length - 2]; const up = prev ? last.c >= prev.c : true; const y = Math.max(main.rect.y + 9, Math.min(main.rect.y + main.rect.h - 9, main.scale.Y(last.c))); // ligne pointillée du dernier prix ctx.strokeStyle = th.alpha(up ? th.up : th.dn, 0.55); ctx.setLineDash([2, 4]); ctx.beginPath(); ctx.moveTo(0, Math.round(main.scale.Y(last.c)) + 0.5); ctx.lineTo(axisX, Math.round(main.scale.Y(last.c)) + 0.5); ctx.stroke(); ctx.setLineDash([]); ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.25)'; ctx.shadowBlur = 6; ctx.shadowOffsetY = 1; ctx.fillStyle = up ? th.up : th.dn; const label = fmtPrice(last.c, spec.decimals); const tw = ctx.measureText(label).width; HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4); ctx.fill(); ctx.restore(); ctx.fillStyle = '#ffffff'; ctx.textAlign = 'left'; ctx.font = '600 11px system-ui, -apple-system, sans-serif'; ctx.fillText(label, axisX + 8, y + 0.5); ctx.font = font; } // labels de fin de ligne pour la comparaison (identité ≠ couleur seule) if (spec.compare && main.scale) { ctx.textAlign = 'left'; ctx.font = 'bold 11px system-ui, -apple-system, sans-serif'; const used = []; for (const cs of spec.compare) { let li = i1; while (li >= i0 && cs.vals[li] == null) li--; if (li < i0) continue; let y = main.scale.Y(cs.vals[li]); y = Math.max(main.rect.y + 8, Math.min(main.rect.y + main.rect.h - 8, y)); while (used.some(u => Math.abs(u - y) < 14)) y += 14; used.push(y); const tw = ctx.measureText(cs.label).width; const lx = Math.min(X(li) + 13, axisX - tw - 4); // jamais rogné par l'axe ctx.fillStyle = cs.color; ctx.beginPath(); ctx.arc(lx - 7, y, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.fillText(cs.label, lx, y); } ctx.font = font; } this._renderOverlay(); } _tickDecimals(ticks) { if (!ticks || ticks.length < 2) return this.spec.decimals; const step = Math.abs(ticks[1] - ticks[0]); if (step >= 1) return Math.min(this.spec.decimals, step >= 10 ? 0 : 1); return Math.min(6, Math.max(this.spec.decimals, Math.ceil(-Math.log10(step)))); } _drawProfile(ctx, env, i0, i1) { const prof = HF.ind.volumeProfile(this.spec.items, i0, i1, 26); if (!prof) return; const r = env.rect; const th = this.th; const maxW = r.w * 0.22; prof.rows.forEach((row, k) => { const y0 = env.Y(row.p1), y1 = env.Y(row.p0); const h = Math.max(1, y1 - y0 - 1); const wUp = (row.up / prof.max) * maxW; const wDn = (row.dn / prof.max) * maxW; const xR = r.x + r.w; ctx.fillStyle = th.alpha(th.up, k === prof.poc ? 0.5 : 0.22); ctx.fillRect(xR - wUp - wDn, y0, wUp, h); ctx.fillStyle = th.alpha(th.dn, k === prof.poc ? 0.5 : 0.22); ctx.fillRect(xR - wDn, y0, wDn, h); }); // ligne POC const poc = prof.rows[prof.poc]; const yP = env.Y((poc.p0 + poc.p1) / 2); ctx.strokeStyle = th.alpha(th.ink, 0.4); ctx.setLineDash([5, 4]); ctx.beginPath(); ctx.moveTo(r.x, Math.round(yP) + 0.5); ctx.lineTo(r.x + r.w, Math.round(yP) + 0.5); ctx.stroke(); ctx.setLineDash([]); } /* ----- overlay : crosshair + chips ----- */ _renderOverlay() { const ctx = this.over.getContext('2d'); ctx.clearRect(0, 0, this.w, this.h); if (!this.cursor || !this.spec || !this.spec.items.length || !this.panes) { if (this.opts.onCrosshair) this.opts.onCrosshair(null, null); return; } const th = this.th; const { x, y } = this.cursor; const main = this.panes[0]; const i = Math.max(0, Math.min(this.spec.items.length - 1, this._iAtX(x, main.rect))); const item = this.spec.items[i]; const cx = Math.round(this._X(i)) + 0.5; const axisX = this.w - this.axisW; ctx.strokeStyle = th.alpha(th.ink, 0.45); ctx.setLineDash([4, 4]); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(cx, 0); ctx.lineTo(cx, this.h - this.axisH); ctx.stroke(); // ligne horizontale dans le pane sous le curseur const pane = this.panes.find(p => y >= p.rect.y && y <= p.rect.y + p.rect.h); if (pane) { ctx.beginPath(); ctx.moveTo(0, Math.round(y) + 0.5); ctx.lineTo(axisX, Math.round(y) + 0.5); ctx.stroke(); } ctx.setLineDash([]); ctx.font = '11px system-ui, -apple-system, sans-serif'; ctx.textBaseline = 'middle'; // chip prix if (pane && pane.kind === 'price' && pane.scale) { const s = pane.scale; let v; if (s.log) { const r = pane.rect; const span = Math.log(s.hi) - Math.log(s.lo) || 1; const p = span * 0.08; v = Math.exp((Math.log(s.lo) - p) + (1 - (y - r.y) / r.h) * (span + 2 * p)); } else { const r = pane.rect; const span = s.hi - s.lo, p = span * 0.08; v = (s.lo - p) + (1 - (y - r.y) / r.h) * (span + 2 * p); } const label = fmtPrice(v, this.spec.decimals) + (this.spec.percent ? ' %' : ''); ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.22)'; ctx.shadowBlur = 5; ctx.fillStyle = th.chip; const tw = ctx.measureText(label).width; HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4); ctx.fill(); ctx.restore(); ctx.fillStyle = th.chipText; ctx.textAlign = 'left'; ctx.fillText(label, axisX + 8, y + 0.5); } // chip temps if (item) { const label = fmtTimeFull(item.t); const tw = ctx.measureText(label).width + 14; const bx = Math.max(2, Math.min(this.w - tw - 2, cx - tw / 2)); ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.22)'; ctx.shadowBlur = 5; ctx.fillStyle = th.chip; HF.render.roundRect(ctx, bx, this.h - this.axisH + 2, tw, this.axisH - 5, 4); ctx.fill(); ctx.restore(); ctx.fillStyle = th.chipText; ctx.textAlign = 'center'; ctx.fillText(label, bx + tw / 2, this.h - this.axisH / 2); // point sur les types "ligne" if (['line', 'step', 'area', 'baseline'].includes(this.spec.draw) && main.scale) { ctx.fillStyle = th.accent; ctx.beginPath(); ctx.arc(this._X(i), main.scale.Y(item.c), 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = th.surface; ctx.lineWidth = 2; ctx.stroke(); } } if (this.opts.onCrosshair) this.opts.onCrosshair(i, item); } /* ----- interactions ----- */ _bindEvents() { const el = this.over; let drag = null; let pinch = null; const pts = new Map(); // pointerId → position (pinch tactile à 2 doigts) const pinchDist = () => { const [a, b] = [...pts.values()]; return Math.max(12, Math.hypot(a.x - b.x, a.y - b.y)); }; el.addEventListener('pointerdown', e => { pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); el.setPointerCapture(e.pointerId); if (pts.size === 2) { drag = null; const r = el.getBoundingClientRect(); const [a, b] = [...pts.values()]; const midX = (a.x + b.x) / 2 - r.left; const plotW = this.w - this.axisW; pinch = { dist: pinchDist(), count: this.view.count, frac: Math.max(0, Math.min(1, midX / plotW)), iMid: this.view.first + (midX / plotW) * this.view.count, }; } else { drag = { x: e.clientX, first: this.view.first }; el.style.cursor = 'grabbing'; } }); const endPointer = e => { pts.delete(e.pointerId); if (pts.size < 2) pinch = null; if (pts.size === 0) drag = null; el.style.cursor = 'crosshair'; }; el.addEventListener('pointerup', endPointer); el.addEventListener('pointercancel', endPointer); el.addEventListener('pointermove', e => { if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); const r = el.getBoundingClientRect(); this.cursor = { x: e.clientX - r.left, y: e.clientY - r.top }; if (pinch && pts.size === 2) { const scale = pinch.dist / pinchDist(); this.view.count = pinch.count * scale; this.view.first = pinch.iMid - pinch.frac * this.view.count; this._clampView(); this.render(); if (this.opts.onViewChange) this.opts.onViewChange(this.view); } else if (drag) { const bw = (this.w - this.axisW) / this.view.count; this.view.first = drag.first - (e.clientX - drag.x) / bw; this._clampView(); this.render(); if (this.opts.onViewChange) this.opts.onViewChange(this.view); } else { this._renderOverlay(); } }); el.addEventListener('pointerleave', () => { this.cursor = null; this._renderOverlay(); }); el.addEventListener('wheel', e => { e.preventDefault(); if (!this.spec) return; const r = el.getBoundingClientRect(); const x = e.clientX - r.left; if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { // pan horizontal (trackpad) const bw = (this.w - this.axisW) / this.view.count; this.view.first += e.deltaX / bw; } else { const factor = Math.exp(e.deltaY * 0.0016); const iAt = this.view.first + (x / (this.w - this.axisW)) * this.view.count; const newCount = this.view.count * factor; this.view.count = newCount; this.view.first = iAt - (x / (this.w - this.axisW)) * newCount; } this._clampView(); this.render(); if (this.opts.onViewChange) this.opts.onViewChange(this.view); }, { passive: false }); el.addEventListener('dblclick', () => this.resetView()); } destroy() { this._ro.disconnect(); this.el.innerHTML = ''; } } /* Mini-rendu statique pour la galerie (pas d'axes, pas d'interaction). */ function mini(canvas, draw, items, th, extras) { const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth || 220, h = canvas.clientHeight || 110; canvas.width = w * dpr; canvas.height = h * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h); if (!items || !items.length) return; let lo = Infinity, hi = -Infinity; for (const b of items) { lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); } if (!(hi > lo)) return; const pad = (hi - lo) * 0.06; const rect = { x: 0, y: 0, w, h }; const bw = w / items.length; const env = { ctx, items, i0: 0, i1: items.length - 1, X: i => (i + 0.5) * bw, Y: v => h - ((v - (lo - pad)) / ((hi - lo) + 2 * pad)) * h, rect, bw, th, anchor: extras && extras.anchor != null ? extras.anchor : items[0].c, }; (HF.render[draw] || HF.render.candles)(env); } HF.HFChart = HFChart; HF.mini = mini; HF.fmt = { price: fmtPrice, vol: fmtVol, time: fmtTimeFull, MONTHS, DAYS }; })();