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:
co-authored by
Claude Sonnet 4.6
parent
b35fd2eb5a
commit
935ff203d3
@@ -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,19 +860,19 @@ 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>
|
||||||
@@ -881,7 +880,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
|||||||
<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
|
||||||
|
|||||||
Reference in New Issue
Block a user