Загрузка данных
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { TrendLinePaneView } from './paneView';
import {
createDefaultSettings,
getTrendLineSettingsTabs,
TrendLineSettings,
TrendLineStyle,
TrendLineTextStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type TrendLineMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface TrendLineParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface TrendLineState {
hidden: boolean;
isActive: boolean;
mode: TrendLineMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: TrendLineSettings;
}
interface TrendLineGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface TrendLineRenderData extends TrendLineGeometry, TrendLineStyle, TrendLineTextStyle {
showHandles: boolean;
}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class TrendLine extends SeriesDrawingBase<TrendLineSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TrendLineSettings = createDefaultSettings();
protected mode: TrendLineMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: TrendLineState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: TrendLinePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: TrendLineParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TrendLinePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): TrendLineState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<TrendLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTrendLineSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): TrendLineRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (this.getPointTarget(point)) {
return {
cursorStyle: 'move',
externalId: 'trend-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'trend-line',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.getPointTarget(point) && !this.isPointNearLine(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
const isNearLine = this.isPointNearLine(point);
const isDrawingHit = pointTarget !== null || isNearLine;
if (!this.isActive.value) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (isNearLine) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.isActive.next(false);
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: TrendLineMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-end') {
this.endAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: snapshot.endAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): TrendLineGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
private getPointTarget(point: Point): 'start' | 'end' | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
return 'end';
}
return null;
}
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { VolumeProfilePaneView } from './paneView';
import {
createDefaultSettings,
getVolumeProfileSettingsTabs,
VolumeProfileSettings,
VolumeProfileStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
export type VolumeProfileKind = 'fixedRange' | 'visibleRange';
type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;
interface VolumeProfileParams {
container: HTMLElement;
profileKind?: VolumeProfileKind;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
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;
isActive: 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;
showHandles: boolean;
}
const PROFILE_ROW_COUNT = 24;
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
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;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: VolumeProfilePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, profileKind = 'fixedRange', formatObservable, removeSelf, openSettings }: VolumeProfileParams,
) {
super({ chart, series, container });
this.profileKind = profileKind;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
this.isActive.next(true);
}
this.paneView = new VolumeProfilePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.calculateProfile();
}
this.series.attachPrimitive(this);
}
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,
isActive: this.isActive.value,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
visibleRangeStartRatio: this.visibleRangeStartRatio,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<VolumeProfileState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if (this.profileKind === 'fixedRange') {
this.mode = nextState.mode ?? this.mode;
this.startAnchor = nextState.startAnchor ?? this.startAnchor;
this.endAnchor = nextState.endAnchor ?? this.endAnchor;
}
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
this.resolveReady?.();
if (typeof nextState.visibleRangeStartRatio === 'number') {
this.visibleRangeStartRatio = clamp(nextState.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.calculateProfile();
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getVolumeProfileSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.priceAxisPaneView];
}
public timeAxisViews() {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.startPriceAxisView, this.endPriceAxisView];
}
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,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (this.profileKind === 'visibleRange' || !this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (this.profileKind === 'visibleRange' || !this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
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.isActive.value ? 'grab' : 'pointer',
externalId: 'volume-profile',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind === 'visibleRange') {
return null;
}
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind === 'visibleRange') {
return null;
}
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
// const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;
if (!price) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private handleVisibleLogicalRangeChange = (): void => {
if (this.profileKind !== 'visibleRange') {
return;
}
this.calculateProfile();
this.render();
};
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDragTarget(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerDown(event, point);
return;
}
this.handleFixedRangePointerDown(event, point);
};
protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
const dragTarget = this.getVisibleRangeDragTarget(point);
if (!dragTarget) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
if (dragTarget !== 'poc') {
this.render();
return;
}
this.activeDragTarget = 'poc';
this.dragPointerId = event.pointerId;
this.mode = 'dragging';
this.hideCrosshair();
this.render();
}
private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const dragTarget = this.getFixedRangeDragTarget(point);
if (!this.isActive.value) {
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (!dragTarget) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
}
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerMove(event, point);
return;
}
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.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.isActive.next(true);
this.mode = 'drawing';
this.calculateProfile();
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
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.removeSelf) {
this.removeSelf();
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.isActive.next(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 } = this.getContainerSize();
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.createAnchor(this.clampPointToContainer(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 { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
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.createAnchor(this.clampPointToContainer(startPoint));
const endAnchor = this.createAnchor(this.clampPointToContainer(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 geometry = this.getGeometry();
if (!geometry) {
return null;
}
const pocY = this.getPocY(geometry);
if (pocY !== null && isNearPoint(point, geometry.left, pocY, HANDLE_HIT_TOLERANCE)) {
return 'poc';
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'end';
}
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[], fixedMinPrice?: number, fixedMaxPrice?: number): void {
if (!candles.length && (fixedMinPrice === undefined || fixedMaxPrice === undefined)) {
this.clearProfile();
return;
}
let minPrice = fixedMinPrice ?? Infinity;
let maxPrice = fixedMaxPrice ?? -Infinity;
if (fixedMinPrice === undefined || fixedMaxPrice === undefined) {
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)) {
this.clearProfile();
return;
}
if (minPrice === maxPrice) {
maxPrice = minPrice + Math.max(Math.abs(minPrice) * 0.001, 1);
}
this.profileMinPrice = minPrice;
this.profileMaxPrice = maxPrice;
const priceStep = (maxPrice - minPrice) / PROFILE_ROW_COUNT;
const profileRows = this.createEmptyProfileRows(minPrice, priceStep);
candles.forEach((candle) => {
const volume = this.getCandleVolume(candle);
if (volume <= 0) {
return;
}
const price = this.getCandlePrice(candle);
if (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(profileRows, candleHigh, volume, isBuyVolume);
return;
}
const highInRange = Math.min(maxPrice, candleHigh);
const lowInRange = Math.max(minPrice, candleLow);
const candleRange = candleHigh - candleLow;
profileRows.forEach((row) => {
const overlap = Math.max(0, Math.min(row.priceHigh, highInRange) - Math.max(row.priceLow, lowInRange));
if (overlap <= 0) {
return;
}
addVolumeToRow(row, volume * (overlap / candleRange), isBuyVolume);
});
});
this.profileRows = profileRows;
}
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 } = this.getContainerSize();
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 { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.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 };
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
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 { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} from '@core/Drawings/helpers';
import { AxisLabel } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { TraectoryPaneView } from './paneView';
import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
interface TraectoryParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
export interface TraectoryState {
hidden: boolean;
isActive: 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;
showHandles: boolean;
showArrow: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
export class Traectory extends SeriesDrawingBase<TraectorySettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TraectorySettings = createDefaultSettings();
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
private previewAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragPointIndex: number | null = null;
private dragGeometrySnapshot: TraectoryGeometry | null = null;
private readonly paneView: TraectoryPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
constructor(chart: IChartApi, series: SeriesApi, params: TraectoryParams) {
super({ chart, series, container: params.container });
const { removeSelf, openSettings } = params;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TraectoryPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): TraectoryState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
points: this.points,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TraectoryState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
this.mode = nextState.mode ?? this.mode;
this.points = Array.isArray(nextState.points) ? nextState.points : this.points;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTraectorySettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisPaneView, this.priceAxisPaneView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [];
}
public priceAxisViews() {
return [];
}
public getRenderData(): TraectoryRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
previewPoint: this.getPreviewPoint(),
showHandles: this.shouldShowHandles(),
showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
const pointIndex = this.getPointIndexAt(point);
if (pointIndex !== null) {
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
if (!this.isPointNearTraectory(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
return null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
return null;
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden) {
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
const point = this.getEventPoint(event as PointerEvent);
this.appendPoint(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (this.getPointIndexAt(point) === null && !this.isPointNearTraectory(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handleContextMenu = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'drawing') {
return;
}
event.preventDefault();
event.stopPropagation();
this.finishDrawing();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
if (event.detail > 1) {
return;
}
this.appendPoint(point);
return;
}
if (this.mode !== 'ready') {
return;
}
const pointIndex = this.getPointIndexAt(point);
const isInsideTraectory = pointIndex !== null || this.isPointNearTraectory(point);
if (!this.isActive.value) {
if (!isInsideTraectory) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (pointIndex !== null) {
event.preventDefault();
event.stopPropagation();
this.startDraggingPoint(point, event.pointerId, pointIndex);
return;
}
if (!this.isPointNearTraectory(point)) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDraggingBody(point, event.pointerId);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updatePreview(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-point') {
event.preventDefault();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-point' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.points = [anchor];
this.previewAnchor = anchor;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private appendPoint(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
const lastPoint = this.points[this.points.length - 1];
if (lastPoint && Number(lastPoint.time) === Number(anchor.time) && lastPoint.price === anchor.price) {
this.previewAnchor = anchor;
this.render();
return;
}
this.points = [...this.points, anchor];
this.previewAnchor = anchor;
this.render();
}
private updatePreview(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.previewAnchor = anchor;
this.render();
}
private finishDrawing(): void {
if (this.points.length < MIN_POINTS_COUNT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.previewAnchor = null;
this.isActive.next(true);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDraggingPoint(point: Point, pointerId: number, pointIndex: number): void {
this.mode = 'dragging-point';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = pointIndex;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private startDraggingBody(point: Point, pointerId: number): void {
this.mode = 'dragging-body';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = null;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.isActive.next(false);
this.mode = 'idle';
this.points = [];
this.previewAnchor = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const pointIndex = this.dragPointIndex;
if (!geometry || pointIndex === null) {
return;
}
const nextPoint = this.clampPointToContainer(point);
const nextPoints = [...geometry.points];
nextPoints[pointIndex] = nextPoint;
this.setAnchorsFromPoints(nextPoints);
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextPoints = geometry.points.map((item) =>
this.clampPointToContainer({
x: item.x + offsetX,
y: item.y + offsetY,
}),
);
this.setAnchorsFromPoints(nextPoints);
}
private setAnchorsFromPoints(points: Point[]): void {
const nextAnchors: Anchor[] = [];
for (const point of points) {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
nextAnchors.push(anchor);
}
this.points = nextAnchors;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): TraectoryGeometry | null {
if (!this.points.length) {
return null;
}
const { width, height } = this.getContainerSize();
const screenPoints: Point[] = [];
for (const anchor of this.points) {
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
screenPoints.push({
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
});
}
const left = Math.round(Math.min(...screenPoints.map((point) => point.x)));
const right = Math.round(Math.max(...screenPoints.map((point) => point.x)));
const top = Math.round(Math.min(...screenPoints.map((point) => point.y)));
const bottom = Math.round(Math.max(...screenPoints.map((point) => point.y)));
return {
points: screenPoints,
left,
right,
top,
bottom,
};
}
private getPreviewPoint(): Point | null {
if (this.mode !== 'drawing' || !this.previewAnchor) {
return null;
}
const x = getXCoordinateFromTime(this.chart, this.previewAnchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, this.previewAnchor.price);
if (x === null || y === null) {
return null;
}
const { width, height } = this.getContainerSize();
return {
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
};
}
private getPointIndexAt(point: Point): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const index = geometry.points.findIndex((item) => isNearPoint(point, item.x, item.y, POINT_HIT_TOLERANCE));
return index >= 0 ? index : null;
}
private isPointNearTraectory(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (geometry.points.length === 1) {
const firstPoint = geometry.points[0];
return isNearPoint(point, firstPoint.x, firstPoint.y, POINT_HIT_TOLERANCE);
}
for (let index = 0; index < geometry.points.length - 1; index += 1) {
const startPoint = geometry.points[index];
const endPoint = geometry.points[index + 1];
if (this.getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
return true;
}
}
return false;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatDate, formatPrice } from '@src/utils';
import { TextPaneView } from './paneView';
import { createDefaultSettings, getTextSettingsTabs, TextContentStyle, TextSettings, TextStyle } from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
type TextMode = 'idle' | 'dragging' | 'ready';
export interface TextState {
hidden: boolean;
isActive: 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;
}
interface TextParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
const UI = {
padding: 6,
};
let measureCanvas: HTMLCanvasElement | null = null;
export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDrawing {
private readonly removeSelf?: () => void;
private readonly openSettings?: () => void;
protected mode: TextMode = 'idle';
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private point: Anchor | null = null;
protected settings: TextSettings = createDefaultSettings();
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: TextGeometry | null = null;
private readonly paneView: TextPaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
constructor(chart: IChartApi, series: SeriesApi, params: TextParams) {
super({ chart, series, container: params.container });
const { formatObservable, removeSelf, openSettings } = params;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TextPaneView(this);
this.timeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'main',
});
this.priceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getSettingsTabs(): SettingsTab[] {
return getTextSettingsTabs(this.settings);
}
public getState(): TextState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
point: this.point,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TextState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
this.mode = nextState.mode ?? this.mode;
this.point = nextState.point ?? this.point;
if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisView, this.priceAxisView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews() {
return [this.timeAxisView];
}
public priceAxisViews() {
return [this.priceAxisView];
}
public getRenderData(): TextRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
showSelectionBorder: this.isActive.value,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
if (!this.containsPoint({ x, y })) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'text',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || !this.isActive.value || !this.point || typeof this.point.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.point.time, this.series);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
this.point.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || !this.isActive.value || !this.point) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.point.price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(this.point.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.setPoint(point);
return;
}
if (this.mode !== 'ready') {
return;
}
const containsPoint = this.containsPoint(point);
if (!this.isActive.value) {
if (!containsPoint) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (containsPoint) {
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId);
return;
}
this.isActive.next(false);
this.render();
};
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId || this.mode !== 'dragging') {
return;
}
event.preventDefault();
this.movePoint(this.getEventPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging') {
this.finishDragging();
}
};
private setPoint(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.point = anchor;
this.isActive.next(true);
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;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const offsetX = eventPoint.x - dragStartPoint.x;
const offsetY = eventPoint.y - dragStartPoint.y;
const nextLeft = clamp(geometry.left + offsetX, 0, Math.max(0, width - geometry.width));
const nextTop = clamp(geometry.top + offsetY, 0, Math.max(0, height - geometry.height));
const anchor = this.createAnchor({ x: nextLeft, y: nextTop });
if (!anchor) {
return;
}
this.point = anchor;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): TextGeometry | null {
if (!this.point) {
return null;
}
const { width: containerWidth, height: containerHeight } = this.getContainerSize();
const x = getXCoordinateFromTime(this.chart, this.point.time, this.series);
const y = getYCoordinateFromPrice(this.series, this.point.price);
if (x === null || y === null) {
return null;
}
const anchorPoint: Point = {
x: clamp(Math.round(Number(x)), 0, containerWidth),
y: clamp(Math.round(Number(y)), 0, containerHeight),
};
const lines = getTextLines(this.settings.text);
const font = getFont(this.settings);
const lineHeight = this.settings.fontSize;
const measured = measureTextBlock(lines, font, lineHeight);
const width = Math.min(containerWidth, measured.width + UI.padding * 2);
const height = Math.min(containerHeight, measured.height + UI.padding * 2);
const left = clamp(anchorPoint.x, 0, Math.max(0, containerWidth - width));
const top = clamp(anchorPoint.y, 0, Math.max(0, containerHeight - height));
const right = left + width;
const bottom = top + height;
return {
point: { x: left, y: top },
left,
right,
top,
bottom,
width,
height,
lines,
font,
lineHeight,
};
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, 2);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
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 { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getPointerPoint as getPointerPointFromEvent,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
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 { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
IChartApi,
IPrimitivePaneView,
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 TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams {
side: SliderSide;
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
resetTriggers?: Observable<unknown>[];
removeSelf?: () => void;
openSettings?: () => void;
}
interface SliderPositionState {
hidden: boolean;
active: 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;
showFill: boolean;
showHandles: boolean;
showLabels: boolean;
}
const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;
export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: SliderPositionSettings = createDefaultSettings();
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
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 didDrag = false;
private defaultRiskRewardRatio = 1;
private amount = 1000;
private tickSize = 1;
private ignoreNextChartClick = false;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly paneView: SliderPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly targetPriceAxisView: CustomPriceAxisView;
private readonly entryPriceAxisView: CustomPriceAxisView;
private readonly stopPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ side, container, formatObservable, resetTriggers = [], removeSelf, openSettings }: SliderPositionParams,
) {
super({ chart, series, container });
this.side = side;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) => this.handleChartClick(params);
this.paneView = new SliderPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.targetPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'target',
});
this.entryPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'entry',
});
this.stopPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'stop',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
active: this.isActive.value,
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 {
const next = state as Partial<SliderPositionState>;
this.hidden = next.hidden ?? this.hidden;
if (typeof next.active === 'boolean') {
this.isActive.next(next.active);
}
this.mode = next.mode ?? this.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;
if (typeof next.tickSize === 'number' && next.tickSize > 0) {
this.tickSize = next.tickSize;
}
if (next.targetPrice !== undefined) {
this.targetPrice = next.targetPrice;
} else if (
this.entryPrice !== null &&
this.stopPrice !== null &&
typeof next.riskRewardRatio === 'number' &&
next.riskRewardRatio > 0
) {
const risk = Math.abs(this.entryPrice - this.stopPrice);
this.targetPrice =
this.side === 'long'
? this.entryPrice + risk * next.riskRewardRatio
: this.entryPrice - risk * next.riskRewardRatio;
}
if ('settings' in next && next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getSliderPositionSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.targetPriceAxisView,
this.entryPriceAxisView,
this.stopPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
}
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,
showFill: true,
showHandles: this.shouldShowHandles(),
showLabels: this.isActive.value,
...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.isActive.value) {
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[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.profitTop,
to: geometry.profitBottom,
color: colors.axisMarkerAreaFill,
},
{
from: geometry.lossTop,
to: geometry.lossBottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (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.isActive.value) {
return;
}
const point = this.getLocalPoint(event as PointerEvent);
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handleChartClick(params: MouseEventParams<Time>): void {
if (this.ignoreNextChartClick) {
this.ignoreNextChartClick = false;
return;
}
if (this.hidden || !params.point) {
return;
}
if (this.mode === 'idle') {
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 = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? 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.isActive.next(true);
this.mode = 'ready';
this.resolveReady?.();
this.render();
return;
}
this.isActive.next(this.containsPoint({ x: params.point.x, y: params.point.y }));
this.render();
}
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getLocalPoint(event);
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.didDrag = false;
this.mode = 'dragging';
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.didDrag = true;
this.applyDrag(this.getLocalPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.ignoreNextChartClick = this.didDrag;
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.didDrag = false;
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 minDistance = this.getEditMinimumDistance();
const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;
if (low > high) {
return;
}
this.entryPrice = Math.max(low, Math.min(nextPrice, high));
}
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 = this.getPriceDelta(this.dragStartPoint.y, point.y);
const nextStartTime = this.shiftTime(snapshot.startTime, timeOffset);
const nextEndTime = this.shiftTime(snapshot.endTime, timeOffset);
if (nextStartTime === null || nextEndTime === null) {
return;
}
let nextEntryPrice = snapshot.entryPrice + priceOffset;
let nextStopPrice = snapshot.stopPrice + priceOffset;
let nextTargetPrice = snapshot.targetPrice + priceOffset;
const range = this.getPriceScaleRange();
if (range) {
const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);
if (minValue < range.min) {
const shift = range.min - minValue;
nextEntryPrice += shift;
nextStopPrice += shift;
nextTargetPrice += shift;
}
if (maxValue > range.max) {
const shift = maxValue - range.max;
nextEntryPrice -= shift;
nextStopPrice -= shift;
nextTargetPrice -= shift;
}
}
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.entryPrice = nextEntryPrice;
this.stopPrice = nextStopPrice;
this.targetPrice = nextTargetPrice;
}
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,
this.clampPriceToRange(nextPrice),
this.getEditMinimumDistance(),
);
}
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,
this.clampPriceToRange(nextPrice),
this.getEditMinimumDistance(),
);
}
private resizeEnd(snapshot: SliderPositionState, point: Point): void {
const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);
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 = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
}
private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
}
private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return minDistance;
}
const availableDistance =
this.side === 'long'
? kind === 'stop'
? Math.max(entryPrice - range.min, 0)
: Math.max(range.max - entryPrice, 0)
: kind === 'stop'
? Math.max(range.max - entryPrice, 0)
: Math.max(entryPrice - range.min, 0);
return Math.min(minDistance, availableDistance);
}
private clampPriceToRange(price: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return price;
}
return Math.max(range.min, Math.min(price, range.max));
}
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 getEditMinimumDistance(): number {
return Math.max(this.tickSize, MIN_DISTANCE);
}
private getPriceScaleRange(): { min: number; max: number } | null {
return getPriceRangeInContainer(this.series, this.container);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getCurrentRiskRewardRatio(): number {
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return this.defaultRiskRewardRatio;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
if (!risk) {
return this.defaultRiskRewardRatio;
}
return Math.abs(this.targetPrice - this.entryPrice) / 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;
}
if ('close' in item && typeof item.close === 'number') {
lastPrice = item.close;
continue;
}
if ('value' in item && typeof item.value === 'number') {
lastPrice = item.value;
}
}
return lastPrice;
}
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 geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
return 'entry';
}
if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
return 'target';
}
if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
return 'stop';
}
if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
return 'end';
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
if (isPointInBounds(point, bounds)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
return isPointInBounds(point, bounds, HIT_TOLERANCE);
}
private getLocalPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
}
import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import { getPriceFromYCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
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 { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type {
AutoscaleInfo,
Coordinate,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitiveAxisView,
Logical,
MouseEventHandler,
MouseEventParams,
SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
type RulerMode = 'idle' | 'placingEnd' | 'ready';
interface RulerState {
hidden: boolean;
mode: RulerMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface RulerParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
resetTriggers?: Observable<unknown>[];
removeSelf?: () => void;
}
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 SeriesDrawingBase implements ISeriesDrawing {
private removeSelf?: () => void;
protected settings: SettingsValues = {};
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected mode: RulerMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly moveHandler: MouseEventHandler<Time>;
private readonly paneView: RulerPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ resetTriggers = [], formatObservable, removeSelf, container }: RulerParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.clickHandler = (params) => this.handleClick(params);
this.moveHandler = (params) => this.handleMove(params);
this.paneView = new RulerPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
(resetTriggers ?? []).forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
}
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,
endAnchor: this.endAnchor,
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RulerState>;
this.hidden = nextState.hidden ?? this.hidden;
this.mode = nextState.mode ?? this.mode;
this.startAnchor = nextState.startAnchor ?? this.startAnchor;
this.endAnchor = nextState.endAnchor ?? this.endAnchor;
this.render();
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
if (this.hidden || this.mode === 'placingEnd') {
return null;
}
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startCoordinate = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endCoordinate = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
if (startCoordinate === null || endCoordinate === null) {
return null;
}
const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);
if (startLogical === null || endLogical === null) {
return null;
}
const leftLogical = Math.min(Number(startLogical), Number(endLogical));
const rightLogical = Math.max(Number(startLogical), Number(endLogical));
if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
return null;
}
return {
priceRange: {
minValue: Math.min(Number(this.startAnchor.price), Number(this.endAnchor.price)),
maxValue: Math.max(Number(this.startAnchor.price), Number(this.endAnchor.price)),
},
};
}
public getRenderData(): RulerRenderData {
const startPoint = this.startAnchor ? this.getPoint(this.startAnchor) : null;
const endPoint = this.endAnchor ? this.getPoint(this.endAnchor) : null;
const startPrice = this.startAnchor ? Number(this.startAnchor.price) : 0;
const endPrice = this.endAnchor ? Number(this.endAnchor.price) : 0;
const priceDiff = endPrice - startPrice;
const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;
const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;
const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
const volume = this.getVolumeInRange();
const isLong = priceDiff >= 0;
const horizontalArrowSide =
startPoint && endPoint && startPoint.x !== endPoint.x
? endPoint.x > startPoint.x
? Direction.Right
: Direction.Left
: null;
const verticalArrowSide =
startPoint && endPoint && startPoint.y !== endPoint.y
? endPoint.y > startPoint.y
? Direction.Bottom
: Direction.Top
: null;
const { colors } = getThemeStore();
return {
hidden: this.hidden,
startPoint,
endPoint,
lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
textColor: colors.chartPriceLineText,
infoLines: [
`${formatPrice(Math.abs(priceDiff))} (${percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`})`,
`${barsCount} ${t('bars')},`,
`${t('Vol')} ${formatVolume(volume)}`,
],
horizontalArrowSide,
verticalArrowSide,
};
}
public getTimeCoordinate(kind: 'start' | 'end'): Coordinate | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
return getXCoordinateFromTime(this.chart, anchor.time, this.series);
}
public getPriceCoordinate(kind: 'start' | 'end'): Coordinate | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
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(startX, endX),
right: Math.max(startX, 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(startY, endY),
bottom: Math.max(startY, endY),
};
}
public getTimeText(kind: 'start' | 'end'): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceText(kind: 'start' | 'end'): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(Number(anchor.price)) ?? '';
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
return null;
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'start' && kind !== 'end') {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'start' && kind !== 'end') {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => {
if (typeof item.time === 'number' && typeof time === 'number') {
return item.time === time;
}
return false;
});
}
protected bindEvents(): void {
// todo: попробовать привести к виду базового класса
if (this.isBound) {
return;
}
this.isBound = true;
this.chart.subscribeClick(this.clickHandler);
this.chart.subscribeCrosshairMove(this.moveHandler);
}
protected getGeometry(): void {
console.log('stub');
}
protected unbindEvents(): void {
// todo: попробовать привести к виду базового класса
if (!this.isBound) {
return;
}
this.isBound = false;
this.chart.unsubscribeClick(this.clickHandler);
this.chart.unsubscribeCrosshairMove(this.moveHandler);
}
private handleClick(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point) {
return;
}
if (this.mode === 'ready') {
this.removeSelf?.();
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
if (this.mode === 'idle') {
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'placingEnd';
this.hideCrosshair();
this.render();
return;
}
if (this.mode === 'placingEnd') {
this.endAnchor = anchor;
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
}
private handleMove(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point) {
return;
}
if (this.mode !== 'placingEnd') {
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);
if (price === null) {
return null;
}
return {
price,
time,
};
}
private getPoint(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,
y,
};
}
private getVolumeInRange(): number {
if (!this.startAnchor || !this.endAnchor) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startAnchor.time);
const endIndex = this.findIndexByTime(this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
}
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
normalizeBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RectanglePaneView } from './paneView';
import {
createDefaultSettings,
getRectangleSettingsTabs,
RectangleSettings,
RectangleStyle,
RectangleTextStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface RectangleParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface RectangleState {
hidden: boolean;
isActive: boolean;
mode: RectangleMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: RectangleSettings;
}
interface RectangleGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
handles: Record<RectangleHandleKey, Point>;
}
export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
showFill: boolean;
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
protected mode: RectangleMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: RectangleHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RectangleState | null = null;
private dragGeometrySnapshot: RectangleGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: RectanglePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: RectangleParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new RectanglePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RectangleState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RectangleState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startTime' in nextState) {
this.startTime = nextState.startTime ?? null;
}
if ('endTime' in nextState) {
this.endTime = nextState.endTime ?? null;
}
if ('startPrice' in nextState) {
this.startPrice = nextState.startPrice ?? null;
}
if ('endPrice' in nextState) {
this.endPrice = nextState.endPrice ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showFill: true,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: 'rectangle-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.containsPoint(point) && !this.getHandleTarget(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const clampedPoint = this.clampPointToContainer(point);
const anchor = this.createAnchor(clampedPoint);
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.isActive.next(false);
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return handleTarget;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + priceOffset;
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const clampedPoint = this.clampPointToContainer(point);
let { left } = geometry;
let { right } = geometry;
let { top } = geometry;
let { bottom } = geometry;
switch (this.activeDragTarget) {
case 'nw':
left = clampedPoint.x;
top = clampedPoint.y;
break;
case 'n':
top = clampedPoint.y;
break;
case 'ne':
right = clampedPoint.x;
top = clampedPoint.y;
break;
case 'e':
right = clampedPoint.x;
break;
case 'se':
right = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 's':
bottom = clampedPoint.y;
break;
case 'sw':
left = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 'w':
left = clampedPoint.x;
break;
default:
return;
}
this.setRectangleBounds(left, right, top, bottom);
}
private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);
if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
return false;
}
this.startTime = startTime;
this.endTime = endTime;
this.startPrice = startPrice;
this.endPrice = endPrice;
return true;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): RectangleGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const left = Math.round(Math.min(Number(startX), Number(endX)));
const right = Math.round(Math.max(Number(startX), Number(endX)));
const top = Math.round(Math.min(Number(startY), Number(endY)));
const bottom = Math.round(Math.max(Number(startY), Number(endY)));
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
handles: {
nw: { x: left, y: top },
n: { x: centerX, y: top },
ne: { x: right, y: top },
e: { x: right, y: centerY },
se: { x: right, y: bottom },
s: { x: centerX, y: bottom },
sw: { x: left, y: bottom },
w: { x: left, y: centerY },
},
};
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.bottom,
};
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = this.getTimeValueForLabel(kind);
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return kind === 'left' ? this.startTime : this.endTime;
}
const startIsLeft = Number(startX) <= Number(endX);
if (kind === 'left') {
return startIsLeft ? this.startTime : this.endTime;
}
return startIsLeft ? this.endTime : this.startTime;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
}
const startIsTop = Number(startY) <= Number(endY);
if (kind === 'top') {
return startIsTop ? this.startPrice : this.endPrice;
}
return startIsTop ? this.endPrice : this.startPrice;
}
private getHandleTarget(point: Point): RectangleHandleKey | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
for (const handleName of handleOrder) {
const handle = geometry.handles[handleName];
if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
return handleName;
}
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
switch (handle) {
case 'nw':
case 'se':
return 'nwse-resize';
case 'ne':
case 'sw':
return 'nesw-resize';
case 'n':
case 's':
return 'ns-resize';
case 'e':
case 'w':
return 'ew-resize';
case 'body':
return 'grab';
default:
return 'default';
}
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type RayMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
type TimeLabelKind = 'start' | 'direction';
type PriceLabelKind = 'start' | 'direction';
interface RayParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface RayState {
hidden: boolean;
isActive: boolean;
mode: RayMode;
startAnchor: Anchor | null;
directionAnchor: Anchor | null;
settings: RaySettings;
}
interface RayGeometry {
startPoint: Point;
directionPoint: Point;
rayEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {
showHandles: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RaySettings = createDefaultSettings();
protected mode: RayMode = 'idle';
private startAnchor: Anchor | null = null;
private directionAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RayState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: RayPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly directionTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly directionPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: RayParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new RayPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.directionTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'direction',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.directionPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'direction',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RayState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
startAnchor: this.startAnchor,
directionAnchor: this.directionAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RayState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('directionAnchor' in nextState) {
this.directionAnchor = nextState.directionAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRaySettingTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.directionTimeAxisView,
this.startPriceAxisView,
this.directionPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.directionTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.directionPriceAxisView];
}
public getRenderData(): RayRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (this.getPointTarget(point)) {
return {
cursorStyle: 'move',
externalId: 'ray',
zOrder: 'top',
};
}
if (!this.isPointNearRay(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'ray',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: Math.min(geometry.startPoint.x, geometry.directionPoint.x),
to: Math.max(geometry.startPoint.x, geometry.directionPoint.x),
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: Math.min(geometry.startPoint.y, geometry.directionPoint.y),
to: Math.max(geometry.startPoint.y, geometry.directionPoint.y),
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'direction')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'direction')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.getPointTarget(point) && !this.isPointNearRay(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
if (!this.isActive.value) {
if (!pointTarget && !this.isPointNearRay(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'direction') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-direction', point, event.pointerId);
return;
}
if (this.isPointNearRay(point)) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.isActive.next(false);
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-direction') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-direction' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.directionAnchor = anchor;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.directionAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.directionPoint.x - geometry.startPoint.x,
geometry.directionPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: RayMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-direction') {
this.directionAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.directionAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const nextDirectionTime = shiftTimeByPixels(this.chart, snapshot.directionAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextDirectionTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.directionAnchor = {
time: nextDirectionTime,
price: snapshot.directionAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): RayGeometry | null {
if (!this.startAnchor || !this.directionAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const directionX = getXCoordinateFromTime(this.chart, this.directionAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const directionY = getYCoordinateFromPrice(this.series, this.directionAnchor.price);
if (startX === null || directionX === null || startY === null || directionY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const directionPoint = {
x: Math.round(Number(directionX)),
y: Math.round(Number(directionY)),
};
const rayEndPoint = this.getRayEndPoint(startPoint, directionPoint);
if (!rayEndPoint) {
return null;
}
return {
startPoint,
directionPoint,
rayEndPoint,
left: Math.min(startPoint.x, rayEndPoint.x),
right: Math.max(startPoint.x, rayEndPoint.x),
top: Math.min(startPoint.y, rayEndPoint.y),
bottom: Math.max(startPoint.y, rayEndPoint.y),
};
}
private getRayEndPoint(startPoint: Point, directionPoint: Point): Point | null {
const dx = directionPoint.x - startPoint.x;
const dy = directionPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return null;
}
const { width, height } = this.container.getBoundingClientRect();
const candidates: Point[] = [];
if (dx !== 0) {
const leftT = (0 - startPoint.x) / dx;
const rightT = (width - startPoint.x) / dx;
const leftY = startPoint.y + leftT * dy;
const rightY = startPoint.y + rightT * dy;
if (leftT >= 1 && leftY >= 0 && leftY <= height) {
candidates.push({ x: 0, y: leftY });
}
if (rightT >= 1 && rightY >= 0 && rightY <= height) {
candidates.push({ x: width, y: rightY });
}
}
if (dy !== 0) {
const topT = (0 - startPoint.y) / dy;
const bottomT = (height - startPoint.y) / dy;
const topX = startPoint.x + topT * dx;
const bottomX = startPoint.x + bottomT * dx;
if (topT >= 1 && topX >= 0 && topX <= width) {
candidates.push({ x: topX, y: 0 });
}
if (bottomT >= 1 && bottomX >= 0 && bottomX <= width) {
candidates.push({ x: bottomX, y: height });
}
}
return candidates[0] ?? directionPoint;
}
private getPointTarget(point: Point): 'start' | 'direction' | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, POINT_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.directionPoint.x, geometry.directionPoint.y, POINT_HIT_TOLERANCE)) {
return 'direction';
}
return null;
}
private isPointNearRay(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return this.getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { FibonacciRetracementPaneView } from './paneView';
import {
cloneFibonacciRetracementSettings,
createDefaultSettings,
FibonacciRetracementSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementHandle, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';
interface FibonacciRetracementParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface FibonacciRetracementState {
hidden: boolean;
isActive: boolean;
mode: FibonacciRetracementMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: FibonacciRetracementSettings;
}
export interface FibonacciRetracementLevelRenderData {
id: string;
value: number;
price: number;
y: number;
color: string;
text: string;
}
export interface FibonacciRetracementAreaRenderData {
top: number;
bottom: number;
color: string;
}
interface FibonacciRetracementGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
levels: FibonacciRetracementLevelRenderData[];
areas: FibonacciRetracementAreaRenderData[];
handles: Record<FibonacciRetracementHandleKey, Point>;
}
type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
showHandles: boolean;
backgroundOpacity: number;
}
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracementSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: FibonacciRetracementSettings = createDefaultSettings();
protected mode: FibonacciRetracementMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: FibonacciRetracementHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: FibonacciRetracementState | null = null;
private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: FibonacciRetracementPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: FibonacciRetracementParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new FibonacciRetracementPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): FibonacciRetracementState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
public setState(state: unknown): void {
const next = state as Partial<FibonacciRetracementState>;
this.hidden = next.hidden ?? this.hidden;
if (next.isActive !== undefined) {
this.isActive.next(next.isActive);
}
this.mode = next.mode ?? this.mode;
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.startPrice = next.startPrice ?? this.startPrice;
this.endPrice = next.endPrice ?? this.endPrice;
if (next.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), next.settings);
}
this.render();
}
public getSettings(): SettingsValues {
return getFibonacciRetracementSettingsValues(this.settings);
}
public getSettingsTabs(): SettingsTab[] {
return getFibonacciRetracementSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = mergeFibonacciRetracementSettings(this.settings, settings);
this.render();
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): FibonacciRetracementRenderData | null {
const geometry = this.hidden ? null : this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.shouldShowHandles(),
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 getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isActive.value && !this.containsPoint(point)) {
return null;
}
const handleTarget = this.isActive.value ? this.getHandleTarget(point) : null;
if (handleTarget) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: this.isActive.value ? 'grab' : 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.isActive.value ? this.getTimeBounds() : null;
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.isActive.value ? this.getPriceBounds() : null;
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
const coordinate = this.isActive.value ? this.getTimeCoordinate(kind as TimeLabelKind) : null;
const text = this.getTimeText(kind as TimeLabelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
const coordinate = this.isActive.value ? this.getPriceCoordinate(kind as PriceLabelKind) : null;
const text = this.getPriceText(kind as PriceLabelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
const point = this.getMousePoint(event);
if (this.hidden || this.mode !== 'ready' || (!this.containsPoint(point) && !this.getHandleTarget(point))) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isActive.value) {
this.isActive.next(this.containsPoint(point));
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
} else {
this.resize(point);
}
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (
!geometry ||
geometry.width < MIN_DISTANCE ||
Math.abs(geometry.startPoint.y - geometry.endPoint.y) < MIN_DISTANCE
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<FibonacciRetracementHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.isActive.next(false);
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
return this.getHandleTarget(point) ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + priceOffset;
}
private resize(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startTime = anchor.time;
this.startPrice = anchor.price;
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): FibonacciRetracementGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
const left = Math.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const levels = this.getLevels();
const top = Math.min(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
const bottom = Math.max(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
levels,
areas: this.getAreas(levels),
handles: {
start: startPoint,
end: endPoint,
},
};
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
if (this.startPrice === null || this.endPrice === null) {
return [];
}
return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((result, level) => {
const price = this.getLevelPrice(level.value);
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return result;
}
result.push({
id: level.id,
value: level.value,
price,
y: Math.round(Number(y)),
color: level.color,
text: this.getLevelText(level.value, price),
});
return result;
}, []);
}
private getLevelPrice(value: number): number {
const startPrice = this.startPrice ?? 0;
const endPrice = this.endPrice ?? 0;
return this.settings.reverse
? startPrice + (endPrice - startPrice) * value
: endPrice + (startPrice - endPrice) * value;
}
private getAreas(levels: FibonacciRetracementLevelRenderData[]): FibonacciRetracementAreaRenderData[] {
if (!this.settings.showBackground || levels.length < 2) {
return [];
}
const orderedLevels = [...levels].sort((a, b) => a.value - b.value);
return orderedLevels.slice(0, -1).map((level, index) => {
const nextLevel = orderedLevels[index + 1];
return {
top: Math.min(level.y, nextLevel.y),
bottom: Math.max(level.y, nextLevel.y),
color: nextLevel.color,
};
});
}
private getLevelText(value: number, price: number): string {
const parts: string[] = [];
if (this.settings.showLevelValues) {
parts.push(formatLevelLabel(value));
}
if (this.settings.showPrices) {
parts.push(`(${formatPrice(price) ?? String(price)})`);
}
return parts.join(' ');
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
return geometry ? { left: geometry.left, right: geometry.right } : null;
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
return geometry ? { top: geometry.top, bottom: geometry.bottom } : null;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = kind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
return price === null ? '' : (formatPrice(price) ?? '');
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const targetY = kind === 'top' ? geometry.top : geometry.bottom;
const edgeLevel = geometry.levels.find((level) => level.y === targetY);
if (edgeLevel) {
return edgeLevel.price;
}
return kind === 'top'
? Math.max(this.startPrice ?? 0, this.endPrice ?? 0)
: Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
}
private getHandleTarget(point: Point): FibonacciRetracementHandleKey | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.handles.start.x, geometry.handles.start.y, HANDLE_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.handles.end.x, geometry.handles.end.y, HANDLE_HIT_TOLERANCE)) {
return 'end';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const bounds: Bounds = {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
};
if (this.settings.showBackground && isPointInBounds(point, bounds, BODY_HIT_TOLERANCE)) {
return true;
}
const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;
return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
private getMousePoint(event: MouseEvent): Point {
const rect = this.container.getBoundingClientRect();
return this.clampPointToContainer({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
}
}
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
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 { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams {
container: HTMLElement;
rangeMode: DiapsonRangeMode;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
isActive: boolean;
mode: DiapsonMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
showHandles: boolean;
labelLines: string[];
}
interface DateMetrics {
barsCount: number;
elapsedText: string;
volumeText: string;
}
interface PriceMetrics {
delta: number;
percent: number;
steps: number;
}
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: DiapsonSettings = createDefaultSettings();
protected mode: DiapsonMode = 'idle';
private rangeMode: DiapsonRangeMode;
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: DiapsonHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: DiapsonGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly stepSize: number;
private readonly stepLabel: string;
private readonly paneView: DiapsonPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor(chart: IChartApi, series: SeriesApi, params: DiapsonParams) {
super({ chart, series, container: params.container });
const { rangeMode, formatObservable, removeSelf, openSettings, stepSize = 1, stepLabel = '' } = params;
this.rangeMode = rangeMode;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.stepSize = stepSize > 0 ? stepSize : 1;
this.stepLabel = stepLabel;
this.paneView = new DiapsonPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public setRangeMode(nextMode: DiapsonRangeMode): void {
if (this.rangeMode === nextMode) {
return;
}
this.rangeMode = nextMode;
this.resetToIdle();
}
public getState(): DiapsonState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
rangeMode: this.rangeMode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<DiapsonState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
this.mode = nextState.mode ?? this.mode;
this.rangeMode = nextState.rangeMode ?? this.rangeMode;
this.startTime = 'startTime' in nextState ? (nextState.startTime ?? null) : this.startTime;
this.endTime = 'endTime' in nextState ? (nextState.endTime ?? null) : this.endTime;
this.startPrice = 'startPrice' in nextState ? (nextState.startPrice ?? null) : this.startPrice;
this.endPrice = 'endPrice' in nextState ? (nextState.endPrice ?? null) : this.endPrice;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): DiapsonRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
rangeMode: this.rangeMode,
showFill: true,
showHandles: this.shouldShowHandles(),
labelLines: this.getLabelLines(),
...this.settings,
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getHandleTarget(point) && !this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
this.resetToIdle();
return;
}
if (geometry.width < MIN_RECTANGLE_WIDTH || geometry.height < MIN_RECTANGLE_HEIGHT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.isActive.next(false);
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return handleTarget;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private moveWhole(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextStartPoint = this.clampPointToContainer({
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
});
const nextEndPoint = this.clampPointToContainer({
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
});
this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const nextPoint = this.clampPointToContainer(point);
if (this.activeDragTarget === 'start') {
this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
return;
}
this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return;
}
this.startTime = startAnchor.time;
this.startPrice = startAnchor.price;
this.endTime = endAnchor.time;
this.endPrice = endAnchor.price;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): DiapsonGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const top = Math.round(Math.min(startPoint.y, endPoint.y));
const bottom = Math.round(Math.max(startPoint.y, endPoint.y));
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
startPoint,
endPoint,
};
}
private getLabelLines(): string[] {
if (this.rangeMode === 'date') {
const metrics = this.getDateMetrics();
if (!metrics) {
return [];
}
const firstLine = metrics.elapsedText
? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
: `${metrics.barsCount} ${t('bars')}`;
if (!metrics.volumeText) {
return [firstLine];
}
return [firstLine, `${t('Vol')} ${metrics.volumeText}`];
}
const metrics = this.getPriceMetrics();
if (!metrics) {
return [];
}
const percentText =
metrics.percent < 0 ? `-${formatPercent(Math.abs(metrics.percent))}` : formatPercent(Math.abs(metrics.percent));
const absSteps = Math.abs(metrics.steps);
let stepsText = '';
if (Number.isInteger(absSteps)) {
stepsText = absSteps.toString();
} else if (absSteps >= 1000) {
stepsText = absSteps.toFixed(0);
} else if (absSteps >= 100) {
stepsText = absSteps.toFixed(1);
} else {
stepsText = absSteps.toFixed(2);
}
if (metrics.steps < 0) {
stepsText = `-${stepsText}`;
}
const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';
return [`${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`];
}
private getDateMetrics(): DateMetrics | null {
const leftTime = this.getLeftTimeValue();
const rightTime = this.getRightTimeValue();
if (!leftTime || !rightTime) {
return null;
}
const barsCount = this.getBarsCount();
const durationSeconds = Math.max(0, Math.floor(Math.abs(Number(rightTime) - Number(leftTime))));
const days = Math.floor(durationSeconds / 86400);
const hours = Math.floor((durationSeconds % 86400) / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
const elapsedParts: string[] = [];
if (days > 0) {
elapsedParts.push(`${days}d`);
}
if (hours > 0) {
elapsedParts.push(`${hours}h`);
}
if (minutes > 0) {
elapsedParts.push(`${minutes}m`);
}
if (elapsedParts.length === 0) {
elapsedParts.push(`${seconds}s`);
}
const volume = this.getVolumeInRange();
return {
barsCount,
elapsedText: elapsedParts.slice(0, 2).join(' '),
volumeText: volume > 0 ? formatVolume(volume) : '',
};
}
private getPriceMetrics(): PriceMetrics | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const delta = this.endPrice - this.startPrice;
return {
delta,
percent: this.startPrice !== 0 ? (delta / Math.abs(this.startPrice)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
return Math.abs(endIndex - startIndex);
}
private getVolumeInRange(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => Number(item.time) === Number(time));
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.bottom,
};
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
if (!time) {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getLeftTimeValue(): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return this.startTime;
}
return Number(startX) <= Number(endX) ? this.startTime : this.endTime;
}
private getRightTimeValue(): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return this.endTime;
}
return Number(startX) <= Number(endX) ? this.endTime : this.startTime;
}
private getTopPriceValue(): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return Math.max(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.startPrice : this.endPrice;
}
private getBottomPriceValue(): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return Math.min(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
}
private getHandleTarget(point: Point): Exclude<DiapsonHandle, 'body' | null> | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'end';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<DiapsonHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
if (handle === 'body') {
return 'move';
}
const geometry = this.getGeometry();
if (!geometry) {
return 'default';
}
const sameDirection =
(geometry.endPoint.x - geometry.startPoint.x >= 0 && geometry.endPoint.y - geometry.startPoint.y >= 0) ||
(geometry.endPoint.x - geometry.startPoint.x < 0 && geometry.endPoint.y - geometry.startPoint.y < 0);
return sameDirection ? 'nwse-resize' : 'nesw-resize';
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} from '@core/Drawings/helpers';
import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { AxisLinePaneView } from './paneView';
import {
AxisLineSettings,
AxisLineStyle,
AxisLineTextStyle,
createDefaultSettings,
getAxisLineSettingsTabs,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
export type AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
interface AxisLineParams {
direction: AxisLineDirection;
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface AxisLineState {
hidden: boolean;
isActive: boolean;
mode: AxisLineMode;
time: Time | null;
price: number | null;
settings: AxisLineSettings;
}
export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
direction: AxisLineDirection;
coordinate: number;
handle: Point;
showHandle: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: AxisLineSettings = createDefaultSettings();
protected mode: AxisLineMode = 'idle';
private direction: AxisLineDirection;
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: AxisLinePaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ direction, container, formatObservable, removeSelf, openSettings }: AxisLineParams,
) {
super({ chart, series, container });
this.direction = direction;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new AxisLinePaneView(this);
this.timeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'main',
});
this.priceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): AxisLineState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
time: this.time,
price: this.price,
settings: { ...this.settings },
};
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public setState(state: unknown): void {
const nextState = state as Partial<AxisLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('time' in nextState) {
this.time = nextState.time ?? null;
}
if ('price' in nextState) {
this.price = nextState.price ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getAxisLineSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisView, this.priceAxisView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisViews() {
return this.direction === 'vertical' ? [this.timeAxisView] : [];
}
public priceAxisViews() {
return this.direction === 'horizontal' ? [this.priceAxisView] : [];
}
public getRenderData(): AxisLineRenderData | null {
if (this.hidden) {
return null;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return null;
}
const { width, height } = this.container.getBoundingClientRect();
return {
direction: this.direction,
coordinate,
handle: this.direction === 'vertical' ? { x: coordinate, y: height / 2 } : { x: width / 2, y: coordinate },
showHandle: this.shouldShowHandles(),
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
const point = { x, y };
const data = this.getRenderData();
if (!data) {
return null;
}
if (this.isActive.value && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE)) {
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point, data.coordinate)) {
return null;
}
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'vertical' || !this.isActive.value || this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
if (coordinate === null || typeof this.time !== 'number') {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
this.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'horizontal' || !this.isActive.value || this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(this.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getGeometry(): void {
console.log('stub');
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
const isNearHandle = this.isActive.value && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!isNearHandle && !isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.updateLine(point);
this.isActive.next(true);
this.mode = 'ready';
this.resolveReady?.();
this.render();
return;
}
if (this.mode !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const isNearHandle = this.isActive.value && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!this.isActive.value) {
if (!isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (!isNearHandle && !isNearLine) {
this.isActive.next(false);
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
this.hideCrosshair();
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.updateLine(this.getEventPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.showCrosshair();
this.render();
};
private updateLine(point: Point): void {
if (this.direction === 'vertical') {
this.time = getTimeFromXCoordinate(this.chart, point.x);
return;
}
this.price = getPriceFromYCoordinate(this.series, point.y);
}
private getCoordinate(): number | null {
if (this.direction === 'vertical') {
if (this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
if (this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
return coordinate === null ? null : Number(coordinate);
}
private isPointNearLine(point: Point, coordinate: number): boolean {
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 { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { FibonacciRetracement } from '@src/core/Drawings/fibonacciRetracement';
import { Ray } from '@src/core/Drawings/ray';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Text } from '@src/core/Drawings/text';
import { Traectory } from '@src/core/Drawings/traectory';
import { TrendLine } from '@src/core/Drawings/trendLine';
import { VolumeProfile } from '@src/core/Drawings/volumeProfile';
import { t } from '@src/translations';
import { DrawingConfig } from '@src/types';
export enum DrawingsNames {
'trendLine' = 'trendLine',
'ray' = 'ray',
'horizontalLine' = 'horizontalLine',
'horizontalRay' = 'horizontalRay',
'verticalLine' = 'verticalLine',
'ruler' = 'ruler',
'fibonacciRetracement' = 'fibonacciRetracement',
'sliderLong' = 'sliderLong',
'sliderShort' = 'sliderShort',
'diapsonDates' = 'diapsonDates',
'diapsonPrices' = 'diapsonPrices',
'fixedRangeProfile' = 'fixedRangeProfile',
'visibleRangeProfile' = 'visibleRangeProfile',
'rectangle' = 'rectangle',
'traectory' = 'traectory',
'text' = 'text',
}
export const drawingLabelById = (): Record<DrawingsNames, string> => ({
[DrawingsNames.trendLine]: t('Trend line'),
[DrawingsNames.ray]: t('Ray'),
[DrawingsNames.horizontalLine]: t('Horizontal line'),
[DrawingsNames.horizontalRay]: t('Horizontal ray'),
[DrawingsNames.verticalLine]: t('Vertical line'),
[DrawingsNames.fibonacciRetracement]: t('Fibonacci retracement'),
[DrawingsNames.ruler]: t('Ruler'),
[DrawingsNames.sliderLong]: t('Long position'),
[DrawingsNames.sliderShort]: t('Short position'),
[DrawingsNames.diapsonDates]: t('Dates range'),
[DrawingsNames.diapsonPrices]: t('Prices range'),
[DrawingsNames.fixedRangeProfile]: t('Fixed range volume profile'),
[DrawingsNames.visibleRangeProfile]: t('Anchored volume profile'),
[DrawingsNames.rectangle]: t('Rectangle'),
[DrawingsNames.traectory]: t('Traectory'),
[DrawingsNames.text]: t('Text'),
});
export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
[DrawingsNames.trendLine]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new TrendLine(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.ray]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new Ray(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.horizontalLine]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new AxisLine(chart, series, {
direction: 'horizontal',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.horizontalRay]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new TrendLine(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.verticalLine]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new AxisLine(chart, series, {
direction: 'vertical',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.sliderLong]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new SliderPosition(chart, series, {
side: 'long',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.fibonacciRetracement]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new FibonacciRetracement(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.sliderShort]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new SliderPosition(chart, series, {
side: 'short',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.diapsonDates]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new Diapson(chart, series, {
rangeMode: 'date',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.diapsonPrices]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new Diapson(chart, series, {
rangeMode: 'price',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.fixedRangeProfile]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new VolumeProfile(chart, series, {
profileKind: 'fixedRange',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.visibleRangeProfile]: {
singleInstance: true,
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new VolumeProfile(chart, series, {
profileKind: 'visibleRange',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.rectangle]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new Rectangle(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.ruler]: {
singleInstance: true,
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new Ruler(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
removeSelf,
});
},
},
[DrawingsNames.traectory]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new Traectory(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
removeSelf,
openSettings,
});
},
},
[DrawingsNames.text]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new Text(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
removeSelf,
openSettings,
});
},
},
};