Frontend recalculava imbalance independente do backend. Desativado ate implementacao correta ser encontrada. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
782 lines
29 KiB
React
782 lines
29 KiB
React
import React, { useRef, useEffect, useState } from 'react';
|
|
|
|
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange, bid = 0, ask = 0 }) {
|
|
const canvasRef = useRef(null);
|
|
|
|
// Navigation & Scale State
|
|
const [scrollOffset, setScrollOffset] = useState({ x: 50, y: 0 }); // X: horizontal offset, Y: vertical offset
|
|
const [zoom, setZoom] = useState(1); // Zoom level
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [cursor, setCursor] = useState('grab');
|
|
const dragStart = useRef({ x: 0, y: 0 });
|
|
const dragOffsetStart = useRef({ x: 0, y: 0 });
|
|
|
|
// Price axis drag (scale) state
|
|
const isDraggingAxis = useRef(false);
|
|
const axisDragStartY = useRef(0);
|
|
const axisDragStartStep = useRef(0);
|
|
|
|
// Time axis drag (horizontal zoom) state
|
|
const isDraggingTimeAxis = useRef(false);
|
|
const timeAxisDragStartX = useRef(0);
|
|
const timeAxisDragStartZoom = useRef(1);
|
|
|
|
// Apply zoom to sizes
|
|
const colWidth = 140 * zoom; // width of each cluster column
|
|
const colGap = 15 * zoom; // gap between columns
|
|
const rowHeight = 26 * zoom; // height of each price cell
|
|
const axisWidth = 70; // width of the vertical price axis on the right
|
|
|
|
// Auto-scroll to keep latest cluster visible whenever clusters or zoom changes
|
|
const lastClusterCount = useRef(0);
|
|
useEffect(() => {
|
|
if (clusters && canvasRef.current) {
|
|
const canvas = canvasRef.current;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const visibleWidth = rect.width - axisWidth - 160; // leave space for vol profile
|
|
const totalClustersWidth = clusters.length * (colWidth + colGap);
|
|
// Pin latest cluster to right side of visible area
|
|
const newX = visibleWidth - totalClustersWidth;
|
|
setScrollOffset(prev => ({ ...prev, x: newX }));
|
|
lastClusterCount.current = clusters.length;
|
|
}
|
|
}, [clusters?.length, zoom]);
|
|
|
|
// Main Render Loop
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Handle High DPI displays
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = rect.height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const width = rect.width;
|
|
const height = rect.height;
|
|
|
|
// Split View Layout
|
|
const bottomPanelHeight = 100;
|
|
const chartHeight = height - bottomPanelHeight;
|
|
|
|
// Clear screen
|
|
ctx.fillStyle = '#0B0E14';
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
// Draw Grid Background
|
|
ctx.strokeStyle = '#151B26';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x < width; x += 50) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y < height; y += 50) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (!clusters || clusters.length === 0) {
|
|
ctx.fillStyle = '#64748B';
|
|
ctx.font = '16px Outfit, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('Waiting for market data from MetaTrader 5...', width / 2, height / 2);
|
|
return;
|
|
}
|
|
|
|
// Determine the baseline price to align Y coordinates
|
|
const latestCluster = clusters[clusters.length - 1];
|
|
const basePrice = latestCluster.poc || 0;
|
|
const actualTickSize = (latestCluster.tick_size || tickSize) * stepMultiplier;
|
|
const centerY = chartHeight / 2 + scrollOffset.y;
|
|
|
|
// Helper to get Y coordinate for a given price
|
|
const getPriceY = (price) => {
|
|
const diffTicks = (price - basePrice) / actualTickSize;
|
|
return centerY - (diffTicks * rowHeight);
|
|
};
|
|
|
|
// Helper to get price from Y coordinate (for axis rendering)
|
|
const getYPrice = (y) => {
|
|
const diffTicks = (centerY - y) / rowHeight;
|
|
return basePrice + (diffTicks * actualTickSize);
|
|
};
|
|
|
|
// Bottom panel layout
|
|
const bottomPanelH = bottomPanelHeight;
|
|
const panelBaseY = height - bottomPanelH;
|
|
const barMaxH = 52; // max height a single bar can reach
|
|
const barBaseY = panelBaseY + 58; // y where bars touch the floor
|
|
|
|
// Pre-compute max bid or ask side across all clusters (for proportional scaling)
|
|
const maxSideVolume = Math.max(...clusters.map(c => {
|
|
const lvls = Object.values(c.levels || {});
|
|
const b = lvls.reduce((s, l) => s + (l.bid || 0), 0);
|
|
const a = lvls.reduce((s, l) => s + (l.ask || 0), 0);
|
|
return Math.max(b, a);
|
|
}), 1);
|
|
|
|
// Draw bottom panel background BEFORE cluster loop so bars render on top
|
|
ctx.fillStyle = '#0B0E14';
|
|
ctx.fillRect(0, panelBaseY, width, bottomPanelH);
|
|
ctx.strokeStyle = '#1E293B';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, panelBaseY);
|
|
ctx.lineTo(width, panelBaseY);
|
|
ctx.stroke();
|
|
|
|
// Draw Columns (Clusters)
|
|
clusters.forEach((cluster, index) => {
|
|
// Calculate column X position
|
|
// Offset starting after the Volume Profile panel (width 140)
|
|
const colX = scrollOffset.x + index * (colWidth + colGap);
|
|
|
|
// Don't render if outside canvas bounds (horizontal clipping)
|
|
if (colX + colWidth < 0 || colX > width - axisWidth) return;
|
|
|
|
let levels = cluster.levels || {};
|
|
|
|
// Dynamic Binning based on stepMultiplier
|
|
if (stepMultiplier > 1) {
|
|
const binnedLevels = {};
|
|
Object.keys(levels).forEach(pStr => {
|
|
const p = parseFloat(pStr);
|
|
const data = levels[pStr];
|
|
const binPrice = Math.round(p / actualTickSize) * actualTickSize;
|
|
|
|
if (!binnedLevels[binPrice]) {
|
|
binnedLevels[binPrice] = { ask: 0, bid: 0, total: 0, delta: 0, imbalance: null };
|
|
}
|
|
binnedLevels[binPrice].ask += data.ask || 0;
|
|
binnedLevels[binPrice].bid += data.bid || 0;
|
|
binnedLevels[binPrice].total += data.total || 0;
|
|
binnedLevels[binPrice].delta += data.delta || 0;
|
|
|
|
// Imbalance desativado — cálculo pendente de revisão
|
|
binnedLevels[binPrice].imbalance = null;
|
|
});
|
|
levels = binnedLevels;
|
|
}
|
|
// Sort string keys numerically, but keep them as strings to avoid trailing zero lookup issues
|
|
const pricesStr = Object.keys(levels).sort((a, b) => Number(b) - Number(a));
|
|
|
|
if (pricesStr.length === 0) return;
|
|
|
|
const numericPrices = pricesStr.map(Number);
|
|
const highestPrice = Math.max(...numericPrices);
|
|
const lowestPrice = Math.min(...numericPrices);
|
|
|
|
// Stacked imbalance visual — to be reimplemented based on YuCluster config
|
|
|
|
|
|
// OHLC body range — levels outside = wicks (just a line)
|
|
const bodyHigh = (cluster.open_price !== undefined && cluster.close_price !== undefined)
|
|
? Math.max(cluster.open_price, cluster.close_price)
|
|
: highestPrice;
|
|
const bodyLow = (cluster.open_price !== undefined && cluster.close_price !== undefined)
|
|
? Math.min(cluster.open_price, cluster.close_price)
|
|
: lowestPrice;
|
|
const isBull = cluster.close_price >= cluster.open_price;
|
|
|
|
// OHLC body border rectangle
|
|
if (cluster.open_price !== undefined && cluster.close_price !== undefined) {
|
|
const openY = getPriceY(cluster.open_price);
|
|
const closeY = getPriceY(cluster.close_price);
|
|
const candleTop = Math.min(openY, closeY) - rowHeight / 2;
|
|
const candleBottom = Math.max(openY, closeY) + rowHeight / 2;
|
|
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.55)' : 'rgba(255, 23, 68, 0.55)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(colX - 2, candleTop, colWidth + 4, Math.max(2, candleBottom - candleTop));
|
|
|
|
// Wick lines (thin center line above body to high, below body to low)
|
|
const highY = getPriceY(highestPrice);
|
|
const lowY = getPriceY(lowestPrice);
|
|
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.4)' : 'rgba(255, 23, 68, 0.4)';
|
|
ctx.lineWidth = 1;
|
|
if (highestPrice > bodyHigh) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(colX + colWidth / 2, highY);
|
|
ctx.lineTo(colX + colWidth / 2, candleTop);
|
|
ctx.stroke();
|
|
}
|
|
if (lowestPrice < bodyLow) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(colX + colWidth / 2, candleBottom);
|
|
ctx.lineTo(colX + colWidth / 2, lowY + rowHeight);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
const fmtK = (v) => {
|
|
const n = Math.abs(v);
|
|
if (n >= 1000) return (v / 1000).toFixed(1) + 'K';
|
|
return v.toFixed(0);
|
|
};
|
|
|
|
// Max volume in cluster for proportional bars
|
|
const maxVol = Math.max(...Object.values(levels).map(l => Math.max(l.ask || 0, l.bid || 0)), 1);
|
|
|
|
const fontSize = Math.max(6, 11 * zoom);
|
|
ctx.font = `bold ${fontSize}px JetBrains Mono, monospace`;
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// Draw Cells — one color per level (dominant side), number only at POC
|
|
pricesStr.forEach((priceStr, i) => {
|
|
const price = Number(priceStr);
|
|
const cellData = levels[priceStr];
|
|
|
|
const cellY = getPriceY(price) - rowHeight / 2;
|
|
if (cellY + rowHeight < 0 || cellY > height) return;
|
|
|
|
const isWick = price < bodyLow - actualTickSize * 0.5 || price > bodyHigh + actualTickSize * 0.5;
|
|
const alpha = isWick ? 0.4 : 0.85;
|
|
|
|
const bid = cellData.bid || 0;
|
|
const ask = cellData.ask || 0;
|
|
const dominant = ask >= bid ? 'ask' : 'bid';
|
|
const dominantVal = Math.max(ask, bid);
|
|
|
|
// Single proportional bar — dominant color only, grows left to right
|
|
const barW = Math.min((dominantVal / maxVol) * colWidth, colWidth);
|
|
ctx.fillStyle = dominant === 'ask'
|
|
? `rgba(59, 130, 246, ${alpha})`
|
|
: `rgba(236, 72, 153, ${alpha})`;
|
|
ctx.fillRect(colX, cellY + 1, barW, rowHeight - 3);
|
|
|
|
|
|
// Imbalance dots — centered in the cluster column, like YuCluster
|
|
if (!isWick && cellData.imbalance) {
|
|
const cy = cellY + rowHeight / 2;
|
|
const cx = colX + colWidth / 2;
|
|
const radius = Math.max(3, Math.min(rowHeight * 0.38, 10 * zoom));
|
|
const imbalColor = cellData.imbalance === 'sell' ? '#CC0000' : '#1A237E';
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = imbalColor;
|
|
ctx.fill();
|
|
|
|
// Show number inside circle when zoomed in enough
|
|
if (zoom >= 1.2 && radius >= 7) {
|
|
const val = cellData.imbalance === 'sell' ? (cellData.bid || 0) : (cellData.ask || 0);
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = `bold ${Math.floor(radius * 1.1)}px JetBrains Mono, monospace`;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(fmtK(val), cx, cy);
|
|
}
|
|
}
|
|
|
|
// POC border (rectangle only at POC level)
|
|
if (!isWick && price === cluster.poc) {
|
|
ctx.strokeStyle = '#FFD600';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(colX + 1, cellY + 1, colWidth - 2, rowHeight - 4);
|
|
}
|
|
|
|
// Number only at POC (inside bar, right-aligned)
|
|
if (zoom >= 0.5 && !isWick && price === cluster.poc) {
|
|
const cy = cellY + rowHeight / 2;
|
|
if (viewMode === 'delta') {
|
|
const d = cellData.delta || 0;
|
|
ctx.fillStyle = d >= 0 ? '#00E676' : '#FF1744';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText((d >= 0 ? '+' : '') + fmtK(d), colX + colWidth - 4, cy);
|
|
} else {
|
|
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText(fmtK(dominantVal), colX + colWidth - 4, cy);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Render Extremes Ratios (Informers)
|
|
const adv = cluster.advanced_metrics;
|
|
if (adv) {
|
|
ctx.font = '10px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const highY = getPriceY(highestPrice) - rowHeight;
|
|
const lowY = getPriceY(lowestPrice) + rowHeight;
|
|
|
|
// Top Ratio
|
|
if (adv.high_ratio !== undefined) {
|
|
const hRatio = adv.high_ratio;
|
|
ctx.fillStyle = hRatio < 0.7 ? '#FFD600' : '#64748B'; // Gold if exhaustion
|
|
ctx.fillText(hRatio.toFixed(2), colX + colWidth / 2, highY);
|
|
}
|
|
|
|
// Bottom Ratio
|
|
if (adv.low_ratio !== undefined) {
|
|
const lRatio = adv.low_ratio;
|
|
ctx.fillStyle = lRatio < 0.7 ? '#FFD600' : '#64748B'; // Gold if exhaustion
|
|
ctx.fillText(lRatio.toFixed(2), colX + colWidth / 2, lowY);
|
|
}
|
|
}
|
|
|
|
// Draw Top/Bottom Extreme Markers
|
|
if (adv) {
|
|
if (adv.top_extreme === 'exhaustion' || adv.top_extreme === 'absorption') {
|
|
const topY = getPriceY(highestPrice) - rowHeight / 2;
|
|
ctx.strokeStyle = adv.top_extreme === 'absorption' ? '#FF9800' : '#2196F3'; // Orange for Absorption, Blue for Exhaustion
|
|
ctx.lineWidth = adv.top_extreme === 'absorption' ? 3 : 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(colX, topY);
|
|
ctx.lineTo(colX + colWidth, topY);
|
|
ctx.stroke();
|
|
}
|
|
if (adv.bottom_extreme === 'exhaustion' || adv.bottom_extreme === 'absorption') {
|
|
const bottomY = getPriceY(lowestPrice) + rowHeight / 2;
|
|
ctx.strokeStyle = adv.bottom_extreme === 'absorption' ? '#FF9800' : '#2196F3';
|
|
ctx.lineWidth = adv.bottom_extreme === 'absorption' ? 3 : 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(colX, bottomY);
|
|
ctx.lineTo(colX + colWidth, bottomY);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Bottom Panel — two side-by-side bars: bid (sell/red) left, ask (buy/blue) right
|
|
const bidTotal = pricesStr.reduce((s, p) => s + (levels[p]?.bid || 0), 0);
|
|
const askTotal = pricesStr.reduce((s, p) => s + (levels[p]?.ask || 0), 0);
|
|
|
|
const halfW = Math.floor((colWidth - 4) / 2);
|
|
const gap = 2;
|
|
|
|
// Bid bar (left, sell = red)
|
|
const bidBarH = Math.max(2, (bidTotal / maxSideVolume) * barMaxH);
|
|
ctx.fillStyle = 'rgba(210, 38, 38, 0.9)';
|
|
ctx.fillRect(colX + 1, barBaseY - bidBarH, halfW, bidBarH);
|
|
|
|
// Ask bar (right, buy = blue)
|
|
const askBarH = Math.max(2, (askTotal / maxSideVolume) * barMaxH);
|
|
ctx.fillStyle = 'rgba(37, 99, 235, 0.9)';
|
|
ctx.fillRect(colX + halfW + gap + 1, barBaseY - askBarH, halfW, askBarH);
|
|
|
|
// Volume label — dominant side (max of ask/bid), matching original YuCluster display
|
|
const domVol = Math.max(bidTotal, askTotal);
|
|
const volLabel = domVol >= 1000 ? (domVol / 1000).toFixed(1) + 'K' : domVol.toFixed(0);
|
|
const delta = cluster.total_delta || 0;
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 11px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(volLabel, colX + colWidth / 2, barBaseY - 2);
|
|
|
|
// Delta block with value — sits below the bars
|
|
const deltaBlockY = barBaseY + 2;
|
|
const deltaBlockH = 18;
|
|
ctx.fillStyle = delta >= 0 ? '#1D4ED8' : '#991B1B';
|
|
ctx.fillRect(colX + 1, deltaBlockY, colWidth - 2, deltaBlockH);
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.font = 'bold 10px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(delta.toFixed(0), colX + colWidth / 2, deltaBlockY + deltaBlockH / 2);
|
|
|
|
// Timestamp at the very bottom
|
|
if (cluster.open_time) {
|
|
const timeStr = new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
ctx.fillStyle = '#94A3B8';
|
|
ctx.font = '9px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText(timeStr, colX + colWidth / 2, height - 2);
|
|
}
|
|
|
|
});
|
|
|
|
// Draw Volume Profile Overlay Panel (Left Side)
|
|
const volProfileWidth = 140;
|
|
const volumeProfile = {};
|
|
let maxProfileVolume = 0;
|
|
clusters.forEach(cluster => {
|
|
const lvls = cluster.levels || {};
|
|
Object.keys(lvls).forEach(pStr => {
|
|
const p = parseFloat(pStr);
|
|
const data = lvls[pStr];
|
|
const binPrice = Math.round(p / actualTickSize) * actualTickSize;
|
|
volumeProfile[binPrice] = (volumeProfile[binPrice] || 0) + (data.total || 0);
|
|
if (volumeProfile[binPrice] > maxProfileVolume) {
|
|
maxProfileVolume = volumeProfile[binPrice];
|
|
}
|
|
});
|
|
});
|
|
|
|
// Value Area Calculation (70% of total volume)
|
|
let totalDayVolume = 0;
|
|
let dayPocPrice = null;
|
|
let dayPocVol = -1;
|
|
|
|
Object.keys(volumeProfile).forEach(pStr => {
|
|
const vol = volumeProfile[pStr];
|
|
totalDayVolume += vol;
|
|
if (vol > dayPocVol) {
|
|
dayPocVol = vol;
|
|
dayPocPrice = parseFloat(pStr);
|
|
}
|
|
});
|
|
|
|
let vah = dayPocPrice;
|
|
let val = dayPocPrice;
|
|
|
|
if (totalDayVolume > 0 && dayPocPrice !== null) {
|
|
let currentValVol = dayPocVol;
|
|
const targetVol = totalDayVolume * 0.70;
|
|
let upperPrice = dayPocPrice + actualTickSize;
|
|
let lowerPrice = dayPocPrice - actualTickSize;
|
|
|
|
while (currentValVol < targetVol) {
|
|
const upperVol = volumeProfile[upperPrice.toString()] || 0;
|
|
const lowerVol = volumeProfile[lowerPrice.toString()] || 0;
|
|
|
|
if (upperVol === 0 && lowerVol === 0) {
|
|
break; // No more volume to add
|
|
}
|
|
|
|
if (upperVol >= lowerVol) {
|
|
currentValVol += upperVol;
|
|
vah = upperPrice;
|
|
upperPrice += actualTickSize;
|
|
} else {
|
|
currentValVol += lowerVol;
|
|
val = lowerPrice;
|
|
lowerPrice -= actualTickSize;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw VAH, VAL, and POC Lines across the chart
|
|
if (dayPocPrice !== null) {
|
|
const pocY = getPriceY(dayPocPrice);
|
|
const vahY = getPriceY(vah);
|
|
const valY = getPriceY(val);
|
|
|
|
// VAH Line
|
|
ctx.strokeStyle = 'rgba(148, 163, 184, 0.5)'; // Slate-400 dashed
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.beginPath(); ctx.moveTo(0, vahY); ctx.lineTo(width, vahY); ctx.stroke();
|
|
|
|
// VAL Line
|
|
ctx.beginPath(); ctx.moveTo(0, valY); ctx.lineTo(width, valY); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// POC Line
|
|
ctx.strokeStyle = 'rgba(255, 214, 0, 0.6)'; // Gold solid
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(0, pocY); ctx.lineTo(width, pocY); ctx.stroke();
|
|
|
|
// POC Label
|
|
ctx.fillStyle = '#FFD600';
|
|
ctx.font = '10px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillText('POC', width - axisWidth - 5, pocY - 5);
|
|
}
|
|
|
|
if (maxProfileVolume > 0) {
|
|
// Solid background for the panel to cover grid/clusters underneath
|
|
ctx.fillStyle = 'rgba(15, 20, 30, 0.85)';
|
|
ctx.fillRect(0, 0, volProfileWidth, chartHeight);
|
|
|
|
// Right border of panel
|
|
ctx.strokeStyle = '#2A364F';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(volProfileWidth, 0);
|
|
ctx.lineTo(volProfileWidth, chartHeight);
|
|
ctx.stroke();
|
|
|
|
// Histogram bars
|
|
Object.keys(volumeProfile).forEach(pStr => {
|
|
const price = parseFloat(pStr);
|
|
const vol = volumeProfile[pStr];
|
|
const y = getPriceY(price) - rowHeight / 2;
|
|
const barWidth = (vol / maxProfileVolume) * (volProfileWidth - 5);
|
|
|
|
// Highlight bars inside Value Area
|
|
if (price <= vah && price >= val) {
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.6)'; // Stronger blue for Value Area
|
|
} else {
|
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)'; // Faded blue outside Value Area
|
|
}
|
|
|
|
ctx.fillRect(0, y, barWidth, rowHeight - 2);
|
|
});
|
|
|
|
// Panel Title
|
|
ctx.fillStyle = '#94A3B8';
|
|
ctx.font = '10px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20);
|
|
}
|
|
|
|
// Draw Vertical Price Axis (Right Side)
|
|
ctx.fillStyle = 'rgba(11, 14, 20, 0.95)';
|
|
ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight);
|
|
|
|
ctx.strokeStyle = '#1E293B';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(width - axisWidth, 0);
|
|
ctx.lineTo(width - axisWidth, chartHeight);
|
|
ctx.stroke();
|
|
|
|
// Price axis labels — show every 10 rows to keep it clean
|
|
const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2;
|
|
const labelStep = actualTickSize * 10; // one label every 10 price rows
|
|
const topPrice = getYPrice(0);
|
|
const bottomPrice = getYPrice(chartHeight);
|
|
const firstLabel = Math.ceil(bottomPrice / labelStep) * labelStep;
|
|
|
|
ctx.font = '10px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
for (let p = firstLabel; p <= topPrice + labelStep; p += labelStep) {
|
|
const labelY = getPriceY(p);
|
|
if (labelY < 0 || labelY > chartHeight) continue;
|
|
|
|
// Subtle horizontal grid line
|
|
ctx.strokeStyle = 'rgba(30, 41, 59, 0.4)';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, labelY);
|
|
ctx.lineTo(width - axisWidth, labelY);
|
|
ctx.stroke();
|
|
|
|
// Small tick mark on axis border
|
|
ctx.strokeStyle = '#334155';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(width - axisWidth, labelY);
|
|
ctx.lineTo(width - axisWidth + 4, labelY);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#64748B';
|
|
ctx.fillText(p.toFixed(decimals), width - axisWidth + 8, labelY);
|
|
}
|
|
|
|
// Current Price Line — drawn last so it appears on top of everything
|
|
const currentPrice = latestCluster.close_price;
|
|
if (currentPrice) {
|
|
const priceY = getPriceY(currentPrice);
|
|
if (priceY >= 0 && priceY <= chartHeight) {
|
|
ctx.strokeStyle = 'rgba(0, 229, 255, 0.9)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.setLineDash([6, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(volProfileWidth, priceY);
|
|
ctx.lineTo(width - axisWidth, priceY);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Price tag on axis (on top of axis labels)
|
|
const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2;
|
|
const priceLabel = currentPrice.toFixed(decimals);
|
|
const tagH = 18;
|
|
const tagW = axisWidth - 4;
|
|
ctx.fillStyle = '#00E5FF';
|
|
ctx.fillRect(width - axisWidth + 2, priceY - tagH / 2, tagW, tagH);
|
|
ctx.fillStyle = '#000';
|
|
ctx.font = 'bold 10px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(priceLabel, width - axisWidth + 2 + tagW / 2, priceY);
|
|
|
|
// Bid/Ask spread indicator below the price tag
|
|
if (bid > 0 && ask > 0) {
|
|
const askY = getPriceY(ask);
|
|
const bidY = getPriceY(bid);
|
|
const boxH = 16;
|
|
const boxW = tagW;
|
|
const boxX = width - axisWidth + 2;
|
|
|
|
// Ask box (green)
|
|
if (askY >= 0 && askY <= chartHeight) {
|
|
ctx.fillStyle = '#1B5E20';
|
|
ctx.fillRect(boxX, askY - boxH / 2, boxW, boxH);
|
|
ctx.fillStyle = '#00E676';
|
|
ctx.font = 'bold 9px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(ask.toFixed(decimals), boxX + boxW / 2, askY);
|
|
}
|
|
|
|
// Spread arrow between ask and bid
|
|
const midSpreadY = (askY + bidY) / 2;
|
|
if (midSpreadY >= 0 && midSpreadY <= chartHeight) {
|
|
ctx.fillStyle = '#94A3B8';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('↕', boxX + boxW / 2, midSpreadY);
|
|
}
|
|
|
|
// Bid box (orange/red)
|
|
if (bidY >= 0 && bidY <= chartHeight) {
|
|
ctx.fillStyle = '#7C2D00';
|
|
ctx.fillRect(boxX, bidY - boxH / 2, boxW, boxH);
|
|
ctx.fillStyle = '#FB923C';
|
|
ctx.font = 'bold 9px JetBrains Mono, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(bid.toFixed(decimals), boxX + boxW / 2, bidY);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}, [clusters, scrollOffset]);
|
|
|
|
// Mouse Interaction: Panning/Scrolling
|
|
const handleMouseDown = (e) => {
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
// Clicking on price axis → enter scale drag mode
|
|
if (x > rect.width - axisWidth) {
|
|
isDraggingAxis.current = true;
|
|
axisDragStartY.current = y;
|
|
axisDragStartStep.current = stepMultiplier;
|
|
setCursor('ns-resize');
|
|
return;
|
|
}
|
|
|
|
// Clicking on time axis (bottom panel) → horizontal zoom drag
|
|
const chartH = rect.height - 100;
|
|
if (y > chartH) {
|
|
isDraggingTimeAxis.current = true;
|
|
timeAxisDragStartX.current = x;
|
|
timeAxisDragStartZoom.current = zoom;
|
|
setCursor('ew-resize');
|
|
return;
|
|
}
|
|
|
|
setIsDragging(true);
|
|
dragStart.current = { x, y };
|
|
dragOffsetStart.current = { ...scrollOffset };
|
|
setCursor('grabbing');
|
|
};
|
|
|
|
const handleMouseMove = (e) => {
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
// Cursor hover logic
|
|
if (!isDragging && !isDraggingAxis.current && !isDraggingTimeAxis.current) {
|
|
const chartH = rect.height - 100;
|
|
if (x > rect.width - axisWidth) setCursor('ns-resize');
|
|
else if (y > chartH) setCursor('ew-resize');
|
|
else setCursor('grab');
|
|
}
|
|
|
|
// Price axis drag → adjust stepMultiplier
|
|
if (isDraggingAxis.current && onStepChange) {
|
|
const dy = y - axisDragStartY.current; // inverted: drag down = zoom in (larger step)
|
|
const sensitivity = 4;
|
|
const newStep = Math.max(25, Math.round((axisDragStartStep.current + dy * sensitivity) / 25) * 25);
|
|
onStepChange(newStep);
|
|
return;
|
|
}
|
|
|
|
// Time axis drag → adjust horizontal zoom
|
|
if (isDraggingTimeAxis.current) {
|
|
const dx = x - timeAxisDragStartX.current;
|
|
const sensitivity = 0.005;
|
|
const newZoom = Math.max(0.1, Math.min(3.0, timeAxisDragStartZoom.current + dx * sensitivity));
|
|
setZoom(newZoom);
|
|
return;
|
|
}
|
|
|
|
if (!isDragging) return;
|
|
|
|
const dx = x - dragStart.current.x;
|
|
const dy = y - dragStart.current.y;
|
|
|
|
setScrollOffset({
|
|
x: dragOffsetStart.current.x + dx,
|
|
y: dragOffsetStart.current.y + dy
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
isDraggingAxis.current = false;
|
|
isDraggingTimeAxis.current = false;
|
|
setCursor('grab');
|
|
};
|
|
|
|
// Wheel interaction for scrolling and zooming
|
|
const handleWheel = (e) => {
|
|
if (e.ctrlKey) {
|
|
// Zoom in/out
|
|
if (e.deltaY < 0) {
|
|
setZoom(z => Math.min(2.5, z + 0.1));
|
|
} else {
|
|
setZoom(z => Math.max(0.3, z - 0.1));
|
|
}
|
|
return;
|
|
}
|
|
// shift + wheel = horizontal scroll, normal wheel = vertical scroll
|
|
if (e.shiftKey) {
|
|
setScrollOffset(prev => ({ ...prev, x: prev.x - e.deltaY }));
|
|
} else {
|
|
setScrollOffset(prev => ({ ...prev, y: prev.y - e.deltaY * 0.5 }));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full h-full select-none overflow-hidden rounded-xl border border-slate-800 bg-darkBg shadow-2xl" style={{ cursor }}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full block"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
onWheel={handleWheel}
|
|
/>
|
|
<div className="absolute bottom-4 left-4 flex gap-2 items-center bg-darkBg/50 p-2 rounded-xl backdrop-blur-md border border-slate-800">
|
|
<button
|
|
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
|
|
className="w-8 h-8 flex items-center justify-center bg-darkPanel border border-slate-700 rounded-lg text-lg font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition"
|
|
title="Zoom Out"
|
|
>
|
|
-
|
|
</button>
|
|
<span className="text-xs font-mono text-slate-400 min-w-[35px] text-center">
|
|
{Math.round(zoom * 100)}%
|
|
</span>
|
|
<button
|
|
onClick={() => setZoom(z => Math.min(2.5, z + 0.1))}
|
|
className="w-8 h-8 flex items-center justify-center bg-darkPanel border border-slate-700 rounded-lg text-lg font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition"
|
|
title="Zoom In"
|
|
>
|
|
+
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-slate-700 mx-1"></div>
|
|
|
|
<button
|
|
onClick={() => { setScrollOffset({ x: 50, y: 0 }); setZoom(1); }}
|
|
className="px-4 py-1.5 bg-darkPanel border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-800 transition"
|
|
>
|
|
Reset View
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|