Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
MouseEventParams,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import {
getAnchorFromPoint,
getPointerPoint as getPointerPointFromEvent,
getRawPointerPoint,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import { Defaults } from '@src/types/defaults';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi, UpdatableView } from '@core/Drawings/types';
export interface DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
isSelected(): boolean;
isLocked(): boolean;
select(): void;
deselect(): void;
}
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
waitTillReady(): Promise<void>;
isCreationPending(): boolean;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
getSettingsTabs(): SettingsTab[];
updateSettings(settings: SettingsValues): void;
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
isHit(event: MouseEvent): boolean;
pointerDown(event: PointerEvent): void;
click(event: MouseEvent): void;
doubleClick(event: MouseEvent): void;
contextMenu(event: MouseEvent): void;
getRenderData(): unknown;
}
export type StartPoint = unknown;
interface DrawingBaseParams {
chart: IChartApi;
series: SeriesApi;
container: HTMLElement;
interaction: DrawingInteraction;
removeSelf?: () => void;
openSettings?: () => void;
}
export interface BaseDrawingParams extends DrawingBaseParams {
formatObservable?: Observable<ChartOptionsModel>;
initialEvent?: MouseEventParams;
}
export interface AxisBoundsGeometry {
left: number;
right: number;
top: number;
bottom: number;
}
export abstract class DrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
TMode extends string = string,
TGeometry = unknown,
> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: TMode;
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected readonly removeSelf?: () => void;
protected readonly openSettings?: () => void;
protected displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
private paneViewsList: readonly (IPrimitivePaneView & UpdatableView)[] = [];
private timeAxisPaneViewsList: readonly CustomTimeAxisPaneView[] = [];
private priceAxisPaneViewsList: readonly CustomPriceAxisPaneView[] = [];
private timeAxisViewsList: readonly CustomTimeAxisView[] = [];
private priceAxisViewsList: readonly CustomPriceAxisView[] = [];
private isInteractionBound = false;
private isBound = false;
private readonly onPointerMove = (event: PointerEvent): void => {
this.handlePointerMove(event);
};
private readonly onPointerUp = (event: PointerEvent): void => {
this.handlePointerUp(event);
};
constructor({ chart, series, container, interaction, removeSelf, openSettings }: DrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
if (this.hidden || !this.shouldShowHandles()) {
return [];
}
return this.getDrawingHandles();
});
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = { ...this.settings, ...settings };
this.settingsSubject.next(this.getSettings());
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.series.attachPrimitive(this.handlesPrimitive);
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
this.series.detachPrimitive(this.handlesPrimitive);
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return { ...hoveredItem, cursorStyle: 'pointer' };
}
public isHit(event: MouseEvent): boolean {
if (!this.isEventInside(event)) {
return false;
}
const point = this.getEventPoint(event as PointerEvent);
return this.getHoveredItem(point.x, point.y) !== null;
}
public pointerDown(event: PointerEvent): void {
if (!this.isEventInside(event)) {
return;
}
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
this.handleLockedPointerDown(event);
}
public click(event: MouseEvent): void {
if (this.isEventInside(event)) {
this.handleClick(event);
}
}
public doubleClick(event: MouseEvent): void {
if (this.isEventInside(event)) {
this.handleDoubleClick(event);
}
}
public contextMenu(event: MouseEvent): void {
if (this.isEventInside(event)) {
this.handleContextMenu(event);
}
}
public abstract getRenderData(): unknown;
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public updateAllViews(): void {
updateViews([
...this.paneViewsList,
...this.timeAxisPaneViewsList,
...this.priceAxisPaneViewsList,
...this.timeAxisViewsList,
...this.priceAxisViewsList,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return this.paneViewsList;
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.timeAxisPaneViewsList;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewsList;
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return this.timeAxisViewsList;
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return this.priceAxisViewsList;
}
protected isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
protected shouldShowAxisElements(): boolean {
return this.isSelected() || this.isCreationPending();
}
protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
return [];
}
protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
return this.handlesPrimitive.findHandle(point);
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({ crosshair: { mode: CrosshairMode.Hidden } });
}
protected showCrosshair(): void {
this.chart.applyOptions({ crosshair: { mode: CrosshairMode.Normal } });
}
protected getEventPoint(event: PointerEvent | TouchMouseEventData): Point {
return getPointerPointFromEvent(this.container, event);
}
protected createDrawingAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getPointFromAnchor(anchor: Anchor): Point | null {
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
return { x: Math.round(Number(x)), y: Math.round(Number(y)) };
}
protected createDefaultAxisLabel(coordinate: number | null, text: string): AxisLabel | null {
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected consumeEvent(event: Event): void {
event.preventDefault();
event.stopPropagation();
}
protected handleClick(_event: MouseEvent): void {}
protected handleContextMenu(_event: MouseEvent): void {}
protected handleDoubleClick(_event: MouseEvent): void {}
protected handlePointerMove(_event: PointerEvent): void {}
protected handlePointerUp(_event: PointerEvent): void {}
protected handlePointerDown(_event: PointerEvent | TouchMouseEventData): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): TGeometry | null;
protected getTimeAxisSegments(): AxisSegment[] {
const geometry = this.getAxisBoundsGeometry();
return this.createAxisSegments(geometry?.left ?? null, geometry?.right ?? null);
}
protected getPriceAxisSegments(): AxisSegment[] {
const geometry = this.getAxisBoundsGeometry();
return this.createAxisSegments(geometry?.top ?? null, geometry?.bottom ?? null);
}
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
protected initializeDrawingViews(
paneView: IPrimitivePaneView & UpdatableView,
timeAxisLabelKinds: readonly string[],
priceAxisLabelKinds: readonly string[],
): void {
this.paneViewsList = [paneView];
this.timeAxisPaneViewsList = [
new CustomTimeAxisPaneView({ getAxisSegments: () => this.getTimeAxisSegments() }),
];
this.priceAxisPaneViewsList = [
new CustomPriceAxisPaneView({ getAxisSegments: () => this.getPriceAxisSegments() }),
];
this.timeAxisViewsList = timeAxisLabelKinds.map(
(labelKind) => new CustomTimeAxisView({ getAxisLabel: (kind) => this.getTimeAxisLabel(kind), labelKind }),
);
this.priceAxisViewsList = priceAxisLabelKinds.map(
(labelKind) => new CustomPriceAxisView({ getAxisLabel: (kind) => this.getPriceAxisLabel(kind), labelKind }),
);
}
protected initializeDrawing(
formatObservable?: Observable<ChartOptionsModel>,
initialEvent?: MouseEventParams,
startDrawing?: (point: Point) => void,
): void {
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this as ISeriesPrimitive<Time>);
if (initialEvent?.sourceEvent && startDrawing) {
startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
protected createAxisSegments(from: number | null, to: number | null): AxisSegment[] {
if (!this.shouldShowAxisElements() || from === null || to === null) {
return [];
}
return [{ from, to, color: getThemeStore().colors.axisMarkerAreaFill }];
}
private handleLockedPointerDown(event: PointerEvent): void {
const point = this.getEventPoint(event);
if (this.getHoveredItem(point.x, point.y)) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
}
private bindInteraction(): void {
if (this.isInteractionBound) {
return;
}
this.isInteractionBound = true;
this.subscriptions.add(this.interaction.selected$.subscribe(() => this.render()));
this.subscriptions.add(
this.interaction.locked$.subscribe((isLocked) => {
if (isLocked) {
this.showCrosshair();
}
this.render();
}),
);
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
window.addEventListener('pointermove', this.onPointerMove);
window.addEventListener('pointerup', this.onPointerUp);
window.addEventListener('pointercancel', this.onPointerUp);
}
private unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
window.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointercancel', this.onPointerUp);
}
private getAxisBoundsGeometry(): AxisBoundsGeometry | null {
const geometry = this.getGeometry() as Partial<AxisBoundsGeometry> | null;
if (!geometry) {
return null;
}
const { left, right, top, bottom } = geometry;
if (![left, right, top, bottom].every((value) => typeof value === 'number')) {
return null;
}
return { left: left!, right: right!, top: top!, bottom: bottom! };
}
private isEventInside(event: MouseEvent): boolean {
if (event.target instanceof Node && this.container.contains(event.target)) {
return true;
}
return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
}
}
import { clamp } from 'lodash-es';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
clampPointToContainer,
getContainerSize,
getPriceDelta,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import type { AxisBoundsGeometry, BaseDrawingParams } from '@core/Drawings/DrawingBase';
import type { Anchor, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
import type { MouseEventParams } from 'lightweight-charts';
export type TwoPointDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging';
export interface TwoPointGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
interface AnchorSnapshot {
startAnchor: Anchor;
endAnchor: Anchor;
}
export abstract class TwoPointDrawingBase<
TSettings extends SettingsValues,
THandleId extends string,
TDragTarget extends string,
TGeometry extends AxisBoundsGeometry,
> extends DrawingBase<TSettings, THandleId, TwoPointDrawingMode, TGeometry> {
protected mode: TwoPointDrawingMode = 'idle';
protected startAnchor: Anchor | null = null;
protected endAnchor: Anchor | null = null;
protected activeDragTarget: TDragTarget | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragAnchorSnapshot: AnchorSnapshot | null = null;
private dragGeometrySnapshot: TGeometry | null = null;
private crosshairHiddenByDrag = false;
constructor(params: BaseDrawingParams) {
super(params);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
protected initializeTwoPointDrawing(formatObservable?: BaseDrawingParams['formatObservable'], initialEvent?: MouseEventParams): void {
this.initializeDrawing(formatObservable, initialEvent, (point) => this.startDrawing(point));
}
protected getTwoPointGeometry(): TwoPointGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startPoint = this.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
if (!startPoint || !endPoint) {
return null;
}
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
protected getDragStartPoint(): Point | null {
return this.dragStartPoint;
}
protected getDragGeometrySnapshot(): TGeometry | null {
return this.dragGeometrySnapshot;
}
protected getDragAnchorSnapshot(): AnchorSnapshot | null {
return this.dragAnchorSnapshot;
}
protected setAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
}
protected setAnchorFromPoint(kind: 'start' | 'end', point: Point): boolean {
const anchor = this.createDrawingAnchor(this.normalizeAnchorPoint(point));
if (!anchor) {
return false;
}
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
return true;
}
protected moveWholeFromSnapshot(point: Point, constrainToContainer = true): void {
const snapshot = this.dragAnchorSnapshot;
const dragStartPoint = this.dragStartPoint;
if (!snapshot || !dragStartPoint) {
return;
}
const offset = this.getDragOffset(point, constrainToContainer);
const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offset.x, this.series);
const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offset.x, this.series);
if (startTime === null || endTime === null) {
return;
}
const priceOffset = getPriceDelta(this.series, dragStartPoint.y, dragStartPoint.y + offset.y);
this.startAnchor = { time: startTime, price: snapshot.startAnchor.price + priceOffset };
this.endAnchor = { time: endTime, price: snapshot.endAnchor.price + priceOffset };
}
protected resetToIdle(): void {
this.hidden = false;
this.beforeResetToIdle();
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.clearDragState();
this.render();
}
protected normalizeRestoredMode(mode: unknown): TwoPointDrawingMode | null {
if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
return mode;
}
return mode === 'dragging' ? 'ready' : null;
}
protected normalizeAnchorPoint(point: Point): Point {
return point;
}
protected shouldHideCrosshairWhileDragging(): boolean {
return false;
}
protected beforeResetToIdle(): void {}
protected handleDoubleClick(event: MouseEvent): void {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.isDrawingHit(point)) {
return;
}
this.consumeEvent(event);
this.openSettings?.();
}
protected handlePointerDown(event: PointerEvent): void {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.handleCreationPointerDown(event, point)) {
return;
}
this.handleReadyPointerDown(event, point);
}
protected handlePointerMove(event: PointerEvent): void {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (!this.isActiveDragEvent(event)) {
return;
}
event.preventDefault();
this.applyDrag(point, this.activeDragTarget!);
this.render();
}
protected handlePointerUp(event: PointerEvent): void {
if (!this.isActiveDragEvent(event)) {
return;
}
this.finishDragging();
}
protected abstract isDrawingHit(point: Point): boolean;
protected abstract getDragTarget(point: Point): TDragTarget | null;
protected abstract applyDrag(point: Point, dragTarget: TDragTarget): void;
protected abstract isValidGeometry(geometry: TGeometry): boolean;
private handleCreationPointerDown(event: PointerEvent, point: Point): boolean {
if (this.mode === 'idle') {
this.consumeEvent(event);
this.startDrawing(point);
return true;
}
if (this.mode !== 'drawing') {
return false;
}
this.consumeEvent(event);
this.updateDrawing(point);
this.finishDrawing();
return true;
}
private handleReadyPointerDown(event: PointerEvent, point: Point): void {
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
this.selectIfHit(event, point);
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
this.consumeEvent(event);
this.startDragging(point, event.pointerId, dragTarget);
}
private selectIfHit(event: PointerEvent, point: Point): void {
if (!this.isDrawingHit(point)) {
return;
}
this.consumeEvent(event);
this.select();
}
private startDrawing(point: Point): void {
const anchor = this.createDrawingAnchor(this.normalizeAnchorPoint(point));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
if (this.setAnchorFromPoint('end', point)) {
this.render();
}
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || !this.isValidGeometry(geometry)) {
this.handleInvalidDrawing();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private handleInvalidDrawing(): void {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
}
private startDragging(point: Point, pointerId: number, dragTarget: TDragTarget): void {
if (!this.startAnchor || !this.endAnchor) {
return;
}
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragAnchorSnapshot = {
startAnchor: { ...this.startAnchor },
endAnchor: { ...this.endAnchor },
};
this.dragGeometrySnapshot = this.getGeometry();
this.crosshairHiddenByDrag = this.shouldHideCrosshairWhileDragging();
if (this.crosshairHiddenByDrag) {
this.hideCrosshair();
}
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearDragState();
if (this.crosshairHiddenByDrag) {
this.showCrosshair();
}
this.crosshairHiddenByDrag = false;
this.render();
}
private clearDragState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragAnchorSnapshot = null;
this.dragGeometrySnapshot = null;
}
private isActiveDragEvent(event: PointerEvent): boolean {
return this.mode === 'dragging' && this.dragPointerId === event.pointerId && this.activeDragTarget !== null;
}
private getDragOffset(point: Point, constrainToContainer: boolean): Point {
const dragStartPoint = this.dragStartPoint;
if (!dragStartPoint) {
return { x: 0, y: 0 };
}
const rawOffset = { x: point.x - dragStartPoint.x, y: point.y - dragStartPoint.y };
if (!constrainToContainer) {
return rawOffset;
}
return this.clampDragOffset(rawOffset);
}
private clampDragOffset(offset: Point): Point {
const geometry = this.dragGeometrySnapshot;
if (!geometry) {
return offset;
}
const { width, height } = getContainerSize(this.container);
return {
x: clamp(offset.x, -geometry.left, width - geometry.right),
y: clamp(offset.y, -geometry.top, height - geometry.bottom),
};
}
}
export function clampTwoPointDrawingPoint(point: Point, container: HTMLElement): Point {
return clampPointToContainer(point, container);
}
export { DrawingBase as SeriesDrawingBase } from './DrawingBase';
export type { BaseDrawingParams, DrawingInteraction, ISeriesDrawing, StartPoint } from './DrawingBase';
import { normalizeBounds, isPointInBounds } from '@core/Drawings/helpers';
import {
clampTwoPointDrawingPoint,
TwoPointDrawingBase,
TwoPointDrawingMode,
TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RectanglePaneView } from './paneView';
import {
createDefaultSettings,
getRectangleSettingsTabs,
RectangleSettings,
RectangleStyle,
RectangleTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
type RectangleHandleKey = Exclude<RectangleHandle, 'body'>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
type RectangleParams = BaseDrawingParams;
interface RectangleState {
hidden: boolean;
mode: TwoPointDrawingMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: RectangleSettings;
}
interface RectangleGeometry extends TwoPointGeometry {
width: number;
height: number;
}
export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
const EXTERNAL_ID = 'rectangle-position';
export class Rectangle
extends TwoPointDrawingBase<RectangleSettings, RectangleHandleKey, RectangleHandle, RectangleGeometry>
implements ISeriesDrawing {
protected settings: RectangleSettings = createDefaultSettings();
constructor(params: RectangleParams) {
super(params);
this.initializeDrawingViews(new RectanglePaneView(this), ['left', 'right'], ['top', 'bottom']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): RectangleState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startAnchor?.time ?? null,
endTime: this.endAnchor?.time ?? null,
startPrice: this.startAnchor?.price ?? null,
endPrice: this.endAnchor?.price ?? null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RectangleState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = this.normalizeRestoredMode(nextState.mode);
if (mode) {
this.mode = mode;
}
this.restoreAnchor('start', nextState);
this.restoreAnchor('end', nextState);
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
return geometry ? { ...geometry, ...this.settings } : null;
}
protected getDrawingHandles(): readonly DrawingHandle<RectangleHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { left, right, top, bottom } = geometry;
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
return [
{ id: 'w', x: left, y: centerY, shape: 'rounded' },
{ id: 'sw', x: left, y: bottom, shape: 'circle' },
{ id: 's', x: centerX, y: bottom, shape: 'rounded' },
{ id: 'se', x: right, y: bottom, shape: 'circle' },
{ id: 'e', x: right, y: centerY, shape: 'rounded' },
{ id: 'ne', x: right, y: top, shape: 'circle' },
{ id: 'n', x: centerX, y: top, shape: 'rounded' },
{ id: 'nw', x: left, y: top, shape: 'circle' },
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
return this.containsPoint(point) ? this.createHoveredItem('pointer') : null;
}
const handle = this.getDrawingHandleAtPoint(point)?.id;
if (handle) {
return this.createHoveredItem(this.getCursorStyle(handle));
}
return this.containsPoint(point) ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
}
protected getGeometry(): RectangleGeometry | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
width: geometry.right - geometry.left,
height: geometry.bottom - geometry.top,
};
}
protected normalizeAnchorPoint(point: Point): Point {
return clampTwoPointDrawingPoint(point, this.container);
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
}
protected getDragTarget(point: Point): RectangleHandle | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: RectangleHandle): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.resizeRectangle(point, dragTarget);
}
protected isValidGeometry(geometry: RectangleGeometry): boolean {
return geometry.width >= MIN_RECTANGLE_SIZE && geometry.height >= MIN_RECTANGLE_SIZE;
}
private restoreAnchor(kind: 'start' | 'end', state: Partial<RectangleState>): void {
const current = kind === 'start' ? this.startAnchor : this.endAnchor;
const timeKey = kind === 'start' ? 'startTime' : 'endTime';
const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
const time = timeKey in state ? state[timeKey] ?? null : current?.time ?? null;
const price = priceKey in state ? state[priceKey] ?? null : current?.price ?? null;
const anchor = time === null || price === null ? null : { time, price };
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
}
private resizeRectangle(point: Point, dragTarget: RectangleHandleKey): void {
const geometry = this.getDragGeometrySnapshot();
if (!geometry) {
return;
}
const clampedPoint = clampTwoPointDrawingPoint(point, this.container);
const bounds = getResizedBounds(geometry, clampedPoint, dragTarget);
this.setRectangleBounds(bounds.left, bounds.right, bounds.top, bounds.bottom);
}
private setRectangleBounds(left: number, right: number, top: number, bottom: number): void {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
const startAnchor = this.createDrawingAnchor({ x: bounds.left, y: bounds.top });
const endAnchor = this.createDrawingAnchor({ x: bounds.right, y: bounds.bottom });
if (startAnchor && endAnchor) {
this.setAnchors(startAnchor, endAnchor);
}
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = this.getTimeValueForLabel(kind);
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
return price === null ? '' : (formatPrice(price) ?? '');
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return kind === 'left' ? this.startAnchor.time : this.endAnchor.time;
}
const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
if (kind === 'left') {
return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
}
return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return kind === 'top'
? Math.max(this.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
if (kind === 'top') {
return startIsTop ? this.startAnchor.price : this.endAnchor.price;
}
return startIsTop ? this.endAnchor.price : this.startAnchor.price;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry ? isPointInBounds(point, geometry, BODY_HIT_TOLERANCE) : false;
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
private getCursorStyle(handle: RectangleHandle): PrimitiveHoveredItem['cursorStyle'] {
if (handle === 'nw' || handle === 'se') {
return 'nwse-resize';
}
if (handle === 'ne' || handle === 'sw') {
return 'nesw-resize';
}
if (handle === 'n' || handle === 's') {
return 'ns-resize';
}
if (handle === 'e' || handle === 'w') {
return 'ew-resize';
}
return handle === 'body' ? 'grab' : 'default';
}
}
function getResizedBounds(
geometry: RectangleGeometry,
point: Point,
handle: RectangleHandleKey,
): Pick<RectangleGeometry, 'left' | 'right' | 'top' | 'bottom'> {
let { left, right, top, bottom } = geometry;
if (handle.includes('w')) {
left = point.x;
}
if (handle.includes('e')) {
right = point.x;
}
if (handle.includes('n')) {
top = point.y;
}
if (handle.includes('s')) {
bottom = point.y;
}
return { left, right, top, bottom };
}
function isTimeLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'left' || kind === 'right';
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'top' || kind === 'bottom';
}
import { isPointInBounds } from '@core/Drawings/helpers';
import {
clampTwoPointDrawingPoint,
TwoPointDrawingBase,
TwoPointDrawingMode,
TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { t } from '@src/translations';
import { formatPercent, formatPrice, formatSignedNumber, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { DiapsonPaneView } from './paneView';
import {
createDefaultSettings,
DiapsonSettings,
DiapsonStyle,
DiapsonTextStyle,
getDiapsonSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonDragTarget = 'body' | 'start' | 'end';
type DiapsonHandleKey = Exclude<DiapsonDragTarget, 'body'>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams extends BaseDrawingParams {
rangeMode: DiapsonRangeMode;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
mode: TwoPointDrawingMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry extends TwoPointGeometry {
width: number;
height: number;
}
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
labelLines: string[];
}
interface DateMetrics {
barsCount: number;
elapsedText: string;
volumeText: string;
}
interface PriceMetrics {
delta: number;
percent: number;
steps: number;
}
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
export class Diapson
extends TwoPointDrawingBase<DiapsonSettings, DiapsonHandleKey, DiapsonDragTarget, DiapsonGeometry>
implements ISeriesDrawing {
protected settings: DiapsonSettings = createDefaultSettings();
private rangeMode: DiapsonRangeMode;
private readonly stepSize: number;
private readonly stepLabel: string;
constructor(params: DiapsonParams) {
super(params);
this.rangeMode = params.rangeMode;
this.stepSize = params.stepSize && params.stepSize > 0 ? params.stepSize : 1;
this.stepLabel = params.stepLabel ?? '';
this.initializeDrawingViews(new DiapsonPaneView(this), ['left', 'right'], ['top', 'bottom']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public setRangeMode(nextMode: DiapsonRangeMode): void {
if (this.rangeMode === nextMode) {
return;
}
this.rangeMode = nextMode;
this.resetToIdle();
}
public getState(): DiapsonState {
return {
hidden: this.hidden,
mode: this.mode,
rangeMode: this.rangeMode,
startTime: this.startAnchor?.time ?? null,
endTime: this.endAnchor?.time ?? null,
startPrice: this.startAnchor?.price ?? null,
endPrice: this.endAnchor?.price ?? null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<DiapsonState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = this.normalizeRestoredMode(nextState.mode);
if (mode) {
this.mode = mode;
}
if (nextState.rangeMode) {
this.rangeMode = nextState.rangeMode;
}
this.restoreAnchor('start', nextState);
this.restoreAnchor('end', nextState);
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(this.settings);
}
public getRenderData(): DiapsonRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
rangeMode: this.rangeMode,
showFill: true,
labelLines: this.getLabelLines(),
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{ id: 'end', ...geometry.endPoint, shape: 'circle', size: 12, borderWidth: 2 },
{ id: 'start', ...geometry.startPoint, shape: 'circle', size: 12, borderWidth: 2 },
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
const externalId = `diapson-${this.rangeMode}`;
if (!this.isSelected()) {
return this.containsPoint(point) ? { cursorStyle: 'move', externalId, zOrder: 'top' } : null;
}
const handle = this.getDrawingHandleAtPoint(point)?.id;
if (handle) {
return { cursorStyle: this.getCursorStyle(handle), externalId, zOrder: 'top' };
}
return this.containsPoint(point) ? { cursorStyle: 'move', externalId, zOrder: 'top' } : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
}
protected getGeometry(): DiapsonGeometry | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
const startPoint = clampTwoPointDrawingPoint(geometry.startPoint, this.container);
const endPoint = clampTwoPointDrawingPoint(geometry.endPoint, this.container);
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 };
}
protected normalizeAnchorPoint(point: Point): Point {
return clampTwoPointDrawingPoint(point, this.container);
}
protected beforeResetToIdle(): void {
this.deselect();
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
}
protected getDragTarget(point: Point): DiapsonDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: DiapsonDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.setAnchorFromPoint(dragTarget, point);
}
protected isValidGeometry(geometry: DiapsonGeometry): boolean {
return geometry.width >= MIN_RECTANGLE_WIDTH && geometry.height >= MIN_RECTANGLE_HEIGHT;
}
private restoreAnchor(kind: 'start' | 'end', state: Partial<DiapsonState>): void {
const current = kind === 'start' ? this.startAnchor : this.endAnchor;
const timeKey = kind === 'start' ? 'startTime' : 'endTime';
const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
const time = timeKey in state ? state[timeKey] ?? null : current?.time ?? null;
const price = priceKey in state ? state[priceKey] ?? null : current?.price ?? null;
const anchor = time === null || price === null ? null : { time, price };
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
}
private getLabelLines(): string[] {
return this.rangeMode === 'date' ? this.getDateLabelLines() : this.getPriceLabelLines();
}
private getDateLabelLines(): string[] {
const metrics = this.getDateMetrics();
if (!metrics) {
return [];
}
const firstLine = metrics.elapsedText
? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
: `${metrics.barsCount} ${t('bars')}`;
return metrics.volumeText ? [firstLine, `${t('Vol')} ${metrics.volumeText}`] : [firstLine];
}
private getPriceLabelLines(): string[] {
const metrics = this.getPriceMetrics();
if (!metrics) {
return [];
}
const percentText = metrics.percent < 0
? `-${formatPercent(Math.abs(metrics.percent))}`
: formatPercent(Math.abs(metrics.percent));
const stepsText = formatSteps(metrics.steps);
const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';
return [`${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`];
}
private getDateMetrics(): DateMetrics | null {
const leftTime = this.getLeftTimeValue();
const rightTime = this.getRightTimeValue();
if (leftTime === null || rightTime === null) {
return null;
}
const durationSeconds = Math.floor(Math.abs(Number(rightTime) - Number(leftTime)));
const volume = this.getVolumeInRange();
return {
barsCount: this.getBarsCount(),
elapsedText: formatDuration(durationSeconds),
volumeText: volume > 0 ? formatVolume(volume) : '',
};
}
private getPriceMetrics(): PriceMetrics | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const delta = this.endAnchor.price - this.startAnchor.price;
return {
delta,
percent: this.startAnchor.price !== 0 ? (delta / Math.abs(this.startAnchor.price)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
const range = this.getDataRange();
return range ? Math.abs(range.to - range.from) : 0;
}
private getVolumeInRange(): number {
const range = this.getDataRange();
if (!range) {
return 0;
}
const data = this.series.data() ?? [];
let volume = 0;
for (let index = range.from; index <= range.to; index += 1) {
volume += getItemVolume(data[index] as unknown as Record<string, unknown> | undefined);
}
return volume;
}
private getDataRange(): { from: number; to: number } | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const data = this.series.data() ?? [];
if (!data.length) {
return null;
}
const startIndex = findNearestIndexByTime(data, this.startAnchor.time);
const endIndex = findNearestIndexByTime(data, this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return null;
}
return { from: Math.min(startIndex, endIndex), to: Math.max(startIndex, endIndex) };
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
return price === null ? '' : (formatPrice(price) ?? '');
}
private getLeftTimeValue(): Time | null {
return this.getTimeValueBySide('left');
}
private getRightTimeValue(): Time | null {
return this.getTimeValueBySide('right');
}
private getTimeValueBySide(side: TimeLabelKind): Time | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return side === 'left' ? this.startAnchor.time : this.endAnchor.time;
}
const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
if (side === 'left') {
return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
}
return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
}
private getTopPriceValue(): number | null {
return this.getPriceValueBySide('top');
}
private getBottomPriceValue(): number | null {
return this.getPriceValueBySide('bottom');
}
private getPriceValueBySide(side: PriceLabelKind): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return side === 'top'
? Math.max(this.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
if (side === 'top') {
return startIsTop ? this.startAnchor.price : this.endAnchor.price;
}
return startIsTop ? this.endAnchor.price : this.startAnchor.price;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry ? isPointInBounds(point, geometry, BODY_HIT_TOLERANCE) : false;
}
private getCursorStyle(handle: DiapsonHandleKey): PrimitiveHoveredItem['cursorStyle'] {
const geometry = this.getGeometry();
if (!geometry) {
return 'default';
}
const dx = geometry.endPoint.x - geometry.startPoint.x;
const dy = geometry.endPoint.y - geometry.startPoint.y;
return dx * dy >= 0 ? 'nwse-resize' : 'nesw-resize';
}
}
function formatSteps(steps: number): string {
const absoluteSteps = Math.abs(steps);
let text: string;
if (Number.isInteger(absoluteSteps)) {
text = absoluteSteps.toString();
} else if (absoluteSteps >= 1000) {
text = absoluteSteps.toFixed(0);
} else if (absoluteSteps >= 100) {
text = absoluteSteps.toFixed(1);
} else {
text = absoluteSteps.toFixed(2);
}
return steps < 0 ? `-${text}` : text;
}
function formatDuration(durationSeconds: number): string {
const days = Math.floor(durationSeconds / 86400);
const hours = Math.floor((durationSeconds % 86400) / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(`${days}d`);
}
if (hours > 0) {
parts.push(`${hours}h`);
}
if (minutes > 0) {
parts.push(`${minutes}m`);
}
return (parts.length ? parts : [`${seconds}s`]).slice(0, 2).join(' ');
}
function getItemVolume(item: Record<string, unknown> | undefined): number {
if (!item) {
return 0;
}
if (typeof item.volume === 'number') {
return item.volume;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
return customValues && typeof customValues.volume === 'number' ? customValues.volume : 0;
}
function findNearestIndexByTime(data: readonly { time: Time }[], time: Time): number {
const target = Number(time);
if (!Number.isFinite(target)) {
return -1;
}
let nearestIndex = -1;
let nearestDistance = Number.POSITIVE_INFINITY;
data.forEach((item, index) => {
const itemTime = Number(item.time);
const distance = Math.abs(itemTime - target);
if (Number.isFinite(itemTime) && distance < nearestDistance) {
nearestIndex = index;
nearestDistance = distance;
}
});
return nearestIndex;
}
function isTimeLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'left' || kind === 'right';
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'top' || kind === 'bottom';
}
import { getYCoordinateFromPrice, isPointInBounds } from '@core/Drawings/helpers';
import {
clampTwoPointDrawingPoint,
TwoPointDrawingBase,
TwoPointDrawingMode,
TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { FibonacciRetracementPaneView } from './paneView';
import {
cloneFibonacciRetracementSettings,
createDefaultSettings,
FibonacciRetracementSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab, SettingsValues } from '@src/types';
import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type FibonacciRetracementDragTarget = 'body' | 'start' | 'end';
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;
interface FibonacciRetracementState {
hidden: boolean;
mode: TwoPointDrawingMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: FibonacciRetracementSettings;
}
export interface FibonacciRetracementLevelRenderData {
id: string;
value: number;
price: number;
y: number;
color: string;
text: string;
}
export interface FibonacciRetracementAreaRenderData {
top: number;
bottom: number;
color: string;
}
interface FibonacciRetracementGeometry extends TwoPointGeometry {
width: number;
height: number;
levels: FibonacciRetracementLevelRenderData[];
areas: FibonacciRetracementAreaRenderData[];
}
type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
backgroundOpacity: number;
}
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
const EXTERNAL_ID = 'fibonacci-retracement-position';
export class FibonacciRetracement
extends TwoPointDrawingBase<
FibonacciRetracementSettings,
FibonacciRetracementHandleKey,
FibonacciRetracementDragTarget,
FibonacciRetracementGeometry
>
implements ISeriesDrawing {
protected settings: FibonacciRetracementSettings = createDefaultSettings();
constructor(params: FibonacciRetracementParams) {
super(params);
this.initializeDrawingViews(new FibonacciRetracementPaneView(this), ['start', 'end'], ['top', 'bottom']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): FibonacciRetracementState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startAnchor?.time ?? null,
endTime: this.endAnchor?.time ?? null,
startPrice: this.startAnchor?.price ?? null,
endPrice: this.endAnchor?.price ?? null,
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<FibonacciRetracementState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = this.normalizeRestoredMode(nextState.mode);
if (mode) {
this.mode = mode;
}
this.restoreAnchor('start', nextState);
this.restoreAnchor('end', nextState);
if (nextState.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), nextState.settings);
}
this.render();
}
public getSettings(): SettingsValues {
return getFibonacciRetracementSettingsValues(this.settings);
}
public getSettingsTabs(): SettingsTab[] {
return getFibonacciRetracementSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = mergeFibonacciRetracementSettings(this.settings, settings);
this.render();
}
public getRenderData(): FibonacciRetracementRenderData | null {
const geometry = this.hidden ? null : this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showBackground: this.settings.showBackground,
backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
reverse: this.settings.reverse,
labelsPosition: this.settings.labelsPosition,
showPrices: this.settings.showPrices,
showLevelValues: this.settings.showLevelValues,
fontSize: this.settings.fontSize,
isBold: this.settings.isBold,
isItalic: this.settings.isItalic,
};
}
protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{ id: 'end', ...geometry.endPoint },
{ id: 'start', ...geometry.startPoint },
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
const containsPoint = this.containsPoint(point);
if (!this.isSelected()) {
return containsPoint ? this.createHoveredItem('pointer') : null;
}
if (this.getDrawingHandleAtPoint(point)) {
return this.createHoveredItem('pointer');
}
return containsPoint ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isTimeLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getTimeCoordinate(kind), this.getTimeText(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isPriceLabelKind(kind)) {
return null;
}
return this.createDefaultAxisLabel(this.getPriceCoordinate(kind), this.getPriceText(kind));
}
protected getGeometry(): FibonacciRetracementGeometry | null {
const baseGeometry = this.getTwoPointGeometry();
if (!baseGeometry) {
return null;
}
const levels = this.getLevels();
const top = Math.min(baseGeometry.top, ...levels.map((level) => level.y));
const bottom = Math.max(baseGeometry.bottom, ...levels.map((level) => level.y));
return {
...baseGeometry,
top,
bottom,
width: baseGeometry.right - baseGeometry.left,
height: bottom - top,
levels,
areas: this.getAreas(levels),
};
}
protected normalizeAnchorPoint(point: Point): Point {
return clampTwoPointDrawingPoint(point, this.container);
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.containsPoint(point);
}
protected getDragTarget(point: Point): FibonacciRetracementDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: FibonacciRetracementDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point);
return;
}
this.setAnchorFromPoint(dragTarget, point);
}
protected isValidGeometry(geometry: FibonacciRetracementGeometry): boolean {
const priceDistance = Math.abs(geometry.startPoint.y - geometry.endPoint.y);
return geometry.width >= MIN_DISTANCE && priceDistance >= MIN_DISTANCE;
}
private restoreAnchor(kind: 'start' | 'end', state: Partial<FibonacciRetracementState>): void {
const current = kind === 'start' ? this.startAnchor : this.endAnchor;
const timeKey = kind === 'start' ? 'startTime' : 'endTime';
const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
const time = timeKey in state ? state[timeKey] ?? null : current?.time ?? null;
const price = priceKey in state ? state[priceKey] ?? null : current?.price ?? null;
const anchor = time === null || price === null ? null : { time, price };
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
if (!this.startAnchor || !this.endAnchor) {
return [];
}
return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((levels, level) => {
const price = this.getLevelPrice(level.value);
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return levels;
}
levels.push({
id: level.id,
value: level.value,
price,
y: Math.round(Number(y)),
color: level.color,
text: this.getLevelText(level.value, price),
});
return levels;
}, []);
}
private getLevelPrice(value: number): number {
const startPrice = this.startAnchor?.price ?? 0;
const endPrice = this.endAnchor?.price ?? 0;
return this.settings.reverse
? startPrice + (endPrice - startPrice) * value
: endPrice + (startPrice - endPrice) * value;
}
private getAreas(levels: FibonacciRetracementLevelRenderData[]): FibonacciRetracementAreaRenderData[] {
if (!this.settings.showBackground || levels.length < 2) {
return [];
}
const orderedLevels = [...levels].sort((first, second) => first.value - second.value);
return orderedLevels.slice(0, -1).map((level, index) => {
const nextLevel = orderedLevels[index + 1];
return {
top: Math.min(level.y, nextLevel.y),
bottom: Math.max(level.y, nextLevel.y),
color: nextLevel.color,
};
});
}
private getLevelText(value: number, price: number): string {
const parts: string[] = [];
if (this.settings.showLevelValues) {
parts.push(formatLevelLabel(value));
}
if (this.settings.showPrices) {
parts.push(`(${formatPrice(price) ?? String(price)})`);
}
return parts.join(' ');
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = kind === 'start' ? this.startAnchor?.time : this.endAnchor?.time;
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 || !this.startAnchor || !this.endAnchor) {
return null;
}
const targetY = kind === 'top' ? geometry.top : geometry.bottom;
const edgeLevel = geometry.levels.find((level) => level.y === targetY);
if (edgeLevel) {
return edgeLevel.price;
}
return kind === 'top'
? Math.max(this.startAnchor.price, this.endAnchor.price)
: Math.min(this.startAnchor.price, this.endAnchor.price);
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (this.isInsideBackground(point, geometry)) {
return true;
}
const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;
return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
}
private isInsideBackground(point: Point, geometry: FibonacciRetracementGeometry): boolean {
if (!this.settings.showBackground) {
return false;
}
const bounds: Bounds = {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
};
return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
}
function isTimeLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'start' || kind === 'end';
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
return kind === 'top' || kind === 'bottom';
}
import { getDistanceToSegment } from '@core/Drawings/utils';
import {
TwoPointDrawingBase,
TwoPointDrawingMode,
TwoPointGeometry,
} from '@core/Drawings/TwoPointDrawingBase';
import { LineMarker, type SettingsTab } from '@src/types';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { LineDrawingPaneView } from './paneView';
import {
createDefaultSettings,
getLineDrawingSettingsTabs,
LineDrawingMarkers,
LineDrawingSettings,
LineDrawingStyle,
LineDrawingTextStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
type LineDrawingDragTarget = 'body' | 'start' | 'end';
type LineDrawingHandleKey = Exclude<LineDrawingDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface LineDrawingParams extends BaseDrawingParams {
defaultMarkers?: Partial<LineDrawingMarkers>;
}
interface LineDrawingState {
hidden: boolean;
mode: TwoPointDrawingMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: LineDrawingSettings;
}
interface LineDrawingGeometry extends TwoPointGeometry {}
export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const EXTERNAL_ID = 'line-drawing';
export class LineDrawing
extends TwoPointDrawingBase<LineDrawingSettings, LineDrawingHandleKey, LineDrawingDragTarget, LineDrawingGeometry>
implements ISeriesDrawing {
protected settings: LineDrawingSettings;
private readonly defaultMarkers: LineDrawingMarkers;
constructor(params: LineDrawingParams) {
super(params);
this.defaultMarkers = {
startMarker: LineMarker.normal,
endMarker: LineMarker.normal,
...params.defaultMarkers,
};
this.settings = createDefaultSettings(this.defaultMarkers);
this.initializeDrawingViews(new LineDrawingPaneView(this), ['start', 'end'], ['start', 'end']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): LineDrawingState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
endAnchor: this.endAnchor ? { ...this.endAnchor } : null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<LineDrawingState> & { mode?: unknown };
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = normalizeLineDrawingMode(nextState.mode);
if (mode) {
this.mode = mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ? { ...nextState.endAnchor } : null;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(this.defaultMarkers), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getLineDrawingSettingsTabs(this.settings);
}
public getRenderData(): LineDrawingRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
return geometry ? { ...geometry, ...this.settings } : null;
}
protected getDrawingHandles(): readonly DrawingHandle<LineDrawingHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{ id: 'start', ...geometry.startPoint, shape: 'circle', borderWidth: 2 },
{ id: 'end', ...geometry.endPoint, shape: 'circle', borderWidth: 2 },
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
if (this.getDrawingHandleAtPoint(point)) {
return this.createHoveredItem('move');
}
return this.isPointNearLine(point) ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const point = this.getPointFromAnchor(anchor);
const text = formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
return this.createDefaultAxisLabel(point?.x ?? null, text);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isLineLabelKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const point = this.getPointFromAnchor(anchor);
return this.createDefaultAxisLabel(point?.y ?? null, formatPrice(anchor.price) ?? '');
}
protected getGeometry(): LineDrawingGeometry | null {
return this.getTwoPointGeometry();
}
protected shouldHideCrosshairWhileDragging(): boolean {
return true;
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.isPointNearLine(point);
}
protected getDragTarget(point: Point): LineDrawingDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.isPointNearLine(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: LineDrawingDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point, false);
return;
}
this.setAnchorFromPoint(dragTarget, point);
}
protected isValidGeometry(geometry: LineDrawingGeometry): boolean {
return Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
) >= MIN_LINE_SIZE;
}
private getAnchor(kind: TimeLabelKind | PriceLabelKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
return geometry
? getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE
: false;
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
}
function normalizeLineDrawingMode(mode: unknown): TwoPointDrawingMode | null {
if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
return mode;
}
if (mode === 'dragging' || mode === 'dragging-start' || mode === 'dragging-end' || mode === 'dragging-body') {
return 'ready';
}
return null;
}
function isLineLabelKind(kind: string): kind is TimeLabelKind {
return kind === 'start' || kind === 'end';
}
import { getDistanceToSegment } from '@core/Drawings/utils';
import { TwoPointDrawingBase, TwoPointDrawingMode } from '@core/Drawings/TwoPointDrawingBase';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
import type { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
type RayDragTarget = 'body' | 'start' | 'direction';
type RayHandleKey = Exclude<RayDragTarget, 'body'>;
type RayLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;
interface RayState {
hidden: boolean;
mode: TwoPointDrawingMode;
startAnchor: Anchor | null;
directionAnchor: Anchor | null;
settings: RaySettings;
}
interface RayGeometry {
startPoint: Point;
directionPoint: Point;
rayEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const EXTERNAL_ID = 'ray';
export class Ray
extends TwoPointDrawingBase<RaySettings, RayHandleKey, RayDragTarget, RayGeometry>
implements ISeriesDrawing {
protected settings: RaySettings = createDefaultSettings();
constructor(params: RayParams) {
super(params);
this.initializeDrawingViews(new RayPaneView(this), ['start', 'direction'], ['start', 'direction']);
this.initializeTwoPointDrawing(params.formatObservable, params.initialEvent);
}
public getState(): RayState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
directionAnchor: this.endAnchor ? { ...this.endAnchor } : null,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RayState> & { mode?: unknown };
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
const mode = normalizeRayMode(nextState.mode);
if (mode) {
this.mode = mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ? { ...nextState.startAnchor } : null;
}
if ('directionAnchor' in nextState) {
this.endAnchor = nextState.directionAnchor ? { ...nextState.directionAnchor } : null;
}
if (nextState.settings) {
this.settings = { ...createDefaultSettings(), ...nextState.settings };
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRaySettingTabs(this.settings);
}
public getRenderData(): RayRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
return geometry ? { ...geometry, ...this.settings } : null;
}
protected getDrawingHandles(): readonly DrawingHandle<RayHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
borderWidth: 2,
strokeColor: this.settings.lineColor,
},
{
id: 'direction',
...geometry.directionPoint,
shape: 'circle',
borderWidth: 2,
strokeColor: this.settings.lineColor,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.isCreationPending()) {
return null;
}
const point = { x, y };
if (this.getDrawingHandleAtPoint(point)) {
return this.createHoveredItem('move');
}
return this.isPointNearRay(point) ? this.createHoveredItem('grab') : null;
}
protected getTimeAxisSegments(): AxisSegment[] {
const points = this.getDirectionPoints();
return points ? this.createAxisSegments(points.start.x, points.direction.x) : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
const points = this.getDirectionPoints();
return points ? this.createAxisSegments(points.start.y, points.direction.y) : [];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const point = this.getPointFromAnchor(anchor);
const text = formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
return this.createDefaultAxisLabel(point?.x ?? null, text);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowAxisElements() || !isRayLabelKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const point = this.getPointFromAnchor(anchor);
return this.createDefaultAxisLabel(point?.y ?? null, formatPrice(anchor.price) ?? '');
}
protected getGeometry(): RayGeometry | null {
const points = this.getDirectionPoints();
if (!points) {
return null;
}
const rayEndPoint = this.getRayEndPoint(points.start, points.direction);
if (!rayEndPoint) {
return null;
}
return {
startPoint: points.start,
directionPoint: points.direction,
rayEndPoint,
left: Math.min(points.start.x, rayEndPoint.x),
right: Math.max(points.start.x, rayEndPoint.x),
top: Math.min(points.start.y, rayEndPoint.y),
bottom: Math.max(points.start.y, rayEndPoint.y),
};
}
protected shouldHideCrosshairWhileDragging(): boolean {
return true;
}
protected isDrawingHit(point: Point): boolean {
return Boolean(this.getDrawingHandleAtPoint(point)) || this.isPointNearRay(point);
}
protected getDragTarget(point: Point): RayDragTarget | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.isPointNearRay(point) ? 'body' : null);
}
protected applyDrag(point: Point, dragTarget: RayDragTarget): void {
if (dragTarget === 'body') {
this.moveWholeFromSnapshot(point, false);
return;
}
this.setAnchorFromPoint(dragTarget === 'start' ? 'start' : 'end', point);
}
protected isValidGeometry(geometry: RayGeometry): boolean {
return Math.hypot(
geometry.directionPoint.x - geometry.startPoint.x,
geometry.directionPoint.y - geometry.startPoint.y,
) >= MIN_LINE_SIZE;
}
private getAnchor(kind: RayLabelKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
private getDirectionPoints(): { start: Point; direction: Point } | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const start = this.getPointFromAnchor(this.startAnchor);
const direction = this.getPointFromAnchor(this.endAnchor);
return start && direction ? { start, direction } : null;
}
private getRayEndPoint(startPoint: Point, directionPoint: Point): Point | null {
const dx = directionPoint.x - startPoint.x;
const dy = directionPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return null;
}
const { width, height } = this.container.getBoundingClientRect();
const candidates = [
getVerticalIntersection(startPoint, dx, dy, 0, width, height),
getVerticalIntersection(startPoint, dx, dy, width, width, height),
getHorizontalIntersection(startPoint, dx, dy, 0, width, height),
getHorizontalIntersection(startPoint, dx, dy, height, width, height),
].filter((candidate): candidate is Point => candidate !== null);
return candidates[0] ?? directionPoint;
}
private isPointNearRay(point: Point): boolean {
const geometry = this.getGeometry();
return geometry
? getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE
: false;
}
private createHoveredItem(cursorStyle: PrimitiveHoveredItem['cursorStyle']): PrimitiveHoveredItem {
return { cursorStyle, externalId: EXTERNAL_ID, zOrder: 'top' };
}
}
function getVerticalIntersection(
start: Point,
dx: number,
dy: number,
x: number,
width: number,
height: number,
): Point | null {
if (dx === 0) {
return null;
}
const factor = (x - start.x) / dx;
const y = start.y + factor * dy;
return factor >= 1 && x >= 0 && x <= width && y >= 0 && y <= height ? { x, y } : null;
}
function getHorizontalIntersection(
start: Point,
dx: number,
dy: number,
y: number,
width: number,
height: number,
): Point | null {
if (dy === 0) {
return null;
}
const factor = (y - start.y) / dy;
const x = start.x + factor * dx;
return factor >= 1 && x >= 0 && x <= width && y >= 0 && y <= height ? { x, y } : null;
}
function normalizeRayMode(mode: unknown): TwoPointDrawingMode | null {
if (mode === 'idle' || mode === 'drawing' || mode === 'ready') {
return mode;
}
if (mode === 'dragging' || mode === 'dragging-start' || mode === 'dragging-direction' || mode === 'dragging-body') {
return 'ready';
}
return null;
}
function isRayLabelKind(kind: string): kind is RayLabelKind {
return kind === 'start' || kind === 'direction';
}