Загрузка данных
import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';
import type { Coordinate, IChartApi, Logical, Time } from 'lightweight-charts';
interface SeriesTimeItem {
time: Time;
}
interface TimePoint {
time: number;
logical: number;
}
export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
return series.coordinateToPrice(yCoordinate as Coordinate);
}
export function getYCoordinateFromPrice(series: SeriesApi, price: number): Coordinate | null {
return series.priceToCoordinate(price);
}
export function getTimeFromXCoordinate(chart: IChartApi, xCoordinate: number, series?: SeriesApi): Time | null {
const coordinate = xCoordinate as Coordinate;
const time = chart.timeScale().coordinateToTime(coordinate);
if (time !== null) {
return time;
}
if (!series) {
return null;
}
const logical = chart.timeScale().coordinateToLogical(coordinate);
if (logical === null) {
return null;
}
return getTimeFromLogical(series, Number(logical));
}
export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: SeriesApi): Coordinate | null {
const coordinate = chart.timeScale().timeToCoordinate(time);
if (coordinate !== null) {
return coordinate;
}
if (!series) {
return null;
}
const logical = getLogicalFromTime(series, time);
if (logical === null) {
return null;
}
return chart.timeScale().logicalToCoordinate(logical as Logical);
}
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
export function getContainerSize(container: HTMLElement): ContainerSize {
const rect = container.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
};
}
export function clampPointToContainer(point: Point, container: HTMLElement): Point {
const { width, height } = getContainerSize(container);
return {
x: clamp(point.x, 0, width),
y: clamp(point.y, 0, height),
};
}
export function getPointerPoint(container: HTMLElement, event: PointerEvent): Point {
const rect = container.getBoundingClientRect();
return clampPointToContainer(
{
x: event.clientX - rect.left,
y: event.clientY - rect.top,
},
container,
);
}
export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
return Math.abs(point.x - x) <= tolerance && Math.abs(point.y - y) <= tolerance;
}
export function isPointInBounds(point: Point, bounds: Bounds, tolerance = 0): boolean {
return (
point.x >= bounds.left - tolerance &&
point.x <= bounds.right + tolerance &&
point.y >= bounds.top - tolerance &&
point.y <= bounds.bottom + tolerance
);
}
export function normalizeBounds(
left: number,
right: number,
top: number,
bottom: number,
container: HTMLElement,
): Bounds {
const { width, height } = getContainerSize(container);
return {
left: clamp(Math.min(left, right), 0, width),
right: clamp(Math.max(left, right), 0, width),
top: clamp(Math.min(top, bottom), 0, height),
bottom: clamp(Math.max(top, bottom), 0, height),
};
}
export function shiftTimeByPixels(chart: IChartApi, time: Time, offsetX: number, series?: SeriesApi): Time | null {
const coordinate = getXCoordinateFromTime(chart, time, series);
if (coordinate === null) {
return null;
}
return getTimeFromXCoordinate(chart, Number(coordinate) + offsetX, series);
}
export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
const fromPrice = getPriceFromYCoordinate(series, fromY);
const toPrice = getPriceFromYCoordinate(series, toY);
if (fromPrice === null || toPrice === null) {
return 0;
}
return toPrice - fromPrice;
}
export function getPriceRangeInContainer(
series: SeriesApi,
container: HTMLElement,
): { min: number; max: number } | null {
const { height } = getContainerSize(container);
if (!height) {
return null;
}
const topPrice = getPriceFromYCoordinate(series, 0);
const bottomPrice = getPriceFromYCoordinate(series, height);
if (topPrice === null || bottomPrice === null) {
return null;
}
return {
min: Math.min(topPrice, bottomPrice),
max: Math.max(topPrice, bottomPrice),
};
}
export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
const time = getTimeFromXCoordinate(chart, point.x, series);
const price = getPriceFromYCoordinate(series, point.y);
if (time === null || price === null) {
return null;
}
return {
time,
price,
};
}
function getSeriesTimePoints(series: SeriesApi): TimePoint[] {
const data = series.data() as readonly SeriesTimeItem[];
return data.reduce<TimePoint[]>((points, item, logical) => {
const time = getNumericTime(item.time);
if (time === null) {
return points;
}
points.push({
time,
logical,
});
return points;
}, []);
}
function getLogicalFromTime(series: SeriesApi, time: Time): number | null {
const targetTime = getNumericTime(time);
if (targetTime === null) {
return null;
}
const points = getSeriesTimePoints(series);
if (points.length < 2) {
return null;
}
const lastIndex = points.length - 1;
if (targetTime <= points[0].time) {
return interpolateLogical(points[0], points[1], targetTime);
}
if (targetTime >= points[lastIndex].time) {
return interpolateLogical(points[lastIndex - 1], points[lastIndex], targetTime);
}
let left = 0;
let right = lastIndex;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const middleTime = points[middle].time;
if (middleTime === targetTime) {
return points[middle].logical;
}
if (middleTime < targetTime) {
left = middle + 1;
} else {
right = middle - 1;
}
}
return interpolateLogical(points[right], points[left], targetTime);
}
function getTimeFromLogical(series: SeriesApi, logical: number): Time | null {
const points = getSeriesTimePoints(series);
if (points.length < 2) {
return null;
}
const lastIndex = points.length - 1;
if (logical <= points[0].logical) {
return interpolateTime(points[0], points[1], logical);
}
if (logical >= points[lastIndex].logical) {
return interpolateTime(points[lastIndex - 1], points[lastIndex], logical);
}
const leftIndex = Math.floor(logical);
const rightIndex = Math.ceil(logical);
if (leftIndex === rightIndex) {
return points[leftIndex].time as Time;
}
return interpolateTime(points[leftIndex], points[rightIndex], logical);
}
function interpolateLogical(leftPoint: TimePoint, rightPoint: TimePoint, targetTime: number): number | null {
const timeRange = rightPoint.time - leftPoint.time;
if (timeRange === 0) {
return null;
}
const ratio = (targetTime - leftPoint.time) / timeRange;
return leftPoint.logical + (rightPoint.logical - leftPoint.logical) * ratio;
}
function interpolateTime(leftPoint: TimePoint, rightPoint: TimePoint, logical: number): Time | null {
const logicalRange = rightPoint.logical - leftPoint.logical;
if (logicalRange === 0) {
return null;
}
const ratio = (logical - leftPoint.logical) / logicalRange;
const time = leftPoint.time + (rightPoint.time - leftPoint.time) * ratio;
return Math.round(time) as Time;
}
function getNumericTime(time: Time): number | null {
if (typeof time !== 'number') {
return null;
}
return Number.isFinite(time) ? time : null;
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { Rectangle } from './rectangle';
const UI = {
borderWidth: 1,
handleSize: 10,
handleBorderWidth: 1,
textOffset: 4,
textLineHeightMultiplier: 1.2,
};
export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
private readonly rectangle: Rectangle;
constructor(rectangle: Rectangle) {
this.rectangle = rectangle;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.rectangle.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const bottom = data.bottom * verticalPixelRatio;
const visibleLeft = clamp(left, 0, bitmapSize.width);
const visibleRight = clamp(right, 0, bitmapSize.width);
const visibleTop = clamp(top, 0, bitmapSize.height);
const visibleBottom = clamp(bottom, 0, bitmapSize.height);
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
return;
}
context.save();
if (data.showFill) {
context.fillStyle = data.fillColor;
context.fillRect(visibleLeft, visibleTop, visibleRight - visibleLeft, visibleBottom - visibleTop);
}
drawVisibleBorders(context, {
left,
right,
top,
bottom,
visibleLeft,
visibleRight,
visibleTop,
visibleBottom,
bitmapWidth: bitmapSize.width,
bitmapHeight: bitmapSize.height,
borderColor: data.borderColor,
pixelRatio,
});
drawRectangleText(context, {
left: visibleLeft,
top: visibleTop,
originalTop: top,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
pixelRatio,
});
if (data.showHandles) {
for (const handle of Object.values(data.handles)) {
const x = handle.x * horizontalPixelRatio;
const y = handle.y * verticalPixelRatio;
if (!isPointVisible(x, y, bitmapSize.width, bitmapSize.height, UI.handleSize * pixelRatio)) {
continue;
}
drawHandle(
context,
x,
y,
horizontalPixelRatio,
verticalPixelRatio,
colors.chartLineColor,
colors.chartBackground,
);
}
}
context.restore();
});
}
}
function drawVisibleBorders(
context: CanvasRenderingContext2D,
params: {
left: number;
right: number;
top: number;
bottom: number;
visibleLeft: number;
visibleRight: number;
visibleTop: number;
visibleBottom: number;
bitmapWidth: number;
bitmapHeight: number;
borderColor: string;
pixelRatio: number;
},
): void {
const {
left,
right,
top,
bottom,
visibleLeft,
visibleRight,
visibleTop,
visibleBottom,
bitmapWidth,
bitmapHeight,
borderColor,
pixelRatio,
} = params;
context.save();
context.strokeStyle = borderColor;
context.lineWidth = UI.borderWidth * pixelRatio;
context.beginPath();
if (left >= 0 && left <= bitmapWidth) {
context.moveTo(left, visibleTop);
context.lineTo(left, visibleBottom);
}
if (right >= 0 && right <= bitmapWidth) {
context.moveTo(right, visibleTop);
context.lineTo(right, visibleBottom);
}
if (top >= 0 && top <= bitmapHeight) {
context.moveTo(visibleLeft, top);
context.lineTo(visibleRight, top);
}
if (bottom >= 0 && bottom <= bitmapHeight) {
context.moveTo(visibleLeft, bottom);
context.lineTo(visibleRight, bottom);
}
context.stroke();
context.restore();
}
function drawRectangleText(
context: CanvasRenderingContext2D,
params: {
left: number;
top: number;
originalTop: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
pixelRatio: number;
},
): void {
const { left, top, originalTop, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;
if (!text.trim()) {
return;
}
if (originalTop !== top) {
return;
}
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * pixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * pixelRatio;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
const textOffset = UI.textOffset * pixelRatio;
const textX = left;
const blockHeight = lines.length * lineHeight;
const firstLineY = top - textOffset - blockHeight + lineHeight / 2;
context.save();
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'left';
context.textBaseline = 'middle';
lines.forEach((line, index) => {
context.fillText(line, textX, firstLineY + index * lineHeight);
});
context.restore();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
strokeColor: string,
fillColor: string,
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
context.save();
context.fillStyle = fillColor;
context.strokeStyle = strokeColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
context.rect(left, top, width, height);
context.fill();
context.stroke();
context.restore();
}
function isPointVisible(x: number, y: number, width: number, height: number, tolerance: number): boolean {
return x >= -tolerance && x <= width + tolerance && y >= -tolerance && y <= height + tolerance;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}