Загрузка данных
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { formatPercent, formatPrice, formatSignedNumber, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { DiapsonPaneView } from './paneView';
import {
createDefaultSettings,
DiapsonSettings,
DiapsonStyle,
DiapsonTextStyle,
getDiapsonSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams extends BaseDrawingParams {
rangeMode: DiapsonRangeMode;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
mode: DiapsonMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
labelLines: string[];
}
interface DateMetrics {
barsCount: number;
elapsedText: string;
volumeText: string;
}
interface PriceMetrics {
delta: number;
percent: number;
steps: number;
}
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: DiapsonSettings = createDefaultSettings();
protected mode: DiapsonMode = 'idle';
private rangeMode: DiapsonRangeMode;
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: DiapsonHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: DiapsonGeometry | null = null;
private readonly stepSize: number;
private readonly stepLabel: string;
private readonly paneView: DiapsonPaneView;
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,
rangeMode,
formatObservable,
removeSelf,
openSettings,
stepSize = 1,
stepLabel = '',
initialEvent,
}: DiapsonParams) {
super({
chart,
series,
container,
interaction,
});
this.rangeMode = rangeMode;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.stepSize = stepSize > 0 ? stepSize : 1;
this.stepLabel = stepLabel;
this.paneView = new DiapsonPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public setRangeMode(nextMode: DiapsonRangeMode): void {
if (this.rangeMode === nextMode) {
return;
}
this.rangeMode = nextMode;
this.resetToIdle();
}
public getState(): DiapsonState {
return {
hidden: this.hidden,
mode: this.mode,
rangeMode: this.rangeMode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<DiapsonState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
this.rangeMode = nextState.rangeMode ?? this.rangeMode;
this.startTime = 'startTime' in nextState ? (nextState.startTime ?? null) : this.startTime;
this.endTime = 'endTime' in nextState ? (nextState.endTime ?? null) : this.endTime;
this.startPrice = 'startPrice' in nextState ? (nextState.startPrice ?? null) : this.startPrice;
this.endPrice = 'endPrice' in nextState ? (nextState.endPrice ?? null) : this.endPrice;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(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(): DiapsonRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
rangeMode: this.rangeMode,
showFill: true,
labelLines: this.getLabelLines(),
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
shape: 'circle',
size: 12,
borderWidth: 2,
},
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
size: 12,
borderWidth: 2,
},
];
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [{ from: bounds.left, to: bounds.right, color: colors.axisMarkerAreaFill }];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [{ from: bounds.top, to: bounds.bottom, color: colors.axisMarkerAreaFill }];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
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: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
const handleTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !this.containsPoint(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(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
this.resetToIdle();
return;
}
if (geometry.width < MIN_RECTANGLE_WIDTH || geometry.height < MIN_RECTANGLE_HEIGHT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
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.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.deselect();
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(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 nextStartPoint = this.clampPointToContainer({
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
});
const nextEndPoint = this.clampPointToContainer({
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
});
this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const nextPoint = this.clampPointToContainer(point);
if (this.activeDragTarget === 'start') {
this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
return;
}
this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return;
}
this.startTime = startAnchor.time;
this.startPrice = startAnchor.price;
this.endTime = endAnchor.time;
this.endPrice = endAnchor.price;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): DiapsonGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const top = Math.round(Math.min(startPoint.y, endPoint.y));
const bottom = Math.round(Math.max(startPoint.y, endPoint.y));
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
startPoint,
endPoint,
};
}
private getLabelLines(): string[] {
if (this.rangeMode === 'date') {
const metrics = this.getDateMetrics();
if (!metrics) {
return [];
}
const firstLine = metrics.elapsedText
? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
: `${metrics.barsCount} ${t('bars')}`;
if (!metrics.volumeText) {
return [firstLine];
}
return [firstLine, `${t('Vol')} ${metrics.volumeText}`];
}
const metrics = this.getPriceMetrics();
if (!metrics) {
return [];
}
const percentText =
metrics.percent < 0 ? `-${formatPercent(Math.abs(metrics.percent))}` : formatPercent(Math.abs(metrics.percent));
const absSteps = Math.abs(metrics.steps);
let stepsText = '';
if (Number.isInteger(absSteps)) {
stepsText = absSteps.toString();
} else if (absSteps >= 1000) {
stepsText = absSteps.toFixed(0);
} else if (absSteps >= 100) {
stepsText = absSteps.toFixed(1);
} else {
stepsText = absSteps.toFixed(2);
}
if (metrics.steps < 0) {
stepsText = `-${stepsText}`;
}
const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';
return [`${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`];
}
private getDateMetrics(): DateMetrics | null {
const leftTime = this.getLeftTimeValue();
const rightTime = this.getRightTimeValue();
if (!leftTime || !rightTime) {
return null;
}
const barsCount = this.getBarsCount();
const durationSeconds = Math.max(0, Math.floor(Math.abs(Number(rightTime) - Number(leftTime))));
const days = Math.floor(durationSeconds / 86400);
const hours = Math.floor((durationSeconds % 86400) / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
const elapsedParts: string[] = [];
if (days > 0) {
elapsedParts.push(`${days}d`);
}
if (hours > 0) {
elapsedParts.push(`${hours}h`);
}
if (minutes > 0) {
elapsedParts.push(`${minutes}m`);
}
if (elapsedParts.length === 0) {
elapsedParts.push(`${seconds}s`);
}
const volume = this.getVolumeInRange();
return {
barsCount,
elapsedText: elapsedParts.slice(0, 2).join(' '),
volumeText: volume > 0 ? formatVolume(volume) : '',
};
}
private getPriceMetrics(): PriceMetrics | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const delta = this.endPrice - this.startPrice;
return {
delta,
percent: this.startPrice !== 0 ? (delta / Math.abs(this.startPrice)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
return Math.abs(endIndex - startIndex);
}
private getVolumeInRange(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => Number(item.time) === Number(time));
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.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 = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
if (!time) {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getLeftTimeValue(): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return this.startTime;
}
return Number(startX) <= Number(endX) ? this.startTime : this.endTime;
}
private getRightTimeValue(): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return this.endTime;
}
return Number(startX) <= Number(endX) ? this.endTime : this.startTime;
}
private getTopPriceValue(): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return Math.max(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.startPrice : this.endPrice;
}
private getBottomPriceValue(): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return Math.min(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<DiapsonHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
if (handle === 'body') {
return 'move';
}
const geometry = this.getGeometry();
if (!geometry) {
return 'default';
}
const sameDirection =
(geometry.endPoint.x - geometry.startPoint.x >= 0 && geometry.endPoint.y - geometry.startPoint.y >= 0) ||
(geometry.endPoint.x - geometry.startPoint.x < 0 && geometry.endPoint.y - geometry.startPoint.y < 0);
return sameDirection ? 'nwse-resize' : 'nesw-resize';
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { FibonacciRetracementPaneView } from './paneView';
import {
cloneFibonacciRetracementSettings,
createDefaultSettings,
FibonacciRetracementSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab, SettingsValues } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementHandle, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;
interface FibonacciRetracementState {
hidden: boolean;
mode: FibonacciRetracementMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: FibonacciRetracementSettings;
}
export interface FibonacciRetracementLevelRenderData {
id: string;
value: number;
price: number;
y: number;
color: string;
text: string;
}
export interface FibonacciRetracementAreaRenderData {
top: number;
bottom: number;
color: string;
}
interface FibonacciRetracementGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
levels: FibonacciRetracementLevelRenderData[];
areas: FibonacciRetracementAreaRenderData[];
}
type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
backgroundOpacity: number;
}
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
export class FibonacciRetracement
extends SeriesDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: FibonacciRetracementSettings = createDefaultSettings();
protected mode: FibonacciRetracementMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: FibonacciRetracementHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: FibonacciRetracementState | null = null;
private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;
private readonly paneView: FibonacciRetracementPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: FibonacciRetracementParams) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new FibonacciRetracementPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): FibonacciRetracementState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<FibonacciRetracementState>;
this.hidden = next.hidden ?? this.hidden;
this.mode = next.mode ?? this.mode;
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.startPrice = next.startPrice ?? this.startPrice;
this.endPrice = next.endPrice ?? this.endPrice;
if (next.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), next.settings);
}
this.render();
}
public getSettings(): SettingsValues {
return getFibonacciRetracementSettingsValues(this.settings);
}
public getSettingsTabs(): SettingsTab[] {
return getFibonacciRetracementSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = mergeFibonacciRetracementSettings(this.settings, settings);
this.render();
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
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.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): FibonacciRetracementRenderData | null {
const geometry = this.hidden ? null : this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showBackground: this.settings.showBackground,
backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
reverse: this.settings.reverse,
labelsPosition: this.settings.labelsPosition,
showPrices: this.settings.showPrices,
showLevelValues: this.settings.showLevelValues,
fontSize: this.settings.fontSize,
isBold: this.settings.isBold,
isItalic: this.settings.isItalic,
};
}
protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
},
{
id: 'start',
...geometry.startPoint,
},
];
}
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() && !this.containsPoint(point)) {
return null;
}
if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: this.isSelected() ? 'grab' : 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [{ from: bounds.left, to: bounds.right, color: colors.axisMarkerAreaFill }];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [{ from: bounds.top, to: bounds.bottom, color: colors.axisMarkerAreaFill }];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
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);
} else {
this.resize(point);
}
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (
!geometry ||
geometry.width < MIN_DISTANCE ||
Math.abs(geometry.startPoint.y - geometry.endPoint.y) < MIN_DISTANCE
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<FibonacciRetracementHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + priceOffset;
}
private resize(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startTime = anchor.time;
this.startPrice = anchor.price;
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): FibonacciRetracementGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
const left = Math.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const levels = this.getLevels();
const top = Math.min(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
const bottom = Math.max(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
levels,
areas: this.getAreas(levels),
};
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
if (this.startPrice === null || this.endPrice === null) {
return [];
}
return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((result, level) => {
const price = this.getLevelPrice(level.value);
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return result;
}
result.push({
id: level.id,
value: level.value,
price,
y: Math.round(Number(y)),
color: level.color,
text: this.getLevelText(level.value, price),
});
return result;
}, []);
}
private getLevelPrice(value: number): number {
const startPrice = this.startPrice ?? 0;
const endPrice = this.endPrice ?? 0;
return this.settings.reverse
? startPrice + (endPrice - startPrice) * value
: endPrice + (startPrice - endPrice) * value;
}
private getAreas(levels: FibonacciRetracementLevelRenderData[]): FibonacciRetracementAreaRenderData[] {
if (!this.settings.showBackground || levels.length < 2) {
return [];
}
const orderedLevels = [...levels].sort((a, b) => a.value - b.value);
return orderedLevels.slice(0, -1).map((level, index) => {
const nextLevel = orderedLevels[index + 1];
return {
top: Math.min(level.y, nextLevel.y),
bottom: Math.max(level.y, nextLevel.y),
color: nextLevel.color,
};
});
}
private getLevelText(value: number, price: number): string {
const parts: string[] = [];
if (this.settings.showLevelValues) {
parts.push(formatLevelLabel(value));
}
if (this.settings.showPrices) {
parts.push(`(${formatPrice(price) ?? String(price)})`);
}
return parts.join(' ');
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
return geometry ? { left: geometry.left, right: geometry.right } : null;
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
return geometry ? { top: geometry.top, bottom: geometry.bottom } : null;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
}
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 = kind === 'start' ? this.startTime : this.endTime;
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 getPriceValueForLabel(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const targetY = kind === 'top' ? geometry.top : geometry.bottom;
const edgeLevel = geometry.levels.find((level) => level.y === targetY);
if (edgeLevel) {
return edgeLevel.price;
}
return kind === 'top'
? Math.max(this.startPrice ?? 0, this.endPrice ?? 0)
: Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const bounds: Bounds = {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
};
if (this.settings.showBackground && isPointInBounds(point, bounds, BODY_HIT_TOLERANCE)) {
return true;
}
const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;
return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { LineMarker, type SettingsTab } from '@src/types';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { LineDrawingPaneView } from './paneView';
import {
createDefaultSettings,
getLineDrawingSettingsTabs,
LineDrawingMarkers,
LineDrawingSettings,
LineDrawingStyle,
LineDrawingTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type LineDrawingHandleKey = 'start' | 'end';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface LineDrawingParams extends BaseDrawingParams {
defaultMarkers?: Partial<LineDrawingMarkers>;
}
interface LineDrawingState {
hidden: boolean;
mode: LineDrawingMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: LineDrawingSettings;
}
interface LineDrawingGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class LineDrawing
extends SeriesDrawingBase<LineDrawingSettings, LineDrawingHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly defaultMarkers: LineDrawingMarkers;
protected settings: LineDrawingSettings;
protected mode: LineDrawingMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: LineDrawingState | null = null;
private readonly paneView: LineDrawingPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
defaultMarkers = {},
}: LineDrawingParams) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.defaultMarkers = {
startMarker: LineMarker.normal,
endMarker: LineMarker.normal,
...defaultMarkers,
};
this.settings = createDefaultSettings(this.defaultMarkers);
this.paneView = new LineDrawingPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): LineDrawingState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<LineDrawingState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(this.defaultMarkers),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getLineDrawingSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
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.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): LineDrawingRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<LineDrawingHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'end',
...geometry.endPoint,
shape: 'circle',
borderWidth: 2,
},
];
}
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.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'move',
externalId: 'line-drawing',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'line-drawing',
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 getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearLine(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;
}
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearLine = this.isPointNearLine(point);
const isDrawingHit = pointTarget !== null || isNearLine;
if (!this.isSelected()) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (isNearLine) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.deselect();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: LineDrawingMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-end') {
this.endAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: snapshot.endAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): LineDrawingGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
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 isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}
import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { ParallelChannelPaneView } from './paneView';
import {
createDefaultSettings,
getParallelChannelSettingsTabs,
ParallelChannelSettings,
ParallelChannelStyle,
ParallelChannelTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab } from '@src/types';
type ParallelChannelMode = 'idle' | 'drawing-line' | 'drawing-channel' | 'ready' | 'dragging';
type ParallelChannelDragTarget =
| 'main-start'
| 'main-middle'
| 'main-end'
| 'parallel-start'
| 'parallel-middle'
| 'parallel-end'
| 'body';
type ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';
type ParallelChannelParams = BaseDrawingParams;
interface ParallelChannelState {
hidden: boolean;
mode: ParallelChannelMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
priceOffset: number | null;
settings: ParallelChannelSettings;
}
interface ParallelChannelGeometry {
startPoint: Point;
mainMiddlePoint: Point;
endPoint: Point;
parallelStartPoint: Point;
parallelMiddlePoint: Point;
parallelEndPoint: Point;
middleStartPoint: Point;
middleEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface ParallelChannelRenderData
extends ParallelChannelGeometry,
ParallelChannelStyle,
ParallelChannelTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;
const VERTICAL_LINE_TOLERANCE = 0.001;
export class ParallelChannel
extends SeriesDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
implements ISeriesDrawing
{
private openSettings?: () => void;
protected settings: ParallelChannelSettings = createDefaultSettings();
protected mode: ParallelChannelMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private priceOffset: number | null = null;
private activeDragTarget: ParallelChannelDragTarget | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: ParallelChannelState | null = null;
private paneView: ParallelChannelPaneView;
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
private mainStartPriceAxisView: CustomPriceAxisView;
private mainEndPriceAxisView: CustomPriceAxisView;
private parallelStartPriceAxisView: CustomPriceAxisView;
private parallelEndPriceAxisView: CustomPriceAxisView;
constructor({
container,
interaction,
formatObservable,
openSettings,
chart,
series,
initialEvent,
}: ParallelChannelParams) {
super({
chart,
series,
container,
interaction,
});
this.openSettings = openSettings;
this.paneView = new ParallelChannelPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.mainStartPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main-start',
});
this.mainEndPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main-end',
});
this.parallelStartPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'parallel-start',
});
this.parallelEndPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'parallel-end',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing-line' || this.mode === 'drawing-channel';
}
public getState(): ParallelChannelState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
priceOffset: this.priceOffset,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<ParallelChannelState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (nextState.mode) {
this.mode = nextState.mode === 'dragging' ? 'ready' : nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('priceOffset' in nextState) {
this.priceOffset = nextState.priceOffset ?? null;
}
if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getParallelChannelSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.mainStartPriceAxisView,
this.mainEndPriceAxisView,
this.parallelStartPriceAxisView,
this.parallelEndPriceAxisView,
]);
}
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.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [
this.mainStartPriceAxisView,
this.mainEndPriceAxisView,
this.parallelStartPriceAxisView,
this.parallelEndPriceAxisView,
];
}
public getRenderData(): ParallelChannelRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<ParallelChannelHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'parallel-end',
...geometry.parallelEndPoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'parallel-middle',
...geometry.parallelMiddlePoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'parallel-start',
...geometry.parallelStartPoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'main-end',
...geometry.endPoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'main-middle',
...geometry.mainMiddlePoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'main-start',
...geometry.startPoint,
shape: 'circle',
borderWidth: 2,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
}
const point = { x, y };
const pointTarget = this.getDrawingHandleAtPoint(point);
const isChannelHit = this.isPointOnChannel(point);
if (!pointTarget && !isChannelHit) {
return null;
}
if (!this.isSelected()) {
return {
cursorStyle: 'pointer',
externalId: 'parallel-channel',
zOrder: 'top',
};
}
return {
cursorStyle: pointTarget ? 'move' : 'grab',
externalId: 'parallel-channel',
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 getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || !isPriceLabelKind(kind)) {
return null;
}
const price = this.getPriceLabelValue(kind);
if (price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.isPointOnChannel(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);
switch (this.mode) {
case 'idle':
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
case 'drawing-line':
event.preventDefault();
event.stopPropagation();
this.setEndAnchor(point);
if (!this.hasValidMainLine()) {
this.render();
return;
}
this.priceOffset = 0;
this.mode = 'drawing-channel';
this.render();
return;
case 'drawing-channel':
event.preventDefault();
event.stopPropagation();
this.setPriceOffset(point);
if (!this.hasValidChannelWidth()) {
this.render();
return;
}
this.finishDrawing();
return;
case 'ready':
break;
default:
return;
}
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isChannelHit = this.isPointOnChannel(point);
const isSelected = this.isSelected();
if (!isSelected && !pointTarget && !isChannelHit) {
return;
}
if (!isSelected) {
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (pointTarget || isChannelHit) {
event.preventDefault();
event.stopPropagation();
this.startDragging(pointTarget ?? 'body', point, event.pointerId);
return;
}
this.deselect();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing-line') {
this.setEndAnchor(point);
this.render();
return;
}
if (this.mode === 'drawing-channel') {
this.setPriceOffset(point);
this.render();
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.activeDragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.applyDrag(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
protected getGeometry(): ParallelChannelGeometry | null {
if (!this.startAnchor || !this.endAnchor || this.priceOffset === null) {
return null;
}
const startPoint = this.getAnchorPoint(this.startAnchor);
const endPoint = this.getAnchorPoint(this.endAnchor);
const parallelStartPoint = this.getAnchorPoint({
time: this.startAnchor.time,
price: this.startAnchor.price + this.priceOffset,
});
const parallelEndPoint = this.getAnchorPoint({
time: this.endAnchor.time,
price: this.endAnchor.price + this.priceOffset,
});
if (!startPoint || !endPoint || !parallelStartPoint || !parallelEndPoint) {
return null;
}
const mainMiddlePoint = getMiddlePoint(startPoint, endPoint);
const parallelMiddlePoint = getMiddlePoint(parallelStartPoint, parallelEndPoint);
const middleStartPoint = getMiddlePoint(startPoint, parallelStartPoint);
const middleEndPoint = getMiddlePoint(endPoint, parallelEndPoint);
const points = [startPoint, endPoint, parallelStartPoint, parallelEndPoint];
return {
startPoint,
mainMiddlePoint,
endPoint,
parallelStartPoint,
parallelMiddlePoint,
parallelEndPoint,
middleStartPoint,
middleEndPoint,
left: Math.min(...points.map((point) => point.x)),
right: Math.max(...points.map((point) => point.x)),
top: Math.min(...points.map((point) => point.y)),
bottom: Math.max(...points.map((point) => point.y)),
};
}
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.priceOffset = 0;
this.mode = 'drawing-line';
this.render();
}
private finishDrawing(): void {
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(target: ParallelChannelDragTarget, point: Point, pointerId: number): void {
this.mode = 'dragging';
this.activeDragTarget = target;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private applyDrag(point: Point): void {
switch (this.activeDragTarget) {
case 'main-start':
this.moveMainEdge('start', point);
break;
case 'main-middle':
this.moveMainMiddle(point);
break;
case 'main-end':
this.moveMainEdge('end', point);
break;
case 'parallel-start':
this.moveParallelEdge('start', point);
break;
case 'parallel-middle':
this.moveParallelMiddle(point);
break;
case 'parallel-end':
this.moveParallelEdge('end', point);
break;
case 'body':
this.moveBody(point);
break;
default:
break;
}
}
private moveMainEdge(kind: TimeLabelKind, point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
const previousAnchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
if (this.hasValidMainLine()) {
return;
}
if (kind === 'start') {
this.startAnchor = previousAnchor;
} else {
this.endAnchor = previousAnchor;
}
}
private moveParallelEdge(kind: TimeLabelKind, point: Point): void {
const snapshot = this.dragStateSnapshot;
const anchor = this.createAnchor(point);
if (!snapshot || snapshot.priceOffset === null || !anchor) {
return;
}
const previousAnchor = kind === 'start' ? this.startAnchor : this.endAnchor;
const baseAnchor: Anchor = {
time: anchor.time,
price: anchor.price - snapshot.priceOffset,
};
if (kind === 'start') {
this.startAnchor = baseAnchor;
} else {
this.endAnchor = baseAnchor;
}
if (this.hasValidMainLine()) {
return;
}
if (kind === 'start') {
this.startAnchor = previousAnchor;
} else {
this.endAnchor = previousAnchor;
}
}
private moveMainMiddle(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null) {
return;
}
const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
const linePrice = this.getLinePriceAtX(snapshot.startAnchor, snapshot.endAnchor, point.x);
if (pointerPrice === null || linePrice === null) {
return;
}
const priceDelta = pointerPrice - linePrice;
this.startAnchor = {
...snapshot.startAnchor,
price: snapshot.startAnchor.price + priceDelta,
};
this.endAnchor = {
...snapshot.endAnchor,
price: snapshot.endAnchor.price + priceDelta,
};
this.priceOffset = snapshot.priceOffset - priceDelta;
if (this.hasValidChannelWidth()) {
return;
}
this.startAnchor = snapshot.startAnchor;
this.endAnchor = snapshot.endAnchor;
this.priceOffset = snapshot.priceOffset;
}
private moveParallelMiddle(point: Point): void {
const previousOffset = this.priceOffset;
this.setPriceOffset(point);
if (this.hasValidChannelWidth()) {
return;
}
this.priceOffset = previousOffset;
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceDelta = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
if (startTime === null || endTime === null) {
return;
}
this.startAnchor = {
time: startTime,
price: snapshot.startAnchor.price + priceDelta,
};
this.endAnchor = {
time: endTime,
price: snapshot.endAnchor.price + priceDelta,
};
this.priceOffset = snapshot.priceOffset;
}
private setEndAnchor(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
}
private setPriceOffset(point: Point): void {
if (!this.startAnchor || !this.endAnchor) {
return;
}
const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
const linePrice = this.getLinePriceAtX(this.startAnchor, this.endAnchor, point.x);
if (pointerPrice === null || linePrice === null) {
return;
}
this.priceOffset = pointerPrice - linePrice;
}
private getLinePriceAtX(startAnchor: Anchor, endAnchor: Anchor, x: number): number | null {
const startPoint = this.getAnchorPoint(startAnchor);
const endPoint = this.getAnchorPoint(endAnchor);
if (!startPoint || !endPoint) {
return null;
}
const deltaX = endPoint.x - startPoint.x;
if (Math.abs(deltaX) <= VERTICAL_LINE_TOLERANCE) {
return getPriceFromYCoordinate(this.series, (startPoint.y + endPoint.y) / 2);
}
const ratio = (x - startPoint.x) / deltaX;
const y = startPoint.y + (endPoint.y - startPoint.y) * ratio;
return getPriceFromYCoordinate(this.series, y);
}
private hasValidMainLine(): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return getDistance(geometry.startPoint, geometry.endPoint) >= MIN_LINE_SIZE;
}
private hasValidChannelWidth(): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
}
private isPointOnChannel(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
if (getDistanceToSegment(point, geometry.parallelStartPoint, geometry.parallelEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
if (
this.settings.showMiddleLine &&
getDistanceToSegment(point, geometry.middleStartPoint, geometry.middleEndPoint) <= LINE_HIT_TOLERANCE
) {
return true;
}
return isPointInPolygon(point, [
geometry.startPoint,
geometry.endPoint,
geometry.parallelEndPoint,
geometry.parallelStartPoint,
]);
}
private getPriceLabelValue(kind: PriceLabelKind): number | null {
if (!this.startAnchor || !this.endAnchor || this.priceOffset === null) {
return null;
}
switch (kind) {
case 'main-start':
return this.startAnchor.price;
case 'main-end':
return this.endAnchor.price;
case 'parallel-start':
return this.startAnchor.price + this.priceOffset;
case 'parallel-end':
return this.endAnchor.price + this.priceOffset;
default:
return null;
}
}
private getAnchorPoint(anchor: Anchor): Point | 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),
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'main-start' || kind === 'main-end' || kind === 'parallel-start' || kind === 'parallel-end';
}
function getMiddlePoint(startPoint: Point, endPoint: Point): Point {
return {
x: (startPoint.x + endPoint.x) / 2,
y: (startPoint.y + endPoint.y) / 2,
};
}
function getDistance(startPoint: Point, endPoint: Point): number {
return Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
}
function isPointInPolygon(point: Point, polygon: Point[]): boolean {
let isInside = false;
for (let index = 0, previousIndex = polygon.length - 1; index < polygon.length; previousIndex = index, index += 1) {
const currentPoint = polygon[index];
const previousPoint = polygon[previousIndex];
const intersects =
currentPoint.y > point.y !== previousPoint.y > point.y &&
point.x <
((previousPoint.x - currentPoint.x) * (point.y - currentPoint.y)) / (previousPoint.y - currentPoint.y) +
currentPoint.x;
if (intersects) {
isInside = !isInside;
}
}
return isInside;
}
import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab } from '@src/types';
type RayMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
type RayHandleKey = 'start' | 'direction';
type TimeLabelKind = 'start' | 'direction';
type PriceLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;
interface RayState {
hidden: boolean;
mode: RayMode;
startAnchor: Anchor | null;
directionAnchor: Anchor | null;
settings: RaySettings;
}
interface RayGeometry {
startPoint: Point;
directionPoint: Point;
rayEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RaySettings = createDefaultSettings();
protected mode: RayMode = 'idle';
private startAnchor: Anchor | null = null;
private directionAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RayState | null = null;
private readonly paneView: RayPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly directionTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly directionPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: RayParams) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new RayPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.directionTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'direction',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.directionPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'direction',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RayState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
directionAnchor: this.directionAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RayState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('directionAnchor' in nextState) {
this.directionAnchor = nextState.directionAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRaySettingTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.directionTimeAxisView,
this.startPriceAxisView,
this.directionPriceAxisView,
]);
}
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.startTimeAxisView, this.directionTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.directionPriceAxisView];
}
public getRenderData(): RayRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<RayHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
borderWidth: 2,
strokeColor: this.settings.lineColor,
},
{
id: 'direction',
...geometry.directionPoint,
shape: 'circle',
borderWidth: 2,
strokeColor: this.settings.lineColor,
},
];
}
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.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'move',
externalId: 'ray',
zOrder: 'top',
};
}
if (!this.isPointNearRay(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'ray',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
const from = Math.min(geometry.startPoint.x, geometry.directionPoint.x);
const to = Math.max(geometry.startPoint.x, geometry.directionPoint.x);
return [{ from, to, color: colors.axisMarkerAreaFill }];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
const from = Math.min(geometry.startPoint.y, geometry.directionPoint.y);
const to = Math.max(geometry.startPoint.y, geometry.directionPoint.y);
return [{ from, to, color: colors.axisMarkerAreaFill }];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'direction')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'direction')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearRay(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;
}
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearRay = this.isPointNearRay(point);
const isDrawingHit = pointTarget !== null || isNearRay;
if (!this.isSelected()) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'direction') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-direction', point, event.pointerId);
return;
}
if (isNearRay) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.deselect();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-direction') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-direction' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.directionAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.directionAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.directionPoint.x - geometry.startPoint.x,
geometry.directionPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: RayMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-direction') {
this.directionAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.directionAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const nextDirectionTime = shiftTimeByPixels(this.chart, snapshot.directionAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextDirectionTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.directionAnchor = {
time: nextDirectionTime,
price: snapshot.directionAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): RayGeometry | null {
if (!this.startAnchor || !this.directionAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const directionX = getXCoordinateFromTime(this.chart, this.directionAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const directionY = getYCoordinateFromPrice(this.series, this.directionAnchor.price);
if (startX === null || directionX === null || startY === null || directionY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const directionPoint = {
x: Math.round(Number(directionX)),
y: Math.round(Number(directionY)),
};
const rayEndPoint = this.getRayEndPoint(startPoint, directionPoint);
if (!rayEndPoint) {
return null;
}
return {
startPoint,
directionPoint,
rayEndPoint,
left: Math.min(startPoint.x, rayEndPoint.x),
right: Math.max(startPoint.x, rayEndPoint.x),
top: Math.min(startPoint.y, rayEndPoint.y),
bottom: Math.max(startPoint.y, rayEndPoint.y),
};
}
private getRayEndPoint(startPoint: Point, directionPoint: Point): Point | null {
const dx = directionPoint.x - startPoint.x;
const dy = directionPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return null;
}
const { width, height } = this.container.getBoundingClientRect();
const candidates: Point[] = [];
if (dx !== 0) {
const leftT = (0 - startPoint.x) / dx;
const rightT = (width - startPoint.x) / dx;
const leftY = startPoint.y + leftT * dy;
const rightY = startPoint.y + rightT * dy;
if (leftT >= 1 && leftY >= 0 && leftY <= height) {
candidates.push({ x: 0, y: leftY });
}
if (rightT >= 1 && rightY >= 0 && rightY <= height) {
candidates.push({ x: width, y: rightY });
}
}
if (dy !== 0) {
const topT = (0 - startPoint.y) / dy;
const bottomT = (height - startPoint.y) / dy;
const topX = startPoint.x + topT * dx;
const bottomX = startPoint.x + bottomT * dx;
if (topT >= 1 && topX >= 0 && topX <= width) {
candidates.push({ x: topX, y: 0 });
}
if (bottomT >= 1 && bottomX >= 0 && bottomX <= width) {
candidates.push({ x: bottomX, y: height });
}
}
return candidates[0] ?? directionPoint;
}
private isPointNearRay(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
normalizeBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
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 { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
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 RectangleGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}
export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
protected mode: RectangleMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: RectangleHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RectangleState | null = null;
private dragGeometrySnapshot: RectangleGeometry | null = null;
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 = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RectangleState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
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;
}
if ('startTime' in nextState) {
this.startTime = nextState.startTime ?? null;
}
if ('endTime' in nextState) {
this.endTime = nextState.endTime ?? null;
}
if ('startPrice' in nextState) {
this.startPrice = nextState.startPrice ?? null;
}
if ('endPrice' in nextState) {
this.endPrice = nextState.endPrice ?? null;
}
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.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [{ from: bounds.left, to: bounds.right, color: colors.axisMarkerAreaFill }];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [{ from: bounds.top, to: bounds.bottom, color: colors.axisMarkerAreaFill }];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'left' && kind !== 'right')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return { coordinate, text, textColor: colors.chartPriceLineText, backgroundColor: colors.axisMarkerLabelFill };
}
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.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const clampedPoint = this.clampPointToContainer(point);
const anchor = this.createAnchor(clampedPoint);
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
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.dragStateSnapshot = this.getState();
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.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
if (handleTarget) {
return handleTarget;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + priceOffset;
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const clampedPoint = this.clampPointToContainer(point);
let { left } = geometry;
let { right } = geometry;
let { top } = geometry;
let { 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);
const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);
if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
return false;
}
this.startTime = startTime;
this.endTime = endTime;
this.startPrice = startPrice;
this.endPrice = endPrice;
return true;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): RectangleGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const left = Math.round(Math.min(Number(startX), Number(endX)));
const right = Math.round(Math.max(Number(startX), Number(endX)));
const top = Math.round(Math.min(Number(startY), Number(endY)));
const bottom = Math.round(Math.max(Number(startY), Number(endY)));
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.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);
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return kind === 'left' ? this.startTime : this.endTime;
}
const startIsLeft = Number(startX) <= Number(endX);
if (kind === 'left') {
return startIsLeft ? this.startTime : this.endTime;
}
return startIsLeft ? this.endTime : this.startTime;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
}
const startIsTop = Number(startY) <= Number(endY);
if (kind === 'top') {
return startIsTop ? this.startPrice : this.endPrice;
}
return startIsTop ? this.endPrice : this.startPrice;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return 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 shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RegressionTrendPaneView } from './paneView';
import {
createDefaultSettings,
getRegressionTrendSettingsTabs,
RegressionTrendSettings,
RegressionTrendStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'start' | 'end' | 'body' | null;
type RegressionTrendHandleKey = Exclude<DragTarget, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
type RegressionTrendParams = BaseDrawingParams;
interface RegressionTrendState {
hidden: boolean;
mode: RegressionTrendMode;
startTime: Time | null;
endTime: Time | null;
settings: RegressionTrendSettings;
}
interface RegressionSeriesData {
time: Time;
close?: number;
value?: number;
}
interface RegressionResult {
baseStartPrice: number;
baseEndPrice: number;
upperStartPrice: number;
upperEndPrice: number;
lowerStartPrice: number;
lowerEndPrice: number;
correlation: number;
}
interface RegressionTrendGeometry {
baseStartPoint: Point;
baseEndPoint: Point;
upperStartPoint: Point;
upperEndPoint: Point;
lowerStartPoint: Point;
lowerEndPoint: Point;
baseStartPrice: number;
baseEndPrice: number;
correlation: number;
left: number;
right: number;
top: number;
bottom: number;
}
export interface RegressionTrendRenderData extends RegressionTrendGeometry, RegressionTrendStyle {
showChannel: boolean;
}
const LINE_HIT_TOLERANCE = 8;
const MIN_BAR_DISTANCE = 1;
const REGRESSION_DEVIATION = 2;
export class RegressionTrend
extends SeriesDrawingBase<RegressionTrendSettings, RegressionTrendHandleKey>
implements ISeriesDrawing
{
private openSettings?: () => void;
protected settings: RegressionTrendSettings = createDefaultSettings();
protected mode: RegressionTrendMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RegressionTrendState | null = null;
private paneView: RegressionTrendPaneView;
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
private startPriceAxisView: CustomPriceAxisView;
private endPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
openSettings,
initialEvent,
}: RegressionTrendParams) {
super({ chart, series, container, interaction });
this.openSettings = openSettings;
this.paneView = new RegressionTrendPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RegressionTrendState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<RegressionTrendState>;
this.hidden = next.hidden ?? this.hidden;
if (next.mode) {
this.mode = next.mode === 'dragging' ? 'ready' : next.mode;
}
if ('startTime' in next) {
this.startTime = next.startTime ?? null;
}
if ('endTime' in next) {
this.endTime = next.endTime ?? null;
}
if (next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRegressionTrendSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
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.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): RegressionTrendRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
showChannel: this.mode === 'ready' || this.mode === 'dragging',
};
}
protected getDrawingHandles(): readonly DrawingHandle<RegressionTrendHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'start',
...geometry.baseStartPoint,
shape: 'circle',
borderWidth: 2,
},
{
id: 'end',
...geometry.baseEndPoint,
shape: 'circle',
borderWidth: 2,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'regression-trend',
zOrder: 'top',
};
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
return null;
}
return {
cursorStyle: dragTarget === 'body' ? 'grab' : 'ew-resize',
externalId: 'regression-trend',
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();
const from = Math.min(geometry.baseStartPoint.y, geometry.baseEndPoint.y);
const to = Math.max(geometry.baseStartPoint.y, geometry.baseEndPoint.y);
return [{ from, to, color: colors.axisMarkerAreaFill }];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const time = kind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const price = kind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
const coordinate = getYCoordinateFromPrice(this.series, price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
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') {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
event.preventDefault();
event.stopPropagation();
this.endTime = time;
if (!this.hasValidRange()) {
this.render();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
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.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.mode = 'dragging';
this.hideCrosshair();
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
const time = this.getBarTime(point.x);
if (time !== null) {
this.endTime = time;
this.render();
}
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.applyDrag(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.mode = 'ready';
this.showCrosshair();
this.render();
};
protected getGeometry(): RegressionTrendGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const regression = this.getRegression();
if (!regression) {
return null;
}
const baseStartPoint = this.getPoint(this.startTime, regression.baseStartPrice);
const baseEndPoint = this.getPoint(this.endTime, regression.baseEndPrice);
const upperStartPoint = this.getPoint(this.startTime, regression.upperStartPrice);
const upperEndPoint = this.getPoint(this.endTime, regression.upperEndPrice);
const lowerStartPoint = this.getPoint(this.startTime, regression.lowerStartPrice);
const lowerEndPoint = this.getPoint(this.endTime, regression.lowerEndPrice);
if (!baseStartPoint || !baseEndPoint || !upperStartPoint || !upperEndPoint || !lowerStartPoint || !lowerEndPoint) {
return null;
}
const points = [upperStartPoint, upperEndPoint, lowerStartPoint, lowerEndPoint];
return {
baseStartPoint,
baseEndPoint,
upperStartPoint,
upperEndPoint,
lowerStartPoint,
lowerEndPoint,
baseStartPrice: regression.baseStartPrice,
baseEndPrice: regression.baseEndPrice,
correlation: regression.correlation,
left: Math.min(...points.map((item) => item.x)),
right: Math.max(...points.map((item) => item.x)),
top: Math.min(...points.map((item) => item.y)),
bottom: Math.max(...points.map((item) => item.y)),
};
}
private startDrawing(point: Point): void {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
this.startTime = time;
this.endTime = time;
this.mode = 'drawing';
this.render();
}
private applyDrag(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot || snapshot.startTime === null || snapshot.endTime === null) {
return;
}
switch (this.activeDragTarget) {
case 'start':
this.moveEdge('start', point);
break;
case 'end':
this.moveEdge('end', point);
break;
case 'body':
this.moveWhole(snapshot, point);
break;
default:
break;
}
}
private moveEdge(kind: TimeLabelKind, point: Point): void {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
const previousTime = kind === 'start' ? this.startTime : this.endTime;
if (kind === 'start') {
this.startTime = time;
} else {
this.endTime = time;
}
if (this.hasValidRange()) {
return;
}
if (kind === 'start') {
this.startTime = previousTime;
} else {
this.endTime = previousTime;
}
}
private moveWhole(snapshot: RegressionTrendState, point: Point): void {
if (!this.dragStartPoint || snapshot.startTime === null || snapshot.endTime === null) {
return;
}
const data = this.getSeriesData();
const dragStartIndex = this.getBarIndexByX(this.dragStartPoint.x, data);
const currentIndex = this.getBarIndexByX(point.x, data);
const startIndex = this.getBarIndex(snapshot.startTime, data);
const endIndex = this.getBarIndex(snapshot.endTime, data);
if (dragStartIndex === null || currentIndex === null || startIndex === null || endIndex === null) {
return;
}
const rawOffset = currentIndex - dragStartIndex;
const minIndex = Math.min(startIndex, endIndex);
const maxIndex = Math.max(startIndex, endIndex);
const minOffset = -minIndex;
const maxOffset = data.length - 1 - maxIndex;
const offset = Math.max(minOffset, Math.min(rawOffset, maxOffset));
this.startTime = data[startIndex + offset].time;
this.endTime = data[endIndex + offset].time;
}
private getRegression(): RegressionResult | null {
const data = this.getSeriesData();
const range = this.getBarRange(data);
if (!range) {
return null;
}
const values: number[] = [];
for (let index = range.left; index <= range.right; index += 1) {
const value = getSeriesValue(data[index]);
if (value !== null) {
values.push(value);
}
}
if (values.length < 2) {
return null;
}
const regression = calculateRegression(values);
const offset = regression.deviation * REGRESSION_DEVIATION;
const baseStartPrice = range.reversed ? regression.endValue : regression.startValue;
const baseEndPrice = range.reversed ? regression.startValue : regression.endValue;
return {
baseStartPrice,
baseEndPrice,
upperStartPrice: baseStartPrice + offset,
upperEndPrice: baseEndPrice + offset,
lowerStartPrice: baseStartPrice - offset,
lowerEndPrice: baseEndPrice - offset,
correlation: regression.correlation,
};
}
private getBarRange(data = this.getSeriesData()): {
left: number;
right: number;
reversed: boolean;
} | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startIndex = this.getBarIndex(this.startTime, data);
const endIndex = this.getBarIndex(this.endTime, data);
if (startIndex === null || endIndex === null) {
return null;
}
return {
left: Math.min(startIndex, endIndex),
right: Math.max(startIndex, endIndex),
reversed: startIndex > endIndex,
};
}
private hasValidRange(): boolean {
const range = this.getBarRange();
if (!range) {
return false;
}
return range.right - range.left >= MIN_BAR_DISTANCE;
}
private getBarTime(x: number): Time | null {
const time = getTimeFromXCoordinate(this.chart, x);
if (typeof time !== 'number') {
return null;
}
const data = this.getSeriesData();
const index = findNearestBarIndex(data, time);
if (index === null) {
return null;
}
return data[index].time;
}
private getBarIndexByX(x: number, data: readonly RegressionSeriesData[]): number | null {
const time = getTimeFromXCoordinate(this.chart, x);
if (typeof time !== 'number') {
return null;
}
return findNearestBarIndex(data, time);
}
private getBarIndex(time: Time, data: readonly RegressionSeriesData[]): number | null {
if (typeof time !== 'number') {
return null;
}
return findNearestBarIndex(data, time);
}
private getSeriesData(): readonly RegressionSeriesData[] {
return this.series.data() as readonly RegressionSeriesData[];
}
private getPoint(time: Time, price: number): Point | null {
const x = getXCoordinateFromTime(this.chart, time, this.series);
const y = getYCoordinateFromPrice(this.series, price);
if (x === null || y === null) {
return null;
}
return {
x: Number(x),
y: Number(y),
};
}
private getDragTarget(point: Point): DragTarget {
const handle = this.getDrawingHandleAtPoint(point);
if (handle) {
return handle.id;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
if (getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
if (getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
return isPointInPolygon(point, [
geometry.upperStartPoint,
geometry.upperEndPoint,
geometry.lowerEndPoint,
geometry.lowerStartPoint,
]);
}
}
function calculateRegression(values: readonly number[]): {
startValue: number;
endValue: number;
deviation: number;
correlation: number;
} {
const count = values.length;
const meanX = (count - 1) / 2;
const meanY = values.reduce((sum, value) => sum + value, 0) / count;
let sumXX = 0;
let sumXY = 0;
let sumYY = 0;
for (let index = 0; index < count; index += 1) {
const x = index - meanX;
const y = values[index] - meanY;
sumXX += x * x;
sumXY += x * y;
sumYY += y * y;
}
const slope = sumXX === 0 ? 0 : sumXY / sumXX;
const intercept = meanY - slope * meanX;
let residualSum = 0;
for (let index = 0; index < count; index += 1) {
const expected = intercept + slope * index;
const residual = values[index] - expected;
residualSum += residual * residual;
}
const denominator = Math.sqrt(sumXX * sumYY);
return {
startValue: intercept,
endValue: intercept + slope * (count - 1),
deviation: Math.sqrt(residualSum / count),
correlation: denominator === 0 ? 0 : sumXY / denominator,
};
}
function getSeriesValue(data: RegressionSeriesData): number | null {
if (typeof data.close === 'number' && Number.isFinite(data.close)) {
return data.close;
}
if (typeof data.value === 'number' && Number.isFinite(data.value)) {
return data.value;
}
return null;
}
function findNearestBarIndex(data: readonly RegressionSeriesData[], targetTime: number): number | null {
if (!data.length) {
return null;
}
let left = 0;
let right = data.length - 1;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const { time } = data[middle];
if (typeof time !== 'number') {
return findNearestBarIndexLinear(data, targetTime);
}
if (time === targetTime) {
return middle;
}
if (time < targetTime) {
left = middle + 1;
} else {
right = middle - 1;
}
}
const nextIndex = Math.min(left, data.length - 1);
const previousIndex = Math.max(0, nextIndex - 1);
const nextTime = data[nextIndex].time;
const previousTime = data[previousIndex].time;
if (typeof nextTime !== 'number' || typeof previousTime !== 'number') {
return findNearestBarIndexLinear(data, targetTime);
}
return Math.abs(previousTime - targetTime) <= Math.abs(nextTime - targetTime) ? previousIndex : nextIndex;
}
function findNearestBarIndexLinear(data: readonly RegressionSeriesData[], targetTime: number): number | null {
let result: number | null = null;
let minDistance = Number.POSITIVE_INFINITY;
data.forEach((item, index) => {
if (typeof item.time !== 'number') {
return;
}
const distance = Math.abs(item.time - targetTime);
if (distance >= minDistance) {
return;
}
minDistance = distance;
result = index;
});
return result;
}
function isPointInPolygon(point: Point, polygon: Point[]): boolean {
let inside = false;
for (let index = 0, previousIndex = polygon.length - 1; index < polygon.length; previousIndex = index, index += 1) {
const current = polygon[index];
const previous = polygon[previousIndex];
const intersects =
current.y > point.y !== previous.y > point.y &&
point.x < ((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x;
if (intersects) {
inside = !inside;
}
}
return inside;
}