Загрузка данных
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { Coordinate, IPrimitivePaneRenderer, ISeriesApi, SeriesOptionsMap } from 'lightweight-charts';
import type { ViewPoint } from '@core/Drawings/common';
import type { TrendLineOptions } from '@core/Drawings/trendLine';
interface RayPaneRendererParams {
p1: ViewPoint;
p2: ViewPoint;
text1: string;
text2: string;
series: ISeriesApi<keyof SeriesOptionsMap>;
options: TrendLineOptions;
visible: boolean;
}
export class RayPaneRenderer implements IPrimitivePaneRenderer {
private _p1: ViewPoint;
private _p2: ViewPoint;
private _text1: string;
private _text2: string;
private _series: ISeriesApi<keyof SeriesOptionsMap>;
private _options: TrendLineOptions;
private _visible: boolean;
constructor({ p1, p2, text1, text2, series, options, visible }: RayPaneRendererParams) {
this._p1 = p1;
this._p2 = p2;
this._text1 = text1;
this._text2 = text2;
this._series = series;
this._options = options;
this._visible = visible;
}
public draw(target: CanvasRenderingTarget2D): void {
target.useBitmapCoordinateSpace((scope) => {
if (this._p1.x === null || this._p1.y === null || this._p2.x === null || this._p2.y === null || !this._visible) {
return;
}
const paneSize = this.getPaneSize();
if (!paneSize) {
return;
}
const resPoint = bestIntersectionToP2(
{ x: this._p1.x, y: this._p1.y },
{ x: this._p2.x, y: this._p2.y },
{
xmin: 0,
xmax: paneSize.width - 1,
ymin: 0,
ymax: paneSize.height - 1,
},
);
const ctx = scope.context;
const x1Scaled = Math.round(this._p1.x * scope.horizontalPixelRatio);
const y1Scaled = Math.round(this._p1.y * scope.verticalPixelRatio);
const x2Scaled = Math.round(this._p2.x * scope.horizontalPixelRatio);
const y2Scaled = Math.round(this._p2.y * scope.verticalPixelRatio);
const x3Scaled =
resPoint?.x !== null && resPoint?.x !== undefined ? Math.round(resPoint.x * scope.horizontalPixelRatio) : null;
const y3Scaled =
resPoint?.y !== null && resPoint?.y !== undefined ? Math.round(resPoint.y * scope.verticalPixelRatio) : null;
ctx.lineWidth = this._options.width;
ctx.strokeStyle = this._options.lineColor;
ctx.beginPath();
ctx.moveTo(x1Scaled, y1Scaled);
ctx.lineTo(x2Scaled, y2Scaled);
if (x3Scaled !== null && y3Scaled !== null) {
ctx.lineTo(x3Scaled, y3Scaled);
}
ctx.stroke();
if (this._options.showLabels) {
// this._drawTextLabel(scope, this._text1, x1Scaled, y1Scaled, true);
// this._drawTextLabel(scope, this._text2, x2Scaled, y2Scaled, false);
}
});
}
private getPaneSize(): { width: number; height: number } | null {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const pane = this._series.getPane()._private__pane ?? this._series.getPane().Pt;
if (!pane) {
return null;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const width = pane._private__width ?? pane.Mo;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const height = pane._private__height ?? pane.sl;
if (width == null || height == null) {
return null;
}
return { width, height };
}
}
function bestIntersectionToP2(
p1: { x: Coordinate; y: Coordinate },
p2: { x: Coordinate; y: Coordinate },
rect: { xmin: number; xmax: number; ymin: number; ymax: number },
): ViewPoint | null {
const { xmin, xmax, ymin, ymax } = rect;
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const canvasBorderIntersections: (ViewPoint & { t: number })[] = [];
const pushIfValid = (x: number, y: number, t: number) => {
const isP2Outside = p2.x < xmin || p2.x > xmax || p2.y < ymin || p2.y > ymax;
const isCandidateOverP1 =
(dx > 0 && x < p1.x) || (dx < 0 && x > p1.x) || (dy > 0 && y < p1.y) || (dy < 0 && y > p1.y);
const isCandidateOverP2 =
(dx > 0 && x < p2.x) || (dx < 0 && x > p2.x) || (dy > 0 && y < p2.y) || (dy < 0 && y > p2.y);
if ((!isP2Outside && isCandidateOverP2) || (isP2Outside && isCandidateOverP1)) {
return;
}
const isPointInBorders = x >= xmin && x <= xmax && y >= ymin && y <= ymax;
if (!isPointInBorders) {
return;
}
canvasBorderIntersections.push({ x, y, t } as ViewPoint & { t: number });
};
if (dx !== 0) {
let t1 = (xmin - p1.x) / dx;
let t2 = (xmin - p2.x) / dx;
pushIfValid(xmin, p1.y + t1 * dy, t2);
t1 = (xmax - p1.x) / dx;
t2 = (xmax - p2.x) / dx;
pushIfValid(xmax, p1.y + t1 * dy, t2);
}
if (dy !== 0) {
let t1 = (ymin - p1.y) / dy;
let t2 = (ymin - p2.y) / dy;
pushIfValid(p1.x + t1 * dx, ymin, t2);
t1 = (ymax - p1.y) / dy;
t2 = (ymax - p2.y) / dy;
pushIfValid(p1.x + t1 * dx, ymax, t2);
}
if (canvasBorderIntersections.length === 0) {
return null;
}
let best = canvasBorderIntersections[0];
let bestDist = Math.abs(best.t - 1);
for (let index = 1; index < canvasBorderIntersections.length; index += 1) {
const distance = Math.abs(canvasBorderIntersections[index].t - 1);
if (distance > bestDist) {
best = canvasBorderIntersections[index];
bestDist = distance;
}
}
return { x: best.x, y: best.y };
}
import { IPrimitivePaneRenderer, IPrimitivePaneView } from 'lightweight-charts';
import { getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { RayPaneRenderer } from './paneRenderer';
import type { Ray } from './ray';
import type { ViewPoint } from '@core/Drawings/common';
export class RayPaneView implements IPrimitivePaneView {
private _source: Ray;
private _p1: ViewPoint = { x: null, y: null };
private _p2: ViewPoint = { x: null, y: null };
constructor(source: Ray) {
this._source = source;
}
public update(): void {
const x1 = getXCoordinateFromTime(this._source._chart, this._source._p1.time);
const x2 = getXCoordinateFromTime(this._source._chart, this._source._p2.time);
const y1 = getYCoordinateFromPrice(this._source._series, this._source._p1.price);
const y2 = getYCoordinateFromPrice(this._source._series, this._source._p2.price);
this._p1 = { x: x1, y: y1 };
this._p2 = { x: x2, y: y2 };
}
public renderer(): IPrimitivePaneRenderer {
return new RayPaneRenderer({
p1: this._p1,
p2: this._p2,
text1: `${this._source._p1.price.toFixed(1)}`,
text2: `${this._source._p2.price.toFixed(1)}`,
series: this._source._series,
options: this._source._options,
visible: this._source._visible,
});
}
}
import {
AutoscaleInfo,
IChartApi,
ISeriesApi,
Logical,
MouseEventParams,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { ISeriesDrawing, Point } from '@core/Drawings/common';
import { getPriceFromYCoordinate, getTimeFromXCoordinate, getXCoordinateFromTime } from '@core/Drawings/helpers';
import { RayPaneView } from './paneView';
import type { TrendLineOptions } from '@core/Drawings/trendLine';
const defaultOptions: TrendLineOptions = {
lineColor: 'gray',
width: 3,
showLabels: true,
labelBackgroundColor: 'rgba(255, 255, 255, 0)',
labelTextColor: 'rgb(0, 0, 0)',
};
export class Ray implements ISeriesDrawing {
private _attached = false;
_chart: IChartApi;
_series: ISeriesApi<keyof SeriesOptionsMap>;
_p1!: Point;
_p2!: Point;
_paneViews: RayPaneView[];
_options: TrendLineOptions;
_minPrice!: number;
_maxPrice!: number;
_visible = true;
constructor(chart: IChartApi, series: ISeriesApi<SeriesType>, options?: Partial<TrendLineOptions>) {
this._chart = chart;
this._series = series;
chart.subscribeClick(this.startDrawing);
this._options = {
...defaultOptions,
...options,
};
this._paneViews = [new RayPaneView(this)];
}
private startDrawing = (param: MouseEventParams<Time>) => {
this._chart.unsubscribeClick(this.startDrawing);
if (!param.point) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
const price = getPriceFromYCoordinate(this._series, param.point.y);
const time = getTimeFromXCoordinate(this._chart, param.point.x);
if (time == null || price == null) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
this._p1 = { time, price };
this._p2 = { time, price };
this._minPrice = Math.min(this._p1.price, this._p2.price);
this._maxPrice = Math.max(this._p1.price, this._p2.price);
this._series.attachPrimitive(this);
this._attached = true;
this._chart.subscribeClick(this.endDrawing);
this._chart.subscribeCrosshairMove(this.drawing);
this.updateAllViews();
};
private drawing = (param: MouseEventParams<Time>) => {
if (!param.point) {
return;
}
const price = getPriceFromYCoordinate(this._series, param.point.y);
const time = getTimeFromXCoordinate(this._chart, param.point.x);
if (time == null || price == null) {
return;
}
this._p2 = { time, price };
this._minPrice = Math.min(this._p1.price, this._p2.price);
this._maxPrice = Math.max(this._p1.price, this._p2.price);
this.updateAllViews();
};
private endDrawing = (param: MouseEventParams<Time>) => {
if (!param.point) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
const price = getPriceFromYCoordinate(this._series, param.point.y);
const time = getTimeFromXCoordinate(this._chart, param.point.x);
if (time == null || price == null) {
throw new Error('[Drawings] - не удалось получить координаты точки');
}
this._p2 = { time, price };
this._minPrice = Math.min(this._p1.price, this._p2.price);
this._maxPrice = Math.max(this._p1.price, this._p2.price);
this._chart.unsubscribeCrosshairMove(this.drawing);
this._chart.unsubscribeClick(this.endDrawing);
this.updateAllViews();
};
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
const p1Index = this._pointIndex(this._p1);
const p2Index = this._pointIndex(this._p2);
if (p1Index === null || p2Index === null) {
return null;
}
const fromIndex = Math.min(p1Index, p2Index);
const toIndex = Math.max(p1Index, p2Index);
if (endTimePoint < fromIndex || startTimePoint > toIndex) {
return null;
}
return {
priceRange: {
minValue: this._minPrice,
maxValue: this._maxPrice,
},
};
}
public updateAllViews() {
this._paneViews.forEach((pw) => pw.update());
}
public paneViews() {
return this._paneViews;
}
public _pointIndex(p: Point): number | null {
const xCoordinate = getXCoordinateFromTime(this._chart, p.time);
if (xCoordinate === null) {
return null;
}
const index = this._chart.timeScale().coordinateToLogical(xCoordinate);
return index;
}
public hide(): void {
this._visible = false;
this.updateAllViews();
}
public show(): void {
this._visible = true;
this.updateAllViews();
}
public rebind(series: ISeriesApi<SeriesType>): void {
if (this._attached) {
this._series.detachPrimitive(this);
}
this._series = series;
this.updateAllViews();
if (this._attached) {
this._series.attachPrimitive(this);
}
}
public destroy(): void {
this._chart.unsubscribeClick(this.startDrawing);
this._chart.unsubscribeClick(this.endDrawing);
this._chart.unsubscribeCrosshairMove(this.drawing);
if (this._attached) {
this._series.detachPrimitive(this);
this._attached = false;
}
}
}