Загрузка данных
import type { AxisLabel } from '@core/Drawings/types';
import type { ISeriesPrimitiveAxisView } from 'lightweight-charts';
export interface CustomAxisViewParams {
getAxisLabel(labelKind: string): AxisLabel | null;
labelKind: string;
viewport: HTMLElement;
}
export abstract class CustomAxisView implements ISeriesPrimitiveAxisView {
private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
private readonly labelKind: string;
private readonly viewport: HTMLElement;
constructor({ getAxisLabel, labelKind, viewport }: CustomAxisViewParams) {
this.getAxisLabel = getAxisLabel;
this.labelKind = labelKind;
this.viewport = viewport;
}
public update(): void {
// Данные подписи читаются через getAxisLabel при каждом обращении; обновлять кэш не требуется.
}
public visible(): boolean {
const label = this.getCurrentLabel();
if (!label?.text) {
return false;
}
const coordinate = Number(label.coordinate);
const size = this.getViewportSize(this.viewport.getBoundingClientRect());
return Number.isFinite(coordinate) && coordinate >= 0 && coordinate <= size;
}
protected abstract getViewportSize(rect: DOMRect): number;
public tickVisible(): boolean {
return false;
}
public coordinate(): number {
return this.getCurrentLabel()?.coordinate ?? 0;
}
public text(): string {
return this.getCurrentLabel()?.text ?? '';
}
public textColor(): string {
return this.getCurrentLabel()?.textColor ?? '';
}
public backColor(): string {
return this.getCurrentLabel()?.backgroundColor ?? '';
}
private getCurrentLabel(): AxisLabel | null {
return this.getAxisLabel(this.labelKind);
}
}
import { CrosshairMode } from 'lightweight-charts';
import { 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 type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point, SeriesApi, UpdatableView } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
AutoscaleInfo,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
MouseEventParams,
PrimitiveHoveredItem,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';
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;
}
interface DrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
export interface BaseDrawingParams {
chart: IChartApi;
series: SeriesApi;
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
initialEvent?: MouseEventParams;
}
export abstract class DrawingBase<TSettings extends SettingsValues = SettingsValues, THandleId extends string = string>
implements ISeriesDrawing
{
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isBound = false;
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
private isInteractionBound = false;
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
private registeredPaneViews: (IPrimitivePaneView & UpdatableView)[] = [];
private registeredTimePaneViews: CustomTimeAxisPaneView[] = [];
private registeredPricePaneViews: CustomPriceAxisPaneView[] = [];
private registeredTimeViews: CustomTimeAxisView[] = [];
private registeredPriceViews: CustomPriceAxisView[] = [];
constructor({ chart, series, container, interaction }: DrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
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;
}
const point = this.getEventPoint(event);
if (this.getHoveredItem(point.x, point.y)) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
}
public click(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleClick(event);
}
public doubleClick(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleDoubleClick(event);
}
public contextMenu(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleContextMenu(event);
}
public abstract getRenderData(): unknown; // todo: make proper type
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public updateAllViews(): void {
updateViews([
...this.registeredPaneViews,
...this.registeredTimePaneViews,
...this.registeredPricePaneViews,
...this.registeredTimeViews,
...this.registeredPriceViews,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [...this.registeredPaneViews];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [...this.registeredPricePaneViews];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [...this.registeredPriceViews];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [...this.registeredTimePaneViews];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [...this.registeredTimeViews];
}
protected isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected isAxisLabelAvailable(): boolean {
return !this.hidden && !this.isCreationPending();
}
protected shouldShowInteractiveAxis(): boolean {
return this.isSelected() || this.isCreationPending();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
return [];
}
protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
return this.handlesPrimitive.findHandle(point);
}
protected createAnchorFromPoint(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getPointFromAnchor(anchor: Anchor | null): Point | null {
if (!anchor) {
return null;
}
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
return {
x: Number(x),
y: Number(y),
};
}
protected createTimeAxisView(labelKind: string): CustomTimeAxisView {
return new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind,
viewport: this.container,
});
}
protected createPriceAxisView(labelKind: string): CustomPriceAxisView {
return new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind,
viewport: this.container,
});
}
protected createTimeAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomTimeAxisPaneView {
return new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
zOrder,
});
}
protected createPriceAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomPriceAxisPaneView {
return new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
zOrder,
});
}
protected createAxisLabel(
coordinate: number | null,
text: string,
style?: Partial<Pick<AxisLabel, 'textColor' | 'backgroundColor'>>,
): AxisLabel | null {
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: style?.textColor ?? colors.chartPriceLineText,
backgroundColor: style?.backgroundColor ?? colors.axisMarkerLabelFill,
};
}
protected createAxisSegment(from: number, to: number, color?: string): AxisSegment {
const { colors } = getThemeStore();
return {
from,
to,
color: color ?? colors.axisMarkerAreaFill,
};
}
protected subscribeFormat(
formatObservable: Observable<ChartOptionsModel> | undefined,
callback: (format: ChartOptionsModel) => void,
): void {
if (!formatObservable) {
return;
}
this.subscriptions.add(
formatObservable.subscribe((format) => {
callback(format);
this.render();
}),
);
}
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 bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleClick(_event: MouseEvent): void {
// Обработка клика необязательна; нужные инструменты переопределяют этот метод.
}
protected handleContextMenu(_event: MouseEvent): void {
// Контекстное меню обрабатывают только инструменты, которым оно требуется.
}
protected handleDoubleClick(_event: MouseEvent): void {
// Двойной клик обрабатывают только инструменты с соответствующим действием.
}
protected handlePointerMove(_event: PointerEvent): void {
// Инструменты без обработки перемещения указателя используют эту реализацию.
}
protected handlePointerUp(_event: PointerEvent): void {
// Инструменты без обработки отпускания указателя используют эту реализацию.
}
protected handlePointerDown(_event: PointerEvent | TouchMouseEventData): void {
// Некоторые инструменты используют события графика вместо нажатий указателя.
}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
protected getBoundsAxisSegments(axis: 'time' | 'price', getBounds: () => Bounds | null): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const bounds = getBounds();
if (!bounds) {
return [];
}
return axis === 'time'
? [this.createAxisSegment(bounds.left, bounds.right)]
: [this.createAxisSegment(bounds.top, bounds.bottom)];
}
protected getInteractiveAxisLabel<TKind extends string>(
kind: string,
kinds: readonly TKind[],
getLabel: (kind: TKind) => AxisLabel | null,
): AxisLabel | null {
const labelKind = kinds.find((candidate) => candidate === kind);
if (!this.shouldShowInteractiveAxis() || labelKind === undefined) {
return null;
}
return getLabel(labelKind);
}
protected consumeEvent(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
}
protected selectOnPointerDown(event: PointerEvent, isHit: () => boolean): boolean {
if (this.isSelected()) {
return false;
}
if (isHit()) {
this.consumeEvent(event);
this.select();
}
return true;
}
protected openSettingsOnDoubleClick(
event: MouseEvent,
isHit: (point: Point) => boolean,
openSettings: (() => void) | undefined,
selectedOnly = false,
): void {
if (this.hidden || this.mode !== 'ready' || (selectedOnly && !this.isSelected())) {
return;
}
if (!isHit(this.getEventPoint(event as PointerEvent))) {
return;
}
this.consumeEvent(event);
openSettings?.();
}
protected initializeViews(
paneView: IPrimitivePaneView & UpdatableView,
timeLabels: readonly string[],
priceLabels: readonly string[],
zOrder: PrimitivePaneViewZOrder = 'bottom',
): void {
this.registeredPaneViews = [paneView];
this.registeredTimePaneViews = [this.createTimeAxisPaneView(zOrder)];
this.registeredPricePaneViews = [this.createPriceAxisPaneView(zOrder)];
this.registeredTimeViews = timeLabels.map((kind) => this.createTimeAxisView(kind));
this.registeredPriceViews = priceLabels.map((kind) => this.createPriceAxisView(kind));
}
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 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 { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { AreaDrawingBase } from '@core/Drawings/AreaDrawingBase';
import { isPointInBounds } from '@core/Drawings/helpers';
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 { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, Point } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams extends BaseDrawingParams {
rangeMode: DiapsonRangeMode;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
mode: DiapsonMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
labelLines: string[];
}
interface DateMetrics {
barsCount: number;
elapsedText: string;
volumeText: string;
}
interface PriceMetrics {
delta: number;
percent: number;
steps: number;
}
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
export class Diapson extends AreaDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: DiapsonSettings = createDefaultSettings();
protected mode: DiapsonMode = 'idle';
private rangeMode: DiapsonRangeMode;
protected activeDragTarget: DiapsonHandle = null;
protected 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;
constructor({
chart,
series,
container,
interaction,
rangeMode,
formatObservable,
removeSelf,
openSettings,
stepSize = 1,
stepLabel = '',
initialEvent,
}: DiapsonParams) {
super({ chart, series, container, interaction });
this.rangeMode = rangeMode;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.stepSize = stepSize > 0 ? stepSize : 1;
this.stepLabel = stepLabel;
this.initializeViews(new DiapsonPaneView(this), ['left', 'right'], ['top', 'bottom']);
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public 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>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
this.rangeMode = nextState.rangeMode ?? this.rangeMode;
this.restoreAnchor('start', nextState);
this.restoreAnchor('end', nextState);
if ('settings' in nextState && 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',
},
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
return this.getBoundsAxisLabel(kind, ['left', 'right'], (labelKind) => this.getTimeText(labelKind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
return this.getBoundsAxisLabel(kind, ['top', 'bottom'], (labelKind) => this.getPriceText(labelKind));
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
const handleTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
protected getGeometry(): DiapsonGeometry | null {
const rawStartPoint = this.getPointFromAnchor(this.startAnchor);
const rawEndPoint = this.getPointFromAnchor(this.endAnchor);
if (!rawStartPoint || !rawEndPoint) {
return null;
}
const { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(rawStartPoint.x), 0, width),
y: clamp(Math.round(rawStartPoint.y), 0, height),
};
const endPoint = {
x: clamp(Math.round(rawEndPoint.x), 0, width),
y: clamp(Math.round(rawEndPoint.y), 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 {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
startPoint,
endPoint,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
this.openSettingsOnDoubleClick(
event,
(point) => this.getDrawingHandleAtPoint(point) !== null || this.containsPoint(point),
() => this.openSettings?.(),
);
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
protected startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.setAnchors(anchor, anchor);
this.mode = 'drawing';
this.render();
}
protected updateDrawing(point: Point): void {
const nextPoint = this.clampPointToContainer(point);
if (!this.setAnchorFromPoint('end', nextPoint)) {
return;
}
this.render();
}
protected 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();
}
protected startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.deselect();
this.mode = 'idle';
this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
protected getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
protected 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);
}
protected resize(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 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 === null || rightTime === null) {
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.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 {
if (!this.startAnchor || !this.endAnchor) {
return 0;
}
const startIndex = this.findIndexByTime(this.startAnchor.time);
const endIndex = this.findIndexByTime(this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
return Math.abs(endIndex - startIndex);
}
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 {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return this.startAnchor.time;
}
return geometry.startPoint.x <= geometry.endPoint.x ? this.startAnchor.time : this.endAnchor.time;
}
private getRightTimeValue(): Time | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return this.endAnchor.time;
}
return geometry.startPoint.x <= geometry.endPoint.x ? this.endAnchor.time : this.startAnchor.time;
}
private getTopPriceValue(): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return Math.max(this.startAnchor.price, this.endAnchor.price);
}
return geometry.startPoint.y <= geometry.endPoint.y ? this.startAnchor.price : this.endAnchor.price;
}
private getBottomPriceValue(): number | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return Math.min(this.startAnchor.price, this.endAnchor.price);
}
return geometry.startPoint.y <= geometry.endPoint.y ? this.endAnchor.price : this.startAnchor.price;
}
protected containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return geometry !== null && 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';
}
}
import { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { getDistanceToSegment } from '@core/Drawings/utils';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs } from './settings';
import type { RaySettings, RayStyle, RayTextStyle } from './settings';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type RayMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
type RayHandleKey = 'start' | 'direction';
type TimeLabelKind = 'start' | 'direction';
type PriceLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;
interface RayState {
hidden: boolean;
mode: RayMode;
startAnchor: Anchor | null;
directionAnchor: Anchor | null;
settings: RaySettings;
}
interface RayGeometry {
startPoint: Point;
directionPoint: Point;
rayEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
interface RayIntersection {
point: Point;
t: number;
}
export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class Ray extends LinearDrawingBase<RaySettings, RayHandleKey, RayState> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RaySettings = createDefaultSettings();
protected mode: RayMode = 'idle';
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: RayParams) {
super({
chart,
series,
container,
interaction,
});
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.initializeViews(new RayPaneView(this), ['start', 'direction'], ['start', 'direction']);
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
private get directionAnchor(): Anchor | null {
return this.endAnchor;
}
private set directionAnchor(anchor: Anchor | null) {
this.endAnchor = anchor;
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): RayState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
directionAnchor: this.directionAnchor,
settings: {
...this.settings,
},
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RayState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('directionAnchor' in nextState) {
this.directionAnchor = nextState.directionAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRaySettingTabs(this.settings);
}
public getRenderData(): RayRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getStartHandleId(): RayHandleKey {
return 'start';
}
protected getEndHandleId(): RayHandleKey {
return 'direction';
}
protected getLineHandleStrokeColor(): string {
return this.settings.lineColor;
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = {
x,
y,
};
if (this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'move',
externalId: 'ray',
zOrder: 'top',
};
}
if (!this.isPointNearRay(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'ray',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
return this.getInteractiveAxisLabel(kind, ['start', 'direction'], (labelKind) => {
const anchorKind = labelKind === 'start' ? 'start' : 'end';
return this.createAxisLabel(this.getAnchorTimeCoordinate(anchorKind), this.getTimeText(labelKind));
});
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
return this.getInteractiveAxisLabel(kind, ['start', 'direction'], (labelKind) => {
const anchorKind = labelKind === 'start' ? 'start' : 'end';
return this.createAxisLabel(this.getAnchorPriceCoordinate(anchorKind), this.getPriceText(labelKind));
});
}
protected getGeometry(): RayGeometry | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
const { startPoint } = geometry;
const directionPoint = geometry.endPoint;
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),
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
this.openSettingsOnDoubleClick(
event,
(point) => this.getDrawingHandleAtPoint(point) !== null || this.isPointNearRay(point),
() => this.openSettings?.(),
);
};
protected handleReadyPointerDown(event: PointerEvent, point: Point): void {
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearRay = this.isPointNearRay(point);
const isDrawingHit = pointTarget !== null || isNearRay;
if (!this.isSelected()) {
if (!isDrawingHit) {
return;
}
this.consumeEvent(event);
this.select();
return;
}
if (pointTarget === 'start') {
this.consumeEvent(event);
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'direction') {
this.consumeEvent(event);
this.startDragging('dragging-direction', point, event.pointerId);
return;
}
if (isNearRay) {
this.consumeEvent(event);
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.deselect();
}
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-direction') {
this.consumeEvent(event);
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
this.consumeEvent(event);
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();
}
};
protected finishDrawing(): void {
this.finishLinearDrawing(this.getTwoPointGeometry(), MIN_LINE_SIZE, () => this.removeSelf?.());
}
private movePoint(point: Point): void {
if (this.mode === 'dragging-start') {
this.setAnchorFromPoint('start', point);
return;
}
if (this.mode === 'dragging-direction') {
this.setAnchorFromPoint('end', point);
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot) {
return;
}
this.moveAnchorsByPixels(snapshot.startAnchor, snapshot.directionAnchor, this.dragStartPoint, point);
}
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 } = this.container.getBoundingClientRect();
const height = this.series.getPane().getHeight();
if (width <= 0 || height <= 0) {
return null;
}
const candidates: RayIntersection[] = [];
this.addVerticalRayIntersections(candidates, startPoint, dx, dy, width, height);
this.addHorizontalRayIntersections(candidates, startPoint, dx, dy, width, height);
if (!candidates.length) {
return null;
}
return candidates.reduce((farthest, candidate) => {
return candidate.t > farthest.t ? candidate : farthest;
}).point;
}
private isPointNearRay(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
}
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) ?? '';
}
private addVerticalRayIntersections(
candidates: RayIntersection[],
startPoint: Point,
dx: number,
dy: number,
width: number,
height: number,
): void {
if (dx !== 0) {
const leftT = -startPoint.x / dx;
const rightT = (width - startPoint.x) / dx;
const leftY = startPoint.y + leftT * dy;
const rightY = startPoint.y + rightT * dy;
if (leftT >= 0 && leftY >= 0 && leftY <= height) {
candidates.push({
point: {
x: 0,
y: leftY,
},
t: leftT,
});
}
if (rightT >= 0 && rightY >= 0 && rightY <= height) {
candidates.push({
point: {
x: width,
y: rightY,
},
t: rightT,
});
}
}
}
private addHorizontalRayIntersections(
candidates: RayIntersection[],
startPoint: Point,
dx: number,
dy: number,
width: number,
height: number,
): void {
if (dy !== 0) {
const topT = -startPoint.y / dy;
const bottomT = (height - startPoint.y) / dy;
const topX = startPoint.x + topT * dx;
const bottomX = startPoint.x + bottomT * dx;
if (topT >= 0 && topX >= 0 && topX <= width) {
candidates.push({
point: {
x: topX,
y: 0,
},
t: topT,
});
}
if (bottomT >= 0 && bottomX >= 0 && bottomX <= width) {
candidates.push({
point: {
x: bottomX,
y: height,
},
t: bottomT,
});
}
}
}
}