SoftTraderFrontend/components/candlestick-chart/chartRenderer.ts
2026-03-20 11:10:13 -04:00

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();
}