Add volume pane to ticker chart
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatDate } from '../../lib/format';
|
||||
import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format';
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: OHLCVBar[];
|
||||
@@ -50,6 +50,9 @@ interface TooltipState {
|
||||
}
|
||||
|
||||
const MIN_VISIBLE_BARS = 10;
|
||||
const CHART_HEIGHT = 440;
|
||||
const VOLUME_PANE_HEIGHT = 72;
|
||||
const PANE_GAP = 18;
|
||||
|
||||
type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All';
|
||||
const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All'];
|
||||
@@ -109,7 +112,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const W = rect.width;
|
||||
const H = 400;
|
||||
const H = CHART_HEIGHT;
|
||||
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
@@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Margins
|
||||
const ml = 12, mr = 70, mt = 12, mb = 32;
|
||||
const cw = W - ml - mr;
|
||||
const ch = H - mt - mb;
|
||||
const volumeH = VOLUME_PANE_HEIGHT;
|
||||
const ch = H - mt - mb - volumeH - PANE_GAP;
|
||||
const priceBottom = mt + ch;
|
||||
const volumeTop = priceBottom + PANE_GAP;
|
||||
const volumeBottom = volumeTop + volumeH;
|
||||
|
||||
// Current price = explicit prop, else latest close
|
||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||
@@ -145,6 +152,9 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch;
|
||||
const barW = cw / visibleData.length;
|
||||
const candleW = Math.max(barW * 0.65, 1);
|
||||
const volumeW = Math.max(barW * 0.65, 1);
|
||||
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
||||
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
||||
|
||||
// Grid lines (horizontal)
|
||||
const nTicks = 6;
|
||||
@@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
// Volume pane
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, volumeTop - 9);
|
||||
ctx.lineTo(ml + cw, volumeTop - 9);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, volumeBottom);
|
||||
ctx.lineTo(ml + cw, volumeBottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.fillStyle = '#6b7280';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('Volume', ml, volumeTop - 13);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4);
|
||||
|
||||
visibleData.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
const bullish = bar.close >= bar.open;
|
||||
const yVolume = volumeScale(bar.volume);
|
||||
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
|
||||
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
|
||||
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
|
||||
});
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
@@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
});
|
||||
|
||||
// Store geometry for hit testing (includes visibleRange offset)
|
||||
(canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start };
|
||||
(canvas as any).__chartMeta = {
|
||||
ml,
|
||||
mr,
|
||||
mt,
|
||||
mb,
|
||||
cw,
|
||||
ch,
|
||||
barW,
|
||||
lo,
|
||||
hi,
|
||||
yScale,
|
||||
visibleStart: start,
|
||||
volumeTop,
|
||||
volumeH,
|
||||
volumeBottom,
|
||||
};
|
||||
|
||||
// Size the overlay canvas to match
|
||||
const overlay = overlayCanvasRef.current;
|
||||
@@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
const meta = (canvas as any).__chartMeta;
|
||||
if (!meta) return;
|
||||
|
||||
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta;
|
||||
const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta;
|
||||
const H = overlay.height / dpr;
|
||||
const priceBottom = mt + ch;
|
||||
const chartBottom = volumeBottom ?? priceBottom;
|
||||
|
||||
// Clamp crosshair to chart area
|
||||
const cx = Math.max(ml, Math.min(ml + cw, pos.x));
|
||||
const cy = Math.max(mt, Math.min(mt + ch, pos.y));
|
||||
const cy = Math.max(mt, Math.min(chartBottom, pos.y));
|
||||
|
||||
// Dashed crosshair lines
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
@@ -357,37 +412,44 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, mt);
|
||||
ctx.lineTo(cx, mt + ch);
|
||||
ctx.stroke();
|
||||
|
||||
// Horizontal line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, cy);
|
||||
ctx.lineTo(ml + cw, cy);
|
||||
ctx.lineTo(cx, chartBottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Price label on y-axis (right side)
|
||||
const price = hi - ((cy - mt) / ch) * (hi - lo);
|
||||
const priceText = formatPrice(price);
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
const priceMetrics = ctx.measureText(priceText);
|
||||
const labelPadX = 5;
|
||||
const labelPadY = 3;
|
||||
const labelW = priceMetrics.width + labelPadX * 2;
|
||||
const labelH = 16 + labelPadY * 2;
|
||||
const labelX = ml + cw + 2;
|
||||
const labelY = cy - labelH / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceText, labelX + labelPadX, cy);
|
||||
if (cy <= priceBottom) {
|
||||
// Horizontal price crosshair only belongs in the price pane.
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, cy);
|
||||
ctx.lineTo(ml + cw, cy);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Price label on y-axis (right side)
|
||||
const price = hi - ((cy - mt) / ch) * (hi - lo);
|
||||
const priceText = formatPrice(price);
|
||||
const priceMetrics = ctx.measureText(priceText);
|
||||
const labelW = priceMetrics.width + labelPadX * 2;
|
||||
const labelH = 16 + labelPadY * 2;
|
||||
const labelX = ml + cw + 2;
|
||||
const labelY = cy - labelH / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#e5e7eb';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceText, labelX + labelPadX, cy);
|
||||
}
|
||||
|
||||
// Date label on x-axis (bottom)
|
||||
const localIdx = Math.floor((cx - ml) / barW);
|
||||
@@ -619,7 +681,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
<span>High</span><span class="text-right text-gray-200">${formatPrice(bar.high)}</span>
|
||||
<span>Low</span><span class="text-right text-gray-200">${formatPrice(bar.low)}</span>
|
||||
<span>Close</span><span class="text-right text-gray-200">${formatPrice(bar.close)}</span>
|
||||
<span>Vol</span><span class="text-right text-gray-200">${bar.volume.toLocaleString()}</span>
|
||||
<span>Vol</span><span class="text-right text-gray-200" title="${bar.volume.toLocaleString()}">${formatLargeNumber(bar.volume)}</span>
|
||||
</div>${tradeTooltipHtml}`;
|
||||
} else {
|
||||
tip.style.display = 'none';
|
||||
@@ -670,16 +732,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
|
||||
))}
|
||||
<span className="ml-1 text-[10px] text-gray-600">scroll to zoom · drag to pan</span>
|
||||
</div>
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: 400 }}>
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: CHART_HEIGHT }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full"
|
||||
style={{ height: 400 }}
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
/>
|
||||
<canvas
|
||||
ref={overlayCanvasRef}
|
||||
className="absolute top-0 left-0 w-full cursor-crosshair"
|
||||
style={{ height: 400 }}
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
|
||||
@@ -346,8 +346,8 @@ export default function TickerDetailPage() {
|
||||
/>
|
||||
|
||||
{/* Chart — always visible */}
|
||||
<Section title="Price Chart">
|
||||
{ohlcv.isLoading && <SkeletonCard className="h-[400px]" />}
|
||||
<Section title="Price & Volume">
|
||||
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
||||
{ohlcv.isError && (
|
||||
<SectionError
|
||||
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
|
||||
|
||||
Reference in New Issue
Block a user