Files
BlackboxAI 0206ef7cbb Initial commit: orderflow analysis system with 5 pattern detectors
Real-time orderflow trading system with absorption, initiative, sweep,
exhaustion, and divergence detection. Features volume profile framing,
state machine trade lifecycle, MT5 + Bybit feeds, FastAPI dashboard,
and Telegram alerts for 30+ instruments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:38:25 +03:00

701 lines
28 KiB
JavaScript

/**
* Footprint Chart Component — TradingView-Style
* Drag to pan, scroll to zoom, crosshair cursor, smooth canvas rendering
* Bid/Ask volume heatmap at each price level per candle bar
*/
class FootprintChart {
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.canvas = null;
this.ctx = null;
this.tooltip = null;
this.crosshairCanvas = null;
this.crosshairCtx = null;
this.options = {
barWidth: 90,
rowHeight: 17,
priceStep: 0.5,
imbalanceThreshold: 3,
absorptionThreshold: 2,
minBarWidth: 30,
maxBarWidth: 200,
timeAxisHeight: 22,
priceAxisWidth: 68,
colors: {
background: '#1c2128',
gridLine: '#30363d',
gridLineLight: 'rgba(48,54,61,0.4)',
bidCell: '#3fb950',
askCell: '#f85149',
bidText: '#e6edf3',
askText: '#e6edf3',
pocMarker: '#58a6ff',
imbalance: '#d29922',
absorption: '#bc8cff',
priceLevel: '#8b949e',
crosshair: 'rgba(88,166,255,0.5)',
crosshairLabel: '#58a6ff',
currentPrice: '#58a6ff',
bodyGreen: 'rgba(63,185,80,0.25)',
bodyRed: 'rgba(248,81,73,0.25)',
bodyGreenStroke: '#3fb950',
bodyRedStroke: '#f85149',
},
...options,
};
this.data = [];
this.currentPrice = null;
// Viewport
this.offsetX = 0;
this.offsetY = 0;
// Mouse
this._mouseX = -1;
this._mouseY = -1;
this._isDragging = false;
this._dragStartX = 0;
this._dragStartY = 0;
this._dragStartOffsetX = 0;
this._dragStartOffsetY = 0;
// Layout (recalculated on render)
this.width = 0;
this.height = 0;
this._chartLeft = 0;
this._chartRight = 0;
this._chartTop = 0;
this._chartBottom = 0;
this._priceMin = 0;
this._priceMax = 0;
this._pxPerPrice = 1;
this._init();
}
// ═══════════════════════════════════════
// Init
// ═══════════════════════════════════════
_init() {
this.container.style.position = 'relative';
this.container.style.overflow = 'hidden';
this.container.style.cursor = 'crosshair';
// Main canvas
this.canvas = document.createElement('canvas');
this.canvas.style.cssText = 'position:absolute;top:0;left:0';
this.container.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
// Crosshair overlay
this.crosshairCanvas = document.createElement('canvas');
this.crosshairCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none';
this.container.appendChild(this.crosshairCanvas);
this.crosshairCtx = this.crosshairCanvas.getContext('2d');
// Tooltip
this.tooltip = document.createElement('div');
this.tooltip.className = 'footprint-tooltip';
this.tooltip.style.display = 'none';
this.container.appendChild(this.tooltip);
this._resize();
this._resizeBound = () => this._resize();
window.addEventListener('resize', this._resizeBound);
// Mouse
this.container.addEventListener('mousedown', (e) => this._onMouseDown(e));
this.container.addEventListener('mousemove', (e) => this._onMouseMove(e));
this.container.addEventListener('mouseup', () => this._onMouseUp());
this.container.addEventListener('mouseleave', () => this._onMouseLeave());
this.container.addEventListener('wheel', (e) => this._onWheel(e), { passive: false });
// Touch
this.container.addEventListener('touchstart', (e) => this._onTouchStart(e), { passive: false });
this.container.addEventListener('touchmove', (e) => this._onTouchMove(e), { passive: false });
this.container.addEventListener('touchend', () => { this._isDragging = false; this._pinchDist = null; });
}
_resize() {
const rect = this.container.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const dpr = window.devicePixelRatio || 1;
[this.canvas, this.crosshairCanvas].forEach(c => {
c.width = rect.width * dpr;
c.height = rect.height * dpr;
c.style.width = rect.width + 'px';
c.style.height = rect.height + 'px';
c.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
});
this.width = rect.width;
this.height = rect.height;
this._chartLeft = 0;
this._chartRight = this.width - this.options.priceAxisWidth;
this._chartTop = 0;
this._chartBottom = this.height - this.options.timeAxisHeight;
this.render();
}
// ═══════════════════════════════════════
// Public API
// ═══════════════════════════════════════
setData(data) {
this.data = data || [];
if (this.data.length > 0) {
this.currentPrice = this.data[this.data.length - 1].close;
this._autoFit();
}
this.render();
}
updateBar(bar) {
if (!bar || !bar.time) return;
const idx = this.data.findIndex(b => b.time === bar.time);
if (idx >= 0) this.data[idx] = bar; else this.data.push(bar);
this.currentPrice = bar.close;
this.render();
}
resize() { this._resize(); }
destroy() {
window.removeEventListener('resize', this._resizeBound);
this.container.innerHTML = '';
}
// ═══════════════════════════════════════
// Auto-fit
// ═══════════════════════════════════════
_autoFit() {
if (this.data.length === 0) return;
const chartW = this._chartRight - this._chartLeft;
const barW = this.options.barWidth;
const totalW = this.data.length * barW;
this.offsetX = Math.max(0, totalW - chartW + barW * 0.5);
this._fitVertical();
}
_fitVertical() {
const chartW = this._chartRight - this._chartLeft;
const barW = this.options.barWidth;
const s = Math.max(0, Math.floor(this.offsetX / barW) - 1);
const e = Math.min(this.data.length, Math.ceil((this.offsetX + chartW) / barW) + 1);
let lo = Infinity, hi = -Infinity;
for (let i = s; i < e; i++) {
const bar = this.data[i];
if (!bar) continue;
if (bar.low < lo) lo = bar.low;
if (bar.high > hi) hi = bar.high;
}
if (lo === Infinity) return;
const pad = (hi - lo) * 0.12 || 1;
this._priceMin = lo - pad;
this._priceMax = hi + pad;
this._pxPerPrice = (this._chartBottom - this._chartTop) / (this._priceMax - this._priceMin);
this.offsetY = 0;
}
// ═══════════════════════════════════════
// Coordinate helpers
// ═══════════════════════════════════════
_priceToY(p) { return this._chartTop + (this._priceMax - p) * this._pxPerPrice + this.offsetY; }
_yToPrice(y) { return this._priceMax - (y - this._chartTop - this.offsetY) / this._pxPerPrice; }
_barIdxToX(i) { return this._chartLeft + i * this.options.barWidth - this.offsetX; }
_xToBarIdx(x) { return Math.floor((x - this._chartLeft + this.offsetX) / this.options.barWidth); }
// ═══════════════════════════════════════
// Main Render
// ═══════════════════════════════════════
render() {
if (!this.ctx || this.width === 0) return;
const ctx = this.ctx;
const c = this.options.colors;
if (this._priceMax <= this._priceMin) this._fitVertical();
ctx.clearRect(0, 0, this.width, this.height);
ctx.fillStyle = c.background;
ctx.fillRect(0, 0, this.width, this.height);
if (this.data.length === 0) {
ctx.fillStyle = c.priceLevel;
ctx.font = '13px Consolas, monospace';
ctx.textAlign = 'center';
ctx.fillText('No footprint data — switch to Footprint view', this.width / 2, this.height / 2);
return;
}
// Clip chart area
ctx.save();
ctx.beginPath();
ctx.rect(this._chartLeft, this._chartTop, this._chartRight - this._chartLeft, this._chartBottom - this._chartTop);
ctx.clip();
this._drawGrid(ctx);
this._drawBars(ctx);
this._drawCurrentPriceLine(ctx);
ctx.restore();
this._drawPriceAxis(ctx);
this._drawTimeAxis(ctx);
this._drawCrosshair();
}
// ═══════════════════════════════════════
// Grid
// ═══════════════════════════════════════
_drawGrid(ctx) {
const c = this.options.colors;
const step = this._niceStep(this._priceMax - this._priceMin, 10);
const sp = Math.floor(this._priceMin / step) * step;
const ep = Math.ceil(this._priceMax / step) * step;
ctx.lineWidth = 0.5;
ctx.strokeStyle = c.gridLineLight;
for (let p = sp; p <= ep; p += step) {
const y = this._priceToY(p);
if (y < this._chartTop || y > this._chartBottom) continue;
ctx.beginPath(); ctx.moveTo(this._chartLeft, y); ctx.lineTo(this._chartRight, y); ctx.stroke();
}
const barW = this.options.barWidth;
const fi = Math.max(0, Math.floor(this.offsetX / barW));
const li = Math.min(this.data.length, Math.ceil((this.offsetX + (this._chartRight - this._chartLeft)) / barW) + 1);
ctx.strokeStyle = c.gridLine;
for (let i = fi; i <= li; i++) {
const x = this._barIdxToX(i);
if (x < this._chartLeft || x > this._chartRight) continue;
ctx.beginPath(); ctx.moveTo(x, this._chartTop); ctx.lineTo(x, this._chartBottom); ctx.stroke();
}
}
// ═══════════════════════════════════════
// Draw Bars
// ═══════════════════════════════════════
_drawBars(ctx) {
const barW = this.options.barWidth;
const fi = Math.max(0, Math.floor(this.offsetX / barW) - 1);
const li = Math.min(this.data.length, Math.ceil((this.offsetX + (this._chartRight - this._chartLeft)) / barW) + 1);
for (let i = fi; i < li; i++) {
const bar = this.data[i];
if (!bar) continue;
this._drawSingleBar(ctx, bar, this._barIdxToX(i), barW);
}
}
_drawSingleBar(ctx, bar, x, barW) {
const c = this.options.colors;
const levels = bar.levels;
if (!levels || levels.length === 0) return;
const isGreen = bar.close >= bar.open;
const halfW = barW / 2;
// Max vol for heat normalization
let maxVol = 1;
levels.forEach(l => { maxVol = Math.max(maxVol, l.bid || 0, l.ask || 0); });
// POC
let pocPrice = bar.poc;
if (!pocPrice) {
let mx = 0;
levels.forEach(l => { const t = (l.bid || 0) + (l.ask || 0); if (t > mx) { mx = t; pocPrice = l.price; } });
}
// Body fill
const bodyTop = this._priceToY(Math.max(bar.open, bar.close));
const bodyBot = this._priceToY(Math.min(bar.open, bar.close));
ctx.fillStyle = isGreen ? c.bodyGreen : c.bodyRed;
ctx.fillRect(x + 2, bodyTop, barW - 4, Math.max(1, bodyBot - bodyTop));
// Wick
const highY = this._priceToY(bar.high);
const lowY = this._priceToY(bar.low);
ctx.strokeStyle = isGreen ? c.bodyGreenStroke : c.bodyRedStroke;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x + halfW, highY); ctx.lineTo(x + halfW, lowY); ctx.stroke();
// Volume cells
levels.forEach(level => {
const py = this._priceToY(level.price);
const nextPy = this._priceToY(level.price + this.options.priceStep);
const rowH = Math.max(2, Math.abs(py - nextPy));
if (py < this._chartTop - rowH || py > this._chartBottom + rowH) return;
const bid = level.bid || 0;
const ask = level.ask || 0;
const ratio = bid > 0 && ask > 0 ? Math.max(bid / ask, ask / bid) : (bid > 0 || ask > 0 ? 9 : 1);
const isImb = ratio >= this.options.imbalanceThreshold;
const isAbs = this._isAbsorption(bar, level);
if (bid > 0) {
const frac = bid / maxVol;
const cellW = frac * (halfW - 4);
let col = c.bidCell;
if (isAbs) col = c.absorption; else if (isImb && bid > ask) col = c.imbalance;
ctx.fillStyle = this._rgba(col, 0.15 + frac * 0.75);
ctx.fillRect(x + halfW - cellW - 2, py - rowH / 2, cellW, rowH - 1);
if (barW >= 50 && bid >= maxVol * 0.08) {
ctx.fillStyle = c.bidText;
ctx.font = `${Math.min(11, rowH - 2)}px Consolas, monospace`;
ctx.textAlign = 'right';
ctx.fillText(this._fmtVol(bid), x + halfW - 3, py + 3);
}
}
if (ask > 0) {
const frac = ask / maxVol;
const cellW = frac * (halfW - 4);
let col = c.askCell;
if (isAbs) col = c.absorption; else if (isImb && ask > bid) col = c.imbalance;
ctx.fillStyle = this._rgba(col, 0.15 + frac * 0.75);
ctx.fillRect(x + halfW + 2, py - rowH / 2, cellW, rowH - 1);
if (barW >= 50 && ask >= maxVol * 0.08) {
ctx.fillStyle = c.askText;
ctx.font = `${Math.min(11, rowH - 2)}px Consolas, monospace`;
ctx.textAlign = 'left';
ctx.fillText(this._fmtVol(ask), x + halfW + 3, py + 3);
}
}
// POC dot
if (level.price === pocPrice) {
ctx.fillStyle = c.pocMarker;
ctx.beginPath(); ctx.arc(x + halfW, py, 3, 0, Math.PI * 2); ctx.fill();
}
});
// Body outline
ctx.strokeStyle = isGreen ? c.bodyGreenStroke : c.bodyRedStroke;
ctx.lineWidth = 1;
ctx.strokeRect(x + 2, bodyTop, barW - 4, Math.max(1, bodyBot - bodyTop));
}
// ═══════════════════════════════════════
// Current Price Line
// ═══════════════════════════════════════
_drawCurrentPriceLine(ctx) {
if (!this.currentPrice) return;
const y = this._priceToY(this.currentPrice);
if (y < this._chartTop || y > this._chartBottom) return;
ctx.strokeStyle = this.options.colors.currentPrice;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(this._chartLeft, y); ctx.lineTo(this._chartRight, y); ctx.stroke();
ctx.setLineDash([]);
}
// ═══════════════════════════════════════
// Price Axis
// ═══════════════════════════════════════
_drawPriceAxis(ctx) {
const c = this.options.colors;
const ax = this._chartRight;
const aw = this.options.priceAxisWidth;
ctx.fillStyle = c.background;
ctx.fillRect(ax, 0, aw, this.height);
ctx.strokeStyle = c.gridLine;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ax, 0); ctx.lineTo(ax, this.height); ctx.stroke();
const step = this._niceStep(this._priceMax - this._priceMin, 10);
const sp = Math.floor(this._priceMin / step) * step;
const ep = Math.ceil(this._priceMax / step) * step;
ctx.fillStyle = c.priceLevel;
ctx.font = '10px Consolas, monospace';
ctx.textAlign = 'left';
for (let p = sp; p <= ep; p += step) {
const y = this._priceToY(p);
if (y < 5 || y > this._chartBottom - 5) continue;
ctx.fillText(this._fmtPrice(p), ax + 5, y + 3);
}
// Current price badge
if (this.currentPrice) {
const y = this._priceToY(this.currentPrice);
if (y > 0 && y < this._chartBottom) {
ctx.fillStyle = c.currentPrice;
ctx.fillRect(ax, y - 9, aw, 18);
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px Consolas, monospace';
ctx.textAlign = 'left';
ctx.fillText(this._fmtPrice(this.currentPrice), ax + 5, y + 4);
}
}
}
// ═══════════════════════════════════════
// Time Axis
// ═══════════════════════════════════════
_drawTimeAxis(ctx) {
const c = this.options.colors;
const ay = this._chartBottom;
const barW = this.options.barWidth;
ctx.fillStyle = c.background;
ctx.fillRect(0, ay, this.width, this.options.timeAxisHeight);
ctx.strokeStyle = c.gridLine;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(this.width, ay); ctx.stroke();
ctx.fillStyle = c.priceLevel;
ctx.font = '9px Consolas, monospace';
ctx.textAlign = 'center';
const every = Math.max(1, Math.round(80 / barW));
const fi = Math.max(0, Math.floor(this.offsetX / barW));
const li = Math.min(this.data.length, Math.ceil((this.offsetX + (this._chartRight - this._chartLeft)) / barW) + 1);
for (let i = fi; i < li; i += every) {
const bar = this.data[i];
if (!bar) continue;
const bx = this._barIdxToX(i) + barW / 2;
if (bx < this._chartLeft || bx > this._chartRight) continue;
const d = new Date(bar.time * 1000);
ctx.fillText(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`, bx, ay + 14);
}
}
// ═══════════════════════════════════════
// Crosshair
// ═══════════════════════════════════════
_drawCrosshair() {
const ctx = this.crosshairCtx;
ctx.clearRect(0, 0, this.width, this.height);
if (this._mouseX < 0 || this._isDragging) return;
if (this._mouseX > this._chartRight || this._mouseY > this._chartBottom) return;
const c = this.options.colors;
ctx.strokeStyle = c.crosshair;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
// Vertical
ctx.beginPath(); ctx.moveTo(this._mouseX, this._chartTop); ctx.lineTo(this._mouseX, this._chartBottom); ctx.stroke();
// Horizontal
ctx.beginPath(); ctx.moveTo(this._chartLeft, this._mouseY); ctx.lineTo(this._chartRight, this._mouseY); ctx.stroke();
ctx.setLineDash([]);
// Price label
const price = this._yToPrice(this._mouseY);
ctx.fillStyle = 'rgba(88,166,255,0.85)';
ctx.fillRect(this._chartRight, this._mouseY - 9, this.options.priceAxisWidth, 18);
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px Consolas, monospace';
ctx.textAlign = 'left';
ctx.fillText(this._fmtPrice(price), this._chartRight + 5, this._mouseY + 4);
// Time label
const bi = this._xToBarIdx(this._mouseX);
if (bi >= 0 && bi < this.data.length) {
const bar = this.data[bi];
if (bar) {
const bx = this._barIdxToX(bi) + this.options.barWidth / 2;
const d = new Date(bar.time * 1000);
const lbl = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const tw = ctx.measureText(lbl).width + 8;
ctx.fillStyle = 'rgba(88,166,255,0.85)';
ctx.fillRect(bx - tw / 2, this._chartBottom, tw, this.options.timeAxisHeight);
ctx.fillStyle = '#fff';
ctx.font = 'bold 9px Consolas, monospace';
ctx.textAlign = 'center';
ctx.fillText(lbl, bx, this._chartBottom + 14);
}
}
}
// ═══════════════════════════════════════
// Mouse Events
// ═══════════════════════════════════════
_onMouseDown(e) {
if (e.button !== 0) return;
this._isDragging = true;
this._dragStartX = e.clientX;
this._dragStartY = e.clientY;
this._dragStartOffsetX = this.offsetX;
this._dragStartOffsetY = this.offsetY;
this.container.style.cursor = 'grabbing';
this._hideTooltip();
}
_onMouseMove(e) {
const rect = this.container.getBoundingClientRect();
this._mouseX = e.clientX - rect.left;
this._mouseY = e.clientY - rect.top;
if (this._isDragging) {
const dx = e.clientX - this._dragStartX;
const dy = e.clientY - this._dragStartY;
this.offsetX = this._dragStartOffsetX - dx;
this.offsetY = this._dragStartOffsetY + dy;
const maxOff = Math.max(0, this.data.length * this.options.barWidth - (this._chartRight - this._chartLeft) * 0.5);
this.offsetX = Math.max(-(this._chartRight - this._chartLeft) * 0.5, Math.min(maxOff, this.offsetX));
this.render();
} else {
this._updateHover();
this._drawCrosshair();
}
}
_onMouseUp() {
this._isDragging = false;
this.container.style.cursor = 'crosshair';
}
_onMouseLeave() {
this._isDragging = false;
this._mouseX = -1;
this._mouseY = -1;
this._hideTooltip();
this._drawCrosshair();
this.container.style.cursor = 'crosshair';
}
_onWheel(e) {
e.preventDefault();
const rect = this.container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const oldBW = this.options.barWidth;
const newBW = Math.max(this.options.minBarWidth, Math.min(this.options.maxBarWidth, oldBW * factor));
// Keep bar under mouse at same screen X
const barUnder = (mx + this.offsetX) / oldBW;
this.options.barWidth = newBW;
this.offsetX = barUnder * newBW - mx;
// Adjust row height proportionally
this.options.rowHeight = Math.max(8, Math.min(30, 17 * (newBW / 90)));
this.render();
}
// ═══════════════════════════════════════
// Touch Events
// ═══════════════════════════════════════
_onTouchStart(e) {
if (e.touches.length === 1) {
e.preventDefault();
this._isDragging = true;
this._dragStartX = e.touches[0].clientX;
this._dragStartY = e.touches[0].clientY;
this._dragStartOffsetX = this.offsetX;
this._dragStartOffsetY = this.offsetY;
} else if (e.touches.length === 2) {
e.preventDefault();
this._pinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
this._pinchBW = this.options.barWidth;
}
}
_onTouchMove(e) {
if (e.touches.length === 1 && this._isDragging) {
e.preventDefault();
this.offsetX = this._dragStartOffsetX - (e.touches[0].clientX - this._dragStartX);
this.offsetY = this._dragStartOffsetY + (e.touches[0].clientY - this._dragStartY);
this.render();
} else if (e.touches.length === 2 && this._pinchDist) {
e.preventDefault();
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
this.options.barWidth = Math.max(this.options.minBarWidth, Math.min(this.options.maxBarWidth, this._pinchBW * (d / this._pinchDist)));
this.render();
}
}
// ═══════════════════════════════════════
// Hover / Tooltip
// ═══════════════════════════════════════
_updateHover() {
const bi = this._xToBarIdx(this._mouseX);
if (bi < 0 || bi >= this.data.length) { this._hideTooltip(); return; }
const bar = this.data[bi];
const price = this._yToPrice(this._mouseY);
const level = bar.levels?.find(l => Math.abs(l.price - price) < this.options.priceStep * 0.8);
if (level) this._showTooltip(bar, level); else this._hideTooltip();
}
_showTooltip(bar, level) {
const bid = level.bid || 0, ask = level.ask || 0;
const delta = bid - ask;
const imb = bid > 0 && ask > 0 ? Math.max(bid / ask, ask / bid).toFixed(1) : '∞';
this.tooltip.innerHTML = `
<div style="font-weight:600;color:#e6edf3;margin-bottom:3px">${this._fmtPrice(level.price)}</div>
<div style="display:flex;gap:12px">
<span style="color:#3fb950">BID ${this._fmtVol(bid)}</span>
<span style="color:#f85149">ASK ${this._fmtVol(ask)}</span>
</div>
<div style="display:flex;gap:12px;margin-top:2px">
<span style="color:${delta >= 0 ? '#3fb950' : '#f85149'}">Δ ${delta >= 0 ? '+' : ''}${this._fmtVol(delta)}</span>
<span style="color:#8b949e">Imb ${imb}x</span>
</div>`;
this.tooltip.style.display = 'block';
let tx = this._mouseX + 15, ty = this._mouseY - 10;
if (tx + 160 > this.width) tx = this._mouseX - 165;
if (ty < 0) ty = 5;
this.tooltip.style.left = tx + 'px';
this.tooltip.style.top = ty + 'px';
}
_hideTooltip() { if (this.tooltip) this.tooltip.style.display = 'none'; }
// ═══════════════════════════════════════
// Utilities
// ═══════════════════════════════════════
_isAbsorption(bar, level) {
const total = (level.bid || 0) + (level.ask || 0);
const avg = bar.levels.reduce((s, l) => s + (l.bid || 0) + (l.ask || 0), 0) / bar.levels.length;
if (total < avg * this.options.absorptionThreshold) return false;
return Math.abs(bar.close - bar.open) < (bar.high - bar.low) * 0.3;
}
_rgba(hex, a) {
const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${a})`;
}
_fmtPrice(p) { return p >= 10000 ? p.toFixed(1) : p >= 1 ? p.toFixed(2) : p.toFixed(4); }
_fmtVol(v) {
const a = Math.abs(v);
if (a >= 1e6) return (v / 1e6).toFixed(1) + 'M';
if (a >= 1e3) return (v / 1e3).toFixed(1) + 'K';
return Math.round(v).toString();
}
_niceStep(range, maxTicks) {
const rough = range / maxTicks;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
const norm = rough / mag;
return (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
}
}
window.FootprintChart = FootprintChart;