Загрузка данных
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitivePaneViewZOrder,
} from 'lightweight-charts';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
interface CustomPriceAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
zOrder?: PrimitivePaneViewZOrder;
}
interface CustomPriceAxisViewParams {
getAxisLabel(labelKind: string): AxisLabel | null;
labelKind: string;
}
export class CustomPriceAxisPaneView implements IPrimitivePaneView {
private readonly rendererInstance: CustomPriceAxisPaneRenderer;
private readonly paneZOrder: PrimitivePaneViewZOrder;
constructor({ getAxisSegments, zOrder = 'bottom' }: CustomPriceAxisPaneViewParams) {
this.rendererInstance = new CustomPriceAxisPaneRenderer(getAxisSegments);
this.paneZOrder = zOrder;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return this.rendererInstance;
}
public zOrder(): PrimitivePaneViewZOrder {
return this.paneZOrder;
}
}
class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const axisSegments = this.getAxisSegments();
if (!axisSegments.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, verticalPixelRatio, bitmapSize }) => {
context.save();
for (const axisSegment of axisSegments) {
const segmentStart = Math.min(axisSegment.from, axisSegment.to) * verticalPixelRatio;
const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * verticalPixelRatio;
context.fillStyle = axisSegment.color;
context.fillRect(0, segmentStart, bitmapSize.width, segmentEnd - segmentStart);
}
context.restore();
});
}
}
export class CustomPriceAxisView implements ISeriesPrimitiveAxisView {
private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
private readonly labelKind: string;
constructor({ getAxisLabel, labelKind }: CustomPriceAxisViewParams) {
this.getAxisLabel = getAxisLabel;
this.labelKind = labelKind;
}
public update(): void {}
public visible(): boolean {
return Boolean(this.getCurrentLabel()?.text);
}
public tickVisible(): boolean {
return false;
}
public coordinate(): number {
return this.getCurrentLabel()?.coordinate ?? 0;
}
public text(): string {
return this.getCurrentLabel()?.text ?? '';
}
public textColor(): string {
return this.getCurrentLabel()?.textColor ?? '';
}
public backColor(): string {
return this.getCurrentLabel()?.backgroundColor ?? '';
}
private getCurrentLabel(): AxisLabel | null {
return this.getAxisLabel(this.labelKind);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitivePaneViewZOrder,
} from 'lightweight-charts';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
interface CustomTimeAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
zOrder?: PrimitivePaneViewZOrder;
}
interface CustomTimeAxisViewParams {
getAxisLabel(labelKind: string): AxisLabel | null;
labelKind: string;
}
export class CustomTimeAxisPaneView implements IPrimitivePaneView {
private readonly rendererInstance: CustomTimeAxisPaneRenderer;
private readonly paneZOrder: PrimitivePaneViewZOrder;
constructor({ getAxisSegments, zOrder = 'bottom' }: CustomTimeAxisPaneViewParams) {
this.rendererInstance = new CustomTimeAxisPaneRenderer(getAxisSegments);
this.paneZOrder = zOrder;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return this.rendererInstance;
}
public zOrder(): PrimitivePaneViewZOrder {
return this.paneZOrder;
}
}
class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const axisSegments = this.getAxisSegments();
if (!axisSegments.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, bitmapSize }) => {
context.save();
for (const axisSegment of axisSegments) {
const segmentStart = Math.min(axisSegment.from, axisSegment.to) * horizontalPixelRatio;
const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * horizontalPixelRatio;
context.fillStyle = axisSegment.color;
context.fillRect(segmentStart, 0, segmentEnd - segmentStart, bitmapSize.height);
}
context.restore();
});
}
}
export class CustomTimeAxisView implements ISeriesPrimitiveAxisView {
private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
private readonly labelKind: string;
constructor({ getAxisLabel, labelKind }: CustomTimeAxisViewParams) {
this.getAxisLabel = getAxisLabel;
this.labelKind = labelKind;
}
public update(): void {}
public visible(): boolean {
return Boolean(this.getCurrentLabel()?.text);
}
public tickVisible(): boolean {
return false;
}
public coordinate(): number {
return this.getCurrentLabel()?.coordinate ?? 0;
}
public text(): string {
return this.getCurrentLabel()?.text ?? '';
}
public textColor(): string {
return this.getCurrentLabel()?.textColor ?? '';
}
public backColor(): string {
return this.getCurrentLabel()?.backgroundColor ?? '';
}
private getCurrentLabel(): AxisLabel | null {
return this.getAxisLabel(this.labelKind);
}
}
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { AxisLinePaneView } from './paneView';
import {
AxisLineSettings,
AxisLineStyle,
AxisLineTextStyle,
createDefaultSettings,
getAxisLineSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
export type AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
type AxisLineHandleKey = 'main';
interface AxisLineParams extends BaseDrawingParams {
direction: AxisLineDirection;
}
interface AxisLineState {
hidden: boolean;
mode: AxisLineMode;
time: Time | null;
price: number | null;
settings: AxisLineSettings;
}
export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
direction: AxisLineDirection;
coordinate: number;
}
const LINE_HIT_TOLERANCE = 6;
const EXTERNAL_ID = 'axis-line';
export class AxisLine
extends DrawingBase<AxisLineSettings, AxisLineHandleKey, AxisLineMode, null>
implements ISeriesDrawing
{
protected settings: AxisLineSettings = createDefaultSettings();
protected mode: AxisLineMode = 'idle';
private readonly direction: AxisLineDirection;
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
constructor({
chart,
series,
direction,
container,
interaction,
formatObservable,
openSettings,
initialEvent,
}: AxisLineParams) {
super({ chart, series, container, interaction, openSettings });
this.direction = direction;
this.initializeDrawingViews(
new AxisLinePaneView(this),
direction === 'vertical' ? ['main'] : [],
direction === 'horizontal' ? ['main'] : [],
{ timeAxisPane: false, priceAxisPane: false },
);
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): AxisLineState {
return {
hidden: this.hidden,
mode: this.mode,
time: this.time,
price: this.price,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<AxisLineState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (nextState.mode) {
this.mode = nextState.mode === 'dragging' ? 'ready' : nextState.mode;
}
if ('time' in nextState) {
this.time = nextState.time ?? null;
}
if ('price' in nextState) {
this.price = nextState.price ?? null;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getAxisLineSettingsTabs(this.settings);
}
public getRenderData(): AxisLineRenderData | null {
if (this.hidden) {
return null;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return null;
}
return {
direction: this.direction,
coordinate,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
const coordinate = this.getCoordinate();
if (coordinate === null) {
return [];
}
const { width, height } = this.container.getBoundingClientRect();
return [
{
id: 'main',
x: this.direction === 'vertical' ? coordinate : width / 2,
y: this.direction === 'vertical' ? height / 2 : coordinate,
shape: 'rounded',
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
const point = { x, y };
if (!this.isPointNearDrawing(point)) {
return null;
}
return {
cursorStyle: this.getCursorStyle(),
externalId: EXTERNAL_ID,
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'vertical' || !this.isSelected()) {
return null;
}
return this.createTimeAxisLabelForValue(this.time);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'horizontal' || !this.isSelected()) {
return null;
}
return this.createPriceAxisLabelForValue(this.price);
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getGeometry(): null {
return null;
}
protected handleDoubleClick(event: MouseEvent): void {
this.openSettingsOnDoubleClick(
event,
this.mode === 'ready',
(point) => this.isPointNearDrawing(point),
);
}
protected handlePointerDown(event: PointerEvent): void {
const point = this.getPrimaryPointerDownPoint(event);
if (!point) {
return;
}
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return;
}
if (this.mode === 'ready') {
this.handleReadyPointerDown(event, point);
}
}
protected handlePointerMove(event: PointerEvent): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.consumeEvent(event);
this.updateLine(this.getRawEventPoint(event));
this.render();
}
protected handlePointerUp(event: PointerEvent): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.dragPointerId = null;
this.resolveReady?.();
this.showCrosshair();
this.render();
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
if (!this.isPointNearDrawing(point)) {
if (this.isSelected()) {
this.deselect();
}
return;
}
this.consumeEvent(event);
if (!this.isSelected()) {
this.select();
return;
}
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
this.hideCrosshair();
this.render();
}
private startDrawing(point: Point): void {
this.updateLine(point);
if (this.getCoordinate() === null) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private updateLine(point: Point): void {
if (this.direction === 'vertical') {
const time = getTimeFromXCoordinate(this.chart, point.x);
if (time !== null) {
this.time = time;
}
return;
}
const price = getPriceFromYCoordinate(this.series, point.y);
if (price !== null) {
this.price = price;
}
}
private getCoordinate(): number | null {
if (this.direction === 'vertical') {
return this.getTimeCoordinate();
}
if (this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeCoordinate(): number | null {
if (this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private isPointNearDrawing(point: Point): boolean {
if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return true;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return false;
}
return this.direction === 'vertical'
? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
: Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
}
private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
return this.direction === 'vertical' ? 'ew-resize' : 'ns-resize';
}
}
import { clampPointToContainer, findNearestTimeIndex, isPointInBounds } from '@core/Drawings/helpers';
import {
TwoPointDrawingBase,
type TwoPointDrawingMode,
type TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
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 { AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonDragTarget = 'body' | 'start' | 'end';
type DiapsonHandleKey = Exclude<DiapsonDragTarget, 'body'>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams extends BaseDrawingParams {
rangeMode: DiapsonRangeMode;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
mode: TwoPointDrawingMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry extends TwoPointGeometry {
width: number;
height: number;
}
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 TwoPointDrawingBase<DiapsonSettings, DiapsonHandleKey, DiapsonDragTarget, DiapsonGeometry>
implements ISeriesDrawing {
protected settings: DiapsonSettings = createDefaultSettings();
private rangeMode: DiapsonRangeMode;
private readonly stepSize: number;
private readonly stepLabel: string;
constructor(params: DiapsonParams) {
super(params);
this.rangeMode = params.rangeMode;
this.stepSize = params.stepSize && params.stepSize > 0 ? params.stepSize : 1;
this.stepLabel = params.stepLabel ?? '';
this.initializeDrawingViews(new DiapsonPaneView(this), ['left', 'right'], ['top', 'bottom']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public setRangeMode(nextMode: DiapsonRangeMode): void {
if (this.rangeMode === nextMode) {
return;
}
this.rangeMode = nextMode;
this.resetToIdle();
}
public getState(): DiapsonState {
return {
...this.getLegacyTwoPointState(),
rangeMode: this.rangeMode,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<DiapsonState>;
this.restoreLegacyTwoPointState(nextState);
if (nextState.rangeMode) {
this.rangeMode = nextState.rangeMode;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(this.settings);
}
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 getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
const externalId = `diapson-${this.rangeMode}`;
if (!this.isSelected()) {
return this.containsPoint(point) ? { cursorStyle: 'move', externalId, zOrder: 'top' } : null;
}
const handle = this.getDrawingHandleAtPoint(point)?.id;
if (handle) {
return { cursorStyle: this.getCursorStyle(handle), externalId, zOrder: 'top' };
}
return this.containsPoint(point) ? { cursorStyle: 'move', externalId, zOrder: 'top' } : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
}
protected getGeometry(): DiapsonGeometry | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
width: geometry.right - geometry.left,
height: geometry.bottom - geometry.top,
};
}
protected normalizeAnchorPoint(point: Point): Point {
return clampPointToContainer(point, this.container);
}
protected handleMissingGeometry(): void {
this.resetToIdle();
}
protected beforeResetToIdle(): void {
this.deselect();
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
}
protected getDragTarget(point: Point): DiapsonDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: DiapsonDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.setAnchorFromPoint(dragTarget, point);
}
protected isValidGeometry(geometry: DiapsonGeometry): boolean {
return geometry.width >= MIN_RECTANGLE_WIDTH && geometry.height >= MIN_RECTANGLE_HEIGHT;
}
private getLabelLines(): string[] {
return this.rangeMode === 'date' ? this.getDateLabelLines() : this.getPriceLabelLines();
}
private getDateLabelLines(): string[] {
const metrics = this.getDateMetrics();
if (!metrics) {
return [];
}
const firstLine = metrics.elapsedText
? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
: `${metrics.barsCount} ${t('bars')}`;
return metrics.volumeText ? [firstLine, `${t('Vol')} ${metrics.volumeText}`] : [firstLine];
}
private getPriceLabelLines(): string[] {
const metrics = this.getPriceMetrics();
if (!metrics) {
return [];
}
const percentText = metrics.percent < 0
? `-${formatPercent(Math.abs(metrics.percent))}`
: formatPercent(Math.abs(metrics.percent));
const stepsText = formatSteps(metrics.steps);
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 === null || rightTime === null) {
return null;
}
const durationSeconds = Math.floor(Math.abs(Number(rightTime) - Number(leftTime)));
const volume = this.getVolumeInRange();
return {
barsCount: this.getBarsCount(),
elapsedText: formatDuration(durationSeconds),
volumeText: volume > 0 ? formatVolume(volume) : '',
};
}
private getPriceMetrics(): PriceMetrics | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const delta = this.endAnchor.price - this.startAnchor.price;
return {
delta,
percent: this.startAnchor.price !== 0 ? (delta / Math.abs(this.startAnchor.price)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
const range = this.getDataRange();
return range ? Math.abs(range.to - range.from) : 0;
}
private getVolumeInRange(): number {
const range = this.getDataRange();
if (!range) {
return 0;
}
const data = this.series.data() ?? [];
let volume = 0;
for (let index = range.from; index <= range.to; index += 1) {
volume += getItemVolume(data[index] as unknown as Record<string, unknown> | undefined);
}
return volume;
}
private getDataRange(): { from: number; to: number } | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const data = this.series.data() ?? [];
if (!data.length) {
return null;
}
const startIndex = findNearestTimeIndex(data, this.startAnchor.time);
const endIndex = findNearestTimeIndex(data, this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return null;
}
return { from: Math.min(startIndex, endIndex), to: Math.max(startIndex, endIndex) };
}
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 (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 = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
return price === null ? '' : (formatPrice(price) ?? '');
}
private getLeftTimeValue(): Time | null {
return this.getTimeValueBySide('left');
}
private getRightTimeValue(): Time | null {
return this.getTimeValueBySide('right');
}
private getTimeValueBySide(side: TimeLabelKind): Time | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return side === 'left' ? this.startAnchor.time : this.endAnchor.time;
}
const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
if (side === 'left') {
return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
}
return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
}
private getTopPriceValue(): number | null {
return this.getPriceValueBySide('top');
}
private getBottomPriceValue(): number | null {
return this.getPriceValueBySide('bottom');
}
private getPriceValueBySide(side: PriceLabelKind): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return side === 'top'
? Math.max(this.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
if (side === 'top') {
return startIsTop ? this.startAnchor.price : this.endAnchor.price;
}
return startIsTop ? this.endAnchor.price : this.startAnchor.price;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry ? isPointInBounds(point, geometry, BODY_HIT_TOLERANCE) : false;
}
private getCursorStyle(handle: DiapsonHandleKey): PrimitiveHoveredItem['cursorStyle'] {
const geometry = this.getGeometry();
if (!geometry) {
return 'default';
}
const dx = geometry.endPoint.x - geometry.startPoint.x;
const dy = geometry.endPoint.y - geometry.startPoint.y;
return dx * dy >= 0 ? 'nwse-resize' : 'nesw-resize';
}
}
function formatSteps(steps: number): string {
const absoluteSteps = Math.abs(steps);
let text: string;
if (Number.isInteger(absoluteSteps)) {
text = absoluteSteps.toString();
} else if (absoluteSteps >= 1000) {
text = absoluteSteps.toFixed(0);
} else if (absoluteSteps >= 100) {
text = absoluteSteps.toFixed(1);
} else {
text = absoluteSteps.toFixed(2);
}
return steps < 0 ? `-${text}` : text;
}
function formatDuration(durationSeconds: number): string {
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 parts: string[] = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
return (parts.length ? parts : [`${seconds}s`]).slice(0, 2).join(' ');
}
function getItemVolume(item: Record<string, unknown> | undefined): number {
if (!item) {
return 0;
}
if (typeof item.volume === 'number') {
return item.volume;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
return customValues && typeof customValues.volume === 'number' ? customValues.volume : 0;
}
function isTimeLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'left' || kind === 'right';
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'top' || kind === 'bottom';
}
import { clampPointToContainer, getYCoordinateFromPrice, isPointInBounds } from '@core/Drawings/helpers';
import {
TwoPointDrawingBase,
type TwoPointDrawingMode,
type TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
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, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab, SettingsValues } from '@src/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
type FibonacciRetracementDragTarget = 'body' | 'start' | 'end';
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;
interface FibonacciRetracementState {
hidden: boolean;
mode: TwoPointDrawingMode;
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 extends TwoPointGeometry {
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;
const EXTERNAL_ID = 'fibonacci-retracement-position';
export class FibonacciRetracement
extends TwoPointDrawingBase<
FibonacciRetracementSettings,
FibonacciRetracementHandleKey,
FibonacciRetracementDragTarget,
FibonacciRetracementGeometry
>
implements ISeriesDrawing {
protected settings: FibonacciRetracementSettings = createDefaultSettings();
constructor(params: FibonacciRetracementParams) {
super(params);
this.initializeDrawingViews(new FibonacciRetracementPaneView(this), ['start', 'end'], ['top', 'bottom']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): FibonacciRetracementState {
return {
...this.getLegacyTwoPointState(),
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<FibonacciRetracementState>;
this.restoreLegacyTwoPointState(nextState, true);
if (nextState.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), nextState.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 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.isCreationPending()) {
return null;
}
const point = { x, y };
const containsPoint = this.containsPoint(point);
if (!this.isSelected()) {
return containsPoint ? this.createHoveredItem('pointer') : null;
}
if (this.getDrawingHandleAtPoint(point)) {
return this.createHoveredItem('pointer');
}
return containsPoint ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
return null;
}
const time = kind === 'start' ? this.startAnchor?.time ?? null : this.endAnchor?.time ?? null;
return this.createTimeAxisLabelForValue(time, this.getTimeCoordinate(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createPriceAxisLabelForValue(this.getPriceValueForLabel(kind), this.getPriceCoordinate(kind));
}
protected getGeometry(): FibonacciRetracementGeometry | null {
const baseGeometry = this.getTwoPointGeometry();
if (!baseGeometry) {
return null;
}
const levels = this.getLevels();
const top = Math.min(baseGeometry.top, ...levels.map((level) => level.y));
const bottom = Math.max(baseGeometry.bottom, ...levels.map((level) => level.y));
return {
...baseGeometry,
top,
bottom,
width: baseGeometry.right - baseGeometry.left,
height: bottom - top,
levels,
areas: this.getAreas(levels),
};
}
protected normalizeAnchorPoint(point: Point): Point {
return clampPointToContainer(point, this.container);
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
}
protected getDragTarget(point: Point): FibonacciRetracementDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: FibonacciRetracementDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.setAnchorFromPoint(dragTarget, point);
}
protected isValidGeometry(geometry: FibonacciRetracementGeometry): boolean {
const priceDistance = Math.abs(geometry.startPoint.y - geometry.endPoint.y);
return geometry.width >= MIN_DISTANCE && priceDistance >= MIN_DISTANCE;
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
if (!this.startAnchor || !this.endAnchor) {
return [];
}
return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((levels, level) => {
const price = this.getLevelPrice(level.value);
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return levels;
}
levels.push({
id: level.id,
value: level.value,
price,
y: Math.round(Number(y)),
color: level.color,
text: this.getLevelText(level.value, price),
});
return levels;
}, []);
}
private getLevelPrice(value: number): number {
const startPrice = this.startAnchor?.price ?? 0;
const endPrice = this.endAnchor?.price ?? 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((first, second) => first.value - second.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 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 getPriceValueForLabel(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry || !this.startAnchor || !this.endAnchor) {
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.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (this.isInsideBackground(point, geometry)) {
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 isInsideBackground(point: Point, geometry: FibonacciRetracementGeometry): boolean {
if (!this.settings.showBackground) {
return false;
}
const bounds: Bounds = {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
};
return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
}
function isTimeLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'start' || kind === 'end';
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'top' || kind === 'bottom';
}
import { getDistanceToSegment } from '@core/Drawings/utils';
import {
TwoPointDrawingBase,
type TwoPointDrawingMode,
type TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { LineMarker, type SettingsTab } from '@src/types';
import { LineDrawingPaneView } from './paneView';
import {
createDefaultSettings,
getLineDrawingSettingsTabs,
LineDrawingMarkers,
LineDrawingSettings,
LineDrawingStyle,
LineDrawingTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { PrimitiveHoveredItem } from 'lightweight-charts';
type LineDrawingDragTarget = 'body' | 'start' | 'end';
type LineDrawingStateMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type LineDrawingHandleKey = Exclude<LineDrawingDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface LineDrawingParams extends BaseDrawingParams {
defaultMarkers?: Partial<LineDrawingMarkers>;
}
interface LineDrawingState {
hidden: boolean;
mode: LineDrawingStateMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: LineDrawingSettings;
}
type LineDrawingGeometry = TwoPointGeometry;
export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const EXTERNAL_ID = 'line-drawing';
export class LineDrawing
extends TwoPointDrawingBase<LineDrawingSettings, LineDrawingHandleKey, LineDrawingDragTarget, LineDrawingGeometry>
implements ISeriesDrawing {
protected settings: LineDrawingSettings;
private readonly defaultMarkers: LineDrawingMarkers;
constructor(params: LineDrawingParams) {
super(params);
this.defaultMarkers = {
startMarker: LineMarker.normal,
endMarker: LineMarker.normal,
...params.defaultMarkers,
};
this.settings = createDefaultSettings(this.defaultMarkers);
this.initializeDrawingViews(new LineDrawingPaneView(this), ['start', 'end'], ['start', 'end']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): LineDrawingState {
return {
hidden: this.hidden,
mode: getLineDrawingStateMode(this.mode, this.activeDragTarget),
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
endAnchor: this.endAnchor ? { ...this.endAnchor } : null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<LineDrawingState> & { mode?: unknown };
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = normalizeLineDrawingMode(nextState.mode);
if (mode) {
this.mode = mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ? { ...nextState.endAnchor } : null;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(this.defaultMarkers), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getLineDrawingSettingsTabs(this.settings);
}
public getRenderData(): LineDrawingRenderData | null {
return this.createRenderDataWithSettings<LineDrawingRenderData>(this.getGeometry());
}
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.isCreationPending()) {
return null;
}
const point = { x, y };
if (this.getDrawingHandleAtPoint(point)) {
return this.createHoveredItem('move');
}
return this.isPointNearLine(point) ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
return null;
}
return this.createTimeAxisLabelForValue(this.getAnchor(kind)?.time ?? null);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
return null;
}
return this.createPriceAxisLabelForValue(this.getAnchor(kind)?.price ?? null);
}
protected getGeometry(): LineDrawingGeometry | null {
return this.getTwoPointGeometry();
}
protected shouldHideCrosshairWhileDragging(): boolean {
return true;
}
protected shouldStopPropagationWhileDragging(): boolean {
return true;
}
protected handleMissingGeometry(): void {
return;
}
protected handleInvalidGeometry(): void {
this.removeDrawing();
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.isPointNearLine(point);
}
protected getDragTarget(point: Point): LineDrawingDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.isPointNearLine(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: LineDrawingDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.setAnchorFromPoint(dragTarget, point);
}
protected isValidGeometry(geometry: LineDrawingGeometry): boolean {
return Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
) >= MIN_LINE_SIZE;
}
private getAnchor(kind: TimeLabelKind | PriceLabelKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
return geometry
? getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE
: false;
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
}
function getLineDrawingStateMode(
mode: TwoPointDrawingMode,
dragTarget: LineDrawingDragTarget | null,
): LineDrawingStateMode {
if (mode !== 'dragging') {
return mode;
}
if (dragTarget === 'start') {
return 'dragging-start';
}
if (dragTarget === 'end') {
return 'dragging-end';
}
return 'dragging-body';
}
function normalizeLineDrawingMode(mode: unknown): TwoPointDrawingMode | null {
if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
return mode;
}
if (mode === 'dragging' || mode === 'dragging-start' || mode === 'dragging-end' || mode === 'dragging-body') {
return 'ready';
}
return null;
}
function isLineLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'start' || kind === 'end';
}
import { PrimitiveHoveredItem } from 'lightweight-charts';
import {
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { getDistanceToSegment } from '@core/Drawings/utils';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import { ParallelChannelPaneView } from './paneView';
import {
createDefaultSettings,
getParallelChannelSettingsTabs,
ParallelChannelSettings,
ParallelChannelStyle,
ParallelChannelTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
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 DrawingBase<ParallelChannelSettings, ParallelChannelHandleKey, ParallelChannelMode, ParallelChannelGeometry>
implements ISeriesDrawing
{
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;
constructor({
container,
interaction,
formatObservable,
openSettings,
chart,
series,
initialEvent,
}: ParallelChannelParams) {
super({
chart,
series,
container,
interaction,
openSettings,
});
this.initializeDrawingViews(
new ParallelChannelPaneView(this),
['start', 'end'],
['main-start', 'main-end', 'parallel-start', '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 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 getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
return this.createTimeAxisLabelForValue(anchor?.time ?? null);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createPriceAxisLabelForValue(this.getPriceLabelValue(kind));
}
protected handleDoubleClick(event: MouseEvent): void {
this.openSettingsOnDoubleClick(
event,
this.mode === 'ready',
(point) => this.isPointOnChannel(point) || Boolean(this.getDrawingHandleAtPoint(point)),
true,
);
}
protected handlePointerDown(event: PointerEvent): void {
const point = this.getPrimaryPointerDownPoint(event);
if (!point) {
return;
}
if (this.handleCreationPointerDown(event, point)) {
return;
}
if (this.mode === 'ready') {
this.handleReadyPointerDown(event, point);
}
}
protected handlePointerMove(event: PointerEvent): void {
if (this.mode === 'drawing-line') {
this.setEndAnchor(this.getEventPoint(event));
this.render();
return;
}
if (this.mode === 'drawing-channel') {
this.setPriceOffset(this.getEventPoint(event));
this.render();
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.activeDragTarget) {
return;
}
this.consumeEvent(event);
this.applyDrag(this.getRawEventPoint(event));
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.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
const parallelStartPoint = this.getPointFromAnchor({
time: this.startAnchor.time,
price: this.startAnchor.price + this.priceOffset,
});
const parallelEndPoint = this.getPointFromAnchor({
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 handleCreationPointerDown(event: PointerEvent, point: Point): boolean {
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return true;
}
if (this.mode === 'drawing-line') {
this.consumeEvent(event);
this.completeMainLine(point);
return true;
}
if (this.mode === 'drawing-channel') {
this.consumeEvent(event);
this.completeChannel(point);
return true;
}
return false;
}
private completeMainLine(point: Point): void {
this.setEndAnchor(point);
if (!this.hasValidMainLine()) {
this.render();
return;
}
this.priceOffset = 0;
this.mode = 'drawing-channel';
this.render();
}
private completeChannel(point: Point): void {
this.setPriceOffset(point);
if (!this.hasValidChannelWidth()) {
this.render();
return;
}
this.finishDrawing();
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isChannelHit = this.isPointOnChannel(point);
if (!this.isSelected()) {
if (!pointTarget && !isChannelHit) {
return;
}
this.consumeEvent(event);
this.select();
return;
}
if (!pointTarget && !isChannelHit) {
this.deselect();
return;
}
this.consumeEvent(event);
this.startDragging(pointTarget ?? 'body', point, event.pointerId);
}
private startDrawing(point: Point): void {
const anchor = this.createDrawingAnchor(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.createDrawingAnchor(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.createDrawingAnchor(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.createDrawingAnchor(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.getPointFromAnchor(startAnchor);
const endPoint = this.getPointFromAnchor(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;
}
}
}
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 { getDistanceToSegment } from '@core/Drawings/utils';
import { TwoPointDrawingBase, type TwoPointDrawingMode } from '@core/Drawings/TwoPointDrawingBase';
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 '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem } from 'lightweight-charts';
type RayDragTarget = 'body' | 'start' | 'direction';
type RayStateMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
type RayHandleKey = Exclude<RayDragTarget, 'body'>;
type RayLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;
interface RayState {
hidden: boolean;
mode: RayStateMode;
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;
const EXTERNAL_ID = 'ray';
const RAY_EPSILON = 1e-9;
export class Ray
extends TwoPointDrawingBase<RaySettings, RayHandleKey, RayDragTarget, RayGeometry>
implements ISeriesDrawing {
protected settings: RaySettings = createDefaultSettings();
constructor(params: RayParams) {
super(params);
this.initializeDrawingViews(new RayPaneView(this), ['start', 'direction'], ['start', 'direction']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): RayState {
return {
hidden: this.hidden,
mode: getRayStateMode(this.mode, this.activeDragTarget),
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
directionAnchor: this.endAnchor ? { ...this.endAnchor } : null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RayState> & { mode?: unknown };
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = normalizeRayMode(nextState.mode);
if (mode) {
this.mode = mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
}
if ('directionAnchor' in nextState) {
this.endAnchor = nextState.directionAnchor ? { ...nextState.directionAnchor } : null;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRaySettingTabs(this.settings);
}
public getRenderData(): RayRenderData | null {
return this.createRenderDataWithSettings<RayRenderData>(this.getGeometry());
}
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.isCreationPending()) {
return null;
}
const point = { x, y };
if (this.getDrawingHandleAtPoint(point)) {
return this.createHoveredItem('move');
}
return this.isPointNearRay(point) ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisSegments(): AxisSegment[] {
const points = this.getDirectionPoints();
return points ? this.createAxisSegments(points.start.x, points.direction.x) : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
const points = this.getDirectionPoints();
return points ? this.createAxisSegments(points.start.y, points.direction.y) : [];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
return null;
}
return this.createTimeAxisLabelForValue(this.getAnchor(kind)?.time ?? null);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
return null;
}
return this.createPriceAxisLabelForValue(this.getAnchor(kind)?.price ?? null);
}
protected getGeometry(): RayGeometry | null {
const points = this.getDirectionPoints();
if (!points) {
return null;
}
const rayEndPoint = this.getRayEndPoint(points.start, points.direction);
if (!rayEndPoint) {
return null;
}
return {
startPoint: points.start,
directionPoint: points.direction,
rayEndPoint,
left: Math.min(points.start.x, rayEndPoint.x),
right: Math.max(points.start.x, rayEndPoint.x),
top: Math.min(points.start.y, rayEndPoint.y),
bottom: Math.max(points.start.y, rayEndPoint.y),
};
}
protected shouldHideCrosshairWhileDragging(): boolean {
return true;
}
protected shouldStopPropagationWhileDragging(): boolean {
return true;
}
protected handleMissingGeometry(): void {
return;
}
protected handleInvalidGeometry(): void {
this.removeDrawing();
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.isPointNearRay(point);
}
protected getDragTarget(point: Point): RayDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.isPointNearRay(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: RayDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.setAnchorFromPoint(dragTarget === 'start' ? 'start' : 'end', point);
}
protected isValidGeometry(geometry: RayGeometry): boolean {
return Math.hypot(
geometry.directionPoint.x - geometry.startPoint.x,
geometry.directionPoint.y - geometry.startPoint.y,
) >= MIN_LINE_SIZE;
}
private getAnchor(kind: RayLabelKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
private getDirectionPoints(): { start: Point; direction: Point } | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const start = this.getPointFromAnchor(this.startAnchor);
const direction = this.getPointFromAnchor(this.endAnchor);
return start && direction ? { start, direction } : null;
}
private getRayEndPoint(startPoint: Point, directionPoint: Point): Point | null {
const dx = directionPoint.x - startPoint.x;
const dy = directionPoint.y - startPoint.y;
if (Math.abs(dx) < RAY_EPSILON && Math.abs(dy) < RAY_EPSILON) {
return null;
}
const { width, height } = this.container.getBoundingClientRect();
const intersections = [
getVerticalIntersection(startPoint, dx, dy, 0, width, height),
getVerticalIntersection(startPoint, dx, dy, width, width, height),
getHorizontalIntersection(startPoint, dx, dy, 0, width, height),
getHorizontalIntersection(startPoint, dx, dy, height, width, height),
].filter((intersection): intersection is RayIntersection => intersection !== null);
if (!intersections.length) {
return directionPoint;
}
intersections.sort((first, second) => first.factor - second.factor);
return intersections[0].point;
}
private isPointNearRay(point: Point): boolean {
const geometry = this.getGeometry();
return geometry
? getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE
: false;
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
}
interface RayIntersection {
factor: number;
point: Point;
}
function getVerticalIntersection(
start: Point,
dx: number,
dy: number,
x: number,
width: number,
height: number,
): RayIntersection | null {
if (Math.abs(dx) < RAY_EPSILON) {
return null;
}
const factor = (x - start.x) / dx;
const y = start.y + factor * dy;
if (!isForwardIntersection(factor) || !isInsideRange(y, 0, height)) {
return null;
}
return { factor, point: { x: clampNearBoundary(x, 0, width), y: clampNearBoundary(y, 0, height) } };
}
function getHorizontalIntersection(
start: Point,
dx: number,
dy: number,
y: number,
width: number,
height: number,
): RayIntersection | null {
if (Math.abs(dy) < RAY_EPSILON) {
return null;
}
const factor = (y - start.y) / dy;
const x = start.x + factor * dx;
if (!isForwardIntersection(factor) || !isInsideRange(x, 0, width)) {
return null;
}
return { factor, point: { x: clampNearBoundary(x, 0, width), y: clampNearBoundary(y, 0, height) } };
}
function isForwardIntersection(factor: number): boolean {
return factor >= 1 - RAY_EPSILON;
}
function isInsideRange(value: number, min: number, max: number): boolean {
return value >= min - RAY_EPSILON && value <= max + RAY_EPSILON;
}
function clampNearBoundary(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function getRayStateMode(mode: TwoPointDrawingMode, dragTarget: RayDragTarget | null): RayStateMode {
if (mode !== 'dragging') {
return mode;
}
if (dragTarget === 'start') {
return 'dragging-start';
}
if (dragTarget === 'direction') {
return 'dragging-direction';
}
return 'dragging-body';
}
function normalizeRayMode(mode: unknown): TwoPointDrawingMode | null {
if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
return mode;
}
if (mode === 'dragging' || mode === 'dragging-start' || mode === 'dragging-direction' || mode === 'dragging-body') {
return 'ready';
}
return null;
}
function isRayLabelKind(kind: string): kind is RayLabelKind {
return kind === 'start' || kind === 'direction';
}
import { clampPointToContainer, isPointInBounds, normalizeBounds } from '@core/Drawings/helpers';
import {
TwoPointDrawingBase,
type TwoPointDrawingMode,
} from '@core/Drawings/TwoPointDrawingBase';
import { RectanglePaneView } from './paneView';
import {
createDefaultSettings,
getRectangleSettingsTabs,
RectangleSettings,
RectangleStyle,
RectangleTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Point } from '@core/Drawings/types';
import type { AxisBoundsGeometry, BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
type RectangleHandleKey = Exclude<RectangleHandle, 'body'>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
type RectangleParams = BaseDrawingParams;
interface RectangleState {
hidden: boolean;
mode: TwoPointDrawingMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: RectangleSettings;
}
interface RectangleGeometry extends AxisBoundsGeometry {
width: number;
height: number;
}
export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
const EXTERNAL_ID = 'rectangle-position';
export class Rectangle
extends TwoPointDrawingBase<RectangleSettings, RectangleHandleKey, RectangleHandle, RectangleGeometry>
implements ISeriesDrawing {
protected settings: RectangleSettings = createDefaultSettings();
constructor(params: RectangleParams) {
super(params);
this.initializeDrawingViews(new RectanglePaneView(this), ['left', 'right'], ['top', 'bottom']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): RectangleState {
return {
...this.getLegacyTwoPointState(),
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RectangleState>;
this.restoreLegacyTwoPointState(nextState);
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public getRenderData(): RectangleRenderData | null {
return this.createRenderDataWithSettings<RectangleRenderData>(this.getGeometry());
}
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.isCreationPending()) {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
return this.containsPoint(point) ? this.createHoveredItem('pointer') : null;
}
const handle = this.getDrawingHandleAtPoint(point)?.id;
if (handle) {
return this.createHoveredItem(this.getCursorStyle(handle));
}
return this.containsPoint(point) ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
return null;
}
return this.createTimeAxisLabelForValue(this.getTimeValueForLabel(kind), this.getTimeCoordinate(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createPriceAxisLabelForValue(this.getPriceValueForLabel(kind), this.getPriceCoordinate(kind));
}
protected getGeometry(): RectangleGeometry | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
width: geometry.right - geometry.left,
height: geometry.bottom - geometry.top,
};
}
protected normalizeAnchorPoint(point: Point): Point {
return clampPointToContainer(point, this.container);
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
}
protected getDragTarget(point: Point): RectangleHandle | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: RectangleHandle): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.resizeRectangle(point, dragTarget);
}
protected isValidGeometry(geometry: RectangleGeometry): boolean {
return geometry.width >= MIN_RECTANGLE_SIZE && geometry.height >= MIN_RECTANGLE_SIZE;
}
private resizeRectangle(point: Point, dragTarget: RectangleHandleKey): void {
const geometry = this.getDragGeometrySnapshot();
if (!geometry) {
return;
}
const clampedPoint = clampPointToContainer(point, this.container);
const bounds = getResizedBounds(geometry, clampedPoint, dragTarget);
this.setRectangleBounds(bounds.left, bounds.right, bounds.top, bounds.bottom);
}
private setRectangleBounds(left: number, right: number, top: number, bottom: number): void {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
const startAnchor = this.createDrawingAnchor({ x: bounds.left, y: bounds.top });
const endAnchor = this.createDrawingAnchor({ x: bounds.right, y: bounds.bottom });
if (startAnchor && endAnchor) {
this.setAnchors(startAnchor, endAnchor);
}
}
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 getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return kind === 'left' ? this.startAnchor.time : this.endAnchor.time;
}
const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
if (kind === 'left') {
return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
}
return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return kind === 'top'
? Math.max(this.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
if (kind === 'top') {
return startIsTop ? this.startAnchor.price : this.endAnchor.price;
}
return startIsTop ? this.endAnchor.price : this.startAnchor.price;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry ? isPointInBounds(point, geometry, BODY_HIT_TOLERANCE) : false;
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
private getCursorStyle(handle: RectangleHandle): PrimitiveHoveredItem['cursorStyle'] {
if (handle === 'nw' || handle === 'se') {
return 'nwse-resize';
}
if (handle === 'ne' || handle === 'sw') {
return 'nesw-resize';
}
if (handle === 'n' || handle === 's') {
return 'ns-resize';
}
if (handle === 'e' || handle === 'w') {
return 'ew-resize';
}
return handle === 'body' ? 'grab' : 'default';
}
}
function getResizedBounds(
geometry: RectangleGeometry,
point: Point,
handle: RectangleHandleKey,
): Pick<RectangleGeometry, 'left' | 'right' | 'top' | 'bottom'> {
let { left, right, top, bottom } = geometry;
if (handle.includes('w')) {
left = point.x;
}
if (handle.includes('e')) {
right = point.x;
}
if (handle.includes('n')) {
top = point.y;
}
if (handle.includes('s')) {
bottom = point.y;
}
return { left, right, top, bottom };
}
function isTimeLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'left' || kind === 'right';
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'top' || kind === 'bottom';
}
import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { getDistanceToSegment } from '@core/Drawings/utils';
import { DrawingBase } from '@core/Drawings/DrawingBase';
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 '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time } 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 DrawingBase<RegressionTrendSettings, RegressionTrendHandleKey, RegressionTrendMode, RegressionTrendGeometry>
implements ISeriesDrawing
{
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;
constructor({
chart,
series,
container,
interaction,
formatObservable,
openSettings,
initialEvent,
}: RegressionTrendParams) {
super({ chart, series, container, interaction, openSettings });
this.initializeDrawingViews(new RegressionTrendPaneView(this), ['start', 'end'], ['start', '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 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[] {
const geometry = this.getGeometry();
return this.createAxisSegments(geometry?.left ?? null, geometry?.right ?? null);
}
protected getPriceAxisSegments(): AxisSegment[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const from = Math.min(geometry.baseStartPoint.y, geometry.baseEndPoint.y);
const to = Math.max(geometry.baseStartPoint.y, geometry.baseEndPoint.y);
return this.createAxisSegments(from, to);
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || (kind !== 'start' && kind !== 'end')) {
return null;
}
return this.createTimeAxisLabelForValue(kind === 'start' ? this.startTime : this.endTime);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const price = kind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
return this.createPriceAxisLabelForValue(price);
}
protected handleDoubleClick(event: MouseEvent): void {
this.openSettingsOnDoubleClick(
event,
this.mode === 'ready',
(point) => this.containsPoint(point) || Boolean(this.getDrawingHandleAtPoint(point)),
true,
);
}
protected handlePointerDown(event: PointerEvent): void {
const point = this.getPrimaryPointerDownPoint(event);
if (!point) {
return;
}
if (this.handleCreationPointerDown(event, point)) {
return;
}
if (this.mode === 'ready') {
this.handleReadyPointerDown(event, point);
}
}
protected handlePointerMove(event: PointerEvent): void {
if (this.mode === 'drawing') {
this.updateDrawingEnd(this.getEventPoint(event));
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.applyDrag(this.getRawEventPoint(event));
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 handleCreationPointerDown(event: PointerEvent, point: Point): boolean {
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return true;
}
if (this.mode !== 'drawing') {
return false;
}
const time = this.getBarTime(point.x);
if (time === null) {
return true;
}
this.consumeEvent(event);
this.endTime = time;
if (!this.hasValidRange()) {
this.render();
return true;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
return true;
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
this.consumeEvent(event);
this.select();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
this.consumeEvent(event);
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.mode = 'dragging';
this.hideCrosshair();
this.render();
}
private updateDrawingEnd(point: Point): void {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
this.endTime = time;
this.render();
}
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, this.series);
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, this.series);
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;
}
import { Observable, skip } from 'rxjs';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
findNearestTimeIndex,
getPriceFromYCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Direction } from '@src/types';
import { SettingsTab, SettingsValues } from '@src/types/settings';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RulerPaneView } from './paneView';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type {
AutoscaleInfo,
Coordinate,
Logical,
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type RulerMode = 'idle' | 'placingEnd' | 'ready';
type RulerLabelKind = 'start' | 'end';
interface RulerState {
hidden: boolean;
mode: RulerMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface RulerParams extends BaseDrawingParams {
resetTriggers?: Observable<unknown>[];
}
interface RulerGeometry {
left: number;
right: number;
top: number;
bottom: number;
}
interface RulerMetrics {
priceDiff: number;
percentDiff: number | null;
barsCount: number;
volume: number;
isLong: boolean;
}
export interface RulerRenderData {
hidden: boolean;
startPoint: Point | null;
endPoint: Point | null;
lineColor: string;
fillColor: string;
textColor: string;
infoLines: string[];
horizontalArrowSide: Direction.Left | Direction.Right | null;
verticalArrowSide: Direction.Top | Direction.Bottom | null;
}
export class Ruler extends DrawingBase<SettingsValues, string, RulerMode, RulerGeometry> implements ISeriesDrawing {
protected settings: SettingsValues = {};
protected mode: RulerMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly moveHandler: MouseEventHandler<Time>;
constructor({
chart,
series,
resetTriggers = [],
formatObservable,
removeSelf,
container,
interaction,
initialEvent,
}: RulerParams) {
super({ chart, series, container, interaction, removeSelf });
this.clickHandler = (params) => this.handleChartClick(params);
this.moveHandler = (params) => this.handleMove(params);
this.initializeDrawingViews(new RulerPaneView(this), ['start', 'end'], ['start', 'end']);
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeDrawing();
}),
);
});
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'placingEnd';
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public getState(): RulerState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
endAnchor: this.endAnchor ? { ...this.endAnchor } : null,
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RulerState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (isRulerMode(nextState.mode)) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ? { ...nextState.endAnchor } : null;
}
this.render();
}
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
if (this.hidden || this.mode === 'placingEnd' || !this.startAnchor || !this.endAnchor) {
return null;
}
const logicalRange = this.getLogicalRange();
if (!logicalRange || endTimePoint < logicalRange.left || startTimePoint > logicalRange.right) {
return null;
}
return {
priceRange: {
minValue: Math.min(this.startAnchor.price, this.endAnchor.price),
maxValue: Math.max(this.startAnchor.price, this.endAnchor.price),
},
};
}
public getRenderData(): RulerRenderData {
const startPoint = this.startAnchor ? this.getPointFromAnchor(this.startAnchor) : null;
const endPoint = this.endAnchor ? this.getPointFromAnchor(this.endAnchor) : null;
const metrics = this.getMetrics();
const { colors } = getThemeStore();
return {
hidden: this.hidden,
startPoint,
endPoint,
lineColor: metrics.isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
fillColor: metrics.isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
textColor: colors.chartPriceLineText,
infoLines: this.getInfoLines(metrics),
horizontalArrowSide: getHorizontalArrowSide(startPoint, endPoint),
verticalArrowSide: getVerticalArrowSide(startPoint, endPoint),
};
}
public getTimeCoordinate(kind: RulerLabelKind): Coordinate | null {
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
return getXCoordinateFromTime(this.chart, anchor.time, this.series);
}
public getPriceCoordinate(kind: RulerLabelKind): Coordinate | null {
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
return getYCoordinateFromPrice(this.series, anchor.price);
}
public getTimeBounds(): { left: number; right: number } | null {
const startX = this.getTimeCoordinate('start');
const endX = this.getTimeCoordinate('end');
if (startX === null || endX === null) {
return null;
}
return {
left: Math.min(Number(startX), Number(endX)),
right: Math.max(Number(startX), Number(endX)),
};
}
public getPriceBounds(): { top: number; bottom: number } | null {
const startY = this.getPriceCoordinate('start');
const endY = this.getPriceCoordinate('end');
if (startY === null || endY === null) {
return null;
}
return {
top: Math.min(Number(startY), Number(endY)),
bottom: Math.max(Number(startY), Number(endY)),
};
}
public getTimeText(kind: RulerLabelKind): string {
const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceText(kind: RulerLabelKind): string {
const anchor = this.getAnchor(kind);
return anchor ? formatPrice(anchor.price) ?? '' : '';
}
public shouldShowInObjectTree(): boolean {
return false;
}
protected getHoveredItem(_x: number, _y: number): PrimitiveHoveredItem | null {
return null;
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
return [
{
from: bounds.left,
to: bounds.right,
color: getThemeStore().colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
return [
{
from: bounds.top,
to: bounds.bottom,
color: getThemeStore().colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!isRulerLabelKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
const coordinate = this.getTimeCoordinate(kind);
return this.createTimeAxisLabelForValue(anchor?.time ?? null, coordinate === null ? null : Number(coordinate));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!isRulerLabelKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
const coordinate = this.getPriceCoordinate(kind);
return this.createPriceAxisLabelForValue(anchor?.price ?? null, coordinate === null ? null : Number(coordinate));
}
protected bindEvents(): void {
// todo: попробовать привести к виду базового класса
if (this.isBound) {
return;
}
this.isBound = true;
this.chart.subscribeClick(this.clickHandler);
this.chart.subscribeCrosshairMove(this.moveHandler);
}
protected unbindEvents(): void {
// todo: попробовать привести к виду базового класса
if (!this.isBound) {
return;
}
this.isBound = false;
this.chart.unsubscribeClick(this.clickHandler);
this.chart.unsubscribeCrosshairMove(this.moveHandler);
}
protected getGeometry(): RulerGeometry | null {
const timeBounds = this.getTimeBounds();
const priceBounds = this.getPriceBounds();
if (!timeBounds || !priceBounds) {
return null;
}
return {
left: timeBounds.left,
right: timeBounds.right,
top: priceBounds.top,
bottom: priceBounds.bottom,
};
}
private handleChartClick(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point || !params.sourceEvent) {
return;
}
if (this.mode === 'ready') {
this.removeDrawing();
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
if (this.mode === 'idle') {
this.startDrawing(this.getEventPoint(params.sourceEvent));
return;
}
if (this.mode === 'placingEnd') {
this.endAnchor = anchor;
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
}
private startDrawing(point: Point): void {
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'placingEnd';
this.hideCrosshair();
this.render();
}
private handleMove(params: MouseEventParams<Time>): void {
if (this.hidden || this.mode !== 'placingEnd' || !params.point) {
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private createAnchor({ time, point }: MouseEventParams<Time>): Anchor | null {
if (!point || time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, point.y);
return price === null ? null : { price, time };
}
private getAnchor(kind: RulerLabelKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
private getLogicalRange(): { left: number; right: number } | null {
const startCoordinate = this.getTimeCoordinate('start');
const endCoordinate = this.getTimeCoordinate('end');
if (startCoordinate === null || endCoordinate === null) {
return null;
}
const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate);
const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate);
if (startLogical === null || endLogical === null) {
return null;
}
return {
left: Math.min(Number(startLogical), Number(endLogical)),
right: Math.max(Number(startLogical), Number(endLogical)),
};
}
private getMetrics(): RulerMetrics {
const startPrice = this.startAnchor?.price ?? 0;
const endPrice = this.endAnchor?.price ?? 0;
const priceDiff = endPrice - startPrice;
return {
priceDiff,
percentDiff: startPrice === 0 ? null : (priceDiff / startPrice) * 100,
barsCount: this.getBarsCount(),
volume: this.getVolumeInRange(),
isLong: priceDiff >= 0,
};
}
private getInfoLines(metrics: RulerMetrics): string[] {
const percent = metrics.percentDiff === null ? '-' : `${formatPrice(Math.abs(metrics.percentDiff))}%`;
return [
`${formatPrice(Math.abs(metrics.priceDiff))} (${percent})`,
`${metrics.barsCount} ${t('bars')},`,
`${t('Vol')} ${formatVolume(metrics.volume)}`,
];
}
private getBarsCount(): number {
const range = this.getDataRange();
return range ? Math.abs(range.to - range.from) : 0;
}
private getVolumeInRange(): number {
const range = this.getDataRange();
if (!range) {
return 0;
}
const data = this.series.data() ?? [];
let volume = 0;
for (let index = range.from; index <= range.to; index += 1) {
volume += getItemVolume(data[index] as unknown as Record<string, unknown> | undefined);
}
return volume;
}
private getDataRange(): { from: number; to: number } | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const data = this.series.data() ?? [];
if (!data.length) {
return null;
}
const startIndex = findNearestTimeIndex(data, this.startAnchor.time);
const endIndex = findNearestTimeIndex(data, this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return null;
}
return {
from: Math.min(startIndex, endIndex),
to: Math.max(startIndex, endIndex),
};
}
}
function isRulerMode(mode: unknown): mode is RulerMode {
return mode === 'idle' || mode === 'placingEnd' || mode === 'ready';
}
function isRulerLabelKind(kind: string): kind is RulerLabelKind {
return kind === 'start' || kind === 'end';
}
function getHorizontalArrowSide(start: Point | null, end: Point | null): Direction.Left | Direction.Right | null {
if (!start || !end || start.x === end.x) {
return null;
}
return end.x > start.x ? Direction.Right : Direction.Left;
}
function getVerticalArrowSide(start: Point | null, end: Point | null): Direction.Top | Direction.Bottom | null {
if (!start || !end || start.y === end.y) {
return null;
}
return end.y > start.y ? Direction.Bottom : Direction.Top;
}
function getItemVolume(item: Record<string, unknown> | undefined): number {
if (!item) {
return 0;
}
if (typeof item.volume === 'number') {
return item.volume;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
return customValues && typeof customValues.volume === 'number' ? customValues.volume : 0;
}
import { Observable, skip } from 'rxjs';
import {
getPriceDelta,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { createAxisLabel } from '@core/Drawings/utils';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { SliderPaneView } from './paneView';
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
SliderPositionSettings,
SliderPositionStyle,
SliderPositionTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type {
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type SliderHandleId = Exclude<DragTarget, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams extends BaseDrawingParams {
side: SliderSide;
resetTriggers?: Observable<unknown>[];
}
interface SliderPositionState {
hidden: boolean;
mode: SliderMode;
startTime: Time | null;
endTime: Time | null;
entryPrice: number | null;
stopPrice: number | null;
targetPrice: number | null;
riskRewardRatio: number;
amount: number;
tickSize: number;
settings: SliderPositionSettings;
}
interface SliderGeometry {
startX: number;
endX: number;
leftX: number;
rightX: number;
entryY: number;
stopY: number;
targetY: number;
entryPrice: number;
stopPrice: number;
targetPrice: number;
profitTop: number;
profitBottom: number;
lossTop: number;
lossBottom: number;
}
export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
targetLabelDirection: 'up' | 'down';
stopLabelDirection: 'up' | 'down';
showLabels: boolean;
}
const BODY_HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;
export class SliderPosition
extends DrawingBase<SliderPositionSettings, SliderHandleId, SliderMode, SliderGeometry>
implements ISeriesDrawing
{
protected settings: SliderPositionSettings = createDefaultSettings();
protected mode: SliderMode = 'idle';
private side: SliderSide;
private startTime: Time | null = null;
private endTime: Time | null = null;
private entryPrice: number | null = null;
private stopPrice: number | null = null;
private targetPrice: number | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: SliderPositionState | null = null;
private defaultRiskRewardRatio = 1;
private amount = 1000;
private tickSize = 1;
private readonly clickHandler: MouseEventHandler<Time>;
constructor({
chart,
series,
side,
container,
interaction,
formatObservable,
resetTriggers = [],
removeSelf,
openSettings,
initialEvent,
}: SliderPositionParams) {
super({ chart, series, container, interaction, removeSelf, openSettings });
this.side = side;
this.clickHandler = (params) => this.handleChartClick(params);
this.initializeDrawingViews(
new SliderPaneView(this),
['start', 'end'],
['target', 'entry', 'stop'],
{ timeAxisPaneZOrder: 'normal', priceAxisPaneZOrder: 'normal' },
);
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeDrawing();
}),
);
});
this.initializeDrawing(formatObservable);
if (initialEvent) {
this.handleChartClick(initialEvent);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
riskRewardRatio: this.getCurrentRiskRewardRatio(),
amount: this.amount,
tickSize: this.tickSize,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<SliderPositionState>;
this.restoreCoreState(next);
this.restoreTickSize(next);
this.restoreTargetPrice(next);
this.restoreSettings(next);
this.render();
}
private restoreCoreState(next: Partial<SliderPositionState>): void {
if (typeof next.hidden === 'boolean') {
this.hidden = next.hidden;
}
if (isSliderMode(next.mode)) {
this.mode = next.mode === 'dragging' ? 'ready' : next.mode;
}
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.entryPrice = next.entryPrice ?? this.entryPrice;
this.stopPrice = next.stopPrice ?? this.stopPrice;
this.amount = next.amount ?? this.amount;
}
private restoreTickSize(next: Partial<SliderPositionState>): void {
if (typeof next.tickSize === 'number' && next.tickSize > 0) {
this.tickSize = next.tickSize;
}
}
private restoreTargetPrice(next: Partial<SliderPositionState>): void {
if (next.targetPrice !== undefined) {
this.targetPrice = next.targetPrice;
return;
}
if (this.entryPrice === null || this.stopPrice === null) {
return;
}
if (typeof next.riskRewardRatio !== 'number' || next.riskRewardRatio < 0) {
return;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
const reward = risk * next.riskRewardRatio;
this.targetPrice = this.side === 'long' ? this.entryPrice + reward : this.entryPrice - reward;
}
private restoreSettings(next: Partial<SliderPositionState>): void {
if (!next.settings) {
return;
}
this.settings = { ...createDefaultSettings(), ...next.settings };
}
public getSettingsTabs(): SettingsTab[] {
return getSliderPositionSettingsTabs(this.settings);
}
public getRenderData(): SliderRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
const qty = reward > 0 ? this.amount / reward : 0;
const selectedPrice = this.getPriceAtTime(this.endTime);
const pnl =
selectedPrice === null
? 0
: this.side === 'long'
? (selectedPrice - geometry.entryPrice) * qty
: (geometry.entryPrice - selectedPrice) * qty;
const { colors } = getThemeStore();
return {
...geometry,
targetText: this.getTargetText(geometry),
centerText: this.getCenterText(qty, pnl),
stopText: this.getStopText(geometry),
centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
targetLabelDirection: this.side === 'long' ? 'up' : 'down',
stopLabelDirection: this.side === 'long' ? 'down' : 'up',
showLabels: this.isSelected(),
...this.settings,
};
}
public getTimeBounds(): { left: number; right: number } | null {
const start = this.getTimeCoordinate('start');
const end = this.getTimeCoordinate('end');
if (start === null || end === null) {
return null;
}
return {
left: Math.min(start, end),
right: Math.max(start, end),
};
}
public getTimeCoordinate(kind: TimeLabelKind): number | null {
const time = kind === 'start' ? this.startTime : this.endTime;
if (time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
return coordinate === null ? null : Number(coordinate);
}
public 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,
);
}
public getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
switch (kind) {
case 'target':
return geometry.targetY;
case 'entry':
return geometry.entryY;
case 'stop':
return geometry.stopY;
default:
return null;
}
}
public getPriceText(kind: PriceLabelKind): string {
const geometry = this.getGeometry();
if (!geometry) {
return '';
}
switch (kind) {
case 'target':
return formatPrice(geometry.targetPrice) ?? '';
case 'entry':
return formatPrice(geometry.entryPrice) ?? '';
case 'stop':
return formatPrice(geometry.stopPrice) ?? '';
default:
return '';
}
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'slider-position',
zOrder: 'top',
};
}
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return null;
}
let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';
if (dragTarget === 'target' || dragTarget === 'stop') {
cursorStyle = 'ns-resize';
}
if (dragTarget === 'end') {
cursorStyle = 'ew-resize';
}
return {
cursorStyle,
externalId: 'slider-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
return this.createAxisSegments(bounds?.left ?? null, bounds?.right ?? null);
}
protected getPriceAxisSegments(): AxisSegment[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
...this.createAxisSegments(geometry.profitTop, geometry.profitBottom),
...this.createAxisSegments(geometry.lossTop, geometry.lossBottom),
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isSelected() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
return createAxisLabel(coordinate, text);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
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();
let backgroundColor = colors.axisMarkerLabelDefaultFill;
if (labelKind === 'target') {
backgroundColor = colors.axisMarkerLabelPositiveFill;
}
if (labelKind === 'stop') {
backgroundColor = colors.axisMarkerLabelNegativeFill;
}
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor,
};
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
super.bindEvents();
this.chart.subscribeClick(this.clickHandler);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.chart.unsubscribeClick(this.clickHandler);
super.unbindEvents();
}
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)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openDrawingSettings();
};
private handleChartClick(params: MouseEventParams<Time>): void {
// todo: привести к общему виду дровингов как handleChartClick => startDrawing
if (this.hidden || !params.point || this.mode !== 'idle') {
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
const distance = this.getInitialZoneDistance(anchor.price);
const stopDirection = this.side === 'long' ? -1 : 1;
const targetDirection = -stopDirection;
this.startTime = anchor.time;
this.endTime = shiftTimeByPixels(this.chart, anchor.time, INITIAL_WIDTH_PX, this.series) ?? anchor.time;
this.entryPrice = anchor.price;
this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection, distance);
this.targetPrice = this.normalizeTarget(
anchor.price,
anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
distance,
);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || this.mode !== 'ready' || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getHandleTarget(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';
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.applyDrag(this.getRawEventPoint(event));
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.resolveReady?.();
this.render();
};
private applyDrag(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot) {
return;
}
switch (this.activeDragTarget) {
case 'body':
this.moveWhole(snapshot, point);
break;
case 'entry':
this.moveEntry(snapshot, point);
break;
case 'target':
this.moveTarget(snapshot, point);
break;
case 'stop':
this.moveStop(snapshot, point);
break;
case 'end':
this.resizeEnd(snapshot, point);
break;
default:
break;
}
}
private moveEntry(snapshot: SliderPositionState, point: Point): void {
if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice);
const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice);
this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
}
private moveWhole(snapshot: SliderPositionState, point: Point): void {
if (snapshot.startTime === null || snapshot.endTime === null) {
return;
}
if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
if (!this.dragStartPoint) {
return;
}
const timeOffset = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDelta(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startTime, timeOffset, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endTime, timeOffset, this.series);
if (nextStartTime === null || nextEndTime === null) {
return;
}
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.entryPrice = snapshot.entryPrice + priceOffset;
this.stopPrice = snapshot.stopPrice + priceOffset;
this.targetPrice = snapshot.targetPrice + priceOffset;
}
private moveStop(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.stopPrice = this.normalizeStop(snapshot.entryPrice, nextPrice);
}
private moveTarget(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.targetPrice = this.normalizeTarget(snapshot.entryPrice, nextPrice);
}
private resizeEnd(snapshot: SliderPositionState, point: Point): void {
const nextEndTime = getTimeFromXCoordinate(this.chart, point.x, this.series);
if (nextEndTime === null) {
return;
}
this.startTime = snapshot.startTime;
this.endTime = nextEndTime;
}
private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
if (!params.point || params.time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, params.point.y);
if (price === null) {
return null;
}
return {
time: params.time,
price,
};
}
private normalizeStop(entryPrice: number, rawPrice: number, minDistance = 0): number {
return this.side === 'long'
? Math.min(rawPrice, entryPrice - minDistance)
: Math.max(rawPrice, entryPrice + minDistance);
}
private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = 0): number {
return this.side === 'long'
? Math.max(rawPrice, entryPrice + minDistance)
: Math.min(rawPrice, entryPrice - minDistance);
}
private getInitialZoneDistance(entryPrice: number): number {
const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
const range = this.getPriceScaleRange();
if (!range) {
return fallback;
}
const size = range.max - range.min;
if (size <= 0) {
return fallback;
}
return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
}
private getPriceScaleRange(): { min: number; max: number } | null {
return getPriceRangeInContainer(this.series, this.container);
}
private getCurrentRiskRewardRatio(): number {
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return this.defaultRiskRewardRatio;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
const reward = Math.abs(this.targetPrice - this.entryPrice);
if (risk <= MIN_DISTANCE || reward <= MIN_DISTANCE) {
return 0;
}
return reward / risk;
}
private getPriceAtTime(time: Time | null): number | null {
if (typeof time !== 'number') {
return null;
}
const data = this.series.data() ?? [];
let lastPrice: number | null = null;
for (const item of data) {
if (typeof item.time !== 'number') {
continue;
}
if (item.time > time) {
break;
}
const price = getSeriesItemPrice(item);
if (price !== null) {
lastPrice = price;
}
}
return lastPrice;
}
protected getDrawingHandles(): readonly DrawingHandle<SliderHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'target',
x: geometry.startX,
y: geometry.targetY,
},
{
id: 'stop',
x: geometry.startX,
y: geometry.stopY,
},
{
id: 'end',
x: geometry.endX,
y: geometry.entryY,
},
{
id: 'entry',
x: geometry.startX,
y: geometry.entryY,
shape: 'circle',
},
];
}
protected getGeometry(): SliderGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);
if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
return null;
}
const start = Number(startX);
const end = Number(endX);
const entry = Number(entryY);
const stop = Number(stopY);
const target = Number(targetY);
return {
startX: start,
endX: end,
leftX: Math.min(start, end),
rightX: Math.max(start, end),
entryY: entry,
stopY: stop,
targetY: target,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
profitTop: Math.min(target, entry),
profitBottom: Math.max(target, entry),
lossTop: Math.min(stop, entry),
lossBottom: Math.max(stop, entry),
};
}
private getTargetText(geometry: SliderGeometry): string {
const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;
const formattedDiff = formatPrice(diff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedAmount = formatPrice(this.amount) ?? '0';
return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
}
private getCenterText(qty: number, pnl: number): string {
const formattedQty = formatPrice(qty) ?? '0';
const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';
return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
}
private getStopText(geometry: SliderGeometry): string {
const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
const stopAmount = stopDiff * qty;
const formattedStopDiff = formatPrice(stopDiff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedStopAmount = formatPrice(stopAmount) ?? '0';
return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
}
private getHandleTarget(point: Point): DragTarget {
const handle = this.getDrawingHandleAtPoint(point);
if (handle) {
return handle.id;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: Math.min(geometry.targetY, geometry.stopY),
bottom: Math.max(geometry.targetY, geometry.stopY),
};
return isPointInBounds(point, bounds) ? 'body' : null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: Math.min(geometry.targetY, geometry.stopY),
bottom: Math.max(geometry.targetY, geometry.stopY),
};
return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
}
}
function isSliderMode(mode: unknown): mode is SliderMode {
return mode === 'idle' || mode === 'ready' || mode === 'dragging';
}
function getSeriesItemPrice(item: object): number | null {
if ('close' in item && typeof item.close === 'number') {
return item.close;
}
return 'value' in item && typeof item.value === 'number' ? item.value : null;
}
import { DrawingBase } from '@core/Drawings/DrawingBase';
import { isPointInBounds } from '@core/Drawings/helpers';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem } from 'lightweight-charts';
import { TextPaneView } from './paneView';
import { createDefaultSettings, getTextSettingsTabs, TextContentStyle, TextSettings, TextStyle } from './settings';
type TextMode = 'idle' | 'dragging' | 'ready';
export interface TextState {
hidden: boolean;
mode: TextMode;
point: Anchor | null;
settings: TextSettings;
}
interface TextGeometry {
point: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
lines: string[];
font: string;
lineHeight: number;
}
export interface TextRenderData extends TextGeometry, TextStyle, TextContentStyle {
showSelectionBorder: boolean;
}
type TextParams = BaseDrawingParams;
const UI = {
padding: 6,
};
let measureCanvas: HTMLCanvasElement | null = null;
export class Text extends DrawingBase<TextSettings, string, TextMode, TextGeometry> implements ISeriesDrawing {
protected mode: TextMode = 'idle';
protected settings: TextSettings = createDefaultSettings();
private point: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: TextGeometry | null = null;
constructor(params: TextParams) {
super(params);
this.initializeDrawingViews(new TextPaneView(this), ['main'], ['main'], {
timeAxisPane: false,
priceAxisPane: false,
});
this.initializeDrawing(params.formatObservable, params.initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getSettingsTabs(): SettingsTab[] {
return getTextSettingsTabs(this.settings);
}
public getState(): TextState {
return {
hidden: this.hidden,
mode: this.mode,
point: this.point ? { ...this.point } : null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TextState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (isTextMode(nextState.mode)) {
this.mode = nextState.mode === 'dragging' ? 'ready' : nextState.mode;
}
if ('point' in nextState) {
this.point = nextState.point ? { ...nextState.point } : null;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getRenderData(): TextRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
showSelectionBorder: this.isSelected(),
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || !this.containsPoint({ x, y })) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'text',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || !this.isSelected() || !this.point) {
return null;
}
return this.createTimeAxisLabelForValue(this.point.time);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || !this.isSelected() || !this.point) {
return null;
}
return this.createPriceAxisLabelForValue(this.point.price);
}
protected handlePointerDown(event: PointerEvent): void {
const point = this.getPrimaryPointerDownPoint(event);
if (!point) {
return;
}
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return;
}
if (this.mode === 'ready') {
this.handleReadyPointerDown(event, point);
}
}
protected handleDoubleClick(event: MouseEvent): void {
this.openSettingsOnDoubleClick(event, this.mode === 'ready', (point) => this.containsPoint(point), true);
}
protected handlePointerMove(event: PointerEvent): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
this.movePoint(this.getRawEventPoint(event));
this.render();
}
protected handlePointerUp(event: PointerEvent): void {
if (this.mode === 'dragging' && this.dragPointerId === event.pointerId) {
this.finishDragging();
}
}
protected getGeometry(): TextGeometry | null {
if (!this.point) {
return null;
}
const point = this.getPointFromAnchor(this.point);
if (!point) {
return null;
}
const lines = getTextLines(this.settings.text);
const font = getFont(this.settings);
const lineHeight = this.settings.fontSize;
const measured = measureTextBlock(lines, font, lineHeight);
const width = measured.width + UI.padding * 2;
const height = measured.height + UI.padding * 2;
return {
point,
left: point.x,
right: point.x + width,
top: point.y,
bottom: point.y + height,
width,
height,
lines,
font,
lineHeight,
};
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
const containsPoint = this.containsPoint(point);
if (!this.isSelected()) {
if (containsPoint) {
this.consumeEvent(event);
this.select();
}
return;
}
if (!containsPoint) {
this.deselect();
return;
}
this.consumeEvent(event);
this.startDragging(point, event.pointerId);
}
private startDrawing(point: Point): void {
// todo: вынести в абстрактный класс абстрактным методом (и в соседних классах)
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
this.point = anchor;
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number): void {
this.mode = 'dragging';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(eventPoint: Point): void {
const geometry = this.dragGeometrySnapshot;
const dragStartPoint = this.dragStartPoint;
if (!geometry || !dragStartPoint) {
return;
}
const anchor = this.createDrawingAnchor({
x: geometry.left + eventPoint.x - dragStartPoint.x,
y: geometry.top + eventPoint.y - dragStartPoint.y,
});
if (anchor) {
this.point = anchor;
}
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry ? isPointInBounds(point, geometry, 2) : false;
}
}
function isTextMode(mode: unknown): mode is TextMode {
return mode === 'idle' || mode === 'dragging' || mode === 'ready';
}
function getTextLines(text: string): string[] {
const lines = text.split('\n');
return lines.length ? lines : [''];
}
function getFont(settings: TextSettings): string {
const italic = settings.isItalic ? 'italic ' : '';
const bold = settings.isBold ? '700 ' : '';
return `${italic}${bold}${settings.fontSize}px Inter, sans-serif`;
}
function measureTextBlock(lines: string[], font: string, lineHeight: number): { width: number; height: number } {
const context = getMeasureContext();
if (!context) {
const estimatedWidth = Math.max(...lines.map((line) => Math.max(1, line.length))) * 8;
return {
width: estimatedWidth,
height: lines.length * lineHeight,
};
}
context.font = font;
const width = lines.reduce((maxWidth, line) => {
return Math.max(maxWidth, context.measureText(line || ' ').width);
}, 0);
return {
width: Math.ceil(width),
height: lines.length * lineHeight,
};
}
function getMeasureContext(): CanvasRenderingContext2D | null {
if (!measureCanvas) {
measureCanvas = document.createElement('canvas');
}
return measureCanvas.getContext('2d');
}
import { DrawingBase } from '@core/Drawings/DrawingBase';
import { isNearPoint } from '@core/Drawings/helpers';
import { getDistanceToSegment } from '@core/Drawings/utils';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem } from 'lightweight-charts';
import { TraectoryPaneView } from './paneView';
import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';
type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;
export interface TraectoryState {
hidden: boolean;
mode: TraectoryMode;
points: Anchor[];
settings: TraectorySettings;
}
interface TraectoryGeometry {
points: Point[];
left: number;
right: number;
top: number;
bottom: number;
}
export interface TraectoryRenderData extends TraectoryGeometry, TraectoryStyle {
previewPoint: Point | null;
showArrow: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
const EXTERNAL_ID = 'traectory';
export class Traectory
extends DrawingBase<TraectorySettings, TraectoryHandleId, TraectoryMode, TraectoryGeometry>
implements ISeriesDrawing {
protected settings: TraectorySettings = createDefaultSettings();
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
private previewAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragPointIndex: number | null = null;
private dragGeometrySnapshot: TraectoryGeometry | null = null;
constructor(params: TraectoryParams) {
super(params);
this.initializeDrawingViews(new TraectoryPaneView(this));
this.initializeDrawing(params.formatObservable, params.initialEvent, (point) => this.startDrawing(point));
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): TraectoryState {
return {
hidden: this.hidden,
mode: this.mode,
points: this.points.map((point) => ({ ...point })),
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TraectoryState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (isTraectoryMode(nextState.mode)) {
this.mode = isDraggingMode(nextState.mode) ? 'ready' : nextState.mode;
}
if (Array.isArray(nextState.points)) {
this.points = nextState.points.map((point) => ({ ...point }));
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTraectorySettingsTabs(this.settings);
}
public getRenderData(): TraectoryRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
previewPoint: this.getPreviewPoint(),
showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<TraectoryHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return geometry.points
.map<DrawingHandle<TraectoryHandleId>>((point, index) => ({
id: `${index}`,
...point,
shape: 'circle',
size: 10,
borderWidth: 2,
}))
.reverse();
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
if (!this.isPointNearTraectory(point) && this.getPointIndexAt(point) === null) {
return null;
}
return {
cursorStyle: 'move',
externalId: EXTERNAL_ID,
zOrder: 'top',
};
}
protected getPriceAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected getTimeAxisLabel(_kind: string): AxisLabel | null {
return null;
}
protected handleClick(event: MouseEvent): void {
if (this.hidden || this.mode !== 'drawing' || event.detail !== 1) {
return;
}
this.consumeEvent(event);
this.appendPoint(this.getEventPoint(event as PointerEvent));
}
protected handleDoubleClick(event: MouseEvent): void {
if (this.hidden) {
return;
}
if (this.mode === 'drawing') {
this.consumeEvent(event);
this.finishDrawing();
return;
}
this.openSettingsOnDoubleClick(event, this.mode === 'ready', (point) => this.isDrawingHit(point), true);
}
protected handleContextMenu(event: MouseEvent): void {
if (this.hidden || this.mode !== 'drawing') {
return;
}
this.consumeEvent(event);
this.finishDrawing();
}
protected handlePointerDown(event: PointerEvent): void {
const point = this.getPrimaryPointerDownPoint(event);
if (!point) {
return;
}
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return;
}
if (this.mode === 'ready') {
this.handleReadyPointerDown(event, point);
}
}
protected handlePointerMove(event: PointerEvent): void {
if (this.mode === 'drawing') {
this.updatePreview(this.getEventPoint(event));
return;
}
if (this.dragPointerId !== event.pointerId || !isDraggingMode(this.mode)) {
return;
}
event.preventDefault();
const point = this.getRawEventPoint(event);
if (this.mode === 'dragging-point') {
this.movePoint(point);
} else {
this.moveBody(point);
}
this.render();
}
protected handlePointerUp(event: PointerEvent): void {
if (this.dragPointerId === event.pointerId && isDraggingMode(this.mode)) {
this.finishDragging();
}
}
protected getGeometry(): TraectoryGeometry | null {
if (!this.points.length) {
return null;
}
const screenPoints: Point[] = [];
for (const anchor of this.points) {
const point = this.getPointFromAnchor(anchor);
if (!point) {
return null;
}
screenPoints.push(point);
}
return createGeometry(screenPoints);
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
if (!this.isSelected()) {
if (this.isPointNearTraectory(point)) {
this.consumeEvent(event);
this.select();
}
return;
}
const pointIndex = this.getPointIndexAt(point);
if (pointIndex !== null) {
this.consumeEvent(event);
this.startDraggingPoint(point, event.pointerId, pointIndex);
return;
}
if (!this.isPointNearTraectory(point)) {
this.deselect();
return;
}
this.consumeEvent(event);
this.startDraggingBody(point, event.pointerId);
}
private startDrawing(point: Point): void {
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
this.points = [anchor];
this.previewAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private appendPoint(point: Point): void {
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
const lastPoint = this.points[this.points.length - 1];
if (!isSameAnchor(lastPoint, anchor)) {
this.points = [...this.points, anchor];
}
this.previewAnchor = anchor;
this.render();
}
private updatePreview(point: Point): void {
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
this.previewAnchor = anchor;
this.render();
}
private finishDrawing(): void {
if (this.points.length < MIN_POINTS_COUNT) {
if (!this.removeDrawing()) {
this.resetToIdle();
}
return;
}
this.previewAnchor = null;
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDraggingPoint(point: Point, pointerId: number, pointIndex: number): void {
this.mode = 'dragging-point';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = pointIndex;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private startDraggingBody(point: Point, pointerId: number): void {
this.mode = 'dragging-body';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = null;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearDragState();
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.mode = 'idle';
this.points = [];
this.previewAnchor = null;
this.clearDragState();
this.showCrosshair();
this.render();
}
private clearDragState(): void {
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
}
private movePoint(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const pointIndex = this.dragPointIndex;
if (!geometry || pointIndex === null) {
return;
}
const nextPoints = [...geometry.points];
nextPoints[pointIndex] = point;
this.setAnchorsFromPoints(nextPoints);
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const dragStartPoint = this.dragStartPoint;
if (!geometry || !dragStartPoint) {
return;
}
const offsetX = point.x - dragStartPoint.x;
const offsetY = point.y - dragStartPoint.y;
const nextPoints = geometry.points.map((item) => ({
x: item.x + offsetX,
y: item.y + offsetY,
}));
this.setAnchorsFromPoints(nextPoints);
}
private setAnchorsFromPoints(points: Point[]): void {
const nextAnchors: Anchor[] = [];
for (const point of points) {
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
nextAnchors.push(anchor);
}
this.points = nextAnchors;
}
private getPreviewPoint(): Point | null {
if (this.mode !== 'drawing' || !this.previewAnchor) {
return null;
}
return this.getPointFromAnchor(this.previewAnchor);
}
private getPointIndexAt(point: Point): number | null {
const handle = this.getDrawingHandleAtPoint(point);
return handle ? Number(handle.id) : null;
}
private isDrawingHit(point: Point): boolean {
return this.getPointIndexAt(point) !== null || this.isPointNearTraectory(point);
}
private isPointNearTraectory(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (geometry.points.length === 1) {
const firstPoint = geometry.points[0];
return isNearPoint(point, firstPoint.x, firstPoint.y, POINT_HIT_TOLERANCE);
}
return isNearAnySegment(point, geometry.points);
}
}
function createGeometry(points: Point[]): TraectoryGeometry {
return {
points,
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)),
};
}
function isNearAnySegment(point: Point, points: Point[]): boolean {
for (let index = 0; index < points.length - 1; index += 1) {
if (getDistanceToSegment(point, points[index], points[index + 1]) <= SEGMENT_HIT_TOLERANCE) {
return true;
}
}
return false;
}
function isSameAnchor(first: Anchor | undefined, second: Anchor): boolean {
return Boolean(first && Number(first.time) === Number(second.time) && first.price === second.price);
}
function isDraggingMode(mode: TraectoryMode): boolean {
return mode === 'dragging-point' || mode === 'dragging-body';
}
function isTraectoryMode(mode: unknown): mode is TraectoryMode {
return mode === 'idle' || mode === 'drawing' || mode === 'ready' || mode === 'dragging-point' || mode === 'dragging-body';
}
import { PrimitiveHoveredItem, Time } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import {
clampPointToContainer,
getContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import { SettingsTab } from '@src/types';
import { VolumeProfilePaneView } from './paneView';
import {
createDefaultSettings,
getVolumeProfileSettingsTabs,
VolumeProfileSettings,
VolumeProfileStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
export type VolumeProfileKind = 'fixedRange' | 'visibleRange';
type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;
type VolumeProfileHandleId = Exclude<DragTarget, 'body' | null>;
interface VolumeProfileParams extends BaseDrawingParams {
profileKind?: VolumeProfileKind;
}
interface SeriesCandleData {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
volume?: number;
customValues?: {
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
volume?: number;
};
}
export interface VolumeProfileState {
hidden: boolean;
mode: VolumeProfileMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
visibleRangeStartRatio: number;
settings: VolumeProfileSettings;
}
interface VolumeProfileDataRow {
priceLow: number;
priceHigh: number;
buyVolume: number;
sellVolume: number;
totalVolume: number;
}
interface VolumeProfileGeometry extends Bounds {
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
interface VolumeProfileRenderRow {
top: number;
height: number;
buyWidth: number;
sellWidth: number;
}
export interface VolumeProfileRenderData extends VolumeProfileGeometry, VolumeProfileStyle {
profileKind: VolumeProfileKind;
rows: VolumeProfileRenderRow[];
pocY: number | null;
}
const PROFILE_ROW_COUNT = 24;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
export class VolumeProfile
extends DrawingBase<VolumeProfileSettings, VolumeProfileHandleId, VolumeProfileMode, VolumeProfileGeometry>
implements ISeriesDrawing
{
private readonly profileKind: VolumeProfileKind;
protected settings: VolumeProfileSettings = createDefaultSettings();
protected mode: VolumeProfileMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private profileRows: VolumeProfileDataRow[] = [];
private profileMinPrice: number | null = null;
private profileMaxPrice: number | null = null;
private visibleRangeStartRatio = 0;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: VolumeProfileGeometry | null = null;
constructor({
chart,
series,
container,
interaction,
profileKind = 'fixedRange',
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: VolumeProfileParams) {
super({ chart, series, container, interaction, removeSelf, openSettings });
this.profileKind = profileKind;
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
}
const isFixedRange = this.profileKind === 'fixedRange';
this.initializeDrawingViews(
new VolumeProfilePaneView(this),
isFixedRange ? ['start', 'end'] : [],
isFixedRange ? ['start', 'end'] : [],
{ timeAxisPane: isFixedRange, priceAxisPane: isFixedRange },
);
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.calculateProfile();
}
this.initializeDrawing(formatObservable, this.profileKind === 'fixedRange' ? initialEvent : undefined, (point) =>
this.startDrawing(point),
);
}
public destroy(): void {
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
}
super.destroy();
}
public isCreationPending(): boolean {
if (this.profileKind === 'visibleRange') {
return false;
}
return this.mode === 'idle' || this.mode === 'drawing';
}
public shouldShowInObjectTree(): boolean {
if (this.profileKind === 'visibleRange') {
return true;
}
return super.shouldShowInObjectTree();
}
public getState(): VolumeProfileState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
endAnchor: this.endAnchor ? { ...this.endAnchor } : null,
visibleRangeStartRatio: this.visibleRangeStartRatio,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<VolumeProfileState>;
this.restoreHiddenState(nextState);
if (this.profileKind === 'fixedRange') {
this.restoreFixedRangeState(nextState);
} else {
this.restoreVisibleRangeState(nextState);
}
this.restoreSettings(nextState);
this.calculateProfile();
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getVolumeProfileSettingsTabs(this.settings);
}
public getRenderData(): VolumeProfileRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const { rows, pocY } = this.getProfileRenderRows(geometry);
return {
...geometry,
profileKind: this.profileKind,
rows,
pocY,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<VolumeProfileHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
if (this.profileKind === 'visibleRange') {
const pocY = this.getPocY(geometry);
if (pocY === null) {
return [];
}
return [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle', size: 10, borderWidth: 2 }];
}
return [
{ id: 'end', ...geometry.endPoint, shape: 'circle', size: 10, borderWidth: 2 },
{ id: 'start', ...geometry.startPoint, shape: 'circle', size: 10, borderWidth: 2 },
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const dragTarget = this.getDragTarget({ x, y });
if (!dragTarget) {
return null;
}
if (dragTarget === 'poc') {
return {
cursorStyle: 'ew-resize',
externalId: 'volume-profile',
zOrder: 'top',
};
}
return {
cursorStyle: this.isSelected() ? 'grab' : 'pointer',
externalId: 'volume-profile',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind !== 'fixedRange' || !this.shouldShowAxisElements() || !isEndpointKind(kind)) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
return this.createTimeAxisLabelForValue(anchor?.time ?? null);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind !== 'fixedRange' || !this.shouldShowAxisElements() || !isEndpointKind(kind)) {
return null;
}
const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;
return this.createPriceAxisLabelForValue(price);
}
private restoreHiddenState(state: Partial<VolumeProfileState>): void {
if (typeof state.hidden === 'boolean') {
this.hidden = state.hidden;
}
}
private restoreFixedRangeState(state: Partial<VolumeProfileState>): void {
if (state.mode) {
this.mode = state.mode === 'dragging' ? 'ready' : state.mode;
}
this.startAnchor = state.startAnchor ?? this.startAnchor;
this.endAnchor = state.endAnchor ?? this.endAnchor;
}
private restoreVisibleRangeState(state: Partial<VolumeProfileState>): void {
this.mode = 'ready';
this.resolveReady?.();
if (typeof state.visibleRangeStartRatio === 'number') {
this.visibleRangeStartRatio = clamp(state.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
}
private restoreSettings(state: Partial<VolumeProfileState>): void {
if (!state.settings) {
return;
}
this.settings = {
...createDefaultSettings(),
...state.settings,
};
}
private handleVisibleLogicalRangeChange = (): void => {
if (this.profileKind !== 'visibleRange') {
return;
}
this.calculateProfile();
this.render();
};
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDragTarget(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openDrawingSettings();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerDown(event, point);
return;
}
this.handleFixedRangePointerDown(event, point);
};
protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
let dragTarget = this.getVisibleRangeDragTarget(point);
if (!dragTarget) {
if (this.isSelected()) {
this.deselect();
}
return;
}
event.preventDefault();
event.stopPropagation();
if (!this.isSelected()) {
this.select();
dragTarget = this.getVisibleRangeDragTarget(point);
}
if (dragTarget !== 'poc') {
return;
}
this.activeDragTarget = 'poc';
this.dragPointerId = event.pointerId;
this.mode = 'dragging';
this.hideCrosshair();
this.render();
}
private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
if (this.handleFixedRangeCreation(event, point)) {
return;
}
if (this.mode !== 'ready') {
return;
}
const dragTarget = this.getFixedRangeDragTarget(point);
if (!this.isSelected()) {
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
}
private handleFixedRangeCreation(event: PointerEvent, point: Point): boolean {
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return true;
}
if (this.mode !== 'drawing') {
return false;
}
this.consumeEvent(event);
this.updateDrawing(point);
this.finishDrawing();
return true;
}
protected handlePointerMove = (event: PointerEvent): void => {
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerMove(event, this.getEventPoint(event));
return;
}
const point = this.mode === 'dragging' ? this.getRawEventPoint(event) : this.getEventPoint(event);
this.handleFixedRangePointerMove(event, point);
};
private handleVisibleRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || this.activeDragTarget !== 'poc') {
return;
}
event.preventDefault();
this.moveVisibleRangeStart(point);
this.calculateProfile();
this.render();
}
private handleFixedRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveBody(point);
this.render();
return;
}
this.moveHandle(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.createDrawingAnchor(clampPointToContainer(point, this.container));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.calculateProfile();
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createDrawingAnchor(clampPointToContainer(point, this.container));
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.calculateProfile();
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (
this.profileKind === 'fixedRange' &&
(!geometry || geometry.width < MIN_PROFILE_SIZE || geometry.height < MIN_PROFILE_SIZE)
) {
if (this.removeDrawing()) {
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DragTarget, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.profileRows = [];
this.profileMinPrice = null;
this.profileMaxPrice = null;
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private moveVisibleRangeStart(point: Point): void {
const { width } = getContainerSize(this.container);
if (width <= 0) {
return;
}
this.visibleRangeStartRatio = clamp(point.x / width, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
private moveHandle(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body' || this.activeDragTarget === 'poc') {
return;
}
const anchor = this.createDrawingAnchor(point);
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startAnchor = anchor;
}
if (this.activeDragTarget === 'end') {
this.endAnchor = anchor;
}
this.calculateProfile();
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const offsetX = point.x - dragStartPoint.x;
const offsetY = point.y - dragStartPoint.y;
this.setAnchorsFromPoints(
{ x: geometry.startPoint.x + offsetX, y: geometry.startPoint.y + offsetY },
{ x: geometry.endPoint.x + offsetX, y: geometry.endPoint.y + offsetY },
);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createDrawingAnchor(startPoint);
const endAnchor = this.createDrawingAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return;
}
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
this.calculateProfile();
}
private getDragTarget(point: Point): Exclude<DragTarget, null> | null {
if (this.profileKind === 'visibleRange') {
return this.getVisibleRangeDragTarget(point);
}
return this.getFixedRangeDragTarget(point);
}
private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
const handle = this.getDrawingHandleAtPoint(point);
if (handle?.id === 'poc') {
return 'poc';
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
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;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private calculateProfile(): void {
if (this.profileKind === 'visibleRange') {
this.calculateAnchoredVolumeProfile();
return;
}
this.calculateFixedRangeVolumeProfile();
}
private calculateAnchoredVolumeProfile(): void {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) {
this.clearProfile();
return;
}
const from = Number(visibleRange.from);
const to = Number(visibleRange.to);
const start = from + (to - from) * this.visibleRangeStartRatio;
this.calculateProfileByLogicalRange(start, to);
}
private calculateFixedRangeVolumeProfile(): void {
if (!this.startAnchor || !this.endAnchor) {
this.clearProfile();
return;
}
const leftFrameTime = Math.min(Number(this.startAnchor.time), Number(this.endAnchor.time));
const rightFrameTime = Math.max(Number(this.startAnchor.time), Number(this.endAnchor.time));
const candles = this.series.data() as SeriesCandleData[];
const selectedCandles: SeriesCandleData[] = [];
candles.forEach((candle) => {
const candleTime = Number(candle.time);
if (candleTime >= leftFrameTime && candleTime <= rightFrameTime) {
selectedCandles.push(candle);
}
});
this.calculateProfileByCandles(selectedCandles);
}
private calculateProfileByLogicalRange(fromLogical: number, toLogical: number): void {
const candles = this.series.data() as SeriesCandleData[];
if (!candles.length) {
this.clearProfile();
return;
}
const fromIndex = Math.max(0, Math.floor(Math.min(fromLogical, toLogical)));
const toIndex = Math.min(candles.length - 1, Math.ceil(Math.max(fromLogical, toLogical)));
if (fromIndex > toIndex) {
this.clearProfile();
return;
}
this.calculateProfileByCandles(candles.slice(fromIndex, toIndex + 1));
}
private calculateProfileByCandles(candles: SeriesCandleData[]): void {
const priceRange = this.getProfilePriceRange(candles);
if (!priceRange) {
this.clearProfile();
return;
}
const { min: minPrice, max: maxPrice } = priceRange;
const priceStep = (maxPrice - minPrice) / PROFILE_ROW_COUNT;
const profileRows = this.createEmptyProfileRows(minPrice, priceStep);
candles.forEach((candle) => {
this.addCandleToProfile(profileRows, candle, minPrice, maxPrice);
});
this.profileMinPrice = minPrice;
this.profileMaxPrice = maxPrice;
this.profileRows = profileRows;
}
private getProfilePriceRange(candles: SeriesCandleData[]): { min: number; max: number } | null {
let minPrice = Number.POSITIVE_INFINITY;
let maxPrice = Number.NEGATIVE_INFINITY;
candles.forEach((candle) => {
const price = this.getCandlePrice(candle);
if (price === null) {
return;
}
minPrice = Math.min(minPrice, this.getCandleLow(candle, price));
maxPrice = Math.max(maxPrice, this.getCandleHigh(candle, price));
});
if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
return null;
}
if (minPrice === maxPrice) {
maxPrice = minPrice + Math.max(Math.abs(minPrice) * 0.001, 1);
}
return { min: minPrice, max: maxPrice };
}
private addCandleToProfile(
rows: VolumeProfileDataRow[],
candle: SeriesCandleData,
minPrice: number,
maxPrice: number,
): void {
const volume = this.getCandleVolume(candle);
const price = this.getCandlePrice(candle);
if (volume <= 0 || price === null) {
return;
}
const candleHigh = this.getCandleHigh(candle, price);
const candleLow = this.getCandleLow(candle, price);
if (candleHigh < minPrice || candleLow > maxPrice) {
return;
}
const isBuyVolume = this.isBuyVolume(candle);
if (candleHigh === candleLow) {
addPointVolume(rows, candleHigh, volume, isBuyVolume);
return;
}
const highInRange = Math.min(maxPrice, candleHigh);
const lowInRange = Math.max(minPrice, candleLow);
const candleRange = candleHigh - candleLow;
rows.forEach((row) => {
const overlap = getPriceOverlap(row, lowInRange, highInRange);
if (overlap > 0) {
addVolumeToRow(row, volume * (overlap / candleRange), isBuyVolume);
}
});
}
private createEmptyProfileRows(minPrice: number, priceStep: number): VolumeProfileDataRow[] {
const rows: VolumeProfileDataRow[] = [];
for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
rows.push({
priceLow: minPrice + priceStep * index,
priceHigh: minPrice + priceStep * (index + 1),
buyVolume: 0,
sellVolume: 0,
totalVolume: 0,
});
}
return rows;
}
private clearProfile(): void {
this.profileRows = [];
this.profileMinPrice = null;
this.profileMaxPrice = null;
}
private getCandleVolume(candle: SeriesCandleData): number {
return this.getNumber(candle.customValues?.volume) ?? this.getNumber(candle.volume) ?? 0;
}
private getCandlePrice(candle: SeriesCandleData): number | null {
return (
this.getNumber(candle.close) ??
this.getNumber(candle.customValues?.close) ??
this.getNumber(candle.value) ??
this.getNumber(candle.customValues?.value)
);
}
private getCandleOpen(candle: SeriesCandleData): number | null {
return this.getNumber(candle.open) ?? this.getNumber(candle.customValues?.open);
}
private getCandleClose(candle: SeriesCandleData): number | null {
return (
this.getNumber(candle.close) ??
this.getNumber(candle.customValues?.close) ??
this.getNumber(candle.value) ??
this.getNumber(candle.customValues?.value)
);
}
private getCandleHigh(candle: SeriesCandleData, fallbackPrice: number): number {
return this.getNumber(candle.high) ?? this.getNumber(candle.customValues?.high) ?? fallbackPrice;
}
private getCandleLow(candle: SeriesCandleData, fallbackPrice: number): number {
return this.getNumber(candle.low) ?? this.getNumber(candle.customValues?.low) ?? fallbackPrice;
}
private isBuyVolume(candle: SeriesCandleData): boolean {
const open = this.getCandleOpen(candle);
const close = this.getCandleClose(candle);
if (open === null || close === null) {
return true;
}
return close >= open;
}
private getNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
protected getGeometry(): VolumeProfileGeometry | null {
if (this.profileKind === 'visibleRange') {
return this.getAnchoredVolumeProfileGeometry();
}
return this.getFixedRangeVolumeProfileGeometry();
}
private getAnchoredVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
return null;
}
const { width, height } = getContainerSize(this.container);
const topCoordinate = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
const bottomCoordinate = getYCoordinateFromPrice(this.series, this.profileMinPrice);
if (topCoordinate === null || bottomCoordinate === null) {
return null;
}
const left = clamp(Math.round(width * this.visibleRangeStartRatio), 0, width);
const right = width;
const top = clamp(Math.round(Math.min(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
const bottom = clamp(Math.round(Math.max(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
return {
startPoint: {
x: left,
y: top,
},
endPoint: {
x: right,
y: bottom,
},
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getFixedRangeVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
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.profileMinPrice);
const endY = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = { x: Number(startX), y: Number(startY) };
const endPoint = { x: Number(endX), y: Number(endY) };
const left = Math.min(startPoint.x, endPoint.x);
const right = Math.max(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
const bottom = Math.max(startPoint.y, endPoint.y);
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getPocY(geometry: VolumeProfileGeometry): number | null {
return this.getProfileRenderRows(geometry).pocY;
}
private getProfileRenderRows(geometry: VolumeProfileGeometry): {
rows: VolumeProfileRenderRow[];
pocY: number | null;
} {
if (!this.profileRows.length) {
return {
rows: [],
pocY: null,
};
}
const maxVolume = this.profileRows.reduce((max, row) => Math.max(max, row.totalVolume), 0);
if (maxVolume <= 0) {
return {
rows: [],
pocY: null,
};
}
let pocY: number | null = null;
let pocVolume = 0;
const rows = this.profileRows
.map((row) => {
const highY = getYCoordinateFromPrice(this.series, row.priceHigh);
const lowY = getYCoordinateFromPrice(this.series, row.priceLow);
if (highY === null || lowY === null) {
return null;
}
const top = clamp(Math.min(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
const bottom = clamp(Math.max(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
if (row.totalVolume > pocVolume) {
pocVolume = row.totalVolume;
pocY = (top + bottom) / 2;
}
return {
top,
height: Math.max(1, bottom - top),
buyWidth: (geometry.width * row.buyVolume) / maxVolume,
sellWidth: (geometry.width * row.sellVolume) / maxVolume,
};
})
.filter((row): row is VolumeProfileRenderRow => row !== null);
return {
rows,
pocY,
};
}
}
function isEndpointKind(kind: string): kind is 'start' | 'end' {
return kind === 'start' || kind === 'end';
}
function getPriceOverlap(row: VolumeProfileDataRow, low: number, high: number): number {
return Math.max(0, Math.min(row.priceHigh, high) - Math.max(row.priceLow, low));
}
function addPointVolume(
profileRows: VolumeProfileDataRow[],
price: number,
volume: number,
isBuyVolume: boolean,
): void {
const row = profileRows.find((item, index) => {
const isLastRow = index === profileRows.length - 1;
return price >= item.priceLow && (price < item.priceHigh || isLastRow);
});
if (!row) {
return;
}
addVolumeToRow(row, volume, isBuyVolume);
}
function addVolumeToRow(row: VolumeProfileDataRow, volume: number, isBuyVolume: boolean): void {
if (isBuyVolume) {
row.buyVolume += volume;
} else {
row.sellVolume += volume;
}
row.totalVolume += volume;
}
import {
CrosshairMode,
type AutoscaleInfo,
type IChartApi,
type IPrimitivePaneView,
type ISeriesApi,
type ISeriesPrimitive,
type ISeriesPrimitiveAxisView,
type Logical,
type MouseEventParams,
type PrimitiveHoveredItem,
type PrimitivePaneViewZOrder,
type SeriesAttachedParameter,
type SeriesOptionsMap,
type SeriesType,
type Time,
type TouchMouseEventData,
type UTCTimestamp,
} from 'lightweight-charts';
import { Subject, Subscription, type Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive, type DrawingHandle } from '@core/Drawings/handles';
import {
getAnchorFromPoint,
getPointerPoint as getPointerPointFromEvent,
getRawPointerPoint,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi, UpdatableView } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
export interface DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
isSelected(): boolean;
isLocked(): boolean;
select(): void;
deselect(): void;
}
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
waitTillReady(): Promise<void>;
isCreationPending(): boolean;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
getSettingsTabs(): SettingsTab[];
updateSettings(settings: SettingsValues): void;
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
isHit(event: MouseEvent): boolean;
pointerDown(event: PointerEvent): void;
click(event: MouseEvent): void;
doubleClick(event: MouseEvent): void;
contextMenu(event: MouseEvent): void;
getRenderData(): unknown;
}
export type StartPoint = unknown;
interface DrawingBaseParams {
chart: IChartApi;
series: SeriesApi;
container: HTMLElement;
interaction: DrawingInteraction;
removeSelf?: () => void;
openSettings?: () => void;
}
export interface BaseDrawingParams extends DrawingBaseParams {
formatObservable?: Observable<ChartOptionsModel>;
initialEvent?: MouseEventParams;
}
export interface AxisBoundsGeometry {
left: number;
right: number;
top: number;
bottom: number;
}
interface DrawingViewsOptions {
timeAxisPane?: boolean;
priceAxisPane?: boolean;
timeAxisPaneZOrder?: PrimitivePaneViewZOrder;
priceAxisPaneZOrder?: PrimitivePaneViewZOrder;
}
export abstract class DrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
TMode extends string = string,
TGeometry = unknown,
> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: TMode;
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isBound = false;
protected displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
private readonly interaction: DrawingInteraction;
private readonly removeSelfCallback?: () => void;
private readonly openSettingsCallback?: () => void;
private readonly settingsSubject = new Subject<SettingsValues>();
private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
private paneViewsList: readonly (IPrimitivePaneView & UpdatableView)[] = [];
private timeAxisPaneViewsList: readonly CustomTimeAxisPaneView[] = [];
private priceAxisPaneViewsList: readonly CustomPriceAxisPaneView[] = [];
private timeAxisViewsList: readonly CustomTimeAxisView[] = [];
private priceAxisViewsList: readonly CustomPriceAxisView[] = [];
private isInteractionBound = false;
private readonly onPointerMove = (event: PointerEvent): void => {
this.handlePointerMove(event);
};
private readonly onPointerUp = (event: PointerEvent): void => {
this.handlePointerUp(event);
};
constructor({ chart, series, container, interaction, removeSelf, openSettings }: DrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
this.removeSelfCallback = removeSelf;
this.openSettingsCallback = openSettings;
this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
if (this.hidden || !this.shouldShowHandles()) {
return [];
}
return this.getDrawingHandles();
});
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = { ...this.settings, ...settings };
this.notifySettingsChanged();
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.series.attachPrimitive(this.handlesPrimitive);
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
this.series.detachPrimitive(this.handlesPrimitive);
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return { ...hoveredItem, cursorStyle: 'pointer' };
}
public isHit(event: MouseEvent): boolean {
if (!this.isEventInside(event)) {
return false;
}
const point = this.getEventPoint(event as PointerEvent);
return this.getHoveredItem(point.x, point.y) !== null;
}
public pointerDown(event: PointerEvent): void {
if (!this.isEventInside(event)) {
return;
}
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
this.handleLockedPointerDown(event);
}
public click(event: MouseEvent): void {
if (this.isEventInside(event)) {
this.handleClick(event);
}
}
public doubleClick(event: MouseEvent): void {
if (this.isEventInside(event)) {
this.handleDoubleClick(event);
}
}
public contextMenu(event: MouseEvent): void {
if (this.isEventInside(event)) {
this.handleContextMenu(event);
}
}
public abstract getRenderData(): unknown; // todo: make proper type
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public updateAllViews(): void {
updateViews([
...this.paneViewsList,
...this.timeAxisPaneViewsList,
...this.priceAxisPaneViewsList,
...this.timeAxisViewsList,
...this.priceAxisViewsList,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return this.paneViewsList;
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.timeAxisPaneViewsList;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewsList;
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return this.timeAxisViewsList;
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return this.priceAxisViewsList;
}
protected isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
protected shouldShowAxisElements(): boolean {
return this.isSelected() || this.isCreationPending();
}
protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
return [];
}
protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
return this.handlesPrimitive.findHandle(point);
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({ crosshair: { mode: CrosshairMode.Hidden } });
}
protected showCrosshair(): void {
this.chart.applyOptions({ crosshair: { mode: CrosshairMode.Normal } });
}
protected getEventPoint(event: PointerEvent | TouchMouseEventData): Point {
return getPointerPointFromEvent(this.container, event);
}
protected getRawEventPoint(event: PointerEvent | TouchMouseEventData): Point {
return getRawPointerPoint(this.container, event);
}
protected createDrawingAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getPointFromAnchor(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) };
}
protected createDefaultAxisLabel(coordinate: number | null, text: string): AxisLabel | null {
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected createTimeAxisLabelForValue(time: Time | null, coordinate?: number | null): AxisLabel | null {
if (typeof time !== 'number') {
return null;
}
const resolvedCoordinate =
coordinate === undefined ? getXCoordinateFromTime(this.chart, time, this.series) : coordinate;
const numericCoordinate = resolvedCoordinate === null ? null : Number(resolvedCoordinate);
const text = formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
return this.createDefaultAxisLabel(numericCoordinate, text);
}
protected createPriceAxisLabelForValue(price: number | null, coordinate?: number | null): AxisLabel | null {
if (price === null) {
return null;
}
const resolvedCoordinate = coordinate === undefined ? getYCoordinateFromPrice(this.series, price) : coordinate;
const numericCoordinate = resolvedCoordinate === null ? null : Number(resolvedCoordinate);
return this.createDefaultAxisLabel(numericCoordinate, formatPrice(price) ?? '');
}
protected getPrimaryPointerDownPoint(event: PointerEvent): Point | null {
if (this.hidden || event.button !== 0) {
return null;
}
return this.getEventPoint(event);
}
protected openSettingsOnDoubleClick(
event: MouseEvent,
isReady: boolean,
isHit: (point: Point) => boolean,
requireSelected = false,
): void {
if (this.hidden || !isReady || (requireSelected && !this.isSelected())) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!isHit(point)) {
return;
}
this.consumeEvent(event);
this.openDrawingSettings();
}
protected removeDrawing(): boolean {
if (!this.removeSelfCallback) {
return false;
}
this.removeSelfCallback();
return true;
}
protected openDrawingSettings(): void {
this.openSettingsCallback?.();
}
protected notifySettingsChanged(): void {
this.settingsSubject.next(this.getSettings());
}
protected consumeEvent(event: Event): void {
event.preventDefault();
event.stopPropagation();
}
// todo: хочется общую реализацию для каждой кнопки
protected handleClick(_event: MouseEvent): void {}
protected handleContextMenu(_event: MouseEvent): void {}
protected handleDoubleClick(_event: MouseEvent): void {}
protected handlePointerMove(_event: PointerEvent): void {}
protected handlePointerUp(_event: PointerEvent): void {}
protected handlePointerDown(_event: PointerEvent | TouchMouseEventData): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): TGeometry | null;
protected getTimeAxisSegments(): AxisSegment[] {
const geometry = this.getAxisBoundsGeometry();
return this.createAxisSegments(geometry?.left ?? null, geometry?.right ?? null);
}
protected getPriceAxisSegments(): AxisSegment[] {
const geometry = this.getAxisBoundsGeometry();
return this.createAxisSegments(geometry?.top ?? null, geometry?.bottom ?? null);
}
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
protected initializeDrawingViews(
paneView: IPrimitivePaneView & UpdatableView,
timeAxisLabelKinds: readonly string[] = [],
priceAxisLabelKinds: readonly string[] = [],
options: DrawingViewsOptions = {},
): void {
const {
timeAxisPane = true,
priceAxisPane = true,
timeAxisPaneZOrder = 'bottom',
priceAxisPaneZOrder = 'bottom',
} = options;
this.paneViewsList = [paneView];
this.timeAxisPaneViewsList = timeAxisPane
? [
new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
zOrder: timeAxisPaneZOrder,
}),
]
: [];
this.priceAxisPaneViewsList = priceAxisPane
? [
new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
zOrder: priceAxisPaneZOrder,
}),
]
: [];
this.timeAxisViewsList = timeAxisLabelKinds.map(
(labelKind) =>
new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind,
}),
);
this.priceAxisViewsList = priceAxisLabelKinds.map(
(labelKind) =>
new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind,
}),
);
}
protected initializeDrawing(
formatObservable?: Observable<ChartOptionsModel>,
initialEvent?: MouseEventParams,
startDrawing?: (point: Point) => void,
): void {
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
if (initialEvent?.sourceEvent && startDrawing) {
startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
protected createAxisSegments(from: number | null, to: number | null): AxisSegment[] {
if (!this.shouldShowAxisElements() || from === null || to === null) {
return [];
}
return [{ from, to, color: getThemeStore().colors.axisMarkerAreaFill }];
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
window.addEventListener('pointercancel', this.onPointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointercancel', this.onPointerUp);
}
private handleLockedPointerDown(event: PointerEvent): void {
const point = this.getEventPoint(event);
if (this.getHoveredItem(point.x, point.y)) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
}
private bindInteraction(): void {
if (this.isInteractionBound) {
return;
}
this.isInteractionBound = true;
this.subscriptions.add(this.interaction.selected$.subscribe(() => this.render()));
this.subscriptions.add(
this.interaction.locked$.subscribe((isLocked) => {
if (isLocked) {
this.showCrosshair();
}
this.render();
}),
);
}
private getAxisBoundsGeometry(): AxisBoundsGeometry | null {
const geometry = this.getGeometry() as Partial<AxisBoundsGeometry> | null;
if (!geometry) {
return null;
}
const { left, right, top, bottom } = geometry;
const values = [left, right, top, bottom];
if (!values.every((value) => typeof value === 'number' && Number.isFinite(value))) {
return null;
}
return {
left: left as number,
right: right as number,
top: top as number,
bottom: bottom as number,
};
}
private isEventInside(event: MouseEvent): boolean {
if (event.target instanceof Node && this.container.contains(event.target)) {
return true;
}
return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
}
}
import { isNearPoint } from '@core/Drawings/helpers';
import { drawRoundedRect } from '@src/core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import type { Point } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
export type DrawingHandleShape = 'circle' | 'rounded';
export interface DrawingHandle<TId extends string = string> {
id: TId;
x: number;
y: number;
shape?: DrawingHandleShape;
size?: number;
borderWidth?: number;
strokeColor?: string;
}
const DEFAULT_HANDLE_SIZE = 12;
const DEFAULT_HANDLE_BORDER_WIDTH = 2;
const HANDLE_HIT_TOLERANCE = 8;
const HANDLE_RADIUS = 2;
export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
private series: SeriesAttachedParameter<Time>['series'] | null = null;
private readonly paneRenderer: IPrimitivePaneRenderer = {
draw: (target) => this.drawPane(target),
};
private readonly timeAxisRenderer: IPrimitivePaneRenderer = {
draw: (target) => this.drawTimeAxis(target),
};
private readonly paneViewsList: readonly IPrimitivePaneView[] = [
{
renderer: () => this.paneRenderer,
zOrder: () => 'top',
},
];
private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[] = [
{
renderer: () => this.timeAxisRenderer,
zOrder: () => 'top',
},
];
constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {}
public attached({ chart, series }: SeriesAttachedParameter<Time>): void {
this.chart = chart;
this.series = series;
}
public detached(): void {
this.chart = null;
this.series = null;
}
public paneViews(): readonly IPrimitivePaneView[] {
return this.paneViewsList;
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.timeAxisPaneViewsList;
}
public findHandle(point: Point): DrawingHandle<TId> | null {
const handles = this.getHandles();
for (let index = handles.length - 1; index >= 0; index -= 1) {
const handle = handles[index];
if (isNearPoint(point, handle.x, handle.y, getHitTolerance(handle))) {
return handle;
}
}
return null;
}
public isTimeAxisHit(point: Point): boolean {
if (!this.isTimeAxisAdjacent()) {
return false;
}
const paneHeight = this.getPaneHeight();
if (paneHeight <= 0 || point.y < paneHeight) {
return false;
}
const handle = this.findHandle(point);
return handle !== null && intersectsTimeAxis(handle, paneHeight);
}
private drawPane(target: CanvasRenderingTarget2D): void {
const handles = this.getHandles();
if (!handles.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
for (const handle of handles) {
drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
}
});
}
private drawTimeAxis(target: CanvasRenderingTarget2D): void {
if (!this.isTimeAxisAdjacent()) {
return;
}
const paneHeight = this.getPaneHeight();
if (paneHeight <= 0) {
return;
}
const handles = this.getHandles();
if (!handles.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
const axisHeight = bitmapSize.height / verticalPixelRatio;
for (const handle of handles) {
if (!intersectsTimeAxis(handle, paneHeight)) {
continue;
}
const axisY = handle.y - paneHeight;
const halfSize = getHandleSize(handle) / 2;
if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
continue;
}
drawHandle(context, { ...handle, y: axisY }, horizontalPixelRatio, verticalPixelRatio);
}
});
}
private getPaneHeight(): number {
return this.series?.getPane().getHeight() ?? 0;
}
private isTimeAxisAdjacent(): boolean {
if (!this.chart || !this.series) {
return false;
}
return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
}
}
function getHandleSize(handle: DrawingHandle): number {
return handle.size ?? DEFAULT_HANDLE_SIZE;
}
function getHitTolerance(handle: DrawingHandle): number {
return Math.max(HANDLE_HIT_TOLERANCE, getHandleSize(handle) / 2);
}
function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
const halfSize = getHandleSize(handle) / 2;
return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
}
function drawHandle(
context: CanvasRenderingContext2D,
handle: DrawingHandle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const size = getHandleSize(handle);
const shape = handle.shape ?? 'circle';
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = (handle.borderWidth ?? DEFAULT_HANDLE_BORDER_WIDTH) * pixelRatio;
const width = size * horizontalPixelRatio;
const height = size * verticalPixelRatio;
const x = handle.x * horizontalPixelRatio;
const y = handle.y * verticalPixelRatio;
const inset = lineWidth / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = handle.strokeColor ?? colors.chartLineColor;
context.lineWidth = lineWidth;
context.beginPath();
if (shape === 'circle') {
const radius = Math.max(Math.min(width, height) / 2 - inset, 0);
context.arc(x, y, radius, 0, Math.PI * 2);
} else {
const left = x - width / 2 + inset;
const top = y - height / 2 + inset;
const drawWidth = Math.max(width - lineWidth, 0);
const drawHeight = Math.max(height - lineWidth, 0);
drawRoundedRect(context, left, top, drawWidth, drawHeight, HANDLE_RADIUS * pixelRatio);
}
context.fill();
context.stroke();
context.restore();
}
import { clamp } from 'lodash-es';
import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';
import type { Coordinate, IChartApi, Logical, Time, TouchMouseEventData } from 'lightweight-charts';
interface SeriesTimeItem {
time: Time;
}
interface TimePoint {
time: number;
logical: number;
}
export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
return series.coordinateToPrice(yCoordinate as Coordinate);
}
export function getYCoordinateFromPrice(series: SeriesApi, price: number): Coordinate | null {
return series.priceToCoordinate(price);
}
export function getTimeFromXCoordinate(
chart: IChartApi,
xCoordinate: number,
series?: SeriesApi,
): Time | null {
const time = chart.timeScale().coordinateToTime(xCoordinate as Coordinate);
if (time !== null) {
return time;
}
if (!series) {
return null;
}
const logical = chart.timeScale().coordinateToLogical(xCoordinate as Coordinate);
if (logical === null || !Number.isFinite(Number(logical))) {
return null;
}
return getTimeFromLogical(series, Number(logical));
}
export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: SeriesApi): Coordinate | null {
const coordinate = chart.timeScale().timeToCoordinate(time);
if (isValidCoordinate(coordinate)) {
return coordinate;
}
if (!series) {
return null;
}
const logical = getNearestLogicalFromTime(series, time);
if (logical === null) {
return null;
}
const projectedCoordinate = chart.timeScale().logicalToCoordinate(logical as Logical);
return isValidCoordinate(projectedCoordinate) ? projectedCoordinate : null;
}
export function getContainerSize(container: HTMLElement): ContainerSize {
const rect = container.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
};
}
export function clampPointToContainer(point: Point, container: HTMLElement): Point {
const { width, height } = getContainerSize(container);
return {
x: clamp(point.x, 0, width),
y: clamp(point.y, 0, height),
};
}
export function getRawPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
const rect = container.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
export function getPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
return clampPointToContainer(getRawPointerPoint(container, event), container);
}
export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
return Math.abs(point.x - x) <= tolerance && Math.abs(point.y - y) <= tolerance;
}
export function isPointInBounds(point: Point, bounds: Bounds, tolerance = 0): boolean {
return (
point.x >= bounds.left - tolerance &&
point.x <= bounds.right + tolerance &&
point.y >= bounds.top - tolerance &&
point.y <= bounds.bottom + tolerance
);
}
export function normalizeBounds(
left: number,
right: number,
top: number,
bottom: number,
container: HTMLElement,
): Bounds {
const { width, height } = getContainerSize(container);
return {
left: clamp(Math.min(left, right), 0, width),
right: clamp(Math.max(left, right), 0, width),
top: clamp(Math.min(top, bottom), 0, height),
bottom: clamp(Math.max(top, bottom), 0, height),
};
}
export function shiftTimeByPixels(chart: IChartApi, time: Time, offsetX: number, series?: SeriesApi): Time | null {
const coordinate = getXCoordinateFromTime(chart, time, series);
if (!isValidCoordinate(coordinate)) {
return null;
}
return getTimeFromXCoordinate(chart, Number(coordinate) + offsetX, series);
}
export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
const fromPrice = getPriceFromYCoordinate(series, fromY);
const toPrice = getPriceFromYCoordinate(series, toY);
if (fromPrice === null || toPrice === null) {
return 0;
}
return toPrice - fromPrice;
}
export function getPriceRangeInContainer(
series: SeriesApi,
container: HTMLElement,
): { min: number; max: number } | null {
const { height } = getContainerSize(container);
if (!height) {
return null;
}
const topPrice = getPriceFromYCoordinate(series, 0);
const bottomPrice = getPriceFromYCoordinate(series, height);
if (topPrice === null || bottomPrice === null) {
return null;
}
return {
min: Math.min(topPrice, bottomPrice),
max: Math.max(topPrice, bottomPrice),
};
}
export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
const time = getTimeFromXCoordinate(chart, point.x, series);
const price = getPriceFromYCoordinate(series, point.y);
if (time === null || price === null) {
return null;
}
return { time, price };
}
export function findNearestTimeIndex(data: readonly { time: Time }[], time: Time): number {
const targetTime = getNumericTime(time);
if (targetTime === null || !data.length) {
return -1;
}
return findNearestIndex(data, targetTime);
}
function getNearestLogicalFromTime(series: SeriesApi, time: Time): number | null {
const targetTime = getNumericTime(time);
if (targetTime === null) {
return null;
}
const points = getSeriesTimePoints(series);
if (!points.length || targetTime < points[0].time || targetTime > points[points.length - 1].time) {
return null;
}
// Если точного времени нет на текущем таймфрейме, left и right становятся соседними свечами вокруг targetTime
// Для отображения дровинга берём ближайшую существующую свечу, но исходный state дровинга не меняем
const index = findNearestIndex(points, targetTime);
return index < 0 ? null : points[index].logical;
}
function getSeriesTimePoints(series: SeriesApi): TimePoint[] {
const data = series.data() as readonly SeriesTimeItem[];
return data.reduce<TimePoint[]>((points, item, logical) => {
const time = getNumericTime(item.time);
if (time !== null) {
points.push({ time, logical });
}
return points;
}, []);
}
function getTimeFromLogical(series: SeriesApi, logical: number): Time | null {
const data = series.data() as readonly SeriesTimeItem[];
if (!data.length) {
return null;
}
const index = Math.round(logical);
if (index < 0 || index >= data.length) {
return null;
}
return data[index].time ?? null;
}
function findNearestIndex(data: readonly { time: Time }[], targetTime: number): number {
let left = 0;
let right = data.length - 1;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const middleTime = getNumericTime(data[middle].time);
if (middleTime === null) {
return findNearestIndexLinear(data, targetTime);
}
if (middleTime === targetTime) {
return middle;
}
if (middleTime < targetTime) {
left = middle + 1;
} else {
right = middle - 1;
}
}
return getNearestIndexFromBounds(data, targetTime, right, left);
}
function getNearestIndexFromBounds(
data: readonly { time: Time }[],
targetTime: number,
previousIndex: number,
nextIndex: number,
): number {
if (previousIndex < 0) {
return nextIndex < data.length ? nextIndex : -1;
}
if (nextIndex >= data.length) {
return previousIndex;
}
const previousTime = getNumericTime(data[previousIndex].time);
const nextTime = getNumericTime(data[nextIndex].time);
if (previousTime === null || nextTime === null) {
return findNearestIndexLinear(data, targetTime);
}
return Math.abs(targetTime - previousTime) <= Math.abs(nextTime - targetTime) ? previousIndex : nextIndex;
}
function findNearestIndexLinear(data: readonly { time: Time }[], targetTime: number): number {
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
data.forEach((item, index) => {
const itemTime = getNumericTime(item.time);
if (itemTime === null) {
return;
}
const distance = Math.abs(itemTime - targetTime);
if (distance < nearestDistance) {
nearestIndex = index;
nearestDistance = distance;
}
});
return nearestIndex;
}
function getNumericTime(time: Time): number | null {
return typeof time === 'number' && Number.isFinite(time) ? time : null;
}
function isValidCoordinate(coordinate: Coordinate | null): coordinate is Coordinate {
return coordinate !== null && Number.isFinite(Number(coordinate));
}
import { DrawingsNames } from '@src/constants';
import { Keys } from '@src/core/Hotkeys';
import type { MouseEventParams } from 'lightweight-charts';
type DrawingPointerModifier = Keys.shift | Keys.alt | Keys.control | Keys.meta;
interface DrawingKeyboardShortcut {
drawingName: DrawingsNames;
keys: readonly Keys[];
}
interface DrawingPointerShortcut {
drawingName: DrawingsNames;
modifiers: readonly DrawingPointerModifier[];
}
export const DRAWING_KEYBOARD_SHORTCUTS: readonly DrawingKeyboardShortcut[] = [
{
drawingName: DrawingsNames.trendLine,
keys: [Keys.alt, Keys.t],
},
{
drawingName: DrawingsNames.horizontalLine,
keys: [Keys.alt, Keys.h],
},
{
drawingName: DrawingsNames.verticalLine,
keys: [Keys.alt, Keys.v],
},
{
drawingName: DrawingsNames.fibonacciRetracement,
keys: [Keys.alt, Keys.f],
},
{
drawingName: DrawingsNames.rectangle,
keys: [Keys.alt, Keys.shift, Keys.r],
},
];
export const DRAWING_POINTER_SHORTCUTS: readonly DrawingPointerShortcut[] = [
{
drawingName: DrawingsNames.ruler,
modifiers: [Keys.shift],
},
];
export function findDrawingPointerShortcut(event: MouseEventParams): DrawingPointerShortcut | undefined {
const { sourceEvent } = event;
if (!sourceEvent) {
return undefined;
}
return DRAWING_POINTER_SHORTCUTS.find(
({ modifiers }) =>
sourceEvent.shiftKey === modifiers.includes(Keys.shift) &&
sourceEvent.altKey === modifiers.includes(Keys.alt) &&
sourceEvent.ctrlKey === modifiers.includes(Keys.control) &&
sourceEvent.metaKey === modifiers.includes(Keys.meta),
);
}
import { DrawingBase, type AxisBoundsGeometry, type BaseDrawingParams } from '@core/Drawings/DrawingBase';
import type { Anchor, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
import type { MouseEventParams, Time } from 'lightweight-charts';
export type TwoPointDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging';
export interface TwoPointGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface LegacyTwoPointAnchorState {
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
}
export interface LegacyTwoPointDrawingState extends LegacyTwoPointAnchorState {
hidden: boolean;
mode: TwoPointDrawingMode;
}
interface AnchorSnapshot {
startAnchor: Anchor;
endAnchor: Anchor;
}
export abstract class TwoPointDrawingBase<
TSettings extends SettingsValues,
THandleId extends string,
TDragTarget extends string,
TGeometry extends AxisBoundsGeometry,
> extends DrawingBase<TSettings, THandleId, TwoPointDrawingMode, TGeometry> {
protected mode: TwoPointDrawingMode = 'idle';
protected startAnchor: Anchor | null = null;
protected endAnchor: Anchor | null = null;
protected activeDragTarget: TDragTarget | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragAnchorSnapshot: AnchorSnapshot | null = null;
private dragGeometrySnapshot: TGeometry | null = null;
private crosshairHiddenByDrag = false;
constructor(params: BaseDrawingParams) {
super(params);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
protected initializeTwoPointDrawing(
formatObservable?: BaseDrawingParams['formatObservable'],
initialEvent?: MouseEventParams,
): void {
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
protected getTwoPointGeometry(): TwoPointGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startPoint = this.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
if (!startPoint || !endPoint) {
return null;
}
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
protected getDragStartPoint(): Point | null {
return this.dragStartPoint;
}
protected getDragGeometrySnapshot(): TGeometry | null {
return this.dragGeometrySnapshot;
}
protected getDragAnchorSnapshot(): AnchorSnapshot | null {
return this.dragAnchorSnapshot;
}
protected setAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
}
protected setAnchorFromPoint(kind: 'start' | 'end', point: Point): boolean {
const anchor = this.createDrawingAnchor(this.normalizeAnchorPoint(point));
if (!anchor) {
return false;
}
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
return true;
}
protected getLegacyTwoPointState(): LegacyTwoPointDrawingState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startAnchor?.time ?? null,
endTime: this.endAnchor?.time ?? null,
startPrice: this.startAnchor?.price ?? null,
endPrice: this.endAnchor?.price ?? null,
};
}
protected restoreLegacyTwoPointState(
state: Partial<LegacyTwoPointDrawingState>,
preserveNullAnchors = false,
): void {
if (typeof state.hidden === 'boolean') {
this.hidden = state.hidden;
}
const mode = this.normalizeRestoredMode(state.mode);
if (mode) {
this.mode = mode;
}
this.restoreLegacyAnchors(state, preserveNullAnchors);
}
protected restoreLegacyAnchors(
state: Partial<LegacyTwoPointAnchorState>,
preserveNullValues = false,
): void {
this.startAnchor = this.restoreLegacyAnchor('start', state, this.startAnchor, preserveNullValues);
this.endAnchor = this.restoreLegacyAnchor('end', state, this.endAnchor, preserveNullValues);
}
protected createRenderDataWithSettings<TRenderData>(geometry: TGeometry | null): TRenderData | null {
if (this.hidden || !geometry) {
return null;
}
return { ...geometry, ...this.settings } as TRenderData;
}
protected moveWholeFromSnapshot(point: Point): void {
const snapshot = this.dragAnchorSnapshot;
if (!snapshot || !this.dragStartPoint) {
return;
}
const offset = this.getDragOffset(point);
const startAnchor = this.shiftAnchorByPixels(snapshot.startAnchor, offset);
const endAnchor = this.shiftAnchorByPixels(snapshot.endAnchor, offset);
if (!startAnchor || !endAnchor) {
return;
}
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
}
private shiftAnchorByPixels(anchor: Anchor, offset: Point): Anchor | null {
const anchorPoint = this.getPointFromAnchor(anchor);
if (!anchorPoint) {
return null;
}
return this.createDrawingAnchor({
x: anchorPoint.x + offset.x,
y: anchorPoint.y + offset.y,
});
}
protected resetToIdle(): void {
this.hidden = false;
this.beforeResetToIdle();
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.clearDragState();
this.render();
}
protected normalizeRestoredMode(mode: unknown): TwoPointDrawingMode | null {
if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
return mode;
}
return mode === 'dragging' ? 'ready' : null;
}
protected normalizeAnchorPoint(point: Point): Point {
return point;
}
protected shouldHideCrosshairWhileDragging(): boolean {
return false;
}
protected shouldStopPropagationWhileDragging(): boolean {
return false;
}
protected handleMissingGeometry(): void {
this.removeOrResetInvalidDrawing();
}
protected handleInvalidGeometry(): void {
this.removeOrResetInvalidDrawing();
}
protected beforeResetToIdle(): void {}
protected handleDoubleClick(event: MouseEvent): void {
this.openSettingsOnDoubleClick(event, this.mode === 'ready', (point) => this.isDrawingHit(point));
}
protected handlePointerDown(event: PointerEvent): void {
const point = this.getPrimaryPointerDownPoint(event);
if (!point || this.handleCreationPointerDown(event, point)) {
return;
}
this.handleReadyPointerDown(event, point);
}
protected handlePointerMove(event: PointerEvent): void {
if (this.mode === 'drawing') {
this.updateDrawing(this.getEventPoint(event));
return;
}
if (!this.isActiveDragEvent(event) || !this.activeDragTarget) {
return;
}
const point = this.getRawEventPoint(event);
if (this.shouldStopPropagationWhileDragging()) {
this.consumeEvent(event);
} else {
event.preventDefault();
}
this.applyDrag(point, this.activeDragTarget);
this.render();
}
protected handlePointerUp(event: PointerEvent): void {
if (!this.isActiveDragEvent(event)) {
return;
}
this.finishDragging();
}
protected abstract isDrawingHit(point: Point): boolean;
protected abstract getDragTarget(point: Point): TDragTarget | null;
protected abstract applyDrag(point: Point, dragTarget: TDragTarget): void;
protected abstract isValidGeometry(geometry: TGeometry): boolean;
private handleCreationPointerDown(event: PointerEvent, point: Point): boolean {
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return true;
}
if (this.mode !== 'drawing') {
return false;
}
this.consumeEvent(event);
this.updateDrawing(point);
this.finishDrawing();
return true;
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
this.selectIfHit(event, point);
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
this.consumeEvent(event);
this.startDragging(point, event.pointerId, dragTarget);
}
private selectIfHit(event: PointerEvent, point: Point): void {
if (!this.isDrawingHit(point)) {
return;
}
this.consumeEvent(event);
this.select();
}
private startDrawing(point: Point): void {
const anchor = this.createDrawingAnchor(this.normalizeAnchorPoint(point));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
if (this.setAnchorFromPoint('end', point)) {
this.render();
}
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
this.handleMissingGeometry();
return;
}
if (!this.isValidGeometry(geometry)) {
this.handleInvalidGeometry();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private removeOrResetInvalidDrawing(): void {
if (this.removeDrawing()) {
return;
}
this.resetToIdle();
}
private startDragging(point: Point, pointerId: number, dragTarget: TDragTarget): void {
if (!this.startAnchor || !this.endAnchor) {
return;
}
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragAnchorSnapshot = {
startAnchor: { ...this.startAnchor },
endAnchor: { ...this.endAnchor },
};
this.dragGeometrySnapshot = this.getGeometry();
this.crosshairHiddenByDrag = this.shouldHideCrosshairWhileDragging();
if (this.crosshairHiddenByDrag) {
this.hideCrosshair();
}
this.render();
}
private finishDragging(): void {
const shouldRestoreCrosshair = this.crosshairHiddenByDrag;
this.mode = 'ready';
this.resolveReady?.();
this.clearDragState();
this.crosshairHiddenByDrag = false;
if (shouldRestoreCrosshair) {
this.showCrosshair();
}
this.render();
}
private clearDragState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragAnchorSnapshot = null;
this.dragGeometrySnapshot = null;
}
private isActiveDragEvent(event: PointerEvent): boolean {
return this.mode === 'dragging' && this.dragPointerId === event.pointerId;
}
private getDragOffset(point: Point): Point {
if (!this.dragStartPoint) {
return { x: 0, y: 0 };
}
return {
x: point.x - this.dragStartPoint.x,
y: point.y - this.dragStartPoint.y,
};
}
private restoreLegacyAnchor(
kind: 'start' | 'end',
state: Partial<LegacyTwoPointAnchorState>,
current: Anchor | null,
preserveNullValues: boolean,
): Anchor | null {
const timeKey = kind === 'start' ? 'startTime' : 'endTime';
const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
const currentTime = current?.time ?? null;
const currentPrice = current?.price ?? null;
const time = this.getRestoredValue(state, timeKey, currentTime, preserveNullValues);
const price = this.getRestoredValue(state, priceKey, currentPrice, preserveNullValues);
return time === null || price === null ? null : { time, price };
}
private getRestoredValue<TKey extends keyof LegacyTwoPointAnchorState>(
state: Partial<LegacyTwoPointAnchorState>,
key: TKey,
currentValue: LegacyTwoPointAnchorState[TKey],
preserveNullValues: boolean,
): LegacyTwoPointAnchorState[TKey] {
if (!(key in state)) {
return currentValue;
}
const nextValue = state[key];
if (nextValue == null && preserveNullValues) {
return currentValue;
}
return (nextValue ?? null) as LegacyTwoPointAnchorState[TKey];
}
}
import type { ISeriesApi, SeriesOptionsMap, Time } from 'lightweight-charts';
export type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
export interface Point {
x: number;
y: number;
}
export interface Bounds {
left: number;
right: number;
top: number;
bottom: number;
}
export interface ContainerSize {
width: number;
height: number;
}
export interface Anchor {
time: Time;
price: number;
}
export interface AxisSegment {
from: number;
to: number;
color: string;
}
export interface AxisLabel {
coordinate: number;
text: string;
textColor: string;
backgroundColor: string;
}
export interface UpdatableView {
update(): void;
}
import { getThemeStore } from '@src/theme';
import { Point } from '@src/utils';
import type { AxisLabel, UpdatableView } from './types';
export function updateViews(views: readonly UpdatableView[]): void {
for (const view of views) {
view.update();
}
}
export function createAxisLabel(coordinate: number | null, text: string | null | undefined): AxisLabel | null {
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
export function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}
export function getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const deltaX = endPoint.x - startPoint.x;
const deltaY = endPoint.y - startPoint.y;
if (deltaX === 0 && deltaY === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const ratio = Math.max(
0,
Math.min(
1,
((point.x - startPoint.x) * deltaX + (point.y - startPoint.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY),
),
);
const projectionX = startPoint.x + ratio * deltaX;
const projectionY = startPoint.y + ratio * deltaY;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
export function getOrderedSideValue<T>(
startValue: T | null,
endValue: T | null,
startCoordinate: number | null,
endCoordinate: number | null,
side: 'start' | 'end',
): T | null {
if (startValue === null || endValue === null) {
return null;
}
if (startCoordinate === null || endCoordinate === null) {
return side === 'start' ? startValue : endValue;
}
const startFirst = startCoordinate <= endCoordinate;
if (side === 'start') {
return startFirst ? startValue : endValue;
}
return startFirst ? endValue : startValue;
}