visual: redesign bottom panel, price axis cleanup and bid/ask indicator
- Bottom panel: dual bid/ask bars per cluster (red=sell, blue=buy) with white volume label inside and delta block below - Price axis: labels every 10 rows (~7.5pts) instead of every row — cleaner, closer to original - Price axis: bid/ask spread boxes with arrow indicator (shows on live market open) - Price axis zoom: inverted so drag-up = zoom in, matching original behavior - Current price line rendered last (always on top of all bars) - Bar colors: corrected — ask (buy) = blue, bid (sell) = pink - Backend: close_time field added to cluster JSON; bar_volume M1 annotation (triggers after replay) - Backend: last_bid/ask tracked and injected into active cluster WS message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ class FootprintCluster:
|
||||
self.total_delta: float = 0.0
|
||||
self.total_volume: float = 0.0
|
||||
self.total_ticks: int = 0
|
||||
self.close_time: Optional[int] = None
|
||||
|
||||
# levels: price_float -> { 'ask': float, 'bid': float }
|
||||
# internally we keep float keys to make sorting and arithmetic easy
|
||||
@@ -40,6 +41,7 @@ class FootprintCluster:
|
||||
self.open_price = price
|
||||
|
||||
self.close_price = price
|
||||
self.close_time = timestamp_msc
|
||||
self.total_ticks += 1
|
||||
|
||||
# Round price to the nearest tick_size to avoid float precision issues
|
||||
@@ -316,9 +318,11 @@ class FootprintCluster:
|
||||
"high": float(self.high) if self.high is not None else None,
|
||||
"low": float(self.low) if self.low is not None else None,
|
||||
"poc": float(self.poc) if self.poc is not None else None,
|
||||
"close_time": int(self.close_time) if self.close_time is not None else None,
|
||||
"total_delta": float(self.total_delta),
|
||||
"total_volume": float(self.total_volume),
|
||||
"total_ticks": int(self.total_ticks),
|
||||
"bar_volume": None,
|
||||
"levels": levels_str,
|
||||
"stacked": {
|
||||
"buy": bool(self.stacked.get("buy", False)),
|
||||
|
||||
@@ -25,6 +25,9 @@ class MT5Collector:
|
||||
self.seen_ticks_buffer = set()
|
||||
self.last_mid_price = 0.0
|
||||
self.last_is_buy = True
|
||||
self.last_bid = 0.0
|
||||
self.last_ask = 0.0
|
||||
self._history_annotated = False
|
||||
|
||||
async def connect_mt5(self) -> bool:
|
||||
"""
|
||||
@@ -130,6 +133,11 @@ class MT5Collector:
|
||||
await self.disconnect_mt5()
|
||||
continue
|
||||
|
||||
# Annotate history once replay is complete (first under-full batch = caught up)
|
||||
if not self._history_annotated and len(ticks) < 1000:
|
||||
self._history_annotated = True
|
||||
await self.annotate_history_bar_volume()
|
||||
|
||||
if len(ticks) > 0:
|
||||
logger.info(f"Fetched {len(ticks)} ticks starting at {ticks[0]['time_msc']}")
|
||||
for tick in ticks:
|
||||
@@ -153,6 +161,8 @@ class MT5Collector:
|
||||
ask_price = float(tick['ask'])
|
||||
last_price = float(tick['last'])
|
||||
|
||||
if bid_price > 0: self.last_bid = bid_price
|
||||
if ask_price > 0: self.last_ask = ask_price
|
||||
mid_price = (bid_price + ask_price) / 2.0 if (bid_price > 0 and ask_price > 0) else 0.0
|
||||
prev_mid = self.last_mid_price
|
||||
|
||||
@@ -189,7 +199,22 @@ class MT5Collector:
|
||||
# flooding the WebSocket during historical replay
|
||||
import time as _time
|
||||
is_live = ((_time.time() * 1000) - msc) < 10_000
|
||||
|
||||
# Annotate live closed clusters with M1 bar volume
|
||||
if is_live and closed_json and closed_json.get('open_time') and closed_json.get('close_time'):
|
||||
bar_vol = await self.fetch_bar_volume(
|
||||
closed_json['open_time'], closed_json['close_time']
|
||||
)
|
||||
closed_json['bar_volume'] = bar_vol
|
||||
# Sync back to history buffer
|
||||
for h in self.aggregator.history:
|
||||
if h['cluster_id'] == closed_json['cluster_id']:
|
||||
h['bar_volume'] = bar_vol
|
||||
break
|
||||
|
||||
if is_live:
|
||||
active_json['bid'] = self.last_bid
|
||||
active_json['ask'] = self.last_ask
|
||||
self.on_update_callback(active_json, closed_json)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -200,6 +225,55 @@ class MT5Collector:
|
||||
await self.disconnect_mt5()
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
async def fetch_bar_volume(self, open_time_msc: int, close_time_msc: int) -> int:
|
||||
"""Sum tick_volume of M1 bars that overlap with the cluster's time range."""
|
||||
from datetime import datetime
|
||||
# Expand range by 1 minute on each side to capture partial bars
|
||||
open_dt = datetime.fromtimestamp((open_time_msc - 60_000) / 1000.0)
|
||||
close_dt = datetime.fromtimestamp((close_time_msc + 60_000) / 1000.0)
|
||||
rates = await asyncio.to_thread(
|
||||
mt5.copy_rates_range, self.symbol, mt5.TIMEFRAME_M1, open_dt, close_dt
|
||||
)
|
||||
if rates is None or len(rates) == 0:
|
||||
return 0
|
||||
total = 0
|
||||
for r in rates:
|
||||
bar_start_msc = int(r['time']) * 1000
|
||||
bar_end_msc = bar_start_msc + 60_000
|
||||
# Count bar if it overlaps with cluster period
|
||||
if bar_start_msc < close_time_msc and bar_end_msc > open_time_msc:
|
||||
total += int(r['tick_volume'])
|
||||
return total
|
||||
|
||||
async def annotate_history_bar_volume(self):
|
||||
"""Batch-fetch M1 bars and annotate all history clusters with bar_volume."""
|
||||
if not self.aggregator.history:
|
||||
return
|
||||
first_open = self.aggregator.history[0].get('open_time') or 0
|
||||
last_close = self.aggregator.history[-1].get('close_time') or self.aggregator.history[-1].get('open_time') or 0
|
||||
if not first_open or not last_close:
|
||||
return
|
||||
from datetime import datetime
|
||||
open_dt = datetime.fromtimestamp((first_open - 60_000) / 1000.0)
|
||||
close_dt = datetime.fromtimestamp((last_close + 60_000) / 1000.0)
|
||||
all_rates = await asyncio.to_thread(
|
||||
mt5.copy_rates_range, self.symbol, mt5.TIMEFRAME_M1, open_dt, close_dt
|
||||
)
|
||||
if all_rates is None or len(all_rates) == 0:
|
||||
logger.warning("annotate_history_bar_volume: no M1 bars returned")
|
||||
return
|
||||
for cluster in self.aggregator.history:
|
||||
c_open = cluster.get('open_time') or 0
|
||||
c_close = cluster.get('close_time') or c_open
|
||||
total = 0
|
||||
for r in all_rates:
|
||||
bar_start_msc = int(r['time']) * 1000
|
||||
bar_end_msc = bar_start_msc + 60_000
|
||||
if bar_start_msc < c_close and bar_end_msc > c_open:
|
||||
total += int(r['tick_volume'])
|
||||
cluster['bar_volume'] = total
|
||||
logger.info(f"annotate_history_bar_volume: annotated {len(self.aggregator.history)} clusters")
|
||||
|
||||
async def stop(self):
|
||||
self.running = False
|
||||
await self.disconnect_mt5()
|
||||
|
||||
@@ -11,6 +11,8 @@ export default function App() {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [activeCluster, setActiveCluster] = useState(null);
|
||||
const [lastTickTime, setLastTickTime] = useState(null);
|
||||
const [lastBid, setLastBid] = useState(0);
|
||||
const [lastAsk, setLastAsk] = useState(0);
|
||||
|
||||
// Phase 2 & 4 Settings
|
||||
const [stepMultiplier, setStepMultiplier] = useState(75);
|
||||
@@ -79,6 +81,8 @@ export default function App() {
|
||||
fetchHistory(); // Sync history on connection
|
||||
} else if (msg.type === 'tick') {
|
||||
setActiveCluster(msg.active);
|
||||
if (msg.active?.bid) setLastBid(msg.active.bid);
|
||||
if (msg.active?.ask) setLastAsk(msg.active.ask);
|
||||
// If a cluster has just closed, we receive the closed state
|
||||
if (msg.closed) {
|
||||
setHistory(prev => {
|
||||
@@ -179,6 +183,8 @@ export default function App() {
|
||||
viewMode={viewMode}
|
||||
imbalanceRatio={imbalanceRatio}
|
||||
onStepChange={setStepMultiplier}
|
||||
bid={lastBid}
|
||||
ask={lastAsk}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
|
||||
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange }) {
|
||||
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
|
||||
@@ -109,17 +109,21 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
return basePrice + (diffTicks * actualTickSize);
|
||||
};
|
||||
|
||||
// Cumulative Delta Tracking
|
||||
// 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;
|
||||
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
|
||||
const panelBaseY = height - bottomPanelH;
|
||||
ctx.fillStyle = '#0B0E14';
|
||||
ctx.fillRect(0, panelBaseY, width, bottomPanelH);
|
||||
ctx.strokeStyle = '#1E293B';
|
||||
@@ -128,12 +132,6 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
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)
|
||||
clusters.forEach((cluster, index) => {
|
||||
@@ -282,8 +280,8 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
// Single proportional bar — dominant color only, grows left to right
|
||||
const barW = Math.min((dominantVal / maxVol) * colWidth, colWidth);
|
||||
ctx.fillStyle = dominant === 'ask'
|
||||
? `rgba(236, 72, 153, ${alpha})`
|
||||
: `rgba(59, 130, 246, ${alpha})`;
|
||||
? `rgba(59, 130, 246, ${alpha})`
|
||||
: `rgba(236, 72, 153, ${alpha})`;
|
||||
ctx.fillRect(colX, cellY + 1, barW, rowHeight - 3);
|
||||
|
||||
|
||||
@@ -357,44 +355,52 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom Panel — two rows: Volume (top) + Delta (bottom)
|
||||
const panelY = height - bottomPanelH;
|
||||
// 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);
|
||||
|
||||
// --- Volume row ---
|
||||
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 — white, inside the bars at the bottom
|
||||
const totalVol = cluster.total_volume || 0;
|
||||
const volBarMaxH = volRowH - 16;
|
||||
const volBarH = Math.max(3, (totalVol / maxClusterVolume) * volBarMaxH);
|
||||
const volBarColor = (cluster.total_delta || 0) >= 0 ? 'rgba(30, 80, 150, 0.9)' : 'rgba(110, 30, 50, 0.9)';
|
||||
ctx.fillStyle = volBarColor;
|
||||
ctx.fillRect(colX + 2, panelY + volRowH - volBarH, colWidth - 4, volBarH);
|
||||
|
||||
// Volume label above the bar
|
||||
const volLabel = totalVol >= 1000 ? (totalVol / 1000).toFixed(1) + 'K' : totalVol.toFixed(0);
|
||||
ctx.fillStyle = '#CBD5E1';
|
||||
ctx.font = 'bold 12px JetBrains Mono, monospace';
|
||||
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, panelY + volRowH - volBarH - 2);
|
||||
|
||||
// --- Delta row ---
|
||||
const delta = cluster.total_delta || 0;
|
||||
ctx.fillStyle = delta >= 0 ? '#1565C0' : '#C84B00';
|
||||
ctx.fillRect(colX + 1, panelY + volRowH + 1, colWidth - 2, deltaRowH - 2);
|
||||
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 12px JetBrains Mono, monospace';
|
||||
ctx.font = 'bold 10px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(delta.toFixed(0), colX + colWidth / 2, panelY + volRowH + deltaRowH / 2);
|
||||
ctx.fillText(delta.toFixed(0), colX + colWidth / 2, deltaBlockY + deltaBlockH / 2);
|
||||
|
||||
// Timestamp on X axis
|
||||
// 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 = '#FFFFFF';
|
||||
ctx.fillStyle = '#94A3B8';
|
||||
ctx.font = '9px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(timeStr, colX + colWidth / 2, height - 14);
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(timeStr, colX + colWidth / 2, height - 2);
|
||||
}
|
||||
|
||||
});
|
||||
@@ -524,36 +530,6 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
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)
|
||||
ctx.fillStyle = 'rgba(11, 14, 20, 0.95)';
|
||||
ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight);
|
||||
@@ -565,36 +541,110 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
ctx.lineTo(width - axisWidth, chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// Render price tags along the axis
|
||||
ctx.fillStyle = '#94A3B8';
|
||||
// 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';
|
||||
|
||||
// Step every rowHeight
|
||||
const startY = 0;
|
||||
const endY = chartHeight;
|
||||
|
||||
// Draw tick labels on axis
|
||||
for (let y = startY; y < endY; y += rowHeight) {
|
||||
const price = getYPrice(y);
|
||||
// Align price to tickSize
|
||||
const roundedPrice = Math.round(price / tickSize) * tickSize;
|
||||
const labelY = getPriceY(roundedPrice);
|
||||
|
||||
// Draw grid line connection to axis
|
||||
ctx.strokeStyle = 'rgba(30, 41, 59, 0.5)';
|
||||
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();
|
||||
|
||||
const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2;
|
||||
|
||||
// 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(`${roundedPrice.toFixed(decimals)}`, width - axisWidth + 8, labelY);
|
||||
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]);
|
||||
|
||||
@@ -644,7 +694,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
|
||||
// Price axis drag → adjust stepMultiplier
|
||||
if (isDraggingAxis.current && onStepChange) {
|
||||
const dy = axisDragStartY.current - y;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user