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 (
{Math.round(zoom * 100)}%
); }