Files
yuclusters-mt5/frontend/src/FootprintCanvas.jsx
T
rufinomec-afkandClaude Sonnet 4.6 6fe8920412 fix: desativar calculo de imbalance no frontend
Frontend recalculava imbalance independente do backend.
Desativado ate implementacao correta ser encontrada.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:06:57 -03:00

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>
);
}