731 lines
24 KiB
TypeScript
731 lines
24 KiB
TypeScript
import type { ChartViewModel, ChartTheme, Candle, ChartDimensions, MeasurePoint, ChartIndicator } from './chartTypes';
|
|
import { DEFAULT_CHART_THEME, CHART_LAYOUT } from './chartTypes';
|
|
import { getIndicatorData, type IndicatorData } from './indicatorUtils';
|
|
|
|
interface RenderContext {
|
|
ctx: CanvasRenderingContext2D;
|
|
viewModel: ChartViewModel;
|
|
theme: ChartTheme;
|
|
dpr: number;
|
|
getXForIndex: (index: number) => number;
|
|
getYForPrice: (price: number) => number;
|
|
getYForVolume: (volume: number) => number;
|
|
indicators: ChartIndicator[];
|
|
}
|
|
|
|
/**
|
|
* Main chart renderer.
|
|
* Draws all chart elements to a canvas context.
|
|
*/
|
|
export function renderChart(
|
|
ctx: CanvasRenderingContext2D,
|
|
viewModel: ChartViewModel,
|
|
theme: ChartTheme = DEFAULT_CHART_THEME,
|
|
dpr: number,
|
|
getXForIndex: (index: number) => number,
|
|
getYForPrice: (price: number) => number,
|
|
getYForVolume: (volume: number) => number,
|
|
indicators: ChartIndicator[] = []
|
|
): void {
|
|
const { chartDimensions } = viewModel;
|
|
const renderCtx: RenderContext = {
|
|
ctx,
|
|
viewModel,
|
|
theme,
|
|
dpr,
|
|
getXForIndex,
|
|
getYForPrice,
|
|
getYForVolume,
|
|
indicators,
|
|
};
|
|
|
|
// Clear canvas
|
|
ctx.fillStyle = theme.background;
|
|
ctx.fillRect(0, 0, chartDimensions.width * dpr, chartDimensions.height * dpr);
|
|
|
|
// Scale for DPR
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// Draw layers in order
|
|
drawGrid(renderCtx);
|
|
drawVolumeSection(renderCtx);
|
|
drawSubChartOverlay(renderCtx);
|
|
drawCandlesticks(renderCtx);
|
|
drawIndicators(renderCtx);
|
|
drawPriceAxis(renderCtx);
|
|
drawTimeAxis(renderCtx);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Renders the crosshair overlay.
|
|
* Uses a separate canvas for smooth hover updates.
|
|
*/
|
|
export function renderCrosshair(
|
|
ctx: CanvasRenderingContext2D,
|
|
viewModel: ChartViewModel,
|
|
mouseX: number,
|
|
mouseY: number,
|
|
theme: ChartTheme = DEFAULT_CHART_THEME,
|
|
dpr: number,
|
|
getXForIndex: (index: number) => number,
|
|
getYForPrice: (price: number) => number
|
|
): void {
|
|
const { chartDimensions, hoveredIndex, candles } = viewModel;
|
|
|
|
// Clear overlay
|
|
ctx.clearRect(0, 0, chartDimensions.width * dpr, chartDimensions.height * dpr);
|
|
|
|
if (hoveredIndex === null || hoveredIndex >= candles.length) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const candle = candles[hoveredIndex];
|
|
const x = getXForIndex(hoveredIndex);
|
|
const y = mouseY;
|
|
|
|
// Draw vertical line
|
|
ctx.strokeStyle = theme.crosshair;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, chartDimensions.paddingTop);
|
|
ctx.lineTo(x, chartDimensions.height - chartDimensions.paddingBottom);
|
|
ctx.stroke();
|
|
|
|
// Draw horizontal line
|
|
ctx.beginPath();
|
|
ctx.moveTo(chartDimensions.paddingLeft, y);
|
|
ctx.lineTo(chartDimensions.width - chartDimensions.paddingRight, y);
|
|
ctx.stroke();
|
|
|
|
ctx.setLineDash([]);
|
|
|
|
// Draw highlighted candle outline
|
|
const candleWidth = viewModel.candleLayout.candleWidth;
|
|
const candleX = x - candleWidth / 2;
|
|
const openY = getYForPrice(candle.open);
|
|
const closeY = getYForPrice(candle.close);
|
|
const highY = getYForPrice(candle.high);
|
|
const lowY = getYForPrice(candle.low);
|
|
const bodyTop = Math.min(openY, closeY);
|
|
const bodyHeight = Math.abs(closeY - openY) || 1;
|
|
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(candleX - 1, bodyTop - 1, candleWidth + 2, bodyHeight + 2);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawGrid(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, theme } = renderCtx;
|
|
const { chartDimensions, priceRange } = viewModel;
|
|
const { paddingLeft, paddingRight, paddingTop, priceChartHeight } = chartDimensions;
|
|
|
|
const chartWidth = chartDimensions.width - paddingLeft - paddingRight;
|
|
|
|
// Draw horizontal price grid lines
|
|
const priceStep = calculateNiceStep(priceRange.max - priceRange.min, 6);
|
|
const startPrice = Math.ceil(priceRange.min / priceStep) * priceStep;
|
|
|
|
ctx.strokeStyle = theme.gridLine;
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let price = startPrice; price <= priceRange.max; price += priceStep) {
|
|
const y = renderCtx.getYForPrice(price);
|
|
ctx.beginPath();
|
|
ctx.moveTo(paddingLeft, y);
|
|
ctx.lineTo(chartDimensions.width - paddingRight, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw vertical time grid lines (simplified)
|
|
const visibleCandles = viewModel.visibleEndIndex - viewModel.visibleStartIndex + 1;
|
|
const gridInterval = Math.ceil(visibleCandles / 8);
|
|
|
|
for (let i = viewModel.visibleStartIndex; i <= viewModel.visibleEndIndex; i += gridInterval) {
|
|
const x = renderCtx.getXForIndex(i);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, paddingTop);
|
|
ctx.lineTo(x, chartDimensions.height - chartDimensions.paddingBottom);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawIndicators(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, indicators, getXForIndex, getYForPrice } = renderCtx;
|
|
const { candles, visibleStartIndex, visibleEndIndex } = viewModel;
|
|
|
|
if (!indicators || indicators.length === 0) return;
|
|
|
|
// Draw price-based indicators (SMA, EMA, Bollinger Bands)
|
|
for (const indicator of indicators) {
|
|
if (!indicator.enabled) continue;
|
|
if (indicator.type === 'rsi' || indicator.type === 'macd' || indicator.type === 'volume') continue;
|
|
|
|
const data = getIndicatorData(indicator, candles);
|
|
if (data.length === 0) continue;
|
|
|
|
ctx.lineWidth = 1.5;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
|
|
if (indicator.type === 'boll') {
|
|
// Draw Bollinger Bands (middle, upper, lower)
|
|
// Draw upper and lower bands with fill
|
|
ctx.fillStyle = `${indicator.color}15`;
|
|
ctx.beginPath();
|
|
|
|
let firstPoint = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const upperY = getYForPrice(point.values[1]);
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, upperY);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, upperY);
|
|
}
|
|
}
|
|
|
|
// Go back drawing lower band
|
|
for (let i = data.length - 1; i >= 0; i--) {
|
|
const point = data[i];
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const lowerY = getYForPrice(point.values[2]);
|
|
ctx.lineTo(x, lowerY);
|
|
}
|
|
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Draw upper band line
|
|
ctx.strokeStyle = `${indicator.color}80`;
|
|
ctx.beginPath();
|
|
firstPoint = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getYForPrice(point.values[1]);
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, y);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Draw lower band line
|
|
ctx.beginPath();
|
|
firstPoint = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getYForPrice(point.values[2]);
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, y);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Draw middle band line
|
|
ctx.strokeStyle = indicator.color;
|
|
ctx.beginPath();
|
|
firstPoint = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getYForPrice(point.values[0]);
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, y);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
} else {
|
|
// Draw single line indicator (SMA, EMA)
|
|
ctx.strokeStyle = indicator.color;
|
|
ctx.beginPath();
|
|
|
|
let firstPoint = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getYForPrice(point.values[0]);
|
|
if (firstPoint) {
|
|
ctx.moveTo(x, y);
|
|
firstPoint = false;
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawCandlesticks(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, theme } = renderCtx;
|
|
const { candles, visibleStartIndex, visibleEndIndex, candleLayout } = viewModel;
|
|
|
|
const { candleWidth } = candleLayout;
|
|
const wickWidth = Math.max(1, candleWidth / 6);
|
|
|
|
// Only draw visible candles
|
|
for (let i = visibleStartIndex; i <= visibleEndIndex; i++) {
|
|
if (i < 0 || i >= candles.length) continue;
|
|
|
|
const candle = candles[i];
|
|
const x = renderCtx.getXForIndex(i);
|
|
const isBullish = candle.close >= candle.open;
|
|
|
|
const openY = renderCtx.getYForPrice(candle.open);
|
|
const closeY = renderCtx.getYForPrice(candle.close);
|
|
const highY = renderCtx.getYForPrice(candle.high);
|
|
const lowY = renderCtx.getYForPrice(candle.low);
|
|
|
|
// Colors
|
|
const bodyColor = isBullish ? theme.bullishBody : theme.bearishBody;
|
|
const wickColor = isBullish ? theme.bullishWick : theme.bearishWick;
|
|
|
|
// Draw wick
|
|
ctx.fillStyle = wickColor;
|
|
const wickX = x - wickWidth / 2;
|
|
ctx.fillRect(wickX, highY, wickWidth, lowY - highY);
|
|
|
|
// Draw body
|
|
ctx.fillStyle = bodyColor;
|
|
const bodyTop = Math.min(openY, closeY);
|
|
const bodyHeight = Math.max(1, Math.abs(closeY - openY)); // Ensure minimum 1px height
|
|
const bodyX = x - candleWidth / 2;
|
|
ctx.fillRect(bodyX, bodyTop, candleWidth, bodyHeight);
|
|
}
|
|
}
|
|
|
|
function drawVolumeSection(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, theme } = renderCtx;
|
|
const { candles, visibleStartIndex, visibleEndIndex, candleLayout, chartDimensions, volumeRange } = viewModel;
|
|
|
|
const { candleWidth } = candleLayout;
|
|
const volumeTop = chartDimensions.paddingTop + chartDimensions.priceChartHeight + CHART_LAYOUT.VOLUME_SECTION_GAP;
|
|
const volumeBottom = chartDimensions.height - chartDimensions.paddingBottom;
|
|
const volumeHeight = volumeBottom - volumeTop;
|
|
const volumeMiddle = volumeTop + volumeHeight / 2;
|
|
|
|
// Draw separator line
|
|
ctx.strokeStyle = theme.gridLineMajor;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(chartDimensions.paddingLeft, volumeTop - CHART_LAYOUT.VOLUME_SECTION_GAP / 2);
|
|
ctx.lineTo(chartDimensions.width - chartDimensions.paddingRight, volumeTop - CHART_LAYOUT.VOLUME_SECTION_GAP / 2);
|
|
ctx.stroke();
|
|
|
|
// Draw center line for volume
|
|
ctx.strokeStyle = theme.gridLine;
|
|
ctx.beginPath();
|
|
ctx.moveTo(chartDimensions.paddingLeft, volumeMiddle);
|
|
ctx.lineTo(chartDimensions.width - chartDimensions.paddingRight, volumeMiddle);
|
|
ctx.stroke();
|
|
|
|
// Draw volume bars - green goes up, red goes down from center
|
|
for (let i = visibleStartIndex; i <= visibleEndIndex; i++) {
|
|
if (i < 0 || i >= candles.length) continue;
|
|
|
|
const candle = candles[i];
|
|
const x = renderCtx.getXForIndex(i);
|
|
const isBullish = candle.close >= candle.open;
|
|
const barX = x - candleWidth / 2;
|
|
|
|
// Calculate bar height as ratio of max volume, scaled to half the section height
|
|
const volumeRatio = volumeRange.max > 0 ? candle.volume / volumeRange.max : 0;
|
|
const barHeight = volumeRatio * (volumeHeight / 2);
|
|
|
|
ctx.fillStyle = isBullish ? theme.volumeBullish : theme.volumeBearish;
|
|
|
|
if (isBullish) {
|
|
// Green bars grow upward from middle
|
|
ctx.fillRect(barX, volumeMiddle - barHeight, candleWidth, barHeight);
|
|
} else {
|
|
// Red bars grow downward from middle
|
|
ctx.fillRect(barX, volumeMiddle, candleWidth, barHeight);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawPriceAxis(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, theme } = renderCtx;
|
|
const { chartDimensions, priceRange } = viewModel;
|
|
|
|
const axisX = chartDimensions.width - chartDimensions.paddingRight + 8;
|
|
|
|
ctx.fillStyle = theme.axisText;
|
|
ctx.font = '11px ui-monospace, monospace';
|
|
ctx.textAlign = 'left';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
const priceStep = calculateNiceStep(priceRange.max - priceRange.min, 6);
|
|
const startPrice = Math.ceil(priceRange.min / priceStep) * priceStep;
|
|
|
|
for (let price = startPrice; price <= priceRange.max; price += priceStep) {
|
|
const y = renderCtx.getYForPrice(price);
|
|
ctx.fillText(formatPrice(price), axisX, y);
|
|
}
|
|
}
|
|
|
|
function drawTimeAxis(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, theme } = renderCtx;
|
|
const { candles, visibleStartIndex, visibleEndIndex, chartDimensions } = viewModel;
|
|
|
|
const axisY = chartDimensions.height - chartDimensions.paddingBottom + 16;
|
|
|
|
ctx.fillStyle = theme.axisText;
|
|
ctx.font = '11px ui-monospace, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'top';
|
|
|
|
const visibleCandles = visibleEndIndex - visibleStartIndex + 1;
|
|
const labelInterval = Math.ceil(visibleCandles / 6);
|
|
|
|
for (let i = visibleStartIndex; i <= visibleEndIndex; i += labelInterval) {
|
|
if (i < 0 || i >= candles.length) continue;
|
|
|
|
const candle = candles[i];
|
|
const x = renderCtx.getXForIndex(i);
|
|
const date = new Date(candle.time);
|
|
const label = formatTimeLabel(date);
|
|
ctx.fillText(label, x, axisY);
|
|
}
|
|
}
|
|
|
|
function drawSubChartOverlay(renderCtx: RenderContext): void {
|
|
const { ctx, viewModel, theme, indicators, getXForIndex } = renderCtx;
|
|
const { candles, visibleStartIndex, visibleEndIndex, chartDimensions, volumeRange, candleLayout } = viewModel;
|
|
|
|
const volumeTop = chartDimensions.paddingTop + chartDimensions.priceChartHeight + CHART_LAYOUT.VOLUME_SECTION_GAP;
|
|
const volumeHeight = chartDimensions.volumeChartHeight;
|
|
const chartLeft = chartDimensions.paddingLeft;
|
|
const chartRight = chartDimensions.width - chartDimensions.paddingRight;
|
|
const axisX = chartDimensions.paddingLeft - 6;
|
|
|
|
const hasSubChartIndicator = indicators?.some(
|
|
i => i.enabled && (i.type === 'rsi' || i.type === 'macd')
|
|
);
|
|
|
|
// Volume axis labels (left side)
|
|
ctx.font = '9px ui-monospace, monospace';
|
|
ctx.fillStyle = theme.axisText;
|
|
ctx.textAlign = 'right';
|
|
|
|
if (hasSubChartIndicator) {
|
|
// When sub-chart indicator active, show volume inline (top-right)
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText('Vol ' + formatVolume(volumeRange.max), chartRight, volumeTop + 2);
|
|
} else {
|
|
// No sub-chart indicator — volume labels on left axis
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(formatVolume(volumeRange.max), axisX, volumeTop + 2);
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText('0', axisX, volumeTop + volumeHeight - 2);
|
|
}
|
|
|
|
if (!indicators || indicators.length === 0) return;
|
|
|
|
for (const indicator of indicators) {
|
|
if (!indicator.enabled) continue;
|
|
|
|
if (indicator.type === 'rsi') {
|
|
const data = getIndicatorData(indicator, candles);
|
|
if (data.length === 0) continue;
|
|
|
|
const getY = (v: number) => volumeTop + volumeHeight * (1 - v / 100);
|
|
|
|
// Overbought / oversold reference lines (30, 70)
|
|
ctx.strokeStyle = `${indicator.color}25`;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 3]);
|
|
for (const level of [30, 70]) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(chartLeft, getY(level));
|
|
ctx.lineTo(chartRight, getY(level));
|
|
ctx.stroke();
|
|
}
|
|
ctx.setLineDash([]);
|
|
|
|
// RSI line
|
|
ctx.strokeStyle = indicator.color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.beginPath();
|
|
let first = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getY(point.values[0]);
|
|
if (first) { ctx.moveTo(x, y); first = false; }
|
|
else { ctx.lineTo(x, y); }
|
|
}
|
|
ctx.stroke();
|
|
|
|
// RSI axis labels (left)
|
|
ctx.font = '9px ui-monospace, monospace';
|
|
ctx.textAlign = 'right';
|
|
ctx.fillStyle = indicator.color;
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText('30', axisX, getY(30));
|
|
ctx.fillText('70', axisX, getY(70));
|
|
ctx.fillStyle = `${indicator.color}60`;
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText('100', axisX, getY(100));
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillText('0', axisX, getY(0));
|
|
|
|
} else if (indicator.type === 'macd') {
|
|
const data = getIndicatorData(indicator, candles);
|
|
if (data.length === 0) continue;
|
|
|
|
// Compute range from visible data
|
|
let dataMin = Infinity, dataMax = -Infinity;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
for (const v of point.values) {
|
|
dataMin = Math.min(dataMin, v);
|
|
dataMax = Math.max(dataMax, v);
|
|
}
|
|
}
|
|
if (dataMin === Infinity) continue;
|
|
|
|
// Symmetric range centered on 0
|
|
const absMax = Math.max(Math.abs(dataMin), Math.abs(dataMax)) * 1.15 || 1;
|
|
const getY = (v: number) => {
|
|
const ratio = (v + absMax) / (2 * absMax);
|
|
return volumeTop + volumeHeight * (1 - ratio);
|
|
};
|
|
|
|
// Zero line
|
|
ctx.strokeStyle = `${indicator.color}25`;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(chartLeft, getY(0));
|
|
ctx.lineTo(chartRight, getY(0));
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
// Histogram bars
|
|
const barWidth = Math.max(1, candleLayout.candleWidth * 0.5);
|
|
const y0 = getY(0);
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const hist = point.values[2];
|
|
const x = getXForIndex(point.index);
|
|
const yH = getY(hist);
|
|
ctx.fillStyle = hist >= 0 ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)';
|
|
ctx.fillRect(x - barWidth / 2, Math.min(y0, yH), barWidth, Math.abs(yH - y0));
|
|
}
|
|
|
|
// MACD line
|
|
ctx.strokeStyle = indicator.color;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.beginPath();
|
|
let first = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getY(point.values[0]);
|
|
if (first) { ctx.moveTo(x, y); first = false; }
|
|
else { ctx.lineTo(x, y); }
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Signal line (amber)
|
|
ctx.strokeStyle = '#f59e0b';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
first = true;
|
|
for (const point of data) {
|
|
if (point.index < visibleStartIndex || point.index > visibleEndIndex) continue;
|
|
const x = getXForIndex(point.index);
|
|
const y = getY(point.values[1]);
|
|
if (first) { ctx.moveTo(x, y); first = false; }
|
|
else { ctx.lineTo(x, y); }
|
|
}
|
|
ctx.stroke();
|
|
|
|
// MACD axis labels (left)
|
|
ctx.font = '9px ui-monospace, monospace';
|
|
ctx.textAlign = 'right';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = indicator.color;
|
|
ctx.fillText('0', axisX, y0);
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatVolume(volume: number): string {
|
|
if (volume >= 1_000_000_000) return (volume / 1_000_000_000).toFixed(1) + 'B';
|
|
if (volume >= 1_000_000) return (volume / 1_000_000).toFixed(1) + 'M';
|
|
if (volume >= 1_000) return (volume / 1_000).toFixed(1) + 'K';
|
|
return volume.toFixed(0);
|
|
}
|
|
|
|
// Helper functions
|
|
function calculateNiceStep(range: number, targetSteps: number): number {
|
|
const roughStep = range / targetSteps;
|
|
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
|
|
const normalized = roughStep / magnitude;
|
|
|
|
let niceNormalized: number;
|
|
if (normalized < 1.5) niceNormalized = 1;
|
|
else if (normalized < 3) niceNormalized = 2;
|
|
else if (normalized < 7) niceNormalized = 5;
|
|
else niceNormalized = 10;
|
|
|
|
return niceNormalized * magnitude;
|
|
}
|
|
|
|
function formatPrice(price: number): string {
|
|
if (price >= 1000) {
|
|
return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
}
|
|
return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
function formatTimeLabel(date: Date): string {
|
|
const hours = date.getUTCHours().toString().padStart(2, '0');
|
|
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
|
|
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0');
|
|
const day = date.getUTCDate().toString().padStart(2, '0');
|
|
|
|
return `${month}/${day} ${hours}:${minutes}`;
|
|
}
|
|
|
|
/**
|
|
* Renders measure points and lines on the overlay canvas.
|
|
*/
|
|
export function renderMeasure(
|
|
ctx: CanvasRenderingContext2D,
|
|
viewModel: ChartViewModel,
|
|
measurePoints: MeasurePoint[],
|
|
currentMousePos: { x: number; y: number } | null,
|
|
theme: ChartTheme,
|
|
dpr: number,
|
|
getYForPrice: (price: number) => number
|
|
): void {
|
|
const { chartDimensions } = viewModel;
|
|
|
|
if (measurePoints.length === 0 && !currentMousePos) {
|
|
return;
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const measureColor = '#3b82f6'; // Blue color for measure tool
|
|
const measureColorLight = 'rgba(59, 130, 246, 0.3)';
|
|
|
|
// Draw points
|
|
measurePoints.forEach((point, index) => {
|
|
// Draw point circle
|
|
ctx.beginPath();
|
|
ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
|
|
ctx.fillStyle = measureColor;
|
|
ctx.fill();
|
|
|
|
// Draw inner circle
|
|
ctx.beginPath();
|
|
ctx.arc(point.x, point.y, 3, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fill();
|
|
|
|
// Draw point label
|
|
ctx.font = 'bold 11px ui-monospace, monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'bottom';
|
|
ctx.fillStyle = measureColor;
|
|
ctx.fillText(`$${point.price.toFixed(2)}`, point.x, point.y - 10);
|
|
});
|
|
|
|
// Draw lines between consecutive points
|
|
if (measurePoints.length >= 2) {
|
|
ctx.strokeStyle = measureColor;
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([6, 4]);
|
|
|
|
for (let i = 0; i < measurePoints.length - 1; i++) {
|
|
const p1 = measurePoints[i];
|
|
const p2 = measurePoints[i + 1];
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(p1.x, p1.y);
|
|
ctx.lineTo(p2.x, p2.y);
|
|
ctx.stroke();
|
|
|
|
// Draw difference label at midpoint
|
|
const midX = (p1.x + p2.x) / 2;
|
|
const midY = (p1.y + p2.y) / 2;
|
|
const priceDiff = p2.price - p1.price;
|
|
const percentDiff = ((p2.price - p1.price) / p1.price) * 100;
|
|
const isPositive = priceDiff >= 0;
|
|
|
|
// Draw label background
|
|
const labelText = `${isPositive ? '+' : ''}$${priceDiff.toFixed(2)} (${isPositive ? '+' : ''}${percentDiff.toFixed(2)}%)`;
|
|
ctx.font = 'bold 11px ui-monospace, monospace';
|
|
const textWidth = ctx.measureText(labelText).width;
|
|
const labelPadding = 6;
|
|
const labelHeight = 20;
|
|
|
|
ctx.fillStyle = isPositive ? 'rgba(34, 197, 94, 0.9)' : 'rgba(239, 68, 68, 0.9)';
|
|
ctx.beginPath();
|
|
ctx.roundRect(
|
|
midX - textWidth / 2 - labelPadding,
|
|
midY - labelHeight / 2,
|
|
textWidth + labelPadding * 2,
|
|
labelHeight,
|
|
4
|
|
);
|
|
ctx.fill();
|
|
|
|
// Draw label text
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(labelText, midX, midY);
|
|
}
|
|
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
// Draw line from last point to current mouse position (preview)
|
|
if (measurePoints.length > 0 && currentMousePos) {
|
|
const lastPoint = measurePoints[measurePoints.length - 1];
|
|
|
|
ctx.strokeStyle = measureColorLight;
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(lastPoint.x, lastPoint.y);
|
|
ctx.lineTo(currentMousePos.x, currentMousePos.y);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|