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 — renderers canvas, from scratch3 * Author : Simon-Pierre Boucher — contact@spboucher.ai4 * ==========================================================================*/5/* Chaque renderer reçoit un environnement :6 * { ctx, items, i0, i1, X(i)→px centre, Y(prix)→px, rect, bw, th }7 * th = tokens du thème (up, dn, ink, grid, series[], surface, …).8 * Specs de marques : corps fins avec 2px d'écart entre barres adjacentes,9 * lignes 2px, extrémités arrondies discrètes. */10(function () {11 'use strict';12 const HF = (window.HF = window.HF || {});1314 function bodyWidth(bw) {15 // écart de 2px garanti entre corps adjacents dès que la place existe16 return Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8)));17 }1819 function roundRect(ctx, x, y, w, h, r) {20 if (h < 0) { y += h; h = -h; }21 r = Math.min(r, w / 2, h / 2);22 if (r < 0.5) { ctx.rect(x, y, w, h); return; }23 ctx.moveTo(x + r, y);24 ctx.arcTo(x + w, y, x + w, y + h, r);25 ctx.arcTo(x + w, y + h, x, y + h, r);26 ctx.arcTo(x, y + h, x, y, r);27 ctx.arcTo(x, y, x + w, y, r);28 ctx.closePath();29 }3031 /* Fast path haute densité : 1 trait hi-lo par barre quand bw < 2px. */32 function denseHiLo(env) {33 const { ctx, items, i0, i1, X, Y, th } = env;34 ctx.lineWidth = 1;35 for (const up of [true, false]) {36 ctx.strokeStyle = up ? th.up : th.dn;37 ctx.beginPath();38 for (let i = i0; i <= i1; i++) {39 const b = items[i];40 if (!b || (b.c >= b.o) !== up) continue;41 const x = Math.round(X(i)) + 0.5;42 ctx.moveTo(x, Y(b.h));43 ctx.lineTo(x, Y(b.l));44 }45 ctx.stroke();46 }47 }4849 function candles(env, hollow) {50 const { ctx, items, i0, i1, X, Y, bw, th } = env;51 if (bw < 2) return denseHiLo(env);52 const w = bodyWidth(bw);53 const wick = bw >= 10 ? 1.5 : 1;54 const r = w >= 6 ? 1.5 : 0;55 for (const up of [true, false]) {56 const col = up ? th.up : th.dn;57 // mèches58 ctx.strokeStyle = col;59 ctx.lineWidth = wick;60 ctx.beginPath();61 for (let i = i0; i <= i1; i++) {62 const b = items[i];63 if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue;64 const x = Math.round(X(i)) + (wick === 1 ? 0.5 : 0);65 const top = Math.max(b.o, b.c), bot = Math.min(b.o, b.c);66 if (b.h > top) { ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(top)); }67 if (b.l < bot) { ctx.moveTo(x, Y(bot)); ctx.lineTo(x, Y(b.l)); }68 }69 ctx.stroke();70 // corps71 ctx.beginPath();72 for (let i = i0; i <= i1; i++) {73 const b = items[i];74 if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue;75 const x = Math.round(X(i) - w / 2);76 const y0 = Y(b.o), y1 = Y(b.c);77 const h = Math.abs(y1 - y0) < 1 ? 1 : y1 - y0;78 roundRect(ctx, x, y0, w, h, r);79 }80 if (hollow && up) {81 ctx.strokeStyle = col;82 ctx.lineWidth = Math.min(2, Math.max(1, w / 5));83 ctx.stroke();84 } else {85 ctx.fillStyle = col;86 ctx.fill();87 }88 }89 }9091 function ohlcBars(env) {92 const { ctx, items, i0, i1, X, Y, bw, th } = env;93 if (bw < 3) return denseHiLo(env);94 const tick = Math.max(2, Math.min(bw * 0.4, 8));95 ctx.lineWidth = bw >= 8 ? 1.5 : 1;96 for (const up of [true, false]) {97 ctx.strokeStyle = up ? th.up : th.dn;98 ctx.beginPath();99 for (let i = i0; i <= i1; i++) {100 const b = items[i];101 if (!b || (b.c >= b.o) !== up) continue;102 const x = Math.round(X(i)) + 0.5;103 ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(b.l));104 ctx.moveTo(x - tick, Y(b.o)); ctx.lineTo(x, Y(b.o));105 ctx.moveTo(x, Y(b.c)); ctx.lineTo(x + tick, Y(b.c));106 }107 ctx.stroke();108 }109 }110111 function linePath(env, color, width) {112 const { ctx, items, i0, i1, X, Y } = env;113 ctx.strokeStyle = color;114 ctx.lineWidth = width || 2;115 ctx.lineJoin = 'round';116 ctx.lineCap = 'round';117 ctx.beginPath();118 let started = false;119 for (let i = i0; i <= i1; i++) {120 const b = items[i];121 if (!b) continue;122 const x = X(i), y = Y(b.c);123 if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);124 }125 ctx.stroke();126 }127128 function line(env) { linePath(env, env.th.accent, 2); }129130 function step(env) {131 const { ctx, items, i0, i1, X, Y, th } = env;132 ctx.strokeStyle = th.accent;133 ctx.lineWidth = 2;134 ctx.lineJoin = 'round';135 ctx.beginPath();136 let started = false, py = 0;137 for (let i = i0; i <= i1; i++) {138 const b = items[i];139 if (!b) continue;140 const x = X(i), y = Y(b.c);141 if (!started) { ctx.moveTo(x, y); started = true; }142 else { ctx.lineTo(x, py); ctx.lineTo(x, y); }143 py = y;144 }145 ctx.stroke();146 }147148 function area(env) {149 const { ctx, items, i0, i1, X, Y, rect, th } = env;150 const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h);151 grad.addColorStop(0, th.alpha(th.accent, 0.30));152 grad.addColorStop(0.55, th.alpha(th.accent, 0.10));153 grad.addColorStop(1, th.alpha(th.accent, 0));154 ctx.beginPath();155 let started = false, lastX = 0, firstX = 0;156 for (let i = i0; i <= i1; i++) {157 const b = items[i];158 if (!b) continue;159 const x = X(i), y = Y(b.c);160 if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y);161 lastX = x;162 }163 if (!started) return;164 ctx.lineTo(lastX, rect.y + rect.h);165 ctx.lineTo(firstX, rect.y + rect.h);166 ctx.closePath();167 ctx.fillStyle = grad;168 ctx.fill();169 linePath(env, th.accent, 2);170 }171172 /* Baseline : ancre = close de la première barre chargée (env.anchor).173 * Au-dessus → up, en dessous → dn (aires + segments de ligne). */174 function baseline(env) {175 const { ctx, items, i0, i1, X, Y, rect, th } = env;176 const anchor = env.anchor;177 const yA = Math.max(rect.y, Math.min(rect.y + rect.h, Y(anchor)));178 for (const side of ['up', 'dn']) {179 ctx.save();180 ctx.beginPath();181 if (side === 'up') ctx.rect(rect.x, rect.y, rect.w, yA - rect.y);182 else ctx.rect(rect.x, yA, rect.w, rect.y + rect.h - yA);183 ctx.clip();184 const col = side === 'up' ? th.up : th.dn;185 const grad = side === 'up'186 ? ctx.createLinearGradient(0, rect.y, 0, yA)187 : ctx.createLinearGradient(0, yA, 0, rect.y + rect.h);188 grad.addColorStop(side === 'up' ? 0 : 1, th.alpha(col, 0.28));189 grad.addColorStop(side === 'up' ? 1 : 0, th.alpha(col, 0.02));190 // aire fermée sur la baseline191 ctx.beginPath();192 let started = false, lastX = 0, firstX = 0;193 for (let i = i0; i <= i1; i++) {194 const b = items[i];195 if (!b) continue;196 const x = X(i), y = Y(b.c);197 if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y);198 lastX = x;199 }200 if (started) {201 ctx.lineTo(lastX, yA); ctx.lineTo(firstX, yA); ctx.closePath();202 ctx.fillStyle = grad; ctx.fill();203 linePath(env, col, 2);204 }205 ctx.restore();206 }207 // trait d'ancre208 ctx.strokeStyle = th.baselineAxis;209 ctx.setLineDash([4, 4]);210 ctx.lineWidth = 1;211 ctx.beginPath();212 ctx.moveTo(rect.x, Math.round(yA) + 0.5);213 ctx.lineTo(rect.x + rect.w, Math.round(yA) + 0.5);214 ctx.stroke();215 ctx.setLineDash([]);216 }217218 /* Briques pleines (Renko, Line Break, Range) — chandelles sans mèche. */219 function bricks(env) { candles(env, false); }220221 function kagiDraw(env) {222 const { ctx, items, i0, i1, X, Y, th } = env;223 for (const thick of [false, true]) {224 ctx.strokeStyle = thick ? th.up : th.dn;225 ctx.lineWidth = thick ? 2.5 : 1.25;226 ctx.lineJoin = 'miter';227 ctx.beginPath();228 for (let i = Math.max(1, i0); i <= i1; i++) {229 const b = items[i];230 if (!b || b.up !== thick) continue;231 const x = Math.round(X(i)) + 0.5;232 const xPrev = Math.round(X(i - 1)) + 0.5;233 ctx.moveTo(xPrev, Y(b.o)); // épaule horizontale depuis la ligne précédente234 ctx.lineTo(x, Y(b.o));235 ctx.lineTo(x, Y(b.c)); // ligne verticale236 }237 const first = items[i0];238 if (first && first.up === thick && i0 === 0) {239 ctx.moveTo(Math.round(X(0)) + 0.5, Y(first.o));240 ctx.lineTo(Math.round(X(0)) + 0.5, Y(first.c));241 }242 ctx.stroke();243 }244 }245246 function pnfDraw(env) {247 const { ctx, items, i0, i1, X, Y, bw, th } = env;248 if (bw < 3) return denseHiLo(env);249 const w = bodyWidth(bw);250 ctx.lineWidth = Math.max(1, Math.min(2, w / 6));251 for (let i = i0; i <= i1; i++) {252 const col = items[i];253 if (!col || !col.box) continue;254 const x = X(i);255 const glyphW = Math.min(w, Math.abs(Y(col.lo) - Y(col.lo + col.box)) * 0.9);256 ctx.strokeStyle = col.type === 'X' ? th.up : th.dn;257 ctx.beginPath();258 for (let k = 0; k < col.n; k++) {259 const p0 = col.lo + k * col.box;260 const yB = Y(p0), yT = Y(p0 + col.box);261 const half = Math.min(glyphW / 2, Math.abs(yB - yT) / 2 - 1);262 if (half < 1) { // trop dense : trait plein263 ctx.moveTo(x, yB); ctx.lineTo(x, yT);264 continue;265 }266 const cy = (yB + yT) / 2;267 if (col.type === 'X') {268 ctx.moveTo(x - half, cy + half); ctx.lineTo(x + half, cy - half);269 ctx.moveTo(x - half, cy - half); ctx.lineTo(x + half, cy + half);270 } else {271 ctx.moveTo(x + half, cy);272 ctx.arc(x, cy, half, 0, Math.PI * 2);273 }274 }275 ctx.stroke();276 }277 }278279 /* Colonnes de volume, colorées par direction de la barre (alpha pour rester280 * une couche secondaire), 2px d'écart. */281 function volumeColumns(env, maxV) {282 const { ctx, items, i0, i1, X, rect, bw, th } = env;283 const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8)));284 const y0 = rect.y + rect.h;285 for (const up of [true, false]) {286 // dégradé vertical : plus dense à la base, léger vers le haut du pane287 const col = up ? th.up : th.dn;288 const grad = ctx.createLinearGradient(0, rect.y, 0, y0);289 grad.addColorStop(0, th.alpha(col, 0.28));290 grad.addColorStop(1, th.alpha(col, 0.6));291 ctx.fillStyle = grad;292 ctx.beginPath();293 for (let i = i0; i <= i1; i++) {294 const b = items[i];295 if (!b || !(b.v > 0) || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue;296 const h = Math.max(1, (b.v / maxV) * (rect.h - 4));297 roundRect(ctx, Math.round(X(i) - w / 2), y0 - h, w, h, w >= 6 ? 2 : 0);298 }299 ctx.fill();300 }301 }302303 /* Ligne d'indicateur (overlay ou pane) sur un tableau de valeurs alignées. */304 function indicatorLine(env, vals, color, width, dash) {305 const { ctx, i0, i1, X, Y } = env;306 ctx.strokeStyle = color;307 ctx.lineWidth = width || 2;308 ctx.lineJoin = 'round';309 if (dash) ctx.setLineDash(dash);310 ctx.beginPath();311 let started = false;312 for (let i = i0; i <= i1; i++) {313 const v = vals[i];314 if (v == null) { continue; }315 const x = X(i), y = Y(v);316 if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);317 }318 ctx.stroke();319 if (dash) ctx.setLineDash([]);320 }321322 /* Remplissage entre deux tableaux (bandes Bollinger / VWAP). */323 function bandFill(env, upVals, loVals, color) {324 const { ctx, i0, i1, X, Y } = env;325 ctx.fillStyle = color;326 ctx.beginPath();327 let started = false;328 const pts = [];329 for (let i = i0; i <= i1; i++) {330 if (upVals[i] == null || loVals[i] == null) continue;331 const x = X(i);332 if (!started) { ctx.moveTo(x, Y(upVals[i])); started = true; } else ctx.lineTo(x, Y(upVals[i]));333 pts.push([x, Y(loVals[i])]);334 }335 for (let k = pts.length - 1; k >= 0; k--) ctx.lineTo(pts[k][0], pts[k][1]);336 if (started) { ctx.closePath(); ctx.fill(); }337 }338339 HF.render = {340 candles: env => candles(env, false),341 hollow: env => candles(env, true),342 ohlc: ohlcBars,343 line, step, area, baseline, bricks,344 kagi: kagiDraw,345 pnf: pnfDraw,346 volumeColumns, indicatorLine, bandFill, linePath, roundRect, denseHiLo,347 };348})();349