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