Загрузка данных
export enum Keys {
'escape' = 'escape',
'delete' = 'delete',
'tab' = 'tab',
'shift' = 'shift',
'control' = 'control',
'meta' = 'meta',
'mod' = 'mod',
'alt' = 'alt',
't' = 'keyt',
'h' = 'keyh',
'v' = 'keyv',
'f' = 'keyf',
'r' = 'keyr',
'y' = 'keyy',
'z' = 'keyz',
'c' = 'keyc',
'mousedown' = 'mousedown',
}
type HotkeyCallback = () => void | Promise<void>;
interface RegisterHotkeyParams {
keys: Keys[];
callback: HotkeyCallback;
pressHoldRequired?: boolean;
}
interface UnregisterHotkeyParams {
keys: Keys[];
hash?: string | null;
}
interface HotkeyRegistration {
keys: Keys[];
callback: HotkeyCallback;
pressHoldRequired: boolean;
}
export interface IHotkeys {
register(params: RegisterHotkeyParams): string | null;
unregister(params: UnregisterHotkeyParams): void;
}
// todo: возможно можно сделать синглтоном и использовать импортируя экземпляр класса,
// там где это нужно вместо props drilling, как сейчас
export class Hotkeys implements IHotkeys {
private registrations = new Map<string, HotkeyRegistration>();
private activeHoldRegistrations = new Set<string>();
constructor() {
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
}
public register({ keys, callback, pressHoldRequired = false }: RegisterHotkeyParams): string | null {
if (keys.length === 0) {
console.error('[Hotkeys] попытка задать пустой хоткей');
return null;
}
const hash = `hotkeyCallback-${crypto.randomUUID()}`;
this.registrations.set(hash, {
keys,
callback,
pressHoldRequired,
});
return hash;
}
public unregister({ hash }: UnregisterHotkeyParams): void {
if (!hash) {
return;
}
this.registrations.delete(hash);
this.activeHoldRegistrations.delete(hash);
}
public destroy(): void {
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
this.registrations.clear();
this.activeHoldRegistrations.clear();
}
private handleKeyDown = async (event: KeyboardEvent): Promise<void> => {
if (event.repeat || isEditableElement(event.target)) {
return;
}
const registrationEntry = [...this.registrations.entries()]
.reverse()
.find(([, registration]) => matchesHotkey(event, registration.keys));
if (!registrationEntry) {
return;
}
event.preventDefault();
const [hash, registration] = registrationEntry;
if (registration.pressHoldRequired) {
this.activeHoldRegistrations.add(hash);
}
await registration.callback();
};
private handleKeyUp = async (event: KeyboardEvent): Promise<void> => {
if (this.activeHoldRegistrations.size === 0) {
return;
}
const releasedKey = normalizeCode(event.code);
let shouldReset = false;
for (const hash of this.activeHoldRegistrations) {
const registration = this.registrations.get(hash);
if (!registration) {
this.activeHoldRegistrations.delete(hash);
continue;
}
if (!registration.keys.some((key) => matchesReleasedKey(key, releasedKey))) {
continue;
}
this.activeHoldRegistrations.delete(hash);
shouldReset = true;
}
if (!shouldReset) {
return;
}
const escapeRegistration = [...this.registrations.values()]
.reverse()
.find(({ keys }) => keys.length === 1 && keys[0] === Keys.escape);
await escapeRegistration?.callback();
};
}
function matchesHotkey(event: KeyboardEvent, keys: Keys[]): boolean {
const eventKey = normalizeCode(event.code);
const primaryKeys = keys.filter((key) => !isModifier(key));
if (primaryKeys.length > 1) {
return false;
}
if (primaryKeys.length === 1 && primaryKeys[0] !== eventKey) {
return false;
}
if (primaryKeys.length === 0 && !matchesModifierKey(keys, eventKey)) {
return false;
}
if (keys.includes(Keys.mod)) {
if (!event.ctrlKey && !event.metaKey) {
return false;
}
} else if (event.ctrlKey !== keys.includes(Keys.control) || event.metaKey !== keys.includes(Keys.meta)) {
return false;
}
return event.shiftKey === keys.includes(Keys.shift) && event.altKey === keys.includes(Keys.alt);
}
function matchesModifierKey(keys: Keys[], eventKey: Keys): boolean {
return keys.includes(eventKey) || (keys.includes(Keys.mod) && (eventKey === Keys.control || eventKey === Keys.meta));
}
function matchesReleasedKey(registeredKey: Keys, releasedKey: Keys): boolean {
return (
registeredKey === releasedKey ||
(registeredKey === Keys.mod && (releasedKey === Keys.control || releasedKey === Keys.meta))
);
}
function isModifier(key: Keys): boolean {
return key === Keys.mod || key === Keys.control || key === Keys.meta || key === Keys.shift || key === Keys.alt;
}
function normalizeCode(code: string): Keys {
switch (code.toLowerCase()) {
case 'controlleft':
case 'controlright':
return Keys.control;
case 'metaleft':
case 'metaright':
return Keys.meta;
case 'shiftleft':
case 'shiftright':
return Keys.shift;
case 'altleft':
case 'altright':
return Keys.alt;
default:
return code.toLowerCase() as Keys;
}
}
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return target.isContentEditable || target.closest('input, textarea, select, [contenteditable]') !== null;
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';
import type { DrawingInteraction, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
onCopy: () => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
select: () => void;
deselect: () => void;
isLocked?: boolean;
hotkeys: Hotkeys;
}
export class Drawing extends DOMObject {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private lockedSubject: BehaviorSubject<boolean>;
private settingsSubject: BehaviorSubject<SettingsValues>;
private subscriptions = new Subscription();
private deleteUnregisterHash: string | null = null;
private copyUnregisterHash: string | null = null;
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
onCopy,
zIndex,
moveUp,
moveDown,
construct,
selected$,
isSelected,
select,
deselect,
isLocked = false,
paneId,
hotkeys,
}: DrawingParams) {
super({
id,
name,
zIndex,
onDelete,
moveUp,
moveDown,
paneId,
});
this.hotkeys = hotkeys;
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.lockedSubject = new BehaviorSubject(isLocked);
const interaction: DrawingInteraction = {
selected$,
locked$: this.lockedSubject.asObservable(),
isSelected,
isLocked: () => this.lockedSubject.value,
select,
deselect,
};
this.lwcDrawing = construct(lwcChart, mainSeries, interaction);
this.settingsSubject = new BehaviorSubject(this.lwcDrawing.getSettings());
this.subscriptions.add(
this.lwcDrawing.subscribeSettings((settings) => {
this.settingsSubject.next(settings);
}),
);
this.subscriptions.add(
selected$.subscribe((isSelectedDrawing) => {
if (isSelectedDrawing) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.mod, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
onCopy();
}
},
});
return;
}
this.unregisterSelectedDrawingHotkeys();
}),
);
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getSeriesDrawing(): ISeriesDrawing {
return this.lwcDrawing;
}
public show(): void {
this.lwcDrawing.show();
super.show();
}
public hide(): void {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies): void => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.lockedSubject.subscribe(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
return this.settingsSubject.subscribe(callback);
}
public isLocked(): boolean {
return this.lockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.lockedSubject.value === isLocked) {
return;
}
this.lockedSubject.next(isLocked);
}
public toggleLock(): void {
this.setLocked(!this.isLocked());
}
public waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
public shouldShowInObjectTree(): boolean {
return this.lwcDrawing.shouldShowInObjectTree();
}
public getState(): unknown {
return this.lwcDrawing.getState();
}
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
this.settingsSubject.next(this.lwcDrawing.getSettings());
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public getToolbarSettings(): ToolbarSettingField[] {
return this.getSettingsTabs()
.flatMap((tab) => tab.fields)
.filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.unregisterSelectedDrawingHotkeys();
this.lockedSubject.complete();
this.settingsSubject.complete();
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private unregisterSelectedDrawingHotkeys(): void {
this.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.mod, Keys.c],
hash: this.copyUnregisterHash,
});
this.deleteUnregisterHash = null;
this.copyUnregisterHash = null;
}
}
import { BehaviorSubject, Observable } from 'rxjs';
import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, SettingsValues } from '@src/types';
import { Drawing } from './Drawings';
import { DrawingsManager } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';
import { PaneManager } from './PaneManager';
// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
private managersMap: Map<number, DrawingsManager> = new Map();
private paneCollection: PaneManager;
private hotkeys: Hotkeys;
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private paneClickListenerUnsub = () => {};
private escapeUnregisterHash: string | null = null;
constructor({ paneCollection, hotkeys }: { paneCollection: PaneManager; hotkeys: Hotkeys }) {
this.paneCollection = paneCollection;
this.hotkeys = hotkeys;
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: this.activateCrosshair,
});
}
public getIsEndlessMode(): boolean {
return this.endlessMode$.value;
}
public getActiveToolValue(): ActiveDrawingTool {
return this.activeTool$.value;
}
public setActiveTool(next: ActiveDrawingTool): void {
this.activeTool$.next(next);
}
public removeDrawingManager(paneId: number): void {
this.managersMap.delete(paneId);
}
public addDrawingManager(manager: DrawingsManager, paneId: number): void {
this.managersMap.set(paneId, manager);
}
public addDrawingForce = async (name: DrawingsNames): Promise<void> => {
this.continueDrawing(name);
};
public continueDrawing = (name: DrawingsNames): void => {
this.paneClickListenerUnsub();
this.setActiveTool(name);
this.paneClickListenerUnsub = this.paneCollection.listenPanesToAddDrawing(name);
};
public setEndlessDrawingMode = (value: boolean): void => {
if (this.endlessMode$.value === value) {
return;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public activateCrosshair = (): void => {
this.paneClickListenerUnsub();
this.setActiveTool('crosshair');
Array.from(this.managersMap.values()).forEach((manager) => {
manager.activateCrosshair();
});
};
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return Array.from(this.managersMap.values())[0].selectedDrawing();
}
public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
Array.from(this.managersMap.values()).forEach((manager) => {
manager.updateSelectedDrawingSettings(settings);
});
};
public toggleSelectedDrawingLock = (): void => {
Array.from(this.managersMap.values())[0].toggleSelectedDrawingLock();
};
public openSelectedDrawingSettings = (): void => {
Array.from(this.managersMap.values())[0].openSelectedDrawingSettings();
};
public deleteSelectedDrawing = (): void => {
Array.from(this.managersMap.values())[0].deleteSelectedDrawing();
};
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.paneClickListenerUnsub();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import { IChartApi, ISeriesApi, MouseEventParams, SeriesType } from 'lightweight-charts';
import { cloneDeep, isEqual } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys } from '@core/Hotkeys';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool, DOMObjectSnapshot } from '@src/types';
import { Pane } from './Pane';
import type { DrawingInteraction } from '@src/core/Drawings/SeriesDrawingBase';
import type { SettingsValues } from '@src/types/settings';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
pane: Pane;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
}
export interface DrawingSnapshotItem extends Partial<DOMObjectSnapshot> {
id: string;
drawingName: DrawingsNames;
state: unknown;
isLocked?: boolean;
zIndex?: number;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
zIndex?: number;
shouldUpdateDrawingsList?: boolean;
}
export type DrawingsManagerSnapshot = DrawingSnapshotItem[];
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private DOM: DOMModel;
private container: HTMLElement;
private modalRenderer: ModalRenderer;
private paneId: number;
private hotkeys: Hotkeys;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null); // todo: переместить в DrawingsManagerCollection
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private selectedDrawingSnapshot: DrawingSnapshotItem | null = null;
private continueDrawingScheduled = false;
private pane: Pane;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private setActiveTool: (name: ActiveDrawingTool) => void;
private getActiveTool: () => ActiveDrawingTool;
private getIsEndlessMode: () => boolean;
private continueDrawing: (name: DrawingsNames) => void;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
pane,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.pane = pane;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.setActiveTool = setActiveTool;
this.getActiveTool = getActiveTool;
this.getIsEndlessMode = getIsEndlessMode;
this.continueDrawing = continueDrawing;
this.subscriptions.add(
mainSeries$.subscribe((series) => {
if (!series) {
return;
}
this.mainSeries = series;
this.drawings$.value.forEach((drawing) => drawing.rebind(series));
if (this.pendingSnapshot) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
doAfterPromise(() => this.setSnapshot(snapshot), this.pane.isReady());
}
}),
);
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]
// // })
// }
// })
}
private handlePointerDown = (event: PointerEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
this.selectedDrawingSnapshot = null;
if (event.button === 0) {
const drawings = this.drawings$.value;
const selectedDrawing = this.selectedDrawing$.value;
const pendingDrawing = drawings.find((drawing) => drawing.isCreationPending());
const drawing =
pendingDrawing ??
(selectedDrawing?.getSeriesDrawing().isHit(event) ? selectedDrawing : null) ??
this.findTopDrawing(event);
if (drawing && !drawing.isCreationPending()) {
this.selectedDrawingSnapshot = this.createDrawingSnapshot(drawing);
}
(drawing ?? selectedDrawing)?.getSeriesDrawing().pointerDown(event);
}
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
const previousSnapshot = this.selectedDrawingSnapshot;
this.selectedDrawingSnapshot = null;
queueMicrotask(() => {
if (!previousSnapshot) {
return;
}
const drawing = this.findDrawing(previousSnapshot.id);
if (!drawing || drawing.isCreationPending()) {
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
});
this.DOM.refreshEntities();
};
private handleDoubleClick = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
const drawing = pendingDrawing ?? this.findTopDrawing(event);
if (!drawing) {
return;
}
if (!drawing.isCreationPending() && drawing !== this.selectedDrawing$.value) {
this.selectedDrawing$.next(drawing);
}
drawing.getSeriesDrawing().doubleClick(event);
this.DOM.refreshEntities();
};
private handleContextMenu = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
const drawing = pendingDrawing ?? this.findTopDrawing(event);
drawing?.getSeriesDrawing().contextMenu(event);
};
private handleClick = (): void => {
this.DOM.refreshEntities();
};
private isEventInPane(event: MouseEvent): boolean {
const paneElement = this.pane.getHTMLElement();
return paneElement !== null && event.target instanceof Node && paneElement.contains(event.target);
}
private findTopDrawing(event: MouseEvent): Drawing | null {
let topDrawing: Drawing | null = null;
for (const drawing of this.drawings$.value) {
if (!drawing.getSeriesDrawing().isHit(event)) {
continue;
}
if (!topDrawing || drawing.zIndex > topDrawing.zIndex) {
topDrawing = drawing;
}
}
return topDrawing;
}
private findDrawing(id: string): Drawing | undefined {
return this.drawings$.value.find((drawing) => drawing.id === id);
}
private createDrawingSnapshot(drawing: Drawing): DrawingSnapshotItem {
return {
...drawing.getSnapshot(),
drawingName: drawing.getDrawingName(),
state: cloneDeep(drawing.getState()),
isLocked: drawing.isLocked(),
};
}
private updateDrawing(drawing: Drawing, update: () => void): void {
if (drawing.isCreationPending()) {
return;
}
const previousSnapshot = this.createDrawingSnapshot(drawing);
update();
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
}
private pushDrawingChange(
previousSnapshot: DrawingSnapshotItem | null,
nextSnapshot: DrawingSnapshotItem | null,
): void {
if (isEqual(previousSnapshot, nextSnapshot)) {
return;
}
const previous = cloneDeep(previousSnapshot);
const next = cloneDeep(nextSnapshot);
// todo: объединять последовательные изменения одного дровинга в одну запись истории
this.eventManager.getUndoRedo().pushCommand({
undo: () => {
this.replaceDrawingSnapshot(next, previous);
},
redo: () => {
this.replaceDrawingSnapshot(previous, next);
},
});
}
private replaceDrawingSnapshot(
currentSnapshot: DrawingSnapshotItem | null,
nextSnapshot: DrawingSnapshotItem | null,
): void {
if (currentSnapshot && nextSnapshot && currentSnapshot.id === nextSnapshot.id) {
const drawing = this.findDrawing(nextSnapshot.id);
if (drawing) {
drawing.setState(cloneDeep(nextSnapshot.state));
drawing.setLocked(nextSnapshot.isLocked ?? false);
this.DOM.refreshEntities();
return;
}
}
if (currentSnapshot) {
this.removeDrawingInternal(currentSnapshot.id, false);
}
if (nextSnapshot) {
this.restoreDrawing(nextSnapshot);
}
this.setActiveTool('crosshair');
}
private restoreDrawing(snapshot: DrawingSnapshotItem): Drawing {
const existingDrawing = this.findDrawing(snapshot.id);
if (existingDrawing) {
existingDrawing.setState(cloneDeep(snapshot.state));
existingDrawing.setLocked(snapshot.isLocked ?? false);
if (snapshot.zIndex !== undefined) {
existingDrawing.setZIndex(snapshot.zIndex);
}
this.drawings$.next([...this.drawings$.value].sort((left, right) => left.zIndex - right.zIndex));
this.DOM.refreshEntities();
return existingDrawing;
}
return this.createDrawing({
name: snapshot.drawingName,
options: {
id: snapshot.id,
state: cloneDeep(snapshot.state),
isLocked: snapshot.isLocked,
zIndex: snapshot.zIndex,
},
});
}
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.getActiveTool();
if (activeTool !== 'crosshair' && this.getIsEndlessMode()) {
if (this.continueDrawingScheduled) {
return;
}
this.continueDrawingScheduled = true;
queueMicrotask(() => {
this.continueDrawingScheduled = false;
const currentTool = this.getActiveTool();
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair' || !this.getIsEndlessMode() || hasPendingAfterTick) {
return;
}
this.continueDrawing(currentTool);
});
return;
}
this.setActiveTool('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
if (drawing.isCreationPending()) {
this.removeDrawingInternal(id);
return;
}
const snapshot = this.createDrawingSnapshot(drawing);
this.removeDrawingInternal(id);
this.pushDrawingChange(snapshot, null);
};
private removeDrawingInternal(id: string, shouldUpdateTool = true): void {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
this.removeDrawings([drawing], shouldUpdateTool);
}
private removePendingDrawings(shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
if (!drawingsToRemove.length) {
return;
}
const selectedDrawing = this.selectedDrawing$.value;
if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
this.selectedDrawing$.next(null);
}
drawingsToRemove.forEach((drawing) => {
drawing.destroy();
this.DOM.removeEntity(drawing);
});
this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));
if (shouldUpdateTool) {
this.updateActiveTool();
}
this.DOM.refreshEntities();
}
public addDrawingForce = async (name: DrawingsNames, event?: MouseEventParams): Promise<void> => {
this.removePendingDrawings(false);
const previousDrawing = drawingsMap[name].singleInstance
? this.drawings$.value.find((drawing) => drawing.getDrawingName() === name)
: undefined;
const previousSnapshot = previousDrawing ? this.createDrawingSnapshot(previousDrawing) : null;
if (previousDrawing) {
this.removeDrawingInternal(previousDrawing.id, false);
}
this.setActiveTool(name);
const drawing = this.createDrawing({ name, event });
this.DOM.refreshEntities();
await drawing.waitForCreation();
if (!this.findDrawing(drawing.id)) {
if (previousSnapshot) {
this.restoreDrawing(previousSnapshot);
}
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
};
private createDrawing({
name,
options = {},
event,
}: {
name: DrawingsNames;
options?: CreateDrawingOptions;
event?: MouseEventParams;
}): Drawing {
const { mainSeries } = this;
if (!mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, isLocked = false, zIndex, shouldUpdateDrawingsList = true } = options;
const shouldSelectAfterCreation = state === undefined;
if (shouldSelectAfterCreation && this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const selected$ = this.selectedDrawing$.pipe(
map((drawing) => drawing?.id === drawingId),
distinctUntilChanged(),
);
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => {
const paneElement = this.pane.getHTMLElement();
if (!paneElement) {
throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
}
const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const canvasElement = cells.item(1);
return config.construct({
chart,
series,
eventManager: this.eventManager,
container: canvasElement,
interaction,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
initialEvent: event,
});
};
const drawingFactory = (entityZIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
onCopy: () => {
if (createdDrawing) {
this.copyPasteBuffer = this.createDrawingSnapshot(createdDrawing);
}
},
zIndex: entityZIndex,
moveDown,
moveUp,
construct,
selected$,
isSelected: () => this.selectedDrawing$.value?.id === drawingId,
select: () => {
if (!createdDrawing || createdDrawing.isCreationPending() || this.selectedDrawing$.value === createdDrawing) {
return;
}
this.selectedDrawing$.next(createdDrawing);
},
deselect: () => {
if (!createdDrawing || this.selectedDrawing$.value !== createdDrawing) {
return;
}
this.selectedDrawing$.next(null);
},
isLocked,
paneId: this.paneId,
hotkeys: this.hotkeys,
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory, zIndex);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(cloneDeep(state));
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity].sort((left, right) => left.zIndex - right.zIndex));
}
if (shouldSelectAfterCreation) {
entity.waitForCreation().then(() => {
if (!this.drawings$.value.includes(entity)) {
return;
}
this.selectedDrawing$.next(entity);
this.updateActiveTool();
this.DOM.refreshEntities();
});
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => this.createDrawingSnapshot(drawing));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = cloneDeep(snapshot);
return;
}
this.selectedDrawingSnapshot = null;
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing({
name: item.drawingName,
options: {
id: item.id,
state: cloneDeep(item.state),
isLocked: item.isLocked,
zIndex: item.zIndex,
shouldUpdateDrawingsList: false,
},
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings.sort((left, right) => left.zIndex - right.zIndex));
this.setActiveTool('crosshair');
this.DOM.refreshEntities();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return this.selectedDrawing$.asObservable();
}
public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawing(drawing, () => {
drawing.updateSettings(settings);
});
};
public openSelectedDrawingSettings(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.openSettings(drawing);
}
public deleteSelectedDrawing(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.removeDrawing(drawing.id);
}
public toggleSelectedDrawingLock(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawing(drawing, () => {
drawing.toggleLock();
});
}
private openSettings = (drawing: Drawing): void => {
const tabs = drawing.getSettingsTabs();
if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
return;
}
let settings = drawing.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={tabs}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey={tabs[0]?.key}
/>,
{
size: 'sm',
title: drawing.name,
onSave: () => {
if (!this.findDrawing(drawing.id)) {
return;
}
this.updateDrawing(drawing, () => {
drawing.updateSettings(settings);
});
},
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
if (this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.selectedDrawingSnapshot = null;
this.copyPasteBuffer = null;
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
}
}
async function doAfterPromise(cb: () => void, waiter: Promise<void>) {
await waiter;
cb();
}
import { IChartApi, IPaneApi, MouseEventParams, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { ActiveDrawingTool, Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
CompareSnapshot,
DOMObjectSnapshot,
IndicatorSnapshot,
ISerializable,
PaneSnapshot,
PriceScaleSide,
PriceScaleSnapshot,
} from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';
export interface PaneParams {
id: number;
lwcChart: IChartApi;
eventManager: EventManager;
DOM: DOMModel;
isMainPane: boolean;
ohlcConfig: OHLCConfig;
dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
subscribeChartEvent: ChartMouseEvents['subscribe'];
tooltipConfig: TooltipConfig;
onDelete: () => void;
chartContainer: HTMLElement;
modalRenderer: ModalRenderer;
initialPriceScales?: PriceScaleSnapshot[];
onPriceScaleStateChange: () => void;
leftPriceScaleVisible: boolean;
rightPriceScaleVisible: boolean;
hotkeys: Hotkeys;
addDrawingManager: (manager: DrawingsManager, paneId: number) => void;
removeDrawingManager: (paneId: number) => void;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
private readonly isMain: boolean;
private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
// todo: Отвратительный нейминг. Нужно оставить главной только эту серию и переименовать её localMainSeries => mainSerie
// Если вдруг понадобится главная серия главного пейна, то брать из КоллекцииПейнов(-сейчас PaneManager)
// serie to attach drawings
private localMainSeries = new BehaviorSubject<SeriesStrategies | null>(null);
private legend!: Legend;
private tooltip: TooltipService | undefined;
private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
private readonly lwcPane: IPaneApi<Time>;
private readonly lwcChart: IChartApi;
private readonly eventManager: EventManager;
private readonly drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
private mainSerieSub?: Subscription;
private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
private readonly onDelete: () => void;
private readonly onPriceScaleStateChange: () => void;
private readonly subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
private initialDrawingClickListener: (event: MouseEventParams) => void = () => {};
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
leftPriceScaleVisible,
rightPriceScaleVisible,
hotkeys,
addDrawingManager,
removeDrawingManager,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
}: PaneParams) {
this.onDelete = onDelete;
this.onPriceScaleStateChange = onPriceScaleStateChange;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane;
this.id = id;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales, leftPriceScaleVisible);
this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales, rightPriceScaleVisible);
// TODO: Перенести PriceScaleControls внутрь PriceScale, чтобы каждая шкала владела собственными контролами, а PriceScaleControls работал только с одной шкалой.
this.priceScaleControls = new PriceScaleControls({
leftPriceScale: this.leftPriceScale,
rightPriceScale: this.rightPriceScale,
onPriceScaleChange: this.handlePriceScaleStateChange,
});
this.initializeLegend({ ohlcConfig });
this.tooltip = new TooltipService({
config: tooltipConfig,
legend: this.legend,
paneOverlayContainer: this.paneOverlayContainer,
});
this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
this.tooltipRenderer.renderComponent(
<ChartTooltip
formatObs={this.eventManager.getChartOptionsModel()}
timeframeObs={this.eventManager.getTimeframeObs()}
viewModel={this.tooltip.getTooltipViewModel()}
// ohlcConfig={this.legend.getConfig()}
ohlcConfig={ohlcConfig}
tooltipConfig={this.tooltip.getConfig()}
/>,
);
if (dataSource) {
this.initializeMainSerie({ lwcChart, dataSource });
} else if (basedOn) {
this.mainSeries = basedOn.getMainSerie();
this.mainSeries.subscribe(() => {
this.rebindIndicators();
});
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
pane: this,
eventManager,
DOM,
mainSeries$: this.localMainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
paneId: this.id,
hotkeys,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
});
addDrawingManager(this.drawingsManager, this.id);
this.subscriptions.add(() => removeDrawingManager(this.id));
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public fireClick = (event: MouseEventParams) => {
this.initialDrawingClickListener(event);
};
public unsubscribeInitialDrawingClick = () => {
this.initialDrawingClickListener = () => {};
};
public subscribeInitialDrawingClick = (cb: () => void, name: DrawingsNames) => {
this.initialDrawingClickListener = (event: MouseEventParams) => {
this.drawingsManager.addDrawingForce(name, event);
cb();
};
};
public getLocalMainSeries = () => {
return this.localMainSeries.value;
};
public setLocalMainSeries = (next: SeriesStrategies) => {
if (!this.isMain) {
this.localMainSeries.next(next);
}
};
public isMainPane = () => {
return this.isMain;
};
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.drawingsManager.setSnapshot(snapshot);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
return this.id;
};
public isReady = async (interval = 50): Promise<void> => {
return new Promise((resolve) => {
const check = () => {
if (this.lwcPane.getHTMLElement() === null) {
setTimeout(check, interval);
} else {
resolve();
}
};
check();
});
};
public getHTMLElement = () => {
return this.lwcPane.getHTMLElement();
};
public paneIndex = () => {
return this.lwcPane.paneIndex();
};
public getPriceScale(side: PriceScaleSide): PriceScale {
return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
}
public setIndicator(indicatorId: string, indicator: Indicator): void {
const map = this.indicatorsMap.value;
map.set(indicatorId, indicator);
this.indicatorsMap.next(map);
this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
const map = this.indicatorsMap.value;
map.delete(indicatorId);
this.indicatorsMap.next(map);
this.priceScaleControls.refresh();
if (map.size === 0 && !this.isMain) {
this.onDelete();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
public schedulePaneContainerSync(): void {
if (this.paneContainerSyncFrameId !== null) {
return;
}
this.paneContainerSyncFrameId = requestAnimationFrame(() => {
this.paneContainerSyncFrameId = null;
this.syncPaneContainers();
});
}
public refreshPriceScaleControls(): void {
this.priceScaleControls.refresh();
}
public resetPriceScalesAutoScale(): void {
this.leftPriceScale.enableAutoScale();
this.rightPriceScale.enableAutoScale();
this.handlePriceScaleStateChange();
}
public getSnapshot(): PaneSnapshot {
const indicators: (DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot))[] = [];
this.indicatorsMap.value.forEach((indicator) => {
indicators.push(indicator.getSnapshot());
});
return {
isMain: this.isMain,
id: this.id,
indicators,
drawings: this.getDrawingsSnapshot(),
priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
};
}
public destroy(): void {
if (this.paneContainerSyncFrameId !== null) {
cancelAnimationFrame(this.paneContainerSyncFrameId);
this.paneContainerSyncFrameId = null;
}
this.drawingsManager.destroy();
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
this.localMainSeries.complete();
if (this.isMain) {
this.mainSeries.value?.destroy();
this.mainSeries.complete();
}
}
private createPriceScale(
side: PriceScaleSide,
initialPriceScales: PriceScaleSnapshot[],
initialVisible: boolean,
): PriceScale {
const initialMode =
initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;
return new PriceScale({
paneId: this.id,
side,
pane: this.lwcPane,
initialMode,
initialVisible,
hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
});
}
private hasVisibleSeriesData(side: PriceScaleSide): boolean {
const mainSeries = this.mainSeries.value;
if (this.isMain && side === Direction.Right && mainSeries?.isVisible() && mainSeries.data().length > 0) {
return true;
}
const indicators = Array.from(this.indicatorsMap.value.values());
for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());
for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
const currentSeries = series[seriesIndex];
const options = currentSeries.options();
const seriesPriceScaleSide = options.priceScaleId ?? Direction.Right;
if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
return true;
}
}
}
return false;
}
private handlePriceScaleStateChange = (): void => {
this.priceScaleControls.refresh();
this.onPriceScaleStateChange();
};
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.legendRenderer = new ReactRenderer(legendContainer);
this.schedulePaneContainerSync();
this.legend = new Legend({
config: ohlcConfig,
indicators: this.indicatorsMap,
eventManager: this.eventManager,
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettingsValues();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsFields(),
},
]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey="arguments"
/>,
{
size: 'sm',
title: indicatorLabelById()[indicatorId],
onSave: () => indicator.updateSettings(settings),
},
);
},
// todo: throw isMainPane
});
this.legendRenderer.renderComponent(
<LegendComponent
ohlcConfig={this.legend.getConfig()}
viewModel={this.legend.getLegendViewModel()}
/>,
);
}
private rebindIndicators(): void {
for (const indicator of this.indicatorsMap.value.values()) {
indicator.recreateSeries();
}
}
private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
this.localMainSeries = this.mainSeries;
this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
const next = ensureDefined(SeriesFactory.create(nextSeries))({
lwcChart,
dataSource,
mainSymbolId$: this.eventManager.symbolId(),
mainSymbol$: this.eventManager.symbol(),
mainSerie$: this.mainSeries,
});
this.mainSeries.next(next);
this.rebindIndicators();
this.priceScaleControls.refresh();
});
}
private syncPaneContainers(): void {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) {
this.schedulePaneContainerSync();
return;
}
/*
Внутри lightweight-chart DOM построен как таблица из 3 td
[0] left priceScale, [1] center chart, [2] right priceScale
Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
*/
const cells = lwcPaneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const chartCell = cells.item(1);
if (!chartCell) {
this.schedulePaneContainerSync();
return;
}
chartCell.style.position = 'relative';
chartCell.appendChild(this.legendContainer);
chartCell.appendChild(this.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
import { type Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, Direction } from '@src/types';
import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
import type { Indicator } from '@core/Indicator';
import type { IChartApi, LogicalRange, MouseEventParams } from 'lightweight-charts';
interface PaneManagerParams
extends Omit<
PaneParams,
'id' | 'isMainPane' | 'basedOn' | 'onDelete' | 'initialPriceScales' | CollectionDependencies
> {
panesSnapshot: PaneSnapshot[];
}
type CollectionDependencies =
| 'onPriceScaleStateChange'
| 'leftPriceScaleVisible'
| 'rightPriceScaleVisible'
| 'addDrawingManager'
| 'removeDrawingManager'
| 'setActiveTool'
| 'getActiveTool'
| 'getIsEndlessMode'
| 'continueDrawing';
interface PaneManagerStartParams {
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly sharedPaneParams: SharedPaneParams;
private readonly lwcChart: IChartApi;
private readonly panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
private leftPriceScaleVisible = false;
private rightPriceScaleVisible = true;
private drawingsManagerCollection: DrawingsManagerCollection;
private readonly subscriptions = new Subscription();
constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
this.sharedPaneParams = {
...sharedPaneParams,
};
this.lwcChart = sharedPaneParams.lwcChart;
const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
this.drawingsManagerCollection = new DrawingsManagerCollection({
paneCollection: this,
hotkeys: sharedPaneParams.hotkeys,
});
this.mainPane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
id: mainPaneId,
isMainPane: true,
onDelete: () => {},
initialPriceScales: mainPaneSnapshot?.priceScales, // todo: add to sharedParams?
});
this.panesMap.set(mainPaneId, this.mainPane);
if (mainPaneSnapshot) {
this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
}
const greatestPaneId = panesSnapshot.reduce(
(greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
mainPaneId,
);
this.nextPaneId = greatestPaneId + 1;
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
});
this.initClickListener();
this.syncPaneContainers();
}
public start({ compareEntities$, indicatorEntities$ }: PaneManagerStartParams): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$: this.mainPane.getMainSerie().asObservable(),
mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
public listenPanesToAddDrawing(name: DrawingsNames): () => void {
const panes = Array.from(this.panesMap.values());
const unsub = () => {
panes.forEach((pane) => {
pane.unsubscribeInitialDrawingClick();
});
};
panes.forEach((pane) => {
pane.subscribeInitialDrawingClick(unsub, name);
});
return unsub;
}
public getDrawingsCollectionManager(): DrawingsManagerCollection {
return this.drawingsManagerCollection;
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
}
public invalidate(): void {
this.priceAxisLabels?.invalidate();
this.refreshPriceScaleControls();
}
public setPriceScaleSideVisible(side: PriceScaleSide, visible: boolean): void {
if (side === Direction.Left) {
this.leftPriceScaleVisible = visible;
} else {
this.rightPriceScaleVisible = visible;
}
this.panesMap.forEach((pane) => {
pane.getPriceScale(side).setVisible(visible);
});
}
public getPaneByIndex(index: number): Pane | undefined {
return Array.from(this.panesMap.values()).find((pane) => pane.paneIndex() === index);
}
public getPaneById(id: number): Pane | undefined {
return this.panesMap.get(id);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes(): Map<number, Pane> {
return this.panesMap;
}
public getMainPane = (): Pane => {
return this.mainPane;
};
public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(this.nextPaneId, id + 1);
const pane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getSnapshot(): PaneSnapshot[] {
const snapshot: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
snapshot.push(pane.getSnapshot());
});
return snapshot;
}
public destroy(): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = null;
this.subscriptions.unsubscribe();
this.panesMap.forEach((pane) => {
pane.destroy();
});
this.drawingsManagerCollection.destroy();
this.panesMap.clear();
}
private initClickListener(): void {
const handler = (param: MouseEventParams) => {
if (param.paneIndex === undefined) return;
const clickedPane = this.getPaneByIndex(param.paneIndex);
clickedPane?.fireClick(param);
};
this.lwcChart.subscribeClick(handler);
this.subscriptions.add(() => this.lwcChart.unsubscribeClick(handler));
}
private refreshPriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.refreshPriceScaleControls();
});
}
private destroyPane(id: number): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
const paneIndex = pane.paneIndex();
this.subscriptions.unsubscribe();
this.panesMap.delete(id);
pane.destroy();
if (paneIndex >= 0) {
this.sharedPaneParams.lwcChart.removePane(paneIndex);
}
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
private getCollectionDependencies(): Pick<PaneParams, CollectionDependencies> {
return {
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
addDrawingManager: (manager, paneId) => this.drawingsManagerCollection.addDrawingManager(manager, paneId),
removeDrawingManager: (paneId: number) => this.drawingsManagerCollection.removeDrawingManager(paneId),
setActiveTool: (name: ActiveDrawingTool) => this.drawingsManagerCollection.setActiveTool(name),
getActiveTool: () => this.drawingsManagerCollection.getActiveToolValue(),
getIsEndlessMode: () => this.drawingsManagerCollection.getIsEndlessMode(),
continueDrawing: (name: DrawingsNames) => this.drawingsManagerCollection.continueDrawing(name),
};
}
private handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}
import classNames from 'classnames';
import { Button, Divider, Tooltip } from 'exchange-elements/v2';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { Observable } from 'rxjs';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { MenuList } from '@src/components/Menu';
import SplitDropdown from '@src/components/SplitDropdown';
import {
gannAndFibonacciTools,
geometricShapes,
measurementTools,
trendLines,
} from '@src/components/Toolbar/constants';
import { CHART_DRAWING_TOOLTIP, DrawingsNames } from '@src/constants';
import { t } from '@src/translations';
import { ActiveDrawingTool } from '@src/types';
import { ensureDefined, useObservable } from '@src/utils';
import {
CrossIcon,
EyeBrushIcon,
HeartIcon,
LayersIcon,
MagnetIcon,
PencilLockerIcon,
RulerIcon,
SplineCurveIcon,
TrashIcon,
TypeIcon,
UnlockIcon,
ZoomInIcon,
} from '../Icon';
import styles from './index.module.scss';
interface ToolbarProps {
toggleDOM: () => void;
addDrawing: (name: DrawingsNames) => Promise<void>;
setEndlessDrawingsMode: (value: boolean) => void;
isEndlessDrawingsMode$: Observable<boolean>;
activateCrosshair: () => void;
activeTool$: Observable<ActiveDrawingTool>;
hotkeys: Hotkeys;
}
const implemented = {
crosshair: true,
lines: true,
fib: true,
rectangle: true,
text: true,
XABCD: false,
position: true,
icons: false,
};
export default function Toolbar({
toggleDOM,
addDrawing,
setEndlessDrawingsMode,
isEndlessDrawingsMode$,
activateCrosshair,
activeTool$,
hotkeys,
}: ToolbarProps) {
const [selectedLineType, setSelectedLineType] = useState<DrawingsNames>(DrawingsNames.trendLine);
const [selectedMeasurementTool, setSelectedMeasurementTool] = useState(DrawingsNames.fixedRangeProfile);
const [selectedGeometricShape, setSelectedGeometricShape] = useState(DrawingsNames.rectangle);
const [selectedGannAndFibonacci, setSelectedGannAndFibonacci] = useState(DrawingsNames.fibonacciRetracement);
const isEndlessDrawingsMode = useObservable(isEndlessDrawingsMode$);
const activeTool = useObservable(activeTool$, 'crosshair');
const toolbarRef = useRef<HTMLDivElement | null>(null);
const createDrawingHandler = (setter: Dispatch<SetStateAction<DrawingsNames>>) => async (value: DrawingsNames) => {
setter(value);
await addDrawing(value);
};
const findOptionByValue = <T extends { value: string }>(options: T[], selectedValue: T['value']): T | undefined =>
options.find((item) => item.value === selectedValue);
const selectedTrendLineOption = findOptionByValue(trendLines(), selectedLineType);
const selectedMeasurementToolOption = findOptionByValue(measurementTools(), selectedMeasurementTool);
const selectedGeometricShapeOption = findOptionByValue(geometricShapes(), selectedGeometricShape);
const selectedGannAndFibonacciOption = findOptionByValue(gannAndFibonacciTools(), selectedGannAndFibonacci);
const getButtonClassName = (tool: ActiveDrawingTool) =>
classNames(styles.button, {
[styles.pressed]: activeTool === tool,
});
const getTooltipClassName = () => classNames(styles.tooltipHint, CHART_DRAWING_TOOLTIP);
useEffect(() => {
const hashT = hotkeys.register({
keys: [Keys.alt, Keys.t],
callback: async () => {
await createDrawingHandler(setSelectedLineType)(DrawingsNames.trendLine);
},
});
const hashH = hotkeys.register({
keys: [Keys.alt, Keys.h],
callback: async () => {
await createDrawingHandler(setSelectedLineType)(DrawingsNames.horizontalLine);
},
});
const hashV = hotkeys.register({
keys: [Keys.alt, Keys.v],
callback: async () => {
await createDrawingHandler(setSelectedLineType)(DrawingsNames.verticalLine);
},
});
const hashF = hotkeys.register({
keys: [Keys.alt, Keys.f],
callback: async () => {
await createDrawingHandler(setSelectedGannAndFibonacci)(DrawingsNames.fibonacciRetracement);
},
});
const hashR = hotkeys.register({
keys: [Keys.alt, Keys.shift, Keys.r],
callback: async () => {
await createDrawingHandler(setSelectedGeometricShape)(DrawingsNames.rectangle);
},
});
// const hashShift = hotkeys.register({
// keys: [Keys.shift],
// pressHoldRequired: true,
// callback: async () => {
// await addDrawing(DrawingsNames.ruler);
// },
// });
return () => {
hotkeys.unregister({
keys: [Keys.alt, Keys.t],
hash: hashT,
});
hotkeys.unregister({
keys: [Keys.alt, Keys.h],
hash: hashH,
});
hotkeys.unregister({
keys: [Keys.alt, Keys.v],
hash: hashV,
});
hotkeys.unregister({
keys: [Keys.alt, Keys.f],
hash: hashF,
});
hotkeys.unregister({
keys: [Keys.alt, Keys.shift, Keys.r],
hash: hashR,
});
// hotkeys.unregister({
// keys: [Keys.shift],
// hash: hashShift
// });
};
}, []);
return (
<div
ref={toolbarRef}
className={styles.toolbar}
>
<div className={classNames(styles.group)}>
{implemented.crosshair && (
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Crosshair')}
location="right"
>
<Button
size="sm"
onClick={activateCrosshair}
className={getButtonClassName('crosshair')}
label={<CrossIcon />}
/>
</Tooltip>
)}
{implemented.lines && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedTrendLineOption?.icon)}
mainTooltip={ensureDefined(selectedTrendLineOption).label}
onMainClick={() => addDrawing(selectedLineType)}
menuTooltip={t('Trend line')}
mainButtonClassName={getButtonClassName(selectedLineType)}
tooltipClassName={getTooltipClassName()}
>
<MenuList
mode="single"
value={selectedLineType}
options={trendLines()}
onClick={createDrawingHandler(setSelectedLineType)}
/>
</SplitDropdown>
)}
{implemented.fib && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedGannAndFibonacciOption?.icon)}
mainTooltip={ensureDefined(selectedGannAndFibonacciOption).label}
onMainClick={() => addDrawing(selectedGannAndFibonacci)}
menuTooltip={t('Gann and Fibonacci')}
mainButtonClassName={getButtonClassName(selectedGannAndFibonacci)}
tooltipClassName={getTooltipClassName()}
>
<MenuList
mode="single"
value={selectedGannAndFibonacci}
options={gannAndFibonacciTools()}
onClick={createDrawingHandler(setSelectedGannAndFibonacci)}
/>
</SplitDropdown>
)}
{implemented.rectangle && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedGeometricShapeOption?.icon)}
mainTooltip={ensureDefined(selectedGeometricShapeOption).label}
onMainClick={() => addDrawing(selectedGeometricShape)}
menuTooltip={t('Geometric shapes')}
mainButtonClassName={getButtonClassName(selectedGeometricShape)}
tooltipClassName={getTooltipClassName()}
>
<MenuList
mode="single"
value={selectedGeometricShape}
options={geometricShapes()}
onClick={createDrawingHandler(setSelectedGeometricShape)}
/>
</SplitDropdown>
)}
{implemented.text && (
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Text')}
location="right"
>
<Button
size="sm"
className={getButtonClassName(DrawingsNames.text)}
onClick={() => addDrawing(DrawingsNames.text)}
label={<TypeIcon />}
/>
</Tooltip>
)}
{implemented.XABCD && (
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('XABCD template')}
location="right"
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<SplineCurveIcon />}
/>
</Tooltip>
)}
{implemented.position && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedMeasurementToolOption?.icon)}
mainTooltip={ensureDefined(selectedMeasurementToolOption).label}
menuTooltip={t('Measurement tools')}
onMainClick={() => addDrawing(selectedMeasurementTool)}
mainButtonClassName={getButtonClassName(selectedMeasurementTool)}
tooltipClassName={getTooltipClassName()}
>
<MenuList
mode="single"
value={selectedMeasurementTool}
options={measurementTools()}
onClick={createDrawingHandler(setSelectedMeasurementTool)}
/>
</SplitDropdown>
)}
{implemented.icons && (
<Tooltip
label={t('Pin')}
location="right"
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<HeartIcon />}
/>
</Tooltip>
)}
</div>
<Divider
direction="horizontal"
pt={{ divider: { className: styles.divider } }}
/>
<div className={classNames(styles.group)}>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Ruler')}
location="right"
>
<Button
size="sm"
className={getButtonClassName(DrawingsNames.ruler)}
onClick={() => addDrawing(DrawingsNames.ruler)}
label={<RulerIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Upscale')}
location="right"
className={styles.notImplemented}
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<ZoomInIcon />}
/>
</Tooltip>
</div>
<Divider
direction="horizontal"
pt={{ divider: { className: classNames(styles.divider) } }}
/>
<div className={classNames(styles.group)}>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Magnet allows you to attract objects points to the nearest bar prices')}
location="right"
className={styles.notImplemented}
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<MagnetIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Endless drawing mode')}
location="right"
>
<Button
size="sm"
className={`${styles.button} ${isEndlessDrawingsMode ? styles.pressed : ''}`}
onClick={() => setEndlessDrawingsMode(!isEndlessDrawingsMode)}
label={<PencilLockerIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Fix all objects')}
location="right"
className={styles.notImplemented}
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<UnlockIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Hide all drawing objects')}
location="right"
className={styles.notImplemented}
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<EyeBrushIcon />}
/>
</Tooltip>
</div>
<Divider
direction="horizontal"
pt={{ divider: { className: classNames(styles.divider) } }}
/>
<div className={classNames(styles.group)}>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('Clear objects')}
location="right"
className={styles.notImplemented}
>
<Button
size="sm"
className={styles.button}
onClick={() => {}}
label={<TrashIcon />}
/>
</Tooltip>
<Tooltip
tooltipClassName={getTooltipClassName()}
showMessageOnFocus
label={t('DOM tree')}
location="right"
>
<Button
size="sm"
className={styles.button}
onClick={() => toggleDOM()}
label={<LayersIcon />}
/>
</Tooltip>
</div>
</div>
);
}
if (config.chartCollectionPreset.undoRedoEnabled) {
const undoRedo = this.eventManager.getUndoRedo();
this.hotkeys.register({
keys: [Keys.mod, Keys.z],
callback: undoRedo.undo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.y],
callback: undoRedo.redo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.shift, Keys.z],
callback: undoRedo.redo,
});
}