Add volume pane to ticker chart
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 58s
Deploy / deploy (push) Successful in 32s

This commit is contained in:
2026-07-03 08:09:27 +02:00
parent 7fd34d6de8
commit 2b0068ae08
2 changed files with 97 additions and 35 deletions
@@ -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}
+2 -2
View File
@@ -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'}