Загрузка данных
import { clamp } from 'lodash-es';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta,
getXCoordinateFromTime,
getYCoordinateFromPrice,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import type { Anchor, Bounds, ContainerSize, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
export type TwoPointAnchorKind = 'start' | 'end';
export interface TwoPointGeometry extends Bounds {
startPoint: Point;
endPoint: Point;
}
export abstract class TwoPointDrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> extends DrawingBase<TSettings, THandleId> {
protected startAnchor: Anchor | null = null;
protected endAnchor: Anchor | null = null;
protected getAnchor(kind: TwoPointAnchorKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
protected setAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
if (kind === 'start') {
this.startAnchor = anchor;
return;
}
this.endAnchor = anchor;
}
protected setAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
}
protected createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getPointFromAnchor(anchor: Anchor | null): Point | null {
if (!anchor) {
return null;
}
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
return {
x: Number(x),
y: Number(y),
};
}
protected setAnchorFromPoint(kind: TwoPointAnchorKind, point: Point): boolean {
const anchor = this.createAnchor(point);
if (!anchor) {
return false;
}
this.setAnchor(kind, anchor);
return true;
}
protected setAnchorsFromPoints(startPoint: Point, endPoint: Point): boolean {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return false;
}
this.setAnchors(startAnchor, endAnchor);
return true;
}
protected getAnchorTimeCoordinate(kind: TwoPointAnchorKind): number | null {
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
protected getAnchorPriceCoordinate(kind: TwoPointAnchorKind): number | null {
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
protected moveAnchorsByPixels(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
): boolean {
if (!dragStartPoint) {
return false;
}
const offsetX = point.x - dragStartPoint.x;
const priceOffset = getPriceDelta(this.series, dragStartPoint.y, point.y);
return this.moveAnchors(startAnchor, endAnchor, offsetX, priceOffset);
}
protected moveAnchorsWithinContainer(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
bounds: Bounds,
): boolean {
if (!dragStartPoint) {
return false;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -bounds.left, width - bounds.right);
const offsetY = clamp(rawOffsetY, -bounds.top, height - bounds.bottom);
const priceOffset = getPriceDelta(this.series, dragStartPoint.y, dragStartPoint.y + offsetY);
return this.moveAnchors(startAnchor, endAnchor, offsetX, priceOffset);
}
protected getContainerSize(): ContainerSize {
return getElementContainerSize(this.container);
}
protected clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
protected getTwoPointGeometry(): TwoPointGeometry | null {
const startPoint = this.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
if (!startPoint || !endPoint) {
return null;
}
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
private moveAnchors(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
offsetX: number,
priceOffset: number,
): boolean {
if (!startAnchor || !endAnchor) {
return false;
}
const nextStartTime = shiftTimeByPixels(this.chart, startAnchor.time, offsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, endAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return false;
}
this.startAnchor = {
time: nextStartTime,
price: startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: endAnchor.price + priceOffset,
};
return true;
}
}
import {
IPrimitivePaneView,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { isPointInBounds, normalizeBounds } from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { updateViews } from '@core/Drawings/utils';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RectanglePaneView } from './paneView';
import {
createDefaultSettings,
getRectangleSettingsTabs,
RectangleSettings,
RectangleStyle,
RectangleTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
type RectangleParams = BaseDrawingParams;
interface RectangleState {
hidden: boolean;
mode: RectangleMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: RectangleSettings;
}
interface RectangleDragSnapshot {
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface RectangleGeometry extends Bounds {
width: number;
height: number;
}
export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
export class Rectangle
extends TwoPointDrawingBase<RectangleSettings, RectangleHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
protected mode: RectangleMode = 'idle';
private activeDragTarget: RectangleHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragAnchorSnapshot: RectangleDragSnapshot | null = null;
private dragGeometrySnapshot: RectangleGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: RectanglePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: RectangleParams) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new RectanglePaneView(this);
this.timeAxisPaneView = this.createTimeAxisPaneView();
this.priceAxisPaneView = this.createPriceAxisPaneView();
this.leftTimeAxisView = this.createTimeAxisView('left');
this.rightTimeAxisView = this.createTimeAxisView('right');
this.topPriceAxisView = this.createPriceAxisView('top');
this.bottomPriceAxisView = this.createPriceAxisView('bottom');
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RectangleState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startAnchor?.time ?? null,
endTime: this.endAnchor?.time ?? null,
startPrice: this.startAnchor?.price ?? null,
endPrice: this.endAnchor?.price ?? null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RectangleState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
this.restoreStartAnchor(nextState);
this.restoreEndAnchor(nextState);
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<RectangleHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { left, right, top, bottom } = geometry;
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
return [
{ id: 'w', x: left, y: centerY, shape: 'rounded' },
{ id: 'sw', x: left, y: bottom, shape: 'circle' },
{ id: 's', x: centerX, y: bottom, shape: 'rounded' },
{ id: 'se', x: right, y: bottom, shape: 'circle' },
{ id: 'e', x: right, y: centerY, shape: 'rounded' },
{ id: 'ne', x: right, y: top, shape: 'circle' },
{ id: 'n', x: centerX, y: top, shape: 'rounded' },
{ id: 'nw', x: left, y: top, shape: 'circle' },
];
}
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.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: 'rectangle-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [this.createAxisSegment(geometry.left, geometry.right)];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [this.createAxisSegment(geometry.top, geometry.bottom)];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
return this.createAxisLabel(
this.getTimeCoordinate(labelKind),
this.getTimeText(labelKind),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
return this.createAxisLabel(
this.getPriceCoordinate(labelKind),
this.getPriceText(labelKind),
);
}
protected getGeometry(): RectangleGeometry | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
width: geometry.right - geometry.left,
height: geometry.bottom - geometry.top,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
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') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.setAnchors(anchor, anchor);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const clampedPoint = this.clampPointToContainer(point);
if (!this.setAnchorFromPoint('end', clampedPoint)) {
return;
}
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(
point: Point,
pointerId: number,
dragTarget: Exclude<RectangleHandle, null>,
): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragAnchorSnapshot = {
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragAnchorSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
if (handleTarget) {
return handleTarget;
}
return this.containsPoint(point) ? 'body' : null;
}
private moveWhole(point: Point): void {
const snapshot = this.dragAnchorSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry) {
return;
}
this.moveAnchorsWithinContainer(
snapshot.startAnchor,
snapshot.endAnchor,
this.dragStartPoint,
point,
geometry,
);
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const clampedPoint = this.clampPointToContainer(point);
let { left, right, top, bottom } = geometry;
switch (this.activeDragTarget) {
case 'nw':
left = clampedPoint.x;
top = clampedPoint.y;
break;
case 'n':
top = clampedPoint.y;
break;
case 'ne':
right = clampedPoint.x;
top = clampedPoint.y;
break;
case 'e':
right = clampedPoint.x;
break;
case 'se':
right = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 's':
bottom = clampedPoint.y;
break;
case 'sw':
left = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 'w':
left = clampedPoint.x;
break;
default:
return;
}
this.setRectangleBounds(left, right, top, bottom);
}
private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
return this.setAnchorsFromPoints(
{ x: bounds.left, y: bounds.top },
{ x: bounds.right, y: bounds.bottom },
);
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = this.getTimeValueForLabel(kind);
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
return price === null ? '' : formatPrice(price) ?? '';
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return kind === 'left' ? this.startAnchor.time : this.endAnchor.time;
}
const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
if (kind === 'left') {
return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
}
return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return kind === 'top'
? Math.max(this.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
if (kind === 'top') {
return startIsTop ? this.startAnchor.price : this.endAnchor.price;
}
return startIsTop ? this.endAnchor.price : this.startAnchor.price;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry !== null && isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
switch (handle) {
case 'nw':
case 'se':
return 'nwse-resize';
case 'ne':
case 'sw':
return 'nesw-resize';
case 'n':
case 's':
return 'ns-resize';
case 'e':
case 'w':
return 'ew-resize';
case 'body':
return 'grab';
default:
return 'default';
}
}
private restoreStartAnchor(state: Partial<RectangleState>): void {
if (!('startTime' in state) && !('startPrice' in state)) {
return;
}
const time = 'startTime' in state ? state.startTime ?? null : this.startAnchor?.time ?? null;
const price = 'startPrice' in state ? state.startPrice ?? null : this.startAnchor?.price ?? null;
this.startAnchor = time !== null && price !== null ? { time, price } : null;
}
private restoreEndAnchor(state: Partial<RectangleState>): void {
if (!('endTime' in state) && !('endPrice' in state)) {
return;
}
const time = 'endTime' in state ? state.endTime ?? null : this.endAnchor?.time ?? null;
const price = 'endPrice' in state ? state.endPrice ?? null : this.endAnchor?.price ?? null;
this.endAnchor = time !== null && price !== null ? { time, price } : null;
}
}