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%
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