/* ============================================================================ * HFChart — renderers canvas, from scratch * Author : Simon-Pierre Boucher — contact@spboucher.ai * ==========================================================================*/ /* Chaque renderer reçoit un environnement : * { ctx, items, i0, i1, X(i)→px centre, Y(prix)→px, rect, bw, th } * th = tokens du thème (up, dn, ink, grid, series[], surface, …). * Specs de marques : corps fins avec 2px d'écart entre barres adjacentes, * lignes 2px, extrémités arrondies discrètes. */ (function () { 'use strict'; const HF = (window.HF = window.HF || {}); function bodyWidth(bw) { // écart de 2px garanti entre corps adjacents dès que la place existe return Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8))); } function roundRect(ctx, x, y, w, h, r) { if (h < 0) { y += h; h = -h; } r = Math.min(r, w / 2, h / 2); if (r < 0.5) { ctx.rect(x, y, w, h); return; } ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /* Fast path haute densité : 1 trait hi-lo par barre quand bw < 2px. */ function denseHiLo(env) { const { ctx, items, i0, i1, X, Y, th } = env; ctx.lineWidth = 1; for (const up of [true, false]) { ctx.strokeStyle = up ? th.up : th.dn; ctx.beginPath(); for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b || (b.c >= b.o) !== up) continue; const x = Math.round(X(i)) + 0.5; ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(b.l)); } ctx.stroke(); } } function candles(env, hollow) { const { ctx, items, i0, i1, X, Y, bw, th } = env; if (bw < 2) return denseHiLo(env); const w = bodyWidth(bw); const wick = bw >= 10 ? 1.5 : 1; const r = w >= 6 ? 1.5 : 0; for (const up of [true, false]) { const col = up ? th.up : th.dn; // mèches ctx.strokeStyle = col; ctx.lineWidth = wick; ctx.beginPath(); for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue; const x = Math.round(X(i)) + (wick === 1 ? 0.5 : 0); const top = Math.max(b.o, b.c), bot = Math.min(b.o, b.c); if (b.h > top) { ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(top)); } if (b.l < bot) { ctx.moveTo(x, Y(bot)); ctx.lineTo(x, Y(b.l)); } } ctx.stroke(); // corps ctx.beginPath(); for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue; const x = Math.round(X(i) - w / 2); const y0 = Y(b.o), y1 = Y(b.c); const h = Math.abs(y1 - y0) < 1 ? 1 : y1 - y0; roundRect(ctx, x, y0, w, h, r); } if (hollow && up) { ctx.strokeStyle = col; ctx.lineWidth = Math.min(2, Math.max(1, w / 5)); ctx.stroke(); } else { ctx.fillStyle = col; ctx.fill(); } } } function ohlcBars(env) { const { ctx, items, i0, i1, X, Y, bw, th } = env; if (bw < 3) return denseHiLo(env); const tick = Math.max(2, Math.min(bw * 0.4, 8)); ctx.lineWidth = bw >= 8 ? 1.5 : 1; for (const up of [true, false]) { ctx.strokeStyle = up ? th.up : th.dn; ctx.beginPath(); for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b || (b.c >= b.o) !== up) continue; const x = Math.round(X(i)) + 0.5; ctx.moveTo(x, Y(b.h)); ctx.lineTo(x, Y(b.l)); ctx.moveTo(x - tick, Y(b.o)); ctx.lineTo(x, Y(b.o)); ctx.moveTo(x, Y(b.c)); ctx.lineTo(x + tick, Y(b.c)); } ctx.stroke(); } } function linePath(env, color, width) { const { ctx, items, i0, i1, X, Y } = env; ctx.strokeStyle = color; ctx.lineWidth = width || 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.beginPath(); let started = false; for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b) continue; const x = X(i), y = Y(b.c); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.stroke(); } function line(env) { linePath(env, env.th.accent, 2); } function step(env) { const { ctx, items, i0, i1, X, Y, th } = env; ctx.strokeStyle = th.accent; ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.beginPath(); let started = false, py = 0; for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b) continue; const x = X(i), y = Y(b.c); if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, py); ctx.lineTo(x, y); } py = y; } ctx.stroke(); } function area(env) { const { ctx, items, i0, i1, X, Y, rect, th } = env; const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h); grad.addColorStop(0, th.alpha(th.accent, 0.30)); grad.addColorStop(0.55, th.alpha(th.accent, 0.10)); grad.addColorStop(1, th.alpha(th.accent, 0)); ctx.beginPath(); let started = false, lastX = 0, firstX = 0; for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b) continue; const x = X(i), y = Y(b.c); if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y); lastX = x; } if (!started) return; ctx.lineTo(lastX, rect.y + rect.h); ctx.lineTo(firstX, rect.y + rect.h); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); linePath(env, th.accent, 2); } /* Baseline : ancre = close de la première barre chargée (env.anchor). * Au-dessus → up, en dessous → dn (aires + segments de ligne). */ function baseline(env) { const { ctx, items, i0, i1, X, Y, rect, th } = env; const anchor = env.anchor; const yA = Math.max(rect.y, Math.min(rect.y + rect.h, Y(anchor))); for (const side of ['up', 'dn']) { ctx.save(); ctx.beginPath(); if (side === 'up') ctx.rect(rect.x, rect.y, rect.w, yA - rect.y); else ctx.rect(rect.x, yA, rect.w, rect.y + rect.h - yA); ctx.clip(); const col = side === 'up' ? th.up : th.dn; const grad = side === 'up' ? ctx.createLinearGradient(0, rect.y, 0, yA) : ctx.createLinearGradient(0, yA, 0, rect.y + rect.h); grad.addColorStop(side === 'up' ? 0 : 1, th.alpha(col, 0.28)); grad.addColorStop(side === 'up' ? 1 : 0, th.alpha(col, 0.02)); // aire fermée sur la baseline ctx.beginPath(); let started = false, lastX = 0, firstX = 0; for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b) continue; const x = X(i), y = Y(b.c); if (!started) { ctx.moveTo(x, y); firstX = x; started = true; } else ctx.lineTo(x, y); lastX = x; } if (started) { ctx.lineTo(lastX, yA); ctx.lineTo(firstX, yA); ctx.closePath(); ctx.fillStyle = grad; ctx.fill(); linePath(env, col, 2); } ctx.restore(); } // trait d'ancre ctx.strokeStyle = th.baselineAxis; ctx.setLineDash([4, 4]); ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(rect.x, Math.round(yA) + 0.5); ctx.lineTo(rect.x + rect.w, Math.round(yA) + 0.5); ctx.stroke(); ctx.setLineDash([]); } /* Briques pleines (Renko, Line Break, Range) — chandelles sans mèche. */ function bricks(env) { candles(env, false); } function kagiDraw(env) { const { ctx, items, i0, i1, X, Y, th } = env; for (const thick of [false, true]) { ctx.strokeStyle = thick ? th.up : th.dn; ctx.lineWidth = thick ? 2.5 : 1.25; ctx.lineJoin = 'miter'; ctx.beginPath(); for (let i = Math.max(1, i0); i <= i1; i++) { const b = items[i]; if (!b || b.up !== thick) continue; const x = Math.round(X(i)) + 0.5; const xPrev = Math.round(X(i - 1)) + 0.5; ctx.moveTo(xPrev, Y(b.o)); // épaule horizontale depuis la ligne précédente ctx.lineTo(x, Y(b.o)); ctx.lineTo(x, Y(b.c)); // ligne verticale } const first = items[i0]; if (first && first.up === thick && i0 === 0) { ctx.moveTo(Math.round(X(0)) + 0.5, Y(first.o)); ctx.lineTo(Math.round(X(0)) + 0.5, Y(first.c)); } ctx.stroke(); } } function pnfDraw(env) { const { ctx, items, i0, i1, X, Y, bw, th } = env; if (bw < 3) return denseHiLo(env); const w = bodyWidth(bw); ctx.lineWidth = Math.max(1, Math.min(2, w / 6)); for (let i = i0; i <= i1; i++) { const col = items[i]; if (!col || !col.box) continue; const x = X(i); const glyphW = Math.min(w, Math.abs(Y(col.lo) - Y(col.lo + col.box)) * 0.9); ctx.strokeStyle = col.type === 'X' ? th.up : th.dn; ctx.beginPath(); for (let k = 0; k < col.n; k++) { const p0 = col.lo + k * col.box; const yB = Y(p0), yT = Y(p0 + col.box); const half = Math.min(glyphW / 2, Math.abs(yB - yT) / 2 - 1); if (half < 1) { // trop dense : trait plein ctx.moveTo(x, yB); ctx.lineTo(x, yT); continue; } const cy = (yB + yT) / 2; if (col.type === 'X') { ctx.moveTo(x - half, cy + half); ctx.lineTo(x + half, cy - half); ctx.moveTo(x - half, cy - half); ctx.lineTo(x + half, cy + half); } else { ctx.moveTo(x + half, cy); ctx.arc(x, cy, half, 0, Math.PI * 2); } } ctx.stroke(); } } /* Colonnes de volume, colorées par direction de la barre (alpha pour rester * une couche secondaire), 2px d'écart. */ function volumeColumns(env, maxV) { const { ctx, items, i0, i1, X, rect, bw, th } = env; const w = Math.max(1, Math.min(bw - 2, Math.round(bw * 0.8))); const y0 = rect.y + rect.h; for (const up of [true, false]) { // dégradé vertical : plus dense à la base, léger vers le haut du pane const col = up ? th.up : th.dn; const grad = ctx.createLinearGradient(0, rect.y, 0, y0); grad.addColorStop(0, th.alpha(col, 0.28)); grad.addColorStop(1, th.alpha(col, 0.6)); ctx.fillStyle = grad; ctx.beginPath(); for (let i = i0; i <= i1; i++) { const b = items[i]; if (!b || !(b.v > 0) || (b.up !== undefined ? b.up : b.c >= b.o) !== up) continue; const h = Math.max(1, (b.v / maxV) * (rect.h - 4)); roundRect(ctx, Math.round(X(i) - w / 2), y0 - h, w, h, w >= 6 ? 2 : 0); } ctx.fill(); } } /* Ligne d'indicateur (overlay ou pane) sur un tableau de valeurs alignées. */ function indicatorLine(env, vals, color, width, dash) { const { ctx, i0, i1, X, Y } = env; ctx.strokeStyle = color; ctx.lineWidth = width || 2; ctx.lineJoin = 'round'; if (dash) ctx.setLineDash(dash); ctx.beginPath(); let started = false; for (let i = i0; i <= i1; i++) { const v = vals[i]; if (v == null) { continue; } const x = X(i), y = Y(v); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); } ctx.stroke(); if (dash) ctx.setLineDash([]); } /* Remplissage entre deux tableaux (bandes Bollinger / VWAP). */ function bandFill(env, upVals, loVals, color) { const { ctx, i0, i1, X, Y } = env; ctx.fillStyle = color; ctx.beginPath(); let started = false; const pts = []; for (let i = i0; i <= i1; i++) { if (upVals[i] == null || loVals[i] == null) continue; const x = X(i); if (!started) { ctx.moveTo(x, Y(upVals[i])); started = true; } else ctx.lineTo(x, Y(upVals[i])); pts.push([x, Y(loVals[i])]); } for (let k = pts.length - 1; k >= 0; k--) ctx.lineTo(pts[k][0], pts[k][1]); if (started) { ctx.closePath(); ctx.fill(); } } HF.render = { candles: env => candles(env, false), hollow: env => candles(env, true), ohlc: ohlcBars, line, step, area, baseline, bricks, kagi: kagiDraw, pnf: pnfDraw, volumeColumns, indicatorLine, bandFill, linePath, roundRect, denseHiLo, }; })();