feat: visual e coleta de dados do footprint chart

- Footprint bars bid/ask por nível com cor dominante
- Painel inferior duplo: volume (barras) + delta (blocos azul/laranja)
- Linha de preço atual ciano dashed com tag no eixo
- Timestamp por cluster no eixo X
- Drag no eixo de tempo para zoom horizontal
- Drag no eixo de preço para ajustar step multiplier
- Coleta de volume via price-step method para delta correto
- Guard is_live para evitar flood de WebSocket no replay histórico
- total_ticks adicionado ao aggregator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rufinomec-afk
2026-06-06 14:57:02 -03:00
co-authored by Claude Sonnet 4.6
parent 0a185c0223
commit 11a0921e20
7 changed files with 464 additions and 258 deletions
+28 -22
View File
@@ -17,6 +17,7 @@ class FootprintCluster:
self.poc: Optional[float] = None self.poc: Optional[float] = None
self.total_delta: float = 0.0 self.total_delta: float = 0.0
self.total_volume: float = 0.0 self.total_volume: float = 0.0
self.total_ticks: int = 0
# levels: price_float -> { 'ask': float, 'bid': float } # levels: price_float -> { 'ask': float, 'bid': float }
# internally we keep float keys to make sorting and arithmetic easy # internally we keep float keys to make sorting and arithmetic easy
@@ -39,7 +40,8 @@ class FootprintCluster:
self.open_price = price self.open_price = price
self.close_price = price self.close_price = price
self.total_ticks += 1
# Round price to the nearest tick_size to avoid float precision issues # Round price to the nearest tick_size to avoid float precision issues
rounded_price = round(price / self.tick_size) * self.tick_size rounded_price = round(price / self.tick_size) * self.tick_size
@@ -259,29 +261,32 @@ class FootprintCluster:
self.advanced_metrics["delta_divergence"] = True self.advanced_metrics["delta_divergence"] = True
def should_close(self, current_time_msc: int) -> Optional[str]: def should_close(self, current_time_msc: int) -> Optional[str]:
# Check range mode = settings.CLUSTER_CLOSE_MODE
if self.high is not None and self.low is not None:
points = self.high - self.low # Volume safety cap — always applied (prevents runaway cluster if market halts)
# If tick_size is 1.0, range is in points. Let's compare directly.
# (or points/tick_size >= CLUSTER_RANGE_POINTS)
# Standard MT5 points. If range in points exceeds setting:
if points >= settings.CLUSTER_RANGE_POINTS:
return "range"
# Check volume
if self.total_volume >= settings.CLUSTER_VOLUME_MAX: if self.total_volume >= settings.CLUSTER_VOLUME_MAX:
return "volume" return "volume"
# Check delta # Primary closing condition based on configured mode
if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX: if mode == "delta":
return "delta" if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX:
return "delta"
# Check time
if self.open_time is not None: elif mode == "range":
elapsed = (current_time_msc - self.open_time) / 1000.0 if self.high is not None and self.low is not None:
if elapsed >= settings.CLUSTER_TIME_SECONDS: if (self.high - self.low) >= settings.CLUSTER_RANGE_POINTS:
return "time" return "range"
elif mode == "time":
if self.open_time is not None:
elapsed = (current_time_msc - self.open_time) / 1000.0
if elapsed >= settings.CLUSTER_TIME_SECONDS:
return "time"
elif mode == "volume":
if self.total_volume >= settings.CLUSTER_VOLUME_MAX:
return "volume"
return None return None
def close(self, reason: str) -> None: def close(self, reason: str) -> None:
@@ -313,6 +318,7 @@ class FootprintCluster:
"poc": float(self.poc) if self.poc is not None else None, "poc": float(self.poc) if self.poc is not None else None,
"total_delta": float(self.total_delta), "total_delta": float(self.total_delta),
"total_volume": float(self.total_volume), "total_volume": float(self.total_volume),
"total_ticks": int(self.total_ticks),
"levels": levels_str, "levels": levels_str,
"stacked": { "stacked": {
"buy": bool(self.stacked.get("buy", False)), "buy": bool(self.stacked.get("buy", False)),
+43 -12
View File
@@ -23,6 +23,8 @@ class MT5Collector:
self.connected = False self.connected = False
self.last_tick_time_msc = 0 self.last_tick_time_msc = 0
self.seen_ticks_buffer = set() self.seen_ticks_buffer = set()
self.last_mid_price = 0.0
self.last_is_buy = True
async def connect_mt5(self) -> bool: async def connect_mt5(self) -> bool:
""" """
@@ -30,8 +32,8 @@ class MT5Collector:
All MT5 calls are blocking, so they run in a thread executor. All MT5 calls are blocking, so they run in a thread executor.
""" """
try: try:
mt5_path = r"C:\Program Files\MetaTrader 5\terminal64.exe" # Try to connect to any running MT5 terminal without specifying path
initialized = await asyncio.to_thread(mt5.initialize, path=mt5_path) initialized = await asyncio.to_thread(mt5.initialize)
if not initialized: if not initialized:
err = await asyncio.to_thread(mt5.last_error) err = await asyncio.to_thread(mt5.last_error)
logger.error(f"MT5 initialize failed: {err}") logger.error(f"MT5 initialize failed: {err}")
@@ -98,9 +100,9 @@ class MT5Collector:
else: else:
backoff = 1.0 backoff = 1.0
# Fetch from 6 hours ago so the chart isn't empty when started # Fetch from 48 hours ago so the chart isn't empty when started (covers weekends)
from datetime import datetime, timedelta from datetime import datetime, timedelta
start_time_dt = datetime.now() - timedelta(hours=6) start_time_dt = datetime.now() - timedelta(hours=48)
ticks = await asyncio.to_thread( ticks = await asyncio.to_thread(
mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL
) )
@@ -146,20 +148,49 @@ class MT5Collector:
self.seen_ticks_buffer.add(tick_id) self.seen_ticks_buffer.add(tick_id)
price = tick['last'] if tick['last'] > 0 else (tick['bid'] if tick['bid'] > 0 else tick['ask']) flags = int(tick['flags'])
volume = tick['volume_real'] if tick['volume_real'] > 0 else float(tick['volume']) bid_price = float(tick['bid'])
flags = tick['flags'] ask_price = float(tick['ask'])
last_price = float(tick['last'])
# Forex ticks often have volume=0 (they are just quote updates). mid_price = (bid_price + ask_price) / 2.0 if (bid_price > 0 and ask_price > 0) else 0.0
# We count each quote update as 1 unit of tick volume to build the footprint. prev_mid = self.last_mid_price
if volume == 0:
# Update direction tracker from mid movement
if mid_price > 0:
if mid_price > self.last_mid_price:
self.last_is_buy = True
elif mid_price < self.last_mid_price:
self.last_is_buy = False
self.last_mid_price = mid_price
# Determine price (use mid as best proxy for CFD quote feed)
price = last_price if last_price > 0 else (mid_price if mid_price > 0 else (bid_price if bid_price > 0 else ask_price))
# Volume = price movement in tick-size units (how the YuCluster measures activity)
tick_sz = self.aggregator.tick_size if self.aggregator.tick_size > 0 else 0.01
if prev_mid > 0 and mid_price > 0:
price_steps = abs(mid_price - prev_mid) / tick_sz
volume = max(price_steps, 1.0)
else:
volume = 1.0 volume = 1.0
is_buy = classify_tick(price, tick['bid'], tick['ask'], flags) # Determine direction
if flags & 32:
is_buy = True
elif flags & 64:
is_buy = False
else:
is_buy = self.last_is_buy
active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc) active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc)
self.on_update_callback(active_json, closed_json) # Only broadcast during live trading (within 10s of now) to avoid
# flooding the WebSocket during historical replay
import time as _time
is_live = ((_time.time() * 1000) - msc) < 10_000
if is_live:
self.on_update_callback(active_json, closed_json)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
+43 -5
View File
@@ -5,6 +5,7 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Set, Dict, Any, Optional from typing import Set, Dict, Any, Optional
# Add project root to sys.path # Add project root to sys.path
@@ -25,18 +26,23 @@ collector_task: Optional[asyncio.Task] = None
def broadcast_update(active_json: dict, closed_json: Optional[dict]): def broadcast_update(active_json: dict, closed_json: Optional[dict]):
""" """
Callback executed by MT5Collector when a new tick is processed. Callback executed by MT5Collector when a new tick is processed.
Schedules WebSocket sends on the event loop. Only broadcasts when a cluster closes (not every tick) to avoid flooding during replay.
Removes dead connections automatically.
""" """
if not active_connections or closed_json is None:
return
message = { message = {
"type": "tick", "type": "tick",
"active": active_json, "active": active_json,
"closed": closed_json "closed": closed_json
} }
for connection in list(active_connections): async def safe_send(ws: WebSocket, msg: dict):
try: try:
asyncio.create_task(connection.send_json(message)) await ws.send_json(msg)
except Exception as e: except Exception:
logger.error(f"Error sending update to client: {e}") active_connections.discard(ws)
for connection in list(active_connections):
asyncio.create_task(safe_send(connection, message))
# Initialize MT5 Collector # Initialize MT5 Collector
collector = MT5Collector(aggregator, on_update_callback=broadcast_update) collector = MT5Collector(aggregator, on_update_callback=broadcast_update)
@@ -75,6 +81,38 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
class ConfigUpdate(BaseModel):
close_mode: Optional[str] = None
delta_max: Optional[float] = None
volume_max: Optional[float] = None
range_points: Optional[float] = None
time_seconds: Optional[float] = None
@app.get("/config")
async def get_config():
return {
"close_mode": settings.CLUSTER_CLOSE_MODE,
"delta_max": settings.CLUSTER_DELTA_MAX,
"volume_max": settings.CLUSTER_VOLUME_MAX,
"range_points": settings.CLUSTER_RANGE_POINTS,
"time_seconds": settings.CLUSTER_TIME_SECONDS,
}
@app.post("/config")
async def update_config(update: ConfigUpdate):
if update.close_mode is not None:
settings.CLUSTER_CLOSE_MODE = update.close_mode
if update.delta_max is not None:
settings.CLUSTER_DELTA_MAX = update.delta_max
if update.volume_max is not None:
settings.CLUSTER_VOLUME_MAX = update.volume_max
if update.range_points is not None:
settings.CLUSTER_RANGE_POINTS = update.range_points
if update.time_seconds is not None:
settings.CLUSTER_TIME_SECONDS = update.time_seconds
logger.info(f"Config updated: mode={settings.CLUSTER_CLOSE_MODE}, delta_max={settings.CLUSTER_DELTA_MAX}")
return {"ok": True}
@app.get("/history") @app.get("/history")
async def get_history(): async def get_history():
"""Returns the buffer of historical closed clusters.""" """Returns the buffer of historical closed clusters."""
+7 -7
View File
@@ -4,14 +4,14 @@ import os
MT5_LOGIN = int(os.environ.get("MT5_LOGIN", 0)) # seu número de conta MT5_LOGIN = int(os.environ.get("MT5_LOGIN", 0)) # seu número de conta
MT5_PASSWORD = os.environ.get("MT5_PASSWORD", "") # sua senha MT5_PASSWORD = os.environ.get("MT5_PASSWORD", "") # sua senha
MT5_SERVER = os.environ.get("MT5_SERVER", "") # nome do servidor (ex: "XPInvestimentos-Real") MT5_SERVER = os.environ.get("MT5_SERVER", "") # nome do servidor (ex: "XPInvestimentos-Real")
MT5_SYMBOL = os.environ.get("MT5_SYMBOL", "EURUSD") # símbolo padrão MT5_SYMBOL = os.environ.get("MT5_SYMBOL", "USTEC") # símbolo padrão
# Cluster # Cluster
CLUSTER_RANGE_POINTS = int(os.environ.get("CLUSTER_RANGE_POINTS", 10)) CLUSTER_RANGE_POINTS = float(os.environ.get("CLUSTER_RANGE_POINTS", 50.0))
CLUSTER_VOLUME_MAX = float(os.environ.get("CLUSTER_VOLUME_MAX", 1000)) CLUSTER_VOLUME_MAX = float(os.environ.get("CLUSTER_VOLUME_MAX", 100000))
CLUSTER_DELTA_MAX = float(os.environ.get("CLUSTER_DELTA_MAX", 500)) CLUSTER_DELTA_MAX = float(os.environ.get("CLUSTER_DELTA_MAX", 800))
CLUSTER_TIME_SECONDS = float(os.environ.get("CLUSTER_TIME_SECONDS", 60)) CLUSTER_TIME_SECONDS = float(os.environ.get("CLUSTER_TIME_SECONDS", 300))
CLUSTER_CLOSE_MODE = os.environ.get("CLUSTER_CLOSE_MODE", "range") # "range" | "volume" | "delta" | "time" CLUSTER_CLOSE_MODE = os.environ.get("CLUSTER_CLOSE_MODE", "delta") # "range" | "volume" | "delta" | "time"
# Imbalance # Imbalance
IMBALANCE_RATIO = float(os.environ.get("IMBALANCE_RATIO", 3.0)) IMBALANCE_RATIO = float(os.environ.get("IMBALANCE_RATIO", 3.0))
@@ -19,4 +19,4 @@ STACKED_MIN_COUNT = int(os.environ.get("STACKED_MIN_COUNT", 3))
# WebSocket # WebSocket
WS_PORT = int(os.environ.get("WS_PORT", 6002)) WS_PORT = int(os.environ.get("WS_PORT", 6002))
HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 50)) HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 500))
+96 -12
View File
@@ -11,11 +11,16 @@ export default function App() {
const [history, setHistory] = useState([]); const [history, setHistory] = useState([]);
const [activeCluster, setActiveCluster] = useState(null); const [activeCluster, setActiveCluster] = useState(null);
const [lastTickTime, setLastTickTime] = useState(null); const [lastTickTime, setLastTickTime] = useState(null);
// Phase 2 & 4 Settings // Phase 2 & 4 Settings
const [stepMultiplier, setStepMultiplier] = useState(1); const [stepMultiplier, setStepMultiplier] = useState(75);
const [viewMode, setViewMode] = useState('bidask'); // 'bidask' or 'delta' const [viewMode, setViewMode] = useState('bidask'); // 'bidask' or 'delta'
const [imbalanceRatio, setImbalanceRatio] = useState(300); // percentage const [imbalanceRatio, setImbalanceRatio] = useState(300); // percentage
// Cluster aggregator config
const [closeMode, setCloseMode] = useState('delta');
const [deltaMax, setDeltaMax] = useState(800);
const [configDirty, setConfigDirty] = useState(false);
// Toasts // Toasts
const [toasts, setToasts] = useState([]); const [toasts, setToasts] = useState([]);
@@ -28,6 +33,30 @@ export default function App() {
}, 5000); }, 5000);
}, []); }, []);
// Fetch config from backend on load
const fetchConfig = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/config`);
if (res.ok) {
const data = await res.json();
setCloseMode(data.close_mode || 'delta');
setDeltaMax(data.delta_max || 2418);
}
} catch (e) {}
}, []);
// Push config update to backend
const applyConfig = useCallback(async (mode, delta) => {
try {
await fetch(`${API_URL}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ close_mode: mode, delta_max: delta }),
});
setConfigDirty(false);
} catch (e) {}
}, []);
// Fetch initial history when backend becomes reachable // Fetch initial history when backend becomes reachable
const fetchHistory = useCallback(async () => { const fetchHistory = useCallback(async () => {
try { try {
@@ -54,7 +83,7 @@ export default function App() {
if (msg.closed) { if (msg.closed) {
setHistory(prev => { setHistory(prev => {
const updated = [...prev, msg.closed]; const updated = [...prev, msg.closed];
if (updated.length > 50) { if (updated.length > 500) {
updated.shift(); updated.shift();
} }
return updated; return updated;
@@ -70,7 +99,8 @@ export default function App() {
useEffect(() => { useEffect(() => {
fetchHistory(); fetchHistory();
}, [fetchHistory, wsStatus]); fetchConfig();
}, [fetchHistory, fetchConfig, wsStatus]);
// Aggregate stats from history // Aggregate stats from history
const totalVolume = history.reduce((acc, c) => acc + (c.total_volume || 0), 0) + (activeCluster?.total_volume || 0); const totalVolume = history.reduce((acc, c) => acc + (c.total_volume || 0), 0) + (activeCluster?.total_volume || 0);
@@ -78,7 +108,10 @@ export default function App() {
const allClusters = [...history]; const allClusters = [...history];
if (activeCluster) allClusters.push(activeCluster); if (activeCluster) allClusters.push(activeCluster);
// Use tick_size from the latest cluster data (set by MT5 backend)
const dynamicTickSize = allClusters.length > 0 ? (allClusters[allClusters.length - 1].tick_size || 0.01) : 0.01;
return ( return (
<div className="flex flex-col h-full bg-[#0B0E14] text-slate-100 relative"> <div className="flex flex-col h-full bg-[#0B0E14] text-slate-100 relative">
{/* Toast Container */} {/* Toast Container */}
@@ -139,12 +172,13 @@ export default function App() {
<main className="flex-1 flex overflow-hidden p-6 gap-6"> <main className="flex-1 flex overflow-hidden p-6 gap-6">
{/* Footprint Chart Panel */} {/* Footprint Chart Panel */}
<div className="flex-1 flex flex-col h-full bg-[#151B26]/30 rounded-xl overflow-hidden"> <div className="flex-1 flex flex-col h-full bg-[#151B26]/30 rounded-xl overflow-hidden">
<FootprintCanvas <FootprintCanvas
clusters={allClusters} clusters={allClusters}
tickSize={0.00001} tickSize={dynamicTickSize}
stepMultiplier={stepMultiplier} stepMultiplier={stepMultiplier}
viewMode={viewMode} viewMode={viewMode}
imbalanceRatio={imbalanceRatio} imbalanceRatio={imbalanceRatio}
onStepChange={setStepMultiplier}
/> />
</div> </div>
@@ -160,7 +194,7 @@ export default function App() {
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-slate-400">Target Symbol:</span> <span className="text-slate-400">Target Symbol:</span>
<span className="font-mono font-semibold text-[#00B0FF] bg-[#00B0FF]/10 px-2 py-0.5 rounded"> <span className="font-mono font-semibold text-[#00B0FF] bg-[#00B0FF]/10 px-2 py-0.5 rounded">
EURUSD USTEC
</span> </span>
</div> </div>
<div className="flex flex-col sm:flex-row items-center gap-6"> <div className="flex flex-col sm:flex-row items-center gap-6">
@@ -181,9 +215,9 @@ export default function App() {
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-[10px] text-slate-500 font-bold mb-1">PRICE STEP</span> <span className="text-[10px] text-slate-500 font-bold mb-1">PRICE STEP</span>
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden"> <div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => setStepMultiplier(Math.max(1, stepMultiplier - 1))} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">-</button> <button onClick={() => setStepMultiplier(s => Math.max(25, s - 25))} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">-</button>
<div className="px-3 py-1 text-sm font-bold text-white min-w-[30px] text-center">{stepMultiplier}</div> <div className="px-3 py-1 text-sm font-bold text-white min-w-[52px] text-center">{(stepMultiplier * dynamicTickSize).toFixed(2)} pts</div>
<button onClick={() => setStepMultiplier(stepMultiplier + 1)} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">+</button> <button onClick={() => setStepMultiplier(s => s + 25)} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
</div> </div>
</div> </div>
@@ -231,6 +265,56 @@ export default function App() {
</div> </div>
</section> </section>
{/* Cluster Formation Config */}
<section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg">
<h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2">
Cluster Formation
</h2>
<div className="space-y-4 text-xs">
{/* Close mode */}
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">CLOSE MODE</span>
<div className="flex bg-[#0B0E14] border border-slate-700 rounded-md overflow-hidden">
{['delta', 'range', 'time', 'volume'].map(m => (
<button
key={m}
onClick={() => { setCloseMode(m); setConfigDirty(true); }}
className={`flex-1 py-1 text-[10px] font-semibold uppercase ${closeMode === m ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}
>{m}</button>
))}
</div>
</div>
{/* Delta threshold — only relevant when mode=delta */}
{closeMode === 'delta' && (
<div>
<div className="flex justify-between mb-1">
<span className="text-[10px] text-slate-500 font-bold">DELTA THRESHOLD</span>
<span className="text-[10px] font-mono text-slate-300">{deltaMax.toLocaleString()}</span>
</div>
<input
type="range" min="100" max="10000" step="50"
value={deltaMax}
onChange={e => { setDeltaMax(Number(e.target.value)); setConfigDirty(true); }}
className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
/>
<div className="flex justify-between text-[9px] text-slate-600 mt-0.5">
<span>100</span><span>10 000</span>
</div>
</div>
)}
{/* Apply button */}
<button
onClick={() => applyConfig(closeMode, deltaMax)}
disabled={!configDirty}
className={`w-full py-1.5 rounded-md text-xs font-semibold transition ${configDirty ? 'bg-[#00E676] text-slate-900 hover:bg-[#00c853]' : 'bg-slate-800 text-slate-600 cursor-not-allowed'}`}
>
{configDirty ? 'Apply' : 'Applied'}
</button>
</div>
</section>
{/* Aggregator Settings Rules Indicator */} {/* Aggregator Settings Rules Indicator */}
<section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg flex-1"> <section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg flex-1">
<h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2"> <h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2">
+242 -200
View File
@@ -1,14 +1,25 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300 }) { export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange }) {
const canvasRef = useRef(null); const canvasRef = useRef(null);
// 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 [zoom, setZoom] = useState(1); // Zoom level
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
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
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 // Apply zoom to sizes
const colWidth = 140 * zoom; // width of each cluster column const colWidth = 140 * zoom; // width of each cluster column
@@ -16,17 +27,20 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const rowHeight = 26 * zoom; // height of each price cell const rowHeight = 26 * zoom; // height of each price cell
const axisWidth = 70; // width of the vertical price axis on the right const axisWidth = 70; // width of the vertical price axis on the right
// Handle auto-scroll to the right (most recent cluster) on new cluster load // Auto-scroll to keep latest cluster visible whenever clusters or zoom changes
const lastClusterCount = useRef(0); const lastClusterCount = useRef(0);
useEffect(() => { useEffect(() => {
if (clusters && clusters.length > lastClusterCount.current && canvasRef.current) { if (clusters && canvasRef.current) {
const canvas = canvasRef.current; const canvas = canvasRef.current;
// Scroll to show the active cluster at the right side const rect = canvas.getBoundingClientRect();
const rightmostX = canvas.width - axisWidth - (clusters.length * (colWidth + colGap)) - 50; const visibleWidth = rect.width - axisWidth - 160; // leave space for vol profile
setScrollOffset(prev => ({ ...prev, x: Math.min(160, rightmostX) })); 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; lastClusterCount.current = clusters.length;
} }
}, [clusters?.length]); }, [clusters?.length, zoom]);
// Main Render Loop // Main Render Loop
useEffect(() => { useEffect(() => {
@@ -96,7 +110,30 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
}; };
// Cumulative Delta Tracking // Cumulative Delta Tracking
let cumulativeDelta = 0; // Max volume across all clusters (for proportional volume bars in bottom panel)
const maxClusterVolume = Math.max(...clusters.map(c => c.total_volume || 1), 1);
// Bottom panel layout
const bottomPanelH = bottomPanelHeight;
const volRowH = Math.round(bottomPanelH * 0.55);
const deltaRowH = bottomPanelH - volRowH;
// Draw bottom panel background BEFORE cluster loop so bars render on top
const panelBaseY = height - bottomPanelH;
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();
ctx.strokeStyle = 'rgba(30, 41, 59, 0.8)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, panelBaseY + volRowH);
ctx.lineTo(width, panelBaseY + volRowH);
ctx.stroke();
// Draw Columns (Clusters) // Draw Columns (Clusters)
clusters.forEach((cluster, index) => { clusters.forEach((cluster, index) => {
@@ -174,165 +211,101 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
} }
} }
// Draw Cluster Header (Info Card at the top)
const headerY = getPriceY(highestPrice) - rowHeight - 35;
// Header Background
ctx.fillStyle = 'rgba(21, 27, 38, 0.85)';
ctx.strokeStyle = cluster.status === 'active' ? 'rgba(0, 230, 118, 0.4)' : '#2A364F';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(colX, headerY, colWidth, 45, 6);
ctx.fill();
ctx.stroke();
// Header Text // OHLC body range — levels outside = wicks (just a line)
ctx.fillStyle = '#94A3B8'; const bodyHigh = (cluster.open_price !== undefined && cluster.close_price !== undefined)
ctx.font = '10px JetBrains Mono, monospace'; ? Math.max(cluster.open_price, cluster.close_price)
ctx.textAlign = 'left'; : highestPrice;
const bodyLow = (cluster.open_price !== undefined && cluster.close_price !== undefined)
const pattern = cluster.advanced_metrics?.pattern; ? Math.min(cluster.open_price, cluster.close_price)
const divergence = cluster.advanced_metrics?.delta_divergence; : lowestPrice;
const isBull = cluster.close_price >= cluster.open_price;
let patternTag = '';
if (pattern === 'P') patternTag = '[P] ';
if (pattern === 'B') patternTag = '[B] ';
// Divergence Tag
if (divergence) patternTag += '⚠️ ';
// Volume & Delta
const volK = (cluster.total_volume || 0).toFixed(0);
const deltaStr = (cluster.total_delta >= 0 ? '+' : '') + (cluster.total_delta || 0).toFixed(0);
ctx.fillText(`${patternTag}VOL: ${volK}`, colX + 8, headerY + 18);
ctx.fillStyle = cluster.total_delta >= 0 ? '#00E676' : '#FF1744';
ctx.fillText(`DEL: ${deltaStr}`, colX + 8, headerY + 32);
// Time or reason // OHLC body border rectangle
ctx.fillStyle = '#64748B';
const timeStr = cluster.open_time ? new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--:--:--';
ctx.fillText(timeStr, colX + colWidth - 55, headerY + 18);
// Find max volume in this cluster to calculate relative opacities
const maxTotalVolumeInCluster = Math.max(...Object.values(levels).map(l => l.total || 1));
// OHLC Candlestick Skeleton (Background)
if (cluster.open_price !== undefined && cluster.close_price !== undefined) { if (cluster.open_price !== undefined && cluster.close_price !== undefined) {
const isBull = cluster.close_price >= cluster.open_price; const openY = getPriceY(cluster.open_price);
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.4)' : 'rgba(255, 23, 68, 0.4)'; const closeY = getPriceY(cluster.close_price);
ctx.fillStyle = isBull ? 'rgba(0, 230, 118, 0.1)' : 'rgba(255, 23, 68, 0.1)'; const candleTop = Math.min(openY, closeY) - rowHeight / 2;
ctx.lineWidth = 1; const candleBottom = Math.max(openY, closeY) + rowHeight / 2;
const openY = getPriceY(cluster.open_price); ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.55)' : 'rgba(255, 23, 68, 0.55)';
const closeY = getPriceY(cluster.close_price); ctx.lineWidth = 1.5;
const highY = getPriceY(highestPrice); ctx.strokeRect(colX - 2, candleTop, colWidth + 4, Math.max(2, candleBottom - candleTop));
const lowY = getPriceY(lowestPrice);
// Wick lines (thin center line above body to high, below body to low)
const candleTop = Math.min(openY, closeY) - rowHeight / 2; const highY = getPriceY(highestPrice);
const candleBottom = Math.max(openY, closeY) + rowHeight / 2; const lowY = getPriceY(lowestPrice);
const bodyHeight = Math.max(2, candleBottom - candleTop); ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.4)' : 'rgba(255, 23, 68, 0.4)';
ctx.lineWidth = 1;
// Draw Wick (Pavio) if (highestPrice > bodyHigh) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(colX + colWidth / 2, highY - rowHeight / 2); ctx.moveTo(colX + colWidth / 2, highY);
ctx.lineTo(colX + colWidth / 2, lowY + rowHeight / 2); ctx.lineTo(colX + colWidth / 2, candleTop);
ctx.stroke(); ctx.stroke();
}
// Draw Body Background if (lowestPrice < bodyLow) {
ctx.fillRect(colX - 4, candleTop, colWidth + 8, bodyHeight); ctx.beginPath();
ctx.strokeRect(colX - 4, candleTop, colWidth + 8, bodyHeight); ctx.moveTo(colX + colWidth / 2, candleBottom);
ctx.lineTo(colX + colWidth / 2, lowY + rowHeight);
ctx.stroke();
}
} }
// Draw Cells 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) => { pricesStr.forEach((priceStr, i) => {
const price = Number(priceStr); const price = Number(priceStr);
const cellData = levels[priceStr]; const cellData = levels[priceStr];
const cellY = getPriceY(price) - rowHeight / 2; const cellY = getPriceY(price) - rowHeight / 2;
// Skip if vertically out of bounds
if (cellY + rowHeight < 0 || cellY > height) return; 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 bid = cellData.bid || 0;
const ask = cellData.ask || 0; const ask = cellData.ask || 0;
const total = cellData.total || 0; const dominant = ask >= bid ? 'ask' : 'bid';
const dominantVal = Math.max(ask, bid);
// Dynamic Imbalance Calculation (Frontend)
const ratio = imbalanceRatio / 100.0;
let dynImbalance = null;
// ask vs lower bid
const lowerData = i + 1 < pricesStr.length ? levels[pricesStr[i + 1]] : null;
const lowerBid = lowerData ? (lowerData.bid || 0) : 0;
const isBuyImbalance = ask >= lowerBid * ratio && ask > 0;
// bid vs higher ask
const upperData = i - 1 >= 0 ? levels[pricesStr[i - 1]] : null;
const upperAsk = upperData ? (upperData.ask || 0) : 0;
const isSellImbalance = bid >= upperAsk * ratio && bid > 0;
if (isBuyImbalance && isSellImbalance) dynImbalance = 'both';
else if (isBuyImbalance) dynImbalance = 'buy';
else if (isSellImbalance) dynImbalance = 'sell';
// Volume-based opacity // Single proportional bar — dominant color only, grows left to right
const relOpacity = maxTotalVolumeInCluster > 0 ? (total / maxTotalVolumeInCluster) : 0; const barW = Math.min((dominantVal / maxVol) * colWidth, colWidth);
ctx.fillStyle = dominant === 'ask'
// Base fill color with opacity ? `rgba(236, 72, 153, ${alpha})`
let cellColor = `rgba(59, 130, 246, ${0.05 + relOpacity * 0.25})`; // Dark Slate Blue default : `rgba(59, 130, 246, ${alpha})`;
if (ask >= bid * ratio && ask > 0) { ctx.fillRect(colX, cellY + 1, barW, rowHeight - 3);
cellColor = `rgba(0, 230, 118, ${0.2 + relOpacity * 0.5})`; // Strong Green Heatmap (Horizontal)
} else if (bid >= ask * ratio && bid > 0) {
cellColor = `rgba(255, 23, 68, ${0.2 + relOpacity * 0.5})`; // Strong Red Heatmap (Horizontal)
} else if (dynImbalance === 'buy') {
cellColor = `rgba(0, 230, 118, ${0.1 + relOpacity * 0.35})`; // Neon Green tint
} else if (dynImbalance === 'sell') {
cellColor = `rgba(255, 23, 68, ${0.1 + relOpacity * 0.35})`; // Neon Red tint
} else if (dynImbalance === 'both') {
cellColor = `rgba(168, 85, 247, ${0.1 + relOpacity * 0.35})`; // Purple
}
ctx.fillStyle = cellColor;
ctx.fillRect(colX, cellY, colWidth, rowHeight - 2);
// Imbalance border outline // POC border (rectangle only at POC level)
if (dynImbalance === 'buy') { if (!isWick && price === cluster.poc) {
ctx.strokeStyle = 'rgba(0, 230, 118, 0.8)';
ctx.lineWidth = 1;
ctx.strokeRect(colX + 0.5, cellY + 0.5, colWidth - 1, rowHeight - 3);
} else if (dynImbalance === 'sell') {
ctx.strokeStyle = 'rgba(255, 23, 68, 0.8)';
ctx.lineWidth = 1;
ctx.strokeRect(colX + 0.5, cellY + 0.5, colWidth - 1, rowHeight - 3);
}
// Draw POC outline (Thick Gold Border)
if (price === cluster.poc) {
ctx.strokeStyle = '#FFD600'; ctx.strokeStyle = '#FFD600';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeRect(colX + 1, cellY + 1, colWidth - 2, rowHeight - 4); ctx.strokeRect(colX + 1, cellY + 1, colWidth - 2, rowHeight - 4);
} }
// Draw Text (Bid x Ask OR Delta) // Number only at POC (inside bar, right-aligned)
const fontSize = Math.max(6, 11 * zoom); if (zoom >= 0.5 && !isWick && price === cluster.poc) {
ctx.font = `${fontSize}px JetBrains Mono, monospace`; const cy = cellY + rowHeight / 2;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (dynImbalance === 'buy') {
ctx.fillStyle = '#00E676';
} else if (dynImbalance === 'sell') {
ctx.fillStyle = '#FF1744';
} else {
ctx.fillStyle = '#E2E8F0';
}
// Hide text if zoomed out too much to avoid clutter
if (zoom >= 0.5) {
if (viewMode === 'delta') { if (viewMode === 'delta') {
const deltaStr = (cellData.delta >= 0 ? '+' : '') + (cellData.delta || 0).toFixed(0); const d = cellData.delta || 0;
ctx.fillText(deltaStr, colX + colWidth / 2, cellY + rowHeight / 2); ctx.fillStyle = d >= 0 ? '#00E676' : '#FF1744';
ctx.textAlign = 'right';
ctx.fillText((d >= 0 ? '+' : '') + fmtK(d), colX + colWidth - 4, cy);
} else { } else {
ctx.fillText(`${bid.toFixed(0)} × ${ask.toFixed(0)}`, colX + colWidth / 2, cellY + rowHeight / 2); ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.textAlign = 'right';
ctx.fillText(fmtK(dominantVal), colX + colWidth - 4, cy);
} }
} }
}); });
@@ -384,28 +357,45 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
} }
} }
// Calculate Cumulative Delta for bottom panel // Bottom Panel — two rows: Volume (top) + Delta (bottom)
cumulativeDelta += (cluster.total_delta || 0); const panelY = height - bottomPanelH;
// Bottom Panel (Delta Histogram) // --- Volume row ---
const panelY = height - bottomPanelHeight; const totalVol = cluster.total_volume || 0;
const cvdBaseline = panelY + bottomPanelHeight / 2; const volBarMaxH = volRowH - 16;
const volBarH = Math.max(3, (totalVol / maxClusterVolume) * volBarMaxH);
// Delta cluster bar const volBarColor = (cluster.total_delta || 0) >= 0 ? 'rgba(30, 80, 150, 0.9)' : 'rgba(110, 30, 50, 0.9)';
const deltaVol = cluster.total_delta || 0; ctx.fillStyle = volBarColor;
const deltaColor = deltaVol >= 0 ? 'rgba(0, 230, 118, 0.7)' : 'rgba(255, 23, 68, 0.7)'; ctx.fillRect(colX + 2, panelY + volRowH - volBarH, colWidth - 4, volBarH);
ctx.fillStyle = deltaColor;
// Volume label above the bar
// Scale: 1000 volume = 20px const volLabel = totalVol >= 1000 ? (totalVol / 1000).toFixed(1) + 'K' : totalVol.toFixed(0);
const scaleFactor = 30 / 1000; ctx.fillStyle = '#CBD5E1';
const barH = Math.min(Math.abs(deltaVol) * scaleFactor, bottomPanelHeight / 2 - 5); ctx.font = 'bold 12px JetBrains Mono, monospace';
const startY = deltaVol >= 0 ? cvdBaseline - barH : cvdBaseline; ctx.textAlign = 'center';
ctx.fillRect(colX + 5, startY, colWidth - 10, barH); ctx.textBaseline = 'bottom';
ctx.fillText(volLabel, colX + colWidth / 2, panelY + volRowH - volBarH - 2);
// CVD line (Cumulative Delta)
ctx.fillStyle = cumulativeDelta >= 0 ? '#00E676' : '#FF1744'; // --- Delta row ---
ctx.font = '10px JetBrains Mono, monospace'; const delta = cluster.total_delta || 0;
ctx.fillText(`CVD: ${cumulativeDelta.toFixed(0)}`, colX + colWidth / 2, panelY + 15); ctx.fillStyle = delta >= 0 ? '#1565C0' : '#C84B00';
ctx.fillRect(colX + 1, panelY + volRowH + 1, colWidth - 2, deltaRowH - 2);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 12px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(delta.toFixed(0), colX + colWidth / 2, panelY + volRowH + deltaRowH / 2);
// Timestamp on X axis
if (cluster.open_time) {
const timeStr = new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
ctx.fillStyle = '#FFFFFF';
ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(timeStr, colX + colWidth / 2, height - 14);
}
}); });
@@ -534,6 +524,36 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20); ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20);
} }
// Current Price Line (latest close_price from active cluster)
const currentPrice = latestCluster.close_price;
if (currentPrice) {
const priceY = getPriceY(currentPrice);
if (priceY >= 0 && priceY <= chartHeight) {
// Horizontal line across chart (skip volume profile area)
ctx.strokeStyle = 'rgba(0, 229, 255, 0.85)';
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
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);
}
}
// Draw Vertical Price Axis (Right Side) // Draw Vertical Price Axis (Right Side)
ctx.fillStyle = 'rgba(11, 14, 20, 0.95)'; ctx.fillStyle = 'rgba(11, 14, 20, 0.95)';
ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight); ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight);
@@ -575,34 +595,6 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
ctx.fillText(`${roundedPrice.toFixed(decimals)}`, width - axisWidth + 8, labelY); ctx.fillText(`${roundedPrice.toFixed(decimals)}`, width - axisWidth + 8, labelY);
} }
// Bottom Panel separator
const panelY = height - bottomPanelHeight;
ctx.fillStyle = '#111827';
ctx.fillRect(0, panelY, width, bottomPanelHeight);
ctx.strokeStyle = '#1E293B';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, panelY);
ctx.lineTo(width, panelY);
ctx.stroke();
// Draw CVD zero line
const cvdBaseline = panelY + bottomPanelHeight / 2;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(0, cvdBaseline);
ctx.lineTo(width, cvdBaseline);
ctx.stroke();
ctx.setLineDash([]);
// CVD Label
ctx.fillStyle = '#94A3B8';
ctx.font = '12px Outfit, sans-serif';
ctx.textAlign = 'right';
ctx.fillText('CVD / Delta', width - 20, panelY + 20);
}, [clusters, scrollOffset]); }, [clusters, scrollOffset]);
@@ -611,21 +603,68 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
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
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); setIsDragging(true);
dragStart.current = { x, y }; dragStart.current = { x, y };
dragOffsetStart.current = { ...scrollOffset }; dragOffsetStart.current = { ...scrollOffset };
setCursor('grabbing');
}; };
const handleMouseMove = (e) => { const handleMouseMove = (e) => {
if (!isDragging) return;
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; 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 = axisDragStartY.current - y;
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 dx = x - dragStart.current.x;
const dy = y - dragStart.current.y; const dy = y - dragStart.current.y;
setScrollOffset({ setScrollOffset({
x: dragOffsetStart.current.x + dx, x: dragOffsetStart.current.x + dx,
y: dragOffsetStart.current.y + dy y: dragOffsetStart.current.y + dy
@@ -634,6 +673,9 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const handleMouseUp = () => { const handleMouseUp = () => {
setIsDragging(false); setIsDragging(false);
isDraggingAxis.current = false;
isDraggingTimeAxis.current = false;
setCursor('grab');
}; };
// Wheel interaction for scrolling and zooming // Wheel interaction for scrolling and zooming
@@ -656,7 +698,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
}; };
return ( return (
<div className="relative w-full h-full cursor-grab active:cursor-grabbing select-none overflow-hidden rounded-xl border border-slate-800 bg-darkBg shadow-2xl"> <div className="relative w-full h-full select-none overflow-hidden rounded-xl border border-slate-800 bg-darkBg shadow-2xl" style={{ cursor }}>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full block" className="w-full h-full block"
+5
View File
@@ -22,10 +22,15 @@ class AlertEngine {
processClusters(clusters, pushToast) { processClusters(clusters, pushToast) {
if (!clusters || clusters.length === 0) return; if (!clusters || clusters.length === 0) return;
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
clusters.forEach(cluster => { clusters.forEach(cluster => {
// Only alert on closed clusters that we haven't processed yet // Only alert on closed clusters that we haven't processed yet
if (cluster.status === 'closed' && cluster.open_time && !this.processedClusters.has(cluster.open_time)) { if (cluster.status === 'closed' && cluster.open_time && !this.processedClusters.has(cluster.open_time)) {
this.processedClusters.add(cluster.open_time); this.processedClusters.add(cluster.open_time);
// Skip historical clusters — only alert for recent data
if (cluster.open_time < fiveMinutesAgo) return;
const adv = cluster.advanced_metrics; const adv = cluster.advanced_metrics;
if (!adv) return; if (!adv) return;