SPB Git

spb/hfchart Public

HFChart — la référence des charts haute fréquence : 14 types rendus canvas from scratch (zéro dépendance), données HF Market Data — www.hfchart.io

JavaScript 82.1% CSS 10% HTML 5.8% Python 2.1%
33.8 KB · 868 lines javascript
Raw Blame History
1/* ============================================================================2 * HFChart — moteur de chart canvas haute fréquence, from scratch (zéro dépendance)3 * Author : Simon-Pierre Boucher — contact@spboucher.ai4 * ==========================================================================*/5(function () {6  'use strict';7  const HF = (window.HF = window.HF || {});89  const MONTHS = ['janv', 'févr', 'mars', 'avr', 'mai', 'juin', 'juil', 'août', 'sept', 'oct', 'nov', 'déc'];10  const DAYS = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'];1112  function pad(n) { return n < 10 ? '0' + n : '' + n; }1314  function fmtPrice(v, dec) {15    if (v == null || !isFinite(v)) return '—';16    const d = dec != null ? dec : 2;17    return v.toLocaleString('fr-CA', { minimumFractionDigits: d, maximumFractionDigits: d });18  }1920  function fmtVol(v) {21    if (v == null || !isFinite(v) || v === 0) return '0';22    const a = Math.abs(v);23    if (a >= 1e9) return (v / 1e9).toFixed(2) + ' G';24    if (a >= 1e6) return (v / 1e6).toFixed(2) + ' M';25    if (a >= 1e3) return (v / 1e3).toFixed(1) + ' k';26    return String(Math.round(v));27  }2829  function fmtTimeFull(t) {30    const d = new Date(t * 1000);31    return `${DAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} · ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;32  }3334  function hexToRgb(hex) {35    const h = hex.replace('#', '');36    const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);37    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];38  }3940  function niceStep(raw) {41    if (!(raw > 0)) return 1;42    const p = Math.pow(10, Math.floor(Math.log10(raw)));43    const m = raw / p;44    return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p;45  }4647  /* Échelle de temps candidate (secondes) pour l'axe X. */48  const TIME_LADDER = [60, 120, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200,49    86400, 2 * 86400, 7 * 86400, 14 * 86400, 'M', '3M', '6M', 'Y', '2Y', '5Y', '10Y'];5051  class HFChart {52    constructor(container, opts) {53      this.el = container;54      this.opts = opts || {};55      this.base = document.createElement('canvas');56      this.over = document.createElement('canvas');57      for (const c of [this.base, this.over]) {58        c.style.position = 'absolute';59        c.style.inset = '0';60        container.appendChild(c);61      }62      this.over.style.cursor = 'crosshair';63      this.over.style.touchAction = 'none'; // pan/pinch gérés par le chart, pas par la page64      this.spec = null;65      this.view = { first: 0, count: 200 };66      this.cursor = null;          // {x, y} en px CSS67      this.axisW = 72;68      this.axisH = 26;69      this._bindEvents();70      this._ro = new ResizeObserver(() => this._resize());71      this._ro.observe(container);72      this.readTheme();73      this._resize();74    }7576    /* ----- thème : lit les custom properties CSS du container ----- */77    readTheme() {78      const cs = getComputedStyle(this.el);79      const g = name => (cs.getPropertyValue(name) || '').trim();80      const alpha = (hex, a) => {81        const [r, gg, b] = hexToRgb(hex);82        return `rgba(${r},${gg},${b},${a})`;83      };84      const t = {85        surface: g('--surface') || '#fcfcfb',86        up: g('--up') || '#0ca30c',87        dn: g('--dn') || '#d03b3b',88        accent: g('--series-1') || '#2a78d6',89        ink: g('--ink') || '#0b0b0b',90        ink2: g('--ink2') || '#52514e',91        muted: g('--muted') || '#898781',92        grid: g('--grid') || '#e1e0d9',93        baselineAxis: g('--axis') || '#c3c2b7',94        chip: g('--chip') || '#0b0b0b',95        chipText: g('--chip-text') || '#ffffff',96        series: [1, 2, 3, 4, 5, 6, 7, 8].map(i => g('--series-' + i) || '#2a78d6'),97        alpha,98      };99      t.accentA25 = alpha(t.accent, 0.25);100      t.accentA0 = alpha(t.accent, 0.0);101      this.th = t;102    }103104    _resize() {105      const r = this.el.getBoundingClientRect();106      const dpr = window.devicePixelRatio || 1;107      this.w = Math.max(50, r.width);108      this.h = Math.max(50, r.height);109      // axisW recalculé à chaque render (largeur du dernier prix mesurée)110      for (const c of [this.base, this.over]) {111        c.width = Math.round(this.w * dpr);112        c.height = Math.round(this.h * dpr);113        c.style.width = this.w + 'px';114        c.style.height = this.h + 'px';115        c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);116      }117      this.render();118    }119120    /* ----- données ----- */121    setSeries(spec, keepView) {122      const prevN = this.spec ? this.spec.items.length : 0;123      this.spec = spec;124      const n = spec.items.length;125      if (!keepView || !prevN) {126        const def = Math.min(n, spec.defaultVisible || 220);127        // marge de respiration à droite (~6 %), comme sur les plateformes pro128        this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) };129      } else {130        // conserve la fenêtre relative à la fin (nouvelles données à gauche)131        const shift = n - prevN;132        this.view.first += shift;133      }134      this._clampView();135      this.render();136    }137138    _clampView() {139      const n = this.spec ? this.spec.items.length : 0;140      this.view.count = Math.max(8, Math.min(this.view.count, Math.max(16, n * 1.25)));141      const minFirst = -this.view.count * 0.5;142      const maxFirst = Math.max(minFirst, n - this.view.count * 0.15);143      this.view.first = Math.max(minFirst, Math.min(this.view.first, maxFirst));144    }145146    resetView() {147      if (!this.spec) return;148      const n = this.spec.items.length;149      const def = Math.min(n, this.spec.defaultVisible || 220);150      this.view = { first: n - def, count: Math.max(8, Math.round(def * 1.06)) };151      this.render();152    }153154    visibleRange() {155      const n = this.spec ? this.spec.items.length : 0;156      const i0 = Math.max(0, Math.floor(this.view.first));157      const i1 = Math.min(n - 1, Math.ceil(this.view.first + this.view.count));158      return [i0, i1];159    }160161    /* ----- layout des panes ----- */162    _layout() {163      const spec = this.spec;164      const plotW = this.w - this.axisW;165      const plotH = this.h - this.axisH;166      const panes = [];167      const subs = (spec.panes || []);168      const showVol = spec.showVolume;169      let subH = 0;170      const volH = showVol ? Math.max(52, Math.min(110, plotH * 0.16)) : 0;171      const indH = subs.length ? Math.max(72, Math.min(140, plotH * 0.18)) : 0;172      subH = volH + indH * subs.length;173      const mainH = Math.max(80, plotH - subH);174      let y = 0;175      panes.push({ id: 'main', kind: 'price', rect: { x: 0, y, w: plotW, h: mainH } });176      y += mainH;177      if (showVol) { panes.push({ id: 'volume', kind: 'volume', rect: { x: 0, y, w: plotW, h: volH } }); y += volH; }178      for (const s of subs) { panes.push({ ...s, rect: { x: 0, y, w: plotW, h: indH } }); y += indH; }179      return panes;180    }181182    _xScale(rect) {183      const bw = rect.w / this.view.count;184      const first = this.view.first;185      return { bw, X: i => rect.x + (i - first + 0.5) * bw };186    }187188    _iAtX(x, rect) {189      const bw = rect.w / this.view.count;190      return Math.round(this.view.first + (x - rect.x) / bw - 0.5);191    }192193    /* ----- échelle Y d'un pane ----- */194    _yScale(pane, i0, i1) {195      const spec = this.spec;196      const items = spec.items;197      const pad = 0.08;198      if (pane.kind === 'price') {199        let lo = Infinity, hi = -Infinity;200        for (let i = i0; i <= i1; i++) {201          const b = items[i];202          if (!b) continue;203          if (b.l < lo) lo = b.l;204          if (b.h > hi) hi = b.h;205        }206        const extraArrs = [];207        for (const ov of (spec.overlays || [])) {208          if (ov.band) extraArrs.push(ov.band.up, ov.band.lo);209          else extraArrs.push(ov.vals);210        }211        for (const cs of (spec.compare || [])) extraArrs.push(cs.vals);212        for (const arr of extraArrs) {213          if (!arr) continue;214          for (let i = i0; i <= i1; i++) {215            const v = arr[i];216            if (v == null) continue;217            if (v < lo) lo = v;218            if (v > hi) hi = v;219          }220        }221        if (spec.anchor != null) { lo = Math.min(lo, spec.anchor); hi = Math.max(hi, spec.anchor); }222        if (!isFinite(lo) || !isFinite(hi)) { lo = 0; hi = 1; }223        if (hi - lo < 1e-9) { hi += 1; lo -= 1; }224        const r = pane.rect;225        if (spec.logScale && lo > 0) {226          const llo = Math.log(lo), lhi = Math.log(hi);227          const span = (lhi - llo) || 1;228          const p = span * pad;229          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 };230        }231        const span = hi - lo;232        const p = span * pad;233        return { Y: v => r.y + r.h - ((v - (lo - p)) / (span + 2 * p)) * r.h, lo, hi, log: false };234      }235      if (pane.kind === 'volume') {236        let maxV = 0;237        for (let i = i0; i <= i1; i++) { const b = items[i]; if (b && b.v > maxV) maxV = b.v; }238        return { maxV: maxV || 1 };239      }240      if (pane.kind === 'rsi') {241        const r = pane.rect;242        return { Y: v => r.y + r.h - (v / 100) * r.h, lo: 0, hi: 100 };243      }244      // macd / atr : autoscale sur les valeurs du pane245      let lo = Infinity, hi = -Infinity;246      const arrs = pane.kind === 'macd' ? [pane.data.line, pane.data.signal, pane.data.hist] : [pane.data];247      for (const arr of arrs) {248        for (let i = i0; i <= i1; i++) {249          const v = arr[i];250          if (v == null) continue;251          if (v < lo) lo = v;252          if (v > hi) hi = v;253        }254      }255      if (!isFinite(lo)) { lo = 0; hi = 1; }256      if (pane.kind === 'macd') { const m = Math.max(Math.abs(lo), Math.abs(hi), 1e-9); lo = -m; hi = m; }257      if (hi - lo < 1e-9) { hi += 1; lo -= 1; }258      const r = pane.rect;259      const p = (hi - lo) * 0.12;260      return { Y: v => r.y + r.h - ((v - (lo - p)) / ((hi - lo) + 2 * p)) * r.h, lo, hi };261    }262263    /* ----- ticks de temps ----- */264    _timeTicks(i0, i1, bw) {265      const items = this.spec.items;266      if (i1 <= i0) return [];267      // tf médian268      const deltas = [];269      for (let i = Math.max(1, i0); i <= Math.min(i1, i0 + 60); i++) {270        if (items[i] && items[i - 1]) deltas.push(items[i].t - items[i - 1].t);271      }272      deltas.sort((a, b) => a - b);273      const tf = deltas[Math.floor(deltas.length / 2)] || 60;274      const targetSec = (92 / bw) * tf;275      let unit = TIME_LADDER[TIME_LADDER.length - 1];276      for (const u of TIME_LADDER) {277        const sec = typeof u === 'number' ? u278          : u === 'M' ? 30 * 86400 : u === '3M' ? 91 * 86400 : u === '6M' ? 182 * 86400279          : u === 'Y' ? 365 * 86400 : u === '2Y' ? 730 * 86400 : u === '5Y' ? 1826 * 86400 : 3652 * 86400;280        if (sec >= targetSec) { unit = u; break; }281      }282      const ticks = [];283      let lastKey = null, lastPx = -1e9;284      const keyOf = t => {285        const d = new Date(t * 1000);286        if (typeof unit === 'number') return Math.floor(t / unit);287        const y = d.getUTCFullYear(), m = d.getUTCMonth();288        if (unit === 'M') return y * 12 + m;289        if (unit === '3M') return y * 4 + Math.floor(m / 3);290        if (unit === '6M') return y * 2 + Math.floor(m / 6);291        if (unit === 'Y') return y;292        if (unit === '2Y') return Math.floor(y / 2);293        if (unit === '5Y') return Math.floor(y / 5);294        return Math.floor(y / 10);295      };296      for (let i = Math.max(0, i0); i <= i1; i++) {297        const b = items[i];298        if (!b) continue;299        const k = keyOf(b.t);300        if (k === lastKey) continue;301        const px = (i - this.view.first + 0.5) * bw;302        const d = new Date(b.t * 1000);303        const prev = items[i - 1] ? new Date(items[i - 1].t * 1000) : null;304        // force des frontières : année > mois > reste (une année ne saute jamais305        // au profit d'un simple tick de mois trop proche)306        const strength = !prev ? 0307          : prev.getUTCFullYear() !== d.getUTCFullYear() ? 2308          : prev.getUTCMonth() !== d.getUTCMonth() ? 1 : 0;309        if (lastKey !== null && px - lastPx < 68) {310          lastKey = k;311          const lastTick = ticks[ticks.length - 1];312          if (!(lastTick && strength > lastTick.strength)) continue;313          ticks.pop(); // la frontière forte remplace le tick faible trop proche314        }315        lastKey = k; lastPx = px;316        if (ticks.length === 0 && px < 8) continue;317        let label;318        if (typeof unit === 'number' && unit < 86400) {319          label = (!prev || prev.getUTCDate() !== d.getUTCDate())320            ? `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`321            : `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;322        } else if (typeof unit === 'number') {323          label = (!prev || prev.getUTCFullYear() !== d.getUTCFullYear())324            ? String(d.getUTCFullYear())325            : (prev.getUTCMonth() !== d.getUTCMonth())326              ? MONTHS[d.getUTCMonth()]327              : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;328        } else if (unit === 'M' || unit === '3M' || unit === '6M') {329          label = d.getUTCMonth() === 0 ? String(d.getUTCFullYear()) : MONTHS[d.getUTCMonth()];330        } else {331          label = String(d.getUTCFullYear());332        }333        ticks.push({ i, label, strength });334      }335      return ticks;336    }337338    _priceTicks(scale, rect) {339      const ticks = [];340      const n = Math.max(2, Math.floor(rect.h / 56));341      if (scale.log && scale.hi / scale.lo > 3) {342        // décades 1-2-5 en échelle log343        let p = Math.pow(10, Math.floor(Math.log10(scale.lo)));344        const mults = [1, 2, 5];345        for (let dec = 0; dec < 20 && p < scale.hi * 10; dec++, p *= 10) {346          for (const m of mults) {347            const v = m * p;348            if (v >= scale.lo && v <= scale.hi) ticks.push(v);349          }350        }351        return ticks;352      }353      const step = niceStep((scale.hi - scale.lo) / n);354      for (let v = Math.ceil(scale.lo / step) * step; v <= scale.hi + step * 1e-6; v += step) ticks.push(v);355      return ticks;356    }357358    /* ----- rendu principal ----- */359    render() {360      const ctx = this.base.getContext('2d');361      ctx.clearRect(0, 0, this.w, this.h);362      const th = this.th;363      ctx.fillStyle = th.surface;364      ctx.fillRect(0, 0, this.w, this.h);365      if (!this.spec || !this.spec.items.length) {366        ctx.fillStyle = th.muted;367        ctx.font = '13px system-ui, -apple-system, sans-serif';368        ctx.textAlign = 'center';369        ctx.fillText('Chargement…', this.w / 2, this.h / 2);370        this._renderOverlay();371        return;372      }373      const spec = this.spec;374      // largeur d'axe adaptée au prix courant (jamais de label rogné)375      {376        const lastBar = spec.items[spec.items.length - 1];377        ctx.font = '600 11px system-ui, -apple-system, sans-serif';378        const w = lastBar ? ctx.measureText(fmtPrice(lastBar.c, spec.decimals) + (spec.percent ? ' %' : '')).width : 50;379        this.axisW = Math.max(46, Math.min(96, Math.ceil(w) + 16));380      }381      const panes = this._layout();382      this.panes = panes;383      const [i0, i1] = this.visibleRange();384      const main = panes[0];385      const { bw, X } = this._xScale(main.rect);386      this._bw = bw; this._X = X;387      const font = '11px system-ui, -apple-system, sans-serif';388389      // ticks temps (partagés)390      const tticks = this._timeTicks(i0, i1, bw);391392      for (const pane of panes) {393        const r = pane.rect;394        const scale = this._yScale(pane, i0, i1);395        pane.scale = scale;396        ctx.save();397        ctx.beginPath();398        ctx.rect(r.x, r.y, r.w, r.h);399        ctx.clip();400401        // grille verticale (plus discrète que l'horizontale)402        ctx.strokeStyle = th.alpha(th.grid, 0.6);403        ctx.lineWidth = 1;404        ctx.beginPath();405        for (const tk of tticks) {406          const x = Math.round(X(tk.i)) + 0.5;407          ctx.moveTo(x, r.y);408          ctx.lineTo(x, r.y + r.h);409        }410        ctx.stroke();411412        const env = { ctx, items: spec.items, i0, i1, X, Y: scale.Y, rect: r, bw, th, anchor: spec.anchor };413414        if (pane.kind === 'price') {415          // grille horizontale + labels416          const pticks = this._priceTicks(scale, r);417          ctx.beginPath();418          ctx.strokeStyle = th.grid;419          for (const v of pticks) {420            const y = Math.round(scale.Y(v)) + 0.5;421            ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);422          }423          ctx.stroke();424          pane.pticks = pticks;425426          // filigrane symbole (repère d'identité, très en retrait)427          if (spec.meta && spec.meta.symbol && r.h > 90) {428            ctx.save();429            const size = Math.min(r.h * 0.30, r.w * 0.13, 110);430            ctx.font = `700 ${size}px system-ui, -apple-system, sans-serif`;431            ctx.fillStyle = th.alpha(th.ink, 0.045);432            ctx.textAlign = 'center';433            ctx.textBaseline = 'middle';434            const wm = spec.meta.tfLabel ? `${spec.meta.symbol} · ${spec.meta.tfLabel}` : spec.meta.symbol;435            ctx.fillText(wm, r.x + r.w / 2, r.y + r.h / 2);436            ctx.restore();437          }438439          // bandes (Bollinger / VWAP) sous la série440          for (const ov of (spec.overlays || [])) {441            if (ov.band) HF.render.bandFill(env, ov.band.up, ov.band.lo, th.alpha(ov.color, 0.10));442          }443          // profil de volume (avant la série pour rester en fond)444          if (spec.volumeProfile) this._drawProfile(ctx, env, i0, i1);445          // série principale ('none' = mode comparaison, lignes uniquement)446          if (spec.draw !== 'none') (HF.render[spec.draw] || HF.render.candles)(env);447          // lignes d'overlays448          for (const ov of (spec.overlays || [])) {449            if (ov.band) {450              HF.render.indicatorLine(env, ov.band.up, ov.color, 1, [3, 3]);451              HF.render.indicatorLine(env, ov.band.lo, ov.color, 1, [3, 3]);452              if (ov.band.mid) HF.render.indicatorLine(env, ov.band.mid, ov.color, 1.5);453            } else if (ov.vals) {454              HF.render.indicatorLine(env, ov.vals, ov.color, ov.width || 1.75, ov.dash);455            }456          }457          // lignes de comparaison458          if (spec.compare) {459            for (const cs of spec.compare) {460              HF.render.indicatorLine(env, cs.vals, cs.color, 2);461            }462          }463        } else if (pane.kind === 'volume') {464          HF.render.volumeColumns(env, scale.maxV);465        } else if (pane.kind === 'rsi') {466          for (const lvl of [30, 70]) {467            ctx.strokeStyle = th.grid;468            ctx.setLineDash([3, 3]);469            ctx.beginPath();470            const y = Math.round(scale.Y(lvl)) + 0.5;471            ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y);472            ctx.stroke();473            ctx.setLineDash([]);474          }475          ctx.fillStyle = th.alpha(th.series[0], 0.06);476          ctx.fillRect(r.x, scale.Y(70), r.w, scale.Y(30) - scale.Y(70));477          HF.render.indicatorLine(env, pane.data, th.series[0], 2);478        } else if (pane.kind === 'macd') {479          const zero = Math.round(scale.Y(0)) + 0.5;480          ctx.strokeStyle = th.baselineAxis;481          ctx.beginPath(); ctx.moveTo(r.x, zero); ctx.lineTo(r.x + r.w, zero); ctx.stroke();482          const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.7)));483          for (const pos of [true, false]) {484            ctx.fillStyle = th.alpha(pos ? th.up : th.dn, 0.45);485            ctx.beginPath();486            for (let i = i0; i <= i1; i++) {487              const v = pane.data.hist[i];488              if (v == null || (v >= 0) !== pos) continue;489              const y = scale.Y(v);490              ctx.rect(Math.round(X(i) - w / 2), Math.min(y, zero), w, Math.abs(y - zero) || 1);491            }492            ctx.fill();493          }494          HF.render.indicatorLine(env, pane.data.line, th.series[0], 1.75);495          HF.render.indicatorLine(env, pane.data.signal, th.series[1], 1.75);496        } else if (pane.kind === 'atr') {497          HF.render.indicatorLine(env, pane.data, th.series[2], 2);498        }499        ctx.restore();500501        // séparateur de pane502        if (pane !== panes[0]) {503          ctx.strokeStyle = th.baselineAxis;504          ctx.beginPath();505          ctx.moveTo(0, Math.round(r.y) + 0.5);506          ctx.lineTo(this.w, Math.round(r.y) + 0.5);507          ctx.stroke();508        }509      }510511      // ---- axe des prix (pane principal) + labels des sous-panes ----512      ctx.font = font;513      ctx.textBaseline = 'middle';514      ctx.textAlign = 'left';515      const axisX = this.w - this.axisW;516      ctx.strokeStyle = th.baselineAxis;517      ctx.beginPath();518      ctx.moveTo(Math.round(axisX) + 0.5, 0);519      ctx.lineTo(Math.round(axisX) + 0.5, this.h - this.axisH);520      ctx.stroke();521      ctx.fillStyle = th.muted;522      const pctSuffix = spec.percent ? ' %' : '';523      for (const v of (main.pticks || [])) {524        const y = main.scale.Y(v);525        if (y < main.rect.y + 8 || y > main.rect.y + main.rect.h - 8) continue;526        ctx.fillText(fmtPrice(v, this._tickDecimals(main.pticks)) + pctSuffix, axisX + 6, y);527      }528      // labels min/max + titre des sous-panes529      for (const pane of panes.slice(1)) {530        const r = pane.rect;531        ctx.fillStyle = th.muted;532        if (pane.kind === 'volume') {533          ctx.fillText(fmtVol(pane.scale.maxV), axisX + 6, r.y + 10);534        } else if (pane.scale && pane.scale.Y) {535          ctx.fillText(fmtPrice(pane.scale.hi, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + 10);536          ctx.fillText(fmtPrice(pane.scale.lo, pane.kind === 'rsi' ? 0 : 2), axisX + 6, r.y + r.h - 10);537        }538        const title = pane.kind === 'volume' ? 'Volume' : (pane.label || pane.kind.toUpperCase());539        ctx.font = '600 10px system-ui, -apple-system, sans-serif';540        ctx.fillStyle = th.ink2;541        ctx.textAlign = 'left';542        ctx.fillText(title, 8, r.y + 11);543        ctx.font = font;544      }545546      // ---- axe du temps ----547      const axisY = Math.round(this.h - this.axisH) + 0.5;548      ctx.strokeStyle = th.baselineAxis;549      ctx.beginPath();550      ctx.moveTo(0, axisY);551      ctx.lineTo(this.w, axisY);552      for (const tk of tticks) { // petites graduations553        const x = Math.round(X(tk.i)) + 0.5;554        if (x > 4 && x < axisX - 4) { ctx.moveTo(x, axisY); ctx.lineTo(x, axisY + 4); }555      }556      ctx.stroke();557      ctx.fillStyle = th.muted;558      ctx.textAlign = 'center';559      for (const tk of tticks) {560        const x = X(tk.i);561        if (x > 4 && x < axisX - 4) ctx.fillText(tk.label, x, this.h - this.axisH / 2 + 2);562      }563564      // ---- dernier prix (chip sur l'axe, sans objet en mode comparaison) ----565      const last = spec.items[spec.items.length - 1];566      if (last && main.scale && !spec.compare) {567        const prev = spec.items[spec.items.length - 2];568        const up = prev ? last.c >= prev.c : true;569        const y = Math.max(main.rect.y + 9, Math.min(main.rect.y + main.rect.h - 9, main.scale.Y(last.c)));570        // ligne pointillée du dernier prix571        ctx.strokeStyle = th.alpha(up ? th.up : th.dn, 0.55);572        ctx.setLineDash([2, 4]);573        ctx.beginPath();574        ctx.moveTo(0, Math.round(main.scale.Y(last.c)) + 0.5);575        ctx.lineTo(axisX, Math.round(main.scale.Y(last.c)) + 0.5);576        ctx.stroke();577        ctx.setLineDash([]);578        ctx.save();579        ctx.shadowColor = 'rgba(0,0,0,0.25)';580        ctx.shadowBlur = 6;581        ctx.shadowOffsetY = 1;582        ctx.fillStyle = up ? th.up : th.dn;583        const label = fmtPrice(last.c, spec.decimals);584        const tw = ctx.measureText(label).width;585        HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4);586        ctx.fill();587        ctx.restore();588        ctx.fillStyle = '#ffffff';589        ctx.textAlign = 'left';590        ctx.font = '600 11px system-ui, -apple-system, sans-serif';591        ctx.fillText(label, axisX + 8, y + 0.5);592        ctx.font = font;593      }594595      // labels de fin de ligne pour la comparaison (identité ≠ couleur seule)596      if (spec.compare && main.scale) {597        ctx.textAlign = 'left';598        ctx.font = 'bold 11px system-ui, -apple-system, sans-serif';599        const used = [];600        for (const cs of spec.compare) {601          let li = i1;602          while (li >= i0 && cs.vals[li] == null) li--;603          if (li < i0) continue;604          let y = main.scale.Y(cs.vals[li]);605          y = Math.max(main.rect.y + 8, Math.min(main.rect.y + main.rect.h - 8, y));606          while (used.some(u => Math.abs(u - y) < 14)) y += 14;607          used.push(y);608          const tw = ctx.measureText(cs.label).width;609          const lx = Math.min(X(li) + 13, axisX - tw - 4); // jamais rogné par l'axe610          ctx.fillStyle = cs.color;611          ctx.beginPath();612          ctx.arc(lx - 7, y, 3.5, 0, Math.PI * 2);613          ctx.fill();614          ctx.fillText(cs.label, lx, y);615        }616        ctx.font = font;617      }618619      this._renderOverlay();620    }621622    _tickDecimals(ticks) {623      if (!ticks || ticks.length < 2) return this.spec.decimals;624      const step = Math.abs(ticks[1] - ticks[0]);625      if (step >= 1) return Math.min(this.spec.decimals, step >= 10 ? 0 : 1);626      return Math.min(6, Math.max(this.spec.decimals, Math.ceil(-Math.log10(step))));627    }628629    _drawProfile(ctx, env, i0, i1) {630      const prof = HF.ind.volumeProfile(this.spec.items, i0, i1, 26);631      if (!prof) return;632      const r = env.rect;633      const th = this.th;634      const maxW = r.w * 0.22;635      prof.rows.forEach((row, k) => {636        const y0 = env.Y(row.p1), y1 = env.Y(row.p0);637        const h = Math.max(1, y1 - y0 - 1);638        const wUp = (row.up / prof.max) * maxW;639        const wDn = (row.dn / prof.max) * maxW;640        const xR = r.x + r.w;641        ctx.fillStyle = th.alpha(th.up, k === prof.poc ? 0.5 : 0.22);642        ctx.fillRect(xR - wUp - wDn, y0, wUp, h);643        ctx.fillStyle = th.alpha(th.dn, k === prof.poc ? 0.5 : 0.22);644        ctx.fillRect(xR - wDn, y0, wDn, h);645      });646      // ligne POC647      const poc = prof.rows[prof.poc];648      const yP = env.Y((poc.p0 + poc.p1) / 2);649      ctx.strokeStyle = th.alpha(th.ink, 0.4);650      ctx.setLineDash([5, 4]);651      ctx.beginPath();652      ctx.moveTo(r.x, Math.round(yP) + 0.5);653      ctx.lineTo(r.x + r.w, Math.round(yP) + 0.5);654      ctx.stroke();655      ctx.setLineDash([]);656    }657658    /* ----- overlay : crosshair + chips ----- */659    _renderOverlay() {660      const ctx = this.over.getContext('2d');661      ctx.clearRect(0, 0, this.w, this.h);662      if (!this.cursor || !this.spec || !this.spec.items.length || !this.panes) {663        if (this.opts.onCrosshair) this.opts.onCrosshair(null, null);664        return;665      }666      const th = this.th;667      const { x, y } = this.cursor;668      const main = this.panes[0];669      const i = Math.max(0, Math.min(this.spec.items.length - 1, this._iAtX(x, main.rect)));670      const item = this.spec.items[i];671      const cx = Math.round(this._X(i)) + 0.5;672      const axisX = this.w - this.axisW;673      ctx.strokeStyle = th.alpha(th.ink, 0.45);674      ctx.setLineDash([4, 4]);675      ctx.lineWidth = 1;676      ctx.beginPath();677      ctx.moveTo(cx, 0);678      ctx.lineTo(cx, this.h - this.axisH);679      ctx.stroke();680      // ligne horizontale dans le pane sous le curseur681      const pane = this.panes.find(p => y >= p.rect.y && y <= p.rect.y + p.rect.h);682      if (pane) {683        ctx.beginPath();684        ctx.moveTo(0, Math.round(y) + 0.5);685        ctx.lineTo(axisX, Math.round(y) + 0.5);686        ctx.stroke();687      }688      ctx.setLineDash([]);689      ctx.font = '11px system-ui, -apple-system, sans-serif';690      ctx.textBaseline = 'middle';691      // chip prix692      if (pane && pane.kind === 'price' && pane.scale) {693        const s = pane.scale;694        let v;695        if (s.log) {696          const r = pane.rect;697          const span = Math.log(s.hi) - Math.log(s.lo) || 1;698          const p = span * 0.08;699          v = Math.exp((Math.log(s.lo) - p) + (1 - (y - r.y) / r.h) * (span + 2 * p));700        } else {701          const r = pane.rect;702          const span = s.hi - s.lo, p = span * 0.08;703          v = (s.lo - p) + (1 - (y - r.y) / r.h) * (span + 2 * p);704        }705        const label = fmtPrice(v, this.spec.decimals) + (this.spec.percent ? ' %' : '');706        ctx.save();707        ctx.shadowColor = 'rgba(0,0,0,0.22)';708        ctx.shadowBlur = 5;709        ctx.fillStyle = th.chip;710        const tw = ctx.measureText(label).width;711        HF.render.roundRect(ctx, axisX + 2, y - 9, Math.max(tw + 12, this.axisW - 6), 18, 4);712        ctx.fill();713        ctx.restore();714        ctx.fillStyle = th.chipText;715        ctx.textAlign = 'left';716        ctx.fillText(label, axisX + 8, y + 0.5);717      }718      // chip temps719      if (item) {720        const label = fmtTimeFull(item.t);721        const tw = ctx.measureText(label).width + 14;722        const bx = Math.max(2, Math.min(this.w - tw - 2, cx - tw / 2));723        ctx.save();724        ctx.shadowColor = 'rgba(0,0,0,0.22)';725        ctx.shadowBlur = 5;726        ctx.fillStyle = th.chip;727        HF.render.roundRect(ctx, bx, this.h - this.axisH + 2, tw, this.axisH - 5, 4);728        ctx.fill();729        ctx.restore();730        ctx.fillStyle = th.chipText;731        ctx.textAlign = 'center';732        ctx.fillText(label, bx + tw / 2, this.h - this.axisH / 2);733        // point sur les types "ligne"734        if (['line', 'step', 'area', 'baseline'].includes(this.spec.draw) && main.scale) {735          ctx.fillStyle = th.accent;736          ctx.beginPath();737          ctx.arc(this._X(i), main.scale.Y(item.c), 4, 0, Math.PI * 2);738          ctx.fill();739          ctx.strokeStyle = th.surface;740          ctx.lineWidth = 2;741          ctx.stroke();742        }743      }744      if (this.opts.onCrosshair) this.opts.onCrosshair(i, item);745    }746747    /* ----- interactions ----- */748    _bindEvents() {749      const el = this.over;750      let drag = null;751      let pinch = null;752      const pts = new Map(); // pointerId → position (pinch tactile à 2 doigts)753      const pinchDist = () => {754        const [a, b] = [...pts.values()];755        return Math.max(12, Math.hypot(a.x - b.x, a.y - b.y));756      };757      el.addEventListener('pointerdown', e => {758        pts.set(e.pointerId, { x: e.clientX, y: e.clientY });759        el.setPointerCapture(e.pointerId);760        if (pts.size === 2) {761          drag = null;762          const r = el.getBoundingClientRect();763          const [a, b] = [...pts.values()];764          const midX = (a.x + b.x) / 2 - r.left;765          const plotW = this.w - this.axisW;766          pinch = {767            dist: pinchDist(),768            count: this.view.count,769            frac: Math.max(0, Math.min(1, midX / plotW)),770            iMid: this.view.first + (midX / plotW) * this.view.count,771          };772        } else {773          drag = { x: e.clientX, first: this.view.first };774          el.style.cursor = 'grabbing';775        }776      });777      const endPointer = e => {778        pts.delete(e.pointerId);779        if (pts.size < 2) pinch = null;780        if (pts.size === 0) drag = null;781        el.style.cursor = 'crosshair';782      };783      el.addEventListener('pointerup', endPointer);784      el.addEventListener('pointercancel', endPointer);785      el.addEventListener('pointermove', e => {786        if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });787        const r = el.getBoundingClientRect();788        this.cursor = { x: e.clientX - r.left, y: e.clientY - r.top };789        if (pinch && pts.size === 2) {790          const scale = pinch.dist / pinchDist();791          this.view.count = pinch.count * scale;792          this.view.first = pinch.iMid - pinch.frac * this.view.count;793          this._clampView();794          this.render();795          if (this.opts.onViewChange) this.opts.onViewChange(this.view);796        } else if (drag) {797          const bw = (this.w - this.axisW) / this.view.count;798          this.view.first = drag.first - (e.clientX - drag.x) / bw;799          this._clampView();800          this.render();801          if (this.opts.onViewChange) this.opts.onViewChange(this.view);802        } else {803          this._renderOverlay();804        }805      });806      el.addEventListener('pointerleave', () => {807        this.cursor = null;808        this._renderOverlay();809      });810      el.addEventListener('wheel', e => {811        e.preventDefault();812        if (!this.spec) return;813        const r = el.getBoundingClientRect();814        const x = e.clientX - r.left;815        if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {816          // pan horizontal (trackpad)817          const bw = (this.w - this.axisW) / this.view.count;818          this.view.first += e.deltaX / bw;819        } else {820          const factor = Math.exp(e.deltaY * 0.0016);821          const iAt = this.view.first + (x / (this.w - this.axisW)) * this.view.count;822          const newCount = this.view.count * factor;823          this.view.count = newCount;824          this.view.first = iAt - (x / (this.w - this.axisW)) * newCount;825        }826        this._clampView();827        this.render();828        if (this.opts.onViewChange) this.opts.onViewChange(this.view);829      }, { passive: false });830      el.addEventListener('dblclick', () => this.resetView());831    }832833    destroy() {834      this._ro.disconnect();835      this.el.innerHTML = '';836    }837  }838839  /* Mini-rendu statique pour la galerie (pas d'axes, pas d'interaction). */840  function mini(canvas, draw, items, th, extras) {841    const dpr = window.devicePixelRatio || 1;842    const w = canvas.clientWidth || 220, h = canvas.clientHeight || 110;843    canvas.width = w * dpr; canvas.height = h * dpr;844    const ctx = canvas.getContext('2d');845    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);846    ctx.clearRect(0, 0, w, h);847    if (!items || !items.length) return;848    let lo = Infinity, hi = -Infinity;849    for (const b of items) { lo = Math.min(lo, b.l); hi = Math.max(hi, b.h); }850    if (!(hi > lo)) return;851    const pad = (hi - lo) * 0.06;852    const rect = { x: 0, y: 0, w, h };853    const bw = w / items.length;854    const env = {855      ctx, items, i0: 0, i1: items.length - 1,856      X: i => (i + 0.5) * bw,857      Y: v => h - ((v - (lo - pad)) / ((hi - lo) + 2 * pad)) * h,858      rect, bw, th,859      anchor: extras && extras.anchor != null ? extras.anchor : items[0].c,860    };861    (HF.render[draw] || HF.render.candles)(env);862  }863864  HF.HFChart = HFChart;865  HF.mini = mini;866  HF.fmt = { price: fmtPrice, vol: fmtVol, time: fmtTimeFull, MONTHS, DAYS };867})();868