Загрузка данных
import { IPrimitivePaneView, PrimitiveHoveredItem } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} from '@core/Drawings/helpers';
import { AxisLabel } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { TraectoryPaneView } from './paneView';
import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab } from '@src/types';
type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;
export interface TraectoryState {
hidden: boolean;
mode: TraectoryMode;
points: Anchor[];
settings: TraectorySettings;
}
interface TraectoryGeometry {
points: Point[];
left: number;
right: number;
top: number;
bottom: number;
}
export interface TraectoryRenderData extends TraectoryGeometry, TraectoryStyle {
previewPoint: Point | null;
showArrow: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHandleId> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TraectorySettings = createDefaultSettings();
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
private previewAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragPointIndex: number | null = null;
private dragGeometrySnapshot: TraectoryGeometry | null = null;
private readonly paneView: TraectoryPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
constructor({ chart, series, container, interaction, removeSelf, openSettings, initialEvent }: TraectoryParams) {
super({
chart,
series,
container,
interaction,
});
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TraectoryPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.series.attachPrimitive(this);
if (initialEvent && initialEvent.sourceEvent) {
const point = this.getEventPoint(initialEvent.sourceEvent);
this.startDrawing(point);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): TraectoryState {
return {
hidden: this.hidden,
mode: this.mode,
points: this.points,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TraectoryState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
this.points = Array.isArray(nextState.points) ? nextState.points : this.points;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTraectorySettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisPaneView, this.priceAxisPaneView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [];
}
public priceAxisViews() {
return [];
}
public getRenderData(): TraectoryRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
previewPoint: this.getPreviewPoint(),
showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<TraectoryHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return geometry.points
.map<DrawingHandle<TraectoryHandleId>>((point, index) => ({
id: `${index}`,
...point,
shape: 'circle',
size: 10,
borderWidth: 2,
}))
.reverse();
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
if (!this.isPointNearTraectory(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
if (this.getPointIndexAt(point) !== null) {
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
if (!this.isPointNearTraectory(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
return null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
return null;
}
protected handleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'drawing' || event.detail !== 1) {
return;
}
event.preventDefault();
event.stopPropagation();
const point = this.getEventPoint(event as PointerEvent);
this.appendPoint(point);
};
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden) {
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.finishDrawing();
return;
}
if (this.mode !== 'ready' || !this.isSelected()) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (this.getPointIndexAt(point) === null && !this.isPointNearTraectory(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handleContextMenu = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'drawing') {
return;
}
event.preventDefault();
event.stopPropagation();
this.finishDrawing();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
if (!this.isPointNearTraectory(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const pointIndex = this.getPointIndexAt(point);
if (pointIndex !== null) {
event.preventDefault();
event.stopPropagation();
this.startDraggingPoint(point, event.pointerId, pointIndex);
return;
}
if (!this.isPointNearTraectory(point)) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDraggingBody(point, event.pointerId);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updatePreview(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-point') {
event.preventDefault();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-point' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.points = [anchor];
this.previewAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private appendPoint(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
const lastPoint = this.points[this.points.length - 1];
if (lastPoint && Number(lastPoint.time) === Number(anchor.time) && lastPoint.price === anchor.price) {
this.previewAnchor = anchor;
this.render();
return;
}
this.points = [...this.points, anchor];
this.previewAnchor = anchor;
this.render();
}
private updatePreview(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.previewAnchor = anchor;
this.render();
}
private finishDrawing(): void {
if (this.points.length < MIN_POINTS_COUNT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.previewAnchor = null;
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDraggingPoint(point: Point, pointerId: number, pointIndex: number): void {
this.mode = 'dragging-point';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = pointIndex;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private startDraggingBody(point: Point, pointerId: number): void {
this.mode = 'dragging-body';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = null;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.mode = 'idle';
this.points = [];
this.previewAnchor = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const pointIndex = this.dragPointIndex;
if (!geometry || pointIndex === null) {
return;
}
const nextPoint = this.clampPointToContainer(point);
const nextPoints = [...geometry.points];
nextPoints[pointIndex] = nextPoint;
this.setAnchorsFromPoints(nextPoints);
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextPoints = geometry.points.map((item) =>
this.clampPointToContainer({
x: item.x + offsetX,
y: item.y + offsetY,
}),
);
this.setAnchorsFromPoints(nextPoints);
}
private setAnchorsFromPoints(points: Point[]): void {
const nextAnchors: Anchor[] = [];
for (const point of points) {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
nextAnchors.push(anchor);
}
this.points = nextAnchors;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): TraectoryGeometry | null {
if (!this.points.length) {
return null;
}
const { width, height } = this.getContainerSize();
const screenPoints: Point[] = [];
for (const anchor of this.points) {
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
screenPoints.push({
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
});
}
const left = Math.round(Math.min(...screenPoints.map((point) => point.x)));
const right = Math.round(Math.max(...screenPoints.map((point) => point.x)));
const top = Math.round(Math.min(...screenPoints.map((point) => point.y)));
const bottom = Math.round(Math.max(...screenPoints.map((point) => point.y)));
return {
points: screenPoints,
left,
right,
top,
bottom,
};
}
private getPreviewPoint(): Point | null {
if (this.mode !== 'drawing' || !this.previewAnchor) {
return null;
}
const x = getXCoordinateFromTime(this.chart, this.previewAnchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, this.previewAnchor.price);
if (x === null || y === null) {
return null;
}
const { width, height } = this.getContainerSize();
return {
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
};
}
private getPointIndexAt(point: Point): number | null {
const handle = this.getDrawingHandleAtPoint(point);
return handle ? Number(handle.id) : null;
}
private isPointNearTraectory(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (geometry.points.length === 1) {
const firstPoint = geometry.points[0];
return isNearPoint(point, firstPoint.x, firstPoint.y, POINT_HIT_TOLERANCE);
}
for (let index = 0; index < geometry.points.length - 1; index += 1) {
const startPoint = geometry.points[index];
const endPoint = geometry.points[index + 1];
if (this.getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
return true;
}
}
return false;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { Traectory } from './traectory';
const UI = {
lineWidth: 2,
previewLineWidth: 1,
previewDash: [6, 4],
arrowLength: 14,
arrowHalfWidth: 6,
};
export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
private readonly traectory: Traectory;
constructor(traectory: Traectory) {
this.traectory = traectory;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.traectory.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = UI.lineWidth * pixelRatio;
const previewLineWidth = UI.previewLineWidth * pixelRatio;
const arrowLength = UI.arrowLength * pixelRatio;
const arrowHalfWidth = UI.arrowHalfWidth * pixelRatio;
context.save();
context.lineJoin = 'round';
context.lineCap = 'round';
context.strokeStyle = data.lineColor;
context.fillStyle = data.lineColor;
if (data.points.length > 1) {
context.lineWidth = lineWidth;
context.beginPath();
data.points.forEach((point, index) => {
const x = point.x * horizontalPixelRatio;
const y = point.y * verticalPixelRatio;
if (index === 0) {
context.moveTo(x, y);
return;
}
context.lineTo(x, y);
});
context.stroke();
}
if (data.showArrow && data.points.length > 1) {
const previousPoint = data.points[data.points.length - 2];
const lastPoint = data.points[data.points.length - 1];
context.lineWidth = lineWidth;
drawArrowHead(
context,
previousPoint.x * horizontalPixelRatio,
previousPoint.y * verticalPixelRatio,
lastPoint.x * horizontalPixelRatio,
lastPoint.y * verticalPixelRatio,
arrowLength,
arrowHalfWidth,
);
}
if (data.previewPoint && data.points.length > 0) {
const lastPoint = data.points[data.points.length - 1];
context.save();
context.lineWidth = previewLineWidth;
context.setLineDash(UI.previewDash.map((value) => value * pixelRatio));
context.beginPath();
context.moveTo(lastPoint.x * horizontalPixelRatio, lastPoint.y * verticalPixelRatio);
context.lineTo(data.previewPoint.x * horizontalPixelRatio, data.previewPoint.y * verticalPixelRatio);
context.stroke();
context.restore();
}
context.restore();
});
}
}
function drawArrowHead(
context: CanvasRenderingContext2D,
fromX: number,
fromY: number,
toX: number,
toY: number,
arrowLength: number,
arrowHalfWidth: number,
): void {
const dx = toX - fromX;
const dy = toY - fromY;
const distance = Math.hypot(dx, dy);
if (distance === 0) {
return;
}
const directionX = dx / distance;
const directionY = dy / distance;
const normalX = -directionY;
const normalY = directionX;
const leftX = toX - directionX * arrowLength + normalX * arrowHalfWidth;
const leftY = toY - directionY * arrowLength + normalY * arrowHalfWidth;
const rightX = toX - directionX * arrowLength - normalX * arrowHalfWidth;
const rightY = toY - directionY * arrowLength - normalY * arrowHalfWidth;
context.beginPath();
context.moveTo(toX, toY);
context.lineTo(leftX, leftY);
context.moveTo(toX, toY);
context.lineTo(rightX, rightY);
context.stroke();
}