Загрузка данных
import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { getYCoordinateFromPrice, isPointInBounds } from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { updateViews } from '@core/Drawings/utils';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { FibonacciRetracementPaneView } from './paneView';
import {
cloneFibonacciRetracementSettings,
createDefaultSettings,
FibonacciRetracementSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementHandle, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;
interface FibonacciRetracementState {
hidden: boolean;
mode: FibonacciRetracementMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: FibonacciRetracementSettings;
}
interface FibonacciDragSnapshot {
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
export interface FibonacciRetracementLevelRenderData {
id: string;
value: number;
price: number;
y: number;
color: string;
text: string;
}
export interface FibonacciRetracementAreaRenderData {
top: number;
bottom: number;
color: string;
}
interface FibonacciRetracementGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
levels: FibonacciRetracementLevelRenderData[];
areas: FibonacciRetracementAreaRenderData[];
}
type FibonacciRetracementRenderSettings = Omit<
FibonacciRetracementSettings,
'levels' | 'backgroundOpacity'
>;
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
backgroundOpacity: number;
}
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
export class FibonacciRetracement
extends TwoPointDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: FibonacciRetracementSettings = createDefaultSettings();
protected mode: FibonacciRetracementMode = 'idle';
private activeDragTarget: FibonacciRetracementHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragAnchorSnapshot: FibonacciDragSnapshot | null = null;
private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: FibonacciRetracementPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: FibonacciRetracementParams) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new FibonacciRetracementPaneView(this);
this.timeAxisPaneView = this.createTimeAxisPaneView();
this.priceAxisPaneView = this.createPriceAxisPaneView();
this.startTimeAxisView = this.createTimeAxisView('start');
this.endTimeAxisView = this.createTimeAxisView('end');
this.topPriceAxisView = this.createPriceAxisView('top');
this.bottomPriceAxisView = this.createPriceAxisView('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 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 next = state as Partial<FibonacciRetracementState>;
this.hidden = next.hidden ?? this.hidden;
this.mode = next.mode ?? this.mode;
this.restoreStartAnchor(next);
this.restoreEndAnchor(next);
if (next.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), next.settings);
}
this.render();
}
public getSettings(): SettingsValues {
return getFibonacciRetracementSettingsValues(this.settings);
}
public getSettingsTabs(): SettingsTab[] {
return getFibonacciRetracementSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = mergeFibonacciRetracementSettings(this.settings, settings);
this.render();
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): FibonacciRetracementRenderData | null {
const geometry = this.hidden ? null : this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showBackground: this.settings.showBackground,
backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
reverse: this.settings.reverse,
labelsPosition: this.settings.labelsPosition,
showPrices: this.settings.showPrices,
showLevelValues: this.settings.showLevelValues,
fontSize: this.settings.fontSize,
isBold: this.settings.isBold,
isItalic: this.settings.isItalic,
};
}
protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
},
{
id: 'start',
...geometry.startPoint,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isSelected() && !this.containsPoint(point)) {
return null;
}
if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: this.isSelected() ? 'grab' : 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [this.createAxisSegment(geometry.left, geometry.right)];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [this.createAxisSegment(geometry.top, geometry.bottom)];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
return this.createAxisLabel(
this.getAnchorTimeCoordinate(labelKind),
this.getTimeText(labelKind),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
return this.createAxisLabel(
this.getPriceCoordinate(labelKind),
this.getPriceText(labelKind),
);
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
} else {
this.resize(point);
}
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
};
protected getGeometry(): FibonacciRetracementGeometry | null {
const baseGeometry = this.getTwoPointGeometry();
if (!baseGeometry) {
return null;
}
const levels = this.getLevels();
const top = Math.min(
baseGeometry.startPoint.y,
baseGeometry.endPoint.y,
...levels.map((level) => level.y),
);
const bottom = Math.max(
baseGeometry.startPoint.y,
baseGeometry.endPoint.y,
...levels.map((level) => level.y),
);
return {
startPoint: baseGeometry.startPoint,
endPoint: baseGeometry.endPoint,
left: baseGeometry.left,
right: baseGeometry.right,
top,
bottom,
width: baseGeometry.right - baseGeometry.left,
height: bottom - top,
levels,
areas: this.getAreas(levels),
};
}
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.setAnchors(anchor, anchor);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const clampedPoint = this.clampPointToContainer(point);
if (!this.setAnchorFromPoint('end', clampedPoint)) {
return;
}
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (
!geometry ||
geometry.width < MIN_DISTANCE ||
Math.abs(geometry.startPoint.y - geometry.endPoint.y) < MIN_DISTANCE
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(
point: Point,
pointerId: number,
dragTarget: Exclude<FibonacciRetracementHandle, null>,
): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragAnchorSnapshot = {
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragAnchorSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const snapshot = this.dragAnchorSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry) {
return;
}
this.moveAnchorsWithinContainer(
snapshot.startAnchor,
snapshot.endAnchor,
this.dragStartPoint,
point,
geometry,
);
}
private resize(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
this.setAnchorFromPoint(
this.activeDragTarget,
this.clampPointToContainer(point),
);
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
if (!this.startAnchor || !this.endAnchor) {
return [];
}
return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>(
(result, level) => {
const price = this.getLevelPrice(level.value);
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return result;
}
result.push({
id: level.id,
value: level.value,
price,
y: Number(y),
color: level.color,
text: this.getLevelText(level.value, price),
});
return result;
},
[],
);
}
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((a, b) => a.value - b.value);
return orderedLevels.slice(0, -1).map((level, index) => {
const nextLevel = orderedLevels[index + 1];
return {
top: Math.min(level.y, nextLevel.y),
bottom: Math.max(level.y, nextLevel.y),
color: nextLevel.color,
};
});
}
private getLevelText(value: number, price: number): string {
const parts: string[] = [];
if (this.settings.showLevelValues) {
parts.push(formatLevelLabel(value));
}
if (this.settings.showPrices) {
parts.push(`(${formatPrice(price) ?? String(price)})`);
}
return parts.join(' ');
}
private 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 anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
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;
}
const bounds: Bounds = {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
};
if (
this.settings.showBackground &&
isPointInBounds(point, bounds, BODY_HIT_TOLERANCE)
) {
return true;
}
const xInRange =
point.x >= geometry.left - LINE_HIT_TOLERANCE &&
point.x <= geometry.right + LINE_HIT_TOLERANCE;
return (
xInRange &&
geometry.levels.some(
(level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE,
)
);
}
private restoreStartAnchor(state: Partial<FibonacciRetracementState>): void {
const time = state.startTime ?? this.startAnchor?.time ?? null;
const price = state.startPrice ?? this.startAnchor?.price ?? null;
if (time !== null && price !== null) {
this.startAnchor = { time, price };
}
}
private restoreEndAnchor(state: Partial<FibonacciRetracementState>): void {
const time = state.endTime ?? this.endAnchor?.time ?? null;
const price = state.endPrice ?? this.endAnchor?.price ?? null;
if (time !== null && price !== null) {
this.endAnchor = { time, price };
}
}
}
import { clamp } from 'lodash-es';
import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { isPointInBounds } from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { updateViews } from '@core/Drawings/utils';
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 { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
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 TwoPointDrawingBase<DiapsonSettings, DiapsonHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: DiapsonSettings = createDefaultSettings();
protected mode: DiapsonMode = 'idle';
private rangeMode: DiapsonRangeMode;
private activeDragTarget: DiapsonHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: DiapsonGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly stepSize: number;
private readonly stepLabel: string;
private readonly paneView: DiapsonPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
rangeMode,
formatObservable,
removeSelf,
openSettings,
stepSize = 1,
stepLabel = '',
initialEvent,
}: DiapsonParams) {
super({ chart, series, container, interaction });
this.rangeMode = rangeMode;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.stepSize = stepSize > 0 ? stepSize : 1;
this.stepLabel = stepLabel;
this.paneView = new DiapsonPaneView(this);
this.timeAxisPaneView = this.createTimeAxisPaneView();
this.priceAxisPaneView = this.createPriceAxisPaneView();
this.leftTimeAxisView = this.createTimeAxisView('left');
this.rightTimeAxisView = this.createTimeAxisView('right');
this.topPriceAxisView = this.createPriceAxisView('top');
this.bottomPriceAxisView = this.createPriceAxisView('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.restoreStartAnchor(nextState);
this.restoreEndAnchor(nextState);
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): DiapsonRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
rangeMode: this.rangeMode,
showFill: true,
labelLines: this.getLabelLines(),
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
shape: 'circle',
},
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
},
];
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [this.createAxisSegment(geometry.left, geometry.right)];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [this.createAxisSegment(geometry.top, geometry.bottom)];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
return this.createAxisLabel(
this.getTimeCoordinate(labelKind),
this.getTimeText(labelKind),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
return this.createAxisLabel(
this.getPriceCoordinate(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 => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.setAnchors(anchor, anchor);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const nextPoint = this.clampPointToContainer(point);
if (!this.setAnchorFromPoint('end', nextPoint)) {
return;
}
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
this.resetToIdle();
return;
}
if (
geometry.width < MIN_RECTANGLE_WIDTH ||
geometry.height < MIN_RECTANGLE_HEIGHT
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(
point: Point,
pointerId: number,
dragTarget: Exclude<DiapsonHandle, null>,
): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.deselect();
this.mode = 'idle';
this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const dragStartPoint = this.dragStartPoint;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextStartPoint = this.clampPointToContainer({
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
});
const nextEndPoint = this.clampPointToContainer({
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
});
this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const nextPoint = this.clampPointToContainer(point);
if (this.activeDragTarget === 'start') {
this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
return;
}
this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
}
private 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 getVolumeInRange(): number {
if (!this.startAnchor || !this.endAnchor) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startAnchor.time);
const endIndex = this.findIndexByTime(this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => Number(item.time) === Number(time));
}
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 {
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;
}
private 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';
}
private restoreStartAnchor(state: Partial<DiapsonState>): void {
if (!('startTime' in state) && !('startPrice' in state)) {
return;
}
const time =
'startTime' in state
? state.startTime ?? null
: this.startAnchor?.time ?? null;
const price =
'startPrice' in state
? state.startPrice ?? null
: this.startAnchor?.price ?? null;
this.startAnchor =
time !== null && price !== null
? { time, price }
: null;
}
private restoreEndAnchor(state: Partial<DiapsonState>): void {
if (!('endTime' in state) && !('endPrice' in state)) {
return;
}
const time =
'endTime' in state
? state.endTime ?? null
: this.endAnchor?.time ?? null;
const price =
'endPrice' in state
? state.endPrice ?? null
: this.endAnchor?.price ?? null;
this.endAnchor =
time !== null && price !== null
? { time, price }
: null;
}
}