Загрузка данных
diff --git a/src/core/ContainerManager.ts b/src/core/ContainerManager.ts
index 4b42ac0ef55869ed9c67435b69abc4b56030694c..5302f4e1f747428caaf9ae14d5e1172a3bab13b8 100644
--- a/src/core/ContainerManager.ts
+++ b/src/core/ContainerManager.ts
@@ -103,7 +103,7 @@ export class ContainerManager {
controlBarContainer.style.left = '50%';
controlBarContainer.style.transform = 'translateX(-50%)';
controlBarContainer.style.bottom = 'var(--space-2000)';
- controlBarContainer.style.zIndex = ZIndex.Base;
+ controlBarContainer.style.zIndex = ZIndex.Floating;
const drawingToolbarContainer = document.createElement('div');
drawingToolbarContainer.classList.add(CHART_DRAWING_TOOLBAR_CONTAINER);
diff --git a/src/core/Drawings.ts b/src/core/Drawings.ts
index 6df1b9712de194f5d6c43c272c9c6d12cab099fb..1909a72a21eb3baea679846ec430bf486525e42b 100644
--- a/src/core/Drawings.ts
+++ b/src/core/Drawings.ts
@@ -8,7 +8,7 @@ import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';
-import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
+import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/base/DrawingBase';
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
diff --git a/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts b/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts
deleted file mode 100644
index 96c08569669d0bdd9a3a90e2d58071f4e42749b9..0000000000000000000000000000000000000000
--- a/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts
+++ /dev/null
@@ -1,573 +0,0 @@
-import { BehaviorSubject } from 'rxjs';
-
-import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
-import { SeriesDrawingBase } from '@core/Drawings/SeriesDrawingBase';
-
-import type { DrawingHandle } from '@core/Drawings/handles';
-import type { DrawingInteraction } from '@core/Drawings/SeriesDrawingBase';
-import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
-import type { SettingsTab, SettingsValues } from '@src/types';
-import type {
- IChartApi,
- IPrimitivePaneView,
- ISeriesPrimitiveAxisView,
- PrimitiveHoveredItem,
- SeriesAttachedParameter,
- Time,
-} from 'lightweight-charts';
-
-class TestDrawing extends SeriesDrawingBase<SettingsValues, 'main'> {
- protected mode: 'idle' | 'ready' = 'ready';
-
- protected settings: SettingsValues = {
- color: '#ffffff',
- };
-
- public handlePoint: Point = {
- x: 50,
- y: 50,
- };
-
- public updateAllViews = jest.fn();
-
- public pointerDownCalls = 0;
- public clickCalls = 0;
- public doubleClickCalls = 0;
- public contextMenuCalls = 0;
-
- public isCreationPending(): boolean {
- return this.mode === 'idle';
- }
-
- public getState() {
- return {
- mode: this.mode,
- };
- }
-
- public setState(state: unknown): void {
- if (!state || typeof state !== 'object') {
- return;
- }
-
- const nextState = state as { mode?: 'idle' | 'ready' };
-
- this.mode = nextState.mode ?? this.mode;
- }
-
- public getSettingsTabs(): SettingsTab[] {
- return [];
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [];
- }
-
- public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
- return [];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [];
- }
-
- public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
- return [];
- }
-
- public getRenderData() {
- return null;
- }
-
- public setMode(mode: 'idle' | 'ready'): void {
- this.mode = mode;
- }
-
- public markReady(): void {
- this.mode = 'ready';
- this.resolveReady?.();
- }
-
- public findHandle(point: Point): DrawingHandle<'main'> | null {
- return this.getDrawingHandleAtPoint(point);
- }
-
- protected getDrawingHandles(): readonly DrawingHandle<'main'>[] {
- return [
- {
- id: 'main',
- ...this.handlePoint,
- },
- ];
- }
-
- protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
- if (Math.abs(x - this.handlePoint.x) > 8 || Math.abs(y - this.handlePoint.y) > 8) {
- return null;
- }
-
- return {
- cursorStyle: 'move',
- externalId: 'test',
- zOrder: 'top',
- };
- }
-
- protected getGeometry() {
- return null;
- }
-
- protected getTimeAxisSegments(): AxisSegment[] {
- return [];
- }
-
- protected getPriceAxisSegments(): AxisSegment[] {
- return [];
- }
-
- protected getTimeAxisLabel(_kind: string): AxisLabel | null {
- return null;
- }
-
- protected getPriceAxisLabel(_kind: string): AxisLabel | null {
- return null;
- }
-
- protected handlePointerDown(): void {
- this.pointerDownCalls += 1;
- }
-
- protected handleClick(): void {
- this.clickCalls += 1;
- }
-
- protected handleDoubleClick(): void {
- this.doubleClickCalls += 1;
- }
-
- protected handleContextMenu(): void {
- this.contextMenuCalls += 1;
- }
-}
-
-function createEnvironment({
- selected = false,
- locked = false,
- width = 400,
- height = 300,
-}: {
- selected?: boolean;
- locked?: boolean;
- width?: number;
- height?: number;
-} = {}) {
- let selectedValue = selected;
- let lockedValue = locked;
-
- const selected$ = new BehaviorSubject(selected);
- const locked$ = new BehaviorSubject(locked);
-
- const interaction: DrawingInteraction = {
- selected$,
- locked$,
- isSelected: jest.fn(() => selectedValue),
- isLocked: jest.fn(() => lockedValue),
- select: jest.fn(() => {
- selectedValue = true;
- selected$.next(true);
- }),
- deselect: jest.fn(() => {
- selectedValue = false;
- selected$.next(false);
- }),
- };
-
- const container = document.createElement('div');
-
- jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
- x: 0,
- y: 0,
- left: 0,
- top: 0,
- right: width,
- bottom: height,
- width,
- height,
- toJSON: jest.fn(),
- });
-
- const pane = {
- paneIndex: jest.fn(() => 0),
- getHeight: jest.fn(() => height),
- };
-
- const chart = {
- panes: jest.fn(() => [pane]),
- applyOptions: jest.fn(),
- } as unknown as IChartApi;
-
- const series = {
- attachPrimitive: jest.fn(),
- detachPrimitive: jest.fn(),
- getPane: jest.fn(() => pane),
- } as unknown as SeriesApi;
-
- return {
- container,
- interaction,
- chart,
- series,
- setSelected(value: boolean) {
- selectedValue = value;
- selected$.next(value);
- },
- setLocked(value: boolean) {
- lockedValue = value;
- locked$.next(value);
- },
- };
-}
-
-function createPointerEvent(target: Node, x: number, y: number, button = 0): PointerEvent {
- return {
- target,
- clientX: x,
- clientY: y,
- button,
- pointerId: 1,
- preventDefault: jest.fn(),
- stopPropagation: jest.fn(),
- } as unknown as PointerEvent;
-}
-
-function createMouseEvent(target: Node, x: number, y: number): MouseEvent {
- return {
- target,
- clientX: x,
- clientY: y,
- preventDefault: jest.fn(),
- stopPropagation: jest.fn(),
- } as unknown as MouseEvent;
-}
-
-describe('SeriesDrawingBase', () => {
- it('должен возвращать копию settings', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- const first = drawing.getSettings();
-
- first.color = '#000000';
-
- expect(drawing.getSettings()).toEqual({
- color: '#ffffff',
- });
- });
-
- it('должен объединять новые settings с текущими', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.updateSettings({
- lineWidth: 2,
- });
-
- expect(drawing.getSettings()).toEqual({
- color: '#ffffff',
- lineWidth: 2,
- });
- });
-
- it('должен уведомлять подписчика settings', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
- const callback = jest.fn();
-
- const subscription = drawing.subscribeSettings(callback);
-
- expect(callback).toHaveBeenCalledWith({
- color: '#ffffff',
- });
-
- drawing.updateSettings({
- color: '#ff0000',
- });
-
- expect(callback).toHaveBeenLastCalledWith({
- color: '#ff0000',
- });
-
- subscription.unsubscribe();
- });
-
- it('должен скрывать drawing', () => {
- const env = createEnvironment({
- selected: true,
- });
-
- const drawing = new TestDrawing(env);
-
- drawing.hide();
-
- expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
- expect(drawing.updateAllViews).toHaveBeenCalled();
- expect(env.chart.applyOptions).toHaveBeenCalled();
- });
-
- it('должен снова показывать drawing', () => {
- const env = createEnvironment({
- selected: true,
- });
-
- const drawing = new TestDrawing(env);
-
- drawing.hide();
- drawing.show();
-
- expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
- });
-
- it('должен показывать handle для selected drawing', () => {
- const env = createEnvironment({
- selected: true,
- });
-
- const drawing = new TestDrawing(env);
-
- expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
- });
-
- it('не должен показывать handle для невыбранного ready drawing', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
- });
-
- it('должен показывать handle во время создания', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.setMode('idle');
-
- expect(drawing.findHandle({ x: 50, y: 50 })?.id).toBe('main');
- });
-
- it('не должен показывать handle для locked drawing', () => {
- const env = createEnvironment({
- selected: true,
- locked: true,
- });
-
- const drawing = new TestDrawing(env);
-
- expect(drawing.findHandle({ x: 50, y: 50 })).toBeNull();
- });
-
- it('должен прикреплять DrawingHandlesPrimitive при attached', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.attached({
- chart: env.chart,
- series: env.series,
- requestUpdate: jest.fn(),
- } as unknown as SeriesAttachedParameter<Time>);
-
- expect(env.series.attachPrimitive).toHaveBeenCalledTimes(1);
- expect(env.series.attachPrimitive).toHaveBeenCalledWith(expect.any(DrawingHandlesPrimitive));
-
- drawing.detached();
- });
-
- it('должен отсоединять DrawingHandlesPrimitive при detached', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.attached({
- chart: env.chart,
- series: env.series,
- requestUpdate: jest.fn(),
- } as unknown as SeriesAttachedParameter<Time>);
-
- const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0];
-
- drawing.detached();
-
- expect(env.series.detachPrimitive).toHaveBeenCalledWith(handlesPrimitive);
- });
-
- it('должен возвращать false для object tree в idle', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.setMode('idle');
-
- expect(drawing.shouldShowInObjectTree()).toBe(false);
- });
-
- it('должен возвращать true для object tree в ready', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- expect(drawing.shouldShowInObjectTree()).toBe(true);
- });
-
- it('waitTillReady должен сразу завершаться для ready drawing', async () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- await expect(drawing.waitTillReady()).resolves.toBeUndefined();
- });
-
- it('waitTillReady должен ожидать перехода в ready', async () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.setMode('idle');
-
- const promise = drawing.waitTillReady();
-
- drawing.markReady();
-
- await expect(promise).resolves.toBeUndefined();
- });
-
- it('должен возвращать исходный cursor для unlocked drawing', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('move');
- });
-
- it('должен заменять cursor на pointer для locked drawing', () => {
- const env = createEnvironment({
- locked: true,
- });
-
- const drawing = new TestDrawing(env);
-
- expect(drawing.hitTest(50, 50)?.cursorStyle).toBe('pointer');
- });
-
- it('должен передавать pointerDown в drawing для unlocked состояния', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.pointerDown(createPointerEvent(env.container, 50, 50));
-
- expect(drawing.pointerDownCalls).toBe(1);
- });
-
- it('не должен обрабатывать событие вне container', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
- const outside = document.createElement('div');
-
- drawing.pointerDown(createPointerEvent(outside, 50, 50));
-
- expect(drawing.pointerDownCalls).toBe(0);
- });
-
- it('должен выбирать locked drawing при pointerDown по нему', () => {
- const env = createEnvironment({
- locked: true,
- });
-
- const drawing = new TestDrawing(env);
-
- drawing.pointerDown(createPointerEvent(env.container, 50, 50));
-
- expect(env.interaction.select).toHaveBeenCalledTimes(1);
- expect(drawing.pointerDownCalls).toBe(0);
- });
-
- it('должен вызывать click handler', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.click(createMouseEvent(env.container, 50, 50));
-
- expect(drawing.clickCalls).toBe(1);
- });
-
- it('должен вызывать doubleClick handler', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.doubleClick(createMouseEvent(env.container, 50, 50));
-
- expect(drawing.doubleClickCalls).toBe(1);
- });
-
- it('должен вызывать contextMenu handler', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.contextMenu(createMouseEvent(env.container, 50, 50));
-
- expect(drawing.contextMenuCalls).toBe(1);
- });
-
- it('должен принимать событие на выступающей части handle поверх time axis', () => {
- const env = createEnvironment({
- selected: true,
- height: 100,
- });
-
- const drawing = new TestDrawing(env);
-
- drawing.handlePoint = {
- x: 50,
- y: 100,
- };
-
- drawing.attached({
- chart: env.chart,
- series: env.series,
- requestUpdate: jest.fn(),
- } as unknown as SeriesAttachedParameter<Time>);
-
- const handlesPrimitive = (env.series.attachPrimitive as jest.Mock).mock.calls[0][0] as DrawingHandlesPrimitive;
-
- handlesPrimitive.attached({
- chart: env.chart,
- series: env.series,
- requestUpdate: jest.fn(),
- } as unknown as SeriesAttachedParameter<Time>);
-
- const outside = document.createElement('div');
-
- expect(drawing.isHit(createMouseEvent(outside, 50, 104))).toBe(true);
-
- drawing.detached();
- });
-
- it('должен rebind drawing на новую series', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- const newSeries = {
- attachPrimitive: jest.fn(),
- detachPrimitive: jest.fn(),
- } as unknown as SeriesApi;
-
- drawing.rebind(newSeries);
-
- expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
- expect(newSeries.attachPrimitive).toHaveBeenCalledWith(drawing);
- });
-
- it('должен detach drawing при destroy', () => {
- const env = createEnvironment();
- const drawing = new TestDrawing(env);
-
- drawing.destroy();
-
- expect(env.series.detachPrimitive).toHaveBeenCalledWith(drawing);
- });
-});
diff --git a/src/core/Drawings/__tests__/drawingTestUtils.ts b/src/core/Drawings/__tests__/drawingTestUtils.ts
index 25ad42b837b1294618231747f3266623382152e5..3746044a651252cada48ff719d90ebed443a9c82 100644
--- a/src/core/Drawings/__tests__/drawingTestUtils.ts
+++ b/src/core/Drawings/__tests__/drawingTestUtils.ts
@@ -1,6 +1,6 @@
import { BehaviorSubject } from 'rxjs';
-import type { BaseDrawingParams } from '@core/Drawings/SeriesDrawingBase';
+import type { BaseDrawingParams } from '@src/core/Drawings/base';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type { Coordinate, Logical, Time } from 'lightweight-charts';
diff --git a/src/core/Drawings/__tests__/drawings/axis/CustomAxisView.test.ts b/src/core/Drawings/__tests__/drawings/axis/CustomAxisView.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6f9588e58036cf64592c5a841c6fc4ccf802fec9
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/axis/CustomAxisView.test.ts
@@ -0,0 +1,163 @@
+import { CustomAxisView } from '@core/Drawings/axis/CustomAxisView';
+
+import type { AxisLabel } from '@core/Drawings/types';
+
+class TestCustomAxisView extends CustomAxisView {
+ protected getViewportSize(rect: DOMRect): number {
+ return rect.width;
+ }
+}
+
+function createViewport(width = 400, height = 300): HTMLElement {
+ const viewport = document.createElement('div');
+
+ jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
+ width,
+ height,
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: height,
+ toJSON: () => ({}),
+ });
+
+ return viewport;
+}
+
+function createLabel(overrides: Partial<AxisLabel> = {}): AxisLabel {
+ return {
+ coordinate: 100,
+ text: 'Label',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ ...overrides,
+ };
+}
+
+describe('CustomAxisView', () => {
+ it('должен возвращать данные текущего label', () => {
+ const getAxisLabel = jest.fn(() => createLabel());
+
+ const view = new TestCustomAxisView({
+ getAxisLabel,
+ labelKind: 'start',
+ viewport: createViewport(),
+ });
+
+ expect(view.coordinate()).toBe(100);
+ expect(view.text()).toBe('Label');
+ expect(view.textColor()).toBe('#ffffff');
+ expect(view.backColor()).toBe('#000000');
+ expect(getAxisLabel).toHaveBeenCalledWith('start');
+ });
+
+ it('должен возвращать значения по умолчанию при отсутствии label', () => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () => null,
+ labelKind: 'start',
+ viewport: createViewport(),
+ });
+
+ expect(view.visible()).toBe(false);
+ expect(view.tickVisible()).toBe(false);
+ expect(view.coordinate()).toBe(0);
+ expect(view.text()).toBe('');
+ expect(view.textColor()).toBe('');
+ expect(view.backColor()).toBe('');
+ });
+
+ it('не должен показывать label с пустым текстом', () => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () =>
+ createLabel({
+ text: '',
+ }),
+ labelKind: 'start',
+ viewport: createViewport(),
+ });
+
+ expect(view.visible()).toBe(false);
+ });
+
+ it('должен показывать label внутри viewport', () => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () =>
+ createLabel({
+ coordinate: 200,
+ }),
+ labelKind: 'start',
+ viewport: createViewport(400),
+ });
+
+ expect(view.visible()).toBe(true);
+ });
+
+ it.each([-1, 401])('не должен показывать label вне viewport: %s', (coordinate) => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () =>
+ createLabel({
+ coordinate,
+ }),
+ labelKind: 'start',
+ viewport: createViewport(400),
+ });
+
+ expect(view.visible()).toBe(false);
+ });
+
+ it.each([0, 400])('должен показывать label на границе viewport: %s', (coordinate) => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () =>
+ createLabel({
+ coordinate,
+ }),
+ labelKind: 'start',
+ viewport: createViewport(400),
+ });
+
+ expect(view.visible()).toBe(true);
+ });
+
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
+ 'не должен показывать label с невалидной coordinate: %s',
+ (coordinate) => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () =>
+ createLabel({
+ coordinate,
+ }),
+ labelKind: 'start',
+ viewport: createViewport(),
+ });
+
+ expect(view.visible()).toBe(false);
+ },
+ );
+
+ it('tick не должен быть видимым', () => {
+ const view = new TestCustomAxisView({
+ getAxisLabel: () => createLabel(),
+ labelKind: 'start',
+ viewport: createViewport(),
+ });
+
+ expect(view.tickVisible()).toBe(false);
+ });
+
+ it('update не должен изменять состояние view', () => {
+ const getAxisLabel = jest.fn(() => createLabel());
+
+ const view = new TestCustomAxisView({
+ getAxisLabel,
+ labelKind: 'start',
+ viewport: createViewport(),
+ });
+
+ view.update();
+
+ expect(view.visible()).toBe(true);
+ expect(view.coordinate()).toBe(100);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts b/src/core/Drawings/__tests__/drawings/axis/CustomPriceAxis.test.ts
similarity index 55%
rename from src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts
rename to src/core/Drawings/__tests__/drawings/axis/CustomPriceAxis.test.ts
index 1a259067c5fc13feac359bffa2d0647dc9e30e08..9ca991de84ce05efc1d6ecaa5d381ae1cb14176d 100644
--- a/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts
+++ b/src/core/Drawings/__tests__/drawings/axis/CustomPriceAxis.test.ts
@@ -23,18 +23,41 @@ function createTarget(verticalPixelRatio = 2) {
context,
horizontalPixelRatio: 2,
verticalPixelRatio,
- bitmapSize: { width: 300, height: 200 },
+ bitmapSize: {
+ width: 300,
+ height: 200,
+ },
});
},
);
return {
context,
- target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+ target: {
+ useBitmapCoordinateSpace,
+ } as unknown as CanvasRenderingTarget2D,
useBitmapCoordinateSpace,
};
}
+function createViewport(width = 400, height = 300): HTMLElement {
+ const viewport = document.createElement('div');
+
+ jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
+ width,
+ height,
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: height,
+ toJSON: () => ({}),
+ });
+
+ return viewport;
+}
+
describe('CustomPriceAxisPaneView', () => {
it('должен использовать bottom zOrder по умолчанию', () => {
const view = new CustomPriceAxisPaneView({
@@ -61,6 +84,18 @@ describe('CustomPriceAxisPaneView', () => {
expect(view.renderer()).toBe(view.renderer());
});
+ it('update не должен пересоздавать renderer', () => {
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ const renderer = view.renderer();
+
+ view.update();
+
+ expect(view.renderer()).toBe(renderer);
+ });
+
it('не должен рисовать при отсутствии сегментов', () => {
const { target, useBitmapCoordinateSpace } = createTarget();
@@ -94,6 +129,24 @@ describe('CustomPriceAxisPaneView', () => {
expect(context.restore).toHaveBeenCalledTimes(1);
});
+ it('должен учитывать verticalPixelRatio', () => {
+ const { context, target } = createTarget(3);
+
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 10,
+ to: 30,
+ color: '#ff0000',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(0, 30, 300, 60);
+ });
+
it('должен корректно рисовать сегмент при обратном порядке координат', () => {
const { context, target } = createTarget();
@@ -133,56 +186,104 @@ describe('CustomPriceAxisPaneView', () => {
view.renderer().draw(target);
expect(context.fillRect).toHaveBeenNthCalledWith(1, 0, 20, 300, 20);
+
+ expect(context.fillRect).toHaveBeenNthCalledWith(2, 0, 60, 300, 40);
+
+ expect(context.save).toHaveBeenCalledTimes(1);
+ expect(context.restore).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен получать актуальные segments при каждом draw', () => {
+ const { context, target } = createTarget();
+
+ const getAxisSegments = jest
+ .fn()
+ .mockReturnValueOnce([
+ {
+ from: 10,
+ to: 20,
+ color: '#ff0000',
+ },
+ ])
+ .mockReturnValueOnce([
+ {
+ from: 30,
+ to: 50,
+ color: '#00ff00',
+ },
+ ]);
+
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments,
+ });
+
+ view.renderer().draw(target);
+ view.renderer().draw(target);
+
+ expect(getAxisSegments).toHaveBeenCalledTimes(2);
+
+ expect(context.fillRect).toHaveBeenNthCalledWith(1, 0, 20, 300, 20);
+
expect(context.fillRect).toHaveBeenNthCalledWith(2, 0, 60, 300, 40);
});
});
describe('CustomPriceAxisView', () => {
- it('должен возвращать данные label', () => {
- const getAxisLabel = jest.fn(() => ({
- coordinate: 125,
- text: '100.50',
- textColor: '#ffffff',
- backgroundColor: '#000000',
- }));
-
+ it('должен использовать height viewport для проверки видимости', () => {
const view = new CustomPriceAxisView({
- getAxisLabel,
+ getAxisLabel: () => ({
+ coordinate: 250,
+ text: '100.50',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
labelKind: 'entry',
+ viewport: createViewport(100, 300),
});
expect(view.visible()).toBe(true);
- expect(view.tickVisible()).toBe(false);
- expect(view.coordinate()).toBe(125);
- expect(view.text()).toBe('100.50');
- expect(view.textColor()).toBe('#ffffff');
- expect(view.backColor()).toBe('#000000');
- expect(getAxisLabel).toHaveBeenCalledWith('entry');
});
- it('должен возвращать значения по умолчанию при отсутствии label', () => {
+ it('не должен использовать width viewport для проверки видимости', () => {
const view = new CustomPriceAxisView({
- getAxisLabel: () => null,
+ getAxisLabel: () => ({
+ coordinate: 150,
+ text: '100.50',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
labelKind: 'entry',
+ viewport: createViewport(400, 100),
});
expect(view.visible()).toBe(false);
- expect(view.tickVisible()).toBe(false);
- expect(view.coordinate()).toBe(0);
- expect(view.text()).toBe('');
- expect(view.textColor()).toBe('');
- expect(view.backColor()).toBe('');
});
- it('не должен быть видимым при пустом тексте', () => {
+ it('должен считать нижнюю границу height видимой', () => {
+ const view = new CustomPriceAxisView({
+ getAxisLabel: () => ({
+ coordinate: 300,
+ text: '100.50',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
+ labelKind: 'entry',
+ viewport: createViewport(100, 300),
+ });
+
+ expect(view.visible()).toBe(true);
+ });
+
+ it('не должен показывать label ниже height viewport', () => {
const view = new CustomPriceAxisView({
getAxisLabel: () => ({
- coordinate: 100,
- text: '',
+ coordinate: 301,
+ text: '100.50',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'entry',
+ viewport: createViewport(1000, 300),
});
expect(view.visible()).toBe(false);
diff --git a/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts b/src/core/Drawings/__tests__/drawings/axis/CustomTimeAxis.test.ts
similarity index 55%
rename from src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts
rename to src/core/Drawings/__tests__/drawings/axis/CustomTimeAxis.test.ts
index 570590f9d8f72ac6d643d519c0ab8c89abbe8292..766ea651d4f77eae3e34920c43ef188eed2e8666 100644
--- a/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts
+++ b/src/core/Drawings/__tests__/drawings/axis/CustomTimeAxis.test.ts
@@ -23,18 +23,41 @@ function createTarget(horizontalPixelRatio = 2) {
context,
horizontalPixelRatio,
verticalPixelRatio: 2,
- bitmapSize: { width: 300, height: 200 },
+ bitmapSize: {
+ width: 300,
+ height: 200,
+ },
});
},
);
return {
context,
- target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+ target: {
+ useBitmapCoordinateSpace,
+ } as unknown as CanvasRenderingTarget2D,
useBitmapCoordinateSpace,
};
}
+function createViewport(width = 400, height = 300): HTMLElement {
+ const viewport = document.createElement('div');
+
+ jest.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
+ width,
+ height,
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: height,
+ toJSON: () => ({}),
+ });
+
+ return viewport;
+}
+
describe('CustomTimeAxisPaneView', () => {
it('должен использовать bottom zOrder по умолчанию', () => {
const view = new CustomTimeAxisPaneView({
@@ -61,6 +84,18 @@ describe('CustomTimeAxisPaneView', () => {
expect(view.renderer()).toBe(view.renderer());
});
+ it('update не должен пересоздавать renderer', () => {
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ const renderer = view.renderer();
+
+ view.update();
+
+ expect(view.renderer()).toBe(renderer);
+ });
+
it('не должен рисовать при отсутствии сегментов', () => {
const { target, useBitmapCoordinateSpace } = createTarget();
@@ -94,6 +129,24 @@ describe('CustomTimeAxisPaneView', () => {
expect(context.restore).toHaveBeenCalledTimes(1);
});
+ it('должен учитывать horizontalPixelRatio', () => {
+ const { context, target } = createTarget(3);
+
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 10,
+ to: 30,
+ color: '#ff0000',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(30, 0, 60, 200);
+ });
+
it('должен корректно рисовать сегмент при обратном порядке координат', () => {
const { context, target } = createTarget();
@@ -133,56 +186,104 @@ describe('CustomTimeAxisPaneView', () => {
view.renderer().draw(target);
expect(context.fillRect).toHaveBeenNthCalledWith(1, 20, 0, 20, 200);
+
+ expect(context.fillRect).toHaveBeenNthCalledWith(2, 60, 0, 40, 200);
+
+ expect(context.save).toHaveBeenCalledTimes(1);
+ expect(context.restore).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен получать актуальные segments при каждом draw', () => {
+ const { context, target } = createTarget();
+
+ const getAxisSegments = jest
+ .fn()
+ .mockReturnValueOnce([
+ {
+ from: 10,
+ to: 20,
+ color: '#ff0000',
+ },
+ ])
+ .mockReturnValueOnce([
+ {
+ from: 30,
+ to: 50,
+ color: '#00ff00',
+ },
+ ]);
+
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments,
+ });
+
+ view.renderer().draw(target);
+ view.renderer().draw(target);
+
+ expect(getAxisSegments).toHaveBeenCalledTimes(2);
+
+ expect(context.fillRect).toHaveBeenNthCalledWith(1, 20, 0, 20, 200);
+
expect(context.fillRect).toHaveBeenNthCalledWith(2, 60, 0, 40, 200);
});
});
describe('CustomTimeAxisView', () => {
- it('должен возвращать данные label', () => {
- const getAxisLabel = jest.fn(() => ({
- coordinate: 150,
- text: '09:30',
- textColor: '#ffffff',
- backgroundColor: '#000000',
- }));
-
+ it('должен использовать width viewport для проверки видимости', () => {
const view = new CustomTimeAxisView({
- getAxisLabel,
+ getAxisLabel: () => ({
+ coordinate: 250,
+ text: '09:30',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
labelKind: 'start',
+ viewport: createViewport(300, 100),
});
expect(view.visible()).toBe(true);
- expect(view.tickVisible()).toBe(false);
- expect(view.coordinate()).toBe(150);
- expect(view.text()).toBe('09:30');
- expect(view.textColor()).toBe('#ffffff');
- expect(view.backColor()).toBe('#000000');
- expect(getAxisLabel).toHaveBeenCalledWith('start');
});
- it('должен возвращать значения по умолчанию при отсутствии label', () => {
+ it('не должен использовать height viewport для проверки видимости', () => {
const view = new CustomTimeAxisView({
- getAxisLabel: () => null,
+ getAxisLabel: () => ({
+ coordinate: 150,
+ text: '09:30',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
labelKind: 'start',
+ viewport: createViewport(100, 400),
});
expect(view.visible()).toBe(false);
- expect(view.tickVisible()).toBe(false);
- expect(view.coordinate()).toBe(0);
- expect(view.text()).toBe('');
- expect(view.textColor()).toBe('');
- expect(view.backColor()).toBe('');
});
- it('не должен быть видимым при пустом тексте', () => {
+ it('должен считать правую границу width видимой', () => {
const view = new CustomTimeAxisView({
getAxisLabel: () => ({
- coordinate: 150,
- text: '',
+ coordinate: 300,
+ text: '09:30',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
+ labelKind: 'start',
+ viewport: createViewport(300, 100),
+ });
+
+ expect(view.visible()).toBe(true);
+ });
+
+ it('не должен показывать label правее width viewport', () => {
+ const view = new CustomTimeAxisView({
+ getAxisLabel: () => ({
+ coordinate: 301,
+ text: '09:30',
textColor: '#ffffff',
backgroundColor: '#000000',
}),
labelKind: 'start',
+ viewport: createViewport(300, 1000),
});
expect(view.visible()).toBe(false);
diff --git a/src/core/Drawings/__tests__/drawings/axisLine.test.ts b/src/core/Drawings/__tests__/drawings/axisLine.test.ts
index ff40e0f8c8614a5bcf6c1f65c11e9f375aa37222..fcf950e873c85e85302dfb50b927a84dc1bd28f0 100644
--- a/src/core/Drawings/__tests__/drawings/axisLine.test.ts
+++ b/src/core/Drawings/__tests__/drawings/axisLine.test.ts
@@ -25,19 +25,8 @@ jest.mock('@src/theme', () => ({
}),
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date-format',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => String(value),
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: () => 'formatted-date',
}));
@@ -310,17 +299,118 @@ describe('AxisLine', () => {
});
});
- it('не должен возвращать label для unselected line', () => {
+ it('должен сохранять time label для unselected vertical line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 80 as Time,
+ });
+
+ expect(drawing.timeLabel('main')).toEqual({
+ coordinate: 80,
+ text: 'formatted-date',
+ textColor: '#ffffff',
+ backgroundColor: '#112233',
+ });
+ });
+
+ it('должен сохранять price label для unselected horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ mode: 'ready',
+ price: 120,
+ });
+
+ expect(drawing.priceLabel('main')).toEqual({
+ coordinate: 120,
+ text: '120',
+ textColor: '#ffffff',
+ backgroundColor: '#112233',
+ });
+ });
+
+ it('не должен возвращать time label для hidden vertical line', () => {
const { drawing } = createAxisLine('vertical');
drawing.setState({
+ hidden: true,
+ mode: 'ready',
+ time: 80 as Time,
+ });
+
+ expect(drawing.timeLabel('main')).toBeNull();
+ });
+
+ it('не должен возвращать price label для hidden horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ hidden: true,
mode: 'ready',
+ price: 120,
+ });
+
+ expect(drawing.priceLabel('main')).toBeNull();
+ });
+
+ it('не должен возвращать time label до завершения создания vertical line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'idle',
time: 80 as Time,
});
expect(drawing.timeLabel('main')).toBeNull();
});
+ it('не должен возвращать price label до завершения создания horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ mode: 'idle',
+ price: 120,
+ });
+
+ expect(drawing.priceLabel('main')).toBeNull();
+ });
+
+ it('не должен возвращать time label для horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ mode: 'ready',
+ price: 120,
+ });
+
+ expect(drawing.timeLabel('main')).toBeNull();
+ });
+
+ it('не должен возвращать price label для vertical line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 80 as Time,
+ });
+
+ expect(drawing.priceLabel('main')).toBeNull();
+ });
+
+ it('не должен возвращать label для неизвестного kind', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 80 as Time,
+ });
+
+ expect(drawing.timeLabel('unknown')).toBeNull();
+ expect(drawing.priceLabel('unknown')).toBeNull();
+ });
+
it('должен определять hover vertical line', () => {
const { drawing } = createAxisLine('vertical');
diff --git a/src/core/Drawings/__tests__/drawings/base/areaDrawingBase.test.ts b/src/core/Drawings/__tests__/drawings/base/areaDrawingBase.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..429e4a556fe1723746cdb138e16f456f47daeae3
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/base/areaDrawingBase.test.ts
@@ -0,0 +1,408 @@
+import { AreaDrawingBase } from '@src/core/Drawings/base';
+
+import { createDrawingTestEnvironment, createPointerEvent } from '../../drawingTestUtils';
+
+import type { Anchor, AxisLabel, Bounds, Point } from '@core/Drawings/types';
+import type { SettingsTab, SettingsValues } from '@src/types';
+import type { PrimitiveHoveredItem } from 'lightweight-charts';
+
+type HandleId = 'start' | 'end';
+
+type TestMode = 'idle' | 'drawing' | 'ready' | 'dragging';
+
+class TestAreaDrawingBase extends AreaDrawingBase<SettingsValues, HandleId> {
+ protected mode: TestMode = 'idle';
+ protected settings: SettingsValues = {};
+
+ protected dragPointerId: number | null = null;
+ protected activeDragTarget: HandleId | 'body' | null = null;
+
+ public readonly finishDrawingMock = jest.fn();
+ public readonly startDraggingMock = jest.fn();
+ public readonly moveWholeMock = jest.fn();
+ public readonly resizeMock = jest.fn();
+
+ private geometry: Bounds | null = null;
+ private containsPointValue = false;
+ private dragTarget: HandleId | 'body' | null = null;
+
+ public getRenderData(): Bounds | null {
+ return this.geometry;
+ }
+
+ public getState() {
+ return {
+ mode: this.mode,
+ startAnchor: this.startAnchor,
+ endAnchor: this.endAnchor,
+ };
+ }
+
+ public setState(): void {}
+
+ public getSettingsTabs(): SettingsTab[] {
+ return [];
+ }
+
+ public isCreationPending(): boolean {
+ return this.mode === 'idle' || this.mode === 'drawing';
+ }
+
+ public setMode(mode: TestMode): void {
+ this.mode = mode;
+ }
+
+ public setGeometry(geometry: Bounds | null): void {
+ this.geometry = geometry;
+ }
+
+ public setContainsPoint(value: boolean): void {
+ this.containsPointValue = value;
+ }
+
+ public setTestDragTarget(target: HandleId | 'body' | null): void {
+ this.dragTarget = target;
+ }
+
+ public setDragState(target: HandleId | 'body' | null, pointerId: number | null): void {
+ this.activeDragTarget = target;
+ this.dragPointerId = pointerId;
+ }
+
+ public start(point: Point): void {
+ this.startDrawing(point);
+ }
+
+ public update(point: Point): void {
+ this.updateDrawing(point);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public getTestAnchors(): {
+ startAnchor: Anchor | null;
+ endAnchor: Anchor | null;
+ } {
+ return {
+ startAnchor: this.startAnchor,
+ endAnchor: this.endAnchor,
+ };
+ }
+
+ public getTimeSegments() {
+ return this.getTimeAxisSegments();
+ }
+
+ public getPriceSegments() {
+ return this.getPriceAxisSegments();
+ }
+
+ public getBoundsLabel(kind: string, kinds: readonly ('left' | 'right' | 'top' | 'bottom')[]): AxisLabel | null {
+ return this.getBoundsAxisLabel(kind, kinds, (labelKind) => String(labelKind));
+ }
+
+ protected finishDrawing(): void {
+ this.finishDrawingMock();
+ }
+
+ protected containsPoint(): boolean {
+ return this.containsPointValue;
+ }
+
+ protected getDragTarget(): HandleId | 'body' | null {
+ return this.dragTarget;
+ }
+
+ protected startDragging(point: Point, pointerId: number, target: HandleId | 'body'): void {
+ this.startDraggingMock(point, pointerId, target);
+
+ this.activeDragTarget = target;
+
+ this.dragPointerId = pointerId;
+
+ this.mode = 'dragging';
+ }
+
+ protected moveWhole(point: Point): void {
+ this.moveWholeMock(point);
+ }
+
+ protected resize(point: Point): void {
+ this.resizeMock(point);
+ }
+
+ protected getGeometry(): Bounds | null {
+ return this.geometry;
+ }
+
+ protected getHoveredItem(): PrimitiveHoveredItem | null {
+ return null;
+ }
+
+ protected getTimeAxisLabel(): AxisLabel | null {
+ return null;
+ }
+
+ protected getPriceAxisLabel(): AxisLabel | null {
+ return null;
+ }
+}
+
+function createDrawing(selected = false) {
+ const env = createDrawingTestEnvironment({
+ selected,
+ });
+
+ const drawing = new TestAreaDrawingBase(env);
+
+ return {
+ ...env,
+ drawing,
+ };
+}
+
+describe('AreaDrawingBase', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('drawing lifecycle', () => {
+ it('должен начинать drawing с одинаковых anchors', () => {
+ const { drawing } = createDrawing();
+
+ drawing.start({
+ x: 20,
+ y: 30,
+ });
+
+ expect(drawing.getState()).toEqual({
+ mode: 'drawing',
+ startAnchor: {
+ time: 20,
+ price: 30,
+ },
+ endAnchor: {
+ time: 20,
+ price: 30,
+ },
+ });
+ });
+
+ it('должен обновлять end anchor с clamp по container', () => {
+ const { drawing } = createDrawing();
+
+ drawing.start({
+ x: 20,
+ y: 30,
+ });
+
+ drawing.update({
+ x: 500,
+ y: -50,
+ });
+
+ expect(drawing.getTestAnchors().endAnchor).toEqual({
+ time: 400,
+ price: 0,
+ });
+ });
+
+ it('должен начинать drawing через первый pointerDown', () => {
+ const { drawing, container } = createDrawing();
+
+ const event = createPointerEvent(container, 20, 30);
+
+ drawing.pointerDown(event);
+
+ expect(drawing.getState().mode).toBe('drawing');
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+
+ expect(event.stopPropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен завершать drawing через второй pointerDown', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.start({
+ x: 20,
+ y: 30,
+ });
+
+ drawing.pointerDown(createPointerEvent(container, 100, 120));
+
+ expect(drawing.finishDrawingMock).toHaveBeenCalledTimes(1);
+
+ expect(drawing.getTestAnchors().endAnchor).toEqual({
+ time: 100,
+ price: 120,
+ });
+ });
+ });
+
+ describe('selection and dragging', () => {
+ it('должен только выбрать ready drawing при первом pointerDown', () => {
+ const { drawing, container, interaction } = createDrawing();
+
+ drawing.setMode('ready');
+ drawing.setContainsPoint(true);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 60));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+
+ expect(drawing.startDraggingMock).not.toHaveBeenCalled();
+ });
+
+ it('должен deselect selected drawing при pointerDown вне drag target', () => {
+ const { drawing, container, interaction } = createDrawing(true);
+
+ drawing.setMode('ready');
+ drawing.setTestDragTarget(null);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 60));
+
+ expect(interaction.deselect).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен начинать drag выбранного target', () => {
+ const { drawing, container } = createDrawing(true);
+
+ drawing.setMode('ready');
+ drawing.setTestDragTarget('start');
+
+ const event = createPointerEvent(container, 50, 60, 7);
+
+ drawing.pointerDown(event);
+
+ expect(drawing.startDraggingMock).toHaveBeenCalledWith(
+ {
+ x: 50,
+ y: 60,
+ },
+ 7,
+ 'start',
+ );
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+
+ expect(event.stopPropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен двигать body во время body drag', () => {
+ const { drawing, container } = createDrawing(true);
+
+ drawing.setMode('dragging');
+ drawing.setDragState('body', 5);
+
+ const event = createPointerEvent(container, 80, 90, 5);
+
+ drawing.pointerMove(event);
+
+ expect(drawing.moveWholeMock).toHaveBeenCalledWith({
+ x: 80,
+ y: 90,
+ });
+
+ expect(drawing.resizeMock).not.toHaveBeenCalled();
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен resize handle во время handle drag', () => {
+ const { drawing, container } = createDrawing(true);
+
+ drawing.setMode('dragging');
+ drawing.setDragState('end', 5);
+
+ drawing.pointerMove(createPointerEvent(container, 80, 90, 5));
+
+ expect(drawing.resizeMock).toHaveBeenCalledWith({
+ x: 80,
+ y: 90,
+ });
+
+ expect(drawing.moveWholeMock).not.toHaveBeenCalled();
+ });
+
+ it('не должен обрабатывать drag другого pointerId', () => {
+ const { drawing, container } = createDrawing(true);
+
+ drawing.setMode('dragging');
+ drawing.setDragState('body', 5);
+
+ drawing.pointerMove(createPointerEvent(container, 80, 90, 6));
+
+ expect(drawing.moveWholeMock).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('axis', () => {
+ it('должен строить time и price segments по geometry', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ drawing.setGeometry({
+ left: 10,
+ right: 100,
+ top: 20,
+ bottom: 80,
+ });
+
+ setSelected(true);
+
+ expect(drawing.getTimeSegments()[0]).toMatchObject({
+ from: 10,
+ to: 100,
+ });
+
+ expect(drawing.getPriceSegments()[0]).toMatchObject({
+ from: 20,
+ to: 80,
+ });
+ });
+
+ it('должен создавать label из соответствующего geometry bound', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ drawing.setGeometry({
+ left: 10,
+ right: 100,
+ top: 20,
+ bottom: 80,
+ });
+
+ setSelected(true);
+
+ expect(drawing.getBoundsLabel('left', ['left', 'right'])).toMatchObject({
+ coordinate: 10,
+ text: 'left',
+ });
+
+ expect(drawing.getBoundsLabel('bottom', ['top', 'bottom'])).toMatchObject({
+ coordinate: 80,
+ text: 'bottom',
+ });
+ });
+
+ it('не должен создавать label для неизвестного kind', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ drawing.setGeometry({
+ left: 10,
+ right: 100,
+ top: 20,
+ bottom: 80,
+ });
+
+ setSelected(true);
+
+ expect(drawing.getBoundsLabel('unknown', ['left', 'right'])).toBeNull();
+ });
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/base/drawingBase.test.ts b/src/core/Drawings/__tests__/drawings/base/drawingBase.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..220ba35707a5ee8cc0d6f2b5f77361f695e2756f
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/base/drawingBase.test.ts
@@ -0,0 +1,597 @@
+import { DrawingBase } from '@src/core/Drawings/base';
+
+import { createDrawingTestEnvironment, createMouseEvent, createPointerEvent } from '../../drawingTestUtils';
+
+import type { Anchor, AxisLabel, AxisSegment, Bounds, Point, UpdatableView } from '@core/Drawings/types';
+import type { SettingsTab, SettingsValues } from '@src/types';
+import type {
+ IPrimitivePaneView,
+ PrimitiveHoveredItem,
+ SeriesAttachedParameter,
+ SeriesOptionsMap,
+ Time,
+} from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartPriceLineText: '#ffffff',
+ axisMarkerLabelFill: '#112233',
+ axisMarkerAreaFill: '#445566',
+ },
+ }),
+}));
+
+type TestMode = 'idle' | 'ready';
+
+class TestDrawingBase extends DrawingBase<SettingsValues, 'main'> {
+ protected mode: TestMode = 'ready';
+ protected settings: SettingsValues = {
+ lineWidth: 1,
+ };
+
+ private geometry: Bounds | null = null;
+ private hoveredItem: PrimitiveHoveredItem | null = null;
+
+ public getRenderData(): Bounds | null {
+ return this.geometry;
+ }
+
+ public getState() {
+ return {
+ hidden: this.hidden,
+ mode: this.mode,
+ settings: this.settings,
+ };
+ }
+
+ public setState(state: unknown): void {
+ if (!state || typeof state !== 'object') {
+ return;
+ }
+
+ const nextState = state as Partial<{
+ hidden: boolean;
+ mode: TestMode;
+ }>;
+
+ if (typeof nextState.hidden === 'boolean') {
+ this.hidden = nextState.hidden;
+ }
+
+ if (nextState.mode) {
+ this.mode = nextState.mode;
+ }
+ }
+
+ public getSettingsTabs(): SettingsTab[] {
+ return [];
+ }
+
+ public isCreationPending(): boolean {
+ return this.mode === 'idle';
+ }
+
+ public setMode(mode: TestMode): void {
+ this.mode = mode;
+ }
+
+ public setGeometry(geometry: Bounds | null): void {
+ this.geometry = geometry;
+ }
+
+ public setHoveredItem(item: PrimitiveHoveredItem | null): void {
+ this.hoveredItem = item;
+ }
+
+ public createAnchor(point: Point): Anchor | null {
+ return this.createAnchorFromPoint(point);
+ }
+
+ public getAnchorPoint(anchor: Anchor | null): Point | null {
+ return this.getPointFromAnchor(anchor);
+ }
+
+ public createLabel(
+ coordinate: number | null,
+ text: string,
+ style?: Partial<Pick<AxisLabel, 'textColor' | 'backgroundColor'>>,
+ ): AxisLabel | null {
+ return this.createAxisLabel(coordinate, text, style);
+ }
+
+ public createSegment(from: number, to: number, color?: string): AxisSegment {
+ return this.createAxisSegment(from, to, color);
+ }
+
+ public getBoundsSegments(axis: 'time' | 'price'): AxisSegment[] {
+ return this.getBoundsAxisSegments(axis, () => this.geometry);
+ }
+
+ public getInteractiveLabel(
+ kind: string,
+ kinds: readonly string[],
+ getLabel: (kind: string) => AxisLabel | null,
+ ): AxisLabel | null {
+ return this.getInteractiveAxisLabel(kind, kinds, getLabel);
+ }
+
+ public consume(event: MouseEvent): void {
+ this.consumeEvent(event);
+ }
+
+ public selectOnDown(event: PointerEvent, isHit: () => boolean): boolean {
+ return this.selectOnPointerDown(event, isHit);
+ }
+
+ public openSettings(
+ event: MouseEvent,
+ isHit: (point: Point) => boolean,
+ callback: (() => void) | undefined,
+ selectedOnly = false,
+ ): void {
+ this.openSettingsOnDoubleClick(event, isHit, callback, selectedOnly);
+ }
+
+ public initialize(
+ paneView: IPrimitivePaneView & UpdatableView,
+ timeLabels: readonly string[],
+ priceLabels: readonly string[],
+ ): void {
+ this.initializeViews(paneView, timeLabels, priceLabels);
+ }
+
+ public triggerRender(): void {
+ this.render();
+ }
+
+ protected getHoveredItem(): PrimitiveHoveredItem | null {
+ return this.hoveredItem;
+ }
+
+ protected getGeometry(): Bounds | null {
+ return this.geometry;
+ }
+
+ protected getTimeAxisSegments(): AxisSegment[] {
+ return [];
+ }
+
+ protected getPriceAxisSegments(): AxisSegment[] {
+ return [];
+ }
+
+ protected getTimeAxisLabel(): AxisLabel | null {
+ return null;
+ }
+
+ protected getPriceAxisLabel(): AxisLabel | null {
+ return null;
+ }
+}
+
+function createDrawing(options: Parameters<typeof createDrawingTestEnvironment>[0] = {}) {
+ const env = createDrawingTestEnvironment(options);
+ const drawing = new TestDrawingBase(env);
+
+ return {
+ ...env,
+ drawing,
+ };
+}
+
+function attachDrawing(
+ drawing: TestDrawingBase,
+ env: ReturnType<typeof createDrawingTestEnvironment>,
+ requestUpdate = jest.fn(),
+): jest.Mock {
+ drawing.attached({
+ chart: env.chart,
+ series: env.series,
+ requestUpdate,
+ } as unknown as SeriesAttachedParameter<Time, keyof SeriesOptionsMap>);
+
+ requestUpdate.mockClear();
+
+ return requestUpdate;
+}
+
+describe('DrawingBase', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('anchors', () => {
+ it('должен создавать anchor из point', () => {
+ const { drawing } = createDrawing();
+
+ expect(
+ drawing.createAnchor({
+ x: 120,
+ y: 80,
+ }),
+ ).toEqual({
+ time: 120,
+ price: 80,
+ });
+ });
+
+ it('должен возвращать null, если anchor создать невозможно', () => {
+ const { drawing, timeScale } = createDrawing();
+
+ timeScale.coordinateToTime.mockReturnValue(null);
+
+ expect(
+ drawing.createAnchor({
+ x: 120,
+ y: 80,
+ }),
+ ).toBeNull();
+ });
+
+ it('должен преобразовывать anchor в point без округления', () => {
+ const { drawing } = createDrawing();
+
+ expect(
+ drawing.getAnchorPoint({
+ time: 120.25 as Time,
+ price: 80.75,
+ }),
+ ).toEqual({
+ x: 120.25,
+ y: 80.75,
+ });
+ });
+
+ it('должен возвращать null для пустого anchor', () => {
+ const { drawing } = createDrawing();
+
+ expect(drawing.getAnchorPoint(null)).toBeNull();
+ });
+ });
+
+ describe('axis helpers', () => {
+ it('должен создавать axis label с theme цветами', () => {
+ const { drawing } = createDrawing();
+
+ expect(drawing.createLabel(100, '100')).toEqual({
+ coordinate: 100,
+ text: '100',
+ textColor: '#ffffff',
+ backgroundColor: '#112233',
+ });
+ });
+
+ it('должен поддерживать пользовательские цвета axis label', () => {
+ const { drawing } = createDrawing();
+
+ expect(
+ drawing.createLabel(100, '100', {
+ textColor: '#111111',
+ backgroundColor: '#222222',
+ }),
+ ).toEqual({
+ coordinate: 100,
+ text: '100',
+ textColor: '#111111',
+ backgroundColor: '#222222',
+ });
+ });
+
+ it('должен возвращать null для label без coordinate', () => {
+ const { drawing } = createDrawing();
+
+ expect(drawing.createLabel(null, '100')).toBeNull();
+ });
+
+ it('должен возвращать null для label без текста', () => {
+ const { drawing } = createDrawing();
+
+ expect(drawing.createLabel(100, '')).toBeNull();
+ });
+
+ it('должен создавать axis segment', () => {
+ const { drawing } = createDrawing();
+
+ expect(drawing.createSegment(10, 20)).toEqual({
+ from: 10,
+ to: 20,
+ color: '#445566',
+ });
+ });
+
+ it('должен строить time segment по bounds только для interactive axis', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ drawing.setGeometry({
+ left: 10,
+ right: 120,
+ top: 30,
+ bottom: 80,
+ });
+
+ expect(drawing.getBoundsSegments('time')).toEqual([]);
+
+ setSelected(true);
+
+ expect(drawing.getBoundsSegments('time')).toEqual([
+ {
+ from: 10,
+ to: 120,
+ color: '#445566',
+ },
+ ]);
+ });
+
+ it('должен строить price segment по bounds', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ drawing.setGeometry({
+ left: 10,
+ right: 120,
+ top: 30,
+ bottom: 80,
+ });
+
+ setSelected(true);
+
+ expect(drawing.getBoundsSegments('price')).toEqual([
+ {
+ from: 30,
+ to: 80,
+ color: '#445566',
+ },
+ ]);
+ });
+
+ it('должен показывать interactive axis во время создания', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setGeometry({
+ left: 10,
+ right: 120,
+ top: 30,
+ bottom: 80,
+ });
+
+ drawing.setMode('idle');
+
+ expect(drawing.getBoundsSegments('time')).toHaveLength(1);
+ });
+
+ it('должен возвращать interactive label только для разрешённого kind', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ setSelected(true);
+
+ const getLabel = jest.fn(
+ (kind: string): AxisLabel => ({
+ coordinate: 100,
+ text: kind,
+ textColor: '#ffffff',
+ backgroundColor: '#112233',
+ }),
+ );
+
+ expect(drawing.getInteractiveLabel('start', ['start', 'end'], getLabel)).toEqual({
+ coordinate: 100,
+ text: 'start',
+ textColor: '#ffffff',
+ backgroundColor: '#112233',
+ });
+
+ expect(drawing.getInteractiveLabel('unknown', ['start', 'end'], getLabel)).toBeNull();
+ });
+ });
+
+ describe('views', () => {
+ it('должен регистрировать pane и axis views через initializeViews', () => {
+ const { drawing } = createDrawing();
+
+ const paneView = {
+ update: jest.fn(),
+ renderer: jest.fn(),
+ zOrder: jest.fn(),
+ } as unknown as IPrimitivePaneView & UpdatableView;
+
+ drawing.initialize(paneView, ['start', 'end'], ['price']);
+
+ expect(drawing.paneViews()).toEqual([paneView]);
+
+ expect(drawing.timeAxisPaneViews()).toHaveLength(1);
+
+ expect(drawing.priceAxisPaneViews()).toHaveLength(1);
+
+ expect(drawing.timeAxisViews()).toHaveLength(2);
+
+ expect(drawing.priceAxisViews()).toHaveLength(1);
+ });
+
+ it('должен обновлять зарегистрированный pane view', () => {
+ const { drawing } = createDrawing();
+
+ const paneView = {
+ update: jest.fn(),
+ renderer: jest.fn(),
+ zOrder: jest.fn(),
+ } as unknown as IPrimitivePaneView & UpdatableView;
+
+ drawing.initialize(paneView, [], []);
+
+ drawing.updateAllViews();
+
+ expect(paneView.update).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('settings', () => {
+ it('должен сразу отдавать текущие settings при подписке', () => {
+ const { drawing } = createDrawing();
+ const callback = jest.fn();
+
+ drawing.subscribeSettings(callback);
+
+ expect(callback).toHaveBeenCalledWith({
+ lineWidth: 1,
+ });
+ });
+
+ it('должен уведомлять подписчика после updateSettings', () => {
+ const { drawing } = createDrawing();
+ const callback = jest.fn();
+
+ drawing.subscribeSettings(callback);
+
+ callback.mockClear();
+
+ drawing.updateSettings({
+ lineWidth: 3,
+ });
+
+ expect(callback).toHaveBeenCalledWith({
+ lineWidth: 3,
+ });
+ });
+ });
+
+ describe('render lifecycle', () => {
+ it('должен вызывать updateAllViews и requestUpdate после render', () => {
+ const env = createDrawingTestEnvironment();
+ const drawing = new TestDrawingBase(env);
+ const updateAllViews = jest.spyOn(drawing, 'updateAllViews');
+
+ const requestUpdate = attachDrawing(drawing, env);
+
+ updateAllViews.mockClear();
+
+ drawing.hide();
+
+ expect(drawing.getState().hidden).toBe(true);
+
+ expect(updateAllViews).toHaveBeenCalledTimes(1);
+
+ expect(requestUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен возвращать drawing после show', () => {
+ const { drawing } = createDrawing();
+
+ drawing.hide();
+ drawing.show();
+
+ expect(drawing.getState().hidden).toBe(false);
+ });
+
+ it('должен сразу завершать waitTillReady для ready drawing', async () => {
+ const { drawing } = createDrawing();
+
+ await expect(drawing.waitTillReady()).resolves.toBeUndefined();
+ });
+
+ it('должен считать idle drawing ожидающим создания', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setMode('idle');
+
+ expect(drawing.isCreationPending()).toBe(true);
+
+ expect(drawing.shouldShowInObjectTree()).toBe(false);
+ });
+ });
+
+ describe('interaction helpers', () => {
+ it('должен consume event', () => {
+ const { drawing, container } = createDrawing();
+
+ const event = createMouseEvent(container, 10, 20);
+
+ drawing.consume(event);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+
+ expect(event.stopPropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен выбирать drawing при pointerDown по нему', () => {
+ const { drawing, container, interaction } = createDrawing();
+
+ const event = createPointerEvent(container, 10, 20);
+
+ expect(drawing.selectOnDown(event, () => true)).toBe(true);
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+
+ expect(event.stopPropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен повторно обрабатывать selection для уже выбранного drawing', () => {
+ const { drawing, container, setSelected } = createDrawing();
+
+ setSelected(true);
+
+ const event = createPointerEvent(container, 10, 20);
+
+ expect(drawing.selectOnDown(event, () => true)).toBe(false);
+ });
+
+ it('должен открывать settings при double click по drawing', () => {
+ const { drawing, container } = createDrawing();
+
+ const openSettings = jest.fn();
+ const event = createMouseEvent(container, 30, 40, 2);
+
+ drawing.openSettings(event, () => true, openSettings);
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+
+ expect(event.preventDefault).toHaveBeenCalledTimes(1);
+
+ expect(event.stopPropagation).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен открывать selectedOnly settings для невыбранного drawing', () => {
+ const { drawing, container } = createDrawing();
+
+ const openSettings = jest.fn();
+ const event = createMouseEvent(container, 30, 40, 2);
+
+ drawing.openSettings(event, () => true, openSettings, true);
+
+ expect(openSettings).not.toHaveBeenCalled();
+ });
+
+ it('должен заменять cursor на pointer для locked drawing', () => {
+ const { drawing, setLocked } = createDrawing();
+
+ drawing.setHoveredItem({
+ cursorStyle: 'move',
+ externalId: 'test',
+ zOrder: 'top',
+ });
+
+ setLocked(true);
+
+ expect(drawing.hitTest(10, 20)).toEqual({
+ cursorStyle: 'pointer',
+ externalId: 'test',
+ zOrder: 'top',
+ });
+ });
+ });
+
+ describe('rebind', () => {
+ it('должен отвязывать drawing от старой series и привязывать к новой', () => {
+ const firstEnv = createDrawingTestEnvironment();
+ const secondEnv = createDrawingTestEnvironment();
+ const drawing = new TestDrawingBase(firstEnv);
+
+ drawing.rebind(secondEnv.series);
+
+ expect(firstEnv.series.detachPrimitive).toHaveBeenCalledWith(drawing);
+
+ expect(secondEnv.series.attachPrimitive).toHaveBeenCalledWith(drawing);
+ });
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/base/linearDrawingBase.test.ts b/src/core/Drawings/__tests__/drawings/base/linearDrawingBase.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bb2c04bbeb7e3bcd3f452fbd0cd9ff1e5a2852d8
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/base/linearDrawingBase.test.ts
@@ -0,0 +1,506 @@
+import { LinearDrawingBase, type TwoPointGeometry } from '@src/core/Drawings/base';
+
+import { createDrawingTestEnvironment, createPointerEvent } from '../../drawingTestUtils';
+
+import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
+import type { SettingsTab, SettingsValues } from '@src/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+type HandleId = 'start' | 'end';
+
+type TestMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
+
+interface TestState {
+ mode: TestMode;
+ startAnchor: Anchor | null;
+ endAnchor: Anchor | null;
+}
+
+class TestLinearDrawingBase extends LinearDrawingBase<SettingsValues, HandleId, TestState> {
+ protected mode: TestMode = 'ready';
+ protected settings: SettingsValues = {};
+
+ public readonly finishDrawingMock = jest.fn();
+ public readonly readyPointerDownMock = jest.fn();
+
+ private strokeColor: string | undefined;
+
+ public getRenderData(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
+
+ public getState(): TestState {
+ return {
+ mode: this.mode,
+ startAnchor: this.startAnchor,
+ endAnchor: this.endAnchor,
+ };
+ }
+
+ public setState(): void {}
+
+ public getSettingsTabs(): SettingsTab[] {
+ return [];
+ }
+
+ public isCreationPending(): boolean {
+ return this.mode === 'idle' || this.mode === 'drawing';
+ }
+
+ public setMode(mode: TestMode): void {
+ this.mode = mode;
+ }
+
+ public setTestAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
+ this.setAnchors(startAnchor, endAnchor);
+ }
+
+ public setStrokeColor(color: string | undefined): void {
+ this.strokeColor = color;
+ }
+
+ public getHandles() {
+ return this.getDrawingHandles();
+ }
+
+ public getTimeSegments() {
+ return this.getTimeAxisSegments();
+ }
+
+ public getPriceSegments() {
+ return this.getPriceAxisSegments();
+ }
+
+ public isNearLine(point: Point, tolerance: number): boolean {
+ return this.isPointNearLine(point, tolerance);
+ }
+
+ public start(point: Point): void {
+ this.startDrawing(point);
+ }
+
+ public update(point: Point): void {
+ this.updateDrawing(point);
+ }
+
+ public startDrag(mode: `dragging-${HandleId | 'body'}`, point: Point, pointerId: number): void {
+ this.startDragging(mode, point, pointerId);
+ }
+
+ public finishDrag(): void {
+ this.finishDragging();
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public finishLinear(geometry: TwoPointGeometry | null, minimumSize: number, removeSelf: () => void): void {
+ this.finishLinearDrawing(geometry, minimumSize, removeSelf);
+ }
+
+ public getDragSnapshot(): TestState | null {
+ return this.dragStateSnapshot;
+ }
+
+ public getDragPointerId(): number | null {
+ return this.dragPointerId;
+ }
+
+ public getDragStartPoint(): Point | null {
+ return this.dragStartPoint;
+ }
+
+ protected getStartHandleId(): HandleId {
+ return 'start';
+ }
+
+ protected getEndHandleId(): HandleId {
+ return 'end';
+ }
+
+ protected getLineHandleStrokeColor(): string | undefined {
+ return this.strokeColor;
+ }
+
+ protected finishDrawing(): void {
+ this.finishDrawingMock();
+ }
+
+ protected handleReadyPointerDown(event: PointerEvent, point: Point): void {
+ this.readyPointerDownMock(event, point);
+ }
+
+ protected getHoveredItem(): PrimitiveHoveredItem | null {
+ return null;
+ }
+
+ protected getGeometry(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
+
+ protected getTimeAxisLabel(): AxisLabel | null {
+ return null;
+ }
+
+ protected getPriceAxisLabel(): AxisLabel | null {
+ return null;
+ }
+}
+
+function createDrawing(selected = false) {
+ const env = createDrawingTestEnvironment({
+ selected,
+ });
+
+ const drawing = new TestLinearDrawingBase(env);
+
+ return {
+ ...env,
+ drawing,
+ };
+}
+
+function setAnchors(drawing: TestLinearDrawingBase): void {
+ drawing.setTestAnchors(
+ {
+ time: 10 as Time,
+ price: 20,
+ },
+ {
+ time: 110 as Time,
+ price: 120,
+ },
+ );
+}
+
+describe('LinearDrawingBase', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('handles', () => {
+ it('должен создавать start и end handles', () => {
+ const { drawing } = createDrawing();
+
+ setAnchors(drawing);
+
+ expect(drawing.getHandles()).toEqual([
+ {
+ id: 'start',
+ x: 10,
+ y: 20,
+ shape: 'circle',
+ },
+ {
+ id: 'end',
+ x: 110,
+ y: 120,
+ shape: 'circle',
+ },
+ ]);
+ });
+
+ it('должен применять strokeColor к handles', () => {
+ const { drawing } = createDrawing();
+
+ setAnchors(drawing);
+
+ drawing.setStrokeColor('#123456');
+
+ expect(drawing.getHandles()).toEqual([
+ {
+ id: 'start',
+ x: 10,
+ y: 20,
+ shape: 'circle',
+ strokeColor: '#123456',
+ },
+ {
+ id: 'end',
+ x: 110,
+ y: 120,
+ shape: 'circle',
+ strokeColor: '#123456',
+ },
+ ]);
+ });
+ });
+
+ describe('axis segments', () => {
+ it('не должен возвращать axis segments для ready unselected drawing', () => {
+ const { drawing } = createDrawing();
+
+ setAnchors(drawing);
+
+ expect(drawing.getTimeSegments()).toEqual([]);
+
+ expect(drawing.getPriceSegments()).toEqual([]);
+ });
+
+ it('должен возвращать axis segments для selected drawing', () => {
+ const { drawing, setSelected } = createDrawing();
+
+ setAnchors(drawing);
+ setSelected(true);
+
+ expect(drawing.getTimeSegments()).toHaveLength(1);
+
+ expect(drawing.getTimeSegments()[0]).toMatchObject({
+ from: 10,
+ to: 110,
+ });
+
+ expect(drawing.getPriceSegments()[0]).toMatchObject({
+ from: 20,
+ to: 120,
+ });
+ });
+
+ it('должен возвращать axis segments во время создания', () => {
+ const { drawing } = createDrawing();
+
+ setAnchors(drawing);
+ drawing.setMode('drawing');
+
+ expect(drawing.getTimeSegments()).toHaveLength(1);
+ });
+ });
+
+ describe('hit testing', () => {
+ it('должен определять point рядом с line', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setTestAnchors(
+ {
+ time: 0 as Time,
+ price: 0,
+ },
+ {
+ time: 100 as Time,
+ price: 0,
+ },
+ );
+
+ expect(
+ drawing.isNearLine(
+ {
+ x: 50,
+ y: 4,
+ },
+ 5,
+ ),
+ ).toBe(true);
+
+ expect(
+ drawing.isNearLine(
+ {
+ x: 50,
+ y: 10,
+ },
+ 5,
+ ),
+ ).toBe(false);
+ });
+ });
+
+ describe('drawing lifecycle', () => {
+ it('должен начинать drawing с одинаковых anchors', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setMode('idle');
+
+ drawing.start({
+ x: 20,
+ y: 30,
+ });
+
+ expect(drawing.getState()).toEqual({
+ mode: 'drawing',
+ startAnchor: {
+ time: 20,
+ price: 30,
+ },
+ endAnchor: {
+ time: 20,
+ price: 30,
+ },
+ });
+ });
+
+ it('должен обновлять end anchor во время drawing', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setMode('idle');
+
+ drawing.start({
+ x: 20,
+ y: 30,
+ });
+
+ drawing.update({
+ x: 80,
+ y: 90,
+ });
+
+ expect(drawing.getState().endAnchor).toEqual({
+ time: 80,
+ price: 90,
+ });
+ });
+
+ it('должен вызывать finishDrawing вторым pointerDown', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.setMode('idle');
+
+ drawing.pointerDown(createPointerEvent(container, 10, 20));
+
+ drawing.pointerDown(createPointerEvent(container, 100, 120));
+
+ expect(drawing.finishDrawingMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен передавать ready pointerDown concrete drawing', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.setMode('ready');
+
+ const event = createPointerEvent(container, 40, 50);
+
+ drawing.pointerDown(event);
+
+ expect(drawing.readyPointerDownMock).toHaveBeenCalledWith(event, {
+ x: 40,
+ y: 50,
+ });
+ });
+ });
+
+ describe('dragging', () => {
+ it('должен сохранять state snapshot при начале drag', () => {
+ const { drawing, chart } = createDrawing();
+
+ setAnchors(drawing);
+
+ drawing.startDrag(
+ 'dragging-body',
+ {
+ x: 50,
+ y: 60,
+ },
+ 7,
+ );
+
+ expect(drawing.getDragPointerId()).toBe(7);
+
+ expect(drawing.getDragStartPoint()).toEqual({
+ x: 50,
+ y: 60,
+ });
+
+ expect(drawing.getDragSnapshot()).toEqual({
+ mode: 'dragging-body',
+ startAnchor: {
+ time: 10,
+ price: 20,
+ },
+ endAnchor: {
+ time: 110,
+ price: 120,
+ },
+ });
+
+ expect(chart.applyOptions).toHaveBeenCalledWith({
+ crosshair: {
+ mode: expect.any(Number),
+ },
+ });
+ });
+
+ it('должен очищать drag state после finishDragging', () => {
+ const { drawing } = createDrawing();
+
+ setAnchors(drawing);
+
+ drawing.startDrag(
+ 'dragging-body',
+ {
+ x: 50,
+ y: 60,
+ },
+ 7,
+ );
+
+ drawing.finishDrag();
+
+ expect(drawing.getState().mode).toBe('ready');
+
+ expect(drawing.getDragPointerId()).toBeNull();
+
+ expect(drawing.getDragStartPoint()).toBeNull();
+
+ expect(drawing.getDragSnapshot()).toBeNull();
+ });
+ });
+
+ describe('finishLinearDrawing', () => {
+ it('должен удалять слишком короткий drawing', () => {
+ const { drawing } = createDrawing();
+
+ const removeSelf = jest.fn();
+
+ drawing.finishLinear(
+ {
+ startPoint: {
+ x: 10,
+ y: 10,
+ },
+ endPoint: {
+ x: 12,
+ y: 12,
+ },
+ left: 10,
+ right: 12,
+ top: 10,
+ bottom: 12,
+ },
+ 4,
+ removeSelf,
+ );
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен переводить корректный drawing в ready', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setMode('drawing');
+
+ const removeSelf = jest.fn();
+
+ drawing.finishLinear(
+ {
+ startPoint: {
+ x: 10,
+ y: 10,
+ },
+ endPoint: {
+ x: 30,
+ y: 10,
+ },
+ left: 10,
+ right: 30,
+ top: 10,
+ bottom: 10,
+ },
+ 4,
+ removeSelf,
+ );
+
+ expect(removeSelf).not.toHaveBeenCalled();
+
+ expect(drawing.getState().mode).toBe('ready');
+ });
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/base/twoPointDrawingBase.test.ts b/src/core/Drawings/__tests__/drawings/base/twoPointDrawingBase.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b019767ab44473ab8da51555fed0d4dc9ceab651
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/base/twoPointDrawingBase.test.ts
@@ -0,0 +1,519 @@
+import { type TwoPointAnchorKind, TwoPointDrawingBase, type TwoPointGeometry } from '@src/core/Drawings/base';
+
+import { createDrawingTestEnvironment } from '../../drawingTestUtils';
+
+import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
+import type { SettingsTab, SettingsValues } from '@src/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+class TestTwoPointDrawingBase extends TwoPointDrawingBase<SettingsValues> {
+ protected mode: 'idle' | 'ready' = 'ready';
+ protected settings: SettingsValues = {};
+
+ public getRenderData(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
+
+ public getState() {
+ return {
+ startAnchor: this.startAnchor,
+ endAnchor: this.endAnchor,
+ };
+ }
+
+ public setState(): void {}
+
+ public getSettingsTabs(): SettingsTab[] {
+ return [];
+ }
+
+ public isCreationPending(): boolean {
+ return this.mode === 'idle';
+ }
+
+ public setTestAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
+ this.setAnchors(startAnchor, endAnchor);
+ }
+
+ public getTestAnchor(kind: TwoPointAnchorKind): Anchor | null {
+ return this.getAnchor(kind);
+ }
+
+ public setTestAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
+ this.setAnchor(kind, anchor);
+ }
+
+ public createTestAnchor(point: Point): Anchor | null {
+ return this.createAnchor(point);
+ }
+
+ public setTestAnchorFromPoint(kind: TwoPointAnchorKind, point: Point): boolean {
+ return this.setAnchorFromPoint(kind, point);
+ }
+
+ public setTestAnchorsFromPoints(startPoint: Point, endPoint: Point): boolean {
+ return this.setAnchorsFromPoints(startPoint, endPoint);
+ }
+
+ public getTestTimeCoordinate(kind: TwoPointAnchorKind): number | null {
+ return this.getAnchorTimeCoordinate(kind);
+ }
+
+ public getTestPriceCoordinate(kind: TwoPointAnchorKind): number | null {
+ return this.getAnchorPriceCoordinate(kind);
+ }
+
+ public moveByPixels(
+ startAnchor: Anchor | null,
+ endAnchor: Anchor | null,
+ dragStartPoint: Point | null,
+ point: Point,
+ ): boolean {
+ return this.moveAnchorsByPixels(startAnchor, endAnchor, dragStartPoint, point);
+ }
+
+ public moveWithinContainer(
+ startAnchor: Anchor | null,
+ endAnchor: Anchor | null,
+ dragStartPoint: Point | null,
+ point: Point,
+ bounds: {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ },
+ ): boolean {
+ return this.moveAnchorsWithinContainer(startAnchor, endAnchor, dragStartPoint, point, bounds);
+ }
+
+ public getTestGeometry(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
+
+ public restoreTestAnchor(
+ kind: TwoPointAnchorKind,
+ state: Partial<{
+ startTime: Time | null;
+ endTime: Time | null;
+ startPrice: number | null;
+ endPrice: number | null;
+ }>,
+ ): void {
+ this.restoreAnchor(kind, state);
+ }
+
+ public getTestVolumeInRange(): number {
+ return this.getVolumeInRange();
+ }
+
+ public findTestIndexByTime(time: Time): number {
+ return this.findIndexByTime(time);
+ }
+
+ protected getHoveredItem(): PrimitiveHoveredItem | null {
+ return null;
+ }
+
+ protected getGeometry(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
+
+ protected getTimeAxisSegments(): AxisSegment[] {
+ return [];
+ }
+
+ protected getPriceAxisSegments(): AxisSegment[] {
+ return [];
+ }
+
+ protected getTimeAxisLabel(): AxisLabel | null {
+ return null;
+ }
+
+ protected getPriceAxisLabel(): AxisLabel | null {
+ return null;
+ }
+}
+
+function createDrawing(options: Parameters<typeof createDrawingTestEnvironment>[0] = {}) {
+ const env = createDrawingTestEnvironment(options);
+ const drawing = new TestTwoPointDrawingBase(env);
+
+ return {
+ ...env,
+ drawing,
+ };
+}
+
+describe('TwoPointDrawingBase', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('anchors', () => {
+ it('должен хранить start и end anchors', () => {
+ const { drawing } = createDrawing();
+
+ const startAnchor = {
+ time: 10 as Time,
+ price: 100,
+ };
+
+ const endAnchor = {
+ time: 20 as Time,
+ price: 200,
+ };
+
+ drawing.setTestAnchors(startAnchor, endAnchor);
+
+ expect(drawing.getTestAnchor('start')).toEqual(startAnchor);
+
+ expect(drawing.getTestAnchor('end')).toEqual(endAnchor);
+ });
+
+ it('должен создавать anchor из screen point', () => {
+ const { drawing } = createDrawing();
+
+ expect(
+ drawing.createTestAnchor({
+ x: 10,
+ y: 20,
+ }),
+ ).toEqual({
+ time: 10,
+ price: 20,
+ });
+ });
+
+ it('должен изменять один anchor из point', () => {
+ const { drawing } = createDrawing();
+
+ expect(
+ drawing.setTestAnchorFromPoint('start', {
+ x: 15,
+ y: 25,
+ }),
+ ).toBe(true);
+
+ expect(drawing.getTestAnchor('start')).toEqual({
+ time: 15,
+ price: 25,
+ });
+ });
+
+ it('не должен частично менять anchors, если одна из точек не преобразовалась', () => {
+ const { drawing, timeScale } = createDrawing();
+
+ drawing.setTestAnchors(
+ {
+ time: 1 as Time,
+ price: 10,
+ },
+ {
+ time: 2 as Time,
+ price: 20,
+ },
+ );
+
+ timeScale.coordinateToTime.mockReturnValueOnce(100 as Time).mockReturnValueOnce(null);
+
+ expect(
+ drawing.setTestAnchorsFromPoints(
+ {
+ x: 100,
+ y: 110,
+ },
+ {
+ x: 200,
+ y: 210,
+ },
+ ),
+ ).toBe(false);
+
+ expect(drawing.getState()).toEqual({
+ startAnchor: {
+ time: 1,
+ price: 10,
+ },
+ endAnchor: {
+ time: 2,
+ price: 20,
+ },
+ });
+ });
+ });
+
+ describe('coordinates and geometry', () => {
+ it('должен сохранять дробные coordinates anchors', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setTestAnchors(
+ {
+ time: 10.25 as Time,
+ price: 30.75,
+ },
+ {
+ time: 20.5 as Time,
+ price: 40.125,
+ },
+ );
+
+ expect(drawing.getTestTimeCoordinate('start')).toBe(10.25);
+
+ expect(drawing.getTestPriceCoordinate('start')).toBe(30.75);
+ });
+
+ it('должен строить нормализованную geometry из anchors', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setTestAnchors(
+ {
+ time: 80 as Time,
+ price: 90,
+ },
+ {
+ time: 20 as Time,
+ price: 30,
+ },
+ );
+
+ expect(drawing.getTestGeometry()).toEqual({
+ startPoint: {
+ x: 80,
+ y: 90,
+ },
+ endPoint: {
+ x: 20,
+ y: 30,
+ },
+ left: 20,
+ right: 80,
+ top: 30,
+ bottom: 90,
+ });
+ });
+
+ it('должен возвращать null geometry без двух anchors', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setTestAnchor('start', {
+ time: 10 as Time,
+ price: 20,
+ });
+
+ expect(drawing.getTestGeometry()).toBeNull();
+ });
+ });
+
+ describe('moving', () => {
+ it('должен смещать оба anchors по времени и цене', () => {
+ const { drawing } = createDrawing();
+
+ const startAnchor = {
+ time: 10 as Time,
+ price: 100,
+ };
+
+ const endAnchor = {
+ time: 20 as Time,
+ price: 200,
+ };
+
+ expect(
+ drawing.moveByPixels(
+ startAnchor,
+ endAnchor,
+ {
+ x: 50,
+ y: 50,
+ },
+ {
+ x: 70,
+ y: 80,
+ },
+ ),
+ ).toBe(true);
+
+ expect(drawing.getState()).toEqual({
+ startAnchor: {
+ time: 30,
+ price: 130,
+ },
+ endAnchor: {
+ time: 40,
+ price: 230,
+ },
+ });
+ });
+
+ it('должен ограничивать смещение границами container', () => {
+ const { drawing } = createDrawing({
+ width: 200,
+ height: 200,
+ });
+
+ expect(
+ drawing.moveWithinContainer(
+ {
+ time: 50 as Time,
+ price: 40,
+ },
+ {
+ time: 150 as Time,
+ price: 140,
+ },
+ {
+ x: 50,
+ y: 50,
+ },
+ {
+ x: 200,
+ y: 200,
+ },
+ {
+ left: 50,
+ right: 150,
+ top: 40,
+ bottom: 140,
+ },
+ ),
+ ).toBe(true);
+
+ expect(drawing.getState()).toEqual({
+ startAnchor: {
+ time: 100,
+ price: 100,
+ },
+ endAnchor: {
+ time: 200,
+ price: 200,
+ },
+ });
+ });
+
+ it('не должен двигать anchors без dragStartPoint', () => {
+ const { drawing } = createDrawing();
+
+ expect(
+ drawing.moveByPixels(
+ {
+ time: 10 as Time,
+ price: 100,
+ },
+ {
+ time: 20 as Time,
+ price: 200,
+ },
+ null,
+ {
+ x: 30,
+ y: 40,
+ },
+ ),
+ ).toBe(false);
+ });
+ });
+
+ describe('state helpers', () => {
+ it('должен частично восстанавливать anchor из legacy state', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setTestAnchor('start', {
+ time: 10 as Time,
+ price: 100,
+ });
+
+ drawing.restoreTestAnchor('start', {
+ startPrice: 150,
+ });
+
+ expect(drawing.getTestAnchor('start')).toEqual({
+ time: 10,
+ price: 150,
+ });
+ });
+
+ it('должен очищать anchor, если state содержит null time', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setTestAnchor('start', {
+ time: 10 as Time,
+ price: 100,
+ });
+
+ drawing.restoreTestAnchor('start', {
+ startTime: null,
+ });
+
+ expect(drawing.getTestAnchor('start')).toBeNull();
+ });
+ });
+
+ describe('series helpers', () => {
+ it('должен находить index по времени', () => {
+ const { drawing } = createDrawing({
+ seriesData: [
+ {
+ time: 10,
+ },
+ {
+ time: 20,
+ },
+ {
+ time: 30,
+ },
+ ],
+ });
+
+ expect(drawing.findTestIndexByTime(20 as Time)).toBe(1);
+ });
+
+ it('должен суммировать volume из обычного и customValues поля', () => {
+ const { drawing } = createDrawing({
+ seriesData: [
+ {
+ time: 10,
+ volume: 5,
+ },
+ {
+ time: 20,
+ customValues: {
+ volume: 7,
+ },
+ },
+ {
+ time: 30,
+ volume: 3,
+ },
+ ],
+ });
+
+ drawing.setTestAnchors(
+ {
+ time: 10 as Time,
+ price: 100,
+ },
+ {
+ time: 30 as Time,
+ price: 200,
+ },
+ );
+
+ expect(drawing.getTestVolumeInRange()).toBe(15);
+ });
+
+ it('должен возвращать 0 volume без anchors', () => {
+ const { drawing } = createDrawing({
+ seriesData: [
+ {
+ time: 10,
+ volume: 5,
+ },
+ ],
+ });
+
+ expect(drawing.getTestVolumeInRange()).toBe(0);
+ });
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/diapson.test.ts b/src/core/Drawings/__tests__/drawings/diapson.test.ts
index 2f73911810a10331e51626d0b29145850e1b1d3a..5544e0b8bb60d9854f0207b8146c8bb0ce02b9b4 100644
--- a/src/core/Drawings/__tests__/drawings/diapson.test.ts
+++ b/src/core/Drawings/__tests__/drawings/diapson.test.ts
@@ -35,22 +35,11 @@ jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPercent: (value: number) => `${value}%`,
formatPrice: (value: number) => `price:${value}`,
formatSignedNumber: (value: number) => (value > 0 ? `+${value}` : String(value)),
formatVolume: (value: number) => `volume:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts b/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
index 18ffb86f18c53f8d32edd8f004c5d10cc7526fef..5eca344bc39f50e29eb5c3cf5a99e5ff92d2ed03 100644
--- a/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
+++ b/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
@@ -40,19 +40,8 @@ jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/line.test.ts b/src/core/Drawings/__tests__/drawings/line.test.ts
index c53e3ba792cbb1ee34d96b99719ac50139dbfc89..b526813c1fdb9390931bce04d184ac24b4a6620b 100644
--- a/src/core/Drawings/__tests__/drawings/line.test.ts
+++ b/src/core/Drawings/__tests__/drawings/line.test.ts
@@ -32,19 +32,8 @@ jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts b/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
index 9f4bf9c1ec7520132be36c4713819970dab3dbfb..02d72f09308db430b112904dc3ffe3cc0d3ac46d 100644
--- a/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
+++ b/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
@@ -30,19 +30,8 @@ jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/ray.test.ts b/src/core/Drawings/__tests__/drawings/ray.test.ts
index aea33af18054f31bf830ad863d4af5aa3627344d..0bbbe6155b6768225e7e600da96404b7ed8e2620 100644
--- a/src/core/Drawings/__tests__/drawings/ray.test.ts
+++ b/src/core/Drawings/__tests__/drawings/ray.test.ts
@@ -30,19 +30,8 @@ jest.mock('@src/translations', () => ({
t: (key: string) => key,
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/rectangle.test.ts b/src/core/Drawings/__tests__/drawings/rectangle.test.ts
index 20cc89b7046282027b524310c7d6e1fd289895d1..63fee31235b92f58d2f21eeceb638b991cd20dea 100644
--- a/src/core/Drawings/__tests__/drawings/rectangle.test.ts
+++ b/src/core/Drawings/__tests__/drawings/rectangle.test.ts
@@ -28,19 +28,8 @@ jest.mock('@src/theme', () => ({
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts b/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
index 399b3d0e45969fbbd106eb1c0e8e67c39e5ce886..144efda8783ec071aa2c75f229f44178ff2fb09c 100644
--- a/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
+++ b/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
@@ -28,17 +28,12 @@ jest.mock('@src/theme', () => ({
}),
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
-jest.mock('@src/utils', () => ({ formatPrice: (value: number) => `price:${value}` }));
-jest.mock('@src/utils/formatter', () => ({ formatDate: (value: number) => `date:${value}` }));
+
+jest.mock('@src/utils', () => ({
+ formatPrice: (value: number) => `price:${value}`,
+ formatDate: (value: number) => `date:${value}`,
+}));
const seriesData = [
{ time: 10 as Time, close: 10 },
diff --git a/src/core/Drawings/__tests__/drawings/ruler.test.ts b/src/core/Drawings/__tests__/drawings/ruler.test.ts
index 69ebc65d964bcdcc12037333d609aa4e8bb2a976..7578edcc98b4fa0b6764a6ae0699a28259bcb09b 100644
--- a/src/core/Drawings/__tests__/drawings/ruler.test.ts
+++ b/src/core/Drawings/__tests__/drawings/ruler.test.ts
@@ -27,20 +27,9 @@ jest.mock('@src/theme', () => ({
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
formatVolume: (value: number) => `volume:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts b/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
index bd333e56259d939b4530cbab8370953c61ad49fb..cff0ad0473861310bebc470a0d603d01569ebdff 100644
--- a/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
+++ b/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
@@ -31,14 +31,6 @@ jest.mock('@src/translations', () => ({
t: (value: string) => value,
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatPrice: (value: number, precision = 4) => {
const factor = 10 ** precision;
@@ -53,9 +45,6 @@ jest.mock('@src/utils', () => ({
formatSignedNumber: (value: number) => String(value),
getPercentPrecision: () => 2,
getPricePrecisionStep: () => 0.01,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/drawings/text.test.ts b/src/core/Drawings/__tests__/drawings/text.test.ts
index 2eec7aa12b30790be8648b153dd30d4ac8eede21..499436394dc88b0df8526070a59963600a3995ba 100644
--- a/src/core/Drawings/__tests__/drawings/text.test.ts
+++ b/src/core/Drawings/__tests__/drawings/text.test.ts
@@ -25,14 +25,6 @@ jest.mock('@src/theme', () => ({
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/utils', () => ({
formatDate: (value: number) => `date:${value}`,
formatPrice: (value: number) => `price:${value}`,
diff --git a/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts b/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
index 47fed6b915c25c7f2b25b086b436a8692c5f27cd..7cf8805b5a4381ee15b1da892e03ddafd4dba94c 100644
--- a/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
+++ b/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
@@ -23,21 +23,10 @@ jest.mock('@src/theme', () => ({
}),
}));
-jest.mock('@src/types/defaults', () => ({
- Defaults: {
- dateFormat: 'date',
- timeFormat: '24h',
- showTime: true,
- },
-}));
-
jest.mock('@src/translations', () => ({ t: (value: string) => value }));
jest.mock('@src/utils', () => ({
formatPrice: (value: number) => `price:${value}`,
-}));
-
-jest.mock('@src/utils/formatter', () => ({
formatDate: (value: number) => `date:${value}`,
}));
diff --git a/src/core/Drawings/__tests__/helpers.test.ts b/src/core/Drawings/__tests__/helpers.test.ts
index a91cba440acff5e0f18eb1244729c63b0fbbcaee..45ec7e01b5f8023a6398959d6e25581c1047b33c 100644
--- a/src/core/Drawings/__tests__/helpers.test.ts
+++ b/src/core/Drawings/__tests__/helpers.test.ts
@@ -17,7 +17,7 @@ import {
} from '@core/Drawings/helpers';
import type { SeriesApi } from '@core/Drawings/types';
-import type { Coordinate, IChartApi, Logical, Time } from 'lightweight-charts';
+import type { Coordinate, IChartApi, Time } from 'lightweight-charts';
function createContainer(width = 400, height = 300, left = 10, top = 20) {
const container = document.createElement('div');
@@ -42,14 +42,9 @@ function createChart() {
const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
- const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => {
- return (Number(logical) * 10) as Coordinate;
- });
-
const timeScale = {
coordinateToTime,
timeToCoordinate,
- logicalToCoordinate,
};
const chart = {
@@ -83,272 +78,487 @@ function createSeries(data: readonly { time: Time }[] = []) {
};
}
-describe('Drawing helpers', () => {
- it('должен получать цену из y coordinate', () => {
- const { series, coordinateToPrice } = createSeries();
+function mockTimeCoordinates(
+ timeToCoordinate: jest.Mock,
+ coordinates: Record<number, number>,
+ missingTimes: number[] = [],
+): void {
+ timeToCoordinate.mockImplementation((time: Time): Coordinate | null => {
+ if (typeof time !== 'number') {
+ return null;
+ }
- expect(getPriceFromYCoordinate(series, 50)).toBe(50);
- expect(coordinateToPrice).toHaveBeenCalledWith(50);
- });
+ if (missingTimes.includes(time)) {
+ return null;
+ }
- it('должен получать y coordinate из цены', () => {
- const { series, priceToCoordinate } = createSeries();
+ const coordinate = coordinates[time];
- expect(getYCoordinateFromPrice(series, 100)).toBe(100);
- expect(priceToCoordinate).toHaveBeenCalledWith(100);
+ return coordinate === undefined ? null : (coordinate as Coordinate);
});
+}
- it('должен получать время из x coordinate', () => {
- const { chart, timeScale } = createChart();
+describe('Drawing helpers', () => {
+ describe('price coordinates', () => {
+ it('должен получать цену из y coordinate', () => {
+ const { series, coordinateToPrice } = createSeries();
- expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
- expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
- });
+ expect(getPriceFromYCoordinate(series, 50)).toBe(50);
+
+ expect(coordinateToPrice).toHaveBeenCalledWith(50);
+ });
- it('должен возвращать прямую координату времени', () => {
- const { chart } = createChart();
+ it('должен получать y coordinate из цены', () => {
+ const { series, priceToCoordinate } = createSeries();
- expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
+ expect(getYCoordinateFromPrice(series, 100)).toBe(100);
+
+ expect(priceToCoordinate).toHaveBeenCalledWith(100);
+ });
});
- it('должен возвращать null без series, если координата времени отсутствует', () => {
- const { chart, timeScale } = createChart();
+ describe('time coordinates', () => {
+ it('должен получать время из x coordinate', () => {
+ const { chart, timeScale } = createChart();
- timeScale.timeToCoordinate.mockReturnValue(null);
+ expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
- expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
- });
+ expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
+ });
- it('должен проецировать отсутствующее время через ближайшую свечу', () => {
- const { chart, timeScale } = createChart();
+ it('должен возвращать прямую координату времени, если она существует', () => {
+ const { chart, timeScale } = createChart();
- timeScale.timeToCoordinate.mockReturnValue(null);
+ expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
- const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }, { time: 30 as Time }]);
+ expect(timeScale.timeToCoordinate).toHaveBeenCalledWith(100);
+ });
- expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(10);
- expect(timeScale.logicalToCoordinate).toHaveBeenCalledWith(1);
- });
+ it('должен возвращать null без series, если координата времени отсутствует', () => {
+ const { chart, timeScale } = createChart();
- it('должен выбирать предыдущую свечу при одинаковом расстоянии', () => {
- const { chart, timeScale } = createChart();
+ timeScale.timeToCoordinate.mockReturnValue(null);
- timeScale.timeToCoordinate.mockReturnValue(null);
+ expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
+ });
- const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }, { time: 30 as Time }]);
+ it('должен интерполировать координату между существующими свечами', () => {
+ const { chart, timeScale } = createChart();
- expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(10);
- });
+ mockTimeCoordinates(
+ timeScale.timeToCoordinate,
+ {
+ 20: 100,
+ 30: 200,
+ },
+ [24],
+ );
- it('должен использовать первую свечу для времени до диапазона', () => {
- const { chart, timeScale } = createChart();
+ const { series } = createSeries([{ time: 20 as Time }, { time: 30 as Time }]);
- timeScale.timeToCoordinate.mockReturnValue(null);
+ expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(140);
+ });
- const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+ it('должен сохранять разницу координат для разных времён между одними свечами', () => {
+ const { chart, timeScale } = createChart();
- expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(0);
- });
+ mockTimeCoordinates(
+ timeScale.timeToCoordinate,
+ {
+ 20: 100,
+ 30: 200,
+ },
+ [24, 25],
+ );
- it('должен использовать последнюю свечу для времени после диапазона', () => {
- const { chart, timeScale } = createChart();
+ const { series } = createSeries([{ time: 20 as Time }, { time: 30 as Time }]);
- timeScale.timeToCoordinate.mockReturnValue(null);
+ expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(140);
- const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+ expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(150);
+ });
- expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBe(10);
- });
+ it('должен экстраполировать координату для времени до диапазона', () => {
+ const { chart, timeScale } = createChart();
- it('должен возвращать null при отсутствии данных series', () => {
- const { chart, timeScale } = createChart();
+ mockTimeCoordinates(
+ timeScale.timeToCoordinate,
+ {
+ 10: 100,
+ 20: 200,
+ },
+ [5],
+ );
- timeScale.timeToCoordinate.mockReturnValue(null);
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
- const { series } = createSeries();
+ expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(50);
+ });
- expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
- });
+ it('должен экстраполировать координату для времени после диапазона', () => {
+ const { chart, timeScale } = createChart();
+
+ mockTimeCoordinates(
+ timeScale.timeToCoordinate,
+ {
+ 10: 100,
+ 20: 200,
+ },
+ [30],
+ );
- it('должен возвращать null при невозможности спроецировать logical coordinate', () => {
- const { chart, timeScale } = createChart();
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
- timeScale.timeToCoordinate.mockReturnValue(null);
- timeScale.logicalToCoordinate.mockReturnValue(null);
+ expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBe(300);
+ });
- const { series } = createSeries([{ time: 10 as Time }]);
+ it('должен использовать координату единственной свечи, если в series одна точка', () => {
+ const { chart, timeScale } = createChart();
- expect(getXCoordinateFromTime(chart, 10 as Time, series)).toBeNull();
- });
+ mockTimeCoordinates(
+ timeScale.timeToCoordinate,
+ {
+ 10: 100,
+ },
+ [15],
+ );
- it('должен получать размеры container', () => {
- const container = createContainer(500, 250);
+ const { series } = createSeries([{ time: 10 as Time }]);
- expect(getContainerSize(container)).toEqual({
- width: 500,
- height: 250,
+ expect(getXCoordinateFromTime(chart, 15 as Time, series)).toBe(100);
});
- });
- it('должен ограничивать point границами container', () => {
- const container = createContainer(400, 300);
+ it('должен возвращать null при отсутствии данных series', () => {
+ const { chart, timeScale } = createChart();
- expect(clampPointToContainer({ x: -20, y: 350 }, container)).toEqual({
- x: 0,
- y: 300,
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries();
+
+ expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
});
- });
- it('должен получать raw pointer point относительно container', () => {
- const container = createContainer(400, 300, 10, 20);
+ it('должен возвращать null, если координаты соседних свечей недоступны', () => {
+ const { chart, timeScale } = createChart();
- const event = {
- clientX: 110,
- clientY: 70,
- } as unknown as MouseEvent;
+ timeScale.timeToCoordinate.mockReturnValue(null);
- expect(getRawPointerPoint(container, event)).toEqual({
- x: 100,
- y: 50,
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+
+ expect(getXCoordinateFromTime(chart, 15 as Time, series)).toBeNull();
});
- });
- it('должен ограничивать pointer point границами container', () => {
- const container = createContainer(400, 300, 10, 20);
+ it('должен возвращать null для отсутствующего ненумерического времени', () => {
+ const { chart, timeScale } = createChart();
- const event = {
- clientX: 500,
- clientY: -10,
- } as unknown as MouseEvent;
+ timeScale.timeToCoordinate.mockReturnValue(null);
- expect(getPointerPoint(container, event)).toEqual({
- x: 400,
- y: 0,
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+
+ const time = {
+ year: 2026,
+ month: 9,
+ day: 21,
+ } as Time;
+
+ expect(getXCoordinateFromTime(chart, time, series)).toBeNull();
});
- });
- it('должен определять близость point', () => {
- expect(isNearPoint({ x: 108, y: 92 }, 100, 100, 8)).toBe(true);
- expect(isNearPoint({ x: 109, y: 100 }, 100, 100, 8)).toBe(false);
- });
+ it('должен возвращать null для невалидной прямой координаты без series', () => {
+ const { chart, timeScale } = createChart();
- it('должен учитывать границу tolerance при проверке близости', () => {
- expect(isNearPoint({ x: 108, y: 108 }, 100, 100, 8)).toBe(true);
- });
+ timeScale.timeToCoordinate.mockReturnValue(Number.NaN as Coordinate);
- it('должен определять point внутри bounds', () => {
- const bounds = {
- left: 10,
- right: 100,
- top: 20,
- bottom: 80,
- };
-
- expect(isPointInBounds({ x: 50, y: 50 }, bounds)).toBe(true);
- expect(isPointInBounds({ x: 5, y: 50 }, bounds)).toBe(false);
- expect(isPointInBounds({ x: 5, y: 50 }, bounds, 5)).toBe(true);
+ expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
+ });
});
- it('должен нормализовать и ограничивать bounds', () => {
- const container = createContainer(400, 300);
+ describe('container', () => {
+ it('должен получать размеры container', () => {
+ const container = createContainer(500, 250);
- expect(normalizeBounds(450, -20, 350, -50, container)).toEqual({
- left: 0,
- right: 400,
- top: 0,
- bottom: 300,
+ expect(getContainerSize(container)).toEqual({
+ width: 500,
+ height: 250,
+ });
});
- });
- it('должен смещать время на количество пикселей', () => {
- const { chart } = createChart();
+ it('должен ограничивать point границами container', () => {
+ const container = createContainer(400, 300);
+
+ expect(
+ clampPointToContainer(
+ {
+ x: -20,
+ y: 350,
+ },
+ container,
+ ),
+ ).toEqual({
+ x: 0,
+ y: 300,
+ });
+ });
- expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
- });
+ it('должен получать raw pointer point относительно container', () => {
+ const container = createContainer(400, 300, 10, 20);
+
+ const event = {
+ clientX: 110,
+ clientY: 70,
+ } as unknown as MouseEvent;
- it('должен возвращать null при отсутствии исходной координаты времени', () => {
- const { chart, timeScale } = createChart();
+ expect(getRawPointerPoint(container, event)).toEqual({
+ x: 100,
+ y: 50,
+ });
+ });
+
+ it('должен ограничивать pointer point границами container', () => {
+ const container = createContainer(400, 300, 10, 20);
- timeScale.timeToCoordinate.mockReturnValue(null);
+ const event = {
+ clientX: 500,
+ clientY: -10,
+ } as unknown as MouseEvent;
- expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
+ expect(getPointerPoint(container, event)).toEqual({
+ x: 400,
+ y: 0,
+ });
+ });
});
- it('должен вычислять price delta', () => {
- const { series } = createSeries();
+ describe('point helpers', () => {
+ it('должен определять близость point', () => {
+ expect(
+ isNearPoint(
+ {
+ x: 108,
+ y: 92,
+ },
+ 100,
+ 100,
+ 8,
+ ),
+ ).toBe(true);
+
+ expect(
+ isNearPoint(
+ {
+ x: 109,
+ y: 100,
+ },
+ 100,
+ 100,
+ 8,
+ ),
+ ).toBe(false);
+ });
- expect(getPriceDelta(series, 20, 70)).toBe(50);
- });
+ it('должен учитывать границу tolerance при проверке близости', () => {
+ expect(
+ isNearPoint(
+ {
+ x: 108,
+ y: 108,
+ },
+ 100,
+ 100,
+ 8,
+ ),
+ ).toBe(true);
+ });
- it('должен возвращать 0 price delta при отсутствии начальной цены', () => {
- const { series, coordinateToPrice } = createSeries();
+ it('должен определять point внутри bounds', () => {
+ const bounds = {
+ left: 10,
+ right: 100,
+ top: 20,
+ bottom: 80,
+ };
+
+ expect(
+ isPointInBounds(
+ {
+ x: 50,
+ y: 50,
+ },
+ bounds,
+ ),
+ ).toBe(true);
+
+ expect(
+ isPointInBounds(
+ {
+ x: 5,
+ y: 50,
+ },
+ bounds,
+ ),
+ ).toBe(false);
+
+ expect(
+ isPointInBounds(
+ {
+ x: 5,
+ y: 50,
+ },
+ bounds,
+ 5,
+ ),
+ ).toBe(true);
+ });
- coordinateToPrice.mockReturnValueOnce(null);
+ it('должен нормализовать и ограничивать bounds', () => {
+ const container = createContainer(400, 300);
- expect(getPriceDelta(series, 20, 70)).toBe(0);
+ expect(normalizeBounds(450, -20, 350, -50, container)).toEqual({
+ left: 0,
+ right: 400,
+ top: 0,
+ bottom: 300,
+ });
+ });
});
- it('должен возвращать 0 price delta при отсутствии конечной цены', () => {
- const { series, coordinateToPrice } = createSeries();
+ describe('time shifting', () => {
+ it('должен смещать время на количество пикселей', () => {
+ const { chart } = createChart();
- coordinateToPrice.mockReturnValueOnce(20).mockReturnValueOnce(null);
+ expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
+ });
- expect(getPriceDelta(series, 20, 70)).toBe(0);
- });
+ it('должен использовать projected coordinate при смещении отсутствующего времени', () => {
+ const { chart, timeScale } = createChart();
+
+ mockTimeCoordinates(
+ timeScale.timeToCoordinate,
+ {
+ 20: 100,
+ 30: 200,
+ },
+ [24],
+ );
- it('должен вычислять диапазон цены в container', () => {
- const container = createContainer(400, 300);
- const { series, coordinateToPrice } = createSeries();
+ timeScale.coordinateToTime.mockImplementation((coordinate: Coordinate) => Number(coordinate) as Time);
- coordinateToPrice.mockReturnValueOnce(150).mockReturnValueOnce(50);
+ const { series } = createSeries([{ time: 20 as Time }, { time: 30 as Time }]);
- expect(getPriceRangeInContainer(series, container)).toEqual({
- min: 50,
- max: 150,
+ expect(shiftTimeByPixels(chart, 24 as Time, 20, series)).toBe(160);
});
- });
- it('должен возвращать null диапазона при нулевой высоте', () => {
- const container = createContainer(400, 0);
- const { series } = createSeries();
+ it('должен возвращать null при отсутствии исходной координаты времени', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
- expect(getPriceRangeInContainer(series, container)).toBeNull();
+ expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
+ });
});
- it('должен возвращать null диапазона при отсутствии цены', () => {
- const container = createContainer(400, 300);
- const { series, coordinateToPrice } = createSeries();
+ describe('price delta', () => {
+ it('должен вычислять price delta', () => {
+ const { series } = createSeries();
- coordinateToPrice.mockReturnValueOnce(null);
+ expect(getPriceDelta(series, 20, 70)).toBe(50);
+ });
- expect(getPriceRangeInContainer(series, container)).toBeNull();
- });
+ it('должен возвращать 0 price delta при отсутствии начальной цены', () => {
+ const { series, coordinateToPrice } = createSeries();
- it('должен создавать anchor из point', () => {
- const { chart } = createChart();
- const { series } = createSeries();
+ coordinateToPrice.mockReturnValueOnce(null);
- expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toEqual({
- time: 100,
- price: 200,
+ expect(getPriceDelta(series, 20, 70)).toBe(0);
+ });
+
+ it('должен возвращать 0 price delta при отсутствии конечной цены', () => {
+ const { series, coordinateToPrice } = createSeries();
+
+ coordinateToPrice.mockReturnValueOnce(20).mockReturnValueOnce(null);
+
+ expect(getPriceDelta(series, 20, 70)).toBe(0);
});
});
- it('должен возвращать null anchor при отсутствии времени', () => {
- const { chart, timeScale } = createChart();
- const { series } = createSeries();
+ describe('price range', () => {
+ it('должен вычислять диапазон цены в container', () => {
+ const container = createContainer(400, 300);
+
+ const { series, coordinateToPrice } = createSeries();
- timeScale.coordinateToTime.mockReturnValue(null);
+ coordinateToPrice.mockReturnValueOnce(150).mockReturnValueOnce(50);
- expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
+ expect(getPriceRangeInContainer(series, container)).toEqual({
+ min: 50,
+ max: 150,
+ });
+ });
+
+ it('должен возвращать null диапазона при нулевой высоте', () => {
+ const container = createContainer(400, 0);
+
+ const { series } = createSeries();
+
+ expect(getPriceRangeInContainer(series, container)).toBeNull();
+ });
+
+ it('должен возвращать null диапазона при отсутствии цены', () => {
+ const container = createContainer(400, 300);
+
+ const { series, coordinateToPrice } = createSeries();
+
+ coordinateToPrice.mockReturnValueOnce(null);
+
+ expect(getPriceRangeInContainer(series, container)).toBeNull();
+ });
});
- it('должен возвращать null anchor при отсутствии цены', () => {
- const { chart } = createChart();
- const { series, coordinateToPrice } = createSeries();
+ describe('anchors', () => {
+ it('должен создавать anchor из point', () => {
+ const { chart } = createChart();
- coordinateToPrice.mockReturnValue(null);
+ const { series } = createSeries();
- expect(getAnchorFromPoint(chart, series, { x: 100, y: 200 })).toBeNull();
+ expect(
+ getAnchorFromPoint(chart, series, {
+ x: 100,
+ y: 200,
+ }),
+ ).toEqual({
+ time: 100,
+ price: 200,
+ });
+ });
+
+ it('должен возвращать null anchor при отсутствии времени', () => {
+ const { chart, timeScale } = createChart();
+
+ const { series } = createSeries();
+
+ timeScale.coordinateToTime.mockReturnValue(null);
+
+ expect(
+ getAnchorFromPoint(chart, series, {
+ x: 100,
+ y: 200,
+ }),
+ ).toBeNull();
+ });
+
+ it('должен возвращать null anchor при отсутствии цены', () => {
+ const { chart } = createChart();
+
+ const { series, coordinateToPrice } = createSeries();
+
+ coordinateToPrice.mockReturnValue(null);
+
+ expect(
+ getAnchorFromPoint(chart, series, {
+ x: 100,
+ y: 200,
+ }),
+ ).toBeNull();
+ });
});
});
diff --git a/src/core/Drawings/axis/CustomAxisView.ts b/src/core/Drawings/axis/CustomAxisView.ts
new file mode 100644
index 0000000000000000000000000000000000000000..da4136c7367f490bb8934de4428b79c30f92999a
--- /dev/null
+++ b/src/core/Drawings/axis/CustomAxisView.ts
@@ -0,0 +1,63 @@
+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);
+ }
+}
diff --git a/src/core/Drawings/axis/CustomPriceAxis.ts b/src/core/Drawings/axis/CustomPriceAxis.ts
index d13e343b76319566bdf68673660cd5cec62df483..8176e5e3682609af032cc94cacf2e1262cd50dc6 100644
--- a/src/core/Drawings/axis/CustomPriceAxis.ts
+++ b/src/core/Drawings/axis/CustomPriceAxis.ts
@@ -1,23 +1,15 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
-import {
- IPrimitivePaneRenderer,
- IPrimitivePaneView,
- ISeriesPrimitiveAxisView,
- PrimitivePaneViewZOrder,
-} from 'lightweight-charts';
+import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
-import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import { CustomAxisView } from './CustomAxisView';
+
+import type { AxisSegment } from '@core/Drawings/types';
interface CustomPriceAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
zOrder?: PrimitivePaneViewZOrder;
}
-interface CustomPriceAxisViewParams {
- getAxisLabel(labelKind: string): AxisLabel | null;
- labelKind: string;
-}
-
export class CustomPriceAxisPaneView implements IPrimitivePaneView {
private readonly rendererInstance: CustomPriceAxisPaneRenderer;
private readonly paneZOrder: PrimitivePaneViewZOrder;
@@ -64,42 +56,8 @@ class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
}
}
-export class CustomPriceAxisView implements ISeriesPrimitiveAxisView {
- private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
- private readonly labelKind: string;
-
- constructor({ getAxisLabel, labelKind }: CustomPriceAxisViewParams) {
- this.getAxisLabel = getAxisLabel;
- this.labelKind = labelKind;
- }
-
- public update(): void {}
-
- public visible(): boolean {
- return Boolean(this.getCurrentLabel()?.text);
- }
-
- public tickVisible(): boolean {
- return false;
- }
-
- public coordinate(): number {
- return this.getCurrentLabel()?.coordinate ?? 0;
- }
-
- public text(): string {
- return this.getCurrentLabel()?.text ?? '';
- }
-
- public textColor(): string {
- return this.getCurrentLabel()?.textColor ?? '';
- }
-
- public backColor(): string {
- return this.getCurrentLabel()?.backgroundColor ?? '';
- }
-
- private getCurrentLabel(): AxisLabel | null {
- return this.getAxisLabel(this.labelKind);
+export class CustomPriceAxisView extends CustomAxisView {
+ protected getViewportSize(rect: DOMRect): number {
+ return rect.height;
}
}
diff --git a/src/core/Drawings/axis/CustomTimeAxis.ts b/src/core/Drawings/axis/CustomTimeAxis.ts
index 8cb6ce817cdb15964ac717823f430dc215400738..98b177b38643d979b24af61c26e48a3ae034f3a3 100644
--- a/src/core/Drawings/axis/CustomTimeAxis.ts
+++ b/src/core/Drawings/axis/CustomTimeAxis.ts
@@ -1,23 +1,15 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
-import {
- IPrimitivePaneRenderer,
- IPrimitivePaneView,
- ISeriesPrimitiveAxisView,
- PrimitivePaneViewZOrder,
-} from 'lightweight-charts';
+import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
-import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import { CustomAxisView } from './CustomAxisView';
+
+import type { AxisSegment } from '@core/Drawings/types';
interface CustomTimeAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
zOrder?: PrimitivePaneViewZOrder;
}
-interface CustomTimeAxisViewParams {
- getAxisLabel(labelKind: string): AxisLabel | null;
- labelKind: string;
-}
-
export class CustomTimeAxisPaneView implements IPrimitivePaneView {
private readonly rendererInstance: CustomTimeAxisPaneRenderer;
private readonly paneZOrder: PrimitivePaneViewZOrder;
@@ -64,42 +56,8 @@ class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
}
}
-export class CustomTimeAxisView implements ISeriesPrimitiveAxisView {
- private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
- private readonly labelKind: string;
-
- constructor({ getAxisLabel, labelKind }: CustomTimeAxisViewParams) {
- this.getAxisLabel = getAxisLabel;
- this.labelKind = labelKind;
- }
-
- public update(): void {}
-
- public visible(): boolean {
- return Boolean(this.getCurrentLabel()?.text);
- }
-
- public tickVisible(): boolean {
- return false;
- }
-
- public coordinate(): number {
- return this.getCurrentLabel()?.coordinate ?? 0;
- }
-
- public text(): string {
- return this.getCurrentLabel()?.text ?? '';
- }
-
- public textColor(): string {
- return this.getCurrentLabel()?.textColor ?? '';
- }
-
- public backColor(): string {
- return this.getCurrentLabel()?.backgroundColor ?? '';
- }
-
- private getCurrentLabel(): AxisLabel | null {
- return this.getAxisLabel(this.labelKind);
+export class CustomTimeAxisView extends CustomAxisView {
+ protected getViewportSize(rect: DOMRect): number {
+ return rect.width;
}
}
diff --git a/src/core/Drawings/axisLine/axisLine.ts b/src/core/Drawings/axisLine/axisLine.ts
index 11575873900784b788c923c2d4b3c9fc25e88011..9da496e9abda866ef0c076e722b784b3433974e3 100644
--- a/src/core/Drawings/axisLine/axisLine.ts
+++ b/src/core/Drawings/axisLine/axisLine.ts
@@ -1,3 +1,5 @@
+import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
+
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
getPriceFromYCoordinate,
@@ -6,15 +8,12 @@ import {
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-import { getThemeStore } from '@src/theme';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { DrawingBase } from '@src/core/Drawings/base';
+import { Defaults } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { AxisLinePaneView } from './paneView';
-
import {
AxisLineSettings,
AxisLineStyle,
@@ -25,9 +24,8 @@ import {
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
+import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/base/DrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
-import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type AxisLineDirection = 'vertical' | 'horizontal';
@@ -53,13 +51,13 @@ export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
const LINE_HIT_TOLERANCE = 6;
-export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandleKey> implements ISeriesDrawing {
+export class AxisLine extends DrawingBase<AxisLineSettings, AxisLineHandleKey> implements ISeriesDrawing {
private readonly openSettings?: () => void;
+ private readonly direction: AxisLineDirection;
protected settings: AxisLineSettings = createDefaultSettings();
protected mode: AxisLineMode = 'idle';
- private readonly direction: AxisLineDirection;
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
@@ -88,33 +86,18 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
this.direction = direction;
this.openSettings = openSettings;
-
this.paneView = new AxisLinePaneView(this);
+ this.timeAxisView = this.createTimeAxisView('main');
+ this.priceAxisView = this.createPriceAxisView('main');
- this.timeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'main',
- });
-
- this.priceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'main',
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -132,22 +115,18 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
};
}
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [];
- }
-
public setState(state: unknown): void {
+ if (!state || typeof state !== 'object') {
+ return;
+ }
+
const nextState = state as Partial<AxisLineState>;
- if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
+ if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
- if ('mode' in nextState && nextState.mode) {
+ if (nextState.mode) {
this.mode = nextState.mode;
}
@@ -159,7 +138,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
this.price = nextState.price ?? null;
}
- if ('settings' in nextState && nextState.settings) {
+ if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
@@ -181,6 +160,14 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
return [this.paneView];
}
+ public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
+ return [];
+ }
+
+ public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
+ return [];
+ }
+
public timeAxisViews() {
return this.direction === 'vertical' ? [this.timeAxisView] : [];
}
@@ -232,9 +219,9 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
}
const point = { x, y };
- const data = this.getRenderData();
+ const coordinate = this.getCoordinate();
- if (!data) {
+ if (coordinate === null) {
return null;
}
@@ -246,7 +233,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
};
}
- if (!this.isPointNearLine(point, data.coordinate)) {
+ if (!this.isPointNearLine(point, coordinate)) {
return null;
}
@@ -258,50 +245,36 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if (kind !== 'main' || this.direction !== 'vertical' || !this.isSelected() || this.time === null) {
+ if (
+ !this.isAxisLabelAvailable() ||
+ kind !== 'main' ||
+ this.direction !== 'vertical' ||
+ typeof this.time !== 'number'
+ ) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
- if (coordinate === null || typeof this.time !== 'number') {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatDate(
+ return this.createAxisLabel(
+ coordinate === null ? null : Number(coordinate),
+ formatDate(
this.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ );
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if (kind !== 'main' || this.direction !== 'horizontal' || !this.isSelected() || this.price === null) {
+ if (!this.isAxisLabelAvailable() || kind !== 'main' || this.direction !== 'horizontal' || this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatPrice(this.price) ?? '',
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), formatPrice(this.price) ?? '');
}
protected getTimeAxisSegments(): AxisSegment[] {
@@ -321,23 +294,21 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
return;
}
- const data = this.getRenderData();
+ const coordinate = this.getCoordinate();
- if (!data) {
+ if (coordinate === null) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
- const isNearLine = this.isPointNearLine(point, data.coordinate);
+ const isNearLine = this.isPointNearLine(point, coordinate);
if (!isNearHandle && !isNearLine) {
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.openSettings?.();
};
@@ -349,9 +320,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDrawing(point);
return;
}
@@ -360,23 +329,21 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
return;
}
- const data = this.getRenderData();
+ const coordinate = this.getCoordinate();
- if (!data) {
+ if (coordinate === null) {
return;
}
const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
- const isNearLine = this.isPointNearLine(point, data.coordinate);
+ const isNearLine = this.isPointNearLine(point, coordinate);
if (!this.isSelected()) {
if (!isNearLine) {
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.select();
return;
}
@@ -386,12 +353,9 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
-
this.hideCrosshair();
this.render();
};
@@ -401,9 +365,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.updateLine(this.getEventPoint(event));
this.render();
};
@@ -415,7 +377,6 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
this.mode = 'ready';
this.dragPointerId = null;
-
this.resolveReady?.();
this.showCrosshair();
this.render();
@@ -424,7 +385,6 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandle
private startDrawing(point: Point): void {
this.updateLine(point);
this.mode = 'ready';
-
this.resolveReady?.();
this.render();
}
diff --git a/src/core/Drawings/base/AreaDrawingBase.ts b/src/core/Drawings/base/AreaDrawingBase.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4213969d4abc33b23e3dc3a8e3f5707c1ba8e266
--- /dev/null
+++ b/src/core/Drawings/base/AreaDrawingBase.ts
@@ -0,0 +1,131 @@
+import { TwoPointDrawingBase } from './TwoPointDrawingBase';
+
+import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
+import type { SettingsValues } from '@src/types';
+
+export abstract class AreaDrawingBase<
+ TSettings extends SettingsValues,
+ THandleId extends string,
+> extends TwoPointDrawingBase<TSettings, THandleId> {
+ protected abstract dragPointerId: number | null;
+
+ protected abstract activeDragTarget: THandleId | 'body' | null;
+
+ protected abstract finishDrawing(): void;
+
+ protected abstract containsPoint(point: Point): boolean;
+
+ protected abstract getDragTarget(point: Point): THandleId | 'body' | null;
+
+ protected abstract startDragging(point: Point, pointerId: number, target: THandleId | 'body'): void;
+
+ protected abstract moveWhole(point: Point): void;
+
+ protected abstract resize(point: Point): void;
+
+ protected abstract getGeometry(): Bounds | null;
+
+ protected startDrawing(point: Point): void {
+ const anchor = this.createAnchor(point);
+
+ if (!anchor) {
+ return;
+ }
+
+ this.setAnchors(anchor, anchor);
+ this.mode = 'drawing';
+ this.render();
+ }
+
+ protected updateDrawing(point: Point): void {
+ const clampedPoint = this.clampPointToContainer(point);
+
+ if (!this.setAnchorFromPoint('end', clampedPoint)) {
+ return;
+ }
+
+ this.render();
+ }
+
+ protected handlePointerDown = (event: PointerEvent): void => {
+ if (this.hidden || event.button !== 0) {
+ return;
+ }
+
+ const point = this.getEventPoint(event);
+
+ if (this.mode === 'idle') {
+ this.consumeEvent(event);
+ this.startDrawing(point);
+ return;
+ }
+
+ if (this.mode === 'drawing') {
+ this.consumeEvent(event);
+ this.updateDrawing(point);
+ this.finishDrawing();
+ return;
+ }
+
+ if (this.mode !== 'ready') {
+ return;
+ }
+
+ if (this.selectOnPointerDown(event, () => this.containsPoint(point))) {
+ return;
+ }
+
+ const dragTarget = this.getDragTarget(point);
+
+ if (!dragTarget) {
+ this.deselect();
+ return;
+ }
+
+ this.consumeEvent(event);
+ 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 getTimeAxisSegments(): AxisSegment[] {
+ return this.getBoundsAxisSegments('time', () => this.getGeometry());
+ }
+
+ protected getPriceAxisSegments(): AxisSegment[] {
+ return this.getBoundsAxisSegments('price', () => this.getGeometry());
+ }
+
+ protected getBoundsAxisLabel<TKind extends keyof Bounds>(
+ kind: string,
+ kinds: readonly TKind[],
+ getText: (kind: TKind) => string,
+ ): AxisLabel | null {
+ return this.getInteractiveAxisLabel(kind, kinds, (labelKind) => {
+ const coordinate = this.getGeometry()?.[labelKind] ?? null;
+
+ return this.createAxisLabel(coordinate, getText(labelKind));
+ });
+ }
+}
diff --git a/src/core/Drawings/SeriesDrawingBase.ts b/src/core/Drawings/base/DrawingBase.ts
similarity index 53%
rename from src/core/Drawings/SeriesDrawingBase.ts
rename to src/core/Drawings/base/DrawingBase.ts
index 20d57ff0af50e013aab7fc2d8dd21d6568d4bd04..cb4076f3c59c49979072126b4a6e8ec72ab5610d 100644
--- a/src/core/Drawings/SeriesDrawingBase.ts
+++ b/src/core/Drawings/base/DrawingBase.ts
@@ -1,6 +1,30 @@
+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,
- CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
@@ -9,21 +33,14 @@ import {
Logical,
MouseEventParams,
PrimitiveHoveredItem,
+ PrimitivePaneViewZOrder,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
-import { Observable, Subject, Subscription } from 'rxjs';
-
-import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
-import { getPointerPoint as getPointerPointFromEvent, getRawPointerPoint } from '@core/Drawings/helpers';
-import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
-
-import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
-
-import type { DrawingHandle } from '@core/Drawings/handles';
+import type { Observable } from 'rxjs';
export interface DrawingInteraction {
selected$: Observable<boolean>;
@@ -64,9 +81,7 @@ export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
getRenderData(): unknown;
}
-export type StartPoint = unknown;
-
-interface SeriesDrawingBaseParams {
+interface DrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
@@ -84,10 +99,8 @@ export interface BaseDrawingParams {
initialEvent?: MouseEventParams;
}
-export abstract class SeriesDrawingBase<
- TSettings extends SettingsValues = SettingsValues,
- THandleId extends string = string,
-> implements ISeriesDrawing
+export abstract class DrawingBase<TSettings extends SettingsValues = SettingsValues, THandleId extends string = string>
+ implements ISeriesDrawing
{
protected hidden = false;
protected chart: IChartApi;
@@ -107,7 +120,17 @@ export abstract class SeriesDrawingBase<
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
- constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
+ 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;
@@ -147,10 +170,8 @@ export abstract class SeriesDrawingBase<
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();
}
@@ -245,7 +266,6 @@ export abstract class SeriesDrawingBase<
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
-
return;
}
@@ -253,7 +273,6 @@ export abstract class SeriesDrawingBase<
if (this.getHoveredItem(point.x, point.y)) {
this.select();
-
return;
}
@@ -291,12 +310,35 @@ export abstract class SeriesDrawingBase<
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
- public abstract updateAllViews(): void;
- public abstract paneViews(): readonly IPrimitivePaneView[];
- public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
- public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
- public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
- public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
+ 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();
@@ -306,6 +348,14 @@ export abstract class SeriesDrawingBase<
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();
}
@@ -326,6 +376,103 @@ export abstract class SeriesDrawingBase<
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?.();
@@ -376,12 +523,29 @@ export abstract class SeriesDrawingBase<
}
// 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 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
@@ -390,6 +554,85 @@ export abstract class SeriesDrawingBase<
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;
diff --git a/src/core/Drawings/base/LinearDrawingBase.ts b/src/core/Drawings/base/LinearDrawingBase.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1d91ed0c9bfaadcbea456638ecd57e2ef6c2d247
--- /dev/null
+++ b/src/core/Drawings/base/LinearDrawingBase.ts
@@ -0,0 +1,165 @@
+import { getDistanceToSegment } from '@core/Drawings/utils';
+
+import { TwoPointDrawingBase, TwoPointGeometry } from './TwoPointDrawingBase';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisSegment, Point } from '@core/Drawings/types';
+import type { SettingsValues } from '@src/types';
+
+export abstract class LinearDrawingBase<
+ TSettings extends SettingsValues = SettingsValues,
+ THandleId extends string = string,
+ TState = unknown,
+> extends TwoPointDrawingBase<TSettings, THandleId> {
+ protected dragPointerId: number | null = null;
+
+ protected dragStartPoint: Point | null = null;
+
+ protected dragStateSnapshot: TState | null = null;
+
+ public abstract getState(): TState;
+
+ protected abstract getStartHandleId(): THandleId;
+
+ protected abstract getEndHandleId(): THandleId;
+
+ protected getLineHandleStrokeColor(): string | undefined {
+ return undefined;
+ }
+
+ protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
+ const geometry = this.getTwoPointGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ const strokeColor = this.getLineHandleStrokeColor();
+
+ const handleStyle = strokeColor
+ ? {
+ strokeColor,
+ }
+ : {};
+
+ return [
+ {
+ id: this.getStartHandleId(),
+ ...geometry.startPoint,
+ shape: 'circle',
+ ...handleStyle,
+ },
+ {
+ id: this.getEndHandleId(),
+ ...geometry.endPoint,
+ shape: 'circle',
+ ...handleStyle,
+ },
+ ];
+ }
+
+ protected getTimeAxisSegments(): AxisSegment[] {
+ return this.getBoundsAxisSegments('time', () => this.getTwoPointGeometry());
+ }
+
+ protected getPriceAxisSegments(): AxisSegment[] {
+ return this.getBoundsAxisSegments('price', () => this.getTwoPointGeometry());
+ }
+
+ protected isPointNearLine(point: Point, tolerance: number): boolean {
+ const geometry = this.getTwoPointGeometry();
+
+ if (!geometry) {
+ return false;
+ }
+
+ return getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= tolerance;
+ }
+
+ protected startDrawing(point: Point): void {
+ const anchor = this.createAnchor(point);
+
+ if (!anchor) {
+ return;
+ }
+
+ this.setAnchors(anchor, anchor);
+ this.mode = 'drawing';
+ this.render();
+ }
+
+ protected updateDrawing(point: Point): void {
+ if (!this.setAnchorFromPoint('end', point)) {
+ return;
+ }
+
+ this.render();
+ }
+
+ protected startDragging(mode: `dragging-${THandleId | 'body'}`, point: Point, pointerId: number): void {
+ this.mode = mode;
+ this.dragPointerId = pointerId;
+ this.dragStartPoint = point;
+ this.dragStateSnapshot = this.getState();
+ this.hideCrosshair();
+ this.render();
+ }
+
+ protected finishDragging(): void {
+ this.mode = 'ready';
+ this.resolveReady?.();
+ this.dragPointerId = null;
+ this.dragStartPoint = null;
+ this.dragStateSnapshot = null;
+ this.showCrosshair();
+ this.render();
+ }
+
+ protected abstract finishDrawing(): void;
+ protected abstract handleReadyPointerDown(event: PointerEvent, point: Point): void;
+
+ protected handlePointerDown = (event: PointerEvent): void => {
+ if (this.hidden || event.button !== 0) {
+ return;
+ }
+
+ const point = this.getEventPoint(event);
+
+ if (this.mode === 'idle' || this.mode === 'drawing') {
+ this.consumeEvent(event);
+
+ if (this.mode === 'idle') {
+ this.startDrawing(point);
+ } else {
+ this.updateDrawing(point);
+ this.finishDrawing();
+ }
+
+ return;
+ }
+
+ if (this.mode === 'ready') {
+ this.handleReadyPointerDown(event, point);
+ }
+ };
+
+ protected finishLinearDrawing(geometry: TwoPointGeometry | null, minimumSize: number, removeSelf: () => void): void {
+ if (!geometry) {
+ return;
+ }
+
+ const lineSize = Math.hypot(
+ geometry.endPoint.x - geometry.startPoint.x,
+ geometry.endPoint.y - geometry.startPoint.y,
+ );
+
+ if (lineSize < minimumSize) {
+ removeSelf();
+ return;
+ }
+
+ this.mode = 'ready';
+ this.resolveReady?.();
+ this.render();
+ }
+}
diff --git a/src/core/Drawings/base/TwoPointDrawingBase.ts b/src/core/Drawings/base/TwoPointDrawingBase.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ea5c7293ae518b3eed0e8800afb06f779f6b65a9
--- /dev/null
+++ b/src/core/Drawings/base/TwoPointDrawingBase.ts
@@ -0,0 +1,252 @@
+import { clamp } from 'lodash-es';
+
+import {
+ clampPointToContainer as clampPointToContainerInElement,
+ getContainerSize as getElementContainerSize,
+ getPriceDelta,
+ shiftTimeByPixels,
+} from '@core/Drawings/helpers';
+import { DrawingBase } from '@src/core/Drawings/base/DrawingBase';
+
+import type { Anchor, Bounds, ContainerSize, Point } from '@core/Drawings/types';
+import type { SettingsValues } from '@src/types';
+import type { Time } from 'lightweight-charts';
+
+export type TwoPointAnchorKind = 'start' | 'end';
+
+export interface TwoPointGeometry extends Bounds {
+ startPoint: Point;
+ endPoint: Point;
+}
+
+export abstract class TwoPointDrawingBase<
+ TSettings extends SettingsValues = SettingsValues,
+ THandleId extends string = string,
+> extends DrawingBase<TSettings, THandleId> {
+ protected startAnchor: Anchor | null = null;
+ protected endAnchor: Anchor | null = null;
+
+ protected getAnchor(kind: TwoPointAnchorKind): Anchor | null {
+ return kind === 'start' ? this.startAnchor : this.endAnchor;
+ }
+
+ protected setAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
+ if (kind === 'start') {
+ this.startAnchor = anchor;
+ return;
+ }
+
+ this.endAnchor = anchor;
+ }
+
+ protected setAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
+ this.startAnchor = startAnchor;
+ this.endAnchor = endAnchor;
+ }
+
+ protected createAnchor(point: Point): Anchor | null {
+ return this.createAnchorFromPoint(point);
+ }
+
+ protected setAnchorFromPoint(kind: TwoPointAnchorKind, point: Point): boolean {
+ const anchor = this.createAnchor(point);
+
+ if (!anchor) {
+ return false;
+ }
+
+ this.setAnchor(kind, anchor);
+
+ return true;
+ }
+
+ protected setAnchorsFromPoints(startPoint: Point, endPoint: Point): boolean {
+ const startAnchor = this.createAnchor(startPoint);
+ const endAnchor = this.createAnchor(endPoint);
+
+ if (!startAnchor || !endAnchor) {
+ return false;
+ }
+
+ this.setAnchors(startAnchor, endAnchor);
+
+ return true;
+ }
+
+ protected getAnchorTimeCoordinate(kind: TwoPointAnchorKind): number | null {
+ const point = this.getPointFromAnchor(this.getAnchor(kind));
+
+ return point?.x ?? null;
+ }
+
+ protected getAnchorPriceCoordinate(kind: TwoPointAnchorKind): number | null {
+ const point = this.getPointFromAnchor(this.getAnchor(kind));
+
+ return point?.y ?? null;
+ }
+
+ protected moveAnchorsByPixels(
+ startAnchor: Anchor | null,
+ endAnchor: Anchor | null,
+ dragStartPoint: Point | null,
+ point: Point,
+ ): boolean {
+ if (!dragStartPoint) {
+ return false;
+ }
+
+ const offsetX = point.x - dragStartPoint.x;
+ const priceOffset = getPriceDelta(this.series, dragStartPoint.y, point.y);
+
+ return this.moveAnchors(startAnchor, endAnchor, offsetX, priceOffset);
+ }
+
+ protected moveAnchorsWithinContainer(
+ startAnchor: Anchor | null,
+ endAnchor: Anchor | null,
+ dragStartPoint: Point | null,
+ point: Point,
+ bounds: Bounds,
+ ): boolean {
+ if (!dragStartPoint) {
+ return false;
+ }
+
+ const { width, height } = this.getContainerSize();
+ const rawOffsetX = point.x - dragStartPoint.x;
+ const rawOffsetY = point.y - dragStartPoint.y;
+ const offsetX = clamp(rawOffsetX, -bounds.left, width - bounds.right);
+ const offsetY = clamp(rawOffsetY, -bounds.top, height - bounds.bottom);
+ const priceOffset = getPriceDelta(this.series, dragStartPoint.y, dragStartPoint.y + offsetY);
+
+ return this.moveAnchors(startAnchor, endAnchor, offsetX, priceOffset);
+ }
+
+ protected getContainerSize(): ContainerSize {
+ return getElementContainerSize(this.container);
+ }
+
+ protected clampPointToContainer(point: Point): Point {
+ return clampPointToContainerInElement(point, this.container);
+ }
+
+ protected getTwoPointGeometry(): TwoPointGeometry | 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),
+ };
+ }
+
+ private moveAnchors(
+ startAnchor: Anchor | null,
+ endAnchor: Anchor | null,
+ offsetX: number,
+ priceOffset: number,
+ ): boolean {
+ if (!startAnchor || !endAnchor) {
+ return false;
+ }
+
+ const nextStartTime = shiftTimeByPixels(this.chart, startAnchor.time, offsetX, this.series);
+ const nextEndTime = shiftTimeByPixels(this.chart, endAnchor.time, offsetX, this.series);
+
+ if (nextStartTime === null || nextEndTime === null) {
+ return false;
+ }
+
+ this.startAnchor = {
+ time: nextStartTime,
+ price: startAnchor.price + priceOffset,
+ };
+
+ this.endAnchor = {
+ time: nextEndTime,
+ price: endAnchor.price + priceOffset,
+ };
+
+ return true;
+ }
+
+ protected restoreAnchor(
+ kind: TwoPointAnchorKind,
+ state: Partial<{
+ startTime: Anchor['time'] | null;
+ endTime: Anchor['time'] | null;
+ startPrice: number | null;
+ endPrice: number | null;
+ }>,
+ ): void {
+ const timeKey = kind === 'start' ? 'startTime' : 'endTime';
+ const priceKey = kind === 'start' ? 'startPrice' : 'endPrice';
+
+ if (!(timeKey in state) && !(priceKey in state)) {
+ return;
+ }
+
+ const anchor = this.getAnchor(kind);
+ const time = timeKey in state ? (state[timeKey] ?? null) : (anchor?.time ?? null);
+ const price = priceKey in state ? (state[priceKey] ?? null) : (anchor?.price ?? null);
+ this.setAnchor(kind, time !== null && price !== null ? { time, price } : null);
+ }
+
+ protected 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;
+ }
+
+ protected findIndexByTime(time: Time): number {
+ const data = this.series.data() ?? [];
+
+ return data.findIndex((item) => Number(item.time) === Number(time));
+ }
+}
diff --git a/src/core/Drawings/base/index.ts b/src/core/Drawings/base/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..21fce817a94cab8620d0efb3bd5dcef537ba9d87
--- /dev/null
+++ b/src/core/Drawings/base/index.ts
@@ -0,0 +1,8 @@
+export { AreaDrawingBase } from './AreaDrawingBase';
+export { DrawingBase } from './DrawingBase';
+export { LinearDrawingBase } from './LinearDrawingBase';
+export { TwoPointDrawingBase } from './TwoPointDrawingBase';
+
+export type { BaseDrawingParams, DrawingInteraction, ISeriesDrawing } from './DrawingBase';
+
+export type { TwoPointAnchorKind, TwoPointGeometry } from './TwoPointDrawingBase';
diff --git a/src/core/Drawings/diapson/diapson.ts b/src/core/Drawings/diapson/diapson.ts
index cb50bb26ff0c556a9b554ec5f86a686935a14d81..a97859b80bfba9a1ef7d8c215a340ded34e99ae4 100644
--- a/src/core/Drawings/diapson/diapson.ts
+++ b/src/core/Drawings/diapson/diapson.ts
@@ -1,30 +1,14 @@
+import { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- clampPointToContainer as clampPointToContainerInElement,
- getAnchorFromPoint,
- getContainerSize as getElementContainerSize,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- isPointInBounds,
-} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
+import { isPointInBounds } from '@core/Drawings/helpers';
+
+import { AreaDrawingBase, BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/base';
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 { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
+import { formatDate, formatPercent, formatPrice, formatSignedNumber, formatVolume } from '@src/utils';
import { DiapsonPaneView } from './paneView';
-
import {
createDefaultSettings,
DiapsonSettings,
@@ -34,10 +18,7 @@ import {
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
-import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab } from '@src/types';
-import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
+import type { AxisLabel, Point } from '@core/Drawings/types';
export type DiapsonRangeMode = 'date' | 'price';
@@ -97,22 +78,16 @@ const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
-export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
+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;
-
- private startTime: Time | null = null;
- private endTime: Time | null = null;
- private startPrice: number | null = null;
- private endPrice: number | null = null;
- private activeDragTarget: DiapsonHandle = null;
- private dragPointerId: number | null = null;
+ private rangeMode: DiapsonRangeMode;
+ protected activeDragTarget: DiapsonHandle = null;
+ protected dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: DiapsonGeometry | null = null;
@@ -125,14 +100,6 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
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,
@@ -146,63 +113,23 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
stepLabel = '',
initialEvent,
}: DiapsonParams) {
- super({
- chart,
- series,
- container,
- interaction,
- });
+ 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.paneView = new DiapsonPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.leftTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'left',
- });
-
- this.rightTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'right',
- });
-
- this.topPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'top',
- });
-
- this.bottomPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'bottom',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -224,10 +151,10 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
hidden: this.hidden,
mode: this.mode,
rangeMode: this.rangeMode,
- startTime: this.startTime,
- endTime: this.endTime,
- startPrice: this.startPrice,
- endPrice: this.endPrice,
+ startTime: this.startAnchor?.time ?? null,
+ endTime: this.endAnchor?.time ?? null,
+ startPrice: this.startAnchor?.price ?? null,
+ endPrice: this.endAnchor?.price ?? null,
settings: { ...this.settings },
};
}
@@ -238,14 +165,11 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
}
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.startTime = 'startTime' in nextState ? (nextState.startTime ?? null) : this.startTime;
- this.endTime = 'endTime' in nextState ? (nextState.endTime ?? null) : this.endTime;
- this.startPrice = 'startPrice' in nextState ? (nextState.startPrice ?? null) : this.startPrice;
- this.endPrice = 'endPrice' in nextState ? (nextState.endPrice ?? null) : this.endPrice;
+ this.restoreAnchor('start', nextState);
+ this.restoreAnchor('end', nextState);
if ('settings' in nextState && nextState.settings) {
this.settings = {
@@ -261,38 +185,6 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
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;
@@ -334,94 +226,12 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
];
}
- protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const bounds = this.getTimeBounds();
-
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.left,
- to: bounds.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
- protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const bounds = this.getPriceBounds();
-
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.top,
- to: bounds.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'left' && kind !== 'right')) {
- return null;
- }
-
- const labelKind = kind as TimeLabelKind;
- const coordinate = this.getTimeCoordinate(labelKind);
- const text = this.getTimeText(labelKind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getBoundsAxisLabel(kind, ['left', 'right'], (labelKind) => this.getTimeText(labelKind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
- return null;
- }
-
- const labelKind = kind as PriceLabelKind;
- const coordinate = this.getPriceCoordinate(labelKind);
- const text = this.getPriceText(labelKind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getBoundsAxisLabel(kind, ['top', 'bottom'], (labelKind) => this.getPriceText(labelKind));
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
@@ -464,98 +274,49 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
};
}
- 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);
+ protected getGeometry(): DiapsonGeometry | null {
+ const rawStartPoint = this.getPointFromAnchor(this.startAnchor);
+ const rawEndPoint = this.getPointFromAnchor(this.endAnchor);
- if (!dragTarget) {
- this.deselect();
- return;
+ if (!rawStartPoint || !rawEndPoint) {
+ return null;
}
- event.preventDefault();
- event.stopPropagation();
-
- this.startDragging(point, event.pointerId, dragTarget);
- };
-
- protected handlePointerMove = (event: PointerEvent): void => {
- const point = this.getEventPoint(event);
+ const { width, height } = this.getContainerSize();
- if (this.mode === 'drawing') {
- this.updateDrawing(point);
- return;
- }
+ const startPoint = {
+ x: clamp(Math.round(rawStartPoint.x), 0, width),
+ y: clamp(Math.round(rawStartPoint.y), 0, height),
+ };
- if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
- return;
- }
+ const endPoint = {
+ x: clamp(Math.round(rawEndPoint.x), 0, width),
+ y: clamp(Math.round(rawEndPoint.y), 0, height),
+ };
- event.preventDefault();
+ 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);
- if (this.activeDragTarget === 'body') {
- this.moveWhole(point);
- this.render();
- return;
- }
+ return {
+ left,
+ right,
+ top,
+ bottom,
+ width: right - left,
+ height: bottom - top,
+ startPoint,
+ endPoint,
+ };
+ }
- this.resizeRectangle(point);
- this.render();
+ protected handleDoubleClick = (event: MouseEvent): void => {
+ this.openSettingsOnDoubleClick(
+ event,
+ (point) => this.getDrawingHandleAtPoint(point) !== null || this.containsPoint(point),
+ () => this.openSettings?.(),
+ );
};
protected handlePointerUp = (event: PointerEvent): void => {
@@ -566,36 +327,29 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
this.finishDragging();
};
- private startDrawing(point: Point): void {
+ protected startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
- this.startTime = anchor.time;
- this.endTime = anchor.time;
- this.startPrice = anchor.price;
- this.endPrice = anchor.price;
-
+ this.setAnchors(anchor, anchor);
this.mode = 'drawing';
this.render();
}
- private updateDrawing(point: Point): void {
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ protected updateDrawing(point: Point): void {
+ const nextPoint = this.clampPointToContainer(point);
- if (!anchor) {
+ if (!this.setAnchorFromPoint('end', nextPoint)) {
return;
}
- this.endTime = anchor.time;
- this.endPrice = anchor.price;
-
this.render();
}
- private finishDrawing(): void {
+ protected finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
@@ -615,24 +369,21 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
- private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
+ 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();
}
@@ -648,19 +399,16 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
this.hidden = false;
this.deselect();
this.mode = 'idle';
- this.startTime = null;
- this.endTime = null;
- this.startPrice = null;
- this.endPrice = null;
+ this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
- private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
+ protected getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
- private moveWhole(point: Point): void {
+ protected moveWhole(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
@@ -669,10 +417,8 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
}
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);
@@ -689,7 +435,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
}
- private resizeRectangle(point: Point): void {
+ protected resize(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
@@ -706,67 +452,6 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
}
- private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
- const startAnchor = this.createAnchor(startPoint);
- const endAnchor = this.createAnchor(endPoint);
-
- if (!startAnchor || !endAnchor) {
- return;
- }
-
- this.startTime = startAnchor.time;
- this.startPrice = startAnchor.price;
- this.endTime = endAnchor.time;
- this.endPrice = endAnchor.price;
- }
-
- private createAnchor(point: Point): Anchor | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
- protected getGeometry(): DiapsonGeometry | null {
- if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
- return null;
- }
-
- const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
- const startY = getYCoordinateFromPrice(this.series, this.startPrice);
- const endY = getYCoordinateFromPrice(this.series, this.endPrice);
-
- if (startX === null || endX === null || startY === null || endY === null) {
- return null;
- }
-
- const { width, height } = this.getContainerSize();
-
- const startPoint = {
- x: clamp(Math.round(Number(startX)), 0, width),
- y: clamp(Math.round(Number(startY)), 0, height),
- };
-
- const endPoint = {
- x: clamp(Math.round(Number(endX)), 0, width),
- y: clamp(Math.round(Number(endY)), 0, height),
- };
-
- const left = Math.round(Math.min(startPoint.x, endPoint.x));
- const right = Math.round(Math.max(startPoint.x, endPoint.x));
- const top = Math.round(Math.min(startPoint.y, endPoint.y));
- const bottom = Math.round(Math.max(startPoint.y, endPoint.y));
-
- return {
- left,
- right,
- top,
- bottom,
- width: right - left,
- height: bottom - top,
- startPoint,
- endPoint,
- };
- }
-
private getLabelLines(): string[] {
if (this.rangeMode === 'date') {
const metrics = this.getDateMetrics();
@@ -821,7 +506,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
const leftTime = this.getLeftTimeValue();
const rightTime = this.getRightTimeValue();
- if (!leftTime || !rightTime) {
+ if (leftTime === null || rightTime === null) {
return null;
}
@@ -831,7 +516,6 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
const hours = Math.floor((durationSeconds % 86400) / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
-
const elapsedParts: string[] = [];
if (days > 0) {
@@ -860,26 +544,26 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
}
private getPriceMetrics(): PriceMetrics | null {
- if (this.startPrice === null || this.endPrice === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const delta = this.endPrice - this.startPrice;
+ const delta = this.endAnchor.price - this.startAnchor.price;
return {
delta,
- percent: this.startPrice !== 0 ? (delta / Math.abs(this.startPrice)) * 100 : 0,
+ percent: this.startAnchor.price !== 0 ? (delta / Math.abs(this.startAnchor.price)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
- if (this.startTime === null || this.endTime === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return 0;
}
- const startIndex = this.findIndexByTime(this.startTime);
- const endIndex = this.findIndexByTime(this.endTime);
+ const startIndex = this.findIndexByTime(this.startAnchor.time);
+ const endIndex = this.findIndexByTime(this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return 0;
@@ -888,107 +572,10 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
return Math.abs(endIndex - startIndex);
}
- private getVolumeInRange(): number {
- if (this.startTime === null || this.endTime === null) {
- return 0;
- }
-
- const data = this.series.data() ?? [];
-
- if (!data.length) {
- return 0;
- }
-
- const startIndex = this.findIndexByTime(this.startTime);
- const endIndex = this.findIndexByTime(this.endTime);
-
- if (startIndex < 0 || endIndex < 0) {
- return 0;
- }
-
- const from = Math.min(startIndex, endIndex);
- const to = Math.max(startIndex, endIndex);
-
- let volume = 0;
-
- for (let index = from; index <= to; index += 1) {
- const item = data[index] as unknown as Record<string, unknown> | undefined;
-
- if (!item) {
- continue;
- }
-
- if (typeof item.volume === 'number') {
- volume += item.volume;
- continue;
- }
-
- const customValues = item.customValues as Record<string, unknown> | undefined;
-
- if (customValues && typeof customValues.volume === 'number') {
- volume += customValues.volume;
- }
- }
-
- return volume;
- }
-
- private findIndexByTime(time: Time): number {
- const data = this.series.data() ?? [];
-
- return data.findIndex((item) => Number(item.time) === Number(time));
- }
-
- private getTimeBounds(): { left: number; right: number } | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return {
- left: geometry.left,
- right: geometry.right,
- };
- }
-
- private getPriceBounds(): { top: number; bottom: number } | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return {
- top: geometry.top,
- bottom: geometry.bottom,
- };
- }
-
- private getTimeCoordinate(kind: TimeLabelKind): number | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return kind === 'left' ? geometry.left : geometry.right;
- }
-
- private getPriceCoordinate(kind: PriceLabelKind): number | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return kind === 'top' ? geometry.top : geometry.bottom;
- }
-
private getTimeText(kind: TimeLabelKind): string {
const time = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
- if (!time) {
+ if (typeof time !== 'number') {
return '';
}
@@ -1003,81 +590,69 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
private getPriceText(kind: PriceLabelKind): string {
const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
- if (price === null) {
- return '';
- }
-
- return formatPrice(price) ?? '';
+ return price === null ? '' : (formatPrice(price) ?? '');
}
private getLeftTimeValue(): Time | null {
- if (this.startTime === null || this.endTime === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
+ const geometry = this.getTwoPointGeometry();
- if (startX === null || endX === null) {
- return this.startTime;
+ if (!geometry) {
+ return this.startAnchor.time;
}
- return Number(startX) <= Number(endX) ? this.startTime : this.endTime;
+ return geometry.startPoint.x <= geometry.endPoint.x ? this.startAnchor.time : this.endAnchor.time;
}
private getRightTimeValue(): Time | null {
- if (this.startTime === null || this.endTime === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
+ const geometry = this.getTwoPointGeometry();
- if (startX === null || endX === null) {
- return this.endTime;
+ if (!geometry) {
+ return this.endAnchor.time;
}
- return Number(startX) <= Number(endX) ? this.endTime : this.startTime;
+ return geometry.startPoint.x <= geometry.endPoint.x ? this.endAnchor.time : this.startAnchor.time;
}
private getTopPriceValue(): number | null {
- if (this.startPrice === null || this.endPrice === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const startY = getYCoordinateFromPrice(this.series, this.startPrice);
- const endY = getYCoordinateFromPrice(this.series, this.endPrice);
+ const geometry = this.getTwoPointGeometry();
- if (startY === null || endY === null) {
- return Math.max(this.startPrice, this.endPrice);
+ if (!geometry) {
+ return Math.max(this.startAnchor.price, this.endAnchor.price);
}
- return Number(startY) <= Number(endY) ? this.startPrice : this.endPrice;
+ return geometry.startPoint.y <= geometry.endPoint.y ? this.startAnchor.price : this.endAnchor.price;
}
private getBottomPriceValue(): number | null {
- if (this.startPrice === null || this.endPrice === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const startY = getYCoordinateFromPrice(this.series, this.startPrice);
- const endY = getYCoordinateFromPrice(this.series, this.endPrice);
+ const geometry = this.getTwoPointGeometry();
- if (startY === null || endY === null) {
- return Math.min(this.startPrice, this.endPrice);
+ if (!geometry) {
+ return Math.min(this.startAnchor.price, this.endAnchor.price);
}
- return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
+ return geometry.startPoint.y <= geometry.endPoint.y ? this.endAnchor.price : this.startAnchor.price;
}
- private containsPoint(point: Point): boolean {
+ protected containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
- if (!geometry) {
- return false;
- }
-
- return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
+ return geometry !== null && isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<DiapsonHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
@@ -1097,12 +672,4 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey
return sameDirection ? 'nwse-resize' : 'nesw-resize';
}
-
- private getContainerSize(): { width: number; height: number } {
- return getElementContainerSize(this.container);
- }
-
- private clampPointToContainer(point: Point): Point {
- return clampPointToContainerInElement(point, this.container);
- }
}
diff --git a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
index 49278926a26f4ff43fc812452ed81713d55b96ae..a288a14cad591c7118567b6e2718e3ac59e7ea6b 100644
--- a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
+++ b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
@@ -1,28 +1,10 @@
-import { clamp } from 'lodash-es';
+import { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- clampPointToContainer as clampPointToContainerInElement,
- getAnchorFromPoint,
- getContainerSize as getElementContainerSize,
- getPriceDelta as getPriceDeltaFromCoordinates,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- isPointInBounds,
- shiftTimeByPixels,
-} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { getYCoordinateFromPrice, isPointInBounds } from '@core/Drawings/helpers';
+import { AreaDrawingBase, BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/base';
+
+import { type ChartOptionsModel, Defaults, type SettingsTab, type SettingsValues } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { FibonacciRetracementPaneView } from './paneView';
import {
@@ -37,10 +19,7 @@ import {
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
-import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
-import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
+import type { Anchor, AxisLabel, Bounds, Point } from '@core/Drawings/types';
type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
@@ -60,6 +39,11 @@ interface FibonacciRetracementState {
settings: FibonacciRetracementSettings;
}
+interface FibonacciDragSnapshot {
+ startAnchor: Anchor | null;
+ endAnchor: Anchor | null;
+}
+
export interface FibonacciRetracementLevelRenderData {
id: string;
value: number;
@@ -102,7 +86,7 @@ const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
export class FibonacciRetracement
- extends SeriesDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
+ extends AreaDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
@@ -111,15 +95,10 @@ export class FibonacciRetracement
protected settings: FibonacciRetracementSettings = createDefaultSettings();
protected mode: FibonacciRetracementMode = 'idle';
- private startTime: Time | null = null;
- private endTime: Time | null = null;
- private startPrice: number | null = null;
- private endPrice: number | null = null;
-
- private activeDragTarget: FibonacciRetracementHandle = null;
- private dragPointerId: number | null = null;
+ protected activeDragTarget: FibonacciRetracementHandle = null;
+ protected dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
- private dragStateSnapshot: FibonacciRetracementState | null = null;
+ private dragAnchorSnapshot: FibonacciDragSnapshot | null = null;
private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;
private displayFormat: ChartOptionsModel = {
@@ -128,14 +107,6 @@ export class FibonacciRetracement
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,
@@ -150,51 +121,16 @@ export class FibonacciRetracement
this.removeSelf = removeSelf;
this.openSettings = openSettings;
+ this.initializeViews(new FibonacciRetracementPaneView(this), ['start', 'end'], ['top', 'bottom']);
- this.paneView = new FibonacciRetracementPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'end',
- });
-
- this.topPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'top',
- });
-
- this.bottomPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'bottom',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -206,10 +142,10 @@ export class FibonacciRetracement
return {
hidden: this.hidden,
mode: this.mode,
- startTime: this.startTime,
- endTime: this.endTime,
- startPrice: this.startPrice,
- endPrice: this.endPrice,
+ startTime: this.startAnchor?.time ?? null,
+ endTime: this.endAnchor?.time ?? null,
+ startPrice: this.startAnchor?.price ?? null,
+ endPrice: this.endAnchor?.price ?? null,
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
@@ -220,14 +156,10 @@ export class FibonacciRetracement
}
const next = state as Partial<FibonacciRetracementState>;
-
this.hidden = next.hidden ?? this.hidden;
this.mode = next.mode ?? this.mode;
-
- this.startTime = next.startTime ?? this.startTime;
- this.endTime = next.endTime ?? this.endTime;
- this.startPrice = next.startPrice ?? this.startPrice;
- this.endPrice = next.endPrice ?? this.endPrice;
+ this.restoreStartAnchor(next);
+ this.restoreEndAnchor(next);
if (next.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), next.settings);
@@ -249,38 +181,6 @@ export class FibonacciRetracement
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();
@@ -351,177 +251,24 @@ export class FibonacciRetracement
};
}
- protected getTimeAxisSegments(): AxisSegment[] {
- const bounds = this.isSelected() || this.isCreationPending() ? this.getTimeBounds() : null;
-
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.left,
- to: bounds.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
- protected getPriceAxisSegments(): AxisSegment[] {
- const bounds = this.isSelected() || this.isCreationPending() ? this.getPriceBounds() : null;
-
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.top,
- to: bounds.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
- return null;
- }
-
- const coordinate = this.getTimeCoordinate(kind);
- const text = this.getTimeText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getInteractiveAxisLabel(kind, ['start', 'end'], (labelKind) => {
+ return this.createAxisLabel(this.getAnchorTimeCoordinate(labelKind), this.getTimeText(labelKind));
+ });
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
- return null;
- }
-
- const coordinate = this.getPriceCoordinate(kind);
- const text = this.getPriceText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getInteractiveAxisLabel(kind, ['top', 'bottom'], (labelKind) => {
+ 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();
+ this.openSettingsOnDoubleClick(
+ event,
+ (point) => Boolean(this.containsPoint(point) || this.getDrawingHandleAtPoint(point)),
+ () => this.openSettings?.(),
+ );
};
protected handlePointerUp = (event: PointerEvent): void => {
@@ -531,41 +278,36 @@ export class FibonacciRetracement
this.mode = 'ready';
this.resolveReady?.();
-
this.clearInteractionState();
this.render();
};
- private startDrawing(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.startTime = anchor.time;
- this.endTime = anchor.time;
- this.startPrice = anchor.price;
- this.endPrice = anchor.price;
- this.mode = 'drawing';
-
- this.render();
- }
-
- private updateDrawing(point: Point): void {
- const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
+ protected getGeometry(): FibonacciRetracementGeometry | null {
+ const baseGeometry = this.getTwoPointGeometry();
- if (!anchor) {
- return;
+ if (!baseGeometry) {
+ return null;
}
- this.endTime = anchor.time;
- this.endPrice = anchor.price;
+ 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));
- this.render();
+ 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 finishDrawing(): void {
+ protected finishDrawing(): void {
const geometry = this.getGeometry();
if (
@@ -584,19 +326,23 @@ export class FibonacciRetracement
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
- private startDragging(point: Point, pointerId: number, dragTarget: Exclude<FibonacciRetracementHandle, null>): void {
+ protected startDragging(
+ point: Point,
+ pointerId: number,
+ dragTarget: Exclude<FibonacciRetracementHandle, null>,
+ ): void {
this.mode = 'dragging';
-
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
- this.dragStateSnapshot = this.getState();
+ this.dragAnchorSnapshot = {
+ startAnchor: this.startAnchor,
+ endAnchor: this.endAnchor,
+ };
this.dragGeometrySnapshot = this.getGeometry();
-
this.render();
}
@@ -604,148 +350,43 @@ export class FibonacciRetracement
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
- this.dragStateSnapshot = null;
+ this.dragAnchorSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
-
- this.startTime = null;
- this.endTime = null;
- this.startPrice = null;
- this.endPrice = null;
-
+ this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
- private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
+ protected 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.dragStateSnapshot;
+ protected moveWhole(point: Point): void {
+ const snapshot = this.dragAnchorSnapshot;
const geometry = this.dragGeometrySnapshot;
- if (!snapshot || !geometry || !this.dragStartPoint) {
+ if (!snapshot || !geometry) {
return;
}
- if (
- snapshot.startTime === null ||
- snapshot.endTime === null ||
- snapshot.startPrice === null ||
- snapshot.endPrice === null
- ) {
- return;
- }
-
- const containerSize = getElementContainerSize(this.container);
-
- const rawOffsetX = point.x - this.dragStartPoint.x;
- const rawOffsetY = point.y - this.dragStartPoint.y;
-
- const minOffsetX = -geometry.left;
- const maxOffsetX = containerSize.width - geometry.right;
- const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
-
- const minOffsetY = -geometry.top;
- const maxOffsetY = containerSize.height - geometry.bottom;
- const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
-
- const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startTime, clampedOffsetX, this.series);
- const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endTime, clampedOffsetX, this.series);
-
- if (nextStartTime === null || nextEndTime === null) {
- return;
- }
-
- const priceOffset = getPriceDeltaFromCoordinates(
- this.series,
- this.dragStartPoint.y,
- this.dragStartPoint.y + clampedOffsetY,
- );
-
- this.startTime = nextStartTime;
- this.endTime = nextEndTime;
- this.startPrice = snapshot.startPrice + priceOffset;
- this.endPrice = snapshot.endPrice + priceOffset;
+ this.moveAnchorsWithinContainer(snapshot.startAnchor, snapshot.endAnchor, this.dragStartPoint, point, geometry);
}
- private resize(point: Point): void {
+ protected resize(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
- const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
-
- if (!anchor) {
- return;
- }
-
- if (this.activeDragTarget === 'start') {
- this.startTime = anchor.time;
- this.startPrice = anchor.price;
- return;
- }
-
- this.endTime = anchor.time;
- this.endPrice = anchor.price;
- }
-
- private createAnchor(point: Point): { time: Time; price: number } | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
- protected getGeometry(): FibonacciRetracementGeometry | null {
- if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
- return null;
- }
-
- const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
- const startY = getYCoordinateFromPrice(this.series, this.startPrice);
- const endY = getYCoordinateFromPrice(this.series, this.endPrice);
-
- if (startX === null || endX === null || startY === null || endY === null) {
- return null;
- }
-
- const startPoint = {
- x: Math.round(Number(startX)),
- y: Math.round(Number(startY)),
- };
-
- const endPoint = {
- x: Math.round(Number(endX)),
- y: Math.round(Number(endY)),
- };
-
- const left = Math.round(Math.min(startPoint.x, endPoint.x));
- const right = Math.round(Math.max(startPoint.x, endPoint.x));
- const levels = this.getLevels();
-
- const top = Math.min(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
- const bottom = Math.max(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
-
- return {
- startPoint,
- endPoint,
- left,
- right,
- top,
- bottom,
- width: right - left,
- height: bottom - top,
- levels,
- areas: this.getAreas(levels),
- };
+ this.setAnchorFromPoint(this.activeDragTarget, this.clampPointToContainer(point));
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
- if (this.startPrice === null || this.endPrice === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return [];
}
@@ -761,7 +402,7 @@ export class FibonacciRetracement
id: level.id,
value: level.value,
price,
- y: Math.round(Number(y)),
+ y: Number(y),
color: level.color,
text: this.getLevelText(level.value, price),
});
@@ -771,8 +412,8 @@ export class FibonacciRetracement
}
private getLevelPrice(value: number): number {
- const startPrice = this.startPrice ?? 0;
- const endPrice = this.endPrice ?? 0;
+ const startPrice = this.startAnchor?.price ?? 0;
+ const endPrice = this.endAnchor?.price ?? 0;
return this.settings.reverse
? startPrice + (endPrice - startPrice) * value
@@ -811,28 +452,6 @@ export class FibonacciRetracement
return parts.join(' ');
}
- private getTimeBounds(): { left: number; right: number } | null {
- const geometry = this.getGeometry();
-
- return geometry ? { left: geometry.left, right: geometry.right } : null;
- }
-
- private getPriceBounds(): { top: number; bottom: number } | null {
- const geometry = this.getGeometry();
-
- return geometry ? { top: geometry.top, bottom: geometry.bottom } : null;
- }
-
- private getTimeCoordinate(kind: TimeLabelKind): number | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
- }
-
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
@@ -844,14 +463,14 @@ export class FibonacciRetracement
}
private getTimeText(kind: TimeLabelKind): string {
- const time = kind === 'start' ? this.startTime : this.endTime;
+ const anchor = this.getAnchor(kind);
- if (typeof time !== 'number') {
+ if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
- time as UTCTimestamp,
+ anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
@@ -867,7 +486,7 @@ export class FibonacciRetracement
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
- if (!geometry) {
+ if (!geometry || !this.startAnchor || !this.endAnchor) {
return null;
}
@@ -879,11 +498,11 @@ export class FibonacciRetracement
}
return kind === 'top'
- ? Math.max(this.startPrice ?? 0, this.endPrice ?? 0)
- : Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
+ ? Math.max(this.startAnchor.price, this.endAnchor.price)
+ : Math.min(this.startAnchor.price, this.endAnchor.price);
}
- private containsPoint(point: Point): boolean {
+ protected containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
@@ -905,4 +524,22 @@ export class FibonacciRetracement
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 };
+ }
+ }
}
diff --git a/src/core/Drawings/helpers.ts b/src/core/Drawings/helpers.ts
index c536f3f4f17c35fe264b456810a666e930b6a346..5c8c57f5e24192b4693023c3b4bfcfc9e03040f8 100644
--- a/src/core/Drawings/helpers.ts
+++ b/src/core/Drawings/helpers.ts
@@ -2,15 +2,15 @@ import { clamp } from 'lodash-es';
import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';
-import type { Coordinate, IChartApi, Logical, Time, TouchMouseEventData } from 'lightweight-charts';
+import type { Coordinate, IChartApi, Time, TouchMouseEventData } from 'lightweight-charts';
interface SeriesTimeItem {
time: Time;
}
-interface TimePoint {
- time: number;
- logical: number;
+interface SeriesTimePoint {
+ time: Time;
+ timestamp: number;
}
export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
@@ -36,19 +36,7 @@ export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: Se
return null;
}
- const logical = getNearestLogicalFromTime(series, time);
-
- if (logical === null) {
- return null;
- }
-
- const projectedCoordinate = chart.timeScale().logicalToCoordinate(logical as Logical);
-
- if (!isValidCoordinate(projectedCoordinate)) {
- return null;
- }
-
- return projectedCoordinate;
+ return getProjectedXCoordinate(chart, series, time);
}
export function getContainerSize(container: HTMLElement): ContainerSize {
@@ -124,6 +112,7 @@ export function shiftTimeByPixels(chart: IChartApi, time: Time, offsetX: number,
export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
const fromPrice = getPriceFromYCoordinate(series, fromY);
+
const toPrice = getPriceFromYCoordinate(series, toY);
if (fromPrice === null || toPrice === null) {
@@ -136,7 +125,10 @@ export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): nu
export function getPriceRangeInContainer(
series: SeriesApi,
container: HTMLElement,
-): { min: number; max: number } | null {
+): {
+ min: number;
+ max: number;
+} | null {
const { height } = getContainerSize(container);
if (!height) {
@@ -144,6 +136,7 @@ export function getPriceRangeInContainer(
}
const topPrice = getPriceFromYCoordinate(series, 0);
+
const bottomPrice = getPriceFromYCoordinate(series, height);
if (topPrice === null || bottomPrice === null) {
@@ -158,6 +151,7 @@ export function getPriceRangeInContainer(
export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
const time = getTimeFromXCoordinate(chart, point.x);
+
const price = getPriceFromYCoordinate(series, point.y);
if (time === null || price === null) {
@@ -170,7 +164,7 @@ export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: P
};
}
-function getNearestLogicalFromTime(series: SeriesApi, time: Time): number | null {
+function getProjectedXCoordinate(chart: IChartApi, series: SeriesApi, time: Time): Coordinate | null {
const targetTime = getNumericTime(time);
if (targetTime === null) {
@@ -183,14 +177,18 @@ function getNearestLogicalFromTime(series: SeriesApi, time: Time): number | null
return null;
}
+ if (points.length === 1) {
+ return getCoordinateForTime(chart, points[0].time);
+ }
+
const lastIndex = points.length - 1;
- if (targetTime <= points[0].time) {
- return points[0].logical;
+ if (targetTime <= points[0].timestamp) {
+ return interpolateCoordinate(chart, targetTime, points[0], points[1]);
}
- if (targetTime >= points[lastIndex].time) {
- return points[lastIndex].logical;
+ if (targetTime >= points[lastIndex].timestamp) {
+ return interpolateCoordinate(chart, targetTime, points[lastIndex - 1], points[lastIndex]);
}
let left = 0;
@@ -198,63 +196,73 @@ function getNearestLogicalFromTime(series: SeriesApi, time: Time): number | null
while (left <= right) {
const middleIndex = Math.floor((left + right) / 2);
- const middleTime = points[middleIndex].time;
- if (middleTime === targetTime) {
- return points[middleIndex].logical;
+ const middlePoint = points[middleIndex];
+
+ if (middlePoint.timestamp === targetTime) {
+ return getCoordinateForTime(chart, middlePoint.time);
}
- if (middleTime < targetTime) {
+ if (middlePoint.timestamp < targetTime) {
left = middleIndex + 1;
} else {
right = middleIndex - 1;
}
}
- // Если точного времени нет на текущем таймфрейме, left и right становятся соседними свечами вокруг targetTime
- // Для отображения дровинга берём ближайшую существующую свечу, но исходный state дровинга не меняем
- const previousPoint = points[right] ?? null;
- const nextPoint = points[left] ?? null;
-
- return getNearestLogicalByTime(targetTime, previousPoint, nextPoint);
+ return interpolateCoordinate(chart, targetTime, points[right], points[left]);
}
-function getNearestLogicalByTime(
+function interpolateCoordinate(
+ chart: IChartApi,
targetTime: number,
- previousPoint: TimePoint | null,
- nextPoint: TimePoint | null,
-): number | null {
- if (!previousPoint && !nextPoint) {
+ startPoint: SeriesTimePoint,
+ endPoint: SeriesTimePoint,
+): Coordinate | null {
+ const startCoordinate = getCoordinateForTime(chart, startPoint.time);
+
+ const endCoordinate = getCoordinateForTime(chart, endPoint.time);
+
+ if (startCoordinate === null || endCoordinate === null) {
return null;
}
- if (!previousPoint) {
- return nextPoint?.logical ?? null;
+ const timeRange = endPoint.timestamp - startPoint.timestamp;
+
+ if (timeRange === 0) {
+ return startCoordinate;
}
- if (!nextPoint) {
- return previousPoint.logical;
+ const ratio = (targetTime - startPoint.timestamp) / timeRange;
+
+ const coordinate = Number(startCoordinate) + (Number(endCoordinate) - Number(startCoordinate)) * ratio;
+
+ if (!Number.isFinite(coordinate)) {
+ return null;
}
- const previousDistance = Math.abs(targetTime - previousPoint.time);
- const nextDistance = Math.abs(nextPoint.time - targetTime);
+ return coordinate as Coordinate;
+}
+
+function getCoordinateForTime(chart: IChartApi, time: Time): Coordinate | null {
+ const coordinate = chart.timeScale().timeToCoordinate(time);
- return nextDistance < previousDistance ? nextPoint.logical : previousPoint.logical;
+ return isValidCoordinate(coordinate) ? coordinate : null;
}
-function getSeriesTimePoints(series: SeriesApi): TimePoint[] {
+function getSeriesTimePoints(series: SeriesApi): SeriesTimePoint[] {
const data = series.data() as readonly SeriesTimeItem[];
- return data.reduce<TimePoint[]>((points, item, logical) => {
- const time = getNumericTime(item.time);
+ return data.reduce<SeriesTimePoint[]>((points, item) => {
+ const timestamp = getNumericTime(item.time);
- if (time === null) {
+ if (timestamp === null) {
return points;
}
points.push({
- time,
- logical,
+ time: item.time,
+ timestamp,
});
return points;
diff --git a/src/core/Drawings/line/lineDrawing.ts b/src/core/Drawings/line/lineDrawing.ts
index 880c0f6d8a01cae6dd150e915715b91c7d0dc89f..128255b28d9bd901e225f6d8c796e10de919980b 100644
--- a/src/core/Drawings/line/lineDrawing.ts
+++ b/src/core/Drawings/line/lineDrawing.ts
@@ -1,44 +1,23 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
-
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- getAnchorFromPoint,
- getPriceDelta as getPriceDeltaFromCoordinates,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- shiftTimeByPixels,
-} from '@core/Drawings/helpers';
-import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
-import { type ChartOptionsModel, LineMarker, type SettingsTab } from '@src/types';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
+
+import { BaseDrawingParams, ISeriesDrawing, LinearDrawingBase, TwoPointGeometry } from '@src/core/Drawings/base';
+
+import { type ChartOptionsModel, Defaults, LineMarker, type SettingsTab } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { LineDrawingPaneView } from './paneView';
-import {
- createDefaultSettings,
- getLineDrawingSettingsTabs,
- LineDrawingMarkers,
- LineDrawingSettings,
- LineDrawingStyle,
- LineDrawingTextStyle,
-} from './settings';
-
-import type { DrawingHandle } from '@core/Drawings/handles';
-import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
+import { createDefaultSettings, getLineDrawingSettingsTabs } from './settings';
+
+import type { LineDrawingMarkers, LineDrawingSettings, LineDrawingStyle, LineDrawingTextStyle } from './settings';
+
+import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
+
type LineDrawingHandleKey = 'start' | 'end';
+
type TimeLabelKind = 'start' | 'end';
+
type PriceLabelKind = 'start' | 'end';
interface LineDrawingParams extends BaseDrawingParams {
@@ -53,52 +32,29 @@ interface LineDrawingState {
settings: LineDrawingSettings;
}
-interface LineDrawingGeometry {
- startPoint: Point;
- endPoint: Point;
- left: number;
- right: number;
- top: number;
- bottom: number;
-}
-
-export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}
+export interface LineDrawingRenderData extends TwoPointGeometry, LineDrawingStyle, LineDrawingTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class LineDrawing
- extends SeriesDrawingBase<LineDrawingSettings, LineDrawingHandleKey>
+ extends LinearDrawingBase<LineDrawingSettings, LineDrawingHandleKey, LineDrawingState>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
+
private readonly defaultMarkers: LineDrawingMarkers;
protected settings: LineDrawingSettings;
protected mode: LineDrawingMode = 'idle';
- private startAnchor: Anchor | null = null;
- private endAnchor: Anchor | null = null;
-
- private dragPointerId: number | null = null;
- private dragStartPoint: Point | null = null;
- private dragStateSnapshot: LineDrawingState | null = null;
-
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
- private readonly paneView: LineDrawingPaneView;
- private readonly timeAxisPaneView: CustomTimeAxisPaneView;
- private readonly priceAxisPaneView: CustomPriceAxisPaneView;
- private readonly startTimeAxisView: CustomTimeAxisView;
- private readonly endTimeAxisView: CustomTimeAxisView;
- private readonly startPriceAxisView: CustomPriceAxisView;
- private readonly endPriceAxisView: CustomPriceAxisView;
-
constructor({
chart,
series,
@@ -110,7 +66,12 @@ export class LineDrawing
initialEvent,
defaultMarkers = {},
}: LineDrawingParams) {
- super({ chart, series, container, interaction });
+ super({
+ chart,
+ series,
+ container,
+ interaction,
+ });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
@@ -122,51 +83,16 @@ export class LineDrawing
};
this.settings = createDefaultSettings(this.defaultMarkers);
+ this.initializeViews(new LineDrawingPaneView(this), ['start', 'end'], ['start', 'end']);
- this.paneView = new LineDrawingPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'end',
- });
-
- this.startPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'end',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -180,7 +106,9 @@ export class LineDrawing
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
- settings: { ...this.settings },
+ settings: {
+ ...this.settings,
+ },
};
}
@@ -221,38 +149,6 @@ export class LineDrawing
return getLineDrawingSettingsTabs(this.settings);
}
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- this.startTimeAxisView,
- this.endTimeAxisView,
- this.startPriceAxisView,
- this.endPriceAxisView,
- ]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
-
- public timeAxisViews() {
- return [this.startTimeAxisView, this.endTimeAxisView];
- }
-
- public priceAxisViews() {
- return [this.startPriceAxisView, this.endPriceAxisView];
- }
-
public getRenderData(): LineDrawingRenderData | null {
if (this.hidden) {
return null;
@@ -270,25 +166,16 @@ export class LineDrawing
};
}
- protected getDrawingHandles(): readonly DrawingHandle<LineDrawingHandleKey>[] {
- const geometry = this.getGeometry();
+ protected getGeometry(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
- if (!geometry) {
- return [];
- }
+ protected getStartHandleId(): LineDrawingHandleKey {
+ return 'start';
+ }
- return [
- {
- id: 'start',
- ...geometry.startPoint,
- shape: 'circle',
- },
- {
- id: 'end',
- ...geometry.endPoint,
- shape: 'circle',
- },
- ];
+ protected getEndHandleId(): LineDrawingHandleKey {
+ return 'end';
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
@@ -296,7 +183,10 @@ export class LineDrawing
return null;
}
- const point = { x, y };
+ const point = {
+ x,
+ y,
+ };
if (this.getDrawingHandleAtPoint(point)) {
return {
@@ -306,7 +196,7 @@ export class LineDrawing
};
}
- if (!this.isPointNearLine(point)) {
+ if (!this.isPointNearLine(point, LINE_HIT_TOLERANCE)) {
return null;
}
@@ -317,92 +207,16 @@ export class LineDrawing
};
}
- protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.left,
- to: geometry.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
- protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.top,
- to: geometry.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
- return null;
- }
-
- const coordinate = this.getTimeCoordinate(kind);
- const text = this.getTimeText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getInteractiveAxisLabel(kind, ['start', 'end'], (labelKind) => {
+ return this.createAxisLabel(this.getAnchorTimeCoordinate(labelKind), this.getTimeText(labelKind));
+ });
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
- return null;
- }
-
- const coordinate = this.getPriceCoordinate(kind);
- const text = this.getPriceText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getInteractiveAxisLabel(kind, ['start', 'end'], (labelKind) => {
+ return this.createAxisLabel(this.getAnchorPriceCoordinate(labelKind), this.getPriceText(labelKind));
+ });
}
protected handleDoubleClick = (event: MouseEvent): void => {
@@ -412,43 +226,18 @@ export class LineDrawing
const point = this.getEventPoint(event as PointerEvent);
- if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearLine(point)) {
+ if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearLine(point, LINE_HIT_TOLERANCE)) {
return;
}
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.openSettings?.();
};
- protected handlePointerDown = (event: PointerEvent): void => {
- if (this.hidden || event.button !== 0) {
- return;
- }
-
- const point = this.getEventPoint(event);
-
- if (this.mode === 'idle' || this.mode === 'drawing') {
- event.preventDefault();
- event.stopPropagation();
-
- if (this.mode === 'idle') {
- this.startDrawing(point);
- } else {
- this.updateDrawing(point);
- this.finishDrawing();
- }
-
- return;
- }
-
- if (this.mode !== 'ready') {
- return;
- }
-
+ protected handleReadyPointerDown(event: PointerEvent, point: Point): void {
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
- const isNearLine = this.isPointNearLine(point);
+ const isNearLine = this.isPointNearLine(point, LINE_HIT_TOLERANCE);
const isDrawingHit = pointTarget !== null || isNearLine;
const isSelected = this.isSelected();
@@ -460,8 +249,7 @@ export class LineDrawing
return;
}
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
if (!isSelected) {
this.select();
@@ -470,7 +258,7 @@ export class LineDrawing
const dragMode: LineDrawingMode = `dragging-${pointTarget ?? 'body'}`;
this.startDragging(dragMode, point, event.pointerId);
- };
+ }
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
@@ -485,18 +273,15 @@ export class LineDrawing
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.movePoint(point);
this.render();
+
return;
}
if (this.mode === 'dragging-body') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.moveBody(point);
this.render();
}
@@ -512,194 +297,34 @@ export class LineDrawing
}
};
- private startDrawing(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.startAnchor = anchor;
- this.endAnchor = anchor;
- this.mode = 'drawing';
-
- this.render();
- }
-
- private updateDrawing(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.endAnchor = anchor;
- this.render();
- }
-
- private finishDrawing(): void {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return;
- }
-
- const lineSize = Math.hypot(
- geometry.endPoint.x - geometry.startPoint.x,
- geometry.endPoint.y - geometry.startPoint.y,
- );
-
- if (lineSize < MIN_LINE_SIZE) {
- this.removeSelf?.();
- return;
- }
-
- this.mode = 'ready';
- this.resolveReady?.();
-
- this.render();
- }
-
- private startDragging(mode: LineDrawingMode, point: Point, pointerId: number): void {
- this.mode = mode;
- this.dragPointerId = pointerId;
- this.dragStartPoint = point;
- this.dragStateSnapshot = this.getState();
-
- this.hideCrosshair();
- this.render();
- }
-
- private finishDragging(): void {
- this.mode = 'ready';
- this.resolveReady?.();
-
- this.dragPointerId = null;
- this.dragStartPoint = null;
- this.dragStateSnapshot = null;
-
- this.showCrosshair();
- this.render();
+ protected finishDrawing(): void {
+ this.finishLinearDrawing(this.getGeometry(), MIN_LINE_SIZE, () => this.removeSelf?.());
}
private movePoint(point: Point): void {
- const anchor = this.createAnchor(point);
+ if (this.mode === 'dragging-start') {
+ this.setAnchorFromPoint('start', point);
- if (!anchor) {
return;
}
- if (this.mode === 'dragging-start') {
- this.startAnchor = anchor;
- }
-
if (this.mode === 'dragging-end') {
- this.endAnchor = anchor;
+ this.setAnchorFromPoint('end', point);
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
- if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
- return;
- }
-
- const offsetX = point.x - this.dragStartPoint.x;
- const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
-
- const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
- const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
-
- if (nextStartTime === null || nextEndTime === null) {
+ if (!snapshot) {
return;
}
- this.startAnchor = {
- time: nextStartTime,
- price: snapshot.startAnchor.price + priceOffset,
- };
-
- this.endAnchor = {
- time: nextEndTime,
- price: snapshot.endAnchor.price + priceOffset,
- };
- }
-
- private createAnchor(point: Point): Anchor | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
- protected getGeometry(): LineDrawingGeometry | null {
- if (!this.startAnchor || !this.endAnchor) {
- return null;
- }
-
- const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
- const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
- const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
-
- if (startX === null || endX === null || startY === null || endY === null) {
- return null;
- }
-
- const startPoint = {
- x: Math.round(Number(startX)),
- y: Math.round(Number(startY)),
- };
-
- const endPoint = {
- x: Math.round(Number(endX)),
- y: Math.round(Number(endY)),
- };
-
- return {
- startPoint,
- endPoint,
- left: Math.min(startPoint.x, endPoint.x),
- right: Math.max(startPoint.x, endPoint.x),
- top: Math.min(startPoint.y, endPoint.y),
- bottom: Math.max(startPoint.y, endPoint.y),
- };
- }
-
- private isPointNearLine(point: Point): boolean {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return false;
- }
-
- return getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
- }
-
- private getTimeCoordinate(kind: TimeLabelKind): number | null {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
-
- if (!anchor) {
- return null;
- }
-
- const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
-
- return coordinate === null ? null : Number(coordinate);
- }
-
- private getPriceCoordinate(kind: PriceLabelKind): number | null {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
-
- if (!anchor) {
- return null;
- }
-
- const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
-
- return coordinate === null ? null : Number(coordinate);
+ this.moveAnchorsByPixels(snapshot.startAnchor, snapshot.endAnchor, this.dragStartPoint, point);
}
private getTimeText(kind: TimeLabelKind): string {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return '';
@@ -714,7 +339,7 @@ export class LineDrawing
}
private getPriceText(kind: PriceLabelKind): string {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ const anchor = this.getAnchor(kind);
if (!anchor) {
return '';
diff --git a/src/core/Drawings/parallelChannel/parallelChannel.ts b/src/core/Drawings/parallelChannel/parallelChannel.ts
index f86e4ad74ed095b5d73b85cf138a6bd9be898873..db64b42564a8f490b279578f959fb723fb88dced 100644
--- a/src/core/Drawings/parallelChannel/parallelChannel.ts
+++ b/src/core/Drawings/parallelChannel/parallelChannel.ts
@@ -1,26 +1,11 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
+import { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- getAnchorFromPoint,
- getPriceDelta as getPriceDeltaFromCoordinates,
- getPriceFromYCoordinate,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- shiftTimeByPixels,
-} from '@core/Drawings/helpers';
-import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { getPriceFromYCoordinate, getYCoordinateFromPrice } from '@core/Drawings/helpers';
+import { getDistanceToSegment } from '@core/Drawings/utils';
+
+import { BaseDrawingParams, ISeriesDrawing, TwoPointDrawingBase } from '@src/core/Drawings/base';
+import { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { ParallelChannelPaneView } from './paneView';
import {
@@ -33,8 +18,6 @@ import {
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab } from '@src/types';
type ParallelChannelMode = 'idle' | 'drawing-line' | 'drawing-channel' | 'ready' | 'dragging';
@@ -49,6 +32,7 @@ type ParallelChannelDragTarget =
type ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
+
type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';
type ParallelChannelParams = BaseDrawingParams;
@@ -88,7 +72,7 @@ const MIN_CHANNEL_WIDTH = 4;
const VERTICAL_LINE_TOLERANCE = 0.001;
export class ParallelChannel
- extends SeriesDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
+ extends TwoPointDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
implements ISeriesDrawing
{
private openSettings?: () => void;
@@ -96,10 +80,7 @@ export class ParallelChannel
protected settings: ParallelChannelSettings = createDefaultSettings();
protected mode: ParallelChannelMode = 'idle';
- private startAnchor: Anchor | null = null;
- private endAnchor: Anchor | null = null;
private priceOffset: number | null = null;
-
private activeDragTarget: ParallelChannelDragTarget | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
@@ -111,18 +92,6 @@ export class ParallelChannel
showTime: Defaults.showTime,
};
- private paneView: ParallelChannelPaneView;
- private timeAxisPaneView: CustomTimeAxisPaneView;
- private priceAxisPaneView: CustomPriceAxisPaneView;
-
- private startTimeAxisView: CustomTimeAxisView;
- private endTimeAxisView: CustomTimeAxisView;
-
- private mainStartPriceAxisView: CustomPriceAxisView;
- private mainEndPriceAxisView: CustomPriceAxisView;
- private parallelStartPriceAxisView: CustomPriceAxisView;
- private parallelEndPriceAxisView: CustomPriceAxisView;
-
constructor({
container,
interaction,
@@ -132,69 +101,23 @@ export class ParallelChannel
series,
initialEvent,
}: ParallelChannelParams) {
- super({
- chart,
- series,
- container,
- interaction,
- });
+ super({ chart, series, container, interaction });
this.openSettings = openSettings;
-
- this.paneView = new ParallelChannelPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
+ this.initializeViews(
+ new ParallelChannelPaneView(this),
+ ['start', 'end'],
+ ['main-start', 'main-end', 'parallel-start', 'parallel-end'],
+ );
+
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'end',
- });
-
- this.mainStartPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'main-start',
- });
-
- this.mainEndPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'main-end',
- });
-
- this.parallelStartPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'parallel-start',
- });
-
- this.parallelEndPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'parallel-end',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -254,45 +177,6 @@ export class ParallelChannel
return getParallelChannelSettingsTabs(this.settings);
}
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- this.startTimeAxisView,
- this.endTimeAxisView,
- this.mainStartPriceAxisView,
- this.mainEndPriceAxisView,
- this.parallelStartPriceAxisView,
- this.parallelEndPriceAxisView,
- ]);
- }
-
- 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.mainStartPriceAxisView,
- this.mainEndPriceAxisView,
- this.parallelStartPriceAxisView,
- this.parallelEndPriceAxisView,
- ];
- }
-
public getRenderData(): ParallelChannelRenderData | null {
if (this.hidden) {
return null;
@@ -380,83 +264,49 @@ export class ParallelChannel
}
protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
+ if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.left,
- to: geometry.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return geometry ? [this.createAxisSegment(geometry.left, geometry.right)] : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
+ if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.top,
- to: geometry.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return geometry ? [this.createAxisSegment(geometry.top, geometry.bottom)] : [];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
+ if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
- const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
-
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatDate(
+ return this.createAxisLabel(
+ this.getAnchorTimeCoordinate(kind),
+ formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ );
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || !isPriceLabelKind(kind)) {
+ if (!this.shouldShowInteractiveAxis() || !isPriceLabelKind(kind)) {
return null;
}
@@ -468,35 +318,16 @@ export class ParallelChannel
const coordinate = getYCoordinateFromPrice(this.series, price);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatPrice(price) ?? '',
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), formatPrice(price) ?? '');
}
protected handleDoubleClick = (event: MouseEvent): void => {
- if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
- return;
- }
-
- const point = this.getEventPoint(event as PointerEvent);
-
- if (!this.isPointOnChannel(point) && !this.getDrawingHandleAtPoint(point)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- this.openSettings?.();
+ this.openSettingsOnDoubleClick(
+ event,
+ (point) => Boolean(this.isPointOnChannel(point) || this.getDrawingHandleAtPoint(point)),
+ () => this.openSettings?.(),
+ true,
+ );
};
protected handlePointerDown = (event: PointerEvent): void => {
@@ -508,14 +339,12 @@ export class ParallelChannel
switch (this.mode) {
case 'idle':
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.startDrawing(point);
return;
case 'drawing-line':
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.setEndAnchor(point);
if (!this.hasValidMainLine()) {
@@ -529,8 +358,7 @@ export class ParallelChannel
return;
case 'drawing-channel':
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.setPriceOffset(point);
if (!this.hasValidChannelWidth()) {
@@ -557,15 +385,13 @@ export class ParallelChannel
}
if (!isSelected) {
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.select();
return;
}
if (pointTarget || isChannelHit) {
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.startDragging(pointTarget ?? 'body', point, event.pointerId);
return;
}
@@ -592,9 +418,7 @@ export class ParallelChannel
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.applyDrag(point);
this.render();
};
@@ -612,23 +436,28 @@ export class ParallelChannel
return null;
}
- const startPoint = this.getAnchorPoint(this.startAnchor);
- const endPoint = this.getAnchorPoint(this.endAnchor);
+ const mainGeometry = this.getTwoPointGeometry();
- const parallelStartPoint = this.getAnchorPoint({
+ if (!mainGeometry) {
+ return null;
+ }
+
+ const parallelStartPoint = this.getPointFromAnchor({
time: this.startAnchor.time,
price: this.startAnchor.price + this.priceOffset,
});
- const parallelEndPoint = this.getAnchorPoint({
+ const parallelEndPoint = this.getPointFromAnchor({
time: this.endAnchor.time,
price: this.endAnchor.price + this.priceOffset,
});
- if (!startPoint || !endPoint || !parallelStartPoint || !parallelEndPoint) {
+ if (!parallelStartPoint || !parallelEndPoint) {
return null;
}
+ const { startPoint } = mainGeometry;
+ const { endPoint } = mainGeometry;
const mainMiddlePoint = getMiddlePoint(startPoint, endPoint);
const parallelMiddlePoint = getMiddlePoint(parallelStartPoint, parallelEndPoint);
const middleStartPoint = getMiddlePoint(startPoint, parallelStartPoint);
@@ -658,18 +487,15 @@ export class ParallelChannel
return;
}
- this.startAnchor = anchor;
- this.endAnchor = anchor;
+ this.setAnchors(anchor, anchor);
this.priceOffset = 0;
this.mode = 'drawing-line';
-
this.render();
}
private finishDrawing(): void {
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
@@ -679,19 +505,16 @@ export class ParallelChannel
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
-
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
-
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
-
this.showCrosshair();
this.render();
}
@@ -701,31 +524,24 @@ export class ParallelChannel
case 'main-start':
this.moveMainEdge('start', point);
break;
-
case 'main-middle':
this.moveMainMiddle(point);
break;
-
case 'main-end':
this.moveMainEdge('end', point);
break;
-
case 'parallel-start':
this.moveParallelEdge('start', point);
break;
-
case 'parallel-middle':
this.moveParallelMiddle(point);
break;
-
case 'parallel-end':
this.moveParallelEdge('end', point);
break;
-
case 'body':
this.moveBody(point);
break;
-
default:
break;
}
@@ -738,22 +554,11 @@ export class ParallelChannel
return;
}
- const previousAnchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ const previousAnchor = this.getAnchor(kind);
+ this.setAnchor(kind, anchor);
- if (kind === 'start') {
- this.startAnchor = anchor;
- } else {
- this.endAnchor = anchor;
- }
-
- if (this.hasValidMainLine()) {
- return;
- }
-
- if (kind === 'start') {
- this.startAnchor = previousAnchor;
- } else {
- this.endAnchor = previousAnchor;
+ if (!this.hasValidMainLine()) {
+ this.setAnchor(kind, previousAnchor);
}
}
@@ -765,27 +570,16 @@ export class ParallelChannel
return;
}
- const previousAnchor = kind === 'start' ? this.startAnchor : this.endAnchor;
-
+ const previousAnchor = this.getAnchor(kind);
const baseAnchor: Anchor = {
time: anchor.time,
price: anchor.price - snapshot.priceOffset,
};
- if (kind === 'start') {
- this.startAnchor = baseAnchor;
- } else {
- this.endAnchor = baseAnchor;
- }
-
- if (this.hasValidMainLine()) {
- return;
- }
+ this.setAnchor(kind, baseAnchor);
- if (kind === 'start') {
- this.startAnchor = previousAnchor;
- } else {
- this.endAnchor = previousAnchor;
+ if (!this.hasValidMainLine()) {
+ this.setAnchor(kind, previousAnchor);
}
}
@@ -817,65 +611,35 @@ export class ParallelChannel
this.priceOffset = snapshot.priceOffset - priceDelta;
- if (this.hasValidChannelWidth()) {
- return;
+ if (!this.hasValidChannelWidth()) {
+ this.startAnchor = snapshot.startAnchor;
+ this.endAnchor = snapshot.endAnchor;
+ this.priceOffset = snapshot.priceOffset;
}
-
- this.startAnchor = snapshot.startAnchor;
- this.endAnchor = snapshot.endAnchor;
- this.priceOffset = snapshot.priceOffset;
}
private moveParallelMiddle(point: Point): void {
const previousOffset = this.priceOffset;
-
this.setPriceOffset(point);
- if (this.hasValidChannelWidth()) {
- return;
+ if (!this.hasValidChannelWidth()) {
+ this.priceOffset = previousOffset;
}
-
- this.priceOffset = previousOffset;
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
- if (!snapshot?.startAnchor || !snapshot.endAnchor || snapshot.priceOffset === null || !this.dragStartPoint) {
+ if (!snapshot || snapshot.priceOffset === null) {
return;
}
- const offsetX = point.x - this.dragStartPoint.x;
- const priceDelta = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
-
- const startTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
- const endTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
-
- if (startTime === null || endTime === null) {
- return;
- }
-
- this.startAnchor = {
- time: startTime,
- price: snapshot.startAnchor.price + priceDelta,
- };
-
- this.endAnchor = {
- time: endTime,
- price: snapshot.endAnchor.price + priceDelta,
- };
-
+ this.moveAnchorsByPixels(snapshot.startAnchor, snapshot.endAnchor, this.dragStartPoint, point);
this.priceOffset = snapshot.priceOffset;
}
private setEndAnchor(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.endAnchor = anchor;
+ this.setAnchorFromPoint('end', point);
}
private setPriceOffset(point: Point): void {
@@ -894,8 +658,8 @@ export class ParallelChannel
}
private getLinePriceAtX(startAnchor: Anchor, endAnchor: Anchor, x: number): number | null {
- const startPoint = this.getAnchorPoint(startAnchor);
- const endPoint = this.getAnchorPoint(endAnchor);
+ const startPoint = this.getPointFromAnchor(startAnchor);
+ const endPoint = this.getPointFromAnchor(endAnchor);
if (!startPoint || !endPoint) {
return null;
@@ -916,21 +680,13 @@ export class ParallelChannel
private hasValidMainLine(): boolean {
const geometry = this.getGeometry();
- if (!geometry) {
- return false;
- }
-
- return getDistance(geometry.startPoint, geometry.endPoint) >= MIN_LINE_SIZE;
+ return geometry !== null && getDistance(geometry.startPoint, geometry.endPoint) >= MIN_LINE_SIZE;
}
private hasValidChannelWidth(): boolean {
const geometry = this.getGeometry();
- if (!geometry) {
- return false;
- }
-
- return getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
+ return geometry !== null && getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
}
private isPointOnChannel(point: Point): boolean {
@@ -971,38 +727,16 @@ export class ParallelChannel
switch (kind) {
case 'main-start':
return this.startAnchor.price;
-
case 'main-end':
return this.endAnchor.price;
-
case 'parallel-start':
return this.startAnchor.price + this.priceOffset;
-
case 'parallel-end':
return this.endAnchor.price + this.priceOffset;
-
default:
return null;
}
}
-
- private getAnchorPoint(anchor: Anchor): Point | null {
- const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
- const y = getYCoordinateFromPrice(this.series, anchor.price);
-
- if (x === null || y === null) {
- return null;
- }
-
- return {
- x: Number(x),
- y: Number(y),
- };
- }
-
- private createAnchor(point: Point): Anchor | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
}
function isPriceLabelKind(kind: string): kind is PriceLabelKind {
diff --git a/src/core/Drawings/ray/ray.ts b/src/core/Drawings/ray/ray.ts
index 0bc1c878ae9556e24b133d92343005b0e8179874..46a152f0867d181894d1d1210a1698eb5ccf7261 100644
--- a/src/core/Drawings/ray/ray.ts
+++ b/src/core/Drawings/ray/ray.ts
@@ -1,38 +1,24 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
-
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- getAnchorFromPoint,
- getPriceDelta as getPriceDeltaFromCoordinates,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- shiftTimeByPixels,
-} from '@core/Drawings/helpers';
-import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
+
+import { getDistanceToSegment } from '@core/Drawings/utils';
+
+import { BaseDrawingParams, ISeriesDrawing, LinearDrawingBase } from '@src/core/Drawings/base';
+import { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { RayPaneView } from './paneView';
+import { createDefaultSettings, getRaySettingTabs } from './settings';
-import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';
+import type { 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 '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab } from '@src/types';
+import type { Anchor, AxisLabel, Point } from '@core/Drawings/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;
@@ -55,24 +41,23 @@ interface RayGeometry {
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 SeriesDrawingBase<RaySettings, RayHandleKey> implements ISeriesDrawing {
+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 startAnchor: Anchor | null = null;
- private directionAnchor: Anchor | null = null;
- private dragPointerId: number | null = null;
- private dragStartPoint: Point | null = null;
- private dragStateSnapshot: RayState | null = null;
+ protected mode: RayMode = 'idle';
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
@@ -80,14 +65,6 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
showTime: Defaults.showTime,
};
- private readonly paneView: RayPaneView;
- private readonly timeAxisPaneView: CustomTimeAxisPaneView;
- private readonly priceAxisPaneView: CustomPriceAxisPaneView;
- private readonly startTimeAxisView: CustomTimeAxisView;
- private readonly directionTimeAxisView: CustomTimeAxisView;
- private readonly startPriceAxisView: CustomPriceAxisView;
- private readonly directionPriceAxisView: CustomPriceAxisView;
-
constructor({
chart,
series,
@@ -98,56 +75,34 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
openSettings,
initialEvent,
}: RayParams) {
- super({ chart, series, container, interaction });
+ super({
+ chart,
+ series,
+ container,
+ interaction,
+ });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
+ this.initializeViews(new RayPaneView(this), ['start', 'direction'], ['start', 'direction']);
- this.paneView = new RayPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.directionTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'direction',
- });
-
- this.startPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.directionPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'direction',
- });
+ this.series.attachPrimitive(this);
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
+ }
- this.series.attachPrimitive(this);
+ private get directionAnchor(): Anchor | null {
+ return this.endAnchor;
+ }
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
- }
+ private set directionAnchor(anchor: Anchor | null) {
+ this.endAnchor = anchor;
}
public isCreationPending(): boolean {
@@ -160,11 +115,17 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
mode: this.mode,
startAnchor: this.startAnchor,
directionAnchor: this.directionAnchor,
- settings: { ...this.settings },
+ 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') {
@@ -197,38 +158,6 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
return getRaySettingTabs(this.settings);
}
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- this.startTimeAxisView,
- this.directionTimeAxisView,
- this.startPriceAxisView,
- this.directionPriceAxisView,
- ]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
-
- public timeAxisViews() {
- return [this.startTimeAxisView, this.directionTimeAxisView];
- }
-
- public priceAxisViews() {
- return [this.startPriceAxisView, this.directionPriceAxisView];
- }
-
public getRenderData(): RayRenderData | null {
if (this.hidden) {
return null;
@@ -246,27 +175,16 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
};
}
- protected getDrawingHandles(): readonly DrawingHandle<RayHandleKey>[] {
- const geometry = this.getGeometry();
+ protected getStartHandleId(): RayHandleKey {
+ return 'start';
+ }
- if (!geometry) {
- return [];
- }
+ protected getEndHandleId(): RayHandleKey {
+ return 'direction';
+ }
- return [
- {
- id: 'start',
- ...geometry.startPoint,
- shape: 'circle',
- strokeColor: this.settings.lineColor,
- },
- {
- id: 'direction',
- ...geometry.directionPoint,
- shape: 'circle',
- strokeColor: this.settings.lineColor,
- },
- ];
+ protected getLineHandleStrokeColor(): string {
+ return this.settings.lineColor;
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
@@ -274,7 +192,10 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
return null;
}
- const point = { x, y };
+ const point = {
+ x,
+ y,
+ };
if (this.getDrawingHandleAtPoint(point)) {
return {
@@ -295,139 +216,57 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
};
}
- protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
+ protected getTimeAxisLabel(kind: string): AxisLabel | null {
+ return this.getInteractiveAxisLabel(kind, ['start', 'direction'], (labelKind) => {
+ const anchorKind = labelKind === 'start' ? 'start' : 'end';
- return [
- {
- from: Math.min(geometry.startPoint.x, geometry.directionPoint.x),
- to: Math.max(geometry.startPoint.x, geometry.directionPoint.x),
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return this.createAxisLabel(this.getAnchorTimeCoordinate(anchorKind), this.getTimeText(labelKind));
+ });
}
- protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
+ protected getPriceAxisLabel(kind: string): AxisLabel | null {
+ return this.getInteractiveAxisLabel(kind, ['start', 'direction'], (labelKind) => {
+ const anchorKind = labelKind === 'start' ? 'start' : 'end';
- return [
- {
- from: Math.min(geometry.startPoint.y, geometry.directionPoint.y),
- to: Math.max(geometry.startPoint.y, geometry.directionPoint.y),
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return this.createAxisLabel(this.getAnchorPriceCoordinate(anchorKind), this.getPriceText(labelKind));
+ });
}
- protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'direction')) {
- return null;
- }
-
- const coordinate = this.getTimeCoordinate(kind);
- const text = this.getTimeText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
- }
+ protected getGeometry(): RayGeometry | null {
+ const geometry = this.getTwoPointGeometry();
- protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'direction')) {
+ if (!geometry) {
return null;
}
- const coordinate = this.getPriceCoordinate(kind);
- const text = this.getPriceText(kind);
+ const { startPoint } = geometry;
+ const directionPoint = geometry.endPoint;
+ const rayEndPoint = this.getRayEndPoint(startPoint, directionPoint);
- if (coordinate === null || !text) {
+ if (!rayEndPoint) {
return null;
}
- const { colors } = getThemeStore();
-
return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
+ 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 => {
- if (this.hidden || this.mode !== 'ready') {
- return;
- }
-
- const point = this.getEventPoint(event as PointerEvent);
-
- if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearRay(point)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- this.openSettings?.();
+ this.openSettingsOnDoubleClick(
+ event,
+ (point) => this.getDrawingHandleAtPoint(point) !== null || this.isPointNearRay(point),
+ () => 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;
- }
-
+ protected handleReadyPointerDown(event: PointerEvent, point: Point): void {
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearRay = this.isPointNearRay(point);
const isDrawingHit = pointTarget !== null || isNearRay;
@@ -437,39 +276,34 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.select();
return;
}
if (pointTarget === 'start') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDragging('dragging-start', point, event.pointerId);
+
return;
}
if (pointTarget === 'direction') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDragging('dragging-direction', point, event.pointerId);
+
return;
}
if (isNearRay) {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDragging('dragging-body', point, event.pointerId);
+
return;
}
this.deselect();
- };
+ }
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
@@ -484,18 +318,15 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-direction') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.movePoint(point);
this.render();
+
return;
}
if (this.mode === 'dragging-body') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.moveBody(point);
this.render();
}
@@ -511,163 +342,30 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
}
};
- private startDrawing(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.startAnchor = anchor;
- this.directionAnchor = anchor;
- this.mode = 'drawing';
-
- this.render();
- }
-
- private updateDrawing(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.directionAnchor = anchor;
- this.render();
- }
-
- private finishDrawing(): void {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return;
- }
-
- const lineSize = Math.hypot(
- geometry.directionPoint.x - geometry.startPoint.x,
- geometry.directionPoint.y - geometry.startPoint.y,
- );
-
- if (lineSize < MIN_LINE_SIZE) {
- this.removeSelf?.();
- return;
- }
-
- this.mode = 'ready';
- this.resolveReady?.();
-
- this.render();
- }
-
- private startDragging(mode: RayMode, point: Point, pointerId: number): void {
- this.mode = mode;
- this.dragPointerId = pointerId;
- this.dragStartPoint = point;
- this.dragStateSnapshot = this.getState();
-
- this.hideCrosshair();
- this.render();
- }
-
- private finishDragging(): void {
- this.mode = 'ready';
- this.resolveReady?.();
-
- this.dragPointerId = null;
- this.dragStartPoint = null;
- this.dragStateSnapshot = null;
-
- this.showCrosshair();
- this.render();
+ protected finishDrawing(): void {
+ this.finishLinearDrawing(this.getTwoPointGeometry(), MIN_LINE_SIZE, () => this.removeSelf?.());
}
private movePoint(point: Point): void {
- const anchor = this.createAnchor(point);
+ if (this.mode === 'dragging-start') {
+ this.setAnchorFromPoint('start', point);
- if (!anchor) {
return;
}
- if (this.mode === 'dragging-start') {
- this.startAnchor = anchor;
- }
-
if (this.mode === 'dragging-direction') {
- this.directionAnchor = anchor;
+ this.setAnchorFromPoint('end', point);
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
- if (!snapshot?.startAnchor || !snapshot.directionAnchor || !this.dragStartPoint) {
+ if (!snapshot) {
return;
}
- const offsetX = point.x - this.dragStartPoint.x;
- const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
-
- const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
- const nextDirectionTime = shiftTimeByPixels(this.chart, snapshot.directionAnchor.time, offsetX, this.series);
-
- if (nextStartTime === null || nextDirectionTime === null) {
- return;
- }
-
- this.startAnchor = {
- time: nextStartTime,
- price: snapshot.startAnchor.price + priceOffset,
- };
-
- this.directionAnchor = {
- time: nextDirectionTime,
- price: snapshot.directionAnchor.price + priceOffset,
- };
- }
-
- private createAnchor(point: Point): Anchor | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
- protected getGeometry(): RayGeometry | null {
- if (!this.startAnchor || !this.directionAnchor) {
- return null;
- }
-
- const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
- const directionX = getXCoordinateFromTime(this.chart, this.directionAnchor.time, this.series);
- const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
- const directionY = getYCoordinateFromPrice(this.series, this.directionAnchor.price);
-
- if (startX === null || directionX === null || startY === null || directionY === null) {
- return null;
- }
-
- const startPoint = {
- x: Math.round(Number(startX)),
- y: Math.round(Number(startY)),
- };
-
- const directionPoint = {
- x: Math.round(Number(directionX)),
- y: Math.round(Number(directionY)),
- };
-
- const rayEndPoint = this.getRayEndPoint(startPoint, directionPoint);
-
- if (!rayEndPoint) {
- return null;
- }
-
- return {
- startPoint,
- directionPoint,
- rayEndPoint,
- left: Math.min(startPoint.x, rayEndPoint.x),
- right: Math.max(startPoint.x, rayEndPoint.x),
- top: Math.min(startPoint.y, rayEndPoint.y),
- bottom: Math.max(startPoint.y, rayEndPoint.y),
- };
+ this.moveAnchorsByPixels(snapshot.startAnchor, snapshot.directionAnchor, this.dragStartPoint, point);
}
private getRayEndPoint(startPoint: Point, directionPoint: Point): Point | null {
@@ -678,42 +376,24 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
return null;
}
- const { width, height } = this.container.getBoundingClientRect();
- const candidates: Point[] = [];
-
- if (dx !== 0) {
- const leftT = (0 - startPoint.x) / dx;
- const rightT = (width - startPoint.x) / dx;
-
- const leftY = startPoint.y + leftT * dy;
- const rightY = startPoint.y + rightT * dy;
-
- if (leftT >= 1 && leftY >= 0 && leftY <= height) {
- candidates.push({ x: 0, y: leftY });
- }
+ const { width } = this.container.getBoundingClientRect();
+ const height = this.series.getPane().getHeight();
- if (rightT >= 1 && rightY >= 0 && rightY <= height) {
- candidates.push({ x: width, y: rightY });
- }
+ if (width <= 0 || height <= 0) {
+ return null;
}
- if (dy !== 0) {
- const topT = (0 - startPoint.y) / dy;
- const bottomT = (height - startPoint.y) / dy;
-
- const topX = startPoint.x + topT * dx;
- const bottomX = startPoint.x + bottomT * dx;
-
- if (topT >= 1 && topX >= 0 && topX <= width) {
- candidates.push({ x: topX, y: 0 });
- }
+ const candidates: RayIntersection[] = [];
+ this.addVerticalRayIntersections(candidates, startPoint, dx, dy, width, height);
+ this.addHorizontalRayIntersections(candidates, startPoint, dx, dy, width, height);
- if (bottomT >= 1 && bottomX >= 0 && bottomX <= width) {
- candidates.push({ x: bottomX, y: height });
- }
+ if (!candidates.length) {
+ return null;
}
- return candidates[0] ?? directionPoint;
+ return candidates.reduce((farthest, candidate) => {
+ return candidate.t > farthest.t ? candidate : farthest;
+ }).point;
}
private isPointNearRay(point: Point): boolean {
@@ -726,30 +406,6 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
return getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
}
- private getTimeCoordinate(kind: TimeLabelKind): number | null {
- const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
-
- if (!anchor) {
- return null;
- }
-
- const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
-
- return coordinate === null ? null : Number(coordinate);
- }
-
- private getPriceCoordinate(kind: PriceLabelKind): number | null {
- const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
-
- if (!anchor) {
- return null;
- }
-
- const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
-
- return coordinate === null ? null : Number(coordinate);
- }
-
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.directionAnchor;
@@ -774,4 +430,76 @@ export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements
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,
+ });
+ }
+ }
+ }
}
diff --git a/src/core/Drawings/rectangle/rectangle.ts b/src/core/Drawings/rectangle/rectangle.ts
index 82bb27a9708986410b73683dbb7bd6ea1c2a86bb..9c80af45d796d4baad9c0700cf85b0ee775f98fb 100644
--- a/src/core/Drawings/rectangle/rectangle.ts
+++ b/src/core/Drawings/rectangle/rectangle.ts
@@ -1,31 +1,10 @@
-import { clamp } from 'lodash-es';
+import { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- clampPointToContainer as clampPointToContainerInElement,
- getAnchorFromPoint,
- getContainerSize as getElementContainerSize,
- getPriceDelta as getPriceDeltaFromCoordinates,
- getPriceFromYCoordinate,
- getTimeFromXCoordinate,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- isPointInBounds,
- normalizeBounds,
- shiftTimeByPixels,
-} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { isPointInBounds, normalizeBounds } from '@core/Drawings/helpers';
+
+import { AreaDrawingBase, BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/base';
+import { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { RectanglePaneView } from './paneView';
import {
@@ -37,10 +16,7 @@ import {
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
-import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab } from '@src/types';
-import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
+import type { Anchor, AxisLabel, Bounds, Point } from '@core/Drawings/types';
type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
@@ -60,11 +36,12 @@ interface RectangleState {
settings: RectangleSettings;
}
-interface RectangleGeometry {
- left: number;
- right: number;
- top: number;
- bottom: number;
+interface RectangleDragSnapshot {
+ startAnchor: Anchor | null;
+ endAnchor: Anchor | null;
+}
+
+interface RectangleGeometry extends Bounds {
width: number;
height: number;
}
@@ -74,22 +51,17 @@ export type RectangleRenderData = RectangleGeometry & RectangleStyle & Rectangle
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
-export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHandleKey> implements ISeriesDrawing {
+export class Rectangle extends AreaDrawingBase<RectangleSettings, RectangleHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
protected mode: RectangleMode = 'idle';
- private startTime: Time | null = null;
- private endTime: Time | null = null;
- private startPrice: number | null = null;
- private endPrice: number | null = null;
-
- private activeDragTarget: RectangleHandle = null;
- private dragPointerId: number | null = null;
+ protected activeDragTarget: RectangleHandle = null;
+ protected dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
- private dragStateSnapshot: RectangleState | null = null;
+ private dragAnchorSnapshot: RectangleDragSnapshot | null = null;
private dragGeometrySnapshot: RectangleGeometry | null = null;
private displayFormat: ChartOptionsModel = {
@@ -98,14 +70,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
showTime: Defaults.showTime,
};
- private readonly paneView: RectanglePaneView;
- private readonly timeAxisPaneView: CustomTimeAxisPaneView;
- private readonly priceAxisPaneView: CustomPriceAxisPaneView;
- private readonly leftTimeAxisView: CustomTimeAxisView;
- private readonly rightTimeAxisView: CustomTimeAxisView;
- private readonly topPriceAxisView: CustomPriceAxisView;
- private readonly bottomPriceAxisView: CustomPriceAxisView;
-
constructor({
chart,
series,
@@ -120,51 +84,16 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
this.removeSelf = removeSelf;
this.openSettings = openSettings;
+ this.initializeViews(new RectanglePaneView(this), ['left', 'right'], ['top', 'bottom']);
- this.paneView = new RectanglePaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.leftTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'left',
- });
-
- this.rightTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'right',
- });
-
- this.topPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'top',
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.bottomPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'bottom',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -176,15 +105,19 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
return {
hidden: this.hidden,
mode: this.mode,
- startTime: this.startTime,
- endTime: this.endTime,
- startPrice: this.startPrice,
- endPrice: this.endPrice,
+ 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 ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
@@ -195,21 +128,8 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
this.mode = nextState.mode;
}
- if ('startTime' in nextState) {
- this.startTime = nextState.startTime ?? null;
- }
-
- if ('endTime' in nextState) {
- this.endTime = nextState.endTime ?? null;
- }
-
- if ('startPrice' in nextState) {
- this.startPrice = nextState.startPrice ?? null;
- }
-
- if ('endPrice' in nextState) {
- this.endPrice = nextState.endPrice ?? null;
- }
+ this.restoreAnchor('start', nextState);
+ this.restoreAnchor('end', nextState);
if ('settings' in nextState && nextState.settings) {
this.settings = {
@@ -225,38 +145,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
return getRectangleSettingsTabs(this.settings);
}
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- this.leftTimeAxisView,
- this.rightTimeAxisView,
- this.topPriceAxisView,
- this.bottomPriceAxisView,
- ]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
-
- public timeAxisViews() {
- return [this.leftTimeAxisView, this.rightTimeAxisView];
- }
-
- public priceAxisViews() {
- return [this.topPriceAxisView, this.bottomPriceAxisView];
- }
-
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
@@ -337,188 +225,37 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
};
}
- protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const bounds = this.getTimeBounds();
-
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.left,
- to: bounds.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
- protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const bounds = this.getPriceBounds();
-
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.top,
- to: bounds.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
- }
-
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'left' && kind !== 'right')) {
- return null;
- }
-
- const labelKind = kind as TimeLabelKind;
- const coordinate = this.getTimeCoordinate(labelKind);
- const text = this.getTimeText(labelKind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.getBoundsAxisLabel(kind, ['left', 'right'], (labelKind) => this.getTimeText(labelKind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
- return null;
- }
+ return this.getBoundsAxisLabel(kind, ['top', 'bottom'], (labelKind) => this.getPriceText(labelKind));
+ }
- const labelKind = kind as PriceLabelKind;
- const coordinate = this.getPriceCoordinate(labelKind);
- const text = this.getPriceText(labelKind);
+ protected getGeometry(): RectangleGeometry | null {
+ const geometry = this.getTwoPointGeometry();
- if (coordinate === null || !text) {
+ if (!geometry) {
return null;
}
- const { colors } = getThemeStore();
-
return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
+ left: geometry.left,
+ right: geometry.right,
+ top: geometry.top,
+ bottom: geometry.bottom,
+ width: geometry.right - geometry.left,
+ height: geometry.bottom - geometry.top,
};
}
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);
- this.render();
- return;
- }
-
- this.resizeRectangle(point);
- this.render();
+ this.openSettingsOnDoubleClick(
+ event,
+ (point) => Boolean(this.containsPoint(point) || this.getDrawingHandleAtPoint(point)),
+ () => this.openSettings?.(),
+ );
};
protected handlePointerUp = (event: PointerEvent): void => {
@@ -529,37 +266,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
this.finishDragging();
};
- private startDrawing(point: Point): void {
- const anchor = this.createAnchor(point);
-
- if (!anchor) {
- return;
- }
-
- this.startTime = anchor.time;
- this.endTime = anchor.time;
- this.startPrice = anchor.price;
- this.endPrice = anchor.price;
- this.mode = 'drawing';
-
- this.render();
- }
-
- private updateDrawing(point: Point): void {
- const clampedPoint = this.clampPointToContainer(point);
- const anchor = this.createAnchor(clampedPoint);
-
- if (!anchor) {
- return;
- }
-
- this.endTime = anchor.time;
- this.endPrice = anchor.price;
-
- this.render();
- }
-
- private finishDrawing(): void {
+ protected finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
@@ -574,25 +281,25 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
- private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
+ protected startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
- this.dragStateSnapshot = this.getState();
+ this.dragAnchorSnapshot = {
+ startAnchor: this.startAnchor,
+ endAnchor: this.endAnchor,
+ };
this.dragGeometrySnapshot = this.getGeometry();
-
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
-
this.clearInteractionState();
this.render();
}
@@ -601,82 +308,40 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
- this.dragStateSnapshot = null;
+ this.dragAnchorSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
-
- this.startTime = null;
- this.endTime = null;
- this.startPrice = null;
- this.endPrice = null;
-
+ this.setAnchors(null, null);
this.clearInteractionState();
this.render();
}
- private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
+ protected getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
if (handleTarget) {
return handleTarget;
}
- if (this.containsPoint(point)) {
- return 'body';
- }
-
- return null;
+ return this.containsPoint(point) ? 'body' : null;
}
- private moveWhole(point: Point): void {
- const snapshot = this.dragStateSnapshot;
+ protected moveWhole(point: Point): void {
+ const snapshot = this.dragAnchorSnapshot;
const geometry = this.dragGeometrySnapshot;
- if (!snapshot || !geometry || !this.dragStartPoint) {
- return;
- }
-
- if (
- snapshot.startTime === null ||
- snapshot.endTime === null ||
- snapshot.startPrice === null ||
- snapshot.endPrice === null
- ) {
+ if (!snapshot || !geometry) {
return;
}
- const containerSize = this.getContainerSize();
- const rawOffsetX = point.x - this.dragStartPoint.x;
- const rawOffsetY = point.y - this.dragStartPoint.y;
-
- const minOffsetX = -geometry.left;
- const maxOffsetX = containerSize.width - geometry.right;
- const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
-
- const minOffsetY = -geometry.top;
- const maxOffsetY = containerSize.height - geometry.bottom;
- const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
-
- const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
- const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
-
- if (nextStartTime === null || nextEndTime === null) {
- return;
- }
-
- const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
-
- this.startTime = nextStartTime;
- this.endTime = nextEndTime;
- this.startPrice = snapshot.startPrice + priceOffset;
- this.endPrice = snapshot.endPrice + priceOffset;
+ this.moveAnchorsWithinContainer(snapshot.startAnchor, snapshot.endAnchor, this.dragStartPoint, point, geometry);
}
- private resizeRectangle(point: Point): void {
+ protected resize(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
@@ -684,11 +349,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
}
const clampedPoint = this.clampPointToContainer(point);
-
- let { left } = geometry;
- let { right } = geometry;
- let { top } = geometry;
- let { bottom } = geometry;
+ let { left, right, top, bottom } = geometry;
switch (this.activeDragTarget) {
case 'nw':
@@ -729,100 +390,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
- const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
- const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
- const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
- const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);
-
- if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
- return false;
- }
-
- this.startTime = startTime;
- this.endTime = endTime;
- this.startPrice = startPrice;
- this.endPrice = endPrice;
-
- return true;
- }
-
- private createAnchor(point: Point): { time: Time; price: number } | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
- protected getGeometry(): RectangleGeometry | null {
- if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
- return null;
- }
-
- const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
- const startY = getYCoordinateFromPrice(this.series, this.startPrice);
- const endY = getYCoordinateFromPrice(this.series, this.endPrice);
-
- if (startX === null || endX === null || startY === null || endY === null) {
- return null;
- }
-
- const left = Math.round(Math.min(Number(startX), Number(endX)));
- const right = Math.round(Math.max(Number(startX), Number(endX)));
- const top = Math.round(Math.min(Number(startY), Number(endY)));
- const bottom = Math.round(Math.max(Number(startY), Number(endY)));
-
- return {
- left,
- right,
- top,
- bottom,
- width: right - left,
- height: bottom - top,
- };
- }
-
- private getTimeBounds(): { left: number; right: number } | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return {
- left: geometry.left,
- right: geometry.right,
- };
- }
-
- private getPriceBounds(): { top: number; bottom: number } | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return {
- top: geometry.top,
- bottom: geometry.bottom,
- };
- }
-
- private getTimeCoordinate(kind: TimeLabelKind): number | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return kind === 'left' ? geometry.left : geometry.right;
- }
-
- private getPriceCoordinate(kind: PriceLabelKind): number | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- return kind === 'top' ? geometry.top : geometry.bottom;
+ return this.setAnchorsFromPoints({ x: bounds.left, y: bounds.top }, { x: bounds.right, y: bounds.bottom });
}
private getTimeText(kind: TimeLabelKind): string {
@@ -843,63 +411,55 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
- if (price === null) {
- return '';
- }
-
- return formatPrice(price) ?? '';
+ return price === null ? '' : (formatPrice(price) ?? '');
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
- if (this.startTime === null || this.endTime === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
- const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
+ const geometry = this.getTwoPointGeometry();
- if (startX === null || endX === null) {
- return kind === 'left' ? this.startTime : this.endTime;
+ if (!geometry) {
+ return kind === 'left' ? this.startAnchor.time : this.endAnchor.time;
}
- const startIsLeft = Number(startX) <= Number(endX);
+ const startIsLeft = geometry.startPoint.x <= geometry.endPoint.x;
if (kind === 'left') {
- return startIsLeft ? this.startTime : this.endTime;
+ return startIsLeft ? this.startAnchor.time : this.endAnchor.time;
}
- return startIsLeft ? this.endTime : this.startTime;
+ return startIsLeft ? this.endAnchor.time : this.startAnchor.time;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
- if (this.startPrice === null || this.endPrice === null) {
+ if (!this.startAnchor || !this.endAnchor) {
return null;
}
- const startY = getYCoordinateFromPrice(this.series, this.startPrice);
- const endY = getYCoordinateFromPrice(this.series, this.endPrice);
+ const geometry = this.getTwoPointGeometry();
- if (startY === null || endY === null) {
- return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
+ if (!geometry) {
+ return kind === 'top'
+ ? Math.max(this.startAnchor.price, this.endAnchor.price)
+ : Math.min(this.startAnchor.price, this.endAnchor.price);
}
- const startIsTop = Number(startY) <= Number(endY);
+ const startIsTop = geometry.startPoint.y <= geometry.endPoint.y;
if (kind === 'top') {
- return startIsTop ? this.startPrice : this.endPrice;
+ return startIsTop ? this.startAnchor.price : this.endAnchor.price;
}
- return startIsTop ? this.endPrice : this.startPrice;
+ return startIsTop ? this.endAnchor.price : this.startAnchor.price;
}
- private containsPoint(point: Point): boolean {
+ protected containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
- if (!geometry) {
- return false;
- }
-
- return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
+ return geometry !== null && isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
@@ -922,20 +482,4 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHan
return 'default';
}
}
-
- private shiftTime(time: Time, offsetX: number): Time | null {
- return shiftTimeByPixels(this.chart, time, offsetX, this.series);
- }
-
- private getPriceDelta(fromY: number, toY: number): number {
- return getPriceDeltaFromCoordinates(this.series, fromY, toY);
- }
-
- private getContainerSize(): { width: number; height: number } {
- return getElementContainerSize(this.container);
- }
-
- private clampPointToContainer(point: Point): Point {
- return clampPointToContainerInElement(point, this.container);
- }
}
diff --git a/src/core/Drawings/regressionTrend/regressionTrend.ts b/src/core/Drawings/regressionTrend/regressionTrend.ts
index a99e71508d607e0e9569dd118c8913680c911201..6ed24bfd367a9c32664674a6d64490e755e6eb9a 100644
--- a/src/core/Drawings/regressionTrend/regressionTrend.ts
+++ b/src/core/Drawings/regressionTrend/regressionTrend.ts
@@ -1,17 +1,9 @@
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
-import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
+import { getDistanceToSegment } from '@core/Drawings/utils';
-import { getThemeStore } from '@src/theme';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
+import { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { RegressionTrendPaneView } from './paneView';
import {
@@ -23,9 +15,7 @@ import {
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab } from '@src/types';
-import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
+import type { PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'start' | 'end' | 'body' | null;
@@ -84,7 +74,7 @@ const MIN_BAR_DISTANCE = 1;
const REGRESSION_DEVIATION = 2;
export class RegressionTrend
- extends SeriesDrawingBase<RegressionTrendSettings, RegressionTrendHandleKey>
+ extends DrawingBase<RegressionTrendSettings, RegressionTrendHandleKey>
implements ISeriesDrawing
{
private openSettings?: () => void;
@@ -106,14 +96,6 @@ export class RegressionTrend
showTime: Defaults.showTime,
};
- private paneView: RegressionTrendPaneView;
- private timeAxisPaneView: CustomTimeAxisPaneView;
- private priceAxisPaneView: CustomPriceAxisPaneView;
- private startTimeAxisView: CustomTimeAxisView;
- private endTimeAxisView: CustomTimeAxisView;
- private startPriceAxisView: CustomPriceAxisView;
- private endPriceAxisView: CustomPriceAxisView;
-
constructor({
chart,
series,
@@ -126,51 +108,16 @@ export class RegressionTrend
super({ chart, series, container, interaction });
this.openSettings = openSettings;
+ this.initializeViews(new RegressionTrendPaneView(this), ['start', 'end'], ['start', 'end']);
- this.paneView = new RegressionTrendPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'end',
- });
-
- this.startPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'end',
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -193,26 +140,25 @@ export class RegressionTrend
return;
}
- const next = state as Partial<RegressionTrendState>;
-
- this.hidden = next.hidden ?? this.hidden;
+ const nextState = state as Partial<RegressionTrendState>;
+ this.hidden = nextState.hidden ?? this.hidden;
- if (next.mode) {
- this.mode = next.mode === 'dragging' ? 'ready' : next.mode;
+ if (nextState.mode) {
+ this.mode = nextState.mode === 'dragging' ? 'ready' : nextState.mode;
}
- if ('startTime' in next) {
- this.startTime = next.startTime ?? null;
+ if ('startTime' in nextState) {
+ this.startTime = nextState.startTime ?? null;
}
- if ('endTime' in next) {
- this.endTime = next.endTime ?? null;
+ if ('endTime' in nextState) {
+ this.endTime = nextState.endTime ?? null;
}
- if (next.settings) {
+ if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
- ...next.settings,
+ ...nextState.settings,
};
}
@@ -223,36 +169,6 @@ export class RegressionTrend
return getRegressionTrendSettingsTabs(this.settings);
}
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- ...this.timeAxisViews(),
- ...this.priceAxisViews(),
- ]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
-
- public timeAxisViews() {
- return [this.startTimeAxisView, this.endTimeAxisView];
- }
-
- public priceAxisViews() {
- return [this.startPriceAxisView, this.endPriceAxisView];
- }
-
public getRenderData(): RegressionTrendRenderData | null {
if (this.hidden) {
return null;
@@ -325,29 +241,17 @@ export class RegressionTrend
}
protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
+ if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.left,
- to: geometry.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return geometry ? [this.createAxisSegment(geometry.left, geometry.right)] : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
+ if (!this.shouldShowInteractiveAxis()) {
return [];
}
@@ -357,19 +261,16 @@ export class RegressionTrend
return [];
}
- const { colors } = getThemeStore();
-
return [
- {
- from: Math.min(geometry.baseStartPoint.y, geometry.baseEndPoint.y),
- to: Math.max(geometry.baseStartPoint.y, geometry.baseEndPoint.y),
- color: colors.axisMarkerAreaFill,
- },
+ this.createAxisSegment(
+ Math.min(geometry.baseStartPoint.y, geometry.baseEndPoint.y),
+ Math.max(geometry.baseStartPoint.y, geometry.baseEndPoint.y),
+ ),
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
+ if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
@@ -382,69 +283,33 @@ export class RegressionTrend
const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatDate(
- time as UTCTimestamp,
- this.displayFormat.dateFormat,
- this.displayFormat.timeFormat,
- this.displayFormat.showTime,
- ),
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), this.formatTime(time));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
+ if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
- const labelKind = kind as PriceLabelKind;
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
- const price = labelKind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
+ const price = kind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
const coordinate = getYCoordinateFromPrice(this.series, price);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatPrice(price) ?? '',
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), formatPrice(price) ?? '');
}
protected handleDoubleClick = (event: MouseEvent): void => {
- if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
- return;
- }
-
- const point = this.getEventPoint(event as PointerEvent);
-
- if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- this.openSettings?.();
+ this.openSettingsOnDoubleClick(
+ event,
+ (point) => Boolean(this.containsPoint(point) || this.getDrawingHandleAtPoint(point)),
+ () => this.openSettings?.(),
+ true,
+ );
};
protected handlePointerDown = (event: PointerEvent): void => {
@@ -455,9 +320,7 @@ export class RegressionTrend
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDrawing(point);
return;
}
@@ -469,9 +332,7 @@ export class RegressionTrend
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.endTime = time;
if (!this.hasValidRange()) {
@@ -481,7 +342,6 @@ export class RegressionTrend
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
return;
}
@@ -490,15 +350,7 @@ export class RegressionTrend
return;
}
- if (!this.isSelected()) {
- if (!this.containsPoint(point)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- this.select();
+ if (this.selectOnPointerDown(event, () => this.containsPoint(point))) {
return;
}
@@ -509,16 +361,12 @@ export class RegressionTrend
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
-
this.mode = 'dragging';
-
this.hideCrosshair();
this.render();
};
@@ -556,9 +404,7 @@ export class RegressionTrend
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
-
this.mode = 'ready';
-
this.showCrosshair();
this.render();
};
@@ -597,10 +443,10 @@ export class RegressionTrend
baseStartPrice: regression.baseStartPrice,
baseEndPrice: regression.baseEndPrice,
correlation: regression.correlation,
- left: Math.min(...points.map((item) => item.x)),
- right: Math.max(...points.map((item) => item.x)),
- top: Math.min(...points.map((item) => item.y)),
- bottom: Math.max(...points.map((item) => item.y)),
+ left: Math.min(...points.map((point) => point.x)),
+ right: Math.max(...points.map((point) => point.x)),
+ top: Math.min(...points.map((point) => point.y)),
+ bottom: Math.max(...points.map((point) => point.y)),
};
}
@@ -614,7 +460,6 @@ export class RegressionTrend
this.startTime = time;
this.endTime = time;
this.mode = 'drawing';
-
this.render();
}
@@ -687,7 +532,6 @@ export class RegressionTrend
const minOffset = -minIndex;
const maxOffset = data.length - 1 - maxIndex;
const offset = Math.max(minOffset, Math.min(rawOffset, maxOffset));
-
this.startTime = data[startIndex + offset].time;
this.endTime = data[endIndex + offset].time;
}
@@ -756,11 +600,7 @@ export class RegressionTrend
private hasValidRange(): boolean {
const range = this.getBarRange();
- if (!range) {
- return false;
- }
-
- return range.right - range.left >= MIN_BAR_DISTANCE;
+ return range !== null && range.right - range.left >= MIN_BAR_DISTANCE;
}
private getBarTime(x: number): Time | null {
@@ -773,29 +613,17 @@ export class RegressionTrend
const data = this.getSeriesData();
const index = findNearestBarIndex(data, time);
- if (index === null) {
- return null;
- }
-
- return data[index].time;
+ return index === null ? null : data[index].time;
}
private getBarIndexByX(x: number, data: readonly RegressionSeriesData[]): number | null {
const time = getTimeFromXCoordinate(this.chart, x);
- if (typeof time !== 'number') {
- return null;
- }
-
- return findNearestBarIndex(data, time);
+ return typeof time === 'number' ? findNearestBarIndex(data, time) : null;
}
private getBarIndex(time: Time, data: readonly RegressionSeriesData[]): number | null {
- if (typeof time !== 'number') {
- return null;
- }
-
- return findNearestBarIndex(data, time);
+ return typeof time === 'number' ? findNearestBarIndex(data, time) : null;
}
private getSeriesData(): readonly RegressionSeriesData[] {
@@ -823,11 +651,7 @@ export class RegressionTrend
return handle.id;
}
- if (this.containsPoint(point)) {
- return 'body';
- }
-
- return null;
+ return this.containsPoint(point) ? 'body' : null;
}
private containsPoint(point: Point): boolean {
@@ -856,6 +680,15 @@ export class RegressionTrend
geometry.lowerStartPoint,
]);
}
+
+ private formatTime(time: number): string {
+ return formatDate(
+ time as UTCTimestamp,
+ this.displayFormat.dateFormat,
+ this.displayFormat.timeFormat,
+ this.displayFormat.showTime,
+ );
+ }
}
function calculateRegression(values: readonly number[]): {
@@ -944,7 +777,6 @@ function findNearestBarIndex(data: readonly RegressionSeriesData[], targetTime:
const nextIndex = Math.min(left, data.length - 1);
const previousIndex = Math.max(0, nextIndex - 1);
-
const nextTime = data[nextIndex].time;
const previousTime = data[previousIndex].time;
diff --git a/src/core/Drawings/ruler/ruler.ts b/src/core/Drawings/ruler/ruler.ts
index 81f152cc338fce0c28f03678b19c0cbacb390cdc..34fd37b9b7ae953953947829660fa1ee2d286a38 100644
--- a/src/core/Drawings/ruler/ruler.ts
+++ b/src/core/Drawings/ruler/ruler.ts
@@ -1,49 +1,29 @@
import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-import {
- getAnchorFromPoint,
- getPriceFromYCoordinate,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
-} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
+import { getPriceFromYCoordinate } from '@core/Drawings/helpers';
+import { BaseDrawingParams, ISeriesDrawing, TwoPointDrawingBase, TwoPointGeometry } from '@src/core/Drawings/base';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
-import { ChartOptionsModel, Direction } from '@src/types';
-import { Defaults } from '@src/types/defaults';
-import { SettingsTab, SettingsValues } from '@src/types/settings';
-import { formatPrice, formatVolume } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { ChartOptionsModel, Defaults, Direction, SettingsTab, SettingsValues } from '@src/types';
+import { formatDate, formatPrice, formatVolume } from '@src/utils';
import { RulerPaneView } from './paneView';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type {
AutoscaleInfo,
Coordinate,
- IPrimitivePaneView,
- ISeriesApi,
- ISeriesPrimitiveAxisView,
Logical,
MouseEventHandler,
MouseEventParams,
- SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
-type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
type RulerMode = 'idle' | 'placingEnd' | 'ready';
+type RulerAnchorKind = 'start' | 'end';
interface RulerState {
hidden: boolean;
@@ -68,7 +48,7 @@ export interface RulerRenderData {
verticalArrowSide: Direction.Top | Direction.Bottom | null;
}
-export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
+export class Ruler extends TwoPointDrawingBase<SettingsValues> implements ISeriesDrawing {
private removeSelf?: () => void;
protected settings: SettingsValues = {};
@@ -80,20 +60,9 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
showTime: Defaults.showTime,
};
- private startAnchor: Anchor | null = null;
- private endAnchor: Anchor | null = null;
-
private readonly clickHandler: MouseEventHandler<Time>;
private readonly moveHandler: MouseEventHandler<Time>;
- private readonly paneView: RulerPaneView;
- private readonly timeAxisPaneView: CustomTimeAxisPaneView;
- private readonly priceAxisPaneView: CustomPriceAxisPaneView;
- private readonly startTimeAxisView: CustomTimeAxisView;
- private readonly endTimeAxisView: CustomTimeAxisView;
- private readonly startPriceAxisView: CustomPriceAxisView;
- private readonly endPriceAxisView: CustomPriceAxisView;
-
constructor({
chart,
series,
@@ -104,57 +73,17 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
interaction,
initialEvent,
}: RulerParams) {
- super({
- chart,
- series,
- container,
- interaction,
- });
+ super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
-
this.clickHandler = (params) => this.handleChartClick(params);
this.moveHandler = (params) => this.handleMove(params);
+ this.initializeViews(new RulerPaneView(this), ['start', 'end'], ['start', 'end']);
- this.paneView = new RulerPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
- labelKind: 'start',
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
- labelKind: 'end',
- });
-
- this.startPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
- labelKind: 'start',
- });
-
- this.endPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
- labelKind: 'end',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
@@ -165,9 +94,8 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -189,59 +117,32 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
}
public setState(state: unknown): void {
- const nextState = state as Partial<RulerState>;
+ if (!state || typeof state !== 'object') {
+ return;
+ }
+ const nextState = state as Partial<RulerState>;
this.hidden = nextState.hidden ?? this.hidden;
this.mode = nextState.mode ?? this.mode;
- this.startAnchor = nextState.startAnchor ?? this.startAnchor;
- this.endAnchor = nextState.endAnchor ?? this.endAnchor;
- this.render();
- }
-
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- this.startTimeAxisView,
- this.endTimeAxisView,
- this.startPriceAxisView,
- this.endPriceAxisView,
- ]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
+ if ('startAnchor' in nextState) {
+ this.startAnchor = nextState.startAnchor ?? null;
+ }
- public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
- return [this.startTimeAxisView, this.endTimeAxisView];
- }
+ if ('endAnchor' in nextState) {
+ this.endAnchor = nextState.endAnchor ?? null;
+ }
- public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
- return [this.startPriceAxisView, this.endPriceAxisView];
+ this.render();
}
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
- if (this.hidden || this.mode === 'placingEnd') {
+ if (this.hidden || this.mode === 'placingEnd' || !this.startAnchor || !this.endAnchor) {
return null;
}
- if (!this.startAnchor || !this.endAnchor) {
- return null;
- }
-
- const startCoordinate = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
- const endCoordinate = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
+ const startCoordinate = this.getAnchorTimeCoordinate('start');
+ const endCoordinate = this.getAnchorTimeCoordinate('end');
if (startCoordinate === null || endCoordinate === null) {
return null;
@@ -263,42 +164,24 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
return {
priceRange: {
- minValue: Math.min(Number(this.startAnchor.price), Number(this.endAnchor.price)),
- maxValue: Math.max(Number(this.startAnchor.price), Number(this.endAnchor.price)),
+ minValue: Math.min(this.startAnchor.price, this.endAnchor.price),
+ maxValue: Math.max(this.startAnchor.price, this.endAnchor.price),
},
};
}
public getRenderData(): RulerRenderData {
- const startPoint = this.startAnchor ? this.getPoint(this.startAnchor) : null;
- const endPoint = this.endAnchor ? this.getPoint(this.endAnchor) : null;
-
- const startPrice = this.startAnchor ? Number(this.startAnchor.price) : 0;
- const endPrice = this.endAnchor ? Number(this.endAnchor.price) : 0;
+ const startPoint = this.getPointFromAnchor(this.startAnchor);
+ const endPoint = this.getPointFromAnchor(this.endAnchor);
+ const startPrice = this.startAnchor?.price ?? 0;
+ const endPrice = this.endAnchor?.price ?? 0;
const priceDiff = endPrice - startPrice;
const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;
-
- const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
- const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;
-
- const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
+ const barsCount = this.getBarsCount();
const volume = this.getVolumeInRange();
const isLong = priceDiff >= 0;
-
- const horizontalArrowSide =
- startPoint && endPoint && startPoint.x !== endPoint.x
- ? endPoint.x > startPoint.x
- ? Direction.Right
- : Direction.Left
- : null;
-
- const verticalArrowSide =
- startPoint && endPoint && startPoint.y !== endPoint.y
- ? endPoint.y > startPoint.y
- ? Direction.Bottom
- : Direction.Top
- : null;
-
+ const horizontalArrowSide = this.getHorizontalArrowSide(startPoint, endPoint);
+ const verticalArrowSide = this.getVerticalArrowSide(startPoint, endPoint);
const { colors } = getThemeStore();
return {
@@ -309,7 +192,9 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
textColor: colors.chartPriceLineText,
infoLines: [
- `${formatPrice(Math.abs(priceDiff))} (${percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`})`,
+ `${formatPrice(Math.abs(priceDiff))} (${
+ percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`
+ })`,
`${barsCount} ${t('bars')},`,
`${t('Vol')} ${formatVolume(volume)}`,
],
@@ -318,56 +203,46 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
};
}
- public getTimeCoordinate(kind: 'start' | 'end'): Coordinate | null {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
-
- if (!anchor) {
- return null;
- }
+ public getTimeCoordinate(kind: RulerAnchorKind): Coordinate | null {
+ const coordinate = this.getAnchorTimeCoordinate(kind);
- return getXCoordinateFromTime(this.chart, anchor.time, this.series);
+ return coordinate === null ? null : (coordinate as Coordinate);
}
- public getPriceCoordinate(kind: 'start' | 'end'): Coordinate | null {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ public getPriceCoordinate(kind: RulerAnchorKind): Coordinate | null {
+ const coordinate = this.getAnchorPriceCoordinate(kind);
- if (!anchor) {
- return null;
- }
-
- return getYCoordinateFromPrice(this.series, anchor.price);
+ return coordinate === null ? null : (coordinate as Coordinate);
}
public getTimeBounds(): { left: number; right: number } | null {
- const startX = this.getTimeCoordinate('start');
- const endX = this.getTimeCoordinate('end');
+ const geometry = this.getTwoPointGeometry();
- if (startX === null || endX === null) {
+ if (!geometry) {
return null;
}
return {
- left: Math.min(startX, endX),
- right: Math.max(startX, endX),
+ left: geometry.left,
+ right: geometry.right,
};
}
public getPriceBounds(): { top: number; bottom: number } | null {
- const startY = this.getPriceCoordinate('start');
- const endY = this.getPriceCoordinate('end');
+ const geometry = this.getTwoPointGeometry();
- if (startY === null || endY === null) {
+ if (!geometry) {
return null;
}
return {
- top: Math.min(startY, endY),
- bottom: Math.max(startY, endY),
+ top: geometry.top,
+ bottom: geometry.bottom,
};
}
- public getTimeText(kind: 'start' | 'end'): string {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ public getTimeText(kind: RulerAnchorKind): string {
+ const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return '';
@@ -381,58 +256,34 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
);
}
- public getPriceText(kind: 'start' | 'end'): string {
- const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
+ public getPriceText(kind: RulerAnchorKind): string {
+ const anchor = this.getAnchor(kind);
- if (!anchor) {
- return '';
- }
-
- return formatPrice(Number(anchor.price)) ?? '';
+ return anchor ? (formatPrice(anchor.price) ?? '') : '';
}
public shouldShowInObjectTree(): boolean {
return false;
}
- protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
+ protected getHoveredItem(_x: number, _y: number): PrimitiveHoveredItem | null {
return null;
}
+ protected getGeometry(): TwoPointGeometry | null {
+ return this.getTwoPointGeometry();
+ }
+
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.left,
- to: bounds.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return bounds ? [this.createAxisSegment(bounds.left, bounds.right)] : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.getPriceBounds();
- if (!bounds) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.top,
- to: bounds.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return bounds ? [this.createAxisSegment(bounds.top, bounds.bottom)] : [];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
@@ -440,21 +291,7 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
return null;
}
- const coordinate = this.getTimeCoordinate(kind);
- const text = this.getTimeText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(this.getAnchorTimeCoordinate(kind), this.getTimeText(kind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
@@ -462,37 +299,12 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
return null;
}
- const coordinate = this.getPriceCoordinate(kind);
- const text = this.getPriceText(kind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
- }
-
- private findIndexByTime(time: Time): number {
- const data = this.series.data() ?? [];
-
- return data.findIndex((item) => {
- if (typeof item.time === 'number' && typeof time === 'number') {
- return item.time === time;
- }
-
- return false;
- });
+ return this.createAxisLabel(this.getAnchorPriceCoordinate(kind), this.getPriceText(kind));
}
protected bindEvents(): void {
// todo: попробовать привести к виду базового класса
+
if (this.isBound) {
return;
}
@@ -502,12 +314,9 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
this.chart.subscribeCrosshairMove(this.moveHandler);
}
- protected getGeometry(): void {
- console.log('stub');
- }
-
protected unbindEvents(): void {
// todo: попробовать привести к виду базового класса
+
if (!this.isBound) {
return;
}
@@ -527,21 +336,14 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
return;
}
- if (!params.sourceEvent) {
- return;
- }
-
- const anchor = this.createAnchor(params);
+ const anchor = this.createAnchorFromParams(params);
if (!anchor) {
return;
}
- const point = this.getEventPoint(params.sourceEvent);
-
if (this.mode === 'idle') {
- this.startDrawing(point);
-
+ this.startDrawing(this.getEventPoint(params.sourceEvent));
return;
}
@@ -549,32 +351,25 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
this.endAnchor = anchor;
this.mode = 'ready';
this.resolveReady?.();
-
this.showCrosshair();
this.render();
}
}
private startDrawing(point: Point): void {
- const anchor = getAnchorFromPoint(this.chart, this.series, point);
- this.startAnchor = anchor;
- this.endAnchor = anchor;
+ const anchor = this.createAnchor(point);
+ this.setAnchors(anchor, anchor);
this.mode = 'placingEnd';
-
this.hideCrosshair();
this.render();
}
private handleMove(params: MouseEventParams<Time>): void {
- if (this.hidden || !params.point) {
- return;
- }
-
- if (this.mode !== 'placingEnd') {
+ if (this.hidden || !params.point || this.mode !== 'placingEnd') {
return;
}
- const anchor = this.createAnchor(params);
+ const anchor = this.createAnchorFromParams(params);
if (!anchor) {
return;
@@ -584,79 +379,35 @@ export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
this.render();
}
- private createAnchor({ time, point }: MouseEventParams<Time>): Anchor | null {
+ private createAnchorFromParams({ time, point }: MouseEventParams<Time>): Anchor | null {
if (!point || time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, point.y);
- if (price === null) {
- return null;
- }
-
- return {
- price,
- time,
- };
+ return price === null ? null : { time, price };
}
- private getPoint(anchor: Anchor): Point | null {
- const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
- const y = getYCoordinateFromPrice(this.series, anchor.price);
+ private getBarsCount(): number {
+ const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
+ const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;
+ return startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
+ }
- if (x === null || y === null) {
+ private getHorizontalArrowSide(start: Point | null, end: Point | null): RulerRenderData['horizontalArrowSide'] {
+ if (!start || !end || start.x === end.x) {
return null;
}
- return {
- x,
- y,
- };
+ return end.x > start.x ? Direction.Right : Direction.Left;
}
- 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;
- }
+ private getVerticalArrowSide(start: Point | null, end: Point | null): RulerRenderData['verticalArrowSide'] {
+ if (!start || !end || start.y === end.y) {
+ return null;
}
- return volume;
+ return end.y > start.y ? Direction.Bottom : Direction.Top;
}
}
diff --git a/src/core/Drawings/sliderPosition/sliderPosition.ts b/src/core/Drawings/sliderPosition/sliderPosition.ts
index 38267e9eedf8fe1c01b55de7c4cd762dfb6c378f..c9abaa9ac3918de4726334d0c6b7d8ef2983537b 100644
--- a/src/core/Drawings/sliderPosition/sliderPosition.ts
+++ b/src/core/Drawings/sliderPosition/sliderPosition.ts
@@ -1,13 +1,6 @@
import { round } from 'lodash-es';
import { skip } from 'rxjs';
-import {
- CustomPriceAxisPaneView,
- CustomPriceAxisView,
- CustomTimeAxisPaneView,
- CustomTimeAxisView,
-} from '@core/Drawings/axis';
-
import {
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
@@ -18,13 +11,19 @@ import {
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
+
+import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
-import { Defaults } from '@src/types/defaults';
-import { formatPercent, formatPrice, formatSignedNumber, getPercentPrecision, getPricePrecisionStep } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { type ChartOptionsModel, Defaults, type SettingsTab, type SettingsValues } from '@src/types';
+import {
+ formatDate,
+ formatPercent,
+ formatPrice,
+ formatSignedNumber,
+ getPercentPrecision,
+ getPricePrecisionStep,
+} from '@src/utils';
import { SliderPaneView } from './paneView';
import { createDefaultSettings, getSliderPositionSettingsTabs } from './settings';
@@ -33,23 +32,18 @@ import type { SliderPositionArguments, SliderPositionSettings, SliderPositionSty
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
-import type {
- IPrimitivePaneView,
- MouseEventHandler,
- MouseEventParams,
- PrimitiveHoveredItem,
- Time,
- UTCTimestamp,
-} from 'lightweight-charts';
+import type { MouseEventHandler, MouseEventParams, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import type { Observable } from 'rxjs';
type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
+
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
+
type SliderHandleId = Exclude<DragTarget, 'body' | null>;
+
type TimeLabelKind = 'start' | 'end';
+
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams extends BaseDrawingParams {
@@ -110,25 +104,15 @@ const INITIAL_WIDTH_PX = 160;
const INITIAL_ZONE_OFFSET_PX = 60;
const DEFAULT_RISK_REWARD_RATIO = 1;
-export class SliderPosition
- extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
- implements ISeriesDrawing
-{
+export class SliderPosition extends DrawingBase<SliderPositionSettings, SliderHandleId> implements ISeriesDrawing {
protected settings: SliderPositionSettings = createDefaultSettings();
+
protected mode: SliderMode = 'idle';
private readonly side: SliderSide;
private readonly removeSelf?: () => void;
private readonly openSettings?: () => void;
private readonly clickHandler: MouseEventHandler<Time>;
- private readonly paneView: SliderPaneView;
- private readonly timeAxisPaneView: CustomTimeAxisPaneView;
- private readonly priceAxisPaneView: CustomPriceAxisPaneView;
- private readonly startTimeAxisView: CustomTimeAxisView;
- private readonly endTimeAxisView: CustomTimeAxisView;
- private readonly targetPriceAxisView: CustomPriceAxisView;
- private readonly entryPriceAxisView: CustomPriceAxisView;
- private readonly stopPriceAxisView: CustomPriceAxisView;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
@@ -138,9 +122,11 @@ export class SliderPosition
private startTime: Time | null = null;
private endTime: Time | null = null;
+
private entryPrice: number | null = null;
private stopPrice: number | null = null;
private targetPrice: number | null = null;
+
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
@@ -158,58 +144,23 @@ export class SliderPosition
openSettings,
initialEvent,
}: SliderPositionParams) {
- super({ chart, series, container, interaction });
+ super({
+ chart,
+ series,
+ container,
+ interaction,
+ });
this.side = side;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) => this.handleChartClick(params);
- this.paneView = new SliderPaneView(this);
+ this.initializeViews(new SliderPaneView(this), ['start', 'end'], ['target', 'entry', 'stop'], 'normal');
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- zOrder: 'normal',
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- zOrder: 'normal',
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'end',
- });
-
- this.targetPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'target',
- });
-
- this.entryPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'entry',
- });
-
- this.stopPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'stop',
- });
-
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
@@ -240,7 +191,9 @@ export class SliderPosition
targetPrice: this.targetPrice,
riskRewardRatio: this.getCurrentRiskRewardRatio(),
amount: this.settings.accountSize,
- settings: { ...this.settings },
+ settings: {
+ ...this.settings,
+ },
};
}
@@ -250,6 +203,7 @@ export class SliderPosition
}
const next = state as Partial<SliderPositionState>;
+
const {
hidden = this.hidden,
mode = this.mode,
@@ -303,7 +257,12 @@ export class SliderPosition
SliderPositionSettings & SliderPositionArguments
>;
- this.applyPositionArguments({ entryPrice, targetPrice, stopPrice });
+ this.applyPositionArguments({
+ entryPrice,
+ targetPrice,
+ stopPrice,
+ });
+
super.updateSettings(settings as SettingsValues);
}
@@ -311,39 +270,6 @@ export class SliderPosition
return getSliderPositionSettingsTabs(this.settings, this.getPositionArguments());
}
- public updateAllViews(): void {
- updateViews([
- this.paneView,
- this.timeAxisPaneView,
- this.priceAxisPaneView,
- this.startTimeAxisView,
- this.endTimeAxisView,
- this.targetPriceAxisView,
- this.entryPriceAxisView,
- this.stopPriceAxisView,
- ]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
-
- public timeAxisViews() {
- return [this.startTimeAxisView, this.endTimeAxisView];
- }
-
- public priceAxisViews() {
- return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
- }
-
public getRenderData(): SliderRenderData | null {
if (this.hidden) {
return null;
@@ -371,7 +297,10 @@ export class SliderPosition
};
}
- public getTimeBounds(): { left: number; right: number } | null {
+ public getTimeBounds(): {
+ left: number;
+ right: number;
+ } | null {
const start = this.getTimeCoordinate('start');
const end = this.getTimeCoordinate('end');
@@ -422,10 +351,13 @@ export class SliderPosition
switch (kind) {
case 'target':
return geometry.targetY;
+
case 'entry':
return geometry.entryY;
+
case 'stop':
return geometry.stopY;
+
default:
return null;
}
@@ -441,17 +373,23 @@ export class SliderPosition
switch (kind) {
case 'target':
return formatPrice(geometry.targetPrice) ?? '';
+
case 'entry':
return formatPrice(geometry.entryPrice) ?? '';
+
case 'stop':
return formatPrice(geometry.stopPrice) ?? '';
+
default:
return '';
}
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
- const point = { x, y };
+ const point = {
+ x,
+ y,
+ };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
@@ -497,15 +435,7 @@ export class SliderPosition
return [];
}
- const { colors } = getThemeStore();
-
- return [
- {
- from: bounds.left,
- to: bounds.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return [this.createAxisSegment(bounds.left, bounds.right)];
}
protected getPriceAxisSegments(): AxisSegment[] {
@@ -519,59 +449,30 @@ export class SliderPosition
return [];
}
- const { colors } = getThemeStore();
-
return [
- {
- from: geometry.profitTop,
- to: geometry.profitBottom,
- color: colors.axisMarkerAreaFill,
- },
- {
- from: geometry.lossTop,
- to: geometry.lossBottom,
- color: colors.axisMarkerAreaFill,
- },
+ this.createAxisSegment(geometry.profitTop, geometry.profitBottom),
+ this.createAxisSegment(geometry.lossTop, geometry.lossBottom),
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if (!this.isSelected() || (kind !== 'start' && kind !== 'end')) {
+ if (!this.isAxisLabelAvailable() || !this.isSelected() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
- const coordinate = this.getTimeCoordinate(labelKind);
- const text = this.getTimeText(labelKind);
-
- if (coordinate === null || !text) {
- return null;
- }
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(this.getTimeCoordinate(labelKind), this.getTimeText(labelKind));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
+ if (!this.isAxisLabelAvailable() || (kind !== 'target' && kind !== 'entry' && kind !== 'stop')) {
return null;
}
const labelKind = kind as PriceLabelKind;
- const coordinate = this.getPriceCoordinate(labelKind);
- const text = this.getPriceText(labelKind);
-
- if (coordinate === null || !text) {
- return null;
- }
-
const { colors } = getThemeStore();
+
let backgroundColor = colors.axisMarkerLabelDefaultFill;
if (labelKind === 'target') {
@@ -580,12 +481,9 @@ export class SliderPosition
backgroundColor = colors.axisMarkerLabelNegativeFill;
}
- return {
- coordinate,
- text,
- textColor: colors.chartPriceLineText,
+ return this.createAxisLabel(this.getPriceCoordinate(labelKind), this.getPriceText(labelKind), {
backgroundColor,
- };
+ });
}
protected bindEvents(): void {
@@ -594,6 +492,7 @@ export class SliderPosition
}
super.bindEvents();
+
this.chart.subscribeClick(this.clickHandler);
}
@@ -603,6 +502,7 @@ export class SliderPosition
}
this.chart.unsubscribeClick(this.clickHandler);
+
super.unbindEvents();
}
@@ -617,8 +517,7 @@ export class SliderPosition
return;
}
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
this.openSettings?.();
};
@@ -629,14 +528,7 @@ export class SliderPosition
const point = this.getEventPoint(event);
- if (!this.isSelected()) {
- if (!this.containsPoint(point)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- this.select();
+ if (this.selectOnPointerDown(event, () => this.containsPoint(point))) {
return;
}
@@ -647,9 +539,7 @@ export class SliderPosition
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
@@ -663,6 +553,7 @@ export class SliderPosition
}
event.preventDefault();
+
this.applyDrag(this.getEventPoint(event));
this.render();
};
@@ -677,7 +568,6 @@ export class SliderPosition
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.mode = 'ready';
-
this.resolveReady?.();
this.render();
};
@@ -740,6 +630,7 @@ export class SliderPosition
private handleChartClick(params: MouseEventParams<Time>): void {
// todo: привести к общему виду дровингов как handleChartClick => startDrawing
+
if (this.hidden || !params.point || this.mode !== 'idle') {
return;
}
@@ -761,11 +652,11 @@ export class SliderPosition
const stopDirection = this.side === 'long' ? -1 : 1;
const targetDirection = -stopDirection;
-
this.startTime = anchor.time;
this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
this.entryPrice = anchor.price;
this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection);
+
this.targetPrice = this.normalizeTarget(
anchor.price,
anchor.price + distance * targetDirection * DEFAULT_RISK_REWARD_RATIO,
@@ -787,18 +678,23 @@ export class SliderPosition
case 'body':
this.moveWhole(snapshot, point);
break;
+
case 'entry':
this.moveEntry(snapshot, point);
break;
+
case 'target':
this.moveTarget(snapshot, point);
break;
+
case 'stop':
this.moveStop(snapshot, point);
break;
+
case 'end':
this.resizeEnd(snapshot, point);
break;
+
default:
break;
}
@@ -816,9 +712,7 @@ export class SliderPosition
}
const minDistance = getPricePrecisionStep();
-
const minPrice = this.side === 'long' ? snapshot.stopPrice + minDistance : snapshot.targetPrice;
-
const maxPrice = this.side === 'long' ? snapshot.targetPrice : snapshot.stopPrice - minDistance;
if (minPrice > maxPrice) {
@@ -844,7 +738,6 @@ export class SliderPosition
const timeOffset = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
-
const nextStartTime = this.shiftTime(snapshot.startTime, timeOffset);
const nextEndTime = this.shiftTime(snapshot.endTime, timeOffset);
@@ -853,7 +746,9 @@ export class SliderPosition
}
let nextEntryPrice = snapshot.entryPrice + priceOffset;
+
let nextStopPrice = snapshot.stopPrice + priceOffset;
+
let nextTargetPrice = snapshot.targetPrice + priceOffset;
const range = getPriceRangeInContainer(this.series, this.container);
@@ -866,7 +761,9 @@ export class SliderPosition
const shift = range.min - minValue;
nextEntryPrice += shift;
+
nextStopPrice += shift;
+
nextTargetPrice += shift;
}
@@ -874,7 +771,9 @@ export class SliderPosition
const shift = maxValue - range.max;
nextEntryPrice -= shift;
+
nextStopPrice -= shift;
+
nextTargetPrice -= shift;
}
}
@@ -925,7 +824,10 @@ export class SliderPosition
this.endTime = nextEndTime;
}
- private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
+ private createAnchor(params: MouseEventParams<Time>): {
+ time: Time;
+ price: number;
+ } | null {
if (!params.point || params.time === undefined) {
return null;
}
@@ -980,9 +882,7 @@ export class SliderPosition
const minPrice = Math.min(upperPrice, lowerPrice);
const maxPrice = Math.max(upperPrice, lowerPrice);
-
const stopDistance = this.side === 'long' ? entryPrice - minPrice : maxPrice - entryPrice;
-
const targetDistance = this.side === 'long' ? maxPrice - entryPrice : entryPrice - minPrice;
if (stopDistance <= 0 || targetDistance <= 0) {
@@ -1018,6 +918,7 @@ export class SliderPosition
}
const data = this.series.data() ?? [];
+
let lastPrice: number | null = null;
for (const item of data) {
@@ -1157,7 +1058,6 @@ export class SliderPosition
private getTargetText(geometry: SliderGeometry, amount: number): string {
const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
-
const formattedDiff = formatPrice(diff) ?? '0';
const formattedAmount = formatPrice(amount, 2) ?? '0';
@@ -1177,7 +1077,6 @@ export class SliderPosition
private getStopText(geometry: SliderGeometry, amount: number): string {
const diff = Math.abs(geometry.stopPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
-
const formattedDiff = formatPrice(diff) ?? '0';
const formattedAmount = formatPrice(amount, 2) ?? '0';
diff --git a/src/core/Drawings/text/text.ts b/src/core/Drawings/text/text.ts
index af23c0f8a0a4d804466da1a844b3ba79401dd14d..03c59c46ef8dfb2eb95b2ab6be10cfd3ddff0b65 100644
--- a/src/core/Drawings/text/text.ts
+++ b/src/core/Drawings/text/text.ts
@@ -1,29 +1,24 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, TouchMouseEventData, UTCTimestamp } from 'lightweight-charts';
+import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
- clampPointToContainer as clampPointToContainerInElement,
- getAnchorFromPoint,
- getContainerSize as getElementContainerSize,
+ clampPointToContainer,
+ getContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
-import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-import { getThemeStore } from '@src/theme';
-import { SettingsTab } from '@src/types';
-import { Defaults } from '@src/types/defaults';
+import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
+import { Defaults, SettingsTab } from '@src/types';
import { formatDate, formatPrice } from '@src/utils';
import { TextPaneView } from './paneView';
import { createDefaultSettings, getTextSettingsTabs, TextContentStyle, TextSettings, TextStyle } from './settings';
-import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
+import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
type TextMode = 'idle' | 'dragging' | 'ready';
@@ -60,11 +55,16 @@ const UI = {
let measureCanvas: HTMLCanvasElement | null = null;
-export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDrawing {
- private readonly removeSelf?: () => void;
+export class Text extends DrawingBase<TextSettings> implements ISeriesDrawing {
private readonly openSettings?: () => void;
protected mode: TextMode = 'idle';
+ protected settings: TextSettings = createDefaultSettings();
+
+ private point: Anchor | null = null;
+ private dragPointerId: number | null = null;
+ private dragStartPoint: Point | null = null;
+ private dragGeometrySnapshot: TextGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
@@ -72,27 +72,11 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
showTime: Defaults.showTime,
};
- private point: Anchor | null = null;
- protected settings: TextSettings = createDefaultSettings();
-
- private dragPointerId: number | null = null;
- private dragStartPoint: Point | null = null;
- private dragGeometrySnapshot: TextGeometry | null = null;
-
private readonly paneView: TextPaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
- constructor({
- chart,
- series,
- formatObservable,
- removeSelf,
- openSettings,
- container,
- interaction,
- initialEvent,
- }: TextParams) {
+ constructor({ chart, series, formatObservable, openSettings, container, interaction, initialEvent }: TextParams) {
super({
chart,
series,
@@ -100,35 +84,19 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
interaction,
});
- this.removeSelf = removeSelf;
this.openSettings = openSettings;
-
this.paneView = new TextPaneView(this);
+ this.timeAxisView = this.createTimeAxisView('main');
+ this.priceAxisView = this.createPriceAxisView('main');
- this.timeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'main',
- });
-
- this.priceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'main',
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -155,10 +123,12 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
}
const nextState = state as Partial<TextState>;
-
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
- this.point = nextState.point ?? this.point;
+
+ if ('point' in nextState) {
+ this.point = nextState.point ?? null;
+ }
if (nextState.settings) {
this.settings = {
@@ -213,11 +183,7 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
- if (this.hidden || this.mode === 'idle') {
- return null;
- }
-
- if (!this.containsPoint({ x, y })) {
+ if (this.hidden || this.mode === 'idle' || !this.containsPoint({ x, y })) {
return null;
}
@@ -229,50 +195,37 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if (kind !== 'main' || !this.isSelected() || !this.point || typeof this.point.time !== 'number') {
+ if (
+ !this.isAxisLabelAvailable() ||
+ kind !== 'main' ||
+ !this.isSelected() ||
+ !this.point ||
+ typeof this.point.time !== 'number'
+ ) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.point.time, this.series);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatDate(
+ return this.createAxisLabel(
+ coordinate === null ? null : Number(coordinate),
+ formatDate(
this.point.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ );
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if (kind !== 'main' || !this.isSelected() || !this.point) {
+ if (!this.isAxisLabelAvailable() || kind !== 'main' || !this.isSelected() || !this.point) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.point.price);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatPrice(this.point.price) ?? '',
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), formatPrice(this.point.price) ?? '');
}
protected getPriceAxisSegments(): AxisSegment[] {
@@ -291,9 +244,7 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDrawing(point);
return;
}
@@ -309,17 +260,13 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.select();
return;
}
if (containsPoint) {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDragging(point, event.pointerId);
return;
}
@@ -338,9 +285,7 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.openSettings?.();
};
@@ -356,18 +301,16 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
};
protected handlePointerUp = (event: PointerEvent): void => {
- if (this.dragPointerId !== event.pointerId) {
+ if (this.dragPointerId !== event.pointerId || this.mode !== 'dragging') {
return;
}
- if (this.mode === 'dragging') {
- this.finishDragging();
- }
+ this.finishDragging();
};
private startDrawing(point: Point): void {
// todo: вынести в абстрактный класс абстрактным методом (и в соседних классах)
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ const anchor = this.createAnchorFromPoint(clampPointToContainer(point, this.container));
if (!anchor) {
return;
@@ -376,7 +319,6 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
this.point = anchor;
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
@@ -385,7 +327,6 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
-
this.hideCrosshair();
this.render();
}
@@ -393,11 +334,9 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
-
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
-
this.showCrosshair();
this.render();
}
@@ -410,15 +349,13 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
return;
}
- const { width, height } = this.getContainerSize();
-
+ const { width, height } = getContainerSize(this.container);
const offsetX = eventPoint.x - dragStartPoint.x;
const offsetY = eventPoint.y - dragStartPoint.y;
-
const nextLeft = clamp(geometry.left + offsetX, 0, Math.max(0, width - geometry.width));
const nextTop = clamp(geometry.top + offsetY, 0, Math.max(0, height - geometry.height));
- const anchor = this.createAnchor({
+ const anchor = this.createAnchorFromPoint({
x: nextLeft,
y: nextTop,
});
@@ -430,48 +367,38 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
this.point = anchor;
}
- private createAnchor(point: Point): Anchor | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
protected getGeometry(): TextGeometry | null {
- if (!this.point) {
- return null;
- }
-
- const { width: containerWidth, height: containerHeight } = this.getContainerSize();
-
- const x = getXCoordinateFromTime(this.chart, this.point.time, this.series);
- const y = getYCoordinateFromPrice(this.series, this.point.price);
+ const projectedPoint = this.getPointFromAnchor(this.point);
- if (x === null || y === null) {
+ if (!projectedPoint) {
return null;
}
+ const { width: containerWidth, height: containerHeight } = getContainerSize(this.container);
+
const anchorPoint: Point = {
- x: clamp(Math.round(Number(x)), 0, containerWidth),
- y: clamp(Math.round(Number(y)), 0, containerHeight),
+ x: clamp(Math.round(projectedPoint.x), 0, containerWidth),
+ y: clamp(Math.round(projectedPoint.y), 0, containerHeight),
};
const lines = getTextLines(this.settings.text);
const font = getFont(this.settings);
const lineHeight = this.settings.fontSize;
const measured = measureTextBlock(lines, font, lineHeight);
-
const width = Math.min(containerWidth, measured.width + UI.padding * 2);
const height = Math.min(containerHeight, measured.height + UI.padding * 2);
-
const left = clamp(anchorPoint.x, 0, Math.max(0, containerWidth - width));
const top = clamp(anchorPoint.y, 0, Math.max(0, containerHeight - height));
- const right = left + width;
- const bottom = top + height;
return {
- point: { x: left, y: top },
+ point: {
+ x: left,
+ y: top,
+ },
left,
- right,
+ right: left + width,
top,
- bottom,
+ bottom: top + height,
width,
height,
lines,
@@ -483,19 +410,7 @@ export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDraw
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
- if (!geometry) {
- return false;
- }
-
- return isPointInBounds(point, geometry, 2);
- }
-
- private getContainerSize(): { width: number; height: number } {
- return getElementContainerSize(this.container);
- }
-
- private clampPointToContainer(point: Point): Point {
- return clampPointToContainerInElement(point, this.container);
+ return geometry !== null && isPointInBounds(point, geometry, 2);
}
}
@@ -512,7 +427,14 @@ function getFont(settings: TextSettings): string {
return `${italic}${bold}${settings.fontSize}px Inter, sans-serif`;
}
-function measureTextBlock(lines: string[], font: string, lineHeight: number): { width: number; height: number } {
+function measureTextBlock(
+ lines: string[],
+ font: string,
+ lineHeight: number,
+): {
+ width: number;
+ height: number;
+} {
const context = getMeasureContext();
if (!context) {
diff --git a/src/core/Drawings/traectory/traectory.ts b/src/core/Drawings/traectory/traectory.ts
index 9ad386fdb6a14c7c2e036f78d747caec62bb4129..8d2fb230572b8d8ba90ae0309c7ca6a990f4c88d 100644
--- a/src/core/Drawings/traectory/traectory.ts
+++ b/src/core/Drawings/traectory/traectory.ts
@@ -1,33 +1,21 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem } from 'lightweight-charts';
+import { PrimitiveHoveredItem } from 'lightweight-charts';
import { clamp } from 'lodash-es';
-import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
-import {
- clampPointToContainer as clampPointToContainerInElement,
- getAnchorFromPoint,
- getContainerSize as getElementContainerSize,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- isNearPoint,
-} from '@core/Drawings/helpers';
-import { AxisLabel } from '@core/Drawings/types';
-import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-
-import { getThemeStore } from '@src/theme';
+import { clampPointToContainer, getContainerSize, isNearPoint } from '@core/Drawings/helpers';
+import { getDistanceToSegment } from '@core/Drawings/utils';
-import { TraectoryPaneView } from './paneView';
+import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
+import { TraectoryPaneView } from './paneView';
import { createDefaultSettings, getTraectorySettingsTabs, TraectorySettings, TraectoryStyle } from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
-import type { Anchor, AxisSegment, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
+import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { SettingsTab } from '@src/types';
type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
-type TraectoryHandleId = `${number}`;
+type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;
export interface TraectoryState {
@@ -54,11 +42,12 @@ const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
-export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHandleId> implements ISeriesDrawing {
+export class Traectory extends DrawingBase<TraectorySettings, TraectoryHandleId> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TraectorySettings = createDefaultSettings();
+
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
@@ -69,10 +58,6 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
private dragPointIndex: number | null = null;
private dragGeometrySnapshot: TraectoryGeometry | null = null;
- private readonly paneView: TraectoryPaneView;
- private readonly timeAxisPaneView: CustomTimeAxisPaneView;
- private readonly priceAxisPaneView: CustomPriceAxisPaneView;
-
constructor({ chart, series, container, interaction, removeSelf, openSettings, initialEvent }: TraectoryParams) {
super({
chart,
@@ -83,22 +68,11 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
this.removeSelf = removeSelf;
this.openSettings = openSettings;
-
- this.paneView = new TraectoryPaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
+ this.initializeViews(new TraectoryPaneView(this), [], []);
this.series.attachPrimitive(this);
- if (initialEvent && initialEvent.sourceEvent) {
- const point = this.getEventPoint(initialEvent.sourceEvent);
- this.startDrawing(point);
+ if (initialEvent?.sourceEvent) {
+ this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
@@ -111,7 +85,9 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
hidden: this.hidden,
mode: this.mode,
points: this.points,
- settings: { ...this.settings },
+ settings: {
+ ...this.settings,
+ },
};
}
@@ -121,7 +97,6 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
}
const nextState = state as Partial<TraectoryState>;
-
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
this.points = Array.isArray(nextState.points) ? nextState.points : this.points;
@@ -140,30 +115,6 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return getTraectorySettingsTabs(this.settings);
}
- public updateAllViews(): void {
- updateViews([this.paneView, this.timeAxisPaneView, this.priceAxisPaneView]);
- }
-
- public paneViews(): readonly IPrimitivePaneView[] {
- return [this.paneView];
- }
-
- public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.timeAxisPaneView];
- }
-
- public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- return [this.priceAxisPaneView];
- }
-
- public timeAxisViews() {
- return [];
- }
-
- public priceAxisViews() {
- return [];
- }
-
public getRenderData(): TraectoryRenderData | null {
if (this.hidden) {
return null;
@@ -204,7 +155,10 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return null;
}
- const point = { x, y };
+ const point = {
+ x,
+ y,
+ };
if (!this.isSelected()) {
if (!this.isPointNearTraectory(point)) {
@@ -238,54 +192,18 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
}
protected getTimeAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.left,
- to: geometry.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return this.getBoundsAxisSegments('time', () => this.getGeometry());
}
protected getPriceAxisSegments(): AxisSegment[] {
- if (!this.isSelected() && !this.isCreationPending()) {
- return [];
- }
-
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.top,
- to: geometry.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return this.getBoundsAxisSegments('price', () => this.getGeometry());
}
- protected getPriceAxisLabel(kind: string): AxisLabel | null {
+ protected getPriceAxisLabel(_kind: string): AxisLabel | null {
return null;
}
- protected getTimeAxisLabel(kind: string): AxisLabel | null {
+ protected getTimeAxisLabel(_kind: string): AxisLabel | null {
return null;
}
@@ -294,12 +212,8 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- event.preventDefault();
- event.stopPropagation();
-
- const point = this.getEventPoint(event as PointerEvent);
-
- this.appendPoint(point);
+ this.consumeEvent(event);
+ this.appendPoint(this.getEventPoint(event as PointerEvent));
};
protected handleDoubleClick = (event: MouseEvent): void => {
@@ -308,9 +222,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
}
if (this.mode === 'drawing') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.finishDrawing();
return;
}
@@ -325,9 +237,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.openSettings?.();
};
@@ -336,9 +246,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.finishDrawing();
};
@@ -350,9 +258,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDrawing(point);
return;
}
@@ -365,24 +271,14 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- if (!this.isSelected()) {
- if (!this.isPointNearTraectory(point)) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- this.select();
+ if (this.selectOnPointerDown(event, () => this.isPointNearTraectory(point))) {
return;
}
const pointIndex = this.getPointIndexAt(point);
if (pointIndex !== null) {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDraggingPoint(point, event.pointerId, pointIndex);
return;
}
@@ -392,9 +288,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDraggingBody(point, event.pointerId);
};
@@ -412,6 +306,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
if (this.mode === 'dragging-point') {
event.preventDefault();
+
this.movePoint(point);
this.render();
return;
@@ -419,6 +314,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
if (this.mode === 'dragging-body') {
event.preventDefault();
+
this.moveBody(point);
this.render();
}
@@ -435,7 +331,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
};
private startDrawing(point: Point): void {
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ const anchor = this.createAnchorFromPoint(clampPointToContainer(point, this.container));
if (!anchor) {
return;
@@ -444,12 +340,11 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
this.points = [anchor];
this.previewAnchor = anchor;
this.mode = 'drawing';
-
this.render();
}
private appendPoint(point: Point): void {
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ const anchor = this.createAnchorFromPoint(clampPointToContainer(point, this.container));
if (!anchor) {
return;
@@ -469,7 +364,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
}
private updatePreview(point: Point): void {
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ const anchor = this.createAnchorFromPoint(clampPointToContainer(point, this.container));
if (!anchor) {
return;
@@ -493,30 +388,25 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
this.previewAnchor = null;
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
private startDraggingPoint(point: Point, pointerId: number, pointIndex: number): void {
this.mode = 'dragging-point';
-
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = pointIndex;
this.dragGeometrySnapshot = this.getGeometry();
-
this.hideCrosshair();
this.render();
}
private startDraggingBody(point: Point, pointerId: number): void {
this.mode = 'dragging-body';
-
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragPointIndex = null;
this.dragGeometrySnapshot = this.getGeometry();
-
this.hideCrosshair();
this.render();
}
@@ -524,26 +414,22 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
-
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
-
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.mode = 'idle';
-
this.points = [];
this.previewAnchor = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
-
this.showCrosshair();
this.render();
}
@@ -556,10 +442,9 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- const nextPoint = this.clampPointToContainer(point);
const nextPoints = [...geometry.points];
- nextPoints[pointIndex] = nextPoint;
+ nextPoints[pointIndex] = clampPointToContainer(point, this.container);
this.setAnchorsFromPoints(nextPoints);
}
@@ -572,20 +457,21 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return;
}
- const { width, height } = this.getContainerSize();
-
+ const { width, height } = getContainerSize(this.container);
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
-
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
- const nextPoints = geometry.points.map((item) =>
- this.clampPointToContainer({
- x: item.x + offsetX,
- y: item.y + offsetY,
- }),
- );
+ const nextPoints = geometry.points.map((item) => {
+ return clampPointToContainer(
+ {
+ x: item.x + offsetX,
+ y: item.y + offsetY,
+ },
+ this.container,
+ );
+ });
this.setAnchorsFromPoints(nextPoints);
}
@@ -594,7 +480,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
const nextAnchors: Anchor[] = [];
for (const point of points) {
- const anchor = this.createAnchor(point);
+ const anchor = this.createAnchorFromPoint(point);
if (!anchor) {
return;
@@ -606,43 +492,33 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
this.points = nextAnchors;
}
- private createAnchor(point: Point): Anchor | null {
- return getAnchorFromPoint(this.chart, this.series, point);
- }
-
protected getGeometry(): TraectoryGeometry | null {
if (!this.points.length) {
return null;
}
- const { width, height } = this.getContainerSize();
+ const { width, height } = getContainerSize(this.container);
const screenPoints: Point[] = [];
for (const anchor of this.points) {
- const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
- const y = getYCoordinateFromPrice(this.series, anchor.price);
+ const point = this.getPointFromAnchor(anchor);
- if (x === null || y === null) {
+ if (!point) {
return null;
}
screenPoints.push({
- x: clamp(Math.round(Number(x)), 0, width),
- y: clamp(Math.round(Number(y)), 0, height),
+ x: clamp(point.x, 0, width),
+ y: clamp(point.y, 0, height),
});
}
- const left = Math.round(Math.min(...screenPoints.map((point) => point.x)));
- const right = Math.round(Math.max(...screenPoints.map((point) => point.x)));
- const top = Math.round(Math.min(...screenPoints.map((point) => point.y)));
- const bottom = Math.round(Math.max(...screenPoints.map((point) => point.y)));
-
return {
points: screenPoints,
- left,
- right,
- top,
- bottom,
+ left: Math.min(...screenPoints.map((point) => point.x)),
+ right: Math.max(...screenPoints.map((point) => point.x)),
+ top: Math.min(...screenPoints.map((point) => point.y)),
+ bottom: Math.max(...screenPoints.map((point) => point.y)),
};
}
@@ -651,18 +527,17 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return null;
}
- const x = getXCoordinateFromTime(this.chart, this.previewAnchor.time, this.series);
- const y = getYCoordinateFromPrice(this.series, this.previewAnchor.price);
+ const point = this.getPointFromAnchor(this.previewAnchor);
- if (x === null || y === null) {
+ if (!point) {
return null;
}
- const { width, height } = this.getContainerSize();
+ const { width, height } = getContainerSize(this.container);
return {
- x: clamp(Math.round(Number(x)), 0, width),
- y: clamp(Math.round(Number(y)), 0, height),
+ x: clamp(point.x, 0, width),
+ y: clamp(point.y, 0, height),
};
}
@@ -696,12 +571,4 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHan
return false;
}
-
- private getContainerSize(): { width: number; height: number } {
- return getElementContainerSize(this.container);
- }
-
- private clampPointToContainer(point: Point): Point {
- return clampPointToContainerInElement(point, this.container);
- }
}
diff --git a/src/core/Drawings/volumeProfile/volumeProfile.ts b/src/core/Drawings/volumeProfile/volumeProfile.ts
index 46bafc1f56c2eb767401b840421c86a78e662970..9b64c2be9f2a85b57b71685e4f1241d9df55b41f 100644
--- a/src/core/Drawings/volumeProfile/volumeProfile.ts
+++ b/src/core/Drawings/volumeProfile/volumeProfile.ts
@@ -17,16 +17,12 @@ import {
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
-import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
-import { getThemeStore } from '@src/theme';
-import { SettingsTab } from '@src/types';
-import { Defaults } from '@src/types/defaults';
-import { formatPrice } from '@src/utils';
-import { formatDate } from '@src/utils/formatter';
+import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
+import { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
+import { formatDate, formatPrice } from '@src/utils';
import { VolumeProfilePaneView } from './paneView';
-
import {
createDefaultSettings,
getVolumeProfileSettingsTabs,
@@ -36,8 +32,6 @@ import {
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
-import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
-import type { ChartOptionsModel } from '@src/types';
export type VolumeProfileKind = 'fixedRange' | 'visibleRange';
@@ -109,14 +103,12 @@ const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
-export class VolumeProfile
- extends SeriesDrawingBase<VolumeProfileSettings, VolumeProfileHandleId>
- implements ISeriesDrawing
-{
+export class VolumeProfile extends DrawingBase<VolumeProfileSettings, VolumeProfileHandleId> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly profileKind: VolumeProfileKind;
+
protected settings: VolumeProfileSettings = createDefaultSettings();
protected mode: VolumeProfileMode = 'idle';
@@ -126,7 +118,6 @@ export class VolumeProfile
private profileRows: VolumeProfileDataRow[] = [];
private profileMinPrice: number | null = null;
private profileMaxPrice: number | null = null;
-
private visibleRangeStartRatio = 0;
private activeDragTarget: DragTarget = null;
@@ -170,44 +161,17 @@ export class VolumeProfile
}
this.paneView = new VolumeProfilePaneView(this);
-
- this.timeAxisPaneView = new CustomTimeAxisPaneView({
- getAxisSegments: () => this.getTimeAxisSegments(),
- });
-
- this.priceAxisPaneView = new CustomPriceAxisPaneView({
- getAxisSegments: () => this.getPriceAxisSegments(),
- });
-
- this.startTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endTimeAxisView = new CustomTimeAxisView({
- getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
- labelKind: 'end',
- });
-
- this.startPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'start',
- });
-
- this.endPriceAxisView = new CustomPriceAxisView({
- getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
- labelKind: 'end',
+ this.timeAxisPaneView = this.createTimeAxisPaneView();
+ this.priceAxisPaneView = this.createPriceAxisPaneView();
+ this.startTimeAxisView = this.createTimeAxisView('start');
+ this.endTimeAxisView = this.createTimeAxisView('end');
+ this.startPriceAxisView = this.createPriceAxisView('start');
+ this.endPriceAxisView = this.createPriceAxisView('end');
+
+ this.subscribeFormat(formatObservable, (format) => {
+ this.displayFormat = format;
});
- if (formatObservable) {
- this.subscriptions.add(
- formatObservable.subscribe((format) => {
- this.displayFormat = format;
- this.render();
- }),
- );
- }
-
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.calculateProfile();
@@ -261,23 +225,8 @@ export class VolumeProfile
}
const nextState = state as Partial<VolumeProfileState>;
-
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
-
- if (this.profileKind === 'fixedRange') {
- this.mode = nextState.mode ?? this.mode;
- this.startAnchor = nextState.startAnchor ?? this.startAnchor;
- this.endAnchor = nextState.endAnchor ?? this.endAnchor;
- }
-
- if (this.profileKind === 'visibleRange') {
- this.mode = 'ready';
- this.resolveReady?.();
-
- if (typeof nextState.visibleRangeStartRatio === 'number') {
- this.visibleRangeStartRatio = clamp(nextState.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
- }
- }
+ this.restoreRangeState(nextState);
if ('settings' in nextState && nextState.settings) {
this.settings = {
@@ -311,35 +260,21 @@ export class VolumeProfile
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
- if (this.profileKind === 'visibleRange') {
- return [];
- }
-
- return [this.timeAxisPaneView];
+ return this.profileKind === 'visibleRange' ? [] : [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
- if (this.profileKind === 'visibleRange') {
- return [];
- }
-
- return [this.priceAxisPaneView];
+ return this.profileKind === 'visibleRange' ? [] : [this.priceAxisPaneView];
}
public timeAxisViews() {
- if (this.profileKind === 'visibleRange') {
- return [this.startTimeAxisView];
- }
-
- return [this.startTimeAxisView, this.endTimeAxisView];
+ return this.profileKind === 'visibleRange'
+ ? [this.startTimeAxisView]
+ : [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
- if (this.profileKind === 'visibleRange') {
- return [];
- }
-
- return [this.startPriceAxisView, this.endPriceAxisView];
+ return this.profileKind === 'visibleRange' ? [] : [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): VolumeProfileRenderData | null {
@@ -374,11 +309,7 @@ export class VolumeProfile
if (this.profileKind === 'visibleRange') {
const pocY = this.getPocY(geometry);
- if (pocY === null) {
- return [];
- }
-
- return [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle' }];
+ return pocY === null ? [] : [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle' }];
}
return [
@@ -388,47 +319,23 @@ export class VolumeProfile
}
protected getTimeAxisSegments(): AxisSegment[] {
- if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
+ if (this.profileKind === 'visibleRange' || !this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.left,
- to: geometry.right,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return geometry ? [this.createAxisSegment(geometry.left, geometry.right)] : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
- if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
+ if (this.profileKind === 'visibleRange' || !this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
- if (!geometry) {
- return [];
- }
-
- const { colors } = getThemeStore();
-
- return [
- {
- from: geometry.top,
- to: geometry.bottom,
- color: colors.axisMarkerAreaFill,
- },
- ];
+ return geometry ? [this.createAxisSegment(geometry.top, geometry.bottom)] : [];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
@@ -458,7 +365,7 @@ export class VolumeProfile
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
- if (!this.isSelected() && !this.isCreationPending()) {
+ if (!this.shouldShowInteractiveAxis()) {
return null;
}
@@ -479,7 +386,7 @@ export class VolumeProfile
return null;
}
- return this.createTimeAxisLabel(geometry.left, time);
+ return this.createAxisLabel(geometry.left, this.formatTime(time));
}
if (kind !== 'start' && kind !== 'end') {
@@ -494,70 +401,30 @@ export class VolumeProfile
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
- if (coordinate === null) {
- return null;
- }
-
- return this.createTimeAxisLabel(Number(coordinate), anchor.time);
- }
-
- private createTimeAxisLabel(coordinate: number, time: number): AxisLabel {
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatDate(
- time as UTCTimestamp,
- this.displayFormat.dateFormat,
- this.displayFormat.timeFormat,
- this.displayFormat.showTime,
- ),
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), this.formatTime(anchor.time));
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
- if (this.profileKind === 'visibleRange') {
- return null;
- }
-
- if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
+ if (
+ this.profileKind === 'visibleRange' ||
+ !this.shouldShowInteractiveAxis() ||
+ (kind !== 'start' && kind !== 'end')
+ ) {
return null;
}
// const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;
- if (!price) {
+ if (price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, price);
- if (coordinate === null) {
- return null;
- }
-
- const { colors } = getThemeStore();
-
- return {
- coordinate,
- text: formatPrice(price) ?? '',
- textColor: colors.chartPriceLineText,
- backgroundColor: colors.axisMarkerLabelFill,
- };
+ return this.createAxisLabel(coordinate === null ? null : Number(coordinate), formatPrice(price) ?? '');
}
- private handleVisibleLogicalRangeChange = (): void => {
- if (this.profileKind !== 'visibleRange') {
- return;
- }
-
- this.calculateProfile();
- this.render();
- };
-
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
return;
@@ -569,9 +436,7 @@ export class VolumeProfile
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.openSettings?.();
};
@@ -590,7 +455,41 @@ export class VolumeProfile
this.handleFixedRangePointerDown(event, point);
};
- protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
+ protected handlePointerMove = (event: PointerEvent): void => {
+ const point = this.getEventPoint(event);
+
+ if (this.profileKind === 'visibleRange') {
+ this.handleVisibleRangePointerMove(event, point);
+ return;
+ }
+
+ this.handleFixedRangePointerMove(event, point);
+ };
+
+ protected handlePointerUp = (event: PointerEvent): void => {
+ if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
+ return;
+ }
+
+ this.finishDragging();
+ };
+
+ protected getGeometry(): VolumeProfileGeometry | null {
+ return this.profileKind === 'visibleRange'
+ ? this.getAnchoredVolumeProfileGeometry()
+ : this.getFixedRangeVolumeProfileGeometry();
+ }
+
+ private handleVisibleLogicalRangeChange = (): void => {
+ if (this.profileKind !== 'visibleRange') {
+ return;
+ }
+
+ this.calculateProfile();
+ this.render();
+ };
+
+ private handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
let dragTarget = this.getVisibleRangeDragTarget(point);
if (!dragTarget) {
@@ -601,8 +500,7 @@ export class VolumeProfile
return;
}
- event.preventDefault();
- event.stopPropagation();
+ this.consumeEvent(event);
if (!this.isSelected()) {
this.select();
@@ -616,24 +514,19 @@ export class VolumeProfile
this.activeDragTarget = 'poc';
this.dragPointerId = event.pointerId;
this.mode = 'dragging';
-
this.hideCrosshair();
this.render();
}
private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
if (this.mode === 'idle') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.updateDrawing(point);
this.finishDrawing();
return;
@@ -650,9 +543,7 @@ export class VolumeProfile
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.select();
return;
}
@@ -662,23 +553,10 @@ export class VolumeProfile
return;
}
- event.preventDefault();
- event.stopPropagation();
-
+ this.consumeEvent(event);
this.startDragging(point, event.pointerId, dragTarget);
}
- protected handlePointerMove = (event: PointerEvent): void => {
- const point = this.getEventPoint(event);
-
- if (this.profileKind === 'visibleRange') {
- this.handleVisibleRangePointerMove(event, point);
- return;
- }
-
- this.handleFixedRangePointerMove(event, point);
- };
-
private handleVisibleRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || this.activeDragTarget !== 'poc') {
return;
@@ -713,14 +591,6 @@ export class VolumeProfile
this.render();
}
- protected handlePointerUp = (event: PointerEvent): void => {
- if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
- return;
- }
-
- this.finishDragging();
- };
-
private applyInitialPoint(point: Point): void {
if (this.profileKind === 'visibleRange') {
this.moveVisibleRangeStart(point);
@@ -742,7 +612,6 @@ export class VolumeProfile
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
-
this.calculateProfile();
this.render();
}
@@ -755,7 +624,6 @@ export class VolumeProfile
}
this.endAnchor = anchor;
-
this.calculateProfile();
this.render();
}
@@ -778,31 +646,26 @@ export class VolumeProfile
this.mode = 'ready';
this.resolveReady?.();
-
this.showCrosshair();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DragTarget, null>): void {
this.mode = 'dragging';
-
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
-
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
-
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
-
this.showCrosshair();
this.render();
}
@@ -810,18 +673,13 @@ export class VolumeProfile
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
-
this.startAnchor = null;
this.endAnchor = null;
- this.profileRows = [];
- this.profileMinPrice = null;
- this.profileMaxPrice = null;
-
+ this.clearProfile();
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
-
this.showCrosshair();
this.render();
}
@@ -867,10 +725,8 @@ export class VolumeProfile
}
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);
@@ -896,16 +752,13 @@ export class VolumeProfile
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
-
this.calculateProfile();
}
private getDragTarget(point: Point): Exclude<DragTarget, null> | null {
- if (this.profileKind === 'visibleRange') {
- return this.getVisibleRangeDragTarget(point);
- }
-
- return this.getFixedRangeDragTarget(point);
+ return this.profileKind === 'visibleRange'
+ ? this.getVisibleRangeDragTarget(point)
+ : this.getFixedRangeDragTarget(point);
}
private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
@@ -915,11 +768,7 @@ export class VolumeProfile
return 'poc';
}
- if (this.containsPoint(point)) {
- return 'body';
- }
-
- return null;
+ return this.containsPoint(point) ? 'body' : null;
}
private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
@@ -929,21 +778,13 @@ export class VolumeProfile
return handle.id;
}
- if (this.containsPoint(point)) {
- return 'body';
- }
-
- return null;
+ return this.containsPoint(point) ? 'body' : null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
- if (!geometry) {
- return false;
- }
-
- return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
+ return geometry !== null && isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private calculateProfile(): void {
@@ -966,7 +807,6 @@ export class VolumeProfile
const from = Number(visibleRange.from);
const to = Number(visibleRange.to);
const start = from + (to - from) * this.visibleRangeStartRatio;
-
this.calculateProfileByLogicalRange(start, to);
}
@@ -978,16 +818,11 @@ export class VolumeProfile
const leftFrameTime = Math.min(Number(this.startAnchor.time), Number(this.endAnchor.time));
const rightFrameTime = Math.max(Number(this.startAnchor.time), Number(this.endAnchor.time));
-
const candles = this.series.data() as SeriesCandleData[];
- const selectedCandles: SeriesCandleData[] = [];
-
- candles.forEach((candle) => {
+ const selectedCandles = candles.filter((candle) => {
const candleTime = Number(candle.time);
- if (candleTime >= leftFrameTime && candleTime <= rightFrameTime) {
- selectedCandles.push(candle);
- }
+ return candleTime >= leftFrameTime && candleTime <= rightFrameTime;
});
this.calculateProfileByCandles(selectedCandles);
@@ -1045,7 +880,6 @@ export class VolumeProfile
this.profileMinPrice = minPrice;
this.profileMaxPrice = maxPrice;
-
const priceStep = (maxPrice - minPrice) / PROFILE_ROW_COUNT;
const profileRows = this.createEmptyProfileRows(minPrice, priceStep);
@@ -1165,21 +999,12 @@ export class VolumeProfile
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
- protected getGeometry(): VolumeProfileGeometry | null {
- if (this.profileKind === 'visibleRange') {
- return this.getAnchoredVolumeProfileGeometry();
- }
-
- return this.getFixedRangeVolumeProfileGeometry();
- }
-
private getAnchoredVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
return null;
}
const { width, height } = this.getContainerSize();
-
const topCoordinate = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
const bottomCoordinate = getYCoordinateFromPrice(this.series, this.profileMinPrice);
@@ -1193,14 +1018,8 @@ export class VolumeProfile
const bottom = clamp(Math.round(Math.max(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
return {
- startPoint: {
- x: left,
- y: top,
- },
- endPoint: {
- x: right,
- y: bottom,
- },
+ startPoint: { x: left, y: top },
+ endPoint: { x: right, y: bottom },
left,
right,
top,
@@ -1211,11 +1030,7 @@ export class VolumeProfile
}
private getFixedRangeVolumeProfileGeometry(): VolumeProfileGeometry | null {
- if (!this.startAnchor || !this.endAnchor) {
- return null;
- }
-
- if (this.profileMinPrice === null || this.profileMaxPrice === null) {
+ if (!this.startAnchor || !this.endAnchor || this.profileMinPrice === null || this.profileMaxPrice === null) {
return null;
}
@@ -1316,6 +1131,15 @@ export class VolumeProfile
};
}
+ private formatTime(time: number): string {
+ return formatDate(
+ time as UTCTimestamp,
+ this.displayFormat.dateFormat,
+ this.displayFormat.timeFormat,
+ this.displayFormat.showTime,
+ );
+ }
+
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
@@ -1327,6 +1151,29 @@ export class VolumeProfile
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
+
+ private restoreRangeState(nextState: Partial<VolumeProfileState>): void {
+ if (this.profileKind === 'fixedRange') {
+ this.mode = nextState.mode ?? this.mode;
+
+ if ('startAnchor' in nextState) {
+ this.startAnchor = nextState.startAnchor ?? null;
+ }
+
+ if ('endAnchor' in nextState) {
+ this.endAnchor = nextState.endAnchor ?? null;
+ }
+ }
+
+ if (this.profileKind === 'visibleRange') {
+ this.mode = 'ready';
+ this.resolveReady?.();
+
+ if (typeof nextState.visibleRangeStartRatio === 'number') {
+ this.visibleRangeStartRatio = clamp(nextState.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
+ }
+ }
+ }
}
function addPointVolume(
diff --git a/src/core/DrawingsManager.tsx b/src/core/DrawingsManager.tsx
index cfc453c648c6535db7e046bd69b2a8a5b04bad46..a832e1895e75a22529a44be6106b9571695edb95 100644
--- a/src/core/DrawingsManager.tsx
+++ b/src/core/DrawingsManager.tsx
@@ -15,7 +15,7 @@ import { ActiveDrawingTool, DOMObjectSnapshot } from '@src/types';
import { Pane } from './Pane';
-import type { DrawingInteraction } from '@src/core/Drawings/SeriesDrawingBase';
+import type { DrawingInteraction } from '@src/core/Drawings/base/DrawingBase';
import type { SettingsValues } from '@src/types/settings';
interface DrawingsManagerParams {
diff --git a/src/types/defaults.ts b/src/types/defaults.ts
index e1bc777a21eaf198e210e4f6e5fb73b7d69264d6..0a1ff1862647cb7f8b107489ec314ca8ace677e0 100644
--- a/src/types/defaults.ts
+++ b/src/types/defaults.ts
@@ -1,6 +1,6 @@
import { ChartOptionsModel } from '@src/types/chart';
import { TooltipConfig } from '@src/types/ohlc';
-import { DateFormat } from '@src/utils';
+import { DateFormat } from '@src/utils/formatter';
export const Defaults: ChartOptionsModel = {
timeFormat: '24h',
diff --git a/src/types/drawing.ts b/src/types/drawing.ts
index 7ab5844883ff2637b657f1b06094524123d0f42d..314ff195e43704768c863f2d0cc036c93c809013 100644
--- a/src/types/drawing.ts
+++ b/src/types/drawing.ts
@@ -3,7 +3,7 @@ import { IChartApi, ISeriesApi, MouseEventParams, SeriesType } from 'lightweight
import { DrawingsNames } from '@src/constants';
import { EventManager } from '@src/core';
-import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
+import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/base/DrawingBase';
export enum LineMarker {
normal = 'normal',
diff --git a/src/types/index.ts b/src/types/index.ts
index df65df6fff037c35bbf6cc2dbdd10dac659c9bd4..569a98dccce34215822f315dced079f00f59d585 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,6 +1,7 @@
export * from '../theme/types';
export * from './chart';
export * from './compare';
+export * from './defaults';
export * from './drawing';
export * from './indicator';
export * from './intervals';