fix: separa zoom horizontal e vertical no footprint canvas

- zoomH controla largura das colunas (colWidth, colGap)
- zoomV controla altura das linhas (rowHeight) independentemente
- Arrastar eixo de preço (direita) → ajusta zoomV, não mais o stepMultiplier
- Arrastar painel inferior → ajusta zoomH
- Ctrl+scroll e botões +/- → zoomH com escala multiplicativa
- Mínimos: zoomH=0.05, zoomV=0.1 — permite redução bem maior
- Sensibilidade multiplicativa em todos os controles de zoom
- Corrige dependências do useEffect (zoomH, zoomV, bid, ask, etc.)
- Reset View reseta ambos os zooms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rufinomec-afk
2026-06-08 11:35:55 -03:00
co-authored by Claude Sonnet 4.6
parent b35fd2eb5a
commit 935ff203d3
+39 -40
View File
@@ -5,29 +5,30 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
// Navigation & Scale State // Navigation & Scale State
const [scrollOffset, setScrollOffset] = useState({ x: 50, y: 0 }); // X: horizontal offset, Y: vertical offset const [scrollOffset, setScrollOffset] = useState({ x: 50, y: 0 }); // X: horizontal offset, Y: vertical offset
const [zoom, setZoom] = useState(1); // Zoom level const [zoomH, setZoomH] = useState(1); // horizontal zoom: column width
const [zoomV, setZoomV] = useState(1); // vertical zoom: row height
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [cursor, setCursor] = useState('grab'); const [cursor, setCursor] = useState('grab');
const dragStart = useRef({ x: 0, y: 0 }); const dragStart = useRef({ x: 0, y: 0 });
const dragOffsetStart = useRef({ x: 0, y: 0 }); const dragOffsetStart = useRef({ x: 0, y: 0 });
// Price axis drag (scale) state // Price axis drag (vertical scale) state
const isDraggingAxis = useRef(false); const isDraggingAxis = useRef(false);
const axisDragStartY = useRef(0); const axisDragStartY = useRef(0);
const axisDragStartStep = useRef(0); const axisDragStartZoomV = useRef(1);
// Time axis drag (horizontal zoom) state // Time axis drag (horizontal zoom) state
const isDraggingTimeAxis = useRef(false); const isDraggingTimeAxis = useRef(false);
const timeAxisDragStartX = useRef(0); const timeAxisDragStartX = useRef(0);
const timeAxisDragStartZoom = useRef(1); const timeAxisDragStartZoomH = useRef(1);
// Apply zoom to sizes // Independent zoom dimensions
const colWidth = 140 * zoom; // width of each cluster column const colWidth = 140 * zoomH; // width of each cluster column
const colGap = 15 * zoom; // gap between columns const colGap = 15 * zoomH; // gap between columns
const rowHeight = 26 * zoom; // height of each price cell const rowHeight = 26 * zoomV; // height of each price cell (vertical only)
const axisWidth = 70; // width of the vertical price axis on the right const axisWidth = 70; // width of the vertical price axis on the right
// Auto-scroll to keep latest cluster visible whenever clusters or zoom changes // Auto-scroll to keep latest cluster visible whenever clusters or horizontal zoom changes
const lastClusterCount = useRef(0); const lastClusterCount = useRef(0);
useEffect(() => { useEffect(() => {
if (clusters && canvasRef.current) { if (clusters && canvasRef.current) {
@@ -40,7 +41,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
setScrollOffset(prev => ({ ...prev, x: newX })); setScrollOffset(prev => ({ ...prev, x: newX }));
lastClusterCount.current = clusters.length; lastClusterCount.current = clusters.length;
} }
}, [clusters?.length, zoom]); }, [clusters?.length, zoomH]);
// Main Render Loop // Main Render Loop
useEffect(() => { useEffect(() => {
@@ -224,7 +225,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
// Max volume in cluster for proportional bars // 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 maxVol = Math.max(...Object.values(levels).map(l => Math.max(l.ask || 0, l.bid || 0)), 1);
const fontSize = Math.max(6, 11 * zoom); const fontSize = Math.max(6, 11 * zoomH);
ctx.font = `bold ${fontSize}px JetBrains Mono, monospace`; ctx.font = `bold ${fontSize}px JetBrains Mono, monospace`;
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
@@ -256,7 +257,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
if (!isWick && cellData.imbalance) { if (!isWick && cellData.imbalance) {
const cy = cellY + rowHeight / 2; const cy = cellY + rowHeight / 2;
const cx = colX + colWidth / 2; const cx = colX + colWidth / 2;
const radius = Math.max(3, Math.min(rowHeight * 0.38, 10 * zoom)); const radius = Math.max(3, Math.min(rowHeight * 0.38, 10 * zoomH));
const imbalColor = cellData.imbalance === 'sell' ? '#CC0000' : '#1A237E'; const imbalColor = cellData.imbalance === 'sell' ? '#CC0000' : '#1A237E';
ctx.beginPath(); ctx.beginPath();
@@ -265,7 +266,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
ctx.fill(); ctx.fill();
// Show number inside circle when zoomed in enough // Show number inside circle when zoomed in enough
if (zoom >= 1.2 && radius >= 7) { if (zoomH >= 1.2 && radius >= 7) {
const val = cellData.imbalance === 'sell' ? (cellData.bid || 0) : (cellData.ask || 0); const val = cellData.imbalance === 'sell' ? (cellData.bid || 0) : (cellData.ask || 0);
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${Math.floor(radius * 1.1)}px JetBrains Mono, monospace`; ctx.font = `bold ${Math.floor(radius * 1.1)}px JetBrains Mono, monospace`;
@@ -283,7 +284,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
} }
// Number only at POC (inside bar, right-aligned) // Number only at POC (inside bar, right-aligned)
if (zoom >= 0.5 && !isWick && price === cluster.poc) { if (zoomH >= 0.5 && !isWick && price === cluster.poc) {
const cy = cellY + rowHeight / 2; const cy = cellY + rowHeight / 2;
if (viewMode === 'delta') { if (viewMode === 'delta') {
const d = cellData.delta || 0; const d = cellData.delta || 0;
@@ -747,7 +748,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
} }
} }
}, [clusters, scrollOffset]); }, [clusters, scrollOffset, zoomH, zoomV, stepMultiplier, viewMode, bid, ask, domData, bigTrades]);
// Mouse Interaction: Panning/Scrolling // Mouse Interaction: Panning/Scrolling
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
@@ -755,11 +756,11 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; const y = e.clientY - rect.top;
// Clicking on price axis → enter scale drag mode // Clicking on price axis → enter vertical zoom drag mode
if (x > rect.width - axisWidth) { if (x > rect.width - axisWidth) {
isDraggingAxis.current = true; isDraggingAxis.current = true;
axisDragStartY.current = y; axisDragStartY.current = y;
axisDragStartStep.current = stepMultiplier; axisDragStartZoomV.current = zoomV;
setCursor('ns-resize'); setCursor('ns-resize');
return; return;
} }
@@ -769,7 +770,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
if (y > chartH) { if (y > chartH) {
isDraggingTimeAxis.current = true; isDraggingTimeAxis.current = true;
timeAxisDragStartX.current = x; timeAxisDragStartX.current = x;
timeAxisDragStartZoom.current = zoom; timeAxisDragStartZoomH.current = zoomH;
setCursor('ew-resize'); setCursor('ew-resize');
return; return;
} }
@@ -793,21 +794,19 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
else setCursor('grab'); else setCursor('grab');
} }
// Price axis drag → adjust stepMultiplier // Price axis drag → adjust vertical zoom (row height only)
if (isDraggingAxis.current && onStepChange) { if (isDraggingAxis.current) {
const dy = y - axisDragStartY.current; // inverted: drag down = zoom in (larger step) const dy = axisDragStartY.current - y; // drag up = taller rows
const sensitivity = 4; const newZoomV = Math.max(0.1, Math.min(4.0, axisDragStartZoomV.current * (1 + dy * 0.008)));
const newStep = Math.max(25, Math.round((axisDragStartStep.current + dy * sensitivity) / 25) * 25); setZoomV(newZoomV);
onStepChange(newStep);
return; return;
} }
// Time axis drag → adjust horizontal zoom // Time axis drag → adjust horizontal zoom (column width only)
if (isDraggingTimeAxis.current) { if (isDraggingTimeAxis.current) {
const dx = x - timeAxisDragStartX.current; const dx = x - timeAxisDragStartX.current;
const sensitivity = 0.005; const newZoomH = Math.max(0.05, Math.min(3.0, timeAxisDragStartZoomH.current * (1 + dx * 0.008)));
const newZoom = Math.max(0.1, Math.min(3.0, timeAxisDragStartZoom.current + dx * sensitivity)); setZoomH(newZoomH);
setZoom(newZoom);
return; return;
} }
@@ -832,11 +831,11 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
// Wheel interaction for scrolling and zooming // Wheel interaction for scrolling and zooming
const handleWheel = (e) => { const handleWheel = (e) => {
if (e.ctrlKey) { if (e.ctrlKey) {
// Zoom in/out // Horizontal zoom in/out (column width)
if (e.deltaY < 0) { if (e.deltaY < 0) {
setZoom(z => Math.min(2.5, z + 0.1)); setZoomH(z => Math.min(3.0, z * 1.1));
} else { } else {
setZoom(z => Math.max(0.3, z - 0.1)); setZoomH(z => Math.max(0.05, z * 0.9));
} }
return; return;
} }
@@ -861,27 +860,27 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
/> />
<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"> <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 <button
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))} onClick={() => setZoomH(z => Math.max(0.05, z * 0.85))}
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" 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" title="Zoom Horizontal Out"
> >
- -
</button> </button>
<span className="text-xs font-mono text-slate-400 min-w-[35px] text-center"> <span className="text-xs font-mono text-slate-400 min-w-[50px] text-center">
{Math.round(zoom * 100)}% {Math.round(zoomH * 100)}%
</span> </span>
<button <button
onClick={() => setZoom(z => Math.min(2.5, z + 0.1))} onClick={() => setZoomH(z => Math.min(3.0, z * 1.15))}
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" 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" title="Zoom Horizontal In"
> >
+ +
</button> </button>
<div className="w-px h-6 bg-slate-700 mx-1"></div> <div className="w-px h-6 bg-slate-700 mx-1"></div>
<button <button
onClick={() => { setScrollOffset({ x: 50, y: 0 }); setZoom(1); }} onClick={() => { setScrollOffset({ x: 50, y: 0 }); setZoomH(1); setZoomV(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" 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 Reset View