Загрузка данных
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca8ee1c7095b492b95cf409c350a39afd5d9ff11..dde707217686db0c728730de6187682c76a96227 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# latest
+- Исправлено ограничение RR для короткой позиции - значение теперь можно уменьшать до 0
+- Исправлено перекрытие нижних активных точек короткой и длинной позиций подписями у границы графика
+
+# 0.1.24
+
- Добавлено отдельное управление абсолютным и относительным изменением в легенде
- Разделено отображение абсолютного и относительного изменения в тултипе
diff --git a/jest.config.ts b/jest.config.ts
index fffc6d0abb8f6d388d45b4c9960ed20ac462c0f0..a679886f216e6a9dd480a8040300cfde2b33628c 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -1,7 +1,6 @@
import { readFileSync } from 'fs';
import JSON5 from 'json5';
-
import { pathsToModuleNameMapper } from 'ts-jest';
import type { Config } from '@jest/types';
@@ -10,23 +9,34 @@ const fileContent = readFileSync('./tsconfig.json');
const fileContentJSON = JSON5.parse(fileContent.toString());
const config: Config.InitialOptions = {
- // нужен пакет, который поддержит резолв scss модуля
- // transform: {
- // "\\.scss$": "jest-sass",
- // },
clearMocks: true,
resetMocks: true,
restoreMocks: true,
- preset: 'ts-jest',
- transform: {
- '^.+\\.tsx?$': 'ts-jest',
- },
testEnvironment: 'jsdom',
+ testRegex: '[/\\\\]src[/\\\\].*\\.(?:test|spec)\\.[jt]sx?$',
+ setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleDirectories: ['node_modules', '<rootDir>'],
+ transform: {
+ '^.+\\.[cm]?[jt]sx?$': [
+ 'babel-jest',
+ {
+ babelrc: false,
+ configFile: false,
+ presets: [
+ ['@babel/preset-env', { targets: { node: 'current' }, modules: 'commonjs' }],
+ ['@babel/preset-react', { runtime: 'automatic' }],
+ ['@babel/preset-typescript', { allowDeclareFields: true }],
+ ],
+ },
+ ],
+ },
+ transformIgnorePatterns: [],
moduleNameMapper: {
...pathsToModuleNameMapper(fileContentJSON.compilerOptions.paths),
+ '^lightweight-charts$': '<rootDir>/node_modules/lightweight-charts/dist/lightweight-charts.development.mjs',
'^exchange-elements/v2$': '<rootDir>/node_modules/exchange-elements/dist/v2.cjs.js',
- '\\.(css|scss|module.scss)$': '<rootDir>/__mocks__/styleMock.js',
+ '\\.module\\.(?:css|scss|sass)$': 'identity-obj-proxy',
+ '\\.(?:css|scss|sass)$': '<rootDir>/__mocks__/styleMock.cjs',
},
coverageDirectory: '<rootDir>/coverage',
coverageReporters: ['text', 'lcov'],
diff --git a/jest.setup.js b/jest.setup.js
deleted file mode 100644
index 0fb9e24a87ab75469ad2ddd4a09d198317d47f5d..0000000000000000000000000000000000000000
--- a/jest.setup.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line import/no-extraneous-dependencies
-import '@testing-library/jest-dom';
\ No newline at end of file
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd03df87f06b2ae780781cc4db7d244b1afbc5ba
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,5 @@
+// eslint-disable-next-line import/no-extraneous-dependencies
+import '@testing-library/jest-dom';
+import { TextDecoder, TextEncoder } from 'util';
+
+Object.assign(global, { TextDecoder, TextEncoder });
diff --git a/package-lock.json b/package-lock.json
index facb10d082ff14e52350299b02d3c9d3c87585db..aaf6442834decd7a89feb8c0ef7b5fa355ba7db0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -57,6 +57,7 @@
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.21",
+ "babel-jest": "^29.7.0",
"babel-loader": "^8.3.0",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
@@ -78,6 +79,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^9.1.5",
+ "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
"json5": "^2.2.3",
@@ -12576,9 +12578,10 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
- "version": "4.17.13",
- "dev": true,
- "license": "MIT"
+ "version": "4.17.24",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha1-SuM0/GLA6RXKjtjjXcxtTuspIV8=",
+ "dev": true
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
@@ -14606,8 +14609,9 @@
},
"node_modules/babel-jest": {
"version": "29.7.0",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/babel-jest/-/babel-jest-29.7.0.tgz",
+ "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@jest/transform": "^29.7.0",
"@types/babel__core": "^7.1.14",
@@ -20719,6 +20723,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/harmony-reflect": {
+ "version": "1.6.2",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
+ "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
+ "dev": true
+ },
"node_modules/has": {
"version": "1.0.3",
"dev": true,
@@ -21371,6 +21381,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/identity-obj-proxy": {
+ "version": "3.0.0",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
+ "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
+ "dev": true,
+ "dependencies": {
+ "harmony-reflect": "^1.4.6"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/ieee754/-/ieee754-1.2.1.tgz",
@@ -25265,11 +25287,10 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw=",
- "dev": true,
- "license": "MIT"
+ "version": "4.18.1",
+ "resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha1-/ytmwfYybVlRPeJAe/iBQ5gSdxw=",
+ "dev": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
diff --git a/package.json b/package.json
index d13259a4ae9eb954f81a5fde9abe618aba4ee91e..594df0fc835f1a2c6a70f0b2b0ea50586fbaa9e3 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.21",
+ "babel-jest": "^29.7.0",
"babel-loader": "^8.3.0",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
@@ -105,6 +106,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^9.1.5",
+ "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
"json5": "^2.2.3",
diff --git a/src/core/Drawings/SeriesDrawingBase.ts b/src/core/Drawings/SeriesDrawingBase.ts
index 83db7f869b3213725deed6e82b2a6fbab4749cb2..20d57ff0af50e013aab7fc2d8dd21d6568d4bd04 100644
--- a/src/core/Drawings/SeriesDrawingBase.ts
+++ b/src/core/Drawings/SeriesDrawingBase.ts
@@ -17,11 +17,14 @@ import {
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';
-import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
+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';
+
export interface DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
@@ -81,7 +84,11 @@ export interface BaseDrawingParams {
initialEvent?: MouseEventParams;
}
-export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
+export abstract class SeriesDrawingBase<
+ TSettings extends SettingsValues = SettingsValues,
+ THandleId extends string = string,
+> implements ISeriesDrawing
+{
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
@@ -93,6 +100,7 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
+ private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
private isInteractionBound = false;
protected readyPromise: Promise<void> | null = null;
@@ -104,6 +112,14 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
this.series = series;
this.container = container;
this.interaction = interaction;
+
+ this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
+ if (this.hidden || !this.shouldShowHandles()) {
+ return [];
+ }
+
+ return this.getDrawingHandles();
+ });
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
@@ -183,11 +199,13 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
+ this.series.attachPrimitive(this.handlesPrimitive);
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
+ this.series.detachPrimitive(this.handlesPrimitive);
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
@@ -300,6 +318,14 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
+ protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
+ return [];
+ }
+
+ protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
+ return this.handlesPrimitive.findHandle(point);
+ }
+
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
@@ -389,6 +415,10 @@ export abstract class SeriesDrawingBase<TSettings extends SettingsValues = Setti
}
private isEventInside(event: MouseEvent): boolean {
- return event.target instanceof Node && this.container.contains(event.target);
+ if (event.target instanceof Node && this.container.contains(event.target)) {
+ return true;
+ }
+
+ return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
}
}
diff --git a/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts b/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..96c08569669d0bdd9a3a90e2d58071f4e42749b9
--- /dev/null
+++ b/src/core/Drawings/__tests__/SeriesDrawingBase.test.ts
@@ -0,0 +1,573 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..25ad42b837b1294618231747f3266623382152e5
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawingTestUtils.ts
@@ -0,0 +1,225 @@
+import { BehaviorSubject } from 'rxjs';
+
+import type { BaseDrawingParams } from '@core/Drawings/SeriesDrawingBase';
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+import type { Coordinate, Logical, Time } from 'lightweight-charts';
+
+interface EnvironmentOptions {
+ selected?: boolean;
+ locked?: boolean;
+ seriesData?: readonly unknown[];
+ width?: number;
+ height?: number;
+}
+
+interface CanvasTargetOptions {
+ horizontalPixelRatio?: number;
+ verticalPixelRatio?: number;
+ bitmapWidth?: number;
+ bitmapHeight?: number;
+}
+
+export function createDrawingTestEnvironment({
+ selected = false,
+ locked = false,
+ seriesData = [],
+ width = 400,
+ height = 300,
+}: EnvironmentOptions = {}) {
+ let selectedValue = selected;
+ let lockedValue = locked;
+
+ const selected$ = new BehaviorSubject(selected);
+ const locked$ = new BehaviorSubject(locked);
+
+ const interaction = {
+ 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);
+ }),
+ } as unknown as BaseDrawingParams['interaction'];
+
+ 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 coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
+ const timeToCoordinate = jest.fn((time: Time): Coordinate | null => Number(time) as Coordinate);
+ const coordinateToLogical = jest.fn((coordinate: Coordinate): Logical | null => Number(coordinate) as Logical);
+ const logicalToCoordinate = jest.fn((logical: Logical): Coordinate | null => Number(logical) as Coordinate);
+
+ const timeScale = {
+ coordinateToTime,
+ timeToCoordinate,
+ coordinateToLogical,
+ logicalToCoordinate,
+ getVisibleLogicalRange: jest.fn(() => ({
+ from: 0 as Logical,
+ to: Math.max(seriesData.length - 1, 0) as Logical,
+ })),
+ subscribeVisibleLogicalRangeChange: jest.fn(),
+ unsubscribeVisibleLogicalRangeChange: jest.fn(),
+ };
+
+ const pane = {
+ paneIndex: jest.fn(() => 0),
+ getHeight: jest.fn(() => height),
+ };
+
+ const chart = {
+ timeScale: jest.fn(() => timeScale),
+ applyOptions: jest.fn(),
+ panes: jest.fn(() => [pane]),
+ subscribeClick: jest.fn(),
+ unsubscribeClick: jest.fn(),
+ subscribeCrosshairMove: jest.fn(),
+ unsubscribeCrosshairMove: jest.fn(),
+ } as unknown as BaseDrawingParams['chart'];
+
+ const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
+ const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);
+
+ const series = {
+ coordinateToPrice,
+ priceToCoordinate,
+ data: jest.fn(() => seriesData),
+ attachPrimitive: jest.fn(),
+ detachPrimitive: jest.fn(),
+ getPane: jest.fn(() => pane),
+ } as unknown as BaseDrawingParams['series'];
+
+ return {
+ chart,
+ series,
+ container,
+ interaction,
+ timeScale,
+ pane,
+ coordinateToPrice,
+ priceToCoordinate,
+ setSelected(value: boolean) {
+ selectedValue = value;
+ selected$.next(value);
+ },
+ setLocked(value: boolean) {
+ lockedValue = value;
+ locked$.next(value);
+ },
+ };
+}
+
+export function createPointerEvent(target: Node, x: number, y: number, pointerId = 1, button = 0): PointerEvent {
+ return {
+ target,
+ clientX: x,
+ clientY: y,
+ pointerId,
+ button,
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ } as unknown as PointerEvent;
+}
+
+export function createMouseEvent(target: Node, x: number, y: number, detail = 1): MouseEvent {
+ return {
+ target,
+ clientX: x,
+ clientY: y,
+ detail,
+ preventDefault: jest.fn(),
+ stopPropagation: jest.fn(),
+ } as unknown as MouseEvent;
+}
+
+export function createCanvasTarget({
+ horizontalPixelRatio = 2,
+ verticalPixelRatio = 2,
+ bitmapWidth = 800,
+ bitmapHeight = 600,
+}: CanvasTargetOptions = {}) {
+ const context = {
+ canvas: {
+ width: bitmapWidth,
+ height: bitmapHeight,
+ },
+ save: jest.fn(),
+ restore: jest.fn(),
+ beginPath: jest.fn(),
+ closePath: jest.fn(),
+ moveTo: jest.fn(),
+ lineTo: jest.fn(),
+ quadraticCurveTo: jest.fn(),
+ arc: jest.fn(),
+ arcTo: jest.fn(),
+ rect: jest.fn(),
+ clip: jest.fn(),
+ fill: jest.fn(),
+ stroke: jest.fn(),
+ fillRect: jest.fn(),
+ strokeRect: jest.fn(),
+ fillText: jest.fn(),
+ setLineDash: jest.fn(),
+ translate: jest.fn(),
+ rotate: jest.fn(),
+ measureText: jest.fn((text: string) => ({
+ width: text.length * 8,
+ })),
+ fillStyle: '',
+ strokeStyle: '',
+ lineWidth: 0,
+ globalAlpha: 1,
+ font: '',
+ textAlign: 'start',
+ textBaseline: 'alphabetic',
+ };
+
+ const useBitmapCoordinateSpace = jest.fn(
+ (
+ callback: (scope: {
+ context: CanvasRenderingContext2D;
+ horizontalPixelRatio: number;
+ verticalPixelRatio: number;
+ bitmapSize: {
+ width: number;
+ height: number;
+ };
+ }) => void,
+ ) => {
+ callback({
+ context: context as unknown as CanvasRenderingContext2D,
+ horizontalPixelRatio,
+ verticalPixelRatio,
+ bitmapSize: {
+ width: bitmapWidth,
+ height: bitmapHeight,
+ },
+ });
+ },
+ );
+
+ return {
+ context,
+ useBitmapCoordinateSpace,
+ target: {
+ useBitmapCoordinateSpace,
+ } as unknown as CanvasRenderingTarget2D,
+ };
+}
diff --git a/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts b/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1a259067c5fc13feac359bffa2d0647dc9e30e08
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/CustomPriceAxis.test.ts
@@ -0,0 +1,190 @@
+import { CustomPriceAxisPaneView, CustomPriceAxisView } from '@core/Drawings/axis';
+
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+
+function createTarget(verticalPixelRatio = 2) {
+ const context = {
+ save: jest.fn(),
+ restore: jest.fn(),
+ fillRect: jest.fn(),
+ fillStyle: '',
+ } as unknown as CanvasRenderingContext2D;
+
+ const useBitmapCoordinateSpace = jest.fn(
+ (
+ callback: (scope: {
+ context: CanvasRenderingContext2D;
+ horizontalPixelRatio: number;
+ verticalPixelRatio: number;
+ bitmapSize: { width: number; height: number };
+ }) => void,
+ ) => {
+ callback({
+ context,
+ horizontalPixelRatio: 2,
+ verticalPixelRatio,
+ bitmapSize: { width: 300, height: 200 },
+ });
+ },
+ );
+
+ return {
+ context,
+ target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+ useBitmapCoordinateSpace,
+ };
+}
+
+describe('CustomPriceAxisPaneView', () => {
+ it('должен использовать bottom zOrder по умолчанию', () => {
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ expect(view.zOrder()).toBe('bottom');
+ });
+
+ it('должен использовать переданный zOrder', () => {
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [],
+ zOrder: 'top',
+ });
+
+ expect(view.zOrder()).toBe('top');
+ });
+
+ it('должен возвращать один экземпляр renderer', () => {
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ expect(view.renderer()).toBe(view.renderer());
+ });
+
+ it('не должен рисовать при отсутствии сегментов', () => {
+ const { target, useBitmapCoordinateSpace } = createTarget();
+
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ view.renderer().draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать сегмент по вертикали', () => {
+ const { context, target } = createTarget();
+
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 10,
+ to: 30,
+ color: '#ff0000',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ expect(context.fillStyle).toBe('#ff0000');
+ expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
+ expect(context.save).toHaveBeenCalledTimes(1);
+ expect(context.restore).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен корректно рисовать сегмент при обратном порядке координат', () => {
+ const { context, target } = createTarget();
+
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 30,
+ to: 10,
+ color: '#00ff00',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(0, 20, 300, 40);
+ });
+
+ it('должен рисовать несколько сегментов', () => {
+ const { context, target } = createTarget();
+
+ const view = new CustomPriceAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 10,
+ to: 20,
+ color: '#ff0000',
+ },
+ {
+ from: 30,
+ to: 50,
+ color: '#00ff00',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ 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',
+ }));
+
+ const view = new CustomPriceAxisView({
+ getAxisLabel,
+ labelKind: 'entry',
+ });
+
+ 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', () => {
+ const view = new CustomPriceAxisView({
+ getAxisLabel: () => null,
+ labelKind: 'entry',
+ });
+
+ 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('не должен быть видимым при пустом тексте', () => {
+ const view = new CustomPriceAxisView({
+ getAxisLabel: () => ({
+ coordinate: 100,
+ text: '',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
+ labelKind: 'entry',
+ });
+
+ expect(view.visible()).toBe(false);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts b/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..570590f9d8f72ac6d643d519c0ab8c89abbe8292
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/CustomTimeAxis.test.ts
@@ -0,0 +1,190 @@
+import { CustomTimeAxisPaneView, CustomTimeAxisView } from '@core/Drawings/axis';
+
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+
+function createTarget(horizontalPixelRatio = 2) {
+ const context = {
+ save: jest.fn(),
+ restore: jest.fn(),
+ fillRect: jest.fn(),
+ fillStyle: '',
+ } as unknown as CanvasRenderingContext2D;
+
+ const useBitmapCoordinateSpace = jest.fn(
+ (
+ callback: (scope: {
+ context: CanvasRenderingContext2D;
+ horizontalPixelRatio: number;
+ verticalPixelRatio: number;
+ bitmapSize: { width: number; height: number };
+ }) => void,
+ ) => {
+ callback({
+ context,
+ horizontalPixelRatio,
+ verticalPixelRatio: 2,
+ bitmapSize: { width: 300, height: 200 },
+ });
+ },
+ );
+
+ return {
+ context,
+ target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+ useBitmapCoordinateSpace,
+ };
+}
+
+describe('CustomTimeAxisPaneView', () => {
+ it('должен использовать bottom zOrder по умолчанию', () => {
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ expect(view.zOrder()).toBe('bottom');
+ });
+
+ it('должен использовать переданный zOrder', () => {
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [],
+ zOrder: 'normal',
+ });
+
+ expect(view.zOrder()).toBe('normal');
+ });
+
+ it('должен возвращать один экземпляр renderer', () => {
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ expect(view.renderer()).toBe(view.renderer());
+ });
+
+ it('не должен рисовать при отсутствии сегментов', () => {
+ const { target, useBitmapCoordinateSpace } = createTarget();
+
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [],
+ });
+
+ view.renderer().draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать сегмент по горизонтали', () => {
+ const { context, target } = createTarget();
+
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 10,
+ to: 30,
+ color: '#ff0000',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ expect(context.fillStyle).toBe('#ff0000');
+ expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
+ expect(context.save).toHaveBeenCalledTimes(1);
+ expect(context.restore).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен корректно рисовать сегмент при обратном порядке координат', () => {
+ const { context, target } = createTarget();
+
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 30,
+ to: 10,
+ color: '#00ff00',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(20, 0, 40, 200);
+ });
+
+ it('должен рисовать несколько сегментов', () => {
+ const { context, target } = createTarget();
+
+ const view = new CustomTimeAxisPaneView({
+ getAxisSegments: () => [
+ {
+ from: 10,
+ to: 20,
+ color: '#ff0000',
+ },
+ {
+ from: 30,
+ to: 50,
+ color: '#00ff00',
+ },
+ ],
+ });
+
+ view.renderer().draw(target);
+
+ 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',
+ }));
+
+ const view = new CustomTimeAxisView({
+ getAxisLabel,
+ labelKind: 'start',
+ });
+
+ 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', () => {
+ const view = new CustomTimeAxisView({
+ getAxisLabel: () => null,
+ labelKind: 'start',
+ });
+
+ 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('не должен быть видимым при пустом тексте', () => {
+ const view = new CustomTimeAxisView({
+ getAxisLabel: () => ({
+ coordinate: 150,
+ text: '',
+ textColor: '#ffffff',
+ backgroundColor: '#000000',
+ }),
+ labelKind: 'start',
+ });
+
+ 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
new file mode 100644
index 0000000000000000000000000000000000000000..ff40e0f8c8614a5bcf6c1f65c11e9f375aa37222
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/axisLine.test.ts
@@ -0,0 +1,580 @@
+import { AxisLine } from '@core/Drawings/axisLine/axisLine';
+import { AxisLinePaneRenderer } from '@core/Drawings/axisLine/paneRenderer';
+import { AxisLinePaneView } from '@core/Drawings/axisLine/paneView';
+import { createDefaultSettings, getAxisLineSettingsTabs } from '@core/Drawings/axisLine/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartBackground: '#ffffff',
+ chartLineColor: '#3366ff',
+ chartPriceLineText: '#ffffff',
+ axisMarkerLabelFill: '#112233',
+ },
+ }),
+}));
+
+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',
+}));
+
+jest.mock('@src/translations', () => ({
+ t: (value: string) => value,
+}));
+
+class TestAxisLine extends AxisLine {
+ public handles(): readonly DrawingHandle<'main'>[] {
+ return this.getDrawingHandles();
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+
+ public doubleClick(event: MouseEvent): void {
+ this.handleDoubleClick(event);
+ }
+}
+
+function createAxisLine(direction: 'vertical' | 'horizontal', selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const openSettings = jest.fn();
+
+ const drawing = new TestAxisLine({
+ ...env,
+ direction,
+ openSettings,
+ });
+
+ return {
+ ...env,
+ drawing,
+ openSettings,
+ };
+}
+
+describe('AxisLine', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('должен начинаться в idle', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ expect(drawing.isCreationPending()).toBe(true);
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'idle',
+ time: null,
+ price: null,
+ });
+ });
+
+ it('должен создавать vertical line', () => {
+ const { drawing, container, timeScale } = createAxisLine('vertical');
+
+ drawing.pointerDown(createPointerEvent(container, 80, 120));
+
+ expect(timeScale.coordinateToTime).toHaveBeenCalledWith(80);
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ time: 80,
+ price: null,
+ });
+ });
+
+ it('должен создавать horizontal line', () => {
+ const { drawing, container, coordinateToPrice } = createAxisLine('horizontal');
+
+ drawing.pointerDown(createPointerEvent(container, 80, 120));
+
+ expect(coordinateToPrice).toHaveBeenCalledWith(120);
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ time: null,
+ price: 120,
+ });
+ });
+
+ it('не должен начинать drawing правой кнопкой', () => {
+ const { drawing, container } = createAxisLine('vertical');
+
+ drawing.pointerDown(createPointerEvent(container, 80, 120, 1, 2));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'idle',
+ time: null,
+ });
+ });
+
+ it('должен восстанавливать state', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ hidden: true,
+ mode: 'ready',
+ price: 150,
+ settings: {
+ lineColor: '#ff0000',
+ text: 'Test',
+ },
+ });
+
+ expect(drawing.getState()).toMatchObject({
+ hidden: true,
+ mode: 'ready',
+ price: 150,
+ settings: {
+ lineColor: '#ff0000',
+ text: 'Test',
+ },
+ });
+ });
+
+ it('не должен возвращать render data для hidden line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ hidden: true,
+ mode: 'ready',
+ price: 100,
+ });
+
+ expect(drawing.getRenderData()).toBeNull();
+ });
+
+ it('должен возвращать render data vertical line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 100 as Time,
+ });
+
+ expect(drawing.getRenderData()).toEqual({
+ direction: 'vertical',
+ coordinate: 100,
+ lineColor: '#3366ff',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#3366ff',
+ });
+ });
+
+ it('должен возвращать render data horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ mode: 'ready',
+ price: 120,
+ });
+
+ expect(drawing.getRenderData()).toEqual({
+ direction: 'horizontal',
+ coordinate: 120,
+ lineColor: '#3366ff',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#3366ff',
+ });
+ });
+
+ it('должен размещать vertical handle по центру высоты', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 80 as Time,
+ });
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'main',
+ x: 80,
+ y: 150,
+ shape: 'rounded',
+ },
+ ]);
+ });
+
+ it('должен размещать horizontal handle по центру ширины', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ mode: 'ready',
+ price: 120,
+ });
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'main',
+ x: 200,
+ y: 120,
+ shape: 'rounded',
+ },
+ ]);
+ });
+
+ it('должен возвращать time axis view только для vertical line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ expect(drawing.timeAxisViews()).toHaveLength(1);
+ expect(drawing.priceAxisViews()).toHaveLength(0);
+ });
+
+ it('должен возвращать price axis view только для horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ expect(drawing.timeAxisViews()).toHaveLength(0);
+ expect(drawing.priceAxisViews()).toHaveLength(1);
+ });
+
+ it('должен возвращать time label для selected vertical line', () => {
+ const { drawing } = createAxisLine('vertical', true);
+
+ 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 для selected horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal', true);
+
+ drawing.setState({
+ mode: 'ready',
+ price: 120,
+ });
+
+ expect(drawing.priceLabel('main')).toEqual({
+ coordinate: 120,
+ text: '120',
+ textColor: '#ffffff',
+ backgroundColor: '#112233',
+ });
+ });
+
+ it('не должен возвращать label для unselected line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 80 as Time,
+ });
+
+ expect(drawing.timeLabel('main')).toBeNull();
+ });
+
+ it('должен определять hover vertical line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 50 as Time,
+ });
+
+ expect(drawing.hovered(54, 20)).toEqual({
+ cursorStyle: 'ew-resize',
+ externalId: 'axis-line',
+ zOrder: 'top',
+ });
+ });
+
+ it('должен определять hover horizontal line', () => {
+ const { drawing } = createAxisLine('horizontal');
+
+ drawing.setState({
+ mode: 'ready',
+ price: 50,
+ });
+
+ expect(drawing.hovered(20, 54)).toEqual({
+ cursorStyle: 'ns-resize',
+ externalId: 'axis-line',
+ zOrder: 'top',
+ });
+ });
+
+ it('не должен определять hover вдали от line', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 50 as Time,
+ });
+
+ expect(drawing.hovered(70, 20)).toBeNull();
+ });
+
+ it('должен выбирать unselected line', () => {
+ const { drawing, interaction, container } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 50 as Time,
+ });
+
+ drawing.pointerDown(createPointerEvent(container, 50, 20));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен перемещать vertical line', () => {
+ const { drawing, container } = createAxisLine('vertical', true);
+
+ drawing.setState({
+ mode: 'ready',
+ time: 50 as Time,
+ });
+
+ drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
+ drawing.pointerMove(createPointerEvent(container, 90, 20, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'dragging',
+ time: 90,
+ });
+
+ drawing.pointerUp(createPointerEvent(container, 90, 20, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ time: 90,
+ });
+ });
+
+ it('должен перемещать horizontal line', () => {
+ const { drawing, container } = createAxisLine('horizontal', true);
+
+ drawing.setState({
+ mode: 'ready',
+ price: 50,
+ });
+
+ drawing.pointerDown(createPointerEvent(container, 20, 50, 1));
+ drawing.pointerMove(createPointerEvent(container, 20, 90, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'dragging',
+ price: 90,
+ });
+ });
+
+ it('не должен реагировать на другой pointerId', () => {
+ const { drawing, container } = createAxisLine('vertical', true);
+
+ drawing.setState({
+ mode: 'ready',
+ time: 50 as Time,
+ });
+
+ drawing.pointerDown(createPointerEvent(container, 50, 20, 1));
+ drawing.pointerMove(createPointerEvent(container, 90, 20, 2));
+
+ expect(drawing.getState()).toMatchObject({
+ time: 50,
+ });
+ });
+
+ it('должен открывать settings по double click', () => {
+ const { drawing, container, openSettings } = createAxisLine('vertical');
+
+ drawing.setState({
+ mode: 'ready',
+ time: 50 as Time,
+ });
+
+ drawing.doubleClick(createMouseEvent(container, 50, 20));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен обновлять settings через Base', () => {
+ const { drawing } = createAxisLine('vertical');
+
+ drawing.updateSettings({
+ lineColor: '#ff0000',
+ text: 'Updated',
+ });
+
+ expect(drawing.getSettings()).toMatchObject({
+ lineColor: '#ff0000',
+ text: 'Updated',
+ fontSize: 14,
+ });
+ });
+});
+
+describe('AxisLinePaneRenderer', () => {
+ it('не должен рисовать без render data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ const renderer = new AxisLinePaneRenderer({
+ getRenderData: () => null,
+ } as unknown as AxisLine);
+
+ renderer.draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать vertical line', () => {
+ const { context, target } = createCanvasTarget({
+ bitmapWidth: 300,
+ bitmapHeight: 200,
+ });
+
+ const renderer = new AxisLinePaneRenderer({
+ getRenderData: () => ({
+ direction: 'vertical',
+ coordinate: 20,
+ lineColor: '#ff0000',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#ffffff',
+ }),
+ } as unknown as AxisLine);
+
+ renderer.draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(38, 0, 4, 200);
+ });
+
+ it('должен рисовать horizontal line', () => {
+ const { context, target } = createCanvasTarget({
+ bitmapWidth: 300,
+ bitmapHeight: 200,
+ });
+
+ const renderer = new AxisLinePaneRenderer({
+ getRenderData: () => ({
+ direction: 'horizontal',
+ coordinate: 30,
+ lineColor: '#ff0000',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#ffffff',
+ }),
+ } as unknown as AxisLine);
+
+ renderer.draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(0, 58, 300, 4);
+ });
+
+ it('должен рисовать vertical text с поворотом', () => {
+ const { context, target } = createCanvasTarget({
+ bitmapWidth: 300,
+ bitmapHeight: 200,
+ });
+
+ const renderer = new AxisLinePaneRenderer({
+ getRenderData: () => ({
+ direction: 'vertical',
+ coordinate: 20,
+ lineColor: '#ff0000',
+ text: 'Test',
+ fontSize: 14,
+ isBold: true,
+ isItalic: true,
+ textColor: '#ffffff',
+ }),
+ } as unknown as AxisLine);
+
+ renderer.draw(target);
+
+ expect(context.translate).toHaveBeenCalledWith(40, 100);
+ expect(context.rotate).toHaveBeenCalledWith(-Math.PI / 2);
+ expect(context.font).toContain('italic');
+ expect(context.font).toContain('700');
+ expect(context.fillText).toHaveBeenCalledWith('Test', 0, expect.any(Number));
+ });
+});
+
+describe('AxisLinePaneView', () => {
+ it('должен возвращать renderer и top zOrder', () => {
+ const view = new AxisLinePaneView({
+ getRenderData: jest.fn(),
+ } as unknown as AxisLine);
+
+ expect(view.renderer()).toBeInstanceOf(AxisLinePaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('AxisLine settings', () => {
+ it('должен создавать defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ lineColor: '#3366ff',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#3366ff',
+ });
+ });
+
+ it('должен создавать style и text tabs', () => {
+ expect(getAxisLineSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/diapson.test.ts b/src/core/Drawings/__tests__/drawings/diapson.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a6b3eb77528fa036d08f44ecdcd788d4a3174b94
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/diapson.test.ts
@@ -0,0 +1,585 @@
+import { Diapson } from '@core/Drawings/diapson/diapson';
+import { DiapsonPaneRenderer } from '@core/Drawings/diapson/paneRenderer';
+import { DiapsonPaneView } from '@core/Drawings/diapson/paneView';
+import { createDefaultSettings, getDiapsonSettingsTabs } from '@core/Drawings/diapson/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DiapsonRangeMode, DiapsonRenderData } from '@core/Drawings/diapson/diapson';
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartBackground: '#101010',
+ chartLineColor: '#2962ff',
+ diapsonStrokeFill: '#ff9800',
+ diapsonAreaFill: '#ff980033',
+ axisRangeTooltipText: '#ffffff',
+ axisRangeTooltipFill: '#333333',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestDiapson extends Diapson {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public geometry() {
+ return this.getGeometry();
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createDiapson(
+ rangeMode: DiapsonRangeMode = 'date',
+ options: {
+ selected?: boolean;
+ data?: readonly Record<string, unknown>[];
+ stepSize?: number;
+ stepLabel?: string;
+ } = {},
+) {
+ const env = createDrawingTestEnvironment({
+ selected: options.selected,
+ seriesData: options.data,
+ });
+
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const drawing = new TestDiapson({
+ ...env,
+ rangeMode,
+ removeSelf,
+ openSettings,
+ stepSize: options.stepSize,
+ stepLabel: options.stepLabel,
+ });
+
+ return {
+ ...env,
+ drawing,
+ removeSelf,
+ openSettings,
+ };
+}
+
+function setReady(
+ drawing: TestDiapson,
+ {
+ rangeMode = 'date',
+ startTime = 50,
+ endTime = 150,
+ startPrice = 50,
+ endPrice = 150,
+ }: {
+ rangeMode?: DiapsonRangeMode;
+ startTime?: number;
+ endTime?: number;
+ startPrice?: number;
+ endPrice?: number;
+ } = {},
+) {
+ drawing.setState({
+ mode: 'ready',
+ rangeMode,
+ startTime: startTime as Time,
+ endTime: endTime as Time,
+ startPrice,
+ endPrice,
+ });
+}
+
+function createRendererDiapson(data: DiapsonRenderData | null): Diapson {
+ return {
+ getRenderData: () => data,
+ } as unknown as Diapson;
+}
+
+function createRenderData(rangeMode: DiapsonRangeMode, overrides: Partial<DiapsonRenderData> = {}): DiapsonRenderData {
+ return {
+ left: 10,
+ right: 90,
+ top: 20,
+ bottom: 80,
+ width: 80,
+ height: 60,
+ startPoint: { x: 10, y: 20 },
+ endPoint: { x: 90, y: 80 },
+ rangeMode,
+ showFill: true,
+ labelLines: [],
+ borderColor: '#ff0000',
+ fillColor: '#ff000033',
+ labelTextColor: '#ffffff',
+ labelBackgroundColor: '#000000',
+ fontSize: 10,
+ isBold: false,
+ isItalic: false,
+ ...overrides,
+ };
+}
+
+describe('Diapson', () => {
+ it('должен прикреплять primitive', () => {
+ const { drawing, series } = createDiapson();
+
+ expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+ });
+
+ it('должен начинаться в idle', () => {
+ const { drawing } = createDiapson();
+
+ expect(drawing.isCreationPending()).toBe(true);
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'idle',
+ rangeMode: 'date',
+ startTime: null,
+ endTime: null,
+ startPrice: null,
+ endPrice: null,
+ });
+ });
+
+ it('должен создавать диапазон двумя pointerDown', () => {
+ const { drawing, container } = createDiapson();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 60));
+ drawing.pointerDown(createPointerEvent(container, 150, 160));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ startTime: 50,
+ endTime: 150,
+ startPrice: 60,
+ endPrice: 160,
+ });
+ });
+
+ it('должен удалять слишком маленький диапазон', () => {
+ const { drawing, container, removeSelf } = createDiapson();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 53, 53));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен менять rangeMode со сбросом drawing', () => {
+ const { drawing, interaction } = createDiapson();
+
+ setReady(drawing);
+
+ drawing.setRangeMode('price');
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'idle',
+ rangeMode: 'price',
+ startTime: null,
+ endTime: null,
+ startPrice: null,
+ endPrice: null,
+ });
+
+ expect(interaction.deselect).toHaveBeenCalled();
+ });
+
+ it('не должен сбрасываться при текущем rangeMode', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing);
+ drawing.setRangeMode('date');
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ startTime: 50,
+ endTime: 150,
+ });
+ });
+
+ it('должен возвращать нормализованную geometry', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing, {
+ startTime: 150,
+ endTime: 50,
+ startPrice: 160,
+ endPrice: 60,
+ });
+
+ expect(drawing.geometry()).toEqual({
+ left: 50,
+ right: 150,
+ top: 60,
+ bottom: 160,
+ width: 100,
+ height: 100,
+ startPoint: { x: 150, y: 160 },
+ endPoint: { x: 50, y: 60 },
+ });
+ });
+
+ it('должен возвращать end и start handles', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing);
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'end',
+ x: 150,
+ y: 150,
+ shape: 'circle',
+ size: 12,
+ borderWidth: 2,
+ },
+ {
+ id: 'start',
+ x: 50,
+ y: 50,
+ shape: 'circle',
+ size: 12,
+ borderWidth: 2,
+ },
+ ]);
+ });
+
+ it('должен возвращать render data', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing);
+
+ expect(drawing.getRenderData()).toMatchObject({
+ left: 50,
+ right: 150,
+ top: 50,
+ bottom: 150,
+ rangeMode: 'date',
+ borderColor: '#ff9800',
+ fillColor: '#ff980033',
+ });
+ });
+
+ it('должен возвращать axis segments selected drawing', () => {
+ const { drawing } = createDiapson('date', { selected: true });
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+
+ expect(drawing.priceSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+ });
+
+ it('не должен возвращать axis segments unselected drawing', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([]);
+ expect(drawing.priceSegments()).toEqual([]);
+ });
+
+ it('должен возвращать axis labels', () => {
+ const { drawing } = createDiapson('date', { selected: true });
+
+ setReady(drawing);
+
+ expect(drawing.timeLabel('left')).toMatchObject({
+ coordinate: 50,
+ text: 'date:50',
+ });
+
+ expect(drawing.timeLabel('right')).toMatchObject({
+ coordinate: 150,
+ text: 'date:150',
+ });
+
+ expect(drawing.priceLabel('top')).toMatchObject({
+ coordinate: 50,
+ text: 'price:50',
+ });
+
+ expect(drawing.priceLabel('bottom')).toMatchObject({
+ coordinate: 150,
+ text: 'price:150',
+ });
+ });
+
+ it('должен возвращать move для body', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing);
+
+ expect(drawing.hovered(100, 100)?.cursorStyle).toBe('move');
+ });
+
+ it('должен возвращать resize для selected handle', () => {
+ const { drawing } = createDiapson('date', { selected: true });
+
+ setReady(drawing);
+
+ expect(drawing.hovered(50, 50)?.cursorStyle).toBe('nwse-resize');
+ });
+
+ it('должен выбирать диапазон первым нажатием', () => {
+ const { drawing, container, interaction } = createDiapson();
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 100));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен перемещать весь диапазон', () => {
+ const { drawing, container } = createDiapson('date', { selected: true });
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 100, 1));
+ drawing.pointerMove(createPointerEvent(container, 120, 130, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ startTime: 70,
+ endTime: 170,
+ startPrice: 80,
+ endPrice: 180,
+ });
+
+ drawing.pointerUp(createPointerEvent(container, 120, 130, 1));
+
+ expect(drawing.getState().mode).toBe('ready');
+ });
+
+ it('должен resize start', () => {
+ const { drawing, container } = createDiapson('date', { selected: true });
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
+ drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
+ drawing.pointerUp(createPointerEvent(container, 70, 80, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ startTime: 70,
+ startPrice: 80,
+ endTime: 150,
+ endPrice: 150,
+ });
+ });
+
+ it('должен resize end', () => {
+ const { drawing, container } = createDiapson('date', { selected: true });
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
+ drawing.pointerMove(createPointerEvent(container, 180, 190, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ startTime: 50,
+ startPrice: 50,
+ endTime: 180,
+ endPrice: 190,
+ });
+ });
+
+ it('должен открывать settings', () => {
+ const { drawing, container, openSettings } = createDiapson();
+
+ setReady(drawing);
+
+ drawing.doubleClick(createMouseEvent(container, 100, 100));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен формировать price metrics', () => {
+ const { drawing } = createDiapson('price', {
+ stepSize: 5,
+ stepLabel: 'pts',
+ });
+
+ setReady(drawing, {
+ rangeMode: 'price',
+ startPrice: 100,
+ endPrice: 120,
+ });
+
+ expect(drawing.getRenderData()?.labelLines).toEqual(['+20 (20%) 4 pts']);
+ });
+
+ it('должен формировать date metrics', () => {
+ const { drawing } = createDiapson('date', {
+ data: [
+ { time: 1000, volume: 10 },
+ { time: 1060, volume: 20 },
+ { time: 1120, volume: 30 },
+ ],
+ });
+
+ setReady(drawing, {
+ startTime: 1000,
+ endTime: 1120,
+ });
+
+ expect(drawing.getRenderData()?.labelLines).toEqual(['2 bars, 2m', 'Vol volume:60']);
+ });
+
+ it('не должен возвращать render data hidden drawing', () => {
+ const { drawing } = createDiapson();
+
+ setReady(drawing);
+ drawing.hide();
+
+ expect(drawing.getRenderData()).toBeNull();
+ });
+});
+
+describe('DiapsonPaneRenderer', () => {
+ it('не должен рисовать без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new DiapsonPaneRenderer(createRendererDiapson(null)).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать date range', () => {
+ const { target, context } = createCanvasTarget();
+ const data = createRenderData('date');
+
+ new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledWith(20, 40, 160, 120);
+ expect(context.stroke).toHaveBeenCalled();
+ });
+
+ it('должен рисовать price range', () => {
+ const { target, context } = createCanvasTarget();
+ const data = createRenderData('price');
+
+ new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
+
+ expect(context.moveTo).toHaveBeenCalledWith(20, 40);
+ expect(context.lineTo).toHaveBeenCalledWith(180, 40);
+ });
+
+ it('должен рисовать labels', () => {
+ const { target, context } = createCanvasTarget();
+
+ const data = createRenderData('date', {
+ labelLines: ['First', 'Second'],
+ isBold: true,
+ isItalic: true,
+ });
+
+ new DiapsonPaneRenderer(createRendererDiapson(data)).draw(target);
+
+ expect(context.fillText).toHaveBeenCalledTimes(2);
+ expect(context.font).toContain('italic');
+ expect(context.font).toContain('700');
+ });
+});
+
+describe('DiapsonPaneView', () => {
+ it('должен возвращать renderer и top', () => {
+ const view = new DiapsonPaneView(createRendererDiapson(null));
+
+ expect(view.renderer()).toBeInstanceOf(DiapsonPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('Diapson settings', () => {
+ it('должен создавать defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ borderColor: '#ff9800',
+ fillColor: '#ff980033',
+ labelTextColor: '#ffffff',
+ labelBackgroundColor: '#333333',
+ fontSize: 10,
+ isBold: false,
+ isItalic: false,
+ });
+ });
+
+ it('должен создавать style и text tabs', () => {
+ expect(getDiapsonSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts b/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..18ffb86f18c53f8d32edd8f004c5d10cc7526fef
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/fibonacciRetracement.test.ts
@@ -0,0 +1,466 @@
+import { FibonacciRetracement } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
+import { FibonacciRetracementPaneRenderer } from '@core/Drawings/fibonacciRetracement/paneRenderer';
+import { FibonacciRetracementPaneView } from '@core/Drawings/fibonacciRetracement/paneView';
+import {
+ cloneFibonacciRetracementSettings,
+ createDefaultSettings,
+ formatLevelLabel,
+ getFibonacciRetracementSettingsTabs,
+ getFibonacciRetracementSettingsValues,
+ getVisibleFibonacciLevels,
+ mergeFibonacciRetracementSettings,
+} from '@core/Drawings/fibonacciRetracement/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { FibonacciRetracementRenderData } from '@core/Drawings/fibonacciRetracement/fibonacciRetracement';
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartBackground: '#101010',
+ chartLineColor: '#2962ff',
+ chartCrosshairLine: '#999999',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestFibonacciRetracement extends FibonacciRetracement {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createDrawing(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const drawing = new TestFibonacciRetracement({
+ ...env,
+ removeSelf,
+ openSettings,
+ });
+
+ return {
+ ...env,
+ drawing,
+ removeSelf,
+ openSettings,
+ };
+}
+
+function setReady(drawing: TestFibonacciRetracement): void {
+ drawing.setState({
+ mode: 'ready',
+ startTime: 50 as Time,
+ endTime: 150 as Time,
+ startPrice: 50,
+ endPrice: 150,
+ });
+}
+
+function createRendererDrawing(data: FibonacciRetracementRenderData | null): FibonacciRetracement {
+ return {
+ getRenderData: () => data,
+ } as unknown as FibonacciRetracement;
+}
+
+describe('FibonacciRetracement', () => {
+ it('должен прикреплять primitive', () => {
+ const { drawing, series } = createDrawing();
+
+ expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+ });
+
+ it('должен начинаться в idle', () => {
+ const { drawing } = createDrawing();
+
+ expect(drawing.isCreationPending()).toBe(true);
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'idle',
+ startTime: null,
+ endTime: null,
+ startPrice: null,
+ endPrice: null,
+ });
+ });
+
+ it('должен создаваться двумя pointerDown', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 150, 150));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ startTime: 50,
+ endTime: 150,
+ startPrice: 50,
+ endPrice: 150,
+ });
+ });
+
+ it('должен удалять слишком маленький retracement', () => {
+ const { drawing, container, removeSelf } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 53, 53));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен возвращать end и start handles', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'end',
+ x: 150,
+ y: 150,
+ },
+ {
+ id: 'start',
+ x: 50,
+ y: 50,
+ },
+ ]);
+ });
+
+ it('должен рассчитывать levels', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ const data = drawing.getRenderData();
+
+ expect(data?.levels).toHaveLength(11);
+ expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(150);
+ expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(50);
+ expect(data?.backgroundOpacity).toBe(0.18);
+ });
+
+ it('должен учитывать reverse', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+ drawing.updateSettings({ reverse: true });
+
+ const data = drawing.getRenderData();
+
+ expect(data?.levels.find(({ id }) => id === '0')?.price).toBe(50);
+ expect(data?.levels.find(({ id }) => id === '1')?.price).toBe(150);
+ });
+
+ it('должен возвращать selected axis segments', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+
+ expect(drawing.priceSegments()).toHaveLength(1);
+ });
+
+ it('не должен возвращать axis segments unselected drawing', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([]);
+ expect(drawing.priceSegments()).toEqual([]);
+ });
+
+ it('должен возвращать axis labels', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeLabel('start')).toMatchObject({
+ coordinate: 50,
+ text: 'date:50',
+ });
+
+ expect(drawing.timeLabel('end')).toMatchObject({
+ coordinate: 150,
+ text: 'date:150',
+ });
+ });
+
+ it('должен возвращать pointer для selected handle', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.hovered(50, 50)).toEqual({
+ cursorStyle: 'pointer',
+ externalId: 'fibonacci-retracement-position',
+ zOrder: 'top',
+ });
+ });
+
+ it('должен выбирать unselected retracement', () => {
+ const { drawing, interaction, container } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 100));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен перемещать start handle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50, 1));
+ drawing.pointerMove(createPointerEvent(container, 70, 80, 1));
+ drawing.pointerUp(createPointerEvent(container, 70, 80, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ startTime: 70,
+ startPrice: 80,
+ endTime: 150,
+ endPrice: 150,
+ });
+ });
+
+ it('должен перемещать end handle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 150, 150, 1));
+ drawing.pointerMove(createPointerEvent(container, 180, 170, 1));
+
+ expect(drawing.getState()).toMatchObject({
+ endTime: 180,
+ endPrice: 170,
+ });
+ });
+
+ it('должен открывать settings', () => {
+ const { drawing, container, openSettings } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.doubleClick(createMouseEvent(container, 100, 100));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен возвращать render data hidden drawing', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+ drawing.hide();
+
+ expect(drawing.getRenderData()).toBeNull();
+ });
+});
+
+describe('FibonacciRetracementPaneRenderer', () => {
+ it('не должен рисовать без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new FibonacciRetracementPaneRenderer(createRendererDrawing(null)).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать levels и background', () => {
+ const data: FibonacciRetracementRenderData = {
+ startPoint: { x: 10, y: 20 },
+ endPoint: { x: 90, y: 80 },
+ left: 10,
+ right: 90,
+ top: 20,
+ bottom: 80,
+ width: 80,
+ height: 60,
+ levels: [
+ {
+ id: '0',
+ value: 0,
+ price: 100,
+ y: 20,
+ color: '#ff0000',
+ text: '0 (100)',
+ },
+ {
+ id: '1',
+ value: 1,
+ price: 50,
+ y: 80,
+ color: '#00ff00',
+ text: '1 (50)',
+ },
+ ],
+ areas: [
+ {
+ top: 20,
+ bottom: 80,
+ color: '#00ff00',
+ },
+ ],
+ showBackground: true,
+ backgroundOpacity: 0.18,
+ reverse: false,
+ labelsPosition: 'right',
+ showPrices: true,
+ showLevelValues: true,
+ fontSize: 12,
+ isBold: false,
+ isItalic: false,
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new FibonacciRetracementPaneRenderer(createRendererDrawing(data)).draw(target);
+
+ expect(context.fillRect).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('FibonacciRetracementPaneView', () => {
+ it('должен возвращать renderer и top', () => {
+ const view = new FibonacciRetracementPaneView(createRendererDrawing(null));
+
+ expect(view.renderer()).toBeInstanceOf(FibonacciRetracementPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('FibonacciRetracement settings', () => {
+ it('должен создавать независимые defaults', () => {
+ const first = createDefaultSettings();
+ const second = createDefaultSettings();
+
+ first.levels[0].visible = false;
+
+ expect(second.levels[0].visible).toBe(true);
+ });
+
+ it('должен deep clone settings', () => {
+ const settings = createDefaultSettings();
+ const clone = cloneFibonacciRetracementSettings(settings);
+
+ clone.levels[0].color = '#000000';
+
+ expect(settings.levels[0].color).not.toBe('#000000');
+ });
+
+ it('должен merge level settings', () => {
+ const result = mergeFibonacciRetracementSettings(createDefaultSettings(), {
+ reverse: true,
+ level_0_236_visible: false,
+ level_0_236_color: '#123456',
+ });
+
+ const level = result.levels.find(({ id }) => id === '0.236');
+
+ expect(result.reverse).toBe(true);
+ expect(level?.visible).toBe(false);
+ expect(level?.color).toBe('#123456');
+ });
+
+ it('должен возвращать только visible levels', () => {
+ const settings = createDefaultSettings();
+
+ settings.levels[0].visible = false;
+
+ expect(getVisibleFibonacciLevels(settings)).toHaveLength(settings.levels.length - 1);
+ });
+
+ it('должен форматировать level label', () => {
+ expect(formatLevelLabel(0.618)).toBe('0,618');
+ expect(formatLevelLabel(1)).toBe('1');
+ });
+
+ it('должен создавать style, text и levels tabs', () => {
+ expect(getFibonacciRetracementSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual([
+ 'style',
+ 'text',
+ 'levels',
+ ]);
+ });
+
+ it('должен преобразовывать settings values', () => {
+ expect(getFibonacciRetracementSettingsValues(createDefaultSettings())).toMatchObject({
+ showBackground: true,
+ backgroundOpacity: 18,
+ reverse: false,
+ });
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/line.test.ts b/src/core/Drawings/__tests__/drawings/line.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2e6250a8dd357fa84022960609e1224b618a0464
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/line.test.ts
@@ -0,0 +1,398 @@
+import { LineDrawing } from '@core/Drawings/line/lineDrawing';
+import { LineDrawingPaneRenderer } from '@core/Drawings/line/paneRenderer';
+import { LineDrawingPaneView } from '@core/Drawings/line/paneView';
+import { createDefaultSettings, getLineDrawingSettingsTabs } from '@core/Drawings/line/settings';
+
+import { LineMarker } from '@src/types';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { LineDrawingRenderData } from '@core/Drawings/line/lineDrawing';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartLineColor: '#2962ff',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestLineDrawing extends LineDrawing {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createDrawing(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const drawing = new TestLineDrawing({
+ ...env,
+ removeSelf,
+ openSettings,
+ });
+
+ return {
+ ...env,
+ drawing,
+ removeSelf,
+ openSettings,
+ };
+}
+
+function setReady(drawing: TestLineDrawing): void {
+ drawing.setState({
+ mode: 'ready',
+ startAnchor: {
+ time: 50 as Time,
+ price: 50,
+ },
+ endAnchor: {
+ time: 150 as Time,
+ price: 150,
+ },
+ });
+}
+
+function createRendererDrawing(data: LineDrawingRenderData | null): LineDrawing {
+ return {
+ getRenderData: () => data,
+ } as unknown as LineDrawing;
+}
+
+describe('LineDrawing', () => {
+ it('должен прикреплять primitive', () => {
+ const { drawing, series } = createDrawing();
+
+ expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+ });
+
+ it('должен создаваться двумя pointerDown', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 150, 150));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ startAnchor: { time: 50, price: 50 },
+ endAnchor: { time: 150, price: 150 },
+ });
+ });
+
+ it('должен удалять слишком короткую line', () => {
+ const { drawing, container, removeSelf } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 52, 52));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен возвращать два handles', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'start',
+ x: 50,
+ y: 50,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'end',
+ x: 150,
+ y: 150,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ ]);
+ });
+
+ it('должен возвращать render data', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.getRenderData()).toMatchObject({
+ startPoint: { x: 50, y: 50 },
+ endPoint: { x: 150, y: 150 },
+ lineColor: '#2962ff',
+ fontSize: 14,
+ });
+ });
+
+ it('должен возвращать selected axis segments', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+
+ expect(drawing.priceSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+ });
+
+ it('должен возвращать axis labels', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeLabel('start')).toMatchObject({
+ coordinate: 50,
+ text: 'date:50',
+ });
+
+ expect(drawing.priceLabel('end')).toMatchObject({
+ coordinate: 150,
+ text: 'price:150',
+ });
+ });
+
+ it('должен определять handles и body', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
+ expect(drawing.hovered(100, 100)?.cursorStyle).toBe('grab');
+ expect(drawing.hovered(300, 100)).toBeNull();
+ });
+
+ it('должен выбирать unselected line', () => {
+ const { drawing, container, interaction } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 100));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен перемещать start handle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerMove(createPointerEvent(container, 70, 80));
+ drawing.pointerUp(createPointerEvent(container, 70, 80));
+
+ expect(drawing.getState()).toMatchObject({
+ startAnchor: {
+ time: 70,
+ price: 80,
+ },
+ endAnchor: {
+ time: 150,
+ price: 150,
+ },
+ });
+ });
+
+ it('должен перемещать end handle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 150, 150));
+ drawing.pointerMove(createPointerEvent(container, 170, 180));
+
+ expect(drawing.getState()).toMatchObject({
+ endAnchor: {
+ time: 170,
+ price: 180,
+ },
+ });
+ });
+
+ it('должен перемещать всю line', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 100));
+ drawing.pointerMove(createPointerEvent(container, 120, 130));
+
+ expect(drawing.getState()).toMatchObject({
+ startAnchor: {
+ time: 70,
+ price: 80,
+ },
+ endAnchor: {
+ time: 170,
+ price: 180,
+ },
+ });
+ });
+
+ it('должен открывать settings', () => {
+ const { drawing, container, openSettings } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.doubleClick(createMouseEvent(container, 100, 100));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен возвращать render data hidden drawing', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+ drawing.hide();
+
+ expect(drawing.getRenderData()).toBeNull();
+ });
+});
+
+describe('LineDrawingPaneRenderer', () => {
+ it('не должен рисовать без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new LineDrawingPaneRenderer(createRendererDrawing(null)).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать line и text', () => {
+ const data: LineDrawingRenderData = {
+ startPoint: { x: 10, y: 20 },
+ endPoint: { x: 90, y: 80 },
+ left: 10,
+ right: 90,
+ top: 20,
+ bottom: 80,
+ lineColor: '#ff0000',
+ startMarker: LineMarker.arrow,
+ endMarker: LineMarker.arrow,
+ fontSize: 14,
+ text: 'Test',
+ isBold: true,
+ isItalic: true,
+ textColor: '#ffffff',
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new LineDrawingPaneRenderer(createRendererDrawing(data)).draw(target);
+
+ expect(context.stroke).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalled();
+ expect(context.translate).toHaveBeenCalled();
+ });
+});
+
+describe('LineDrawingPaneView', () => {
+ it('должен возвращать renderer и top', () => {
+ const view = new LineDrawingPaneView(createRendererDrawing(null));
+
+ expect(view.renderer()).toBeInstanceOf(LineDrawingPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('LineDrawing settings', () => {
+ it('должен создавать defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ startMarker: LineMarker.normal,
+ endMarker: LineMarker.normal,
+ lineColor: '#2962ff',
+ fontSize: 14,
+ text: '',
+ isBold: false,
+ isItalic: false,
+ textColor: '#2962ff',
+ });
+ });
+
+ it('должен применять default marker override', () => {
+ expect(
+ createDefaultSettings({
+ startMarker: LineMarker.arrow,
+ }),
+ ).toMatchObject({
+ startMarker: LineMarker.arrow,
+ endMarker: LineMarker.normal,
+ });
+ });
+
+ it('должен создавать style и text tabs', () => {
+ expect(getLineDrawingSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts b/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..22b61d43f1339553773a06bcb0e9b7e79648b4bd
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/parallelChannel.test.ts
@@ -0,0 +1,424 @@
+import { ParallelChannelPaneRenderer } from '@core/Drawings/parallelChannel/paneRenderer';
+import { ParallelChannelPaneView } from '@core/Drawings/parallelChannel/paneView';
+import { ParallelChannel } from '@core/Drawings/parallelChannel/parallelChannel';
+import { createDefaultSettings, getParallelChannelSettingsTabs } from '@core/Drawings/parallelChannel/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { ParallelChannelRenderData } from '@core/Drawings/parallelChannel/parallelChannel';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartLineColor: '#2962ff',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestParallelChannel extends ParallelChannel {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createDrawing(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const openSettings = jest.fn();
+
+ const drawing = new TestParallelChannel({
+ ...env,
+ openSettings,
+ });
+
+ return {
+ ...env,
+ drawing,
+ openSettings,
+ };
+}
+
+function setReady(drawing: TestParallelChannel): void {
+ drawing.setState({
+ mode: 'ready',
+ startAnchor: {
+ time: 50 as Time,
+ price: 50,
+ },
+ endAnchor: {
+ time: 150 as Time,
+ price: 80,
+ },
+ priceOffset: 60,
+ });
+}
+
+function createRendererDrawing(data: ParallelChannelRenderData | null): ParallelChannel {
+ return {
+ getRenderData: () => data,
+ } as unknown as ParallelChannel;
+}
+
+describe('ParallelChannel', () => {
+ it('должен создаваться за три этапа', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+
+ expect(drawing.getState().mode).toBe('drawing-line');
+
+ drawing.pointerDown(createPointerEvent(container, 150, 80));
+
+ expect(drawing.getState().mode).toBe('drawing-channel');
+
+ drawing.pointerDown(createPointerEvent(container, 100, 130));
+
+ expect(drawing.getState().mode).toBe('ready');
+ });
+
+ it('должен восстанавливать dragging как ready', () => {
+ const { drawing } = createDrawing();
+
+ drawing.setState({
+ mode: 'dragging',
+ });
+
+ expect(drawing.getState().mode).toBe('ready');
+ });
+
+ it('должен возвращать шесть handles', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'parallel-end',
+ x: 150,
+ y: 140,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'parallel-middle',
+ x: 100,
+ y: 125,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'parallel-start',
+ x: 50,
+ y: 110,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'main-end',
+ x: 150,
+ y: 80,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'main-middle',
+ x: 100,
+ y: 65,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'main-start',
+ x: 50,
+ y: 50,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ ]);
+ });
+
+ it('должен возвращать selected axis segments', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+
+ expect(drawing.priceSegments()).toEqual([
+ {
+ from: 50,
+ to: 140,
+ color: '#2962ff44',
+ },
+ ]);
+ });
+
+ it('должен возвращать time labels', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeLabel('start')).toMatchObject({
+ coordinate: 50,
+ text: 'date:50',
+ });
+
+ expect(drawing.timeLabel('end')).toMatchObject({
+ coordinate: 150,
+ text: 'date:150',
+ });
+ });
+
+ it('должен возвращать price labels', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.priceLabel('main-start')?.text).toBe('price:50');
+ expect(drawing.priceLabel('main-end')?.text).toBe('price:80');
+ expect(drawing.priceLabel('parallel-start')?.text).toBe('price:110');
+ expect(drawing.priceLabel('parallel-end')?.text).toBe('price:140');
+ });
+
+ it('должен возвращать pointer unselected channel', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.hovered(100, 90)?.cursorStyle).toBe('pointer');
+ });
+
+ it('должен возвращать move selected handle', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
+ });
+
+ it('должен возвращать grab внутри selected channel', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.hovered(100, 90)?.cursorStyle).toBe('grab');
+ });
+
+ it('должен выбирать unselected channel', () => {
+ const { drawing, container, interaction } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 90));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен перемещать main-start', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerMove(createPointerEvent(container, 60, 60));
+
+ expect(drawing.getState()).toMatchObject({
+ startAnchor: {
+ time: 60,
+ price: 60,
+ },
+ endAnchor: {
+ time: 150,
+ price: 80,
+ },
+ priceOffset: 60,
+ });
+ });
+
+ it('должен перемещать parallel-middle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 125));
+ drawing.pointerMove(createPointerEvent(container, 100, 145));
+
+ expect(drawing.getState().priceOffset).toBe(80);
+ });
+
+ it('должен перемещать channel целиком', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 100, 90));
+ drawing.pointerMove(createPointerEvent(container, 120, 100));
+
+ expect(drawing.getState()).toMatchObject({
+ startAnchor: {
+ time: 70,
+ price: 60,
+ },
+ endAnchor: {
+ time: 170,
+ price: 90,
+ },
+ priceOffset: 60,
+ });
+ });
+
+ it('должен открывать settings', () => {
+ const { drawing, container, openSettings } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.doubleClick(createMouseEvent(container, 100, 90));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен возвращать data hidden channel', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+ drawing.hide();
+
+ expect(drawing.getRenderData()).toBeNull();
+ });
+});
+
+describe('ParallelChannelPaneRenderer', () => {
+ it('не должен рисовать без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new ParallelChannelPaneRenderer(createRendererDrawing(null)).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать channel и text', () => {
+ const data: ParallelChannelRenderData = {
+ startPoint: { x: 10, y: 20 },
+ mainMiddlePoint: { x: 50, y: 30 },
+ endPoint: { x: 90, y: 40 },
+ parallelStartPoint: { x: 10, y: 80 },
+ parallelMiddlePoint: { x: 50, y: 90 },
+ parallelEndPoint: { x: 90, y: 100 },
+ middleStartPoint: { x: 10, y: 50 },
+ middleEndPoint: { x: 90, y: 70 },
+ left: 10,
+ right: 90,
+ top: 20,
+ bottom: 100,
+ lineColor: '#ff0000',
+ backgroundColor: '#00ff0033',
+ showMiddleLine: true,
+ fontSize: 14,
+ text: 'Channel',
+ isBold: true,
+ isItalic: false,
+ textColor: '#ffffff',
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new ParallelChannelPaneRenderer(createRendererDrawing(data)).draw(target);
+
+ expect(context.fill).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ expect(context.setLineDash).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalled();
+ });
+});
+
+describe('ParallelChannelPaneView', () => {
+ it('должен возвращать renderer и top', () => {
+ const view = new ParallelChannelPaneView(createRendererDrawing(null));
+
+ expect(view.renderer()).toBeInstanceOf(ParallelChannelPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('ParallelChannel settings', () => {
+ it('должен создавать defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ lineColor: '#2962ff',
+ backgroundColor: '#2962ff44',
+ showMiddleLine: true,
+ fontSize: 14,
+ text: '',
+ isBold: false,
+ isItalic: false,
+ textColor: '#2962ff',
+ });
+ });
+
+ it('должен создавать style и text tabs', () => {
+ expect(getParallelChannelSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/ray.test.ts b/src/core/Drawings/__tests__/drawings/ray.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4c482935eda240497c9119e916d9a97605c6d0a2
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/ray.test.ts
@@ -0,0 +1,400 @@
+import { RayPaneRenderer } from '@core/Drawings/ray/paneRenderer';
+import { RayPaneView } from '@core/Drawings/ray/paneView';
+import { Ray } from '@core/Drawings/ray/ray';
+import { createDefaultSettings, getRaySettingTabs } from '@core/Drawings/ray/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { RayRenderData } from '@core/Drawings/ray/ray';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartLineColor: '#2962ff',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestRay extends Ray {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createDrawing(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const drawing = new TestRay({
+ ...env,
+ removeSelf,
+ openSettings,
+ });
+
+ return {
+ ...env,
+ drawing,
+ removeSelf,
+ openSettings,
+ };
+}
+
+function setReady(drawing: TestRay): void {
+ drawing.setState({
+ mode: 'ready',
+ startAnchor: {
+ time: 50 as Time,
+ price: 50,
+ },
+ directionAnchor: {
+ time: 150 as Time,
+ price: 100,
+ },
+ });
+}
+
+function createRendererDrawing(data: RayRenderData | null): Ray {
+ return {
+ getRenderData: () => data,
+ } as unknown as Ray;
+}
+
+describe('Ray', () => {
+ it('должен прикреплять primitive', () => {
+ const { drawing, series } = createDrawing();
+
+ expect(series.attachPrimitive).toHaveBeenCalledWith(drawing);
+ });
+
+ it('должен создаваться двумя pointerDown', () => {
+ const { drawing, container } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 150, 100));
+
+ expect(drawing.getState()).toMatchObject({
+ mode: 'ready',
+ startAnchor: {
+ time: 50,
+ price: 50,
+ },
+ directionAnchor: {
+ time: 150,
+ price: 100,
+ },
+ });
+ });
+
+ it('должен удалять слишком короткий ray', () => {
+ const { drawing, container, removeSelf } = createDrawing();
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerDown(createPointerEvent(container, 52, 52));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен продолжаться до границы container', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.getRenderData()).toMatchObject({
+ startPoint: {
+ x: 50,
+ y: 50,
+ },
+ directionPoint: {
+ x: 150,
+ y: 100,
+ },
+ rayEndPoint: {
+ x: 400,
+ y: 225,
+ },
+ });
+ });
+
+ it('должен возвращать start и direction handles', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+
+ expect(drawing.handles()).toEqual([
+ {
+ id: 'start',
+ x: 50,
+ y: 50,
+ shape: 'circle',
+ borderWidth: 2,
+ strokeColor: '#2962ff',
+ },
+ {
+ id: 'direction',
+ x: 150,
+ y: 100,
+ shape: 'circle',
+ borderWidth: 2,
+ strokeColor: '#2962ff',
+ },
+ ]);
+ });
+
+ it('должен возвращать selected axis segments', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeSegments()).toEqual([
+ {
+ from: 50,
+ to: 150,
+ color: '#2962ff44',
+ },
+ ]);
+
+ expect(drawing.priceSegments()).toEqual([
+ {
+ from: 50,
+ to: 100,
+ color: '#2962ff44',
+ },
+ ]);
+ });
+
+ it('должен возвращать axis labels', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.timeLabel('direction')).toMatchObject({
+ coordinate: 150,
+ text: 'date:150',
+ });
+
+ expect(drawing.priceLabel('start')).toMatchObject({
+ coordinate: 50,
+ text: 'price:50',
+ });
+ });
+
+ it('должен определять handles и body', () => {
+ const { drawing } = createDrawing(true);
+
+ setReady(drawing);
+
+ expect(drawing.hovered(50, 50)?.cursorStyle).toBe('move');
+ expect(drawing.hovered(200, 125)?.cursorStyle).toBe('grab');
+ expect(drawing.hovered(200, 200)).toBeNull();
+ });
+
+ it('должен выбирать unselected ray', () => {
+ const { drawing, interaction, container } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 200, 125));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен перемещать start handle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 50, 50));
+ drawing.pointerMove(createPointerEvent(container, 70, 80));
+ drawing.pointerUp(createPointerEvent(container, 70, 80));
+
+ expect(drawing.getState()).toMatchObject({
+ startAnchor: {
+ time: 70,
+ price: 80,
+ },
+ directionAnchor: {
+ time: 150,
+ price: 100,
+ },
+ });
+ });
+
+ it('должен перемещать direction handle', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 150, 100));
+ drawing.pointerMove(createPointerEvent(container, 180, 130));
+
+ expect(drawing.getState()).toMatchObject({
+ directionAnchor: {
+ time: 180,
+ price: 130,
+ },
+ });
+ });
+
+ it('должен перемещать ray целиком', () => {
+ const { drawing, container } = createDrawing(true);
+
+ setReady(drawing);
+
+ drawing.pointerDown(createPointerEvent(container, 200, 125));
+ drawing.pointerMove(createPointerEvent(container, 220, 155));
+
+ expect(drawing.getState()).toMatchObject({
+ startAnchor: {
+ time: 70,
+ price: 80,
+ },
+ directionAnchor: {
+ time: 170,
+ price: 130,
+ },
+ });
+ });
+
+ it('должен открывать settings', () => {
+ const { drawing, container, openSettings } = createDrawing();
+
+ setReady(drawing);
+
+ drawing.doubleClick(createMouseEvent(container, 200, 125));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не должен возвращать data hidden ray', () => {
+ const { drawing } = createDrawing();
+
+ setReady(drawing);
+ drawing.hide();
+
+ expect(drawing.getRenderData()).toBeNull();
+ });
+});
+
+describe('RayPaneRenderer', () => {
+ it('не должен рисовать без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new RayPaneRenderer(createRendererDrawing(null)).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать ray и text', () => {
+ const data: RayRenderData = {
+ startPoint: { x: 10, y: 20 },
+ directionPoint: { x: 50, y: 40 },
+ rayEndPoint: { x: 100, y: 65 },
+ left: 10,
+ right: 100,
+ top: 20,
+ bottom: 65,
+ lineColor: '#ff0000',
+ text: 'Ray',
+ fontSize: 14,
+ isBold: true,
+ isItalic: true,
+ textColor: '#ffffff',
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new RayPaneRenderer(createRendererDrawing(data)).draw(target);
+
+ expect(context.moveTo).toHaveBeenCalledWith(20, 40);
+ expect(context.lineTo).toHaveBeenCalledWith(200, 130);
+ expect(context.fillText).toHaveBeenCalled();
+ expect(context.translate).toHaveBeenCalled();
+ expect(context.rotate).toHaveBeenCalled();
+ });
+});
+
+describe('RayPaneView', () => {
+ it('должен возвращать renderer и top', () => {
+ const view = new RayPaneView(createRendererDrawing(null));
+
+ expect(view.renderer()).toBeInstanceOf(RayPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('Ray settings', () => {
+ it('должен создавать defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ lineColor: '#2962ff',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#2962ff',
+ });
+ });
+
+ it('должен создавать style и text tabs', () => {
+ expect(getRaySettingTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/rectangle.test.ts b/src/core/Drawings/__tests__/drawings/rectangle.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..20cc89b7046282027b524310c7d6e1fd289895d1
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/rectangle.test.ts
@@ -0,0 +1,412 @@
+import { RectanglePaneRenderer } from '@core/Drawings/rectangle/paneRenderer';
+import { RectanglePaneView } from '@core/Drawings/rectangle/paneView';
+import { Rectangle, type RectangleRenderData } from '@core/Drawings/rectangle/rectangle';
+import { createDefaultSettings, getRectangleSettingsTabs } from '@core/Drawings/rectangle/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ rectangleBorderFill: '#2962ff',
+ rectangleAreaFill: '#2962ff22',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestRectangle extends Rectangle {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+
+ public doubleClick(event: MouseEvent): void {
+ this.handleDoubleClick(event);
+ }
+}
+
+function createRectangle(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const rectangle = new TestRectangle({
+ ...env,
+ removeSelf,
+ openSettings,
+ });
+
+ return { ...env, rectangle, removeSelf, openSettings };
+}
+
+function setReady(rectangle: TestRectangle): void {
+ rectangle.setState({
+ mode: 'ready',
+ startTime: 50 as Time,
+ endTime: 150 as Time,
+ startPrice: 50,
+ endPrice: 150,
+ });
+}
+
+describe('Rectangle', () => {
+ it('прикрепляет primitive', () => {
+ const { rectangle, series } = createRectangle();
+
+ expect(series.attachPrimitive).toHaveBeenCalledWith(rectangle);
+ });
+
+ it('создаётся двумя нажатиями', () => {
+ const { rectangle, container } = createRectangle();
+
+ rectangle.pointerDown(createPointerEvent(container, 50, 50));
+ rectangle.pointerDown(createPointerEvent(container, 150, 150));
+
+ expect(rectangle.getState()).toMatchObject({
+ mode: 'ready',
+ startTime: 50,
+ endTime: 150,
+ startPrice: 50,
+ endPrice: 150,
+ });
+ });
+
+ it('удаляет слишком маленький прямоугольник', () => {
+ const { rectangle, container, removeSelf } = createRectangle();
+
+ rectangle.pointerDown(createPointerEvent(container, 50, 50));
+ rectangle.pointerDown(createPointerEvent(container, 54, 54));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('возвращает геометрию', () => {
+ const { rectangle } = createRectangle();
+
+ setReady(rectangle);
+
+ expect(rectangle.getRenderData()).toMatchObject({
+ left: 50,
+ right: 150,
+ top: 50,
+ bottom: 150,
+ width: 100,
+ height: 100,
+ });
+ });
+
+ it('возвращает восемь handles правильного типа', () => {
+ const { rectangle } = createRectangle();
+
+ setReady(rectangle);
+
+ expect(rectangle.handles()).toEqual([
+ { id: 'w', x: 50, y: 100, shape: 'rounded' },
+ { id: 'sw', x: 50, y: 150, shape: 'circle' },
+ { id: 's', x: 100, y: 150, shape: 'rounded' },
+ { id: 'se', x: 150, y: 150, shape: 'circle' },
+ { id: 'e', x: 150, y: 100, shape: 'rounded' },
+ { id: 'ne', x: 150, y: 50, shape: 'circle' },
+ { id: 'n', x: 100, y: 50, shape: 'rounded' },
+ { id: 'nw', x: 50, y: 50, shape: 'circle' },
+ ]);
+ });
+
+ it.each([
+ ['nw', 50, 50, 'nwse-resize'],
+ ['se', 150, 150, 'nwse-resize'],
+ ['ne', 150, 50, 'nesw-resize'],
+ ['sw', 50, 150, 'nesw-resize'],
+ ['n', 100, 50, 'ns-resize'],
+ ['s', 100, 150, 'ns-resize'],
+ ['e', 150, 100, 'ew-resize'],
+ ['w', 50, 100, 'ew-resize'],
+ ])('возвращает корректный cursor для %s', (_, x, y, cursor) => {
+ const { rectangle } = createRectangle(true);
+
+ setReady(rectangle);
+
+ expect(rectangle.hovered(x as number, y as number)?.cursorStyle).toBe(cursor);
+ });
+
+ it('возвращает pointer для невыбранного rectangle', () => {
+ const { rectangle } = createRectangle();
+
+ setReady(rectangle);
+
+ expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('pointer');
+ });
+
+ it('возвращает grab внутри выбранного rectangle', () => {
+ const { rectangle } = createRectangle(true);
+
+ setReady(rectangle);
+
+ expect(rectangle.hovered(100, 100)?.cursorStyle).toBe('grab');
+ });
+
+ it('возвращает axis segments для selected rectangle', () => {
+ const { rectangle } = createRectangle(true);
+
+ setReady(rectangle);
+
+ expect(rectangle.timeSegments()).toEqual([{ from: 50, to: 150, color: '#2962ff44' }]);
+ expect(rectangle.priceSegments()).toEqual([{ from: 50, to: 150, color: '#2962ff44' }]);
+ });
+
+ it('не возвращает axis segments для ready unselected rectangle', () => {
+ const { rectangle } = createRectangle();
+
+ setReady(rectangle);
+
+ expect(rectangle.timeSegments()).toEqual([]);
+ expect(rectangle.priceSegments()).toEqual([]);
+ });
+
+ it('возвращает axis labels', () => {
+ const { rectangle } = createRectangle(true);
+
+ setReady(rectangle);
+
+ expect(rectangle.timeLabel('left')).toMatchObject({
+ coordinate: 50,
+ text: 'date:50',
+ });
+
+ expect(rectangle.timeLabel('right')).toMatchObject({
+ coordinate: 150,
+ text: 'date:150',
+ });
+
+ expect(rectangle.priceLabel('top')).toMatchObject({
+ coordinate: 50,
+ text: 'price:50',
+ });
+
+ expect(rectangle.priceLabel('bottom')).toMatchObject({
+ coordinate: 150,
+ text: 'price:150',
+ });
+ });
+
+ it('выбирает rectangle первым нажатием', () => {
+ const { rectangle, interaction, container } = createRectangle();
+
+ setReady(rectangle);
+ rectangle.pointerDown(createPointerEvent(container, 100, 100));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('изменяет левую границу через w handle', () => {
+ const { rectangle, container } = createRectangle(true);
+
+ setReady(rectangle);
+
+ rectangle.pointerDown(createPointerEvent(container, 50, 100));
+ rectangle.pointerMove(createPointerEvent(container, 30, 100));
+ rectangle.pointerUp(createPointerEvent(container, 30, 100));
+
+ expect(rectangle.getState()).toMatchObject({
+ mode: 'ready',
+ startTime: 30,
+ endTime: 150,
+ });
+ });
+
+ it('изменяет верхний левый угол', () => {
+ const { rectangle, container } = createRectangle(true);
+
+ setReady(rectangle);
+
+ rectangle.pointerDown(createPointerEvent(container, 50, 50));
+ rectangle.pointerMove(createPointerEvent(container, 30, 20));
+
+ expect(rectangle.getRenderData()).toMatchObject({
+ left: 30,
+ top: 20,
+ right: 150,
+ bottom: 150,
+ });
+ });
+
+ it('перемещает rectangle целиком', () => {
+ const { rectangle, container } = createRectangle(true);
+
+ setReady(rectangle);
+
+ rectangle.pointerDown(createPointerEvent(container, 100, 100));
+ rectangle.pointerMove(createPointerEvent(container, 120, 130));
+
+ expect(rectangle.getState()).toMatchObject({
+ startTime: 70,
+ endTime: 170,
+ startPrice: 80,
+ endPrice: 180,
+ });
+ });
+
+ it('ограничивает перемещение границами контейнера', () => {
+ const { rectangle, container } = createRectangle(true);
+
+ setReady(rectangle);
+
+ rectangle.pointerDown(createPointerEvent(container, 100, 100));
+ rectangle.pointerMove(createPointerEvent(container, -500, -500));
+
+ expect(rectangle.getRenderData()).toMatchObject({
+ left: 0,
+ top: 0,
+ right: 100,
+ bottom: 100,
+ });
+ });
+
+ it('открывает settings по double click', () => {
+ const { rectangle, container, openSettings } = createRectangle();
+
+ setReady(rectangle);
+ rectangle.doubleClick(createMouseEvent(container, 100, 100));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не возвращает render data когда hidden', () => {
+ const { rectangle } = createRectangle();
+
+ setReady(rectangle);
+ rectangle.hide();
+
+ expect(rectangle.getRenderData()).toBeNull();
+ });
+});
+
+describe('RectanglePaneRenderer', () => {
+ it('ничего не рисует без render data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+ const renderer = new RectanglePaneRenderer({
+ getRenderData: () => null,
+ } as unknown as Rectangle);
+
+ renderer.draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует fill, border и text', () => {
+ const data: RectangleRenderData = {
+ left: 10,
+ right: 100,
+ top: 20,
+ bottom: 80,
+ width: 90,
+ height: 60,
+ borderColor: '#fff',
+ fillColor: '#111',
+ text: 'Rectangle',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#fff',
+ };
+
+ const { target, context } = createCanvasTarget();
+ const renderer = new RectanglePaneRenderer({
+ getRenderData: () => data,
+ } as unknown as Rectangle);
+
+ renderer.draw(target);
+
+ expect(context.fillRect).toHaveBeenCalled();
+ expect(context.strokeRect).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalledWith('Rectangle', 20, expect.any(Number));
+ });
+});
+
+describe('RectanglePaneView', () => {
+ it('возвращает renderer и top zOrder', () => {
+ const view = new RectanglePaneView({} as Rectangle);
+
+ expect(view.renderer()).toBeInstanceOf(RectanglePaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('Rectangle settings', () => {
+ it('создаёт default settings', () => {
+ expect(createDefaultSettings()).toEqual({
+ borderColor: '#2962ff',
+ fillColor: '#2962ff22',
+ text: '',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#2962ff',
+ });
+ });
+
+ it('создаёт style и text tabs', () => {
+ expect(getRectangleSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts b/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fa6052badb1b3823afe20acee42f9a19e8d4a1e3
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/regressionTrend.test.ts
@@ -0,0 +1,328 @@
+import { RegressionTrendPaneRenderer } from '@core/Drawings/regressionTrend/paneRenderer';
+import { RegressionTrendPaneView } from '@core/Drawings/regressionTrend/paneView';
+import { RegressionTrend, type RegressionTrendRenderData } from '@core/Drawings/regressionTrend/regressionTrend';
+import { createDefaultSettings, getRegressionTrendSettingsTabs } from '@core/Drawings/regressionTrend/settings';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartLineColor: '#2962ff',
+ chartCrosshairLine: '#999999',
+ sliderPositiveFill: '#00aa0044',
+ sliderNegativeFill: '#aa000044',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}` }));
+
+const seriesData = [
+ { time: 10 as Time, close: 10 },
+ { time: 20 as Time, close: 20 },
+ { time: 30 as Time, close: 15 },
+ { time: 40 as Time, close: 30 },
+];
+
+class TestRegressionTrend extends RegressionTrend {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+
+ public doubleClick(event: MouseEvent): void {
+ this.handleDoubleClick(event);
+ }
+}
+
+function createRegression(selected = false) {
+ const env = createDrawingTestEnvironment({ selected, seriesData });
+ const openSettings = jest.fn();
+
+ const regression = new TestRegressionTrend({
+ ...env,
+ openSettings,
+ });
+
+ regression.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 40 as Time,
+ });
+
+ return { ...env, regression, openSettings };
+}
+
+describe('RegressionTrend', () => {
+ it('рассчитывает linear regression', () => {
+ const { regression } = createRegression();
+ const data = regression.getRenderData();
+
+ expect(data?.baseStartPrice).toBeCloseTo(10.5);
+ expect(data?.baseEndPrice).toBeCloseTo(27);
+ expect(data?.correlation).toBeCloseTo(0.8315218406);
+ });
+
+ it('строит upper и lower channel на двух отклонениях', () => {
+ const { regression } = createRegression();
+ const data = regression.getRenderData();
+
+ const deviation = 4.1079191813;
+
+ expect(data?.upperStartPoint.y).toBeCloseTo(10.5 + deviation * 2);
+ expect(data?.lowerStartPoint.y).toBeCloseTo(10.5 - deviation * 2);
+ expect(data?.upperEndPoint.y).toBeCloseTo(27 + deviation * 2);
+ expect(data?.lowerEndPoint.y).toBeCloseTo(27 - deviation * 2);
+ });
+
+ it('возвращает два handles на base line', () => {
+ const { regression } = createRegression();
+
+ expect(regression.handles()).toEqual([
+ {
+ id: 'start',
+ x: 10,
+ y: 10.5,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'end',
+ x: 40,
+ y: 27,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ ]);
+ });
+
+ it('показывает channel в ready', () => {
+ const { regression } = createRegression();
+
+ expect(regression.getRenderData()?.showChannel).toBe(true);
+ });
+
+ it('не показывает channel во время drawing', () => {
+ const { regression } = createRegression();
+
+ regression.setState({ mode: 'drawing' });
+
+ expect(regression.getRenderData()?.showChannel).toBe(false);
+ });
+
+ it('нормализует dragging state в ready при setState', () => {
+ const { regression } = createRegression();
+
+ regression.setState({ mode: 'dragging' });
+
+ expect(regression.getState().mode).toBe('ready');
+ });
+
+ it('возвращает axis segments', () => {
+ const { regression } = createRegression(true);
+
+ expect(regression.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
+ expect(regression.priceSegments()).toEqual([{ from: 10.5, to: 27, color: '#2962ff44' }]);
+ });
+
+ it('возвращает axis labels', () => {
+ const { regression } = createRegression(true);
+
+ expect(regression.timeLabel('start')).toMatchObject({
+ coordinate: 10,
+ text: 'date:10',
+ });
+
+ expect(regression.priceLabel('end')).toMatchObject({
+ coordinate: 27,
+ text: 'price:27',
+ });
+ });
+
+ it('возвращает pointer для unselected channel', () => {
+ const { regression } = createRegression();
+
+ expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('pointer');
+ });
+
+ it('возвращает ew-resize для selected handle', () => {
+ const { regression } = createRegression(true);
+
+ expect(regression.hovered(10, 10.5)?.cursorStyle).toBe('ew-resize');
+ });
+
+ it('возвращает grab для selected body', () => {
+ const { regression } = createRegression(true);
+
+ expect(regression.hovered(25, 18.75)?.cursorStyle).toBe('grab');
+ });
+
+ it('перемещает start edge на ближайший bar', () => {
+ const { regression, container } = createRegression(true);
+
+ regression.pointerDown(createPointerEvent(container, 10, 10.5));
+ regression.pointerMove(createPointerEvent(container, 20, 20));
+ regression.pointerUp(createPointerEvent(container, 20, 20));
+
+ expect(regression.getState()).toMatchObject({
+ mode: 'ready',
+ startTime: 20,
+ endTime: 40,
+ });
+ });
+
+ it('не позволяет схлопнуть range до одного bar', () => {
+ const { regression, container } = createRegression(true);
+
+ regression.pointerDown(createPointerEvent(container, 10, 10.5));
+ regression.pointerMove(createPointerEvent(container, 40, 30));
+
+ expect(regression.getState().startTime).toBe(10);
+ });
+
+ it('открывает настройки только для selected trend', () => {
+ const { regression, container, openSettings } = createRegression(true);
+
+ regression.doubleClick(createMouseEvent(container, 25, 18.75));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('возвращает null когда hidden', () => {
+ const { regression } = createRegression();
+
+ regression.hide();
+
+ expect(regression.getRenderData()).toBeNull();
+ });
+});
+
+describe('RegressionTrendPaneRenderer', () => {
+ it('ничего не рисует без данных', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+ const renderer = new RegressionTrendPaneRenderer({
+ getRenderData: () => null,
+ } as unknown as RegressionTrend);
+
+ renderer.draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует channel, middle line и correlation', () => {
+ const data: RegressionTrendRenderData = {
+ baseStartPoint: { x: 10, y: 20 },
+ baseEndPoint: { x: 100, y: 40 },
+ upperStartPoint: { x: 10, y: 10 },
+ upperEndPoint: { x: 100, y: 30 },
+ lowerStartPoint: { x: 10, y: 30 },
+ lowerEndPoint: { x: 100, y: 50 },
+ baseStartPrice: 20,
+ baseEndPrice: 40,
+ correlation: 0.9,
+ left: 10,
+ right: 100,
+ top: 10,
+ bottom: 50,
+ lineColor: '#fff',
+ middleLineColor: '#aaa',
+ upperFillColor: '#0f03',
+ lowerFillColor: '#f003',
+ showMiddleLine: true,
+ showCorrelation: true,
+ showChannel: true,
+ };
+
+ const { target, context } = createCanvasTarget();
+ const renderer = new RegressionTrendPaneRenderer({
+ getRenderData: () => data,
+ } as unknown as RegressionTrend);
+
+ renderer.draw(target);
+
+ expect(context.fill).toHaveBeenCalledTimes(2);
+ expect(context.stroke).toHaveBeenCalled();
+ expect(context.setLineDash).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalledWith('0.9', expect.any(Number), expect.any(Number));
+ });
+});
+
+describe('RegressionTrendPaneView', () => {
+ it('возвращает renderer и top', () => {
+ const view = new RegressionTrendPaneView({} as RegressionTrend);
+
+ expect(view.renderer()).toBeInstanceOf(RegressionTrendPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('RegressionTrend settings', () => {
+ it('создаёт defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ lineColor: '#2962ff',
+ middleLineColor: '#999999',
+ upperFillColor: '#00aa0044',
+ lowerFillColor: '#aa000044',
+ showMiddleLine: true,
+ showCorrelation: true,
+ });
+ });
+
+ it('имеет только style tab', () => {
+ expect(getRegressionTrendSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/ruler.test.ts b/src/core/Drawings/__tests__/drawings/ruler.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..69ebc65d964bcdcc12037333d609aa4e8bb2a976
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/ruler.test.ts
@@ -0,0 +1,290 @@
+import { BehaviorSubject } from 'rxjs';
+
+import { RulerPaneRenderer } from '@core/Drawings/ruler/paneRenderer';
+import { RulerPaneView } from '@core/Drawings/ruler/paneView';
+import { Ruler, type RulerRenderData } from '@core/Drawings/ruler/ruler';
+
+import { Direction } from '@src/types';
+
+import { createCanvasTarget, createDrawingTestEnvironment } from '../drawingTestUtils';
+
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartLineColor: '#00aa00',
+ chartLineColorAlternative: '#aa0000',
+ rulerPositiveFill: '#00aa0044',
+ rulerNegativeFill: '#aa000044',
+ chartPriceLineText: '#ffffff',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+const seriesData = [
+ { time: 10 as Time, close: 10, volume: 100 },
+ { time: 20 as Time, close: 20, customValues: { volume: 200 } },
+ { time: 30 as Time, close: 30, volume: 300 },
+];
+
+class TestRuler extends Ruler {
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+}
+
+function createRuler() {
+ const env = createDrawingTestEnvironment({ seriesData });
+ const removeSelf = jest.fn();
+
+ const ruler = new TestRuler({
+ ...env,
+ removeSelf,
+ });
+
+ return { ...env, ruler, removeSelf };
+}
+
+function setReady(ruler: TestRuler, startPrice = 10, endPrice = 30): void {
+ ruler.setState({
+ mode: 'ready',
+ startAnchor: { time: 10 as Time, price: startPrice },
+ endAnchor: { time: 30 as Time, price: endPrice },
+ });
+}
+
+describe('Ruler', () => {
+ it('не отображается в Object Tree', () => {
+ const { ruler } = createRuler();
+
+ expect(ruler.shouldShowInObjectTree()).toBe(false);
+ });
+
+ it('не имеет settings tabs', () => {
+ const { ruler } = createRuler();
+
+ expect(ruler.getSettingsTabs()).toEqual([]);
+ });
+
+ it('считает изменение цены, процент, bars и volume', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.getRenderData().infoLines).toEqual(['price:20 (price:200%)', '2 bars,', 'Vol volume:600']);
+ });
+
+ it('берёт volume из customValues', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.getRenderData().infoLines[2]).toBe('Vol volume:600');
+ });
+
+ it('выбирает positive цвета для роста', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler, 10, 30);
+
+ expect(ruler.getRenderData()).toMatchObject({
+ lineColor: '#00aa00',
+ fillColor: '#00aa0044',
+ });
+ });
+
+ it('выбирает negative цвета для падения', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler, 30, 10);
+
+ expect(ruler.getRenderData()).toMatchObject({
+ lineColor: '#aa0000',
+ fillColor: '#aa000044',
+ });
+ });
+
+ it('определяет направление стрелок', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.getRenderData()).toMatchObject({
+ horizontalArrowSide: Direction.Right,
+ verticalArrowSide: Direction.Bottom,
+ });
+ });
+
+ it('возвращает axis bounds', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.getTimeBounds()).toEqual({ left: 10, right: 30 });
+ expect(ruler.getPriceBounds()).toEqual({ top: 10, bottom: 30 });
+ });
+
+ it('возвращает axis segments', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.timeSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
+ expect(ruler.priceSegments()).toEqual([{ from: 10, to: 30, color: '#2962ff44' }]);
+ });
+
+ it('возвращает axis labels', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.timeLabel('start')).toMatchObject({
+ coordinate: 10,
+ text: 'date:10',
+ });
+
+ expect(ruler.priceLabel('end')).toMatchObject({
+ coordinate: 30,
+ text: 'price:30',
+ });
+ });
+
+ it('возвращает autoscaleInfo когда ruler входит в logical range', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.autoscaleInfo(0 as never, 50 as never)).toEqual({
+ priceRange: {
+ minValue: 10,
+ maxValue: 30,
+ },
+ });
+ });
+
+ it('не влияет на autoscale вне logical range', () => {
+ const { ruler } = createRuler();
+
+ setReady(ruler);
+
+ expect(ruler.autoscaleInfo(100 as never, 200 as never)).toBeNull();
+ });
+
+ it('не влияет на autoscale во время placingEnd', () => {
+ const { ruler } = createRuler();
+
+ ruler.setState({
+ mode: 'placingEnd',
+ startAnchor: { time: 10 as Time, price: 10 },
+ endAnchor: { time: 30 as Time, price: 30 },
+ });
+
+ expect(ruler.autoscaleInfo(0 as never, 50 as never)).toBeNull();
+ });
+
+ it('вызывает removeSelf после reset trigger', () => {
+ const reset$ = new BehaviorSubject(false);
+ const env = createDrawingTestEnvironment({ seriesData });
+ const removeSelf = jest.fn();
+
+ const ruler = new TestRuler({
+ ...env,
+ removeSelf,
+ resetTriggers: [reset$],
+ });
+
+ expect(removeSelf).not.toHaveBeenCalled();
+
+ reset$.next(true);
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+
+ ruler.destroy();
+ });
+});
+
+describe('RulerPaneRenderer', () => {
+ it('не рисует hidden ruler', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+ const renderer = new RulerPaneRenderer({
+ getRenderData: () => ({
+ hidden: true,
+ startPoint: null,
+ endPoint: null,
+ }),
+ } as unknown as Ruler);
+
+ renderer.draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует область, стрелки и info lines', () => {
+ const data: RulerRenderData = {
+ hidden: false,
+ startPoint: { x: 10, y: 20 },
+ endPoint: { x: 100, y: 80 },
+ lineColor: '#fff',
+ fillColor: '#111',
+ textColor: '#fff',
+ infoLines: ['one', 'two', 'three'],
+ horizontalArrowSide: Direction.Right,
+ verticalArrowSide: Direction.Bottom,
+ };
+
+ const { target, context } = createCanvasTarget();
+ const renderer = new RulerPaneRenderer({
+ getRenderData: () => data,
+ } as unknown as Ruler);
+
+ renderer.draw(target);
+
+ expect(context.fillRect).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalledTimes(3);
+ });
+});
+
+describe('RulerPaneView', () => {
+ it('возвращает renderer и top', () => {
+ const view = new RulerPaneView({} as Ruler);
+
+ expect(view.renderer()).toBeInstanceOf(RulerPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts b/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1338dfe9176256e4f5a332459df168b3a21e58cf
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/sliderPosition.test.ts
@@ -0,0 +1,446 @@
+import { SliderPaneRenderer } from '@core/Drawings/sliderPosition/paneRenderer';
+import { SliderPaneView } from '@core/Drawings/sliderPosition/paneView';
+import { createDefaultSettings, getSliderPositionSettingsTabs } from '@core/Drawings/sliderPosition/settings';
+import { SliderPosition, type SliderRenderData } from '@core/Drawings/sliderPosition/sliderPosition';
+
+import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartCrosshairLine: '#999999',
+ sliderPositiveFill: '#00aa0044',
+ sliderNegativeFill: '#aa000044',
+ chartPriceLineText: '#ffffff',
+ chartCandleUp: '#00aa00',
+ chartCandleDown: '#aa0000',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ axisMarkerLabelDefaultFill: '#777777',
+ axisMarkerLabelPositiveFill: '#00aa00',
+ axisMarkerLabelNegativeFill: '#aa0000',
+ },
+ }),
+}));
+
+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) => String(value),
+ formatPercent: (value: number) => `${value}%`,
+ formatSignedNumber: (value: number) => String(value),
+}));
+
+jest.mock('@src/utils/formatter', () => ({
+ formatDate: (value: number) => `date:${value}`,
+}));
+
+class TestSliderPosition extends SliderPosition {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createSlider(side: 'long' | 'short' = 'long', selected = false) {
+ const seriesData = [
+ { time: 10 as Time, close: 100 },
+ { time: 100 as Time, close: 110 },
+ ];
+
+ const env = createDrawingTestEnvironment({ selected, seriesData });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const slider = new TestSliderPosition({
+ ...env,
+ side,
+ removeSelf,
+ openSettings,
+ });
+
+ return { ...env, slider, removeSelf, openSettings };
+}
+
+function setReady(slider: TestSliderPosition): void {
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 90,
+ targetPrice: 120,
+ amount: 1000,
+ tickSize: 1,
+ });
+}
+
+describe('SliderPosition', () => {
+ it('считает Risk/Reward Ratio', () => {
+ const { slider } = createSlider();
+
+ setReady(slider);
+
+ expect(slider.getState().riskRewardRatio).toBe(2);
+ });
+
+ it('поддерживает RR = 0', () => {
+ const { slider } = createSlider();
+
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 90,
+ riskRewardRatio: 0,
+ });
+
+ expect(slider.getState()).toMatchObject({
+ targetPrice: 100,
+ riskRewardRatio: 0,
+ });
+ });
+
+ it('возвращает RR = 0 когда Target совпадает с Entry', () => {
+ const { slider } = createSlider();
+
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 90,
+ targetPrice: 100,
+ });
+
+ expect(slider.getState().riskRewardRatio).toBe(0);
+ });
+
+ it('возвращает RR = 0 когда Stop совпадает с Entry', () => {
+ const { slider } = createSlider();
+
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 100,
+ targetPrice: 120,
+ });
+
+ expect(slider.getState().riskRewardRatio).toBe(0);
+ });
+
+ it('возвращает четыре handles в правильном приоритетном порядке', () => {
+ const { slider } = createSlider();
+
+ setReady(slider);
+
+ expect(slider.handles()).toEqual([
+ { id: 'target', x: 10, y: 120 },
+ { id: 'stop', x: 10, y: 90 },
+ { id: 'end', x: 100, y: 100 },
+ { id: 'entry', x: 10, y: 100, shape: 'circle' },
+ ]);
+ });
+
+ it('Entry handle имеет приоритет над Target при совпадении', () => {
+ const { slider, container } = createSlider('long', true);
+
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 90,
+ targetPrice: 100,
+ });
+
+ slider.pointerDown(createPointerEvent(container, 10, 100));
+ slider.pointerMove(createPointerEvent(container, 10, 95));
+
+ expect(slider.getState()).toMatchObject({
+ entryPrice: 95,
+ targetPrice: 100,
+ });
+ });
+
+ it('Entry handle имеет приоритет над Stop при совпадении', () => {
+ const { slider, container } = createSlider('long', true);
+
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 100,
+ targetPrice: 120,
+ });
+
+ slider.pointerDown(createPointerEvent(container, 10, 100));
+ slider.pointerMove(createPointerEvent(container, 10, 110));
+
+ expect(slider.getState()).toMatchObject({
+ entryPrice: 110,
+ stopPrice: 100,
+ });
+ });
+
+ it('Entry нельзя вывести за Stop и Target', () => {
+ const { slider, container } = createSlider('long', true);
+
+ setReady(slider);
+
+ slider.pointerDown(createPointerEvent(container, 10, 100));
+ slider.pointerMove(createPointerEvent(container, 10, 200));
+
+ expect(slider.getState().entryPrice).toBe(120);
+ });
+
+ it('Stop можно схлопнуть с Entry', () => {
+ const { slider, container } = createSlider('long', true);
+
+ setReady(slider);
+
+ slider.pointerDown(createPointerEvent(container, 10, 90));
+ slider.pointerMove(createPointerEvent(container, 10, 100));
+
+ expect(slider.getState().stopPrice).toBe(100);
+ });
+
+ it('Target можно схлопнуть с Entry', () => {
+ const { slider, container } = createSlider('long', true);
+
+ setReady(slider);
+
+ slider.pointerDown(createPointerEvent(container, 10, 120));
+ slider.pointerMove(createPointerEvent(container, 10, 100));
+
+ expect(slider.getState().targetPrice).toBe(100);
+ });
+
+ it('работает для short позиции', () => {
+ const { slider } = createSlider('short');
+
+ slider.setState({
+ mode: 'ready',
+ startTime: 10 as Time,
+ endTime: 100 as Time,
+ entryPrice: 100,
+ stopPrice: 110,
+ targetPrice: 80,
+ });
+
+ expect(slider.getState().riskRewardRatio).toBe(2);
+ expect(slider.getRenderData()).toMatchObject({
+ targetLabelDirection: 'down',
+ stopLabelDirection: 'up',
+ });
+ });
+
+ it('возвращает selected axis areas', () => {
+ const { slider } = createSlider('long', true);
+
+ setReady(slider);
+
+ expect(slider.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
+ expect(slider.priceSegments()).toEqual([
+ { from: 100, to: 120, color: '#2962ff44' },
+ { from: 90, to: 100, color: '#2962ff44' },
+ ]);
+ });
+
+ it('использует normal zOrder для selected axis pane areas', () => {
+ const { slider } = createSlider();
+
+ expect(slider.timeAxisPaneViews()[0].zOrder?.()).toBe('normal');
+ expect(slider.priceAxisPaneViews()[0].zOrder?.()).toBe('normal');
+ });
+
+ it('time labels показываются только selected drawing', () => {
+ const { slider } = createSlider();
+
+ setReady(slider);
+
+ expect(slider.timeLabel('start')).toBeNull();
+ });
+
+ it('price labels доступны независимо от selection', () => {
+ const { slider } = createSlider();
+
+ setReady(slider);
+
+ expect(slider.priceLabel('target')).toMatchObject({
+ coordinate: 120,
+ text: '120',
+ backgroundColor: '#00aa00',
+ });
+
+ expect(slider.priceLabel('entry')).toMatchObject({
+ coordinate: 100,
+ backgroundColor: '#777777',
+ });
+
+ expect(slider.priceLabel('stop')).toMatchObject({
+ coordinate: 90,
+ backgroundColor: '#aa0000',
+ });
+ });
+
+ it('возвращает правильные cursors', () => {
+ const { slider } = createSlider('long', true);
+
+ setReady(slider);
+
+ expect(slider.hovered(10, 120)?.cursorStyle).toBe('ns-resize');
+ expect(slider.hovered(10, 90)?.cursorStyle).toBe('ns-resize');
+ expect(slider.hovered(100, 100)?.cursorStyle).toBe('ew-resize');
+ expect(slider.hovered(10, 100)?.cursorStyle).toBe('grab');
+ });
+
+ it('выбирает unselected slider по body', () => {
+ const { slider, interaction, container } = createSlider();
+
+ setReady(slider);
+ slider.pointerDown(createPointerEvent(container, 50, 100));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('изменяет end time', () => {
+ const { slider, container } = createSlider('long', true);
+
+ setReady(slider);
+
+ slider.pointerDown(createPointerEvent(container, 100, 100));
+ slider.pointerMove(createPointerEvent(container, 150, 100));
+
+ expect(slider.getState().endTime).toBe(150);
+ });
+});
+
+describe('SliderPaneRenderer', () => {
+ it('не рисует без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new SliderPaneRenderer({
+ getRenderData: () => null,
+ } as unknown as SliderPosition).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует profit/loss и labels', () => {
+ const data: SliderRenderData = {
+ startX: 10,
+ endX: 100,
+ leftX: 10,
+ rightX: 100,
+ entryY: 100,
+ stopY: 120,
+ targetY: 80,
+ entryPrice: 100,
+ stopPrice: 90,
+ targetPrice: 120,
+ profitTop: 80,
+ profitBottom: 100,
+ lossTop: 100,
+ lossBottom: 120,
+ targetText: 'target',
+ centerText: 'center',
+ stopText: 'stop',
+ centerBoxColor: '#0a0',
+ targetLabelDirection: 'up',
+ stopLabelDirection: 'down',
+ showLabels: true,
+ lineColor: '#fff',
+ positiveFillColor: '#0f03',
+ negativeFillColor: '#f003',
+ textColor: '#fff',
+ fontSize: 10,
+ isBold: false,
+ isItalic: false,
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new SliderPaneRenderer({
+ getRenderData: () => data,
+ } as unknown as SliderPosition).draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledTimes(2);
+ expect(context.fillText).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ });
+});
+
+describe('SliderPaneView', () => {
+ it('возвращает renderer и top', () => {
+ const view = new SliderPaneView({} as SliderPosition);
+
+ expect(view.renderer()).toBeInstanceOf(SliderPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('SliderPosition settings', () => {
+ it('создаёт defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ lineColor: '#999999',
+ positiveFillColor: '#00aa0044',
+ negativeFillColor: '#aa000044',
+ textColor: '#ffffff',
+ fontSize: 10,
+ isBold: false,
+ isItalic: false,
+ });
+ });
+
+ it('создаёт style и text tabs', () => {
+ expect(getSliderPositionSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/text.test.ts b/src/core/Drawings/__tests__/drawings/text.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2eec7aa12b30790be8648b153dd30d4ac8eede21
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/text.test.ts
@@ -0,0 +1,337 @@
+import { TextPaneRenderer } from '@core/Drawings/text/paneRenderer';
+import { TextPaneView } from '@core/Drawings/text/paneView';
+import { createDefaultSettings, getTextSettingsTabs } from '@core/Drawings/text/settings';
+import { Text, type TextRenderData } from '@core/Drawings/text/text';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { AxisLabel } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartPriceLineText: '#ffffff',
+ chartLineColor: '#2962ff',
+ axisMarkerLabelFill: '#2962ff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+class TestText extends Text {
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+
+ public doubleClick(event: MouseEvent): void {
+ this.handleDoubleClick(event);
+ }
+}
+
+let getContextSpy: jest.SpyInstance;
+
+beforeAll(() => {
+ getContextSpy = jest.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
+ () =>
+ ({
+ measureText: (value: string) => ({ width: value.length * 8 }),
+ }) as unknown as CanvasRenderingContext2D,
+ );
+});
+
+afterAll(() => {
+ getContextSpy.mockRestore();
+});
+
+function createText(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const text = new TestText({
+ ...env,
+ removeSelf,
+ openSettings,
+ });
+
+ return { ...env, text, removeSelf, openSettings };
+}
+
+function setReady(text: TestText): void {
+ text.setState({
+ mode: 'ready',
+ point: { time: 50 as Time, price: 60 },
+ settings: {
+ ...createDefaultSettings(),
+ text: 'Test',
+ },
+ });
+}
+
+describe('Text', () => {
+ it('создаётся одним pointer down', () => {
+ const { text, container } = createText();
+
+ text.pointerDown(createPointerEvent(container, 50, 60));
+
+ expect(text.getState()).toMatchObject({
+ mode: 'ready',
+ point: {
+ time: 50,
+ price: 60,
+ },
+ });
+ });
+
+ it('правильно измеряет text block', () => {
+ const { text } = createText();
+
+ setReady(text);
+
+ expect(text.getRenderData()).toMatchObject({
+ point: { x: 50, y: 60 },
+ width: 44,
+ height: 26,
+ lines: ['Test'],
+ lineHeight: 14,
+ });
+ });
+
+ it('поддерживает multiline text', () => {
+ const { text } = createText();
+
+ text.setState({
+ mode: 'ready',
+ point: { time: 50 as Time, price: 60 },
+ settings: {
+ ...createDefaultSettings(),
+ text: 'One\nTwo',
+ },
+ });
+
+ expect(text.getRenderData()).toMatchObject({
+ lines: ['One', 'Two'],
+ height: 40,
+ });
+ });
+
+ it('показывает selection border только selected', () => {
+ const { text, setSelected } = createText();
+
+ setReady(text);
+
+ expect(text.getRenderData()?.showSelectionBorder).toBe(false);
+
+ setSelected(true);
+
+ expect(text.getRenderData()?.showSelectionBorder).toBe(true);
+ });
+
+ it('возвращает move cursor внутри текста', () => {
+ const { text } = createText();
+
+ setReady(text);
+
+ expect(text.hovered(60, 70)?.cursorStyle).toBe('move');
+ expect(text.hovered(200, 200)).toBeNull();
+ });
+
+ it('выбирает text первым нажатием', () => {
+ const { text, container, interaction } = createText();
+
+ setReady(text);
+
+ text.pointerDown(createPointerEvent(container, 60, 70));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('перемещает selected text', () => {
+ const { text, container } = createText(true);
+
+ setReady(text);
+
+ text.pointerDown(createPointerEvent(container, 60, 70));
+ text.pointerMove(createPointerEvent(container, 80, 90));
+ text.pointerUp(createPointerEvent(container, 80, 90));
+
+ expect(text.getState()).toMatchObject({
+ mode: 'ready',
+ point: {
+ time: 70,
+ price: 80,
+ },
+ });
+ });
+
+ it('не позволяет text выйти за пределы контейнера', () => {
+ const { text, container } = createText(true);
+
+ setReady(text);
+
+ text.pointerDown(createPointerEvent(container, 60, 70));
+ text.pointerMove(createPointerEvent(container, 1000, 1000));
+
+ const data = text.getRenderData();
+
+ expect(data?.right).toBeLessThanOrEqual(400);
+ expect(data?.bottom).toBeLessThanOrEqual(300);
+ });
+
+ it('показывает axis labels только selected', () => {
+ const { text } = createText(true);
+
+ setReady(text);
+
+ expect(text.timeLabel('main')).toMatchObject({
+ coordinate: 50,
+ text: 'date:50',
+ });
+
+ expect(text.priceLabel('main')).toMatchObject({
+ coordinate: 60,
+ text: 'price:60',
+ });
+ });
+
+ it('не имеет axis pane segments', () => {
+ const { text } = createText(true);
+
+ setReady(text);
+
+ expect(text.timeAxisPaneViews()).toEqual([]);
+ expect(text.priceAxisPaneViews()).toEqual([]);
+ });
+
+ it('открывает settings по double click selected text', () => {
+ const { text, container, openSettings } = createText(true);
+
+ setReady(text);
+ text.doubleClick(createMouseEvent(container, 60, 70));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не возвращает render data когда hidden', () => {
+ const { text } = createText();
+
+ setReady(text);
+ text.hide();
+
+ expect(text.getRenderData()).toBeNull();
+ });
+});
+
+describe('TextPaneRenderer', () => {
+ it('ничего не рисует без данных', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new TextPaneRenderer({
+ getRenderData: () => null,
+ } as unknown as Text).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует background, border, text и selection', () => {
+ const data: TextRenderData = {
+ point: { x: 10, y: 20 },
+ left: 10,
+ right: 80,
+ top: 20,
+ bottom: 60,
+ width: 70,
+ height: 40,
+ lines: ['Test'],
+ font: '14px Inter',
+ lineHeight: 14,
+ text: 'Test',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#fff',
+ backgroundColor: '#111',
+ borderColor: '#222',
+ showSelectionBorder: true,
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new TextPaneRenderer({
+ getRenderData: () => data,
+ } as unknown as Text).draw(target);
+
+ expect(context.fill).toHaveBeenCalled();
+ expect(context.stroke).toHaveBeenCalled();
+ expect(context.fillText).toHaveBeenCalledWith('Test', expect.any(Number), expect.any(Number));
+ });
+});
+
+describe('TextPaneView', () => {
+ it('переиспользует renderer и возвращает top', () => {
+ const view = new TextPaneView({} as Text);
+ const renderer = view.renderer();
+
+ expect(renderer).toBeInstanceOf(TextPaneRenderer);
+ expect(view.renderer()).toBe(renderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('Text settings', () => {
+ it('создаёт defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ text: 'Text',
+ fontSize: 14,
+ isBold: false,
+ isItalic: false,
+ textColor: '#ffffff',
+ backgroundColor: '#00000000',
+ borderColor: '#00000000',
+ });
+ });
+
+ it('создаёт style и text tabs', () => {
+ expect(getTextSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style', 'text']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/traectory.test.ts b/src/core/Drawings/__tests__/drawings/traectory.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e51234b2f2a6331640df240f23e96c485d5c77b8
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/traectory.test.ts
@@ -0,0 +1,289 @@
+import { TraectoryPaneRenderer } from '@core/Drawings/traectory/paneRenderer';
+import { TraectoryPaneView } from '@core/Drawings/traectory/paneView';
+import { createDefaultSettings, getTraectorySettingsTabs } from '@core/Drawings/traectory/settings';
+import { Traectory } from '@core/Drawings/traectory/traectory';
+
+import {
+ createCanvasTarget,
+ createDrawingTestEnvironment,
+ createMouseEvent,
+ createPointerEvent,
+} from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartLineColor: '#2962ff',
+ axisMarkerAreaFill: '#2962ff44',
+ },
+ }),
+}));
+
+jest.mock('@src/translations', () => ({ t: (value: string) => value }));
+
+class TestTraectory extends Traectory {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+
+ public click(event: MouseEvent): void {
+ this.handleClick(event);
+ }
+
+ public doubleClick(event: MouseEvent): void {
+ this.handleDoubleClick(event);
+ }
+
+ public contextMenu(event: MouseEvent): void {
+ this.handleContextMenu(event);
+ }
+}
+
+function createTraectory(selected = false) {
+ const env = createDrawingTestEnvironment({ selected });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const traectory = new TestTraectory({
+ ...env,
+ removeSelf,
+ openSettings,
+ });
+
+ return { ...env, traectory, removeSelf, openSettings };
+}
+
+function setReady(traectory: TestTraectory): void {
+ traectory.setState({
+ mode: 'ready',
+ points: [
+ { time: 10 as Time, price: 20 },
+ { time: 50 as Time, price: 60 },
+ { time: 100 as Time, price: 40 },
+ ],
+ });
+}
+
+describe('Traectory', () => {
+ it('начинает drawing первым pointer down', () => {
+ const { traectory, container } = createTraectory();
+
+ traectory.pointerDown(createPointerEvent(container, 10, 20));
+
+ expect(traectory.getState()).toMatchObject({
+ mode: 'drawing',
+ points: [{ time: 10, price: 20 }],
+ });
+ });
+
+ it('добавляет точки обычным click', () => {
+ const { traectory, container } = createTraectory();
+
+ traectory.pointerDown(createPointerEvent(container, 10, 20));
+ traectory.click(createMouseEvent(container, 50, 60));
+
+ expect(traectory.getState().points).toEqual([
+ { time: 10, price: 20 },
+ { time: 50, price: 60 },
+ ]);
+ });
+
+ it('не добавляет одну и ту же точку дважды', () => {
+ const { traectory, container } = createTraectory();
+
+ traectory.pointerDown(createPointerEvent(container, 10, 20));
+ traectory.click(createMouseEvent(container, 10, 20));
+
+ expect(traectory.getState().points).toHaveLength(1);
+ });
+
+ it('заканчивает drawing double click', () => {
+ const { traectory, container } = createTraectory();
+
+ traectory.pointerDown(createPointerEvent(container, 10, 20));
+ traectory.click(createMouseEvent(container, 50, 60));
+ traectory.doubleClick(createMouseEvent(container, 50, 60, 2));
+
+ expect(traectory.getState().mode).toBe('ready');
+ expect(traectory.getRenderData()?.showArrow).toBe(true);
+ });
+
+ it('заканчивает drawing context menu', () => {
+ const { traectory, container } = createTraectory();
+
+ traectory.pointerDown(createPointerEvent(container, 10, 20));
+ traectory.click(createMouseEvent(container, 50, 60));
+ traectory.contextMenu(createMouseEvent(container, 50, 60));
+
+ expect(traectory.getState().mode).toBe('ready');
+ });
+
+ it('удаляется при завершении с одной точкой', () => {
+ const { traectory, container, removeSelf } = createTraectory();
+
+ traectory.pointerDown(createPointerEvent(container, 10, 20));
+ traectory.doubleClick(createMouseEvent(container, 10, 20, 2));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('возвращает handles в обратном порядке', () => {
+ const { traectory } = createTraectory();
+
+ setReady(traectory);
+
+ expect(traectory.handles()).toEqual([
+ { id: '2', x: 100, y: 40, shape: 'circle', size: 10, borderWidth: 2 },
+ { id: '1', x: 50, y: 60, shape: 'circle', size: 10, borderWidth: 2 },
+ { id: '0', x: 10, y: 20, shape: 'circle', size: 10, borderWidth: 2 },
+ ]);
+ });
+
+ it('возвращает axis segments', () => {
+ const { traectory } = createTraectory(true);
+
+ setReady(traectory);
+
+ expect(traectory.timeSegments()).toEqual([{ from: 10, to: 100, color: '#2962ff44' }]);
+ expect(traectory.priceSegments()).toEqual([{ from: 20, to: 60, color: '#2962ff44' }]);
+ });
+
+ it('не имеет axis labels', () => {
+ const { traectory } = createTraectory(true);
+
+ setReady(traectory);
+
+ expect(traectory.timeAxisViews()).toEqual([]);
+ expect(traectory.priceAxisViews()).toEqual([]);
+ });
+
+ it('определяет segment как drawing hit', () => {
+ const { traectory } = createTraectory();
+
+ setReady(traectory);
+
+ expect(traectory.hovered(30, 40)?.cursorStyle).toBe('move');
+ expect(traectory.hovered(300, 200)).toBeNull();
+ });
+
+ it('перемещает отдельную точку', () => {
+ const { traectory, container } = createTraectory(true);
+
+ setReady(traectory);
+
+ traectory.pointerDown(createPointerEvent(container, 50, 60));
+ traectory.pointerMove(createPointerEvent(container, 70, 80));
+ traectory.pointerUp(createPointerEvent(container, 70, 80));
+
+ expect(traectory.getState().points[1]).toEqual({
+ time: 70,
+ price: 80,
+ });
+ });
+
+ it('перемещает всю trajectory', () => {
+ const { traectory, container } = createTraectory(true);
+
+ setReady(traectory);
+
+ traectory.pointerDown(createPointerEvent(container, 30, 40));
+ traectory.pointerMove(createPointerEvent(container, 40, 50));
+
+ expect(traectory.getState().points).toEqual([
+ { time: 20, price: 30 },
+ { time: 60, price: 70 },
+ { time: 110, price: 50 },
+ ]);
+ });
+
+ it('открывает settings по double click', () => {
+ const { traectory, container, openSettings } = createTraectory(true);
+
+ setReady(traectory);
+ traectory.doubleClick(createMouseEvent(container, 30, 40, 2));
+
+ expect(openSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('не возвращает render data когда hidden', () => {
+ const { traectory } = createTraectory();
+
+ setReady(traectory);
+ traectory.hide();
+
+ expect(traectory.getRenderData()).toBeNull();
+ });
+});
+
+describe('TraectoryPaneRenderer', () => {
+ it('не рисует без data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new TraectoryPaneRenderer({
+ getRenderData: () => null,
+ } as unknown as Traectory).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует trajectory', () => {
+ const { traectory } = createTraectory();
+
+ setReady(traectory);
+
+ const { target, context } = createCanvasTarget();
+
+ new TraectoryPaneRenderer(traectory).draw(target);
+
+ expect(context.stroke).toHaveBeenCalled();
+ });
+});
+
+describe('TraectoryPaneView', () => {
+ it('возвращает renderer и top', () => {
+ const view = new TraectoryPaneView({} as Traectory);
+
+ expect(view.renderer()).toBeInstanceOf(TraectoryPaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('Traectory settings', () => {
+ it('создаёт default lineColor', () => {
+ expect(createDefaultSettings()).toEqual({
+ lineColor: '#2962ff',
+ });
+ });
+
+ it('имеет один style tab', () => {
+ expect(getTraectorySettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts b/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2ddc1db8a04879d941c426ec5c2976c418680364
--- /dev/null
+++ b/src/core/Drawings/__tests__/drawings/volumeProfile.test.ts
@@ -0,0 +1,450 @@
+import { VolumeProfilePaneRenderer } from '@core/Drawings/volumeProfile/paneRenderer';
+import { VolumeProfilePaneView } from '@core/Drawings/volumeProfile/paneView';
+import { createDefaultSettings, getVolumeProfileSettingsTabs } from '@core/Drawings/volumeProfile/settings';
+import { VolumeProfile, type VolumeProfileRenderData } from '@core/Drawings/volumeProfile/volumeProfile';
+
+import { createCanvasTarget, createDrawingTestEnvironment, createPointerEvent } from '../drawingTestUtils';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
+import type { PrimitiveHoveredItem, Time } from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ volumeProfileAreaFill: '#11111122',
+ volumeProfileBuyFill: '#00aa00',
+ volumeProfileSellFill: '#aa0000',
+ volumeProfilePocLine: '#ffaa00',
+ axisMarkerAreaFill: '#2962ff44',
+ axisMarkerLabelFill: '#2962ff',
+ chartPriceLineText: '#ffffff',
+ },
+ }),
+}));
+
+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}`,
+}));
+
+const candles = [
+ { time: 10 as Time, open: 10, high: 14, low: 9, close: 12, volume: 100 },
+ { time: 20 as Time, open: 12, high: 16, low: 10, close: 11, volume: 50 },
+ { time: 30 as Time, open: 11, high: 25, low: 10, close: 20, customValues: { volume: 200 } },
+ { time: 40 as Time, open: 20, high: 31, low: 18, close: 30, volume: 150 },
+];
+
+class TestVolumeProfile extends VolumeProfile {
+ public handles(): readonly DrawingHandle[] {
+ return this.getDrawingHandles();
+ }
+
+ public timeSegments(): AxisSegment[] {
+ return this.getTimeAxisSegments();
+ }
+
+ public priceSegments(): AxisSegment[] {
+ return this.getPriceAxisSegments();
+ }
+
+ public timeLabel(kind: string): AxisLabel | null {
+ return this.getTimeAxisLabel(kind);
+ }
+
+ public priceLabel(kind: string): AxisLabel | null {
+ return this.getPriceAxisLabel(kind);
+ }
+
+ public hovered(x: number, y: number): PrimitiveHoveredItem | null {
+ return this.getHoveredItem(x, y);
+ }
+
+ public pointerDown(event: PointerEvent): void {
+ this.handlePointerDown(event);
+ }
+
+ public pointerMove(event: PointerEvent): void {
+ this.handlePointerMove(event);
+ }
+
+ public pointerUp(event: PointerEvent): void {
+ this.handlePointerUp(event);
+ }
+}
+
+function createProfile(profileKind: 'fixedRange' | 'visibleRange' = 'fixedRange', selected = false) {
+ const env = createDrawingTestEnvironment({ selected, seriesData: candles });
+ const removeSelf = jest.fn();
+ const openSettings = jest.fn();
+
+ const profile = new TestVolumeProfile({
+ ...env,
+ profileKind,
+ removeSelf,
+ openSettings,
+ });
+
+ return { ...env, profile, removeSelf, openSettings };
+}
+
+function setFixedReady(profile: TestVolumeProfile): void {
+ profile.setState({
+ mode: 'ready',
+ startAnchor: { time: 10 as Time, price: 10 },
+ endAnchor: { time: 40 as Time, price: 30 },
+ });
+}
+
+describe('VolumeProfile fixedRange', () => {
+ it('является creation pending до завершения', () => {
+ const { profile } = createProfile();
+
+ expect(profile.isCreationPending()).toBe(true);
+ });
+
+ it('рассчитывает 24 profile rows', () => {
+ const { profile } = createProfile();
+
+ setFixedReady(profile);
+
+ expect(profile.getRenderData()?.rows).toHaveLength(24);
+ });
+
+ it('рассчитывает min/max по high и low candles', () => {
+ const { profile } = createProfile();
+
+ setFixedReady(profile);
+
+ expect(profile.getRenderData()).toMatchObject({
+ left: 10,
+ right: 40,
+ top: 9,
+ bottom: 31,
+ });
+ });
+
+ it('использует customValues.volume раньше обычного volume', () => {
+ const { profile } = createProfile();
+
+ setFixedReady(profile);
+
+ const rows = profile.getRenderData()?.rows ?? [];
+ const hasVolume = rows.some(({ buyWidth, sellWidth }) => buyWidth > 0 || sellWidth > 0);
+
+ expect(hasVolume).toBe(true);
+ });
+
+ it('рассчитывает POC', () => {
+ const { profile } = createProfile();
+
+ setFixedReady(profile);
+
+ expect(profile.getRenderData()?.pocY).not.toBeNull();
+ });
+
+ it('возвращает два edge handles', () => {
+ const { profile } = createProfile();
+
+ setFixedReady(profile);
+
+ expect(profile.handles()).toEqual([
+ { id: 'end', x: 40, y: 31, shape: 'circle', size: 10, borderWidth: 2 },
+ { id: 'start', x: 10, y: 9, shape: 'circle', size: 10, borderWidth: 2 },
+ ]);
+ });
+
+ it('возвращает selected axis segments', () => {
+ const { profile } = createProfile('fixedRange', true);
+
+ setFixedReady(profile);
+
+ expect(profile.timeSegments()).toEqual([{ from: 10, to: 40, color: '#2962ff44' }]);
+ expect(profile.priceSegments()).toEqual([{ from: 9, to: 31, color: '#2962ff44' }]);
+ });
+
+ it('возвращает axis labels', () => {
+ const { profile } = createProfile('fixedRange', true);
+
+ setFixedReady(profile);
+
+ expect(profile.timeLabel('start')).toMatchObject({
+ coordinate: 10,
+ text: 'date:10',
+ });
+
+ expect(profile.priceLabel('start')).toMatchObject({
+ coordinate: 9,
+ text: 'price:9',
+ });
+
+ expect(profile.priceLabel('end')).toMatchObject({
+ coordinate: 31,
+ text: 'price:31',
+ });
+ });
+
+ it('создаётся двумя pointer down', () => {
+ const { profile, container } = createProfile();
+
+ profile.pointerDown(createPointerEvent(container, 10, 10));
+ profile.pointerDown(createPointerEvent(container, 40, 30));
+
+ expect(profile.getState().mode).toBe('ready');
+ });
+
+ it('удаляет слишком маленький profile', () => {
+ const { profile, container, removeSelf } = createProfile();
+
+ profile.pointerDown(createPointerEvent(container, 10, 10));
+ profile.pointerDown(createPointerEvent(container, 14, 14));
+
+ expect(removeSelf).toHaveBeenCalledTimes(1);
+ });
+
+ it('перемещает start handle', () => {
+ const { profile, container } = createProfile('fixedRange', true);
+
+ setFixedReady(profile);
+
+ profile.pointerDown(createPointerEvent(container, 10, 9));
+ profile.pointerMove(createPointerEvent(container, 20, 15));
+ profile.pointerUp(createPointerEvent(container, 20, 15));
+
+ expect(profile.getState().startAnchor).toEqual({
+ time: 20,
+ price: 15,
+ });
+ });
+
+ it('выбирает unselected profile', () => {
+ const { profile, container, interaction } = createProfile();
+
+ setFixedReady(profile);
+
+ profile.pointerDown(createPointerEvent(container, 25, 20));
+
+ expect(interaction.select).toHaveBeenCalledTimes(1);
+ });
+
+ it('возвращает pointer/grab в зависимости от selection', () => {
+ const unselected = createProfile();
+ setFixedReady(unselected.profile);
+
+ expect(unselected.profile.hovered(25, 20)?.cursorStyle).toBe('pointer');
+
+ const selected = createProfile('fixedRange', true);
+ setFixedReady(selected.profile);
+
+ expect(selected.profile.hovered(25, 20)?.cursorStyle).toBe('grab');
+ });
+});
+
+describe('VolumeProfile visibleRange', () => {
+ it('сразу находится в ready', () => {
+ const { profile } = createProfile('visibleRange');
+
+ expect(profile.getState().mode).toBe('ready');
+ expect(profile.isCreationPending()).toBe(false);
+ });
+
+ it('отображается в Object Tree', () => {
+ const { profile } = createProfile('visibleRange');
+
+ expect(profile.shouldShowInObjectTree()).toBe(true);
+ });
+
+ it('не имеет axis views', () => {
+ const { profile } = createProfile('visibleRange');
+
+ expect(profile.timeAxisPaneViews()).toEqual([]);
+ expect(profile.priceAxisPaneViews()).toEqual([]);
+ expect(profile.timeAxisViews()).toEqual([]);
+ expect(profile.priceAxisViews()).toEqual([]);
+ });
+
+ it('clamp visibleRangeStartRatio до 0.95', () => {
+ const { profile } = createProfile('visibleRange');
+
+ profile.setState({ visibleRangeStartRatio: 2 });
+
+ expect(profile.getState().visibleRangeStartRatio).toBe(0.95);
+ });
+
+ it('меняет левую границу через ratio', () => {
+ const { profile } = createProfile('visibleRange');
+
+ profile.setState({ visibleRangeStartRatio: 0.5 });
+
+ expect(profile.getRenderData()).toMatchObject({
+ left: 200,
+ right: 400,
+ width: 200,
+ profileKind: 'visibleRange',
+ });
+ });
+
+ it('имеет только POC handle', () => {
+ const { profile, setSelected } = createProfile('visibleRange');
+
+ setSelected(true);
+
+ const handles = profile.handles();
+
+ expect(handles).toHaveLength(1);
+ expect(handles[0]).toMatchObject({
+ id: 'poc',
+ x: 0,
+ shape: 'circle',
+ size: 10,
+ borderWidth: 2,
+ });
+ });
+
+ it('POC handle имеет ew-resize cursor', () => {
+ const { profile, setSelected } = createProfile('visibleRange');
+
+ setSelected(true);
+
+ const [handle] = profile.handles();
+
+ expect(profile.hovered(handle.x, handle.y)?.cursorStyle).toBe('ew-resize');
+ });
+
+ it('перемещает начало visible profile через POC', () => {
+ const { profile, container, setSelected } = createProfile('visibleRange');
+
+ setSelected(true);
+
+ const [handle] = profile.handles();
+
+ profile.pointerDown(createPointerEvent(container, handle.x, handle.y));
+ profile.pointerMove(createPointerEvent(container, 300, handle.y));
+
+ expect(profile.getState().visibleRangeStartRatio).toBe(0.75);
+ });
+
+ it('подписывается и отписывается от visible range', () => {
+ const { profile, timeScale } = createProfile('visibleRange');
+
+ expect(timeScale.subscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
+
+ profile.destroy();
+
+ expect(timeScale.unsubscribeVisibleLogicalRangeChange).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('VolumeProfilePaneRenderer', () => {
+ it('не рисует без render data', () => {
+ const { target, useBitmapCoordinateSpace } = createCanvasTarget();
+
+ new VolumeProfilePaneRenderer({
+ getRenderData: () => null,
+ } as unknown as VolumeProfile).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('рисует background, rows и POC', () => {
+ const data: VolumeProfileRenderData = {
+ startPoint: { x: 10, y: 10 },
+ endPoint: { x: 100, y: 100 },
+ left: 10,
+ right: 100,
+ top: 10,
+ bottom: 100,
+ width: 90,
+ height: 90,
+ profileKind: 'fixedRange',
+ rows: [
+ {
+ top: 10,
+ height: 10,
+ buyWidth: 30,
+ sellWidth: 20,
+ },
+ ],
+ pocY: 50,
+ areaFillColor: '#111',
+ buyFillColor: '#0a0',
+ sellFillColor: '#a00',
+ pocLineColor: '#fa0',
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new VolumeProfilePaneRenderer({
+ getRenderData: () => data,
+ } as unknown as VolumeProfile).draw(target);
+
+ expect(context.fillRect).toHaveBeenCalledTimes(3);
+ expect(context.stroke).toHaveBeenCalledTimes(1);
+ });
+
+ it('рисует visibleRange справа налево и POC на всю ширину canvas', () => {
+ const data: VolumeProfileRenderData = {
+ startPoint: { x: 200, y: 10 },
+ endPoint: { x: 400, y: 100 },
+ left: 200,
+ right: 400,
+ top: 10,
+ bottom: 100,
+ width: 200,
+ height: 90,
+ profileKind: 'visibleRange',
+ rows: [{ top: 10, height: 10, buyWidth: 30, sellWidth: 20 }],
+ pocY: 50,
+ areaFillColor: '#111',
+ buyFillColor: '#0a0',
+ sellFillColor: '#a00',
+ pocLineColor: '#fa0',
+ };
+
+ const { target, context } = createCanvasTarget();
+
+ new VolumeProfilePaneRenderer({
+ getRenderData: () => data,
+ } as unknown as VolumeProfile).draw(target);
+
+ expect(context.moveTo).toHaveBeenCalledWith(0, 100);
+ expect(context.lineTo).toHaveBeenCalledWith(800, 100);
+ });
+});
+
+describe('VolumeProfilePaneView', () => {
+ it('возвращает renderer и top', () => {
+ const view = new VolumeProfilePaneView({} as VolumeProfile);
+
+ expect(view.renderer()).toBeInstanceOf(VolumeProfilePaneRenderer);
+ expect(view.zOrder()).toBe('top');
+ });
+});
+
+describe('VolumeProfile settings', () => {
+ it('создаёт defaults', () => {
+ expect(createDefaultSettings()).toEqual({
+ areaFillColor: '#11111122',
+ buyFillColor: '#00aa00',
+ sellFillColor: '#aa0000',
+ pocLineColor: '#ffaa00',
+ });
+ });
+
+ it('имеет один style tab', () => {
+ expect(getVolumeProfileSettingsTabs(createDefaultSettings()).map(({ key }) => key)).toEqual(['style']);
+ });
+});
diff --git a/src/core/Drawings/__tests__/handles.test.ts b/src/core/Drawings/__tests__/handles.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8e2b36fe857d434ff5223b2e3dcbbe94056e241a
--- /dev/null
+++ b/src/core/Drawings/__tests__/handles.test.ts
@@ -0,0 +1,367 @@
+import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
+
+import type { DrawingHandle } from '@core/Drawings/handles';
+import type { SeriesApi } from '@core/Drawings/types';
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+import type {
+ IChartApi,
+ IPrimitivePaneRenderer,
+ IPrimitivePaneView,
+ SeriesAttachedParameter,
+ Time,
+} from 'lightweight-charts';
+
+jest.mock('@src/theme', () => ({
+ getThemeStore: () => ({
+ colors: {
+ chartBackground: '#101010',
+ chartLineColor: '#2962ff',
+ },
+ }),
+}));
+
+function createEnvironment(paneIndex = 0, paneCount = 1, paneHeight = 100) {
+ const pane = {
+ paneIndex: jest.fn(() => paneIndex),
+ getHeight: jest.fn(() => paneHeight),
+ };
+
+ const panes = Array.from({ length: paneCount }, (_, index) => {
+ return index === paneIndex ? pane : {};
+ });
+
+ const chart = {
+ panes: jest.fn(() => panes),
+ } as unknown as IChartApi;
+
+ const series = {
+ getPane: jest.fn(() => pane),
+ } as unknown as SeriesApi;
+
+ return { chart, series, pane };
+}
+
+function attach<TId extends string>(primitive: DrawingHandlesPrimitive<TId>, chart: IChartApi, series: SeriesApi) {
+ primitive.attached({
+ chart,
+ series,
+ requestUpdate: jest.fn(),
+ } as unknown as SeriesAttachedParameter<Time>);
+}
+
+function createTarget(horizontalPixelRatio = 2, verticalPixelRatio = 2) {
+ const context = {
+ save: jest.fn(),
+ restore: jest.fn(),
+ beginPath: jest.fn(),
+ arc: jest.fn(),
+ moveTo: jest.fn(),
+ arcTo: jest.fn(),
+ closePath: jest.fn(),
+ fill: jest.fn(),
+ stroke: jest.fn(),
+ fillStyle: '',
+ strokeStyle: '',
+ lineWidth: 0,
+ };
+
+ const useBitmapCoordinateSpace = jest.fn(
+ (
+ callback: (scope: {
+ context: CanvasRenderingContext2D;
+ horizontalPixelRatio: number;
+ verticalPixelRatio: number;
+ bitmapSize: { width: number; height: number };
+ }) => void,
+ ) => {
+ callback({
+ context: context as unknown as CanvasRenderingContext2D,
+ horizontalPixelRatio,
+ verticalPixelRatio,
+ bitmapSize: {
+ width: 400,
+ height: 100,
+ },
+ });
+ },
+ );
+
+ return {
+ context,
+ target: { useBitmapCoordinateSpace } as unknown as CanvasRenderingTarget2D,
+ useBitmapCoordinateSpace,
+ };
+}
+
+function getView(views: readonly IPrimitivePaneView[]): IPrimitivePaneView {
+ const [view] = views;
+
+ if (!view) {
+ throw new Error('Primitive pane view is not defined');
+ }
+
+ return view;
+}
+
+function getRenderer(views: readonly IPrimitivePaneView[]): IPrimitivePaneRenderer {
+ const renderer = getView(views).renderer();
+
+ if (!renderer) {
+ throw new Error('Primitive pane renderer is not defined');
+ }
+
+ return renderer;
+}
+
+describe('DrawingHandlesPrimitive', () => {
+ it('должен возвращать top zOrder для pane', () => {
+ const primitive = new DrawingHandlesPrimitive(() => []);
+
+ expect(getView(primitive.paneViews()).zOrder?.()).toBe('top');
+ });
+
+ it('должен возвращать top zOrder для time axis', () => {
+ const primitive = new DrawingHandlesPrimitive(() => []);
+
+ expect(getView(primitive.timeAxisPaneViews()).zOrder?.()).toBe('top');
+ });
+
+ it('должен находить handle в зоне hit tolerance', () => {
+ const handle: DrawingHandle<'main'> = {
+ id: 'main',
+ x: 100,
+ y: 50,
+ };
+
+ const primitive = new DrawingHandlesPrimitive(() => [handle]);
+
+ expect(primitive.findHandle({ x: 108, y: 58 })).toBe(handle);
+ });
+
+ it('не должен находить handle за пределами hit tolerance', () => {
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 100,
+ y: 50,
+ },
+ ]);
+
+ expect(primitive.findHandle({ x: 109, y: 50 })).toBeNull();
+ });
+
+ it('должен учитывать размер handle при вычислении hit area', () => {
+ const handle: DrawingHandle<'main'> = {
+ id: 'main',
+ x: 100,
+ y: 50,
+ size: 24,
+ };
+
+ const primitive = new DrawingHandlesPrimitive(() => [handle]);
+
+ expect(primitive.findHandle({ x: 112, y: 50 })).toBe(handle);
+ expect(primitive.findHandle({ x: 113, y: 50 })).toBeNull();
+ });
+
+ it('должен отдавать приоритет последнему handle при пересечении', () => {
+ const handles: DrawingHandle<'first' | 'second'>[] = [
+ {
+ id: 'first',
+ x: 100,
+ y: 50,
+ },
+ {
+ id: 'second',
+ x: 100,
+ y: 50,
+ },
+ ];
+
+ const primitive = new DrawingHandlesPrimitive(() => handles);
+
+ expect(primitive.findHandle({ x: 100, y: 50 })?.id).toBe('second');
+ });
+
+ it('должен определять handle, пересекающий time axis', () => {
+ const { chart, series } = createEnvironment(0, 1, 100);
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 50,
+ y: 100,
+ },
+ ]);
+
+ attach(primitive, chart, series);
+
+ expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(true);
+ });
+
+ it('не должен считать точку внутри pane попаданием по time axis', () => {
+ const { chart, series } = createEnvironment(0, 1, 100);
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 50,
+ y: 100,
+ },
+ ]);
+
+ attach(primitive, chart, series);
+
+ expect(primitive.isTimeAxisHit({ x: 50, y: 99 })).toBe(false);
+ });
+
+ it('не должен работать с time axis для pane, который не является последним', () => {
+ const { chart, series } = createEnvironment(0, 2, 100);
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 50,
+ y: 100,
+ },
+ ]);
+
+ attach(primitive, chart, series);
+
+ expect(primitive.isTimeAxisHit({ x: 50, y: 104 })).toBe(false);
+ });
+
+ it('не должен определять time axis hit для handle выше границы pane', () => {
+ const { chart, series } = createEnvironment(0, 1, 100);
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 50,
+ y: 80,
+ },
+ ]);
+
+ attach(primitive, chart, series);
+
+ expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
+ });
+
+ it('должен рисовать circle по умолчанию', () => {
+ const { context, target } = createTarget();
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 100,
+ y: 50,
+ },
+ ]);
+
+ getRenderer(primitive.paneViews()).draw(target);
+
+ expect(context.arc).toHaveBeenCalledWith(200, 100, 10, 0, Math.PI * 2);
+ expect(context.fill).toHaveBeenCalledTimes(1);
+ expect(context.stroke).toHaveBeenCalledTimes(1);
+ expect(context.fillStyle).toBe('#101010');
+ expect(context.strokeStyle).toBe('#2962ff');
+ expect(context.lineWidth).toBe(4);
+ });
+
+ it('должен рисовать rounded handle', () => {
+ const { context, target } = createTarget();
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 100,
+ y: 50,
+ shape: 'rounded',
+ },
+ ]);
+
+ getRenderer(primitive.paneViews()).draw(target);
+
+ expect(context.arc).not.toHaveBeenCalled();
+ expect(context.arcTo).toHaveBeenCalled();
+ expect(context.closePath).toHaveBeenCalled();
+ expect(context.fill).toHaveBeenCalledTimes(1);
+ expect(context.stroke).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен использовать custom strokeColor и borderWidth', () => {
+ const { context, target } = createTarget();
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 100,
+ y: 50,
+ strokeColor: '#ff0000',
+ borderWidth: 3,
+ },
+ ]);
+
+ getRenderer(primitive.paneViews()).draw(target);
+
+ expect(context.strokeStyle).toBe('#ff0000');
+ expect(context.lineWidth).toBe(6);
+ });
+
+ it('не должен вызывать canvas при отсутствии handles', () => {
+ const { target, useBitmapCoordinateSpace } = createTarget();
+ const primitive = new DrawingHandlesPrimitive(() => []);
+
+ getRenderer(primitive.paneViews()).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен рисовать пересекающую границу часть handle на time axis', () => {
+ const { chart, series } = createEnvironment(0, 1, 100);
+ const { context, target } = createTarget();
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 50,
+ y: 100,
+ },
+ ]);
+
+ attach(primitive, chart, series);
+
+ getRenderer(primitive.timeAxisPaneViews()).draw(target);
+
+ expect(context.arc).toHaveBeenCalled();
+ });
+
+ it('не должен рисовать handle на time axis для непоследнего pane', () => {
+ const { chart, series } = createEnvironment(0, 2, 100);
+ const { target, useBitmapCoordinateSpace } = createTarget();
+
+ const primitive = new DrawingHandlesPrimitive(() => [
+ {
+ id: 'main',
+ x: 50,
+ y: 100,
+ },
+ ]);
+
+ attach(primitive, chart, series);
+
+ getRenderer(primitive.timeAxisPaneViews()).draw(target);
+
+ expect(useBitmapCoordinateSpace).not.toHaveBeenCalled();
+ });
+
+ it('должен очищать chart и series после detached', () => {
+ const { chart, series } = createEnvironment();
+ const primitive = new DrawingHandlesPrimitive(() => []);
+
+ attach(primitive, chart, series);
+ primitive.detached();
+
+ expect(primitive.isTimeAxisHit({ x: 50, y: 101 })).toBe(false);
+ });
+});
diff --git a/src/core/Drawings/__tests__/helpers.test.ts b/src/core/Drawings/__tests__/helpers.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a91cba440acff5e0f18eb1244729c63b0fbbcaee
--- /dev/null
+++ b/src/core/Drawings/__tests__/helpers.test.ts
@@ -0,0 +1,354 @@
+import {
+ clampPointToContainer,
+ getAnchorFromPoint,
+ getContainerSize,
+ getPointerPoint,
+ getPriceDelta,
+ getPriceFromYCoordinate,
+ getPriceRangeInContainer,
+ getRawPointerPoint,
+ getTimeFromXCoordinate,
+ getXCoordinateFromTime,
+ getYCoordinateFromPrice,
+ isNearPoint,
+ isPointInBounds,
+ normalizeBounds,
+ shiftTimeByPixels,
+} from '@core/Drawings/helpers';
+
+import type { SeriesApi } from '@core/Drawings/types';
+import type { Coordinate, IChartApi, Logical, Time } from 'lightweight-charts';
+
+function createContainer(width = 400, height = 300, left = 10, top = 20) {
+ const container = document.createElement('div');
+
+ jest.spyOn(container, 'getBoundingClientRect').mockReturnValue({
+ x: left,
+ y: top,
+ left,
+ top,
+ right: left + width,
+ bottom: top + height,
+ width,
+ height,
+ toJSON: jest.fn(),
+ });
+
+ return container;
+}
+
+function createChart() {
+ const coordinateToTime = jest.fn((coordinate: Coordinate): Time | null => Number(coordinate) as Time);
+
+ 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 = {
+ timeScale: jest.fn(() => timeScale),
+ } as unknown as IChartApi;
+
+ return {
+ chart,
+ timeScale,
+ };
+}
+
+function createSeries(data: readonly { time: Time }[] = []) {
+ const coordinateToPrice = jest.fn((coordinate: Coordinate): number | null => Number(coordinate));
+
+ const priceToCoordinate = jest.fn((price: number): Coordinate | null => price as Coordinate);
+
+ const dataMock = jest.fn(() => data);
+
+ const series = {
+ coordinateToPrice,
+ priceToCoordinate,
+ data: dataMock,
+ } as unknown as SeriesApi;
+
+ return {
+ series,
+ coordinateToPrice,
+ priceToCoordinate,
+ dataMock,
+ };
+}
+
+describe('Drawing helpers', () => {
+ it('должен получать цену из y coordinate', () => {
+ const { series, coordinateToPrice } = createSeries();
+
+ expect(getPriceFromYCoordinate(series, 50)).toBe(50);
+ expect(coordinateToPrice).toHaveBeenCalledWith(50);
+ });
+
+ it('должен получать y coordinate из цены', () => {
+ const { series, priceToCoordinate } = createSeries();
+
+ expect(getYCoordinateFromPrice(series, 100)).toBe(100);
+ expect(priceToCoordinate).toHaveBeenCalledWith(100);
+ });
+
+ it('должен получать время из x coordinate', () => {
+ const { chart, timeScale } = createChart();
+
+ expect(getTimeFromXCoordinate(chart, 120)).toBe(120);
+ expect(timeScale.coordinateToTime).toHaveBeenCalledWith(120);
+ });
+
+ it('должен возвращать прямую координату времени', () => {
+ const { chart } = createChart();
+
+ expect(getXCoordinateFromTime(chart, 100 as Time)).toBe(100);
+ });
+
+ it('должен возвращать null без series, если координата времени отсутствует', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ expect(getXCoordinateFromTime(chart, 100 as Time)).toBeNull();
+ });
+
+ it('должен проецировать отсутствующее время через ближайшую свечу', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }, { time: 30 as Time }]);
+
+ expect(getXCoordinateFromTime(chart, 24 as Time, series)).toBe(10);
+ expect(timeScale.logicalToCoordinate).toHaveBeenCalledWith(1);
+ });
+
+ it('должен выбирать предыдущую свечу при одинаковом расстоянии', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }, { time: 30 as Time }]);
+
+ expect(getXCoordinateFromTime(chart, 25 as Time, series)).toBe(10);
+ });
+
+ it('должен использовать первую свечу для времени до диапазона', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+
+ expect(getXCoordinateFromTime(chart, 5 as Time, series)).toBe(0);
+ });
+
+ it('должен использовать последнюю свечу для времени после диапазона', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries([{ time: 10 as Time }, { time: 20 as Time }]);
+
+ expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBe(10);
+ });
+
+ it('должен возвращать null при отсутствии данных series', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries();
+
+ expect(getXCoordinateFromTime(chart, 30 as Time, series)).toBeNull();
+ });
+
+ it('должен возвращать null при невозможности спроецировать logical coordinate', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+ timeScale.logicalToCoordinate.mockReturnValue(null);
+
+ const { series } = createSeries([{ time: 10 as Time }]);
+
+ expect(getXCoordinateFromTime(chart, 10 as Time, series)).toBeNull();
+ });
+
+ it('должен получать размеры container', () => {
+ const container = createContainer(500, 250);
+
+ expect(getContainerSize(container)).toEqual({
+ width: 500,
+ height: 250,
+ });
+ });
+
+ it('должен ограничивать point границами container', () => {
+ const container = createContainer(400, 300);
+
+ expect(clampPointToContainer({ x: -20, y: 350 }, container)).toEqual({
+ x: 0,
+ y: 300,
+ });
+ });
+
+ it('должен получать raw pointer point относительно container', () => {
+ const container = createContainer(400, 300, 10, 20);
+
+ const event = {
+ clientX: 110,
+ clientY: 70,
+ } as unknown as MouseEvent;
+
+ expect(getRawPointerPoint(container, event)).toEqual({
+ x: 100,
+ y: 50,
+ });
+ });
+
+ it('должен ограничивать pointer point границами container', () => {
+ const container = createContainer(400, 300, 10, 20);
+
+ const event = {
+ clientX: 500,
+ clientY: -10,
+ } as unknown as MouseEvent;
+
+ expect(getPointerPoint(container, event)).toEqual({
+ x: 400,
+ y: 0,
+ });
+ });
+
+ 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('должен учитывать границу tolerance при проверке близости', () => {
+ expect(isNearPoint({ x: 108, y: 108 }, 100, 100, 8)).toBe(true);
+ });
+
+ 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);
+ });
+
+ it('должен нормализовать и ограничивать bounds', () => {
+ const container = createContainer(400, 300);
+
+ expect(normalizeBounds(450, -20, 350, -50, container)).toEqual({
+ left: 0,
+ right: 400,
+ top: 0,
+ bottom: 300,
+ });
+ });
+
+ it('должен смещать время на количество пикселей', () => {
+ const { chart } = createChart();
+
+ expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBe(120);
+ });
+
+ it('должен возвращать null при отсутствии исходной координаты времени', () => {
+ const { chart, timeScale } = createChart();
+
+ timeScale.timeToCoordinate.mockReturnValue(null);
+
+ expect(shiftTimeByPixels(chart, 100 as Time, 20)).toBeNull();
+ });
+
+ it('должен вычислять price delta', () => {
+ const { series } = createSeries();
+
+ expect(getPriceDelta(series, 20, 70)).toBe(50);
+ });
+
+ it('должен возвращать 0 price delta при отсутствии начальной цены', () => {
+ const { series, coordinateToPrice } = createSeries();
+
+ coordinateToPrice.mockReturnValueOnce(null);
+
+ 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('должен вычислять диапазон цены в container', () => {
+ const container = createContainer(400, 300);
+ const { series, coordinateToPrice } = createSeries();
+
+ coordinateToPrice.mockReturnValueOnce(150).mockReturnValueOnce(50);
+
+ 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('должен создавать anchor из point', () => {
+ const { chart } = createChart();
+ const { series } = createSeries();
+
+ 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/__tests__/shortcuts.test.ts b/src/core/Drawings/__tests__/shortcuts.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ec941c4692765df0357027330e3fba7ce5c4bce5
--- /dev/null
+++ b/src/core/Drawings/__tests__/shortcuts.test.ts
@@ -0,0 +1,109 @@
+import {
+ DRAWING_KEYBOARD_SHORTCUTS,
+ DRAWING_POINTER_SHORTCUTS,
+ findDrawingPointerShortcut,
+} from '@core/Drawings/shortcuts';
+
+import { DrawingsNames } from '@src/constants';
+import { Keys } from '@src/core/Hotkeys';
+
+import type { MouseEventParams } from 'lightweight-charts';
+
+function createEvent({
+ shiftKey = false,
+ altKey = false,
+ ctrlKey = false,
+ metaKey = false,
+}: {
+ shiftKey?: boolean;
+ altKey?: boolean;
+ ctrlKey?: boolean;
+ metaKey?: boolean;
+}): MouseEventParams {
+ return {
+ sourceEvent: {
+ shiftKey,
+ altKey,
+ ctrlKey,
+ metaKey,
+ },
+ } as unknown as MouseEventParams;
+}
+
+describe('Drawing shortcuts', () => {
+ it('должен содержать keyboard shortcut для trend line', () => {
+ expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+ drawingName: DrawingsNames.trendLine,
+ keys: [Keys.alt, Keys.t],
+ });
+ });
+
+ it('должен содержать keyboard shortcut для horizontal line', () => {
+ expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+ drawingName: DrawingsNames.horizontalLine,
+ keys: [Keys.alt, Keys.h],
+ });
+ });
+
+ it('должен содержать keyboard shortcut для vertical line', () => {
+ expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+ drawingName: DrawingsNames.verticalLine,
+ keys: [Keys.alt, Keys.v],
+ });
+ });
+
+ it('должен содержать keyboard shortcut для fibonacci', () => {
+ expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+ drawingName: DrawingsNames.fibonacciRetracement,
+ keys: [Keys.alt, Keys.f],
+ });
+ });
+
+ it('должен содержать keyboard shortcut для rectangle', () => {
+ expect(DRAWING_KEYBOARD_SHORTCUTS).toContainEqual({
+ drawingName: DrawingsNames.rectangle,
+ keys: [Keys.alt, Keys.shift, Keys.r],
+ });
+ });
+
+ it('должен содержать pointer shortcut для ruler', () => {
+ expect(DRAWING_POINTER_SHORTCUTS).toEqual([
+ {
+ drawingName: DrawingsNames.ruler,
+ modifiers: [Keys.shift],
+ },
+ ]);
+ });
+
+ it('должен находить ruler при нажатом Shift', () => {
+ expect(
+ findDrawingPointerShortcut(
+ createEvent({
+ shiftKey: true,
+ }),
+ ),
+ ).toEqual({
+ drawingName: DrawingsNames.ruler,
+ modifiers: [Keys.shift],
+ });
+ });
+
+ it('не должен находить shortcut без Shift', () => {
+ expect(findDrawingPointerShortcut(createEvent({}))).toBeUndefined();
+ });
+
+ it('не должен находить Shift shortcut при дополнительном Alt', () => {
+ expect(
+ findDrawingPointerShortcut(
+ createEvent({
+ shiftKey: true,
+ altKey: true,
+ }),
+ ),
+ ).toBeUndefined();
+ });
+
+ it('не должен находить shortcut без sourceEvent', () => {
+ expect(findDrawingPointerShortcut({} as MouseEventParams)).toBeUndefined();
+ });
+});
diff --git a/src/core/Drawings/__tests__/utils.test.ts b/src/core/Drawings/__tests__/utils.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..effd68785ecad61b43eb829a31abf8fd0e59e6b6
--- /dev/null
+++ b/src/core/Drawings/__tests__/utils.test.ts
@@ -0,0 +1,54 @@
+import { getOrderedSideValue, updateViews } from '@core/Drawings/utils';
+
+describe('Drawing utils', () => {
+ describe('updateViews', () => {
+ it('должен обновлять все views', () => {
+ const first = {
+ update: jest.fn(),
+ };
+
+ const second = {
+ update: jest.fn(),
+ };
+
+ updateViews([first, second]);
+
+ expect(first.update).toHaveBeenCalledTimes(1);
+ expect(second.update).toHaveBeenCalledTimes(1);
+ });
+
+ it('должен корректно работать с пустым массивом', () => {
+ expect(() => updateViews([])).not.toThrow();
+ });
+ });
+
+ describe('getOrderedSideValue', () => {
+ it('должен возвращать null без startValue', () => {
+ expect(getOrderedSideValue(null, 'end', 10, 20, 'start')).toBeNull();
+ });
+
+ it('должен возвращать null без endValue', () => {
+ expect(getOrderedSideValue('start', null, 10, 20, 'end')).toBeNull();
+ });
+
+ it('должен использовать исходный порядок без координат', () => {
+ expect(getOrderedSideValue('A', 'B', null, 20, 'start')).toBe('A');
+ expect(getOrderedSideValue('A', 'B', 10, null, 'end')).toBe('B');
+ });
+
+ it('должен возвращать startValue для левой координаты', () => {
+ expect(getOrderedSideValue('A', 'B', 10, 20, 'start')).toBe('A');
+ expect(getOrderedSideValue('A', 'B', 10, 20, 'end')).toBe('B');
+ });
+
+ it('должен менять значения местами при обратном порядке координат', () => {
+ expect(getOrderedSideValue('A', 'B', 20, 10, 'start')).toBe('B');
+ expect(getOrderedSideValue('A', 'B', 20, 10, 'end')).toBe('A');
+ });
+
+ it('должен считать одинаковые координаты прямым порядком', () => {
+ expect(getOrderedSideValue('A', 'B', 10, 10, 'start')).toBe('A');
+ expect(getOrderedSideValue('A', 'B', 10, 10, 'end')).toBe('B');
+ });
+ });
+});
diff --git a/src/core/Drawings/axis/CustomPriceAxis.ts b/src/core/Drawings/axis/CustomPriceAxis.ts
index a3d932558861573d8f496665dfe456a1ee41b5fd..d13e343b76319566bdf68673660cd5cec62df483 100644
--- a/src/core/Drawings/axis/CustomPriceAxis.ts
+++ b/src/core/Drawings/axis/CustomPriceAxis.ts
@@ -10,6 +10,7 @@ import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
interface CustomPriceAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
+ zOrder?: PrimitivePaneViewZOrder;
}
interface CustomPriceAxisViewParams {
@@ -18,31 +19,27 @@ interface CustomPriceAxisViewParams {
}
export class CustomPriceAxisPaneView implements IPrimitivePaneView {
- private readonly getAxisSegments: () => AxisSegment[];
+ private readonly rendererInstance: CustomPriceAxisPaneRenderer;
+ private readonly paneZOrder: PrimitivePaneViewZOrder;
- constructor({ getAxisSegments }: CustomPriceAxisPaneViewParams) {
- this.getAxisSegments = getAxisSegments;
+ constructor({ getAxisSegments, zOrder = 'bottom' }: CustomPriceAxisPaneViewParams) {
+ this.rendererInstance = new CustomPriceAxisPaneRenderer(getAxisSegments);
+ this.paneZOrder = zOrder;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
- return new CustomPriceAxisPaneRenderer({
- getAxisSegments: this.getAxisSegments,
- });
+ return this.rendererInstance;
}
public zOrder(): PrimitivePaneViewZOrder {
- return 'bottom';
+ return this.paneZOrder;
}
}
class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
- private readonly getAxisSegments: () => AxisSegment[];
-
- constructor({ getAxisSegments }: CustomPriceAxisPaneViewParams) {
- this.getAxisSegments = getAxisSegments;
- }
+ constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const axisSegments = this.getAxisSegments();
diff --git a/src/core/Drawings/axis/CustomTimeAxis.ts b/src/core/Drawings/axis/CustomTimeAxis.ts
index b70ec9cd6520e60b09d51cc84d45c012b07b1da7..8cb6ce817cdb15964ac717823f430dc215400738 100644
--- a/src/core/Drawings/axis/CustomTimeAxis.ts
+++ b/src/core/Drawings/axis/CustomTimeAxis.ts
@@ -10,6 +10,7 @@ import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
interface CustomTimeAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
+ zOrder?: PrimitivePaneViewZOrder;
}
interface CustomTimeAxisViewParams {
@@ -18,31 +19,27 @@ interface CustomTimeAxisViewParams {
}
export class CustomTimeAxisPaneView implements IPrimitivePaneView {
- private readonly getAxisSegments: () => AxisSegment[];
+ private readonly rendererInstance: CustomTimeAxisPaneRenderer;
+ private readonly paneZOrder: PrimitivePaneViewZOrder;
- constructor({ getAxisSegments }: CustomTimeAxisPaneViewParams) {
- this.getAxisSegments = getAxisSegments;
+ constructor({ getAxisSegments, zOrder = 'bottom' }: CustomTimeAxisPaneViewParams) {
+ this.rendererInstance = new CustomTimeAxisPaneRenderer(getAxisSegments);
+ this.paneZOrder = zOrder;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
- return new CustomTimeAxisPaneRenderer({
- getAxisSegments: this.getAxisSegments,
- });
+ return this.rendererInstance;
}
public zOrder(): PrimitivePaneViewZOrder {
- return 'bottom';
+ return this.paneZOrder;
}
}
class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
- private readonly getAxisSegments: () => AxisSegment[];
-
- constructor({ getAxisSegments }: CustomTimeAxisPaneViewParams) {
- this.getAxisSegments = getAxisSegments;
- }
+ constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const axisSegments = this.getAxisSegments();
diff --git a/src/core/Drawings/axisLine/axisLine.ts b/src/core/Drawings/axisLine/axisLine.ts
index ba1e2969f04a484ecf89ea225d41364ee89c9fb5..11575873900784b788c923c2d4b3c9fc25e88011 100644
--- a/src/core/Drawings/axisLine/axisLine.ts
+++ b/src/core/Drawings/axisLine/axisLine.ts
@@ -1,14 +1,10 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
-
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
} from '@core/Drawings/helpers';
-import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
@@ -27,13 +23,16 @@ import {
getAxisLineSettingsTabs,
} from './settings';
-import type { AxisLabel, Point } from '@core/Drawings/types';
+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';
export type AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
+type AxisLineHandleKey = 'main';
interface AxisLineParams extends BaseDrawingParams {
direction: AxisLineDirection;
@@ -50,22 +49,17 @@ interface AxisLineState {
export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
direction: AxisLineDirection;
coordinate: number;
- handle: Point;
- showHandle: boolean;
}
-const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
-export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISeriesDrawing {
- private removeSelf?: () => void;
- private openSettings?: () => void;
+export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandleKey> implements ISeriesDrawing {
+ private readonly openSettings?: () => void;
protected settings: AxisLineSettings = createDefaultSettings();
-
protected mode: AxisLineMode = 'idle';
- private direction: AxisLineDirection;
+ private readonly direction: AxisLineDirection;
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
@@ -87,14 +81,12 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
container,
interaction,
formatObservable,
- removeSelf,
openSettings,
initialEvent,
}: AxisLineParams) {
super({ chart, series, container, interaction });
this.direction = direction;
- this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new AxisLinePaneView(this);
@@ -208,17 +200,32 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
return null;
}
- const { width, height } = this.container.getBoundingClientRect();
-
return {
direction: this.direction,
coordinate,
- handle: this.direction === 'vertical' ? { x: coordinate, y: height / 2 } : { x: width / 2, y: coordinate },
- showHandle: this.shouldShowHandles(),
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
+ const coordinate = this.getCoordinate();
+
+ if (coordinate === null) {
+ return [];
+ }
+
+ const { width, height } = this.container.getBoundingClientRect();
+
+ return [
+ {
+ id: 'main',
+ x: this.direction === 'vertical' ? coordinate : width / 2,
+ y: this.direction === 'vertical' ? height / 2 : coordinate,
+ shape: 'rounded',
+ },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
@@ -231,7 +238,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
return null;
}
- if (this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE)) {
+ if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
@@ -305,8 +312,8 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
return [];
}
- protected getGeometry(): void {
- console.log('stub');
+ protected getGeometry(): null {
+ return null;
}
protected handleDoubleClick = (event: MouseEvent): void => {
@@ -320,13 +327,8 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
return;
}
- const rect = this.container.getBoundingClientRect();
- const point = {
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- };
-
- const isNearHandle = this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
+ const point = this.getEventPoint(event as PointerEvent);
+ const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!isNearHandle && !isNearLine) {
@@ -364,7 +366,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
return;
}
- const isNearHandle = this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
+ const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!this.isSelected()) {
@@ -389,6 +391,7 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
+
this.hideCrosshair();
this.render();
};
@@ -411,9 +414,9 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
}
this.mode = 'ready';
- this.resolveReady?.();
-
this.dragPointerId = null;
+
+ this.resolveReady?.();
this.showCrosshair();
this.render();
};
@@ -421,8 +424,8 @@ export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISe
private startDrawing(point: Point): void {
this.updateLine(point);
this.mode = 'ready';
- this.resolveReady?.();
+ this.resolveReady?.();
this.render();
}
diff --git a/src/core/Drawings/axisLine/paneRenderer.ts b/src/core/Drawings/axisLine/paneRenderer.ts
index cbaeb765d311775618c7b0c44ed0b7d7e5b64f71..9ad4db72f84160b86578b04b311564ec59c07660 100644
--- a/src/core/Drawings/axisLine/paneRenderer.ts
+++ b/src/core/Drawings/axisLine/paneRenderer.ts
@@ -1,14 +1,10 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import { AxisLine } from './axisLine';
const UI = {
lineWidth: 2,
- handleSize: 10,
- handleBorderWidth: 1,
textLineHeightMultiplier: 1.2,
textOffset: 4,
};
@@ -27,8 +23,6 @@ export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
return;
}
- const { colors } = getThemeStore();
-
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
@@ -63,18 +57,6 @@ export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
verticalPixelRatio,
});
- if (data.showHandle) {
- drawHandle(
- context,
- data.handle.x * horizontalPixelRatio,
- data.handle.y * verticalPixelRatio,
- horizontalPixelRatio,
- verticalPixelRatio,
- colors.chartLineColor,
- colors.chartBackground,
- );
- }
-
context.restore();
});
}
@@ -145,30 +127,3 @@ function drawTextAlongAxisLine(
context.restore();
}
-
-function drawHandle(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- horizontalPixelRatio: number,
- verticalPixelRatio: number,
- strokeColor: string,
- fillColor: string,
-): void {
- const width = UI.handleSize * horizontalPixelRatio;
- const height = UI.handleSize * verticalPixelRatio;
- const left = x - width / 2;
- const top = y - height / 2;
-
- context.save();
- context.fillStyle = fillColor;
- context.strokeStyle = strokeColor;
- context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
-
- context.beginPath();
- context.rect(left, top, width, height);
- context.fill();
- context.stroke();
-
- context.restore();
-}
diff --git a/src/core/Drawings/diapson/diapson.ts b/src/core/Drawings/diapson/diapson.ts
index 2d703cb91f6ea78d9e35aff3860a13abe7b45c4c..d51ed2a2faa00c0d99b6e2623b921ac1abbd295a 100644
--- a/src/core/Drawings/diapson/diapson.ts
+++ b/src/core/Drawings/diapson/diapson.ts
@@ -12,7 +12,6 @@ import {
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
@@ -34,6 +33,7 @@ import {
getDiapsonSettingsTabs,
} 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';
@@ -43,6 +43,7 @@ export type DiapsonRangeMode = 'date' | 'price';
type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
+type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
@@ -77,7 +78,6 @@ interface DiapsonGeometry {
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
- showHandles: boolean;
labelLines: string[];
}
@@ -93,12 +93,11 @@ interface PriceMetrics {
steps: number;
}
-const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
-export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeriesDrawing {
+export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
@@ -309,12 +308,36 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
...geometry,
rangeMode: this.rangeMode,
showFill: true,
- showHandles: this.shouldShowHandles(),
labelLines: this.getLabelLines(),
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'end',
+ ...geometry.endPoint,
+ shape: 'circle',
+ size: 12,
+ borderWidth: 2,
+ },
+ {
+ id: 'start',
+ ...geometry.startPoint,
+ shape: 'circle',
+ size: 12,
+ borderWidth: 2,
+ },
+ ];
+ }
+
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
@@ -424,7 +447,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
};
}
- const handleTarget = this.getHandleTarget(point);
+ const handleTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
if (handleTarget) {
return {
@@ -452,7 +475,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
const point = this.getEventPoint(event as PointerEvent);
- if (!this.getHandleTarget(point) && !this.containsPoint(point)) {
+ if (!this.getDrawingHandleAtPoint(point) && !this.containsPoint(point)) {
return;
}
@@ -638,17 +661,7 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
}
private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
- const handleTarget = this.getHandleTarget(point);
-
- if (handleTarget) {
- return handleTarget;
- }
-
- if (this.containsPoint(point)) {
- return 'body';
- }
-
- return null;
+ return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
@@ -1061,24 +1074,6 @@ export class Diapson extends SeriesDrawingBase<DiapsonSettings> implements ISeri
return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
}
- private getHandleTarget(point: Point): Exclude<DiapsonHandle, 'body' | null> | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
- return 'start';
- }
-
- if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
- return 'end';
- }
-
- return null;
- }
-
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
diff --git a/src/core/Drawings/diapson/paneRenderer.ts b/src/core/Drawings/diapson/paneRenderer.ts
index 2246f5b293972e63334513d17fa7e49ff95dc048..e6e830eec25153e02ad63f5131ad9364d3cd4f5e 100644
--- a/src/core/Drawings/diapson/paneRenderer.ts
+++ b/src/core/Drawings/diapson/paneRenderer.ts
@@ -1,16 +1,12 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import { Diapson, DiapsonRenderData } from './diapson';
import type { DiapsonTextStyle } from './settings';
const UI = {
lineWidth: 1,
- handleRadius: 6,
- handleBorderWidth: 2,
labelPadding: 4,
labelRadius: 2,
labelBottomOffset: 8,
@@ -32,8 +28,6 @@ export class DiapsonPaneRenderer implements IPrimitivePaneRenderer {
return;
}
- const { colors } = getThemeStore();
-
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
@@ -81,28 +75,6 @@ export class DiapsonPaneRenderer implements IPrimitivePaneRenderer {
);
}
- if (data.showHandles) {
- drawHandle(
- context,
- data.startPoint.x * horizontalPixelRatio,
- data.startPoint.y * verticalPixelRatio,
- horizontalPixelRatio,
- verticalPixelRatio,
- colors.chartBackground,
- colors.chartLineColor,
- );
-
- drawHandle(
- context,
- data.endPoint.x * horizontalPixelRatio,
- data.endPoint.y * verticalPixelRatio,
- horizontalPixelRatio,
- verticalPixelRatio,
- colors.chartBackground,
- colors.chartLineColor,
- );
- }
-
if (data.labelLines.length > 0) {
drawLabel(context, data, horizontalPixelRatio, verticalPixelRatio, data);
}
@@ -190,32 +162,6 @@ function drawVerticalArrow(
context.stroke();
}
-function drawHandle(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- horizontalPixelRatio: number,
- verticalPixelRatio: number,
- fillStyle: string,
- strokeStyle: string,
-): void {
- const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
- const radius = UI.handleRadius * pixelRatio;
- const lineWidth = UI.handleBorderWidth * pixelRatio;
-
- context.save();
- context.fillStyle = fillStyle;
- context.strokeStyle = strokeStyle;
- context.lineWidth = lineWidth;
-
- context.beginPath();
- context.arc(x, y, radius, 0, Math.PI * 2);
- context.fill();
- context.stroke();
-
- context.restore();
-}
-
function drawLabel(
context: CanvasRenderingContext2D,
data: DiapsonRenderData,
diff --git a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
index 4c5f820a29a9dc7f979876501ccfcd509abc1dd1..49278926a26f4ff43fc812452ed81713d55b96ae 100644
--- a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
+++ b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
@@ -13,7 +13,6 @@ import {
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
@@ -37,6 +36,7 @@ import {
mergeFibonacciRetracementSettings,
} 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';
@@ -86,7 +86,6 @@ interface FibonacciRetracementGeometry {
height: number;
levels: FibonacciRetracementLevelRenderData[];
areas: FibonacciRetracementAreaRenderData[];
- handles: Record<FibonacciRetracementHandleKey, Point>;
}
type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;
@@ -94,22 +93,22 @@ type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'le
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
- showHandles: boolean;
backgroundOpacity: number;
}
-const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
-export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracementSettings> implements ISeriesDrawing {
+export class FibonacciRetracement
+ extends SeriesDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
+ implements ISeriesDrawing
+{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: FibonacciRetracementSettings = createDefaultSettings();
-
protected mode: FibonacciRetracementMode = 'idle';
private startTime: Time | null = null;
@@ -291,12 +290,9 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
return {
...geometry,
- showHandles: this.shouldShowHandles(),
-
showBackground: this.settings.showBackground,
backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
reverse: this.settings.reverse,
-
labelsPosition: this.settings.labelsPosition,
showPrices: this.settings.showPrices,
showLevelValues: this.settings.showLevelValues,
@@ -306,6 +302,25 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'end',
+ ...geometry.endPoint,
+ },
+ {
+ id: 'start',
+ ...geometry.startPoint,
+ },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
@@ -317,9 +332,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
return null;
}
- const handleTarget = this.isSelected() ? this.getHandleTarget(point) : null;
-
- if (handleTarget) {
+ if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
@@ -419,9 +432,13 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
}
protected handleDoubleClick = (event: MouseEvent): void => {
- const point = this.getMousePoint(event);
+ if (this.hidden || this.mode !== 'ready') {
+ return;
+ }
+
+ const point = this.getEventPoint(event as PointerEvent);
- if (this.hidden || this.mode !== 'ready' || (!this.containsPoint(point) && !this.getHandleTarget(point))) {
+ if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
@@ -536,7 +553,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
}
private updateDrawing(point: Point): void {
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
if (!anchor) {
return;
@@ -605,7 +622,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
}
private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
- return this.getHandleTarget(point) ?? (this.containsPoint(point) ? 'body' : null);
+ return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
@@ -625,7 +642,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
return;
}
- const containerSize = this.getContainerSize();
+ const containerSize = getElementContainerSize(this.container);
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
@@ -638,14 +655,18 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
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);
+ 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 = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
+ const priceOffset = getPriceDeltaFromCoordinates(
+ this.series,
+ this.dragStartPoint.y,
+ this.dragStartPoint.y + clampedOffsetY,
+ );
this.startTime = nextStartTime;
this.endTime = nextEndTime;
@@ -658,7 +679,7 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
return;
}
- const anchor = this.createAnchor(this.clampPointToContainer(point));
+ const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
if (!anchor) {
return;
@@ -720,10 +741,6 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
height: bottom - top,
levels,
areas: this.getAreas(levels),
- handles: {
- start: startPoint,
- end: endPoint,
- },
};
}
@@ -866,24 +883,6 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
: Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
}
- private getHandleTarget(point: Point): FibonacciRetracementHandleKey | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- if (isNearPoint(point, geometry.handles.start.x, geometry.handles.start.y, HANDLE_HIT_TOLERANCE)) {
- return 'start';
- }
-
- if (isNearPoint(point, geometry.handles.end.x, geometry.handles.end.y, HANDLE_HIT_TOLERANCE)) {
- return 'end';
- }
-
- return null;
- }
-
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
@@ -906,29 +905,4 @@ export class FibonacciRetracement extends SeriesDrawingBase<FibonacciRetracement
return xInRange && geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
}
-
- private shiftTime(time: Time, offsetX: number): Time | null {
- return shiftTimeByPixels(this.chart, time, offsetX, this.series);
- }
-
- private getPriceDelta(fromY: number, toY: number): number {
- return getPriceDeltaFromCoordinates(this.series, fromY, toY);
- }
-
- private getContainerSize(): { width: number; height: number } {
- return getElementContainerSize(this.container);
- }
-
- private clampPointToContainer(point: Point): Point {
- return clampPointToContainerInElement(point, this.container);
- }
-
- private getMousePoint(event: MouseEvent): Point {
- const rect = this.container.getBoundingClientRect();
-
- return this.clampPointToContainer({
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- });
- }
}
diff --git a/src/core/Drawings/fibonacciRetracement/paneRenderer.ts b/src/core/Drawings/fibonacciRetracement/paneRenderer.ts
index 11fbf6d0be30fdaa506957a328312c36e5ac47f8..c656f03b2103cc26387384e33cd26edc9e0f86b2 100644
--- a/src/core/Drawings/fibonacciRetracement/paneRenderer.ts
+++ b/src/core/Drawings/fibonacciRetracement/paneRenderer.ts
@@ -7,8 +7,6 @@ import { FibonacciRetracement } from './fibonacciRetracement';
const UI = {
lineWidth: 1,
- handleSize: 10,
- handleBorderWidth: 1,
textOffset: 4,
controlLineWidth: 1,
};
@@ -87,28 +85,6 @@ export class FibonacciRetracementPaneRenderer implements IPrimitivePaneRenderer
pixelRatio,
});
- if (data.showHandles) {
- drawHandle(
- context,
- data.startPoint.x * horizontalPixelRatio,
- data.startPoint.y * verticalPixelRatio,
- horizontalPixelRatio,
- verticalPixelRatio,
- colors.chartLineColor,
- colors.chartBackground,
- );
-
- drawHandle(
- context,
- data.endPoint.x * horizontalPixelRatio,
- data.endPoint.y * verticalPixelRatio,
- horizontalPixelRatio,
- verticalPixelRatio,
- colors.chartLineColor,
- colors.chartBackground,
- );
- }
-
context.restore();
});
}
@@ -221,31 +197,3 @@ function drawText(
context.restore();
}
-
-function drawHandle(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- horizontalPixelRatio: number,
- verticalPixelRatio: number,
- strokeColor: string,
- fillColor: string,
-): void {
- const width = UI.handleSize * horizontalPixelRatio;
- const height = UI.handleSize * verticalPixelRatio;
- const left = x - width / 2;
- const top = y - height / 2;
-
- context.save();
-
- context.fillStyle = fillColor;
- context.strokeStyle = strokeColor;
- context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
-
- context.beginPath();
- context.rect(left, top, width, height);
- context.fill();
- context.stroke();
-
- context.restore();
-}
diff --git a/src/core/Drawings/handles.ts b/src/core/Drawings/handles.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3eaea25fb80f4c6f02cb564aac81a4ee7ffed85f
--- /dev/null
+++ b/src/core/Drawings/handles.ts
@@ -0,0 +1,225 @@
+import { isNearPoint } from '@core/Drawings/helpers';
+import { drawRoundedRect } from '@core/Drawings/utils';
+import { getThemeStore } from '@src/theme';
+
+import type { Point } from '@core/Drawings/types';
+import type { CanvasRenderingTarget2D } from 'fancy-canvas';
+import type {
+ IPrimitivePaneRenderer,
+ IPrimitivePaneView,
+ ISeriesPrimitive,
+ SeriesAttachedParameter,
+ Time,
+} from 'lightweight-charts';
+
+export type DrawingHandleShape = 'circle' | 'rounded';
+
+export interface DrawingHandle<TId extends string = string> {
+ id: TId;
+ x: number;
+ y: number;
+ shape?: DrawingHandleShape;
+ size?: number;
+ borderWidth?: number;
+ strokeColor?: string;
+}
+
+const DEFAULT_HANDLE_SIZE = 12;
+const DEFAULT_HANDLE_BORDER_WIDTH = 2;
+const HANDLE_HIT_TOLERANCE = 8;
+const HANDLE_RADIUS = 2;
+
+export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
+ private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
+ private series: SeriesAttachedParameter<Time>['series'] | null = null;
+
+ private readonly paneRenderer: IPrimitivePaneRenderer = {
+ draw: (target) => this.drawPane(target),
+ };
+
+ private readonly timeAxisRenderer: IPrimitivePaneRenderer = {
+ draw: (target) => this.drawTimeAxis(target),
+ };
+
+ private readonly paneViewsList: readonly IPrimitivePaneView[] = [
+ {
+ renderer: () => this.paneRenderer,
+ zOrder: () => 'top',
+ },
+ ];
+
+ private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[] = [
+ {
+ renderer: () => this.timeAxisRenderer,
+ zOrder: () => 'top',
+ },
+ ];
+
+ constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {}
+
+ public attached({ chart, series }: SeriesAttachedParameter<Time>): void {
+ this.chart = chart;
+ this.series = series;
+ }
+
+ public detached(): void {
+ this.chart = null;
+ this.series = null;
+ }
+
+ public paneViews(): readonly IPrimitivePaneView[] {
+ return this.paneViewsList;
+ }
+
+ public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
+ return this.timeAxisPaneViewsList;
+ }
+
+ public findHandle(point: Point): DrawingHandle<TId> | null {
+ const handles = this.getHandles();
+
+ for (let index = handles.length - 1; index >= 0; index -= 1) {
+ const handle = handles[index];
+
+ if (isNearPoint(point, handle.x, handle.y, getHitTolerance(handle))) {
+ return handle;
+ }
+ }
+
+ return null;
+ }
+
+ public isTimeAxisHit(point: Point): boolean {
+ if (!this.isTimeAxisAdjacent()) {
+ return false;
+ }
+
+ const paneHeight = this.getPaneHeight();
+
+ if (paneHeight <= 0 || point.y < paneHeight) {
+ return false;
+ }
+
+ const handle = this.findHandle(point);
+
+ return handle !== null && intersectsTimeAxis(handle, paneHeight);
+ }
+
+ private drawPane(target: CanvasRenderingTarget2D): void {
+ const handles = this.getHandles();
+
+ if (!handles.length) {
+ return;
+ }
+
+ target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
+ for (const handle of handles) {
+ drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
+ }
+ });
+ }
+
+ private drawTimeAxis(target: CanvasRenderingTarget2D): void {
+ if (!this.isTimeAxisAdjacent()) {
+ return;
+ }
+
+ const paneHeight = this.getPaneHeight();
+
+ if (paneHeight <= 0) {
+ return;
+ }
+
+ const handles = this.getHandles();
+
+ if (!handles.length) {
+ return;
+ }
+
+ target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
+ const axisHeight = bitmapSize.height / verticalPixelRatio;
+
+ for (const handle of handles) {
+ if (!intersectsTimeAxis(handle, paneHeight)) {
+ continue;
+ }
+
+ const axisY = handle.y - paneHeight;
+ const halfSize = getHandleSize(handle) / 2;
+
+ if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
+ continue;
+ }
+
+ drawHandle(context, { ...handle, y: axisY }, horizontalPixelRatio, verticalPixelRatio);
+ }
+ });
+ }
+
+ private getPaneHeight(): number {
+ return this.series?.getPane().getHeight() ?? 0;
+ }
+
+ private isTimeAxisAdjacent(): boolean {
+ if (!this.chart || !this.series) {
+ return false;
+ }
+
+ return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
+ }
+}
+
+function getHandleSize(handle: DrawingHandle): number {
+ return handle.size ?? DEFAULT_HANDLE_SIZE;
+}
+
+function getHitTolerance(handle: DrawingHandle): number {
+ return Math.max(HANDLE_HIT_TOLERANCE, getHandleSize(handle) / 2);
+}
+
+function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
+ const halfSize = getHandleSize(handle) / 2;
+
+ return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
+}
+
+function drawHandle(
+ context: CanvasRenderingContext2D,
+ handle: DrawingHandle,
+ horizontalPixelRatio: number,
+ verticalPixelRatio: number,
+): void {
+ const size = getHandleSize(handle);
+ const shape = handle.shape ?? 'circle';
+ const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
+ const lineWidth = (handle.borderWidth ?? DEFAULT_HANDLE_BORDER_WIDTH) * pixelRatio;
+ const width = size * horizontalPixelRatio;
+ const height = size * verticalPixelRatio;
+ const x = handle.x * horizontalPixelRatio;
+ const y = handle.y * verticalPixelRatio;
+ const inset = lineWidth / 2;
+ const { colors } = getThemeStore();
+
+ context.save();
+ context.fillStyle = colors.chartBackground;
+ context.strokeStyle = handle.strokeColor ?? colors.chartLineColor;
+ context.lineWidth = lineWidth;
+ context.beginPath();
+
+ if (shape === 'circle') {
+ const radius = Math.max(Math.min(width, height) / 2 - inset, 0);
+
+ context.arc(x, y, radius, 0, Math.PI * 2);
+ } else {
+ const left = x - width / 2 + inset;
+ const top = y - height / 2 + inset;
+ const drawWidth = Math.max(width - lineWidth, 0);
+ const drawHeight = Math.max(height - lineWidth, 0);
+
+ drawRoundedRect(context, left, top, drawWidth, drawHeight, HANDLE_RADIUS * pixelRatio);
+ }
+
+ context.fill();
+ context.stroke();
+ context.restore();
+}
diff --git a/src/core/Drawings/helpers.ts b/src/core/Drawings/helpers.ts
index 8ba05801913c2b90080912f718892932fe4330f1..c536f3f4f17c35fe264b456810a666e930b6a346 100644
--- a/src/core/Drawings/helpers.ts
+++ b/src/core/Drawings/helpers.ts
@@ -69,16 +69,17 @@ export function clampPointToContainer(point: Point, container: HTMLElement): Poi
};
}
-export function getPointerPoint(container: HTMLElement, event: PointerEvent | TouchMouseEventData): Point {
+export function getRawPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
const rect = container.getBoundingClientRect();
- return clampPointToContainer(
- {
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- },
- container,
- );
+ return {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+}
+
+export function getPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
+ return clampPointToContainer(getRawPointerPoint(container, event), container);
}
export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
diff --git a/src/core/Drawings/line/lineDrawing.ts b/src/core/Drawings/line/lineDrawing.ts
index 52331ea34f722940589d66cd2553c468cc76d2ea..be4521a4d8ce476cdd8ce0dea586025a7997d55c 100644
--- a/src/core/Drawings/line/lineDrawing.ts
+++ b/src/core/Drawings/line/lineDrawing.ts
@@ -11,10 +11,9 @@ import {
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
@@ -33,10 +32,12 @@ import {
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';
type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
+type LineDrawingHandleKey = 'start' | 'end';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
@@ -61,14 +62,15 @@ interface LineDrawingGeometry {
bottom: number;
}
-export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {
- showHandles: boolean;
-}
+export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
-export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implements ISeriesDrawing {
+export class LineDrawing
+ extends SeriesDrawingBase<LineDrawingSettings, LineDrawingHandleKey>
+ implements ISeriesDrawing
+{
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly defaultMarkers: LineDrawingMarkers;
@@ -264,11 +266,33 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
return {
...geometry,
- showHandles: this.shouldShowHandles(),
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<LineDrawingHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'start',
+ ...geometry.startPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'end',
+ ...geometry.endPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
@@ -276,7 +300,7 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
const point = { x, y };
- if (this.getPointTarget(point)) {
+ if (this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'move',
externalId: 'line-drawing',
@@ -388,13 +412,9 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
return;
}
- const rect = this.container.getBoundingClientRect();
- const point = {
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- };
+ const point = this.getEventPoint(event as PointerEvent);
- if (!this.getPointTarget(point) && !this.isPointNearLine(point)) {
+ if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearLine(point)) {
return;
}
@@ -411,20 +431,17 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
const point = this.getEventPoint(event);
- if (this.mode === 'idle') {
+ if (this.mode === 'idle' || this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
- this.startDrawing(point);
- return;
- }
-
- if (this.mode === 'drawing') {
- event.preventDefault();
- event.stopPropagation();
+ if (this.mode === 'idle') {
+ this.startDrawing(point);
+ } else {
+ this.updateDrawing(point);
+ this.finishDrawing();
+ }
- this.updateDrawing(point);
- this.finishDrawing();
return;
}
@@ -432,47 +449,29 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
return;
}
- const pointTarget = this.getPointTarget(point);
+ const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearLine = this.isPointNearLine(point);
const isDrawingHit = pointTarget !== null || isNearLine;
+ const isSelected = this.isSelected();
- if (!this.isSelected()) {
- if (!isDrawingHit) {
- return;
+ if (!isDrawingHit) {
+ if (isSelected) {
+ this.deselect();
}
- event.preventDefault();
- event.stopPropagation();
-
- this.select();
return;
}
- if (pointTarget === 'start') {
- event.preventDefault();
- event.stopPropagation();
-
- this.startDragging('dragging-start', point, event.pointerId);
- return;
- }
-
- if (pointTarget === 'end') {
- event.preventDefault();
- event.stopPropagation();
-
- this.startDragging('dragging-end', point, event.pointerId);
- return;
- }
-
- if (isNearLine) {
- event.preventDefault();
- event.stopPropagation();
+ event.preventDefault();
+ event.stopPropagation();
- this.startDragging('dragging-body', point, event.pointerId);
+ if (!isSelected) {
+ this.select();
return;
}
- this.deselect();
+ const dragMode: LineDrawingMode = `dragging-${pointTarget ?? 'body'}`;
+ this.startDragging(dragMode, point, event.pointerId);
};
protected handlePointerMove = (event: PointerEvent): void => {
@@ -565,7 +564,6 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
private startDragging(mode: LineDrawingMode, point: Point, pointerId: number): void {
this.mode = mode;
-
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
@@ -668,24 +666,6 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
};
}
- private getPointTarget(point: Point): 'start' | 'end' | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
- return 'start';
- }
-
- if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
- return 'end';
- }
-
- return null;
- }
-
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
@@ -693,26 +673,7 @@ export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implemen
return false;
}
- return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
- }
-
- private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
- const dx = endPoint.x - startPoint.x;
- const dy = endPoint.y - startPoint.y;
-
- if (dx === 0 && dy === 0) {
- return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
- }
-
- const t = Math.max(
- 0,
- Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
- );
-
- const projectionX = startPoint.x + t * dx;
- const projectionY = startPoint.y + t * dy;
-
- return Math.hypot(point.x - projectionX, point.y - projectionY);
+ return getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
diff --git a/src/core/Drawings/line/paneRenderer.ts b/src/core/Drawings/line/paneRenderer.ts
index 232d5b0fc70378a9d8d2ce77ca7908acbf099e0b..a7b709023f2b18ab077e802d92edfd7ab471881e 100644
--- a/src/core/Drawings/line/paneRenderer.ts
+++ b/src/core/Drawings/line/paneRenderer.ts
@@ -1,16 +1,12 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import { LineMarker } from '@src/types';
import { LineDrawing } from './lineDrawing';
const UI = {
lineWidth: 2,
- handleRadius: 5,
- handleBorderWidth: 2,
arrowLength: 12,
arrowAngle: Math.PI / 6,
textLineHeightMultiplier: 1.2,
@@ -31,8 +27,6 @@ export class LineDrawingPaneRenderer implements IPrimitivePaneRenderer {
return;
}
- const { colors } = getThemeStore();
-
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
@@ -87,15 +81,6 @@ export class LineDrawingPaneRenderer implements IPrimitivePaneRenderer {
});
}
- if (data.showHandles) {
- context.fillStyle = colors.chartBackground;
- context.strokeStyle = colors.chartLineColor;
- context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
- drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
- drawHandle(context, endX, endY, UI.handleRadius * pixelRatio);
- }
-
context.restore();
});
}
@@ -132,13 +117,6 @@ function drawArrowHead(
context.stroke();
}
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
- context.beginPath();
- context.arc(x, y, radius, 0, Math.PI * 2);
- context.fill();
- context.stroke();
-}
-
function drawTextAlongLine(
context: CanvasRenderingContext2D,
params: {
diff --git a/src/core/Drawings/parallelChannel/paneRenderer.ts b/src/core/Drawings/parallelChannel/paneRenderer.ts
index f394adfea83a5c53d34d30844dcaed298e402211..634b6fc57f369ecc6a0eef142e5f859654dac73e 100644
--- a/src/core/Drawings/parallelChannel/paneRenderer.ts
+++ b/src/core/Drawings/parallelChannel/paneRenderer.ts
@@ -1,8 +1,6 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import type { ParallelChannel } from './parallelChannel';
import type { Point } from '@core/Drawings/types';
@@ -12,10 +10,9 @@ const UI = {
middleLineWidth: 1,
middleLineDash: 6,
middleLineGap: 4,
- handleRadius: 5,
- handleBorderWidth: 2,
textLineHeightMultiplier: 1.2,
textOffset: 5,
+ textStartPadding: 15,
textStartGap: 5,
};
@@ -86,25 +83,6 @@ export class ParallelChannelPaneRenderer implements IPrimitivePaneRenderer {
});
}
- if (data.showHandles) {
- const { colors } = getThemeStore();
-
- context.fillStyle = colors.chartBackground;
-
- context.strokeStyle = colors.chartLineColor;
-
- context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
- const radius = UI.handleRadius * pixelRatio;
-
- drawHandle(context, startPoint, radius);
- drawHandle(context, mainMiddlePoint, radius);
- drawHandle(context, endPoint, radius);
- drawHandle(context, parallelStartPoint, radius);
- drawHandle(context, parallelMiddlePoint, radius);
- drawHandle(context, parallelEndPoint, radius);
- }
-
context.restore();
});
}
@@ -141,13 +119,6 @@ function drawLine(context: CanvasRenderingContext2D, startPoint: Point, endPoint
context.stroke();
}
-function drawHandle(context: CanvasRenderingContext2D, point: Point, radius: number): void {
- context.beginPath();
- context.arc(point.x, point.y, radius, 0, Math.PI * 2);
- context.fill();
- context.stroke();
-}
-
function drawTextAlongLine(
context: CanvasRenderingContext2D,
params: {
@@ -199,19 +170,12 @@ function drawTextAlongLine(
}
const directionX = deltaX / lineLength;
-
const directionY = deltaY / lineLength;
-
const lineMiddlePoint = getMiddlePoint(startPoint, endPoint);
-
const outwardX = lineMiddlePoint.x - channelCenterPoint.x;
-
const outwardY = lineMiddlePoint.y - channelCenterPoint.y;
-
const outwardLength = Math.hypot(outwardX, outwardY);
-
const normalX = outwardLength ? outwardX / outwardLength : 0;
-
const normalY = outwardLength ? outwardY / outwardLength : -1;
const lines = text.split('\n');
@@ -222,7 +186,6 @@ function drawTextAlongLine(
const fontStyle = isItalic ? 'italic ' : '';
context.save();
-
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
const textWidth = lines.reduce((maxWidth, line) => {
@@ -230,12 +193,9 @@ function drawTextAlongLine(
}, 0);
const blockHeight = lines.length * lineHeight;
-
- const startPadding = (UI.handleRadius * 2 + UI.textStartGap) * pixelRatio;
-
+ const startPadding = UI.textStartPadding * pixelRatio;
const desiredDistance = startPadding + textWidth / 2;
const availableDistance = lineLength - textWidth / 2 - UI.textStartGap * pixelRatio;
-
const distanceAlongLine = availableDistance >= desiredDistance ? desiredDistance : lineLength / 2;
const outwardDistance = blockHeight / 2 + UI.textOffset * verticalPixelRatio;
diff --git a/src/core/Drawings/parallelChannel/parallelChannel.ts b/src/core/Drawings/parallelChannel/parallelChannel.ts
index ba1846ab0b625d6533bfb236c279a24934dd8417..13b1881a5201fca98612933e627be2dc90ea60dd 100644
--- a/src/core/Drawings/parallelChannel/parallelChannel.ts
+++ b/src/core/Drawings/parallelChannel/parallelChannel.ts
@@ -1,11 +1,4 @@
-import {
- IChartApi,
- IPrimitivePaneView,
- PrimitiveHoveredItem,
- TouchMouseEventData,
- UTCTimestamp,
-} from 'lightweight-charts';
-import { Observable } from 'rxjs';
+import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
@@ -19,10 +12,9 @@ import {
getPriceFromYCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
@@ -39,6 +31,7 @@ import {
ParallelChannelTextStyle,
} 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';
@@ -54,8 +47,8 @@ type ParallelChannelDragTarget =
| 'parallel-end'
| 'body';
+type ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
-
type PriceLabelKind = 'main-start' | 'main-end' | 'parallel-start' | 'parallel-end';
type ParallelChannelParams = BaseDrawingParams;
@@ -87,17 +80,17 @@ interface ParallelChannelGeometry {
export interface ParallelChannelRenderData
extends ParallelChannelGeometry,
ParallelChannelStyle,
- ParallelChannelTextStyle {
- showHandles: boolean;
-}
+ ParallelChannelTextStyle {}
-const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;
const VERTICAL_LINE_TOLERANCE = 0.001;
-export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings> implements ISeriesDrawing {
+export class ParallelChannel
+ extends SeriesDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
+ implements ISeriesDrawing
+{
private openSettings?: () => void;
protected settings: ParallelChannelSettings = createDefaultSettings();
@@ -314,17 +307,63 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
return {
...geometry,
...this.settings,
- showHandles: this.shouldShowHandles(),
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<ParallelChannelHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'parallel-end',
+ ...geometry.parallelEndPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'parallel-middle',
+ ...geometry.parallelMiddlePoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'parallel-start',
+ ...geometry.parallelStartPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'main-end',
+ ...geometry.endPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'main-middle',
+ ...geometry.mainMiddlePoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'main-start',
+ ...geometry.startPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
}
const point = { x, y };
- const pointTarget = this.getPointTarget(point);
+ const pointTarget = this.getDrawingHandleAtPoint(point);
const isChannelHit = this.isPointOnChannel(point);
if (!pointTarget && !isChannelHit) {
@@ -456,7 +495,7 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
const point = this.getEventPoint(event as PointerEvent);
- if (!this.isPointOnChannel(point) && !this.getPointTarget(point)) {
+ if (!this.isPointOnChannel(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
@@ -473,82 +512,67 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
const point = this.getEventPoint(event);
- if (this.mode === 'idle') {
- event.preventDefault();
- event.stopPropagation();
-
- this.startDrawing(point);
- return;
- }
+ switch (this.mode) {
+ case 'idle':
+ event.preventDefault();
+ event.stopPropagation();
+ this.startDrawing(point);
+ return;
- if (this.mode === 'drawing-line') {
- event.preventDefault();
- event.stopPropagation();
+ case 'drawing-line':
+ event.preventDefault();
+ event.stopPropagation();
+ this.setEndAnchor(point);
- this.setEndAnchor(point);
+ if (!this.hasValidMainLine()) {
+ this.render();
+ return;
+ }
- if (!this.hasValidMainLine()) {
+ this.priceOffset = 0;
+ this.mode = 'drawing-channel';
this.render();
return;
- }
-
- this.priceOffset = 0;
- this.mode = 'drawing-channel';
-
- this.render();
- return;
- }
- if (this.mode === 'drawing-channel') {
- event.preventDefault();
- event.stopPropagation();
+ case 'drawing-channel':
+ event.preventDefault();
+ event.stopPropagation();
+ this.setPriceOffset(point);
- this.setPriceOffset(point);
+ if (!this.hasValidChannelWidth()) {
+ this.render();
+ return;
+ }
- if (!this.hasValidChannelWidth()) {
- this.render();
+ this.finishDrawing();
return;
- }
- this.finishDrawing();
- return;
- }
+ case 'ready':
+ break;
- if (this.mode !== 'ready') {
- return;
+ default:
+ return;
}
- const pointTarget = this.getPointTarget(point);
+ const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isChannelHit = this.isPointOnChannel(point);
- const isDrawingHit = pointTarget !== null || isChannelHit;
-
- if (!this.isSelected()) {
- if (!isDrawingHit) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
+ const isSelected = this.isSelected();
- this.select();
+ if (!isSelected && !pointTarget && !isChannelHit) {
return;
}
- if (pointTarget) {
+ if (!isSelected) {
event.preventDefault();
event.stopPropagation();
-
- this.startDragging(pointTarget, point, event.pointerId);
-
+ this.select();
return;
}
- if (isChannelHit) {
+ if (pointTarget || isChannelHit) {
event.preventDefault();
event.stopPropagation();
-
- this.startDragging('body', point, event.pointerId);
-
+ this.startDragging(pointTarget ?? 'body', point, event.pointerId);
return;
}
@@ -612,13 +636,9 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
}
const mainMiddlePoint = getMiddlePoint(startPoint, endPoint);
-
const parallelMiddlePoint = getMiddlePoint(parallelStartPoint, parallelEndPoint);
-
const middleStartPoint = getMiddlePoint(startPoint, parallelStartPoint);
-
const middleEndPoint = getMiddlePoint(endPoint, parallelEndPoint);
-
const points = [startPoint, endPoint, parallelStartPoint, parallelEndPoint];
return {
@@ -783,7 +803,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
}
const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
-
const linePrice = this.getLinePriceAtX(snapshot.startAnchor, snapshot.endAnchor, point.x);
if (pointerPrice === null || linePrice === null) {
@@ -833,11 +852,9 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
}
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) {
@@ -873,7 +890,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
}
const pointerPrice = getPriceFromYCoordinate(this.series, point.y);
-
const linePrice = this.getLinePriceAtX(this.startAnchor, this.endAnchor, point.x);
if (pointerPrice === null || linePrice === null) {
@@ -898,7 +914,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
}
const ratio = (x - startPoint.x) / deltaX;
-
const y = startPoint.y + (endPoint.y - startPoint.y) * ratio;
return getPriceFromYCoordinate(this.series, y);
@@ -924,31 +939,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
return getDistance(geometry.startPoint, geometry.parallelStartPoint) >= MIN_CHANNEL_WIDTH;
}
- private getPointTarget(point: Point): ParallelChannelDragTarget | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- const targets: [ParallelChannelDragTarget, Point][] = [
- ['main-start', geometry.startPoint],
- ['main-middle', geometry.mainMiddlePoint],
- ['main-end', geometry.endPoint],
- ['parallel-start', geometry.parallelStartPoint],
- ['parallel-middle', geometry.parallelMiddlePoint],
- ['parallel-end', geometry.parallelEndPoint],
- ];
-
- for (const [target, targetPoint] of targets) {
- if (isNearPoint(point, targetPoint.x, targetPoint.y, HANDLE_HIT_TOLERANCE)) {
- return target;
- }
- }
-
- return null;
- }
-
private isPointOnChannel(point: Point): boolean {
const geometry = this.getGeometry();
@@ -1004,7 +994,6 @@ export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings>
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) {
@@ -1037,29 +1026,6 @@ function getDistance(startPoint: Point, endPoint: Point): number {
return Math.hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y);
}
-function getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
- const deltaX = endPoint.x - startPoint.x;
- const deltaY = endPoint.y - startPoint.y;
-
- if (deltaX === 0 && deltaY === 0) {
- return getDistance(point, startPoint);
- }
-
- const ratio = Math.max(
- 0,
- Math.min(
- 1,
- ((point.x - startPoint.x) * deltaX + (point.y - startPoint.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY),
- ),
- );
-
- const projectionX = startPoint.x + ratio * deltaX;
-
- const projectionY = startPoint.y + ratio * deltaY;
-
- return Math.hypot(point.x - projectionX, point.y - projectionY);
-}
-
function isPointInPolygon(point: Point, polygon: Point[]): boolean {
let isInside = false;
diff --git a/src/core/Drawings/ray/paneRenderer.ts b/src/core/Drawings/ray/paneRenderer.ts
index 066800357c77821e8fee847d7d600a7c26ce55d6..5ec31743f324e13370dd3ada16f89be45957464f 100644
--- a/src/core/Drawings/ray/paneRenderer.ts
+++ b/src/core/Drawings/ray/paneRenderer.ts
@@ -1,14 +1,10 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import { Ray } from './ray';
const UI = {
lineWidth: 2,
- handleRadius: 5,
- handleBorderWidth: 2,
textLineHeightMultiplier: 1.2,
textOffset: 4,
};
@@ -27,8 +23,6 @@ export class RayPaneRenderer implements IPrimitivePaneRenderer {
return;
}
- const { colors } = getThemeStore();
-
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
@@ -61,15 +55,6 @@ export class RayPaneRenderer implements IPrimitivePaneRenderer {
pixelRatio,
});
- if (data.showHandles) {
- context.fillStyle = colors.chartBackground;
- context.strokeStyle = data.lineColor;
- context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
- drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
- drawHandle(context, directionX, directionY, UI.handleRadius * pixelRatio);
- }
-
context.restore();
});
}
@@ -134,10 +119,3 @@ function drawRayText(
context.restore();
}
-
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
- context.beginPath();
- context.arc(x, y, radius, 0, Math.PI * 2);
- context.fill();
- context.stroke();
-}
diff --git a/src/core/Drawings/ray/ray.ts b/src/core/Drawings/ray/ray.ts
index 8f7b24e1e438095466ac369594584aa463a3414f..161f342ea9ef7832377847e3af297839238ed227 100644
--- a/src/core/Drawings/ray/ray.ts
+++ b/src/core/Drawings/ray/ray.ts
@@ -1,4 +1,4 @@
-import { IPrimitivePaneView, PrimitiveHoveredItem, TouchMouseEventData, UTCTimestamp } from 'lightweight-charts';
+import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
@@ -11,10 +11,9 @@ import {
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
@@ -26,11 +25,13 @@ import { RayPaneView } from './paneView';
import { createDefaultSettings, getRaySettingTabs, RaySettings, RayStyle, RayTextStyle } from './settings';
+import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type RayMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-direction' | 'dragging-body';
+type RayHandleKey = 'start' | 'direction';
type TimeLabelKind = 'start' | 'direction';
type PriceLabelKind = 'start' | 'direction';
@@ -54,20 +55,16 @@ interface RayGeometry {
bottom: number;
}
-export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {
- showHandles: boolean;
-}
+export interface RayRenderData extends RayGeometry, RayStyle, RayTextStyle {}
-const POINT_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
-export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawing {
+export class Ray extends SeriesDrawingBase<RaySettings, RayHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RaySettings = createDefaultSettings();
-
protected mode: RayMode = 'idle';
private startAnchor: Anchor | null = null;
@@ -245,11 +242,35 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
return {
...geometry,
- showHandles: this.shouldShowHandles(),
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<RayHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'start',
+ ...geometry.startPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ strokeColor: this.settings.lineColor,
+ },
+ {
+ id: 'direction',
+ ...geometry.directionPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ strokeColor: this.settings.lineColor,
+ },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
@@ -257,7 +278,7 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
const point = { x, y };
- if (this.getPointTarget(point)) {
+ if (this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'move',
externalId: 'ray',
@@ -369,13 +390,9 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
return;
}
- const rect = this.container.getBoundingClientRect();
- const point = {
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- };
+ const point = this.getEventPoint(event as PointerEvent);
- if (!this.getPointTarget(point) && !this.isPointNearRay(point)) {
+ if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearRay(point)) {
return;
}
@@ -413,7 +430,7 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
return;
}
- const pointTarget = this.getPointTarget(point);
+ const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearRay = this.isPointNearRay(point);
const isDrawingHit = pointTarget !== null || isNearRay;
@@ -546,7 +563,6 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
private startDragging(mode: RayMode, point: Point, pointerId: number): void {
this.mode = mode;
-
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
@@ -702,24 +718,6 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
return candidates[0] ?? directionPoint;
}
- private getPointTarget(point: Point): 'start' | 'direction' | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, POINT_HIT_TOLERANCE)) {
- return 'start';
- }
-
- if (isNearPoint(point, geometry.directionPoint.x, geometry.directionPoint.y, POINT_HIT_TOLERANCE)) {
- return 'direction';
- }
-
- return null;
- }
-
private isPointNearRay(point: Point): boolean {
const geometry = this.getGeometry();
@@ -727,26 +725,7 @@ export class Ray extends SeriesDrawingBase<RaySettings> implements ISeriesDrawin
return false;
}
- return this.getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
- }
-
- private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
- const dx = endPoint.x - startPoint.x;
- const dy = endPoint.y - startPoint.y;
-
- if (dx === 0 && dy === 0) {
- return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
- }
-
- const t = Math.max(
- 0,
- Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
- );
-
- const projectionX = startPoint.x + t * dx;
- const projectionY = startPoint.y + t * dy;
-
- return Math.hypot(point.x - projectionX, point.y - projectionY);
+ return getDistanceToSegment(point, geometry.startPoint, geometry.rayEndPoint) <= LINE_HIT_TOLERANCE;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
diff --git a/src/core/Drawings/rectangle/paneRenderer.ts b/src/core/Drawings/rectangle/paneRenderer.ts
index debed3a27c0e0d92ed650e95c2c6f5e4bc2b9684..b996274d5965dc9055e4d10c6700767aa00dc28f 100644
--- a/src/core/Drawings/rectangle/paneRenderer.ts
+++ b/src/core/Drawings/rectangle/paneRenderer.ts
@@ -1,14 +1,10 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import { Rectangle } from './rectangle';
const UI = {
borderWidth: 1,
- handleSize: 10,
- handleBorderWidth: 1,
textOffset: 4,
textLineHeightMultiplier: 1.2,
};
@@ -27,11 +23,8 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
return;
}
- const { colors } = getThemeStore();
-
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
-
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
@@ -39,10 +32,8 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
context.save();
- if (data.showFill) {
- context.fillStyle = data.fillColor;
- context.fillRect(left, top, right - left, bottom - top);
- }
+ context.fillStyle = data.fillColor;
+ context.fillRect(left, top, right - left, bottom - top);
context.lineWidth = UI.borderWidth * pixelRatio;
context.strokeStyle = data.borderColor;
@@ -50,7 +41,6 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
drawRectangleText(context, {
left,
- right,
top,
text: data.text,
fontSize: data.fontSize,
@@ -60,20 +50,6 @@ export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
pixelRatio,
});
- if (data.showHandles) {
- for (const handle of Object.values(data.handles)) {
- drawHandle(
- context,
- handle.x * horizontalPixelRatio,
- handle.y * verticalPixelRatio,
- horizontalPixelRatio,
- verticalPixelRatio,
- colors.chartLineColor,
- colors.chartBackground,
- );
- }
- }
-
context.restore();
});
}
@@ -83,7 +59,6 @@ function drawRectangleText(
context: CanvasRenderingContext2D,
params: {
left: number;
- right: number;
top: number;
text: string;
fontSize: number;
@@ -107,7 +82,6 @@ function drawRectangleText(
const fontStyle = isItalic ? 'italic ' : '';
const textOffset = UI.textOffset * pixelRatio;
- const textX = left;
const blockHeight = lines.length * lineHeight;
const firstLineY = top - textOffset - blockHeight + lineHeight / 2;
@@ -119,35 +93,8 @@ function drawRectangleText(
context.textBaseline = 'middle';
lines.forEach((line, index) => {
- context.fillText(line, textX, firstLineY + index * lineHeight);
+ context.fillText(line, left, firstLineY + index * lineHeight);
});
context.restore();
}
-
-function drawHandle(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- horizontalPixelRatio: number,
- verticalPixelRatio: number,
- strokeColor: string,
- fillColor: string,
-): void {
- const width = UI.handleSize * horizontalPixelRatio;
- const height = UI.handleSize * verticalPixelRatio;
- const left = x - width / 2;
- const top = y - height / 2;
-
- context.save();
- context.fillStyle = fillColor;
- context.strokeStyle = strokeColor;
- context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
-
- context.beginPath();
- context.rect(left, top, width, height);
- context.fill();
- context.stroke();
-
- context.restore();
-}
diff --git a/src/core/Drawings/rectangle/rectangle.ts b/src/core/Drawings/rectangle/rectangle.ts
index e4102fc6a9ce987a7d2845a94c61b6b52fee30b0..82bb27a9708986410b73683dbb7bd6ea1c2a86bb 100644
--- a/src/core/Drawings/rectangle/rectangle.ts
+++ b/src/core/Drawings/rectangle/rectangle.ts
@@ -15,7 +15,6 @@ import {
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
isPointInBounds,
normalizeBounds,
shiftTimeByPixels,
@@ -37,6 +36,7 @@ import {
RectangleTextStyle,
} 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';
@@ -67,24 +67,18 @@ interface RectangleGeometry {
bottom: number;
width: number;
height: number;
- handles: Record<RectangleHandleKey, Point>;
}
-export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
- showFill: boolean;
- showHandles: boolean;
-}
+export type RectangleRenderData = RectangleGeometry & RectangleStyle & RectangleTextStyle;
-const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
-export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
+export class Rectangle extends SeriesDrawingBase<RectangleSettings, RectangleHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
-
protected mode: RectangleMode = 'idle';
private startTime: Time | null = null;
@@ -276,12 +270,33 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
return {
...geometry,
- showFill: true,
- showHandles: this.shouldShowHandles(),
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<RectangleHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ const { left, right, top, bottom } = geometry;
+ const centerX = (left + right) / 2;
+ const centerY = (top + bottom) / 2;
+
+ return [
+ { id: 'w', x: left, y: centerY, shape: 'rounded' },
+ { id: 'sw', x: left, y: bottom, shape: 'circle' },
+ { id: 's', x: centerX, y: bottom, shape: 'rounded' },
+ { id: 'se', x: right, y: bottom, shape: 'circle' },
+ { id: 'e', x: right, y: centerY, shape: 'rounded' },
+ { id: 'ne', x: right, y: top, shape: 'circle' },
+ { id: 'n', x: centerX, y: top, shape: 'rounded' },
+ { id: 'nw', x: left, y: top, shape: 'circle' },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
@@ -301,7 +316,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
};
}
- const handleTarget = this.getHandleTarget(point);
+ const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
if (handleTarget) {
return {
@@ -417,13 +432,9 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
return;
}
- const rect = this.container.getBoundingClientRect();
- const point = {
- x: event.clientX - rect.left,
- y: event.clientY - rect.top,
- };
+ const point = this.getEventPoint(event as PointerEvent);
- if (!this.containsPoint(point) && !this.getHandleTarget(point)) {
+ if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
@@ -569,7 +580,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
this.mode = 'dragging';
-
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
@@ -609,7 +619,7 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
}
private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
- const handleTarget = this.getHandleTarget(point);
+ const handleTarget = this.getDrawingHandleAtPoint(point)?.id;
if (handleTarget) {
return handleTarget;
@@ -640,7 +650,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
}
const containerSize = this.getContainerSize();
-
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
@@ -760,9 +769,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
const top = Math.round(Math.min(Number(startY), Number(endY)));
const bottom = Math.round(Math.max(Number(startY), Number(endY)));
- const centerX = (left + right) / 2;
- const centerY = (top + bottom) / 2;
-
return {
left,
right,
@@ -770,16 +776,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
bottom,
width: right - left,
height: bottom - top,
- handles: {
- nw: { x: left, y: top },
- n: { x: centerX, y: top },
- ne: { x: right, y: top },
- e: { x: right, y: centerY },
- se: { x: right, y: bottom },
- s: { x: centerX, y: bottom },
- sw: { x: left, y: bottom },
- w: { x: left, y: centerY },
- },
};
}
@@ -896,26 +892,6 @@ export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements I
return startIsTop ? this.endPrice : this.startPrice;
}
- private getHandleTarget(point: Point): RectangleHandleKey | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
-
- for (const handleName of handleOrder) {
- const handle = geometry.handles[handleName];
-
- if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
- return handleName;
- }
- }
-
- return null;
- }
-
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
diff --git a/src/core/Drawings/regressionTrend/paneRenderer.ts b/src/core/Drawings/regressionTrend/paneRenderer.ts
index 7f4b9104273ee32e1f0235ebfa271c262b258d44..d3ea3dd5cee7ee44583cb78deafe1b579b374efd 100644
--- a/src/core/Drawings/regressionTrend/paneRenderer.ts
+++ b/src/core/Drawings/regressionTrend/paneRenderer.ts
@@ -1,8 +1,6 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import type { RegressionTrend } from './regressionTrend';
import type { Point } from '@core/Drawings/types';
@@ -10,13 +8,8 @@ import type { Point } from '@core/Drawings/types';
const UI = {
lineWidth: 2,
middleLineWidth: 1,
-
middleLineDash: 5,
middleLineGap: 4,
-
- handleRadius: 5,
- handleBorderWidth: 2,
-
correlationFontSize: 12,
correlationOffsetX: 0,
correlationOffsetY: 6,
@@ -36,17 +29,13 @@ export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
return;
}
- const { colors } = getThemeStore();
-
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const baseStart = scalePoint(data.baseStartPoint, horizontalPixelRatio, verticalPixelRatio);
const baseEnd = scalePoint(data.baseEndPoint, horizontalPixelRatio, verticalPixelRatio);
-
const upperStart = scalePoint(data.upperStartPoint, horizontalPixelRatio, verticalPixelRatio);
const upperEnd = scalePoint(data.upperEndPoint, horizontalPixelRatio, verticalPixelRatio);
-
const lowerStart = scalePoint(data.lowerStartPoint, horizontalPixelRatio, verticalPixelRatio);
const lowerEnd = scalePoint(data.lowerEndPoint, horizontalPixelRatio, verticalPixelRatio);
@@ -68,7 +57,6 @@ export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
context.strokeStyle = data.middleLineColor;
context.lineWidth = UI.middleLineWidth * pixelRatio;
-
context.setLineDash([UI.middleLineDash * pixelRatio, UI.middleLineGap * pixelRatio]);
drawLine(context, baseStart, baseEnd);
@@ -83,17 +71,6 @@ export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
drawCorrelation(context, data.correlation, correlationPoint, correlationDirection, data.lineColor, pixelRatio);
}
- if (data.showHandles) {
- context.fillStyle = colors.chartBackground;
- context.strokeStyle = colors.chartLineColor;
- context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
- const radius = UI.handleRadius * pixelRatio;
-
- drawHandle(context, baseStart, radius);
- drawHandle(context, baseEnd, radius);
- }
-
context.restore();
});
}
@@ -125,24 +102,13 @@ function drawArea(
context.beginPath();
context.moveTo(first.x, first.y);
-
context.lineTo(second.x, second.y);
context.lineTo(third.x, third.y);
context.lineTo(fourth.x, fourth.y);
-
context.closePath();
context.fill();
}
-function drawHandle(context: CanvasRenderingContext2D, point: Point, radius: number): void {
- context.beginPath();
-
- context.arc(point.x, point.y, radius, 0, Math.PI * 2);
-
- context.fill();
- context.stroke();
-}
-
function drawCorrelation(
context: CanvasRenderingContext2D,
correlation: number,
@@ -156,17 +122,14 @@ function drawCorrelation(
context.save();
context.font = `${UI.correlationFontSize * pixelRatio}px Inter, sans-serif`;
-
context.fillStyle = color;
context.textAlign = 'left';
if (direction === 'upper') {
context.textBaseline = 'bottom';
-
context.fillText(correlation.toString(), point.x + offsetX, point.y - offsetY);
} else {
context.textBaseline = 'top';
-
context.fillText(correlation.toString(), point.x + offsetX, point.y + offsetY);
}
diff --git a/src/core/Drawings/regressionTrend/regressionTrend.ts b/src/core/Drawings/regressionTrend/regressionTrend.ts
index a9f81ef43d128996653d641939f7d4108d1a0a4a..659e6845a8d2537725704e1c81b45efd5191fec2 100644
--- a/src/core/Drawings/regressionTrend/regressionTrend.ts
+++ b/src/core/Drawings/regressionTrend/regressionTrend.ts
@@ -4,13 +4,8 @@ import {
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
-import {
- getTimeFromXCoordinate,
- getXCoordinateFromTime,
- getYCoordinateFromPrice,
- isNearPoint,
-} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { getTimeFromXCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
@@ -26,6 +21,7 @@ import {
RegressionTrendStyle,
} 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';
@@ -33,6 +29,7 @@ import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } fro
type RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'start' | 'end' | 'body' | null;
+type RegressionTrendHandleKey = Exclude<DragTarget, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
@@ -55,31 +52,23 @@ interface RegressionSeriesData {
interface RegressionResult {
baseStartPrice: number;
baseEndPrice: number;
-
upperStartPrice: number;
upperEndPrice: number;
-
lowerStartPrice: number;
lowerEndPrice: number;
-
correlation: number;
}
interface RegressionTrendGeometry {
baseStartPoint: Point;
baseEndPoint: Point;
-
upperStartPoint: Point;
upperEndPoint: Point;
-
lowerStartPoint: Point;
lowerEndPoint: Point;
-
baseStartPrice: number;
baseEndPrice: number;
-
correlation: number;
-
left: number;
right: number;
top: number;
@@ -88,14 +77,16 @@ interface RegressionTrendGeometry {
export interface RegressionTrendRenderData extends RegressionTrendGeometry, RegressionTrendStyle {
showChannel: boolean;
- showHandles: boolean;
}
-const HIT_TOLERANCE = 8;
+const LINE_HIT_TOLERANCE = 8;
const MIN_BAR_DISTANCE = 1;
const REGRESSION_DEVIATION = 2;
-export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings> implements ISeriesDrawing {
+export class RegressionTrend
+ extends SeriesDrawingBase<RegressionTrendSettings, RegressionTrendHandleKey>
+ implements ISeriesDrawing
+{
private openSettings?: () => void;
protected settings: RegressionTrendSettings = createDefaultSettings();
@@ -116,13 +107,10 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
};
private paneView: RegressionTrendPaneView;
-
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
-
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
-
private startPriceAxisView: CustomPriceAxisView;
private endPriceAxisView: CustomPriceAxisView;
@@ -135,12 +123,7 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
openSettings,
initialEvent,
}: RegressionTrendParams) {
- super({
- chart,
- series,
- container,
- interaction,
- });
+ super({ chart, series, container, interaction });
this.openSettings = openSettings;
@@ -245,10 +228,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
- this.startTimeAxisView,
- this.endTimeAxisView,
- this.startPriceAxisView,
- this.endPriceAxisView,
+ ...this.timeAxisViews(),
+ ...this.priceAxisViews(),
]);
}
@@ -287,10 +268,32 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
...geometry,
...this.settings,
showChannel: this.mode === 'ready' || this.mode === 'dragging',
- showHandles: this.shouldShowHandles(),
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<RegressionTrendHandleKey>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'start',
+ ...geometry.baseStartPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ {
+ id: 'end',
+ ...geometry.baseEndPoint,
+ shape: 'circle',
+ borderWidth: 2,
+ },
+ ];
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
@@ -373,7 +376,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const labelKind = kind as TimeLabelKind;
-
const time = labelKind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
@@ -407,7 +409,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const labelKind = kind as PriceLabelKind;
-
const geometry = this.getGeometry();
if (!geometry) {
@@ -415,7 +416,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const price = labelKind === 'start' ? geometry.baseStartPrice : geometry.baseEndPrice;
-
const coordinate = getYCoordinateFromPrice(this.series, price);
if (coordinate === null) {
@@ -439,7 +439,7 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
const point = this.getEventPoint(event as PointerEvent);
- if (!this.containsPoint(point)) {
+ if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
@@ -459,8 +459,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
- this.startDrawing(point);
+ this.startDrawing(point);
return;
}
@@ -478,7 +478,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
if (!this.hasValidRange()) {
this.render();
-
return;
}
@@ -486,7 +485,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
this.resolveReady?.();
this.render();
-
return;
}
@@ -503,7 +501,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
event.stopPropagation();
this.select();
-
return;
}
@@ -511,7 +508,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
if (!dragTarget) {
this.deselect();
-
return;
}
@@ -566,7 +562,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
this.mode = 'ready';
this.showCrosshair();
-
this.render();
};
@@ -583,10 +578,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
const baseStartPoint = this.getPoint(this.startTime, regression.baseStartPrice);
const baseEndPoint = this.getPoint(this.endTime, regression.baseEndPrice);
-
const upperStartPoint = this.getPoint(this.startTime, regression.upperStartPrice);
const upperEndPoint = this.getPoint(this.endTime, regression.upperEndPrice);
-
const lowerStartPoint = this.getPoint(this.startTime, regression.lowerStartPrice);
const lowerEndPoint = this.getPoint(this.endTime, regression.lowerEndPrice);
@@ -599,18 +592,13 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
return {
baseStartPoint,
baseEndPoint,
-
upperStartPoint,
upperEndPoint,
-
lowerStartPoint,
lowerEndPoint,
-
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)),
@@ -643,15 +631,12 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
case 'start':
this.moveEdge('start', point);
break;
-
case 'end':
this.moveEdge('end', point);
break;
-
case 'body':
this.moveWhole(snapshot, point);
break;
-
default:
break;
}
@@ -689,10 +674,8 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const data = this.getSeriesData();
-
const dragStartIndex = this.getBarIndexByX(this.dragStartPoint.x, data);
const currentIndex = this.getBarIndexByX(point.x, data);
-
const startIndex = this.getBarIndex(snapshot.startTime, data);
const endIndex = this.getBarIndex(snapshot.endTime, data);
@@ -701,13 +684,10 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const rawOffset = currentIndex - dragStartIndex;
-
const minIndex = Math.min(startIndex, endIndex);
const maxIndex = Math.max(startIndex, endIndex);
-
const minOffset = -minIndex;
const maxOffset = data.length - 1 - maxIndex;
-
const offset = Math.max(minOffset, Math.min(rawOffset, maxOffset));
this.startTime = data[startIndex + offset].time;
@@ -737,22 +717,17 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const regression = calculateRegression(values);
-
const offset = regression.deviation * REGRESSION_DEVIATION;
-
const baseStartPrice = range.reversed ? regression.endValue : regression.startValue;
const baseEndPrice = range.reversed ? regression.startValue : regression.endValue;
return {
baseStartPrice,
baseEndPrice,
-
upperStartPrice: baseStartPrice + offset,
upperEndPrice: baseEndPrice + offset,
-
lowerStartPrice: baseStartPrice - offset,
lowerEndPrice: baseEndPrice - offset,
-
correlation: regression.correlation,
};
}
@@ -798,7 +773,6 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
const data = this.getSeriesData();
-
const index = findNearestBarIndex(data, time);
if (index === null) {
@@ -845,18 +819,10 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
}
private getDragTarget(point: Point): DragTarget {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
+ const handle = this.getDrawingHandleAtPoint(point);
- if (isNearPoint(point, geometry.baseStartPoint.x, geometry.baseStartPoint.y, HIT_TOLERANCE)) {
- return 'start';
- }
-
- if (isNearPoint(point, geometry.baseEndPoint.x, geometry.baseEndPoint.y, HIT_TOLERANCE)) {
- return 'end';
+ if (handle) {
+ return handle.id;
}
if (this.containsPoint(point)) {
@@ -873,15 +839,15 @@ export class RegressionTrend extends SeriesDrawingBase<RegressionTrendSettings>
return false;
}
- if (getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= HIT_TOLERANCE) {
+ if (getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
- if (getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= HIT_TOLERANCE) {
+ if (getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
- if (getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= HIT_TOLERANCE) {
+ if (getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= LINE_HIT_TOLERANCE) {
return true;
}
@@ -901,7 +867,6 @@ function calculateRegression(values: readonly number[]): {
correlation: number;
} {
const count = values.length;
-
const meanX = (count - 1) / 2;
const meanY = values.reduce((sum, value) => sum + value, 0) / count;
@@ -919,7 +884,6 @@ function calculateRegression(values: readonly number[]): {
}
const slope = sumXX === 0 ? 0 : sumXY / sumXX;
-
const intercept = meanY - slope * meanX;
let residualSum = 0;
@@ -936,9 +900,7 @@ function calculateRegression(values: readonly number[]): {
return {
startValue: intercept,
endValue: intercept + slope * (count - 1),
-
deviation: Math.sqrt(residualSum / count),
-
correlation: denominator === 0 ? 0 : sumXY / denominator,
};
}
@@ -965,7 +927,6 @@ function findNearestBarIndex(data: readonly RegressionSeriesData[], targetTime:
while (left <= right) {
const middle = Math.floor((left + right) / 2);
-
const { time } = data[middle];
if (typeof time !== 'number') {
@@ -1018,25 +979,6 @@ function findNearestBarIndexLinear(data: readonly RegressionSeriesData[], target
return result;
}
-function getDistanceToSegment(point: Point, start: Point, end: Point): number {
- const deltaX = end.x - start.x;
- const deltaY = end.y - start.y;
-
- if (deltaX === 0 && deltaY === 0) {
- return Math.hypot(point.x - start.x, point.y - start.y);
- }
-
- const ratio = Math.max(
- 0,
- Math.min(1, ((point.x - start.x) * deltaX + (point.y - start.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY)),
- );
-
- const x = start.x + ratio * deltaX;
- const y = start.y + ratio * deltaY;
-
- return Math.hypot(point.x - x, point.y - y);
-}
-
function isPointInPolygon(point: Point, polygon: Point[]): boolean {
let inside = false;
diff --git a/src/core/Drawings/ruler/paneRenderer.ts b/src/core/Drawings/ruler/paneRenderer.ts
index 1b191e4c94e52347207d2427ecdfef5ee4aaa6ed..2c7d9a14e0e19b1efb903f6d29b0581b4e15c3a3 100644
--- a/src/core/Drawings/ruler/paneRenderer.ts
+++ b/src/core/Drawings/ruler/paneRenderer.ts
@@ -1,6 +1,7 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
+import { drawRoundedRect } from '@core/Drawings/utils';
import { Direction } from '@src/types';
import type { Ruler } from './ruler';
@@ -199,21 +200,3 @@ function drawInfoBox(
context.restore();
}
-
-function drawRoundedRect(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- width: number,
- height: number,
- radius: number,
-): void {
- const safeRadius = Math.min(radius, width / 2, height / 2);
-
- context.moveTo(x + safeRadius, y);
- context.arcTo(x + width, y, x + width, y + height, safeRadius);
- context.arcTo(x + width, y + height, x, y + height, safeRadius);
- context.arcTo(x, y + height, x, y, safeRadius);
- context.arcTo(x, y, x + width, y, safeRadius);
- context.closePath();
-}
diff --git a/src/core/Drawings/sliderPosition/paneRenderer.ts b/src/core/Drawings/sliderPosition/paneRenderer.ts
index 626494465becb3a6bd47120451ce0678ed0e43ba..a1df62f8258094d4f8f0cbcaea5c2fc86003df0e 100644
--- a/src/core/Drawings/sliderPosition/paneRenderer.ts
+++ b/src/core/Drawings/sliderPosition/paneRenderer.ts
@@ -1,7 +1,7 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
+import { drawRoundedRect } from '@core/Drawings/utils';
import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';
@@ -14,9 +14,6 @@ const UI = {
padding: 4,
boxRadius: 4,
labelOffset: 10,
- handleSize: 10,
- handleRadius: 3,
- handleBorderWidth: 1,
};
export class SliderPaneRenderer implements IPrimitivePaneRenderer {
@@ -33,54 +30,53 @@ export class SliderPaneRenderer implements IPrimitivePaneRenderer {
return;
}
- target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
- const startX = data.startX * horizontalPixelRatio;
- const endX = data.endX * horizontalPixelRatio;
+ target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
const left = data.leftX * horizontalPixelRatio;
const right = data.rightX * horizontalPixelRatio;
-
const entryY = data.entryY * verticalPixelRatio;
const stopY = data.stopY * verticalPixelRatio;
const targetY = data.targetY * verticalPixelRatio;
-
const profitTop = data.profitTop * verticalPixelRatio;
const profitBottom = data.profitBottom * verticalPixelRatio;
const lossTop = data.lossTop * verticalPixelRatio;
const lossBottom = data.lossBottom * verticalPixelRatio;
-
const centerX = (left + right) / 2;
- const sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
- const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
- const targetLabelCenterY =
- targetY < entryY
- ? targetY - labelOffsetPx - sideBoxHeightPx / 2
- : targetY + labelOffsetPx + sideBoxHeightPx / 2;
+ const targetBoxHeight = getTextBoxHeight(data.targetText, data, UI.sideBoxHeight, verticalPixelRatio);
+ const centerBoxHeight = getTextBoxHeight(data.centerText, data, UI.mainBoxHeight, verticalPixelRatio);
+ const stopBoxHeight = getTextBoxHeight(data.stopText, data, UI.sideBoxHeight, verticalPixelRatio);
+
+ const targetLabelCenterY = getOuterLabelCenterY(
+ targetY,
+ data.targetLabelDirection,
+ targetBoxHeight,
+ labelOffsetPx,
+ );
- const stopLabelCenterY =
- stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;
+ const stopLabelCenterY = getOuterLabelCenterY(stopY, data.stopLabelDirection, stopBoxHeight, labelOffsetPx);
+
+ const centerLabelCenterY = getCenterLabelCenterY(
+ entryY,
+ targetY,
+ stopY,
+ centerBoxHeight,
+ labelOffsetPx,
+ bitmapSize.height,
+ );
context.save();
- if (data.showFill) {
- context.fillStyle = data.positiveFillColor;
- context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
+ context.fillStyle = data.positiveFillColor;
+ context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
- context.fillStyle = data.negativeFillColor;
- context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
- }
+ context.fillStyle = data.negativeFillColor;
+ context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.strokeStyle = data.lineColor;
- drawHorizontalLine(context, left, right, entryY);
- if (data.showHandles) {
- drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
- drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
- drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
- drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
- }
+ drawHorizontalLine(context, left, right, entryY);
if (data.showLabels) {
drawTextBox(
@@ -98,7 +94,7 @@ export class SliderPaneRenderer implements IPrimitivePaneRenderer {
drawTextBox(
context,
centerX,
- entryY + labelOffsetPx + mainBoxHeightPx / 2,
+ centerLabelCenterY,
data.centerText,
data.centerBoxColor,
data,
@@ -125,51 +121,54 @@ export class SliderPaneRenderer implements IPrimitivePaneRenderer {
}
}
-function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
- context.beginPath();
- context.moveTo(left, y);
- context.lineTo(right, y);
- context.stroke();
+function getOuterLabelCenterY(
+ coordinate: number,
+ direction: 'up' | 'down',
+ boxHeight: number,
+ labelOffset: number,
+): number {
+ const offset = labelOffset + boxHeight / 2;
+
+ return direction === 'up' ? coordinate - offset : coordinate + offset;
}
-function drawHandle(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- horizontalPixelRatio: number,
- verticalPixelRatio: number,
- shape: 'circle' | 'rounded',
-): void {
- const width = UI.handleSize * horizontalPixelRatio;
- const height = UI.handleSize * verticalPixelRatio;
- const left = x - width / 2;
- const top = y - height / 2;
+function getCenterLabelCenterY(
+ entryY: number,
+ targetY: number,
+ stopY: number,
+ boxHeight: number,
+ labelOffset: number,
+ viewportHeight: number,
+): number {
+ const targetSpace = Math.abs(targetY - entryY);
+ const stopSpace = Math.abs(stopY - entryY);
+
+ let direction: -1 | 1;
+
+ if (targetSpace < stopSpace) {
+ direction = stopY < entryY ? -1 : 1;
+ } else {
+ direction = targetY < entryY ? -1 : 1;
+ }
- const { colors } = getThemeStore();
+ return clampTextBoxCenter(entryY + direction * (labelOffset + boxHeight / 2), boxHeight, viewportHeight);
+}
- context.save();
- context.fillStyle = colors.chartBackground;
- context.strokeStyle = colors.chartLineColor;
- context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
+function clampTextBoxCenter(coordinate: number, boxHeight: number, viewportHeight: number): number {
+ if (viewportHeight <= boxHeight) {
+ return viewportHeight / 2;
+ }
- context.beginPath();
+ const halfHeight = boxHeight / 2;
- if (shape === 'circle') {
- context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
- } else {
- drawRoundedRect(
- context,
- left,
- top,
- width,
- height,
- UI.handleRadius * Math.max(horizontalPixelRatio, verticalPixelRatio),
- );
- }
+ return Math.max(halfHeight, Math.min(coordinate, viewportHeight - halfHeight));
+}
- context.fill();
+function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
+ context.beginPath();
+ context.moveTo(left, y);
+ context.lineTo(right, y);
context.stroke();
- context.restore();
}
function drawTextBox(
@@ -189,9 +188,7 @@ function drawTextBox(
const fontSize = textStyle.fontSize * verticalPixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingX = UI.padding * horizontalPixelRatio;
- const paddingY = UI.padding * verticalPixelRatio;
- const minBoxHeight = fixedHeight * verticalPixelRatio;
- const boxHeight = Math.max(minBoxHeight, lines.length * lineHeight + paddingY * 2);
+ const boxHeight = getTextBoxHeight(text, textStyle, fixedHeight, verticalPixelRatio);
const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.font = getTextFont(textStyle, fontSize);
@@ -231,27 +228,23 @@ function drawTextBox(
context.restore();
}
+function getTextBoxHeight(
+ text: string,
+ textStyle: SliderPositionTextStyle,
+ fixedHeight: number,
+ verticalPixelRatio: number,
+): number {
+ const linesCount = text.split('\n').length;
+ const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
+ const paddingY = UI.padding * verticalPixelRatio;
+ const minBoxHeight = fixedHeight * verticalPixelRatio;
+
+ return Math.max(minBoxHeight, linesCount * lineHeight + paddingY * 2);
+}
+
function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
-
-function drawRoundedRect(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- width: number,
- height: number,
- radius: number,
-): void {
- const safeRadius = Math.min(radius, width / 2, height / 2);
-
- context.moveTo(x + safeRadius, y);
- context.arcTo(x + width, y, x + width, y + height, safeRadius);
- context.arcTo(x + width, y + height, x, y + height, safeRadius);
- context.arcTo(x, y + height, x, y, safeRadius);
- context.arcTo(x, y, x + width, y, safeRadius);
- context.closePath();
-}
diff --git a/src/core/Drawings/sliderPosition/sliderPosition.ts b/src/core/Drawings/sliderPosition/sliderPosition.ts
index d6c9450cdbd8013b1757808c5c5dcdb42af82238..7d3e7ae82134cfa68559857175af819d8f7cb8e1 100644
--- a/src/core/Drawings/sliderPosition/sliderPosition.ts
+++ b/src/core/Drawings/sliderPosition/sliderPosition.ts
@@ -13,7 +13,6 @@ import {
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
@@ -27,7 +26,6 @@ import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { SliderPaneView } from './paneView';
-
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
@@ -36,6 +34,7 @@ import {
SliderPositionTextStyle,
} 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 } from '@src/types';
@@ -51,6 +50,7 @@ import type {
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';
@@ -95,16 +95,19 @@ export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, S
centerText: string;
stopText: string;
centerBoxColor: string;
- showFill: boolean;
- showHandles: boolean;
+ targetLabelDirection: 'up' | 'down';
+ stopLabelDirection: 'up' | 'down';
showLabels: boolean;
}
-const HIT_TOLERANCE = 8;
+const BODY_HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;
-export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> implements ISeriesDrawing {
+export class SliderPosition
+ extends SeriesDrawingBase<SliderPositionSettings, SliderHandleId>
+ implements ISeriesDrawing
+{
private removeSelf?: () => void;
private openSettings?: () => void;
@@ -119,7 +122,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
protected mode: SliderMode = 'idle';
private side: SliderSide;
-
private startTime: Time | null = null;
private endTime: Time | null = null;
private entryPrice: number | null = null;
@@ -157,27 +159,23 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
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.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
+ zOrder: 'normal',
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
+ zOrder: 'normal',
});
this.startTimeAxisView = new CustomTimeAxisView({
@@ -258,7 +256,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
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.entryPrice = next.entryPrice ?? this.entryPrice;
@@ -275,7 +272,7 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
this.entryPrice !== null &&
this.stopPrice !== null &&
typeof next.riskRewardRatio === 'number' &&
- next.riskRewardRatio > 0
+ next.riskRewardRatio >= 0
) {
const risk = Math.abs(this.entryPrice - this.stopPrice);
@@ -345,8 +342,8 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
const qty = reward > 0 ? this.amount / reward : 0;
-
const selectedPrice = this.getPriceAtTime(this.endTime);
+
const pnl =
selectedPrice === null
? 0
@@ -362,8 +359,8 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
centerText: this.getCenterText(qty, pnl),
stopText: this.getStopText(geometry),
centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
- showFill: true,
- showHandles: this.shouldShowHandles(),
+ targetLabelDirection: this.side === 'long' ? 'up' : 'down',
+ stopLabelDirection: this.side === 'long' ? 'down' : 'up',
showLabels: this.isSelected(),
...this.settings,
};
@@ -622,7 +619,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
event.preventDefault();
event.stopPropagation();
-
this.openSettings?.();
};
@@ -654,7 +650,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
}
@@ -672,7 +667,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
event.preventDefault();
event.stopPropagation();
-
this.select();
return;
}
@@ -700,7 +694,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
}
event.preventDefault();
-
this.applyDrag(this.getEventPoint(event));
this.render();
};
@@ -717,7 +710,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
this.mode = 'ready';
this.resolveReady?.();
-
this.render();
};
@@ -760,15 +752,10 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
return;
}
- const minDistance = this.getEditMinimumDistance();
- const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
- const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;
+ const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice);
+ const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice);
- if (low > high) {
- return;
- }
-
- this.entryPrice = Math.max(low, Math.min(nextPrice, high));
+ this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
}
private moveWhole(snapshot: SliderPositionState, point: Point): void {
@@ -839,11 +826,7 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
return;
}
- this.stopPrice = this.normalizeStop(
- snapshot.entryPrice,
- this.clampPriceToRange(nextPrice),
- this.getEditMinimumDistance(),
- );
+ this.stopPrice = this.normalizeStop(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
}
private moveTarget(snapshot: SliderPositionState, point: Point): void {
@@ -857,11 +840,7 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
return;
}
- this.targetPrice = this.normalizeTarget(
- snapshot.entryPrice,
- this.clampPriceToRange(nextPrice),
- this.getEditMinimumDistance(),
- );
+ this.targetPrice = this.normalizeTarget(snapshot.entryPrice, this.clampPriceToRange(nextPrice));
}
private resizeEnd(snapshot: SliderPositionState, point: Point): void {
@@ -892,13 +871,13 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
};
}
- private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
+ private normalizeStop(entryPrice: number, rawPrice: number, minDistance = 0): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
}
- private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
+ private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = 0): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
@@ -950,10 +929,6 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
}
- private getEditMinimumDistance(): number {
- return Math.max(this.tickSize, MIN_DISTANCE);
- }
-
private getPriceScaleRange(): { min: number; max: number } | null {
return getPriceRangeInContainer(this.series, this.container);
}
@@ -972,12 +947,13 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
+ const reward = Math.abs(this.targetPrice - this.entryPrice);
- if (!risk) {
- return this.defaultRiskRewardRatio;
+ if (risk <= MIN_DISTANCE || reward <= MIN_DISTANCE) {
+ return 0;
}
- return Math.abs(this.targetPrice - this.entryPrice) / risk;
+ return reward / risk;
}
private getPriceAtTime(time: Time | null): number | null {
@@ -1010,6 +986,38 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
return lastPrice;
}
+ protected getDrawingHandles(): readonly DrawingHandle<SliderHandleId>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return [
+ {
+ id: 'target',
+ x: geometry.startX,
+ y: geometry.targetY,
+ },
+ {
+ id: 'stop',
+ x: geometry.startX,
+ y: geometry.stopY,
+ },
+ {
+ id: 'end',
+ x: geometry.endX,
+ y: geometry.entryY,
+ },
+ {
+ id: 'entry',
+ x: geometry.startX,
+ y: geometry.entryY,
+ shape: 'circle',
+ },
+ ];
+ }
+
protected getGeometry(): SliderGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
@@ -1088,43 +1096,26 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
}
private getHandleTarget(point: Point): DragTarget {
- const geometry = this.getGeometry();
+ const handle = this.getDrawingHandleAtPoint(point);
- if (!geometry) {
- return null;
- }
-
- if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
- return 'entry';
+ if (handle) {
+ return handle.id;
}
- if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
- return 'target';
- }
-
- if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
- return 'stop';
- }
+ const geometry = this.getGeometry();
- if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
- return 'end';
+ if (!geometry) {
+ return null;
}
- const minY = Math.min(geometry.targetY, geometry.stopY);
- const maxY = Math.max(geometry.targetY, geometry.stopY);
-
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
- top: minY,
- bottom: maxY,
+ top: Math.min(geometry.targetY, geometry.stopY),
+ bottom: Math.max(geometry.targetY, geometry.stopY),
};
- if (isPointInBounds(point, bounds)) {
- return 'body';
- }
-
- return null;
+ return isPointInBounds(point, bounds) ? 'body' : null;
}
private containsPoint(point: Point): boolean {
@@ -1134,16 +1125,13 @@ export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> im
return false;
}
- const minY = Math.min(geometry.targetY, geometry.stopY);
- const maxY = Math.max(geometry.targetY, geometry.stopY);
-
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
- top: minY,
- bottom: maxY,
+ top: Math.min(geometry.targetY, geometry.stopY),
+ bottom: Math.max(geometry.targetY, geometry.stopY),
};
- return isPointInBounds(point, bounds, HIT_TOLERANCE);
+ return isPointInBounds(point, bounds, BODY_HIT_TOLERANCE);
}
}
diff --git a/src/core/Drawings/text/paneRenderer.ts b/src/core/Drawings/text/paneRenderer.ts
index 5576be418ca7de6e5cafbb491ca67c4be9420caf..91a0b7c32e8a448587e37ac427163ed4ed1b35c2 100644
--- a/src/core/Drawings/text/paneRenderer.ts
+++ b/src/core/Drawings/text/paneRenderer.ts
@@ -1,6 +1,7 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
+import { drawRoundedRect } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Text } from './text';
@@ -99,7 +100,7 @@ function fillRoundedRect(
radius: number,
): void {
context.beginPath();
- buildRoundedRectPath(context, x, y, width, height, radius);
+ drawRoundedRect(context, x, y, width, height, radius);
context.fill();
}
@@ -112,24 +113,6 @@ function strokeRoundedRect(
radius: number,
): void {
context.beginPath();
- buildRoundedRectPath(context, x, y, width, height, radius);
+ drawRoundedRect(context, x, y, width, height, radius);
context.stroke();
}
-
-function buildRoundedRectPath(
- context: CanvasRenderingContext2D,
- x: number,
- y: number,
- width: number,
- height: number,
- radius: number,
-): void {
- const safeRadius = Math.min(radius, width / 2, height / 2);
-
- context.moveTo(x + safeRadius, y);
- context.arcTo(x + width, y, x + width, y + height, safeRadius);
- context.arcTo(x + width, y + height, x, y + height, safeRadius);
- context.arcTo(x, y + height, x, y, safeRadius);
- context.arcTo(x, y, x + width, y, safeRadius);
- context.closePath();
-}
diff --git a/src/core/Drawings/traectory/paneRenderer.ts b/src/core/Drawings/traectory/paneRenderer.ts
index bb631bad223d92942c025c84cd29f8f92d923889..ca2d43113b01d91e8c7b51fbdb0c5169c28226b7 100644
--- a/src/core/Drawings/traectory/paneRenderer.ts
+++ b/src/core/Drawings/traectory/paneRenderer.ts
@@ -1,15 +1,11 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import { Traectory } from './traectory';
const UI = {
lineWidth: 2,
previewLineWidth: 1,
- handleRadius: 5,
- handleBorderWidth: 2,
previewDash: [6, 4],
arrowLength: 14,
arrowHalfWidth: 6,
@@ -33,8 +29,6 @@ export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = UI.lineWidth * pixelRatio;
const previewLineWidth = UI.previewLineWidth * pixelRatio;
- const handleRadius = UI.handleRadius * pixelRatio;
- const handleBorderWidth = UI.handleBorderWidth * pixelRatio;
const arrowLength = UI.arrowLength * pixelRatio;
const arrowHalfWidth = UI.arrowHalfWidth * pixelRatio;
@@ -68,6 +62,7 @@ export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
const lastPoint = data.points[data.points.length - 1];
context.lineWidth = lineWidth;
+
drawArrowHead(
context,
previousPoint.x * horizontalPixelRatio,
@@ -92,30 +87,11 @@ export class TraectoryPaneRenderer implements IPrimitivePaneRenderer {
context.restore();
}
- if (data.showHandles) {
- const { colors } = getThemeStore();
-
- context.fillStyle = colors.chartBackground;
- context.strokeStyle = colors.chartLineColor;
- context.lineWidth = handleBorderWidth;
-
- data.points.forEach((point) => {
- drawHandle(context, point.x * horizontalPixelRatio, point.y * verticalPixelRatio, handleRadius);
- });
- }
-
context.restore();
});
}
}
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
- context.beginPath();
- context.arc(x, y, radius, 0, Math.PI * 2);
- context.fill();
- context.stroke();
-}
-
function drawArrowHead(
context: CanvasRenderingContext2D,
fromX: number,
@@ -135,7 +111,6 @@ function drawArrowHead(
const directionX = dx / distance;
const directionY = dy / distance;
-
const normalX = -directionY;
const normalY = directionX;
diff --git a/src/core/Drawings/traectory/traectory.ts b/src/core/Drawings/traectory/traectory.ts
index 733448e72939ce60de45a7989d8f02116fa19360..38e7872f0dac7ff648bb3458eef76597e66f3976 100644
--- a/src/core/Drawings/traectory/traectory.ts
+++ b/src/core/Drawings/traectory/traectory.ts
@@ -11,7 +11,7 @@ import {
isNearPoint,
} from '@core/Drawings/helpers';
import { AxisLabel } from '@core/Drawings/types';
-import { updateViews } from '@core/Drawings/utils';
+import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
@@ -20,11 +20,13 @@ 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 { SettingsTab } from '@src/types';
type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging-body';
+type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;
@@ -45,7 +47,6 @@ interface TraectoryGeometry {
export interface TraectoryRenderData extends TraectoryGeometry, TraectoryStyle {
previewPoint: Point | null;
- showHandles: boolean;
showArrow: boolean;
}
@@ -53,12 +54,11 @@ const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
-export class Traectory extends SeriesDrawingBase<TraectorySettings> implements ISeriesDrawing {
+export class Traectory extends SeriesDrawingBase<TraectorySettings, TraectoryHandleId> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TraectorySettings = createDefaultSettings();
-
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
@@ -178,21 +178,49 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
return {
...geometry,
previewPoint: this.getPreviewPoint(),
- showHandles: this.shouldShowHandles(),
showArrow: this.mode !== 'drawing' && geometry.points.length > 1,
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<TraectoryHandleId>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ return geometry.points
+ .map<DrawingHandle<TraectoryHandleId>>((point, index) => ({
+ id: `${index}`,
+ ...point,
+ shape: 'circle',
+ size: 10,
+ borderWidth: 2,
+ }))
+ .reverse();
+ }
+
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
- const pointIndex = this.getPointIndexAt(point);
- if (pointIndex !== null) {
+ if (!this.isSelected()) {
+ if (!this.isPointNearTraectory(point)) {
+ return null;
+ }
+
+ return {
+ cursorStyle: 'move',
+ externalId: 'traectory',
+ zOrder: 'top',
+ };
+ }
+
+ if (this.getPointIndexAt(point) !== null) {
return {
cursorStyle: 'move',
externalId: 'traectory',
@@ -339,11 +367,8 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
return;
}
- const pointIndex = this.getPointIndexAt(point);
- const isInsideTraectory = pointIndex !== null || this.isPointNearTraectory(point);
-
if (!this.isSelected()) {
- if (!isInsideTraectory) {
+ if (!this.isPointNearTraectory(point)) {
return;
}
@@ -354,6 +379,8 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
return;
}
+ const pointIndex = this.getPointIndexAt(point);
+
if (pointIndex !== null) {
event.preventDefault();
event.stopPropagation();
@@ -642,15 +669,9 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
}
private getPointIndexAt(point: Point): number | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- const index = geometry.points.findIndex((item) => isNearPoint(point, item.x, item.y, POINT_HIT_TOLERANCE));
+ const handle = this.getDrawingHandleAtPoint(point);
- return index >= 0 ? index : null;
+ return handle ? Number(handle.id) : null;
}
private isPointNearTraectory(point: Point): boolean {
@@ -670,7 +691,7 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
const startPoint = geometry.points[index];
const endPoint = geometry.points[index + 1];
- if (this.getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
+ if (getDistanceToSegment(point, startPoint, endPoint) <= SEGMENT_HIT_TOLERANCE) {
return true;
}
}
@@ -678,25 +699,6 @@ export class Traectory extends SeriesDrawingBase<TraectorySettings> implements I
return false;
}
- private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
- const dx = endPoint.x - startPoint.x;
- const dy = endPoint.y - startPoint.y;
-
- if (dx === 0 && dy === 0) {
- return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
- }
-
- const t = Math.max(
- 0,
- Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
- );
-
- const projectionX = startPoint.x + t * dx;
- const projectionY = startPoint.y + t * dy;
-
- return Math.hypot(point.x - projectionX, point.y - projectionY);
- }
-
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
diff --git a/src/core/Drawings/utils.ts b/src/core/Drawings/utils.ts
index b9a1f5fb47d07793265c48a92879719bce7c2fea..a00fdf76fd8222319e2578fd891a5a0bf8baf12b 100644
--- a/src/core/Drawings/utils.ts
+++ b/src/core/Drawings/utils.ts
@@ -1,4 +1,4 @@
-import type { UpdatableView } from './types';
+import type { Point, UpdatableView } from './types';
export function updateViews(views: readonly UpdatableView[]): void {
for (const view of views) {
@@ -29,3 +29,40 @@ export function getOrderedSideValue<T>(
return startFirst ? endValue : startValue;
}
+
+export function drawRoundedRect(
+ context: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number,
+): void {
+ const safeRadius = Math.min(radius, width / 2, height / 2);
+
+ context.moveTo(x + safeRadius, y);
+ context.arcTo(x + width, y, x + width, y + height, safeRadius);
+ context.arcTo(x + width, y + height, x, y + height, safeRadius);
+ context.arcTo(x, y + height, x, y, safeRadius);
+ context.arcTo(x, y, x + width, y, safeRadius);
+ context.closePath();
+}
+
+export function getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
+ const dx = endPoint.x - startPoint.x;
+ const dy = endPoint.y - startPoint.y;
+
+ if (dx === 0 && dy === 0) {
+ return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
+ }
+
+ const t = Math.max(
+ 0,
+ Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
+ );
+
+ const projectionX = startPoint.x + t * dx;
+ const projectionY = startPoint.y + t * dy;
+
+ return Math.hypot(point.x - projectionX, point.y - projectionY);
+}
diff --git a/src/core/Drawings/volumeProfile/paneRenderer.ts b/src/core/Drawings/volumeProfile/paneRenderer.ts
index 34ec06d9527778fac4ef50bd258c24682965838c..ae0b011bc356a6750bfba2a1fbb3de0fd14c927a 100644
--- a/src/core/Drawings/volumeProfile/paneRenderer.ts
+++ b/src/core/Drawings/volumeProfile/paneRenderer.ts
@@ -1,13 +1,9 @@
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
-import { getThemeStore } from '@src/theme';
-
import type { VolumeProfile, VolumeProfileKind } from './volumeProfile';
const UI = {
- handleRadius: 5,
- handleBorderWidth: 2,
rowGap: 1,
pocLineWidth: 2,
};
@@ -68,21 +64,6 @@ export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
const pocY = data.pocY * verticalPixelRatio;
drawPocLine(context, data.profileKind, left, right, pocY, pixelRatio, data.pocLineColor);
-
- if (data.showHandles && data.profileKind === 'visibleRange') {
- drawHandle(context, left, pocY, pixelRatio);
- }
- }
-
- if (data.showHandles && data.profileKind === 'fixedRange') {
- drawHandle(
- context,
- data.startPoint.x * horizontalPixelRatio,
- data.startPoint.y * verticalPixelRatio,
- pixelRatio,
- );
-
- drawHandle(context, data.endPoint.x * horizontalPixelRatio, data.endPoint.y * verticalPixelRatio, pixelRatio);
}
context.restore();
@@ -156,20 +137,3 @@ function drawPocLine(
context.lineTo(lineRight, y);
context.stroke();
}
-
-function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, pixelRatio: number): void {
- context.save();
-
- const { colors } = getThemeStore();
-
- context.fillStyle = colors.chartBackground;
- context.strokeStyle = colors.chartLineColor;
- context.lineWidth = UI.handleBorderWidth * pixelRatio;
-
- context.beginPath();
- context.arc(x, y, UI.handleRadius * pixelRatio, 0, Math.PI * 2);
- context.fill();
- context.stroke();
-
- context.restore();
-}
diff --git a/src/core/Drawings/volumeProfile/volumeProfile.ts b/src/core/Drawings/volumeProfile/volumeProfile.ts
index 60066238661b2fbde4dd259f51336449ea4e66a4..1c2ba0dd30e4efc2d74a9d55e904a860b6bb0441 100644
--- a/src/core/Drawings/volumeProfile/volumeProfile.ts
+++ b/src/core/Drawings/volumeProfile/volumeProfile.ts
@@ -13,7 +13,6 @@ import {
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
- isNearPoint,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
@@ -34,6 +33,7 @@ import {
VolumeProfileStyle,
} from './settings';
+import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel } from '@src/types';
@@ -42,6 +42,7 @@ export type VolumeProfileKind = 'fixedRange' | 'visibleRange';
type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;
+type VolumeProfileHandleId = Exclude<DragTarget, 'body' | null>;
interface VolumeProfileParams extends BaseDrawingParams {
profileKind?: VolumeProfileKind;
@@ -100,16 +101,17 @@ export interface VolumeProfileRenderData extends VolumeProfileGeometry, VolumePr
profileKind: VolumeProfileKind;
rows: VolumeProfileRenderRow[];
pocY: number | null;
- showHandles: boolean;
}
const PROFILE_ROW_COUNT = 24;
-const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
-export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> implements ISeriesDrawing {
+export class VolumeProfile
+ extends SeriesDrawingBase<VolumeProfileSettings, VolumeProfileHandleId>
+ implements ISeriesDrawing
+{
private removeSelf?: () => void;
private openSettings?: () => void;
@@ -358,11 +360,33 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
profileKind: this.profileKind,
rows,
pocY,
- showHandles: this.shouldShowHandles(),
...this.settings,
};
}
+ protected getDrawingHandles(): readonly DrawingHandle<VolumeProfileHandleId>[] {
+ const geometry = this.getGeometry();
+
+ if (!geometry) {
+ return [];
+ }
+
+ if (this.profileKind === 'visibleRange') {
+ const pocY = this.getPocY(geometry);
+
+ if (pocY === null) {
+ return [];
+ }
+
+ return [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle', size: 10, borderWidth: 2 }];
+ }
+
+ return [
+ { id: 'end', ...geometry.endPoint, shape: 'circle', size: 10, borderWidth: 2 },
+ { id: 'start', ...geometry.startPoint, shape: 'circle', size: 10, borderWidth: 2 },
+ ];
+ }
+
protected getTimeAxisSegments(): AxisSegment[] {
if (this.profileKind === 'visibleRange' || (!this.isSelected() && !this.isCreationPending())) {
return [];
@@ -543,7 +567,7 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
};
protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
- const dragTarget = this.getVisibleRangeDragTarget(point);
+ let dragTarget = this.getVisibleRangeDragTarget(point);
if (!dragTarget) {
if (this.isSelected()) {
@@ -558,6 +582,7 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
if (!this.isSelected()) {
this.select();
+ dragTarget = this.getVisibleRangeDragTarget(point);
}
if (dragTarget !== 'poc') {
@@ -849,15 +874,9 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
}
private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- const pocY = this.getPocY(geometry);
+ const handle = this.getDrawingHandleAtPoint(point);
- if (pocY !== null && isNearPoint(point, geometry.left, pocY, HANDLE_HIT_TOLERANCE)) {
+ if (handle?.id === 'poc') {
return 'poc';
}
@@ -869,18 +888,10 @@ export class VolumeProfile extends SeriesDrawingBase<VolumeProfileSettings> impl
}
private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
- const geometry = this.getGeometry();
-
- if (!geometry) {
- return null;
- }
-
- if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
- return 'start';
- }
+ const handle = this.getDrawingHandleAtPoint(point);
- if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
- return 'end';
+ if (handle) {
+ return handle.id;
}
if (this.containsPoint(point)) {
diff --git a/tsconfig.json b/tsconfig.json
index 27c78cf1810ec0e064002d9a61b8c82781735ddd..c19bc2f96a6a7d6eb405fb879b8b26035bb0de0a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -30,6 +30,6 @@
"resolveJsonModule": true,
"sourceMap": true
},
- "include": ["src", "stories", ".storybook", "jest.config.ts"],
+ "include": ["src", "stories", ".storybook", "jest.config.ts", "jest.setup.ts"],
"exclude": ["dist", "node_modules"]
}