Загрузка данных
diff --git a/.gitignore b/.gitignore
index 81032cd0664cf01d0646f2decaf1f8b0627f6d1b..8f371ae3895902342be8db77a1bca3fc876e774a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ storybook-static
yalc.lock
coverage/
.scannerwork/
+.kilo/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ced559768fe9af635167c13cacfc74c73b5cfe4..919ca48e60dda72239cb92ca105c8c7477ef70ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@
- Добавлены тултипы для элементов управления в шапке графика
- Добавлен параметр `showSnapshotButton?: boolean` для управления отображением кнопки «Снимок графика»
- Добавлен VWAP индикатор
+- Добавлена механика ctrl+c/ctrl+v для дровингов
# 0.2.1
diff --git a/src/components/Loader/index.module.scss b/src/components/Loader/index.module.scss
new file mode 100644
index 0000000000000000000000000000000000000000..6cd13ece627595713578598758e1dac98a380636
--- /dev/null
+++ b/src/components/Loader/index.module.scss
@@ -0,0 +1,28 @@
+.loaderWrapper {
+ position: 'relative';
+ width: '100%';
+ height: '100%';
+ background-color: '#fff';
+ z-index: '100';
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.loader {
+ border: 10px solid #1b1717;
+ border-top: 10px solid #242627;
+ border-radius: 50%;
+ width: 120px;
+ height: 120px;
+ animation: spin 2s linear infinite;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..89c48f1cce9882665e5845f11cd05ec6617d4594
--- /dev/null
+++ b/src/components/Loader/index.tsx
@@ -0,0 +1,12 @@
+import styles from './index.module.scss';
+
+export const Loader = () => {
+ return (
+ <div
+ className={styles.loaderWrapper}
+ style={{ position: 'relative', width: '100%', height: '100%', backgroundColor: 'transparent', zIndex: '100' }}
+ >
+ <div className={styles.loader} />
+ </div>
+ );
+};
diff --git a/src/core/ContainerManager.ts b/src/core/ContainerManager.ts
index 3f7411d0a68145bb732104700ed34f3547550be6..538b7209b46fed4ba9dbae018ac79177914d0379 100644
--- a/src/core/ContainerManager.ts
+++ b/src/core/ContainerManager.ts
@@ -155,6 +155,15 @@ export class ContainerManager {
minHeight: '0',
});
+ const loaderContainer = document.createElement('div');
+ loaderContainer.style.position = 'absolute';
+ loaderContainer.style.height = '100%';
+ loaderContainer.style.width = '100%';
+ loaderContainer.style.minHeight = '0';
+ loaderContainer.style.minWidth = '0';
+ loaderContainer.style.outline = 'none';
+ loaderContainer.tabIndex = -1;
+
const toolBarContainer = document.createElement('div');
Object.assign(toolBarContainer.style, {
@@ -195,7 +204,7 @@ export class ContainerManager {
});
seasonalityContainer.append(seasonalityControlsContainer, seasonalityPlotContainer);
- chartAreaContainer.append(controlBarContainer, drawingToolbarContainer);
+ chartAreaContainer.append(controlBarContainer, drawingToolbarContainer, loaderContainer);
chartContainer.append(chartAreaContainer, modalContainer);
chartViewContainer.append(chartContainer, seasonalityContainer);
parentContainer.append(headerContainer, chartViewContainer);
@@ -240,6 +249,7 @@ export class ContainerManager {
modalContainer,
controlBarContainer,
drawingToolbarContainer,
+ loaderContainer,
toggleToolbar,
};
}
diff --git a/src/core/DataSource.ts b/src/core/DataSource.ts
index 1017342b5907e89a3aac7ce33923d63bfc47ad1c..adcdca0e017deda7f635ab99f608e1266f30340f 100644
--- a/src/core/DataSource.ts
+++ b/src/core/DataSource.ts
@@ -1,5 +1,5 @@
-import { BehaviorSubject, combineLatest, Observable, Subject, Subscription } from 'rxjs';
-import { map } from 'rxjs/operators';
+import { BehaviorSubject, combineLatest, Observable, of, Subject, Subscription } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
import { EventManager } from '@core/EventManager';
import { SymbolSource } from '@src/core/SymbolSource';
@@ -26,6 +26,7 @@ export class DataSource {
private readonly eventManager: EventManager;
private readonly states = new Map<string, SymbolSource>();
+ private readonly activeStates$ = new BehaviorSubject<Set<SymbolSource>>(new Set());
private readonly activeSymbols$ = new BehaviorSubject<Set<string>>(new Set());
private realtimeSub: Subscription | null = null;
@@ -37,6 +38,23 @@ export class DataSource {
this.initSubscriptions();
}
+ public isLoading = (): Observable<boolean> => {
+ return this.activeStates$.pipe(
+ switchMap((activeStates: Set<SymbolSource>) => {
+ if (activeStates.size === 0) {
+ return of(false);
+ }
+
+ const isSymbolLoading$ = Array.from(activeStates).map((st) => st.isLoading());
+ return combineLatest(isSymbolLoading$).pipe(
+ map((isSymbolLoading) => {
+ return isSymbolLoading.some((val) => val === true);
+ }),
+ );
+ }),
+ );
+ };
+
public setSymbols(symbols: string[]): void {
const nextSet = new Set<string>();
@@ -175,6 +193,17 @@ export class DataSource {
}
private initSubscriptions(): void {
+ this.subscriptions.add(
+ this.activeSymbols$.subscribe((symbolsSet) => {
+ const symbolSources: Set<SymbolSource> = new Set();
+ [...symbolsSet].forEach((symbol) => {
+ symbolSources.add(this.ensureState(symbol));
+ });
+
+ this.activeStates$.next(symbolSources);
+ }),
+ );
+
this.subscriptions.add(
combineLatest([this.eventManager.getSelectedSeries()]).subscribe(() => {
this.states.forEach((st) => st.saveRealtimeCache());
diff --git a/src/core/Drawings.ts b/src/core/Drawings.ts
index dc72e4e6bddef066e1e1907753944e365166c2b6..d80060268388852b2acd22de541901861ea4354a 100644
--- a/src/core/Drawings.ts
+++ b/src/core/Drawings.ts
@@ -15,7 +15,6 @@ interface DrawingParams extends DOMObjectParams {
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
- onCopy: () => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
@@ -35,7 +34,6 @@ export class Drawing extends DOMObject {
private subscriptions = new Subscription();
private unregisterDeleteHotkey = () => {};
- private unregisterCopyHotkey = () => {};
constructor({
lwcChart,
@@ -44,7 +42,6 @@ export class Drawing extends DOMObject {
drawingName,
id,
onDelete,
- onCopy,
zIndex,
moveUp,
moveDown,
@@ -103,15 +100,6 @@ export class Drawing extends DOMObject {
this.delete();
},
});
-
- this.unregisterCopyHotkey = this.hotkeys.register({
- keys: [Keys.mod, Keys.c],
- callback: () => {
- if (!this.isCreationPending()) {
- onCopy();
- }
- },
- });
}),
);
}
@@ -219,9 +207,7 @@ export class Drawing extends DOMObject {
private unregisterSelectedDrawingHotkeys(): void {
this.unregisterDeleteHotkey();
- this.unregisterCopyHotkey();
this.unregisterDeleteHotkey = () => {};
- this.unregisterCopyHotkey = () => {};
}
}
diff --git a/src/core/Drawings/axisLine/axisLine.ts b/src/core/Drawings/axisLine/axisLine.ts
index 9da496e9abda866ef0c076e722b784b3433974e3..8bce3938d26a20a7ddfa9640033e2fc3a72cf043 100644
--- a/src/core/Drawings/axisLine/axisLine.ts
+++ b/src/core/Drawings/axisLine/axisLine.ts
@@ -36,6 +36,19 @@ interface AxisLineParams extends BaseDrawingParams {
direction: AxisLineDirection;
}
+export function isAxisLineState(state: unknown): state is AxisLineState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return (
+ 'time' in state &&
+ 'price' in state &&
+ ((typeof state.time === 'number' && state.price === null) ||
+ (state.time === null && typeof state.price === 'number'))
+ );
+}
+
interface AxisLineState {
hidden: boolean;
mode: AxisLineMode;
diff --git a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
index a288a14cad591c7118567b6e2718e3ac59e7ea6b..ccc8e3b691698e1783ad044b1fc162651b56d9e1 100644
--- a/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
+++ b/src/core/Drawings/fibonacciRetracement/fibonacciRetracement.ts
@@ -29,6 +29,23 @@ type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;
+export function isFibonacciRetracementState(state: unknown): state is FibonacciRetracementState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return (
+ 'startTime' in state &&
+ typeof state.startTime === 'number' &&
+ 'endTime' in state &&
+ typeof state.endTime === 'number' &&
+ 'startPrice' in state &&
+ typeof state.startPrice === 'number' &&
+ 'endPrice' in state &&
+ typeof state.endPrice === 'number'
+ );
+}
+
interface FibonacciRetracementState {
hidden: boolean;
mode: FibonacciRetracementMode;
diff --git a/src/core/Drawings/line/lineDrawing.ts b/src/core/Drawings/line/lineDrawing.ts
index 128255b28d9bd901e225f6d8c796e10de919980b..b21f8814cb7baf4267f114b63f04da81cd8b5e4e 100644
--- a/src/core/Drawings/line/lineDrawing.ts
+++ b/src/core/Drawings/line/lineDrawing.ts
@@ -5,6 +5,8 @@ import { BaseDrawingParams, ISeriesDrawing, LinearDrawingBase, TwoPointGeometry
import { type ChartOptionsModel, Defaults, LineMarker, type SettingsTab } from '@src/types';
import { formatDate, formatPrice } from '@src/utils';
+import { isAnchor } from '../utils';
+
import { LineDrawingPaneView } from './paneView';
import { createDefaultSettings, getLineDrawingSettingsTabs } from './settings';
@@ -24,6 +26,14 @@ interface LineDrawingParams extends BaseDrawingParams {
defaultMarkers?: Partial<LineDrawingMarkers>;
}
+export function isLineDrawingState(state: unknown): state is LineDrawingState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return 'startAnchor' in state && isAnchor(state.startAnchor) && 'endAnchor' in state && isAnchor(state.endAnchor);
+}
+
interface LineDrawingState {
hidden: boolean;
mode: LineDrawingMode;
diff --git a/src/core/Drawings/ray/ray.ts b/src/core/Drawings/ray/ray.ts
index 46a152f0867d181894d1d1210a1698eb5ccf7261..a90c04a7ddd3d251334b15d6dfae716d30bc429f 100644
--- a/src/core/Drawings/ray/ray.ts
+++ b/src/core/Drawings/ray/ray.ts
@@ -1,6 +1,6 @@
import { PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
-import { getDistanceToSegment } from '@core/Drawings/utils';
+import { getDistanceToSegment, isAnchor } from '@core/Drawings/utils';
import { BaseDrawingParams, ISeriesDrawing, LinearDrawingBase } from '@src/core/Drawings/base';
import { type ChartOptionsModel, Defaults, type SettingsTab } from '@src/types';
@@ -23,6 +23,19 @@ type PriceLabelKind = 'start' | 'direction';
type RayParams = BaseDrawingParams;
+export function isRayState(state: unknown): state is RayState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return (
+ 'startAnchor' in state &&
+ isAnchor(state.startAnchor) &&
+ 'directionAnchor' in state &&
+ isAnchor(state.directionAnchor)
+ );
+}
+
interface RayState {
hidden: boolean;
mode: RayMode;
diff --git a/src/core/Drawings/regressionTrend/regressionTrend.ts b/src/core/Drawings/regressionTrend/regressionTrend.ts
index 6ed24bfd367a9c32664674a6d64490e755e6eb9a..b9ad932850c9c26eeb097eb13ffb7b83a6b45774 100644
--- a/src/core/Drawings/regressionTrend/regressionTrend.ts
+++ b/src/core/Drawings/regressionTrend/regressionTrend.ts
@@ -25,6 +25,19 @@ type PriceLabelKind = 'start' | 'end';
type RegressionTrendParams = BaseDrawingParams;
+export function isRegressionTrendState(state: unknown): state is RegressionTrendState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return (
+ 'startTime' in state &&
+ typeof state.startTime === 'number' &&
+ 'endTime' in state &&
+ typeof state.endTime === 'number'
+ );
+}
+
interface RegressionTrendState {
hidden: boolean;
mode: RegressionTrendMode;
diff --git a/src/core/Drawings/sliderPosition/sliderPosition.ts b/src/core/Drawings/sliderPosition/sliderPosition.ts
index c9abaa9ac3918de4726334d0c6b7d8ef2983537b..1fa5d8d7bbb73335e7c4a9e9dccbd55a5bf7b3c4 100644
--- a/src/core/Drawings/sliderPosition/sliderPosition.ts
+++ b/src/core/Drawings/sliderPosition/sliderPosition.ts
@@ -51,6 +51,25 @@ interface SliderPositionParams extends BaseDrawingParams {
resetTriggers?: Observable<unknown>[];
}
+export function isSliderPositionState(state: unknown): state is SliderPositionState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return (
+ 'startTime' in state &&
+ typeof state.startTime === 'number' &&
+ 'endTime' in state &&
+ typeof state.endTime === 'number' &&
+ 'entryPrice' in state &&
+ typeof state.entryPrice === 'number' &&
+ 'stopPrice' in state &&
+ typeof state.stopPrice === 'number' &&
+ 'targetPrice' in state &&
+ typeof state.targetPrice === 'number'
+ );
+}
+
interface SliderPositionState {
hidden: boolean;
mode: SliderMode;
diff --git a/src/core/Drawings/text/text.ts b/src/core/Drawings/text/text.ts
index 03c59c46ef8dfb2eb95b2ab6be10cfd3ddff0b65..9489acb4130a608630839b4dc7c9560896bd54ee 100644
--- a/src/core/Drawings/text/text.ts
+++ b/src/core/Drawings/text/text.ts
@@ -9,7 +9,7 @@ import {
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
-import { updateViews } from '@core/Drawings/utils';
+import { isAnchor, updateViews } from '@core/Drawings/utils';
import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
import { Defaults, SettingsTab } from '@src/types';
@@ -23,6 +23,14 @@ import type { ChartOptionsModel } from '@src/types';
type TextMode = 'idle' | 'dragging' | 'ready';
+export function isTextState(state: unknown): state is TextState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return 'point' in state && isAnchor(state.point);
+}
+
export interface TextState {
hidden: boolean;
mode: TextMode;
diff --git a/src/core/Drawings/traectory/traectory.ts b/src/core/Drawings/traectory/traectory.ts
index 8d2fb230572b8d8ba90ae0309c7ca6a990f4c88d..aad51bf06c25bc85543f7e8f7e287e24f00a8512 100644
--- a/src/core/Drawings/traectory/traectory.ts
+++ b/src/core/Drawings/traectory/traectory.ts
@@ -2,7 +2,7 @@ import { PrimitiveHoveredItem } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { clampPointToContainer, getContainerSize, isNearPoint } from '@core/Drawings/helpers';
-import { getDistanceToSegment } from '@core/Drawings/utils';
+import { getDistanceToSegment, isAnchor } from '@core/Drawings/utils';
import { BaseDrawingParams, DrawingBase, ISeriesDrawing } from '@src/core/Drawings/base';
@@ -18,6 +18,14 @@ type TraectoryMode = 'idle' | 'drawing' | 'ready' | 'dragging-point' | 'dragging
type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;
+export function isTraectoryState(state: unknown): state is TraectoryState {
+ if (typeof state !== 'object' || state === null) {
+ return false;
+ }
+
+ return 'points' in state && Array.isArray(state.points) && state.points.every((point) => isAnchor(point));
+}
+
export interface TraectoryState {
hidden: boolean;
mode: TraectoryMode;
diff --git a/src/core/Drawings/utils.ts b/src/core/Drawings/utils.ts
index a00fdf76fd8222319e2578fd891a5a0bf8baf12b..92cbcc6a3b8f89503446106dcd7173e2351a9b68 100644
--- a/src/core/Drawings/utils.ts
+++ b/src/core/Drawings/utils.ts
@@ -1,4 +1,4 @@
-import type { Point, UpdatableView } from './types';
+import type { Anchor, Point, UpdatableView } from './types';
export function updateViews(views: readonly UpdatableView[]): void {
for (const view of views) {
@@ -66,3 +66,14 @@ export function getDistanceToSegment(point: Point, startPoint: Point, endPoint:
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
+
+export function isAnchor(value: unknown): value is Anchor {
+ return (
+ value !== null &&
+ typeof value === 'object' &&
+ 'time' in value &&
+ typeof value.time === 'number' &&
+ 'price' in value &&
+ typeof value.price === 'number'
+ );
+}
diff --git a/src/core/DrawingsManager.tsx b/src/core/DrawingsManager.tsx
index 5b1f486c6e87eaff0dd87fbceb90637769eb5ccb..87ed194714fbeb9886ef4e46b93e2bf6c934cff8 100644
--- a/src/core/DrawingsManager.tsx
+++ b/src/core/DrawingsManager.tsx
@@ -13,12 +13,13 @@ import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool, DOMObjectSnapshot } from '@src/types';
+import { COPPIED_DRWAING_ID } from './DrawingsManagerCollection';
import { Pane } from './Pane';
import type { DrawingInteraction } from '@src/core/Drawings/base';
import type { SettingsValues } from '@src/types/settings';
-interface DrawingsManagerParams {
+export interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
@@ -69,7 +70,6 @@ export class DrawingsManager {
private selectedDrawingSnapshot: DrawingSnapshotItem | null = null;
private pane: Pane;
- private copyPasteBuffer: DrawingSnapshotItem | null = null;
private setActiveTool: (name: ActiveDrawingTool) => void;
private getActiveTool: () => ActiveDrawingTool;
private getIsEndlessMode: () => boolean;
@@ -120,36 +120,14 @@ export class DrawingsManager {
}),
);
+ // todo: перенести в panе
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('contextmenu', this.handleContextMenu);
- // todo: implement ctrl+v
- // hotkeys.register({
- // keys: [Keys.control, Keys.v],
- // callback: () => {
- // const bufferWithAppliedPosition = {
- // ...this.copyPasteBuffer,
- // state: {
- // ...this.copyPasteBuffer?.state,
- // startAnchor: {
- // price: 73.36210252637723,
- // time: 1783679170
- // }
- // }
- // }
- //
- // this.setSnapshot([
- // ...this.getSnapshot(),
- // bufferWithAppliedPosition
- // ])
- // // hotkeys.unregister({ // todo: unregister all else ctrl+c's
- // // keys: [Keys.control, Keys.c]
- // // })
- // }
- // })
+ this.container.addEventListener('contextmenu', this.handleContextMenu);
}
// TODO: handlePointerDown конфликтует с DrawingsManagerCollection.handlePaneClick
@@ -240,6 +218,7 @@ export class DrawingsManager {
};
private handleClick = (event: MouseEvent): void => {
+ // todo: move method to pane
if (!this.isEventInPane(event)) {
return;
}
@@ -248,6 +227,8 @@ export class DrawingsManager {
pendingDrawing?.getSeriesDrawing().click(event);
+ this.pane.setSelected();
+
this.DOM.refreshEntities();
};
@@ -561,11 +542,6 @@ export class DrawingsManager {
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
- onCopy: () => {
- if (createdDrawing) {
- this.copyPasteBuffer = this.createDrawingSnapshot(createdDrawing);
- }
- },
zIndex: entityZIndex,
moveDown,
moveUp,
@@ -634,13 +610,20 @@ export class DrawingsManager {
drawings.push(
this.createDrawing({
name: item.drawingName,
- options: {
- id: item.id,
- state: cloneDeep(item.state),
- isLocked: item.isLocked,
- zIndex: item.zIndex,
- shouldUpdateDrawingsList: false,
- },
+ options:
+ item.id === COPPIED_DRWAING_ID
+ ? {
+ state: cloneDeep(item.state),
+ isLocked: item.isLocked,
+ shouldUpdateDrawingsList: false,
+ }
+ : {
+ id: item.id,
+ state: cloneDeep(item.state),
+ isLocked: item.isLocked,
+ zIndex: item.zIndex,
+ shouldUpdateDrawingsList: false,
+ },
}),
);
@@ -698,6 +681,16 @@ export class DrawingsManager {
this.removeDrawing(drawing.id);
}
+ public getSelectedDrawingSnapshot(): DrawingSnapshotItem | null {
+ const drawing = this.selectedDrawing$.value;
+
+ if (!drawing) {
+ return null;
+ }
+
+ return this.createDrawingSnapshot(drawing);
+ }
+
public toggleSelectedDrawingLock(): void {
const drawing = this.selectedDrawing$.value;
@@ -768,7 +761,6 @@ export class DrawingsManager {
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.selectedDrawingSnapshot = null;
- this.copyPasteBuffer = null;
this.subscriptions.unsubscribe();
this.drawings$.complete();
diff --git a/src/core/DrawingsManagerCollection.ts b/src/core/DrawingsManagerCollection.ts
index 06359f4739f3b061fc75e939bb071ed0629aa35c..faacd33abbe9fd62ec3d54152f6e742e04a91bdf 100644
--- a/src/core/DrawingsManagerCollection.ts
+++ b/src/core/DrawingsManagerCollection.ts
@@ -1,21 +1,50 @@
-import { BehaviorSubject, Observable } from 'rxjs';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { DrawingsNames } from '@src/constants';
import { DRAWING_KEYBOARD_SHORTCUTS, findDrawingPointerShortcut } from '@src/core/Drawings/shortcuts';
import { ActiveDrawingTool, SettingsValues } from '@src/types';
+import { ChartMouseEvents } from './ChartMouseEvents';
import { Drawing } from './Drawings';
-import { DrawingsManager } from './DrawingsManager';
+import { isAxisLineState } from './Drawings/axisLine/axisLine';
+import { isFibonacciRetracementState } from './Drawings/fibonacciRetracement/fibonacciRetracement';
+import {
+ getPriceFromYCoordinate,
+ getTimeFromXCoordinate,
+ getXCoordinateFromTime,
+ getYCoordinateFromPrice,
+} from './Drawings/helpers';
+import { isLineDrawingState } from './Drawings/line/lineDrawing';
+import { isRayState } from './Drawings/ray/ray';
+import { isRegressionTrendState } from './Drawings/regressionTrend/regressionTrend';
+import { isSliderPositionState } from './Drawings/sliderPosition/sliderPosition';
+import { isTextState } from './Drawings/text/text';
+import { isTraectoryState } from './Drawings/traectory/traectory';
+import { Anchor, SeriesApi } from './Drawings/types';
+import { DrawingsManager, DrawingSnapshotItem } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';
import type { Pane } from './Pane';
+import type { IChartApi, MouseEventParams, Point, Time } from 'lightweight-charts';
-import type { MouseEventParams } from 'lightweight-charts';
+export interface DrawingsManagerCollectionParams {
+ hotkeys: Hotkeys;
+ selectedPane: Observable<Pane | null>;
+ subscribeChartEvent: ChartMouseEvents['subscribe'];
+ lwcChart: IChartApi;
+}
+
+export const COPPIED_DRWAING_ID = 'coppied';
-// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
private managersMap: Map<number, DrawingsManager> = new Map();
private hotkeys: Hotkeys;
+ private lwcChart: IChartApi;
+ private selectedPane: Pane | null = null;
+ private copyPasteBuffer: DrawingSnapshotItem | null = null;
+ private cursorPos = new BehaviorSubject<null | Point>(null);
+ private hoveredPane = new BehaviorSubject<null | number>(null);
+ private copyPasteAnchor: Anchor | null = null;
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
@@ -23,26 +52,26 @@ export class DrawingsManagerCollection {
private isAwaitingDrawingStart = false;
private unregisterHotkeys: (() => void)[] = [];
- constructor({ hotkeys }: { hotkeys: Hotkeys }) {
+ private subscriptions = new Subscription();
+
+ constructor({ hotkeys, selectedPane, subscribeChartEvent, lwcChart }: DrawingsManagerCollectionParams) {
this.hotkeys = hotkeys;
+ this.lwcChart = lwcChart;
- for (const { keys, drawingName } of DRAWING_KEYBOARD_SHORTCUTS) {
- this.unregisterHotkeys.push(
- this.hotkeys.register({
- keys,
- callback: () => {
- this.activateDrawingTool(drawingName);
- },
- }),
- );
- }
+ this.subscriptions.add(
+ subscribeChartEvent('crosshairMove', (param) => {
+ this.cursorPos.next(param.point ?? null);
+ this.hoveredPane.next(param.paneIndex ?? null);
+ }),
+ );
- this.unregisterHotkeys.push(
- this.hotkeys.register({
- keys: [Keys.escape],
- callback: this.activateCrosshair,
+ this.subscriptions.add(
+ selectedPane.subscribe((next) => {
+ this.selectedPane = next;
}),
);
+
+ this.hotkeysSetup();
}
public getIsEndlessMode(): boolean {
@@ -144,15 +173,310 @@ export class DrawingsManagerCollection {
unregisterHotkey();
}
+ this.subscriptions.unsubscribe();
+
this.unregisterHotkeys = [];
this.activeTool$.complete();
this.endlessMode$.complete();
}
+ public copy() {
+ // public only for tests and this is sad
+ const pane = this.selectedPane;
+ if (!pane || this.hoveredPane.value !== pane.paneIndex()) {
+ return;
+ }
+
+ const drawingManager = pane.getDrawingManager();
+
+ const yCoord = this.cursorPos.value?.y;
+ const xCoord = this.cursorPos.value?.x;
+
+ if (yCoord === undefined || xCoord === undefined) {
+ this.copyPasteBuffer = null;
+ return;
+ }
+
+ this.copyPasteBuffer = drawingManager.getSelectedDrawingSnapshot();
+ this.copyPasteAnchor = {
+ time: <Time>getTimeFromXCoordinate(this.lwcChart, xCoord),
+ price: <number>getPriceFromYCoordinate(pane.getLocalMainSeries() as SeriesApi, yCoord),
+ };
+ }
+
+ public paste() {
+ // public only for tests and this is sad
+ const pane = this.selectedPane;
+ if (!pane || this.hoveredPane.value !== pane.paneIndex()) {
+ return;
+ }
+
+ const drawingManager = pane.getDrawingManager();
+
+ const drawingToPaste = this.copyPasteBuffer;
+
+ if (!drawingToPaste) {
+ return;
+ }
+
+ const { state } = drawingToPaste;
+
+ const yCoord = this.cursorPos.value?.y;
+ const xCoord = this.cursorPos.value?.x;
+
+ if (yCoord === undefined || xCoord === undefined || !this.copyPasteAnchor) {
+ return;
+ }
+
+ const { time: prevTime, price: prevPrice } = this.copyPasteAnchor;
+
+ const onCopyYCoord = getYCoordinateFromPrice(pane.getLocalMainSeries() as SeriesApi, prevPrice);
+ const onCopyXCoord = getXCoordinateFromTime(this.lwcChart, prevTime, pane.getLocalMainSeries() as SeriesApi);
+
+ if (onCopyYCoord === null || onCopyXCoord === null) {
+ return;
+ }
+
+ const shift = {
+ priceDiff: yCoord - onCopyYCoord,
+ timeDiff: xCoord - onCopyXCoord,
+ };
+
+ const shiftedState = shiftDrawingStateCoordsTo(state, shift, this.lwcChart, pane.getLocalMainSeries() as SeriesApi);
+
+ const bufferWithAppliedPosition: DrawingSnapshotItem = {
+ ...drawingToPaste,
+ id: COPPIED_DRWAING_ID,
+ zIndex: drawingToPaste.zIndex,
+ state: shiftedState,
+ };
+
+ drawingManager.setSnapshot([...drawingManager.getSnapshot(), bufferWithAppliedPosition]);
+ }
+
private cancelPendingDrawings(): void {
for (const manager of this.managersMap.values()) {
manager.cancelPendingDrawing();
}
}
+
+ private hotkeysSetup(): void {
+ for (const { keys, drawingName } of DRAWING_KEYBOARD_SHORTCUTS) {
+ this.unregisterHotkeys.push(
+ this.hotkeys.register({
+ keys,
+ callback: () => {
+ this.activateDrawingTool(drawingName);
+ },
+ }),
+ );
+ }
+
+ this.unregisterHotkeys.push(
+ this.hotkeys.register({
+ keys: [Keys.mod, Keys.c],
+ callback: () => this.copy(),
+ }),
+ );
+
+ this.unregisterHotkeys.push(
+ this.hotkeys.register({
+ keys: [Keys.mod, Keys.v],
+ callback: () => this.paste(),
+ }),
+ );
+
+ this.unregisterHotkeys.push(
+ this.hotkeys.register({
+ keys: [Keys.escape],
+ callback: this.activateCrosshair,
+ }),
+ );
+ }
+}
+
+export function shiftPointByLogicalCoords(
+ time: Time,
+ price: number,
+ shift: { timeDiff: number; priceDiff: number },
+ chart: IChartApi,
+ serie: SeriesApi,
+): { time: Time; price: number } {
+ const startTimeCoord = getXCoordinateFromTime(chart, time, serie);
+ const startPriceCoord = getYCoordinateFromPrice(serie, price);
+
+ if (startTimeCoord === null || startPriceCoord === null) {
+ return {
+ time,
+ price,
+ };
+ }
+
+ const resTime = getTimeFromXCoordinate(chart, startTimeCoord + shift.timeDiff);
+ const resPrice = getPriceFromYCoordinate(serie, startPriceCoord + shift.priceDiff);
+
+ return {
+ time: resTime ?? time,
+ price: resPrice ?? price,
+ };
+}
+
+export function shiftDrawingStateCoordsTo(
+ state: unknown,
+ shift: { timeDiff: number; priceDiff: number },
+ chart: IChartApi,
+ serie: SeriesApi,
+) {
+ if (isLineDrawingState(state)) {
+ // LineDrawing, ParallelChannelState, VolumeProfileState
+ return {
+ ...state,
+ startAnchor: shiftPointByLogicalCoords(
+ <Time>state.startAnchor?.time,
+ <number>state.startAnchor?.price,
+ shift,
+ chart,
+ serie,
+ ),
+ endAnchor: shiftPointByLogicalCoords(
+ <Time>state.endAnchor?.time,
+ <number>state.endAnchor?.price,
+ shift,
+ chart,
+ serie,
+ ),
+ };
+ }
+
+ if (isRayState(state)) {
+ return {
+ ...state,
+ startAnchor: shiftPointByLogicalCoords(
+ <Time>state.startAnchor?.time,
+ <number>state.startAnchor?.price,
+ shift,
+ chart,
+ serie,
+ ),
+ directionAnchor: shiftPointByLogicalCoords(
+ <Time>state.directionAnchor?.time,
+ <number>state.directionAnchor?.price,
+ shift,
+ chart,
+ serie,
+ ),
+ };
+ }
+
+ if (isSliderPositionState(state)) {
+ // SliderPositionState
+ const { time: startTime, price: entryPrice } = shiftPointByLogicalCoords(
+ <Time>state.startTime,
+ <number>state.entryPrice,
+ shift,
+ chart,
+ serie,
+ );
+ const { time: endTime, price: stopPrice } = shiftPointByLogicalCoords(
+ <Time>state.endTime,
+ <number>state.stopPrice,
+ shift,
+ chart,
+ serie,
+ );
+ const { price: targetPrice } = shiftPointByLogicalCoords(
+ <Time>state.endTime,
+ <number>state.targetPrice,
+ shift,
+ chart,
+ serie,
+ );
+
+ return {
+ ...state,
+ startTime,
+ endTime,
+ entryPrice,
+ stopPrice,
+ targetPrice,
+ };
+ }
+ if (isFibonacciRetracementState(state)) {
+ // FibonacciRetracement, DiapsonState, RectangleState
+ const { time: startTime, price: startPrice } = shiftPointByLogicalCoords(
+ <Time>state.startTime,
+ <number>state.startPrice,
+ shift,
+ chart,
+ serie,
+ );
+ const { time: endTime, price: endPrice } = shiftPointByLogicalCoords(
+ <Time>state.endTime,
+ <number>state.endPrice,
+ shift,
+ chart,
+ serie,
+ );
+
+ return {
+ ...state,
+ startTime,
+ endTime,
+ startPrice,
+ endPrice,
+ };
+ }
+ if (isRegressionTrendState(state)) {
+ // RegressionTrend
+ const { time: startTime } = shiftPointByLogicalCoords(<Time>state.startTime, 0, shift, chart, serie);
+ const { time: endTime } = shiftPointByLogicalCoords(<Time>state.endTime, 0, shift, chart, serie);
+
+ return {
+ ...state,
+ startTime,
+ endTime,
+ };
+ }
+
+ if (isAxisLineState(state)) {
+ // AxisLine
+ if (state.time === null) {
+ const { price } = shiftPointByLogicalCoords(<Time>0, <number>state.price, shift, chart, serie);
+ return {
+ ...state,
+ price,
+ };
+ }
+
+ const { time } = shiftPointByLogicalCoords(state.time, 0, shift, chart, serie);
+
+ return {
+ ...state,
+ time,
+ };
+ }
+
+ if (isTextState(state)) {
+ // TextState
+ return {
+ ...state,
+ point: shiftPointByLogicalCoords(<Time>state.point?.time, <number>state.point?.price, shift, chart, serie),
+ };
+ }
+
+ if (isTraectoryState(state)) {
+ // TraectoryState
+
+ const points = state.points.map((point) => {
+ return shiftPointByLogicalCoords(point.time, point.price, shift, chart, serie);
+ });
+
+ return {
+ ...state,
+ points,
+ };
+ }
+
+ return state;
}
diff --git a/src/core/Indicators/index.tsx b/src/core/Indicators/index.tsx
index aadeff35ba5b32315086118eebfe5152ffbbd80a..60c57bdf05b9f76eec104f60620fcf3727cb9c5f 100644
--- a/src/core/Indicators/index.tsx
+++ b/src/core/Indicators/index.tsx
@@ -1,4 +1,4 @@
-import { PriceScaleMode, SeriesType } from 'lightweight-charts';
+import { AutoscaleInfo, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
@@ -50,6 +50,18 @@ export const indicatorsMap = (): Partial<Record<IndicatorsIds, IndicatorConfig>>
priceFormat: {
type: 'volume',
},
+ autoscaleInfoProvider: (baseImplementation: () => AutoscaleInfo | null) => {
+ const res = baseImplementation();
+ if (!res || !res.priceRange || (res.priceRange.maxValue === 0 && res.priceRange.minValue === 0)) {
+ return {
+ priceRange: {
+ minValue: 0,
+ maxValue: 1,
+ },
+ };
+ }
+ return res;
+ },
},
dataFormatter: (params) => volume(params as IndicatorDataFormatter<'Histogram'>),
},
diff --git a/src/core/MoexChart.tsx b/src/core/MoexChart.tsx
index 0bc048234a3fdf439fad1a36b3cca422b880497a..063b2634a7c0d5cfdea904540c978b828f9924b9 100644
--- a/src/core/MoexChart.tsx
+++ b/src/core/MoexChart.tsx
@@ -10,6 +10,7 @@ import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
+import { Loader } from '@src/components/Loader';
import { SeasonalityControls } from '@src/components/SeasonalityControls';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
@@ -137,6 +138,7 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
private headerRenderer!: UIRenderer;
private modalRenderer!: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
+ private loaderRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private drawingToolbarRenderer!: UIRenderer;
@@ -206,6 +208,7 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
controlBarContainer,
drawingToolbarContainer,
footerContainer,
+ loaderContainer,
toggleToolbar, // todo: move this function to toolbarModel
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
@@ -270,7 +273,9 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
this.subscriptions.add(
this.hotkeys.register({
keys: [Keys.escape],
- enabled: () => this.chartView.value === ChartViewType.Seasonality && modalContainer.hidden,
+ enabled: () => {
+ return !!(this.chartView.value === ChartViewType.Seasonality && modalContainer.hidden);
+ },
callback: this.backToChart,
}),
);
@@ -303,6 +308,7 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
drawingToolbarContainer,
controlBarContainer,
footerContainer,
+ loaderContainer,
});
this.timeScaleHoverController = new TimeScaleHoverController({
@@ -328,11 +334,22 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
preset: ChartCollectionPreset,
containers: Pick<
ReturnType<typeof ContainerManager.createContainers>,
- 'headerContainer' | 'toolBarContainer' | 'drawingToolbarContainer' | 'controlBarContainer' | 'footerContainer'
+ | 'headerContainer'
+ | 'toolBarContainer'
+ | 'drawingToolbarContainer'
+ | 'controlBarContainer'
+ | 'footerContainer'
+ | 'loaderContainer'
>,
): void {
- const { headerContainer, toolBarContainer, drawingToolbarContainer, controlBarContainer, footerContainer } =
- containers;
+ const {
+ headerContainer,
+ toolBarContainer,
+ drawingToolbarContainer,
+ controlBarContainer,
+ footerContainer,
+ loaderContainer,
+ } = containers;
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
@@ -345,6 +362,17 @@ export class MoexChart implements ISerializable<MoexChartSnapshot> {
if (preset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
+
+ this.loaderRenderer = new ReactRenderer(loaderContainer);
+ this.subscriptions.add(
+ this.dataSource.isLoading().subscribe((isLoading) => {
+ if (isLoading) {
+ this.loaderRenderer?.renderComponent(<Loader />);
+ } else {
+ this.loaderRenderer?.clear();
+ }
+ }),
+ );
}
public setSettings(settings: ChartSettingsSource): void {
diff --git a/src/core/Pane.tsx b/src/core/Pane.tsx
index 0484870b14f536ffdf4d930fc32eccb47c1a5545..df0333525801649e77acf2a3b51262d15fe39d37 100644
--- a/src/core/Pane.tsx
+++ b/src/core/Pane.tsx
@@ -58,6 +58,7 @@ export interface PaneParams {
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
+ deselectAllPanes: () => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
@@ -94,6 +95,8 @@ export class Pane implements ISerializable<PaneSnapshot> {
private readonly onPriceScaleStateChange: () => void;
private readonly subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
+ private isSelected = new BehaviorSubject<boolean>(false);
+ private deselectAllPanes: () => void;
constructor({
lwcChart,
@@ -120,6 +123,7 @@ export class Pane implements ISerializable<PaneSnapshot> {
getActiveTool,
getIsEndlessMode,
continueDrawing,
+ deselectAllPanes,
}: PaneParams) {
this.onDelete = onDelete;
this.onPriceScaleStateChange = onPriceScaleStateChange;
@@ -129,6 +133,7 @@ export class Pane implements ISerializable<PaneSnapshot> {
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane;
this.id = id;
+ this.deselectAllPanes = deselectAllPanes;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
@@ -216,8 +221,25 @@ export class Pane implements ISerializable<PaneSnapshot> {
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
+
+ this.subscriptions.add(
+ this.drawingsManager.entities().subscribe((drawings) => {
+ const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
+
+ this.legendContainer.style.display = hasRuler ? 'none' : '';
+ }),
+ );
}
+ public setSelected = () => {
+ this.deselectAllPanes();
+ this.isSelected.next(true);
+ };
+
+ public deselected = () => {
+ this.isSelected.next(false);
+ };
+
public getLocalMainSeries = () => {
return this.localMainSeries.value;
};
diff --git a/src/core/PaneManager.ts b/src/core/PaneManager.ts
index a2e59cbc7ffd88a421c57287c24a23be18694fa3..d27f85e29debf0ec752ed4ec7bfea9244fb317d9 100644
--- a/src/core/PaneManager.ts
+++ b/src/core/PaneManager.ts
@@ -1,4 +1,4 @@
-import { type Observable, Subscription } from 'rxjs';
+import { BehaviorSubject, type Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
@@ -29,7 +29,8 @@ type CollectionDependencies =
| 'setActiveTool'
| 'getActiveTool'
| 'getIsEndlessMode'
- | 'continueDrawing';
+ | 'continueDrawing'
+ | 'deselectAllPanes';
interface PaneManagerStartParams {
compareEntities$: Observable<Indicator[]>;
@@ -48,6 +49,7 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly lwcChart: IChartApi;
private readonly panesMap = new Map<number, Pane>();
+ private selectedPane = new BehaviorSubject<Pane | null>(null);
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
@@ -57,6 +59,13 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly subscriptions = new Subscription();
constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
+ this.drawingsManagerCollection = new DrawingsManagerCollection({
+ hotkeys: sharedPaneParams.hotkeys,
+ selectedPane: this.selectedPane.asObservable(),
+ subscribeChartEvent: sharedPaneParams.subscribeChartEvent,
+ lwcChart: sharedPaneParams.lwcChart,
+ });
+
this.sharedPaneParams = {
...sharedPaneParams,
};
@@ -65,10 +74,6 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
- this.drawingsManagerCollection = new DrawingsManagerCollection({
- hotkeys: sharedPaneParams.hotkeys,
- });
-
this.mainPane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
@@ -229,19 +234,25 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
this.panesMap.clear();
}
+ private deselectAllPanes(): void {
+ this.panesMap.forEach((pane) => {
+ pane.deselected();
+ });
+ }
+
private initClickListener(): void {
const handler = (param: MouseEventParams) => {
if (param.paneIndex === undefined) {
return;
}
- const clickedPane = this.getPaneByIndex(param.paneIndex);
+ this.selectedPane.next(this.getPaneByIndex(param.paneIndex) ?? null);
- if (!clickedPane) {
+ if (!this.selectedPane.value) {
return;
}
- this.drawingsManagerCollection.handlePaneClick(clickedPane, param);
+ this.drawingsManagerCollection.handlePaneClick(this.selectedPane.value, param);
};
this.lwcChart.subscribeClick(handler);
@@ -261,6 +272,10 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
return;
}
+ if (this.selectedPane.value?.getId() === id) {
+ this.selectedPane.next(null);
+ }
+
const paneIndex = pane.paneIndex();
this.panesMap.delete(id);
@@ -291,6 +306,7 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
getActiveTool: () => this.drawingsManagerCollection.getActiveToolValue(),
getIsEndlessMode: () => this.drawingsManagerCollection.getIsEndlessMode(),
continueDrawing: (name: DrawingsNames) => this.drawingsManagerCollection.activateDrawingTool(name),
+ deselectAllPanes: () => this.deselectAllPanes(),
};
}
diff --git a/src/core/SymbolSource.ts b/src/core/SymbolSource.ts
index e76eb1afb520b30df1f03450c39a68a946d448c2..2a536b9b3c7d30990298c1da6a2712f95fe3249f 100644
--- a/src/core/SymbolSource.ts
+++ b/src/core/SymbolSource.ts
@@ -87,6 +87,10 @@ export class SymbolSource {
return this.isLoadingSubject.value;
}
+ public isLoading = (): Observable<boolean> => {
+ return this.isLoadingSubject.asObservable();
+ };
+
public getOldestTime(): number | null {
return this.oldestCandle?.time ?? null;
}
diff --git a/src/core/__tests__/DrawingsManagerCollection.test.ts b/src/core/__tests__/DrawingsManagerCollection.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2317c2119509b7ce5438cfebb728ff50adeaf292
--- /dev/null
+++ b/src/core/__tests__/DrawingsManagerCollection.test.ts
@@ -0,0 +1,1023 @@
+import { IChartApi, MouseEventParams, Point, Time } from 'lightweight-charts';
+import { BehaviorSubject, Subscription } from 'rxjs';
+
+import { DrawingsManager, DrawingsManagerParams } from '@core/DrawingsManager';
+import {
+ DrawingsManagerCollection,
+ shiftDrawingStateCoordsTo,
+ shiftPointByLogicalCoords,
+} from '@core/DrawingsManagerCollection';
+import { Pane } from '@core/Pane';
+
+import { DrawingsNames } from '@src/constants';
+
+import { SettingsValues } from '@src/types';
+
+import { ChartMouseEvents } from '../ChartMouseEvents';
+import { SeriesApi } from '../Drawings/types';
+import { Hotkeys, Keys } from '../Hotkeys';
+
+jest.mock('@core/DrawingsManager');
+
+// ============ Factory Functions ============
+
+interface MockSeries {
+ data: jest.Mock<unknown[], []>;
+ coordinateToPrice: jest.Mock<number | null, [number]>;
+ priceToCoordinate: jest.Mock<number | null, [number]>;
+}
+
+function createMockSeries(): MockSeries {
+ return {
+ data: jest.fn(() => []),
+ coordinateToPrice: jest.fn((coord: number) => coord),
+ priceToCoordinate: jest.fn((price: number) => price),
+ };
+}
+
+function createMockTimeScale() {
+ const timeToCoordinate = jest.fn((time: Time): number | null => time as unknown as number);
+ const coordinateToTime = jest.fn((coord: number): Time | null => coord as Time);
+
+ return { timeToCoordinate, coordinateToTime };
+}
+
+function createMockChart(): { chart: IChartApi; timeScale: ReturnType<typeof createMockTimeScale> } {
+ const timeScale = createMockTimeScale();
+ const chart = {
+ timeScale: jest.fn(() => timeScale),
+ } as unknown as IChartApi;
+
+ return { chart, timeScale };
+}
+
+function createMockPane(params?: { id?: number; paneIndexValue?: number; series?: MockSeries | SeriesApi }): Pane {
+ const { id = 0, paneIndexValue = 0, series } = params ?? {};
+ const mockSeries = (series ?? createMockSeries()) as unknown as SeriesApi;
+ const drawingManager = new DrawingsManager({} as DrawingsManagerParams);
+
+ return {
+ getId: jest.fn(() => id),
+ paneIndex: jest.fn(() => paneIndexValue),
+ getDrawingManager: jest.fn(() => drawingManager),
+ getLocalMainSeries: jest.fn(() => mockSeries),
+ getHTMLElement: jest.fn(() => document.createElement('div')),
+ setSelected: jest.fn(),
+ deselected: jest.fn(),
+ isMainPane: jest.fn(() => id === 0),
+ getMainSerie: jest.fn(() => new BehaviorSubject(series ?? null)),
+ schedulePaneContainerSync: jest.fn(),
+ refreshPriceScaleControls: jest.fn(),
+ resetPriceScalesAutoScale: jest.fn(),
+ getSnapshot: jest.fn(),
+ getDrawingsSnapshot: jest.fn(),
+ setDrawingsSnapshot: jest.fn(),
+ setLocalMainSeries: jest.fn(),
+ setIndicator: jest.fn(),
+ removeIndicator: jest.fn(),
+ getPriceScale: jest.fn(),
+ isReady: jest.fn(() => Promise.resolve()),
+ destroy: jest.fn(),
+ } as unknown as Pane;
+}
+
+function createMockDrawingsManager(overrides?: Partial<DrawingsManager>) {
+ const defaultMocks = {
+ cancelPendingDrawing: jest.fn(),
+ startDrawing: jest.fn(),
+ openSelectedDrawingSettings: jest.fn(),
+ deleteSelectedDrawing: jest.fn(),
+ updateSelectedDrawingSettings: jest.fn(),
+ toggleSelectedDrawingLock: jest.fn(),
+ getSelectedDrawingSnapshot: jest.fn(() => null),
+ getSnapshot: jest.fn(() => []),
+ setSnapshot: jest.fn(),
+ entities: jest.fn(() => new BehaviorSubject([])),
+ selectedDrawing: jest.fn(() => new BehaviorSubject(null)),
+ destroy: jest.fn(),
+ };
+
+ return {
+ ...defaultMocks,
+ ...overrides,
+ } as unknown as DrawingsManager;
+}
+
+// ============ Tests ============
+
+describe('DrawingsManagerCollection', () => {
+ const DrawingManagerMock = DrawingsManager as jest.MockedClass<typeof DrawingsManager>;
+
+ let collection: DrawingsManagerCollection;
+ let pane: Pane;
+ let mockHotkeys: Hotkeys;
+ let mockChart: IChartApi;
+ let selectedPaneSubject: BehaviorSubject<Pane | null>;
+ let subscribeChartEventMock: jest.Mock;
+ let mockSeries: MockSeries;
+
+ beforeEach(() => {
+ mockSeries = createMockSeries();
+ mockChart = createMockChart().chart;
+ selectedPaneSubject = new BehaviorSubject<Pane | null>(null);
+ subscribeChartEventMock = jest.fn(() => () => {});
+ mockHotkeys = new Hotkeys();
+ pane = createMockPane({ series: mockSeries });
+
+ DrawingManagerMock.mockImplementation(() => createMockDrawingsManager());
+
+ collection = new DrawingsManagerCollection({
+ hotkeys: mockHotkeys,
+ selectedPane: selectedPaneSubject.asObservable(),
+ subscribeChartEvent: subscribeChartEventMock as unknown as ChartMouseEvents['subscribe'],
+ lwcChart: mockChart,
+ });
+ });
+
+ afterEach(() => {
+ collection.destroy();
+ mockHotkeys.destroy();
+ selectedPaneSubject.complete();
+ jest.clearAllMocks();
+ });
+
+ describe('Initialization', () => {
+ it('должен инициализироваться с пустой managersMap', () => {
+ expect((collection as unknown as { managersMap: Map<number, DrawingsManager> }).managersMap.size).toBe(0);
+ });
+
+ it('должен устанавливать активный инструмент в crosshair по умолчанию', () => {
+ expect(collection.getActiveToolValue()).toBe('crosshair');
+ });
+
+ it('должен устанавливать isAwaitingDrawingStart в false', () => {
+ expect((collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart).toBe(false);
+ });
+
+ it('должен подписываться на selectedPane Observable', () => {
+ const newPane = createMockPane({ id: 1, paneIndexValue: 1 });
+ selectedPaneSubject.next(newPane);
+
+ const internalSelectedPane = (collection as unknown as { selectedPane: Pane | null }).selectedPane;
+ expect(internalSelectedPane).toBe(newPane);
+ });
+
+ it('должен подписываться на crosshairMove событие чарта', () => {
+ expect(subscribeChartEventMock).toHaveBeenCalledWith('crosshairMove', expect.any(Function));
+ });
+
+ it('должен обновлять cursorPos и hoveredPane при crosshairMove', () => {
+ const crosshairHandler = (
+ subscribeChartEventMock.mock.calls[0] as [string, (param: MouseEventParams) => void]
+ )[1];
+ const mockEvent = {
+ point: { x: 100, y: 200 } as Point,
+ paneIndex: 0,
+ };
+
+ crosshairHandler(mockEvent as unknown as MouseEventParams);
+
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ const { hoveredPane } = collection as unknown as { hoveredPane: BehaviorSubject<number | null> };
+
+ expect(cursorPos.value).toEqual({ x: 100, y: 200 });
+ expect(hoveredPane.value).toBe(0);
+ });
+ });
+
+ describe('Drawing Manager Collection Management', () => {
+ it('Менеджер должен добавляться в коллекцию', () => {
+ const drawingManager = new DrawingsManager({} as DrawingsManagerParams);
+ collection.addDrawingManager(drawingManager, 1);
+
+ expect((collection as unknown as { managersMap: Map<number, DrawingsManager> }).managersMap.size).toBe(1);
+ });
+
+ it('Менеджер должен удаляться из коллекции', () => {
+ const drawingManager = new DrawingsManager({} as DrawingsManagerParams);
+ collection.addDrawingManager(drawingManager, 1);
+ collection.removeDrawingManager(1);
+
+ expect((collection as unknown as { managersMap: Map<number, DrawingsManager> }).managersMap.size).toBe(0);
+ });
+ });
+
+ describe('Active Tool Management', () => {
+ it('activateDrawingTool должен устанавливать инструмент и флаг isAwaitingDrawingStart', () => {
+ collection.activateDrawingTool(DrawingsNames.trendLine);
+
+ expect(collection.getActiveToolValue()).toBe(DrawingsNames.trendLine);
+ expect((collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart).toBe(true);
+ });
+
+ it('activateDrawingTool должен отменять pending drawing во всех менеджерах', () => {
+ const manager1 = createMockDrawingsManager();
+ const manager2 = createMockDrawingsManager();
+
+ collection.addDrawingManager(manager1 as DrawingsManager, 0);
+ collection.addDrawingManager(manager2 as DrawingsManager, 1);
+
+ collection.activateDrawingTool(DrawingsNames.trendLine);
+
+ expect(manager1.cancelPendingDrawing).toHaveBeenCalledTimes(1);
+ expect(manager2.cancelPendingDrawing).toHaveBeenCalledTimes(1);
+ });
+
+ it('activateCrosshair должен переключать инструмент на crosshair', () => {
+ collection.activateDrawingTool(DrawingsNames.trendLine);
+ collection.activateCrosshair();
+
+ expect(collection.getActiveToolValue()).toBe('crosshair');
+ expect((collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart).toBe(false);
+ });
+
+ it('activateCrosshair должен отменять pending drawing', () => {
+ const manager = createMockDrawingsManager();
+ collection.addDrawingManager(manager as DrawingsManager, 0);
+
+ collection.activateCrosshair();
+
+ expect(manager.cancelPendingDrawing).toHaveBeenCalledTimes(1);
+ });
+
+ it('setActiveTool должен устанавливать инструмент', () => {
+ collection.setActiveTool(DrawingsNames.horizontalLine);
+ expect(collection.getActiveToolValue()).toBe(DrawingsNames.horizontalLine);
+ });
+
+ it('setActiveTool на crosshair должен сбрасывать isAwaitingDrawingStart', () => {
+ collection.activateDrawingTool(DrawingsNames.trendLine);
+ collection.setActiveTool('crosshair');
+
+ expect((collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart).toBe(false);
+ });
+
+ it('setActiveTool на drawing не должен менять isAwaitingDrawingStart', () => {
+ collection.activateDrawingTool(DrawingsNames.trendLine);
+ (collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart = false;
+
+ collection.setActiveTool(DrawingsNames.horizontalLine);
+
+ expect((collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart).toBe(false);
+ });
+
+ it('getActiveTool должен возвращать Observable', () => {
+ const observable = collection.getActiveTool();
+ expect(observable).toBeDefined();
+ expect(typeof observable.subscribe).toBe('function');
+ });
+ });
+
+ describe('Endless Drawing Mode', () => {
+ it('setEndlessDrawingMode должен устанавливать режим в true', () => {
+ collection.setEndlessDrawingMode(true);
+ expect(collection.getIsEndlessMode()).toBe(true);
+ });
+
+ it('setEndlessDrawingMode должен устанавливать режим в false', () => {
+ collection.setEndlessDrawingMode(true);
+ collection.setEndlessDrawingMode(false);
+ expect(collection.getIsEndlessMode()).toBe(false);
+ });
+
+ it('setEndlessDrawingMode не должен изменять значение при одинаковом значении', () => {
+ const { endlessMode$ } = collection as unknown as { endlessMode$: BehaviorSubject<boolean> };
+ const nextSpy = jest.spyOn(endlessMode$, 'next');
+
+ collection.setEndlessDrawingMode(false);
+
+ expect(nextSpy).not.toHaveBeenCalled();
+ });
+
+ it('getIsEndlessMode должен возвращать текущее значение', () => {
+ expect(collection.getIsEndlessMode()).toBe(false);
+ collection.setEndlessDrawingMode(true);
+ expect(collection.getIsEndlessMode()).toBe(true);
+ });
+
+ it('isEndlessDrawingsMode должен возвращать Observable', () => {
+ const observable = collection.isEndlessDrawingsMode();
+ expect(observable).toBeDefined();
+ expect(typeof observable.subscribe).toBe('function');
+ });
+ });
+
+ describe('handlePaneClick', () => {
+ let mockDrawingManager: DrawingsManager;
+
+ beforeEach(() => {
+ mockDrawingManager = createMockDrawingsManager();
+ jest.spyOn(pane, 'getDrawingManager').mockReturnValue(mockDrawingManager);
+ });
+
+ it('должен запускать drawing при pointer shortcut когда инструмент crosshair', () => {
+ collection.activateCrosshair();
+ selectedPaneSubject.next(pane);
+
+ const mockEvent = {
+ sourceEvent: { shiftKey: true, altKey: false, ctrlKey: false, metaKey: false },
+ } as unknown as MouseEventParams;
+
+ collection.handlePaneClick(pane, mockEvent);
+
+ expect(mockDrawingManager.startDrawing).toHaveBeenCalledWith(DrawingsNames.ruler, mockEvent);
+ });
+
+ it('не должен запускать drawing без pointer shortcut когда инструмент crosshair', () => {
+ collection.activateCrosshair();
+ selectedPaneSubject.next(pane);
+
+ collection.handlePaneClick(pane, {} as MouseEventParams);
+
+ expect(mockDrawingManager.startDrawing).not.toHaveBeenCalled();
+ });
+
+ it('должен запускать drawing когда активен инструмент и isAwaitingDrawingStart=true', () => {
+ collection.activateDrawingTool(DrawingsNames.verticalLine);
+ selectedPaneSubject.next(pane);
+
+ const mockEvent = {} as MouseEventParams;
+ collection.handlePaneClick(pane, mockEvent);
+
+ expect(mockDrawingManager.startDrawing).toHaveBeenCalledWith(DrawingsNames.verticalLine, mockEvent);
+ expect((collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart).toBe(false);
+ });
+
+ it('не должен запускать drawing когда isAwaitingDrawingStart=false', () => {
+ collection.activateDrawingTool(DrawingsNames.verticalLine);
+ (collection as unknown as { isAwaitingDrawingStart: boolean }).isAwaitingDrawingStart = false;
+ selectedPaneSubject.next(pane);
+
+ collection.handlePaneClick(pane, {} as MouseEventParams);
+
+ expect(mockDrawingManager.startDrawing).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Selected Drawing Operations', () => {
+ let mockDrawingManager: DrawingsManager;
+
+ beforeEach(() => {
+ mockDrawingManager = createMockDrawingsManager();
+ collection.addDrawingManager(mockDrawingManager, 0);
+ });
+
+ it('Should open selected drawing settings', () => {
+ collection.openSelectedDrawingSettings();
+ expect(mockDrawingManager.openSelectedDrawingSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('Should delete selected drawing', () => {
+ collection.deleteSelectedDrawing();
+ expect(mockDrawingManager.deleteSelectedDrawing).toHaveBeenCalledTimes(1);
+ });
+
+ it('Should toggle selected drawing lock', () => {
+ collection.toggleSelectedDrawingLock();
+ expect(mockDrawingManager.toggleSelectedDrawingLock).toHaveBeenCalledTimes(1);
+ });
+
+ it('Should update selected drawing settings', () => {
+ const settings = { lineColor: '#ff0000' } as SettingsValues;
+ collection.updateSelectedDrawingSettings(settings);
+ expect(mockDrawingManager.updateSelectedDrawingSettings).toHaveBeenCalledWith(settings);
+ });
+
+ it('selectedDrawing должен возвращать Observable', () => {
+ const observable = collection.selectedDrawing();
+ expect(observable).toBeDefined();
+ expect(typeof observable.subscribe).toBe('function');
+ });
+
+ it('updateSelectedDrawingSettings должен обновлять настройки во всех менеджерах', () => {
+ const manager1 = createMockDrawingsManager();
+ const manager2 = createMockDrawingsManager();
+
+ collection.addDrawingManager(manager1 as DrawingsManager, 0);
+ collection.addDrawingManager(manager2 as DrawingsManager, 1);
+
+ const settings = { lineColor: '#ff0000' } as SettingsValues;
+ collection.updateSelectedDrawingSettings(settings);
+
+ expect(manager1.updateSelectedDrawingSettings).toHaveBeenCalledWith(settings);
+ expect(manager2.updateSelectedDrawingSettings).toHaveBeenCalledWith(settings);
+ });
+ });
+
+ describe('Copy/Paste', () => {
+ let mockPane: Pane;
+ let mockDrawingManager: DrawingsManager;
+
+ beforeEach(() => {
+ mockDrawingManager = createMockDrawingsManager();
+ mockPane = createMockPane({ id: 0, paneIndexValue: 0, series: mockSeries });
+
+ jest.spyOn(mockPane, 'getDrawingManager').mockReturnValue(mockDrawingManager);
+ jest.spyOn(mockPane, 'getLocalMainSeries').mockReturnValue(mockSeries as never);
+
+ selectedPaneSubject.next(mockPane);
+
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ cursorPos.next({ x: 100, y: 200 } as unknown as Point);
+
+ const { hoveredPane } = collection as unknown as { hoveredPane: BehaviorSubject<number | null> };
+ hoveredPane.next(0);
+ });
+
+ describe('copy', () => {
+ it('должен сохранять snapshot и anchor при успешном копировании', () => {
+ const snapshot = { id: 'test', drawingName: DrawingsNames.trendLine, state: {} as never };
+ jest.spyOn(mockDrawingManager, 'getSelectedDrawingSnapshot').mockReturnValue(snapshot);
+
+ collection.copy();
+
+ expect(mockDrawingManager.getSelectedDrawingSnapshot).toHaveBeenCalled();
+ const buffer = (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer;
+ const anchor = (collection as unknown as { copyPasteAnchor: unknown }).copyPasteAnchor;
+
+ expect(buffer).toEqual(snapshot);
+ expect(anchor).toEqual({ time: 100, price: 200 });
+ });
+
+ it('должен устанавливать null buffer при отсутствии координат cursorPos', () => {
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ cursorPos.next(null);
+
+ collection.copy();
+
+ const buffer = (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer;
+ expect(buffer).toBeNull();
+ });
+
+ it('не должен работать если selectedPane отсутствует', () => {
+ selectedPaneSubject.next(null);
+
+ collection.copy();
+
+ const buffer = (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer;
+ expect(buffer).toBeNull();
+ });
+
+ it('не должен работать если hoveredPane не совпадает с selectedPane', () => {
+ const { hoveredPane } = collection as unknown as { hoveredPane: BehaviorSubject<number | null> };
+ hoveredPane.next(1);
+
+ collection.copy();
+
+ const buffer = (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer;
+ expect(buffer).toBeNull();
+ });
+ });
+
+ describe('paste', () => {
+ it('должен вставлять drawing со смещением', () => {
+ const snapshot = {
+ id: 'test',
+ drawingName: DrawingsNames.trendLine,
+ state: {
+ startAnchor: { time: 50 as Time, price: 100 },
+ endAnchor: { time: 60 as Time, price: 110 },
+ } as never,
+ };
+ (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer = snapshot;
+ (collection as unknown as { copyPasteAnchor: unknown }).copyPasteAnchor = { time: 50 as Time, price: 100 };
+
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ cursorPos.next({ x: 150, y: 250 } as unknown as Point);
+
+ collection.paste();
+
+ expect(mockDrawingManager.setSnapshot).toHaveBeenCalled();
+ });
+
+ it('не должен работать без buffer', () => {
+ collection.paste();
+ expect(mockDrawingManager.setSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('не должен работать если selectedPane отсутствует', () => {
+ (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer = {
+ id: 'test',
+ drawingName: DrawingsNames.trendLine,
+ state: {} as never,
+ };
+
+ selectedPaneSubject.next(null);
+
+ collection.paste();
+ expect(mockDrawingManager.setSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('не должен работать если hoveredPane не совпадает', () => {
+ (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer = {
+ id: 'test',
+ drawingName: DrawingsNames.trendLine,
+ state: {} as never,
+ };
+
+ const { hoveredPane } = collection as unknown as { hoveredPane: BehaviorSubject<number | null> };
+ hoveredPane.next(1);
+
+ collection.paste();
+ expect(mockDrawingManager.setSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('не должен работать при отсутствии cursorPos координат', () => {
+ (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer = {
+ id: 'test',
+ drawingName: DrawingsNames.trendLine,
+ state: {} as never,
+ };
+
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ cursorPos.next(null);
+
+ collection.paste();
+ expect(mockDrawingManager.setSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('не должен работать при отсутствии copyPasteAnchor', () => {
+ (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer = {
+ id: 'test',
+ drawingName: DrawingsNames.trendLine,
+ state: {} as never,
+ };
+
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ cursorPos.next({ x: 150, y: 250 } as unknown as Point);
+
+ collection.paste();
+ expect(mockDrawingManager.setSnapshot).not.toHaveBeenCalled();
+ });
+
+ it('не должен работать при null координате якоря', () => {
+ (collection as unknown as { copyPasteBuffer: unknown }).copyPasteBuffer = {
+ id: 'test',
+ drawingName: DrawingsNames.trendLine,
+ state: {
+ startAnchor: { time: 50 as Time, price: 100 },
+ endAnchor: { time: 60 as Time, price: 110 },
+ } as never,
+ };
+ (collection as unknown as { copyPasteAnchor: unknown }).copyPasteAnchor = { time: 50 as Time, price: 100 };
+
+ const { cursorPos } = collection as unknown as { cursorPos: BehaviorSubject<Point | null> };
+ cursorPos.next({ x: 150, y: 250 } as unknown as Point);
+
+ mockSeries.priceToCoordinate.mockReturnValue(null);
+
+ collection.paste();
+ expect(mockDrawingManager.setSnapshot).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Destroy', () => {
+ it('destroy должен отписываться от всех подписок', () => {
+ const { subscriptions } = collection as unknown as { subscriptions: Subscription };
+ const unsubscribeSpy = jest.spyOn(subscriptions, 'unsubscribe');
+
+ collection.destroy();
+
+ expect(unsubscribeSpy).toHaveBeenCalled();
+ });
+
+ it('destroy должен очищать unregisterHotkeys массив', () => {
+ collection.destroy();
+ const { unregisterHotkeys } = collection as unknown as { unregisterHotkeys: (() => void)[] };
+ expect(unregisterHotkeys).toEqual([]);
+ });
+
+ it('destroy должен завершать activeTool$', () => {
+ const { activeTool$ } = collection as unknown as { activeTool$: BehaviorSubject<unknown> };
+ const completeSpy = jest.spyOn(activeTool$, 'complete');
+
+ collection.destroy();
+
+ expect(completeSpy).toHaveBeenCalled();
+ });
+
+ it('destroy должен завершать endlessMode$', () => {
+ const { endlessMode$ } = collection as unknown as { endlessMode$: BehaviorSubject<boolean> };
+ const completeSpy = jest.spyOn(endlessMode$, 'complete');
+
+ collection.destroy();
+
+ expect(completeSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('Hotkeys Setup', () => {
+ let registerSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ registerSpy = jest.spyOn(mockHotkeys, 'register');
+
+ collection = new DrawingsManagerCollection({
+ hotkeys: mockHotkeys,
+ selectedPane: selectedPaneSubject.asObservable(),
+ subscribeChartEvent: subscribeChartEventMock as unknown as ChartMouseEvents['subscribe'],
+ lwcChart: mockChart,
+ });
+ });
+
+ it('должен регистрировать хоткеи для инструментов рисования', () => {
+ const drawingShortcuts = registerSpy.mock.calls.filter((call) => call[0]?.keys?.includes(Keys.alt));
+
+ expect(drawingShortcuts.length).toBeGreaterThan(0);
+ expect(drawingShortcuts).toEqual(
+ expect.arrayContaining([
+ [expect.objectContaining({ keys: [Keys.alt, Keys.t] })],
+ [expect.objectContaining({ keys: [Keys.alt, Keys.h] })],
+ [expect.objectContaining({ keys: [Keys.alt, Keys.v] })],
+ [expect.objectContaining({ keys: [Keys.alt, Keys.f] })],
+ [expect.objectContaining({ keys: [Keys.alt, Keys.shift, Keys.r] })],
+ ]),
+ );
+ });
+
+ it('должен регистрировать хоткей для copy (mod+c)', () => {
+ const copyRegistration = registerSpy.mock.calls.find(
+ (call) => call[0]?.keys?.includes(Keys.mod) && call[0]?.keys?.includes(Keys.c),
+ );
+
+ expect(copyRegistration).toBeDefined();
+ expect(typeof copyRegistration?.[0]?.callback).toBe('function');
+ });
+
+ it('должен регистрировать хоткей для paste (mod+v)', () => {
+ const pasteRegistration = registerSpy.mock.calls.find(
+ (call) => call[0]?.keys?.includes(Keys.mod) && call[0]?.keys?.includes(Keys.v),
+ );
+
+ expect(pasteRegistration).toBeDefined();
+ expect(typeof pasteRegistration?.[0]?.callback).toBe('function');
+ });
+
+ it('должен регистрировать хоткей для escape', () => {
+ const escapeRegistration = registerSpy.mock.calls.find((call) => call[0]?.keys?.includes(Keys.escape));
+
+ expect(escapeRegistration).toBeDefined();
+ expect(typeof escapeRegistration?.[0]?.callback).toBe('function');
+ });
+
+ it('callback хоткея инструмента рисования должен вызывать activateDrawingTool', () => {
+ const trendLineRegistration = registerSpy.mock.calls.find(
+ (call) => call[0]?.keys?.includes(Keys.alt) && call[0]?.keys?.includes(Keys.t),
+ );
+ const activateSpy = jest.spyOn(collection, 'activateDrawingTool');
+
+ trendLineRegistration?.[0]?.callback();
+
+ expect(activateSpy).toHaveBeenCalledWith(DrawingsNames.trendLine);
+ });
+
+ it('callback хоткея copy должен вызывать copy', () => {
+ const copyRegistration = registerSpy.mock.calls.find(
+ (call) => call[0]?.keys?.includes(Keys.mod) && call[0]?.keys?.includes(Keys.c),
+ );
+ const copySpy = jest.spyOn(collection, 'copy');
+
+ copyRegistration?.[0]?.callback();
+
+ expect(copySpy).toHaveBeenCalled();
+ });
+
+ it('callback хоткея paste должен вызывать paste', () => {
+ const pasteRegistration = registerSpy.mock.calls.find(
+ (call) => call[0]?.keys?.includes(Keys.mod) && call[0]?.keys?.includes(Keys.v),
+ );
+ const pasteSpy = jest.spyOn(collection, 'paste');
+
+ pasteRegistration?.[0]?.callback();
+
+ expect(pasteSpy).toHaveBeenCalled();
+ });
+
+ it('callback хоткея escape должен вызывать activateCrosshair', () => {
+ const escapeRegistration = registerSpy.mock.calls.find((call) => call[0]?.keys?.includes(Keys.escape));
+
+ collection.activateDrawingTool(DrawingsNames.trendLine);
+
+ escapeRegistration?.[0]?.callback();
+
+ expect(collection.getActiveToolValue()).toBe('crosshair');
+ });
+
+ it('destroy должен вызывать все функции регистрации хоткеев', () => {
+ const unregister = jest.fn();
+
+ registerSpy.mockReturnValue(unregister);
+
+ collection = new DrawingsManagerCollection({
+ hotkeys: mockHotkeys,
+ selectedPane: selectedPaneSubject.asObservable(),
+ subscribeChartEvent: subscribeChartEventMock as unknown as ChartMouseEvents['subscribe'],
+ lwcChart: mockChart,
+ });
+
+ collection.destroy();
+
+ expect(unregister).toHaveBeenCalled();
+ expect((collection as unknown as { unregisterHotkeys: (() => void)[] }).unregisterHotkeys).toEqual([]);
+ });
+ });
+});
+
+describe('shiftPointByLogicalCoords', () => {
+ let mockSeries: MockSeries;
+ let mockChart: IChartApi;
+ let mockTimeScale: ReturnType<typeof createMockTimeScale>;
+
+ beforeEach(() => {
+ mockTimeScale = createMockTimeScale();
+ mockSeries = createMockSeries();
+ mockChart = {
+ timeScale: jest.fn(() => mockTimeScale),
+ } as unknown as IChartApi;
+ });
+
+ it('должен смещать time и price на shift', () => {
+ const shift = { timeDiff: 100, priceDiff: 50 };
+
+ const result = shiftPointByLogicalCoords(50 as Time, 100, shift, mockChart, mockSeries as unknown as SeriesApi);
+
+ expect(result).toEqual({ time: 150, price: 150 });
+ });
+
+ it('должен возвращать исходные значения при null time координате', () => {
+ mockTimeScale.timeToCoordinate.mockReturnValue(null);
+
+ const shift = { timeDiff: 100, priceDiff: 50 };
+ const result = shiftPointByLogicalCoords(50 as Time, 100, shift, mockChart, mockSeries as unknown as SeriesApi);
+
+ expect(result).toEqual({ time: 50 as Time, price: 100 });
+ });
+
+ it('должен возвращать исходные значения при null price координате', () => {
+ mockSeries.priceToCoordinate.mockReturnValue(null);
+
+ const shift = { timeDiff: 100, priceDiff: 50 };
+ const result = shiftPointByLogicalCoords(50 as Time, 100, shift, mockChart, mockSeries as unknown as SeriesApi);
+
+ expect(result).toEqual({ time: 50 as Time, price: 100 });
+ });
+
+ it('должен возвращать исходное время при null результате coordinateToTime', () => {
+ mockTimeScale.timeToCoordinate.mockReturnValue(100 as number);
+ mockTimeScale.coordinateToTime.mockImplementation(() => null);
+
+ const shift = { timeDiff: 100, priceDiff: 50 };
+ const result = shiftPointByLogicalCoords(50 as Time, 100, shift, mockChart, mockSeries as unknown as SeriesApi);
+
+ expect(result).toEqual({ time: 50 as Time, price: 150 });
+ });
+
+ it('должен возвращать исходную цену при null результате coordinateToPrice', () => {
+ mockTimeScale.timeToCoordinate.mockReturnValue(100 as number);
+ mockSeries.coordinateToPrice.mockReturnValue(null);
+
+ const shift = { timeDiff: 100, priceDiff: 50 };
+ const result = shiftPointByLogicalCoords(50 as Time, 100, shift, mockChart, mockSeries as unknown as SeriesApi);
+
+ expect(result).toEqual({ time: 200, price: 100 });
+ });
+});
+
+describe('shiftDrawingStateCoordsTo', () => {
+ let mockSeries: MockSeries;
+ let mockChart: IChartApi;
+ let mockTimeScale: ReturnType<typeof createMockTimeScale>;
+
+ const series = () => mockSeries as unknown as SeriesApi;
+
+ beforeEach(() => {
+ mockSeries = createMockSeries();
+ mockTimeScale = createMockTimeScale();
+ mockChart = {
+ timeScale: jest.fn(() => mockTimeScale),
+ } as unknown as IChartApi;
+ });
+
+ const shift = { timeDiff: 100, priceDiff: 50 };
+
+ it('должен смещать LineDrawingState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startAnchor: { time: 10 as Time, price: 20 },
+ endAnchor: { time: 30 as Time, price: 40 },
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startAnchor: { time: 110, price: 70 },
+ endAnchor: { time: 130, price: 90 },
+ });
+ });
+
+ it('должен смещать RayState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startAnchor: { time: 10 as Time, price: 20 },
+ directionAnchor: { time: 30 as Time, price: 40 },
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startAnchor: { time: 110, price: 70 },
+ directionAnchor: { time: 130, price: 90 },
+ });
+ });
+
+ it('должен смещать SliderPositionState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startTime: 10 as Time,
+ endTime: 30 as Time,
+ entryPrice: 20,
+ stopPrice: 40,
+ targetPrice: 60,
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startTime: 110,
+ endTime: 130,
+ entryPrice: 70,
+ stopPrice: 90,
+ targetPrice: 110,
+ });
+ });
+
+ it('должен смещать FibonacciRetracementState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startTime: 10 as Time,
+ endTime: 30 as Time,
+ startPrice: 20,
+ endPrice: 40,
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startTime: 110,
+ endTime: 130,
+ startPrice: 70,
+ endPrice: 90,
+ });
+ });
+
+ it('должен смещать RegressionTrendState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startTime: 10 as Time,
+ endTime: 30 as Time,
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startTime: 110,
+ endTime: 130,
+ });
+ });
+
+ it('должен смещать AxisLineState (вертикальная)', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ time: 10 as Time,
+ price: null,
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ time: 110,
+ });
+ });
+
+ it('должен смещать AxisLineState (горизонтальная)', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ time: null,
+ price: 20,
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ price: 70,
+ });
+ });
+
+ it('должен смещать TextState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ point: { time: 10 as Time, price: 20 },
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ point: { time: 110, price: 70 },
+ });
+ });
+
+ it('должен смещать TraectoryState', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ points: [
+ { time: 10 as Time, price: 20 },
+ { time: 30 as Time, price: 40 },
+ ],
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ points: [
+ { time: 110, price: 70 },
+ { time: 130, price: 90 },
+ ],
+ });
+ });
+
+ it('должен возвращать состояние без изменений для неизвестного типа', () => {
+ const state = { unknown: 'type' };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toBe(state);
+ });
+
+ it('должен смещать ParallelChannelState (как LineDrawing)', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startAnchor: { time: 5 as Time, price: 10 },
+ endAnchor: { time: 15 as Time, price: 20 },
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startAnchor: { time: 105, price: 60 },
+ endAnchor: { time: 115, price: 70 },
+ });
+ });
+
+ it('должен смещать DiapsonState (как FibonacciRetracement)', () => {
+ const state = {
+ hidden: false,
+ mode: 'idle' as const,
+ startTime: 5 as Time,
+ endTime: 15 as Time,
+ startPrice: 10,
+ endPrice: 20,
+ settings: {},
+ };
+
+ const result = shiftDrawingStateCoordsTo(state, shift, mockChart, series());
+
+ expect(result).toEqual({
+ ...state,
+ startTime: 105,
+ endTime: 115,
+ startPrice: 60,
+ endPrice: 70,
+ });
+ });
+});
diff --git a/src/core/__tests__/MoexChart.test.ts b/src/core/__tests__/MoexChart.test.ts
index 8640b89478e79e0344efe25c77452192118d0518..6d95ebb904bd33b4c21bd856f7108f0a57fc5a2a 100644
--- a/src/core/__tests__/MoexChart.test.ts
+++ b/src/core/__tests__/MoexChart.test.ts
@@ -1,4 +1,4 @@
-import { BehaviorSubject } from 'rxjs';
+import { BehaviorSubject, of, Subscription } from 'rxjs';
import { Keys } from '@core/Hotkeys';
import { type ChartCollectionPreset, type IMoexChart, MoexChart } from '@core/MoexChart';
@@ -336,6 +336,7 @@ describe('MoexChart', () => {
jest.mock('@core/ReactRenderer', () => ({
ReactRenderer: jest.fn(() => ({
renderComponent: jest.fn(),
+ clear: jest.fn(),
destroy: jest.fn(),
})),
}));
@@ -346,6 +347,7 @@ interface RendererContainers {
drawingToolbarContainer: HTMLElement;
controlBarContainer: HTMLElement;
footerContainer: HTMLElement;
+ loaderContainer: HTMLElement;
}
interface ChartSetupInternals {
@@ -356,6 +358,7 @@ interface ChartSetupInternals {
drawingToolbarRenderer?: object;
controlBarRenderer?: object;
footerRenderer?: object;
+ loaderRenderer?: object;
}
function createSetupContext() {
@@ -370,16 +373,20 @@ function createSetupContext() {
drawingToolbarContainer: document.createElement('div'),
controlBarContainer: document.createElement('div'),
footerContainer: document.createElement('div'),
+ loaderContainer: document.createElement('div'),
};
const chart = Object.assign(Object.create(MoexChart.prototype), {
hotkeys: { register },
eventManager: { getUndoRedo },
+ dataSource: { isLoading: jest.fn(() => of(false)) },
+ subscriptions: new Subscription(),
headerRenderer: undefined,
toolbarRenderer: undefined,
drawingToolbarRenderer: undefined,
controlBarRenderer: undefined,
footerRenderer: undefined,
+ loaderRenderer: undefined,
}) as ChartSetupInternals;
return { chart, containers, register, getUndoRedo, undo, redo };
@@ -424,15 +431,17 @@ describe('MoexChart: настройка интерфейса', () => {
chart.createRenderers({} as ChartCollectionPreset, containers);
- expect(ReactRenderer).toHaveBeenCalledTimes(3);
+ expect(ReactRenderer).toHaveBeenCalledTimes(4);
expect(ReactRenderer).toHaveBeenNthCalledWith(1, containers.headerContainer);
expect(ReactRenderer).toHaveBeenNthCalledWith(2, containers.toolBarContainer);
expect(ReactRenderer).toHaveBeenNthCalledWith(3, containers.drawingToolbarContainer);
+ expect(ReactRenderer).toHaveBeenNthCalledWith(4, containers.loaderContainer);
expect(chart.headerRenderer).toBeDefined();
expect(chart.toolbarRenderer).toBeDefined();
expect(chart.drawingToolbarRenderer).toBeDefined();
expect(chart.controlBarRenderer).toBeUndefined();
expect(chart.footerRenderer).toBeUndefined();
+ expect(chart.loaderRenderer).toBeDefined();
});
it('должен создавать обе дополнительные панели при включённых флагах', () => {
@@ -446,11 +455,13 @@ describe('MoexChart: настройка интерфейса', () => {
containers,
);
- expect(ReactRenderer).toHaveBeenCalledTimes(5);
+ expect(ReactRenderer).toHaveBeenCalledTimes(6);
expect(ReactRenderer).toHaveBeenCalledWith(containers.controlBarContainer);
expect(ReactRenderer).toHaveBeenCalledWith(containers.footerContainer);
+ expect(ReactRenderer).toHaveBeenCalledWith(containers.loaderContainer);
expect(chart.controlBarRenderer).toBeDefined();
expect(chart.footerRenderer).toBeDefined();
+ expect(chart.loaderRenderer).toBeDefined();
});
it.each([
@@ -461,7 +472,7 @@ describe('MoexChart: настройка интерфейса', () => {
chart.createRenderers(preset as ChartCollectionPreset, containers);
- expect(ReactRenderer).toHaveBeenCalledTimes(4);
+ expect(ReactRenderer).toHaveBeenCalledTimes(5);
expect(Boolean(chart.controlBarRenderer)).toBe(preset.showControlBar);
expect(Boolean(chart.footerRenderer)).toBe(preset.showBottomPanel);
});
diff --git a/src/core/__tests__/Pane.test.ts b/src/core/__tests__/Pane.test.ts
index d425fb524776ca1157d81e679d9f23f978d1ffd2..61755d7ae33dd47bab015a96d81e5c09b15b7980 100644
--- a/src/core/__tests__/Pane.test.ts
+++ b/src/core/__tests__/Pane.test.ts
@@ -160,6 +160,7 @@ describe('Pane', () => {
getActiveTool: jest.fn(),
getIsEndlessMode: jest.fn(),
continueDrawing: jest.fn(),
+ deselectAllPanes: jest.fn(),
});
}
diff --git a/src/core/__tests__/PaneManager.test.ts b/src/core/__tests__/PaneManager.test.ts
index 79adee94c2eee2d7958599a338f0ce1f4a08b7e9..0c965aa9bc4492e3f2d8e0f99a5b1402ebf89f04 100644
--- a/src/core/__tests__/PaneManager.test.ts
+++ b/src/core/__tests__/PaneManager.test.ts
@@ -7,6 +7,7 @@ import { PaneManager } from '@core/PaneManager';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { IndicatorsIds } from '@src/constants';
+import { Direction } from '@src/types';
import { PaneSnapshot } from '@src/types/snapshot';
jest.mock('@core/Pane');
@@ -125,4 +126,69 @@ describe('PaneManager', () => {
manager.destroy();
});
+
+ it('Выставление шкал', () => {
+ const manager = createManager([
+ {
+ isMain: true,
+ id: 0,
+ indicators: [],
+ drawings: [],
+ },
+ {
+ isMain: false,
+ id: 1,
+ indicators: [],
+ drawings: [],
+ },
+ {
+ isMain: false,
+ id: 2,
+ indicators: [
+ {
+ indicatorType: IndicatorsIds.RSI,
+ },
+ ],
+ drawings: [],
+ },
+ ]);
+ manager.setPriceScaleSideVisible(Direction.Left, true);
+
+ expect(manager['leftPriceScaleVisible']).toBe(true);
+ expect(manager['rightPriceScaleVisible']).toBe(true);
+ expect(manager.getMainPane().getPriceScale(Direction.Left)).toBeDefined();
+
+ manager.destroy();
+ });
+
+ it('геттер пейнов', () => {
+ const manager = createManager([
+ {
+ isMain: true,
+ id: 0,
+ indicators: [],
+ drawings: [],
+ },
+ {
+ isMain: false,
+ id: 1,
+ indicators: [],
+ drawings: [],
+ },
+ {
+ isMain: false,
+ id: 2,
+ indicators: [
+ {
+ indicatorType: IndicatorsIds.RSI,
+ },
+ ],
+ drawings: [],
+ },
+ ]);
+
+ expect(manager.getPaneByIndex(0)).toMatchObject(manager.getMainPane());
+
+ manager.destroy();
+ });
});