Загрузка данных
import { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { FibonacciRetracement } from '@src/core/Drawings/fibonacciRetracement';
import { Ray } from '@src/core/Drawings/ray';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Text } from '@src/core/Drawings/text';
import { Traectory } from '@src/core/Drawings/traectory';
import { TrendLine } from '@src/core/Drawings/trendLine';
import { VolumeProfile } from '@src/core/Drawings/volumeProfile';
import { t } from '@src/translations';
import { DrawingConfig } from '@src/types';
export enum DrawingsNames {
'trendLine' = 'trendLine',
'ray' = 'ray',
'horizontalLine' = 'horizontalLine',
'horizontalRay' = 'horizontalRay',
'verticalLine' = 'verticalLine',
'ruler' = 'ruler',
'fibonacciRetracement' = 'fibonacciRetracement',
'sliderLong' = 'sliderLong',
'sliderShort' = 'sliderShort',
'diapsonDates' = 'diapsonDates',
'diapsonPrices' = 'diapsonPrices',
'fixedRangeProfile' = 'fixedRangeProfile',
'visibleRangeProfile' = 'visibleRangeProfile',
'rectangle' = 'rectangle',
'traectory' = 'traectory',
'text' = 'text',
}
export const drawingLabelById = (): Record<DrawingsNames, string> => ({
[DrawingsNames.trendLine]: t('Trend line'),
[DrawingsNames.ray]: t('Ray'),
[DrawingsNames.horizontalLine]: t('Horizontal line'),
[DrawingsNames.horizontalRay]: t('Horizontal ray'),
[DrawingsNames.verticalLine]: t('Vertical line'),
[DrawingsNames.fibonacciRetracement]: t('Fibonacci retracement'),
[DrawingsNames.ruler]: t('Ruler'),
[DrawingsNames.sliderLong]: t('Long position'),
[DrawingsNames.sliderShort]: t('Short position'),
[DrawingsNames.diapsonDates]: t('Dates range'),
[DrawingsNames.diapsonPrices]: t('Prices range'),
[DrawingsNames.fixedRangeProfile]: t('Fixed range volume profile'),
[DrawingsNames.visibleRangeProfile]: t('Anchored volume profile'),
[DrawingsNames.rectangle]: t('Rectangle'),
[DrawingsNames.traectory]: t('Traectory'),
[DrawingsNames.text]: t('Text'),
});
export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
[DrawingsNames.trendLine]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new TrendLine(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.ray]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new Ray(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.horizontalLine]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new AxisLine(chart, series, {
direction: 'horizontal',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.horizontalRay]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new TrendLine(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.verticalLine]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new AxisLine(chart, series, {
direction: 'vertical',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.sliderLong]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new SliderPosition(chart, series, {
side: 'long',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.fibonacciRetracement]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new FibonacciRetracement(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.sliderShort]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new SliderPosition(chart, series, {
side: 'short',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.diapsonDates]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new Diapson(chart, series, {
rangeMode: 'date',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.diapsonPrices]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new Diapson(chart, series, {
rangeMode: 'price',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.fixedRangeProfile]: {
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new VolumeProfile(chart, series, {
profileKind: 'fixedRange',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.visibleRangeProfile]: {
singleInstance: true,
construct: ({ chart, series, container, eventManager, removeSelf, openSettings }) => {
return new VolumeProfile(chart, series, {
profileKind: 'visibleRange',
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.rectangle]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new Rectangle(chart, series, {
container,
formatObservable: eventManager.getChartOptionsModel(),
removeSelf,
openSettings,
});
},
},
[DrawingsNames.ruler]: {
singleInstance: true,
construct: ({ chart, series, eventManager, container, removeSelf }) => {
return new Ruler(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
removeSelf,
});
},
},
[DrawingsNames.traectory]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new Traectory(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
removeSelf,
openSettings,
});
},
},
[DrawingsNames.text]: {
construct: ({ chart, series, eventManager, container, removeSelf, openSettings }) => {
return new Text(chart, series, {
formatObservable: eventManager.getChartOptionsModel(),
container,
removeSelf,
openSettings,
});
},
},
};
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Subject, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { SettingsTab, SettingsValues } from '@src/types';
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
waitTillReady(): Promise<void>;
isCreationPending(): boolean;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
getSettingsTabs(): SettingsTab[];
updateSettings(settings: SettingsValues): void;
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription;
subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription;
isSelected(): boolean;
isLocked(): boolean;
setSelected(isSelected: boolean): void;
setLocked(isLocked: boolean): void;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isActive = new BehaviorSubject(false);
protected isBound = false;
private readonly isLockedSubject = new BehaviorSubject(false);
private readonly settingsSubject = new Subject<SettingsValues>();
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
}
public subscribeIsSelected(callback: (isSelected: boolean) => void): Subscription {
return this.isActive.pipe(distinctUntilChanged()).subscribe(callback);
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.isLockedSubject.pipe(distinctUntilChanged()).subscribe(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public isSelected(): boolean {
return this.isActive.value;
}
public setSelected(isSelected: boolean): void {
if (this.isActive.value === isSelected) {
return;
}
this.isActive.next(isSelected);
this.render();
}
public isLocked(): boolean {
return this.isLockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.isLockedSubject.value === isLocked) {
return;
}
this.isLockedSubject.next(isLocked);
if (isLocked) {
this.showCrosshair();
}
this.render();
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.isLockedSubject.complete();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.settingsSubject.next(this.getSettings());
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public abstract getRenderData(): unknown; // todo: make proper type
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isActive.value || this.isCreationPending());
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
this.container.addEventListener('contextmenu', this.handleContextMenu);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerDown(event: PointerEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private handlePointerDownEvent = (event: PointerEvent): void => {
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (this.isActive.value === isDrawingHit) {
return;
}
this.isActive.next(isDrawingHit);
this.render();
};
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
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';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
hotkeys: Hotkeys;
setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
resetActiveTool: () => void;
}
export class Drawing extends DOMObject implements IDrawing {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private escapeUnregisterHash: string | null = null;
private deleteUnregisterHash: string | null = null;
private copyUnregisterHash: string | null = null;
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
zIndex,
moveUp,
moveDown,
construct,
paneId,
hotkeys,
setCopyPasteBuffer,
resetActiveTool,
}: DrawingParams) {
super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });
this.hotkeys = hotkeys;
this.lwcDrawing = construct(lwcChart, mainSeries);
this.onDelete = onDelete;
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.lwcDrawing.subscribeIsSelected((isSelected) => {
if (isSelected) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.control, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
setCopyPasteBuffer({
id: this.id,
drawingName: this.getDrawingName(),
state: this.getState(),
isLocked: this.isLocked(),
});
}
},
});
return;
}
hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
this.deleteUnregisterHash = null;
this.copyUnregisterHash = null;
});
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.afterCreation(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
});
}
public delete(): void {
this.destroy();
super.delete();
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getLwcDrawing(): 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.lwcDrawing.subscribeIsLocked(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
return this.lwcDrawing.subscribeSettings(callback);
}
public isLocked(): boolean {
return this.lwcDrawing.isLocked();
}
public isSelected(): boolean {
return this.lwcDrawing.isSelected();
}
public setSelected(isSelected: boolean): void {
this.lwcDrawing.setSelected(isSelected);
}
public setLocked(isLocked: boolean): void {
this.lwcDrawing.setLocked(isLocked);
}
public async 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);
}
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.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private async afterCreation(callback: () => void): Promise<void> {
await this.lwcDrawing.waitTillReady();
callback();
}
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } 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 } from '@src/types';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
}
export interface DrawingSnapshotItem {
id: string;
drawingName: DrawingsNames;
state: unknown;
isLocked?: boolean;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
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 mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private recreateScheduled = false;
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private paneId: number;
private hotkeys: Hotkeys;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private escapeUnregisterHash: string | null = null;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
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;
this.setSnapshot(snapshot);
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
// 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 = (): void => {
this.DOM.refreshEntities();
queueMicrotask(this.updateSelectedDrawing);
};
private handlePointerUp = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
queueMicrotask(this.updateSelectedDrawing);
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
this.updateSelectedDrawing();
};
private updateSelectedDrawing = (): void => {
const drawings = this.drawings$.value;
const pendingDrawing = drawings.find((drawing) => drawing.isCreationPending() && drawing.isSelected());
if (pendingDrawing) {
drawings.forEach((drawing) => {
if (drawing !== pendingDrawing && drawing.isSelected()) {
drawing.setSelected(false);
}
});
if (this.selectedDrawing$.value !== null) {
this.selectedDrawing$.next(null);
}
return;
}
const selectedDrawings = drawings.filter((drawing) => drawing.isSelected() && !drawing.isCreationPending());
const currentDrawing = this.selectedDrawing$.value;
const nextDrawing = selectedDrawings.find((drawing) => drawing !== currentDrawing) ?? selectedDrawings[0] ?? null;
this.setSelectedDrawing(nextDrawing);
};
private setSelectedDrawing(drawing: Drawing | null): void {
this.drawings$.value.forEach((item) => {
const shouldBeSelected = item === drawing;
if (item.isSelected() !== shouldBeSelected) {
item.setSelected(shouldBeSelected);
}
});
if (this.selectedDrawing$.value !== drawing) {
this.selectedDrawing$.next(drawing);
}
}
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.activeTool$.value;
const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;
if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
if (this.recreateScheduled) {
return;
}
this.recreateScheduled = true;
queueMicrotask(() => {
this.recreateScheduled = false;
const currentTool = this.activeTool$.value;
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair') {
return;
}
if (!this.endlessMode$.value) {
return;
}
if (drawingsMap[currentTool]?.singleInstance) {
return;
}
if (hasPendingAfterTick) {
return;
}
this.createDrawing(currentTool);
});
return;
}
this.activeTool$.next('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.drawings$.value.find((item) => item.id === id);
if (!drawing) {
return;
}
this.removeDrawings([drawing]);
};
private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);
this.removeDrawings(drawingsToRemove, 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;
}
if (this.selectedDrawing$.value && drawingsToRemove.includes(this.selectedDrawing$.value)) {
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.updateSelectedDrawing();
this.DOM.refreshEntities();
}
public addDrawingForce = (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
if (drawingsMap[name].singleInstance) {
this.removeDrawingsByName(name, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name);
this.DOM.refreshEntities();
return drawing.waitForCreation();
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
if (!this.mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, isLocked = false, shouldUpdateDrawingsList = true } = options;
const shouldSelectAfterCreation = state === undefined;
if (shouldSelectAfterCreation) {
this.setSelectedDrawing(null);
}
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) => {
const paneElement = series.getPane().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,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
});
};
const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries: this.mainSeries as SeriesStrategies,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
zIndex,
moveDown,
moveUp,
construct,
paneId: this.paneId,
hotkeys: this.hotkeys,
setCopyPasteBuffer: (copyPasteBuffer) => {
this.copyPasteBuffer = copyPasteBuffer;
},
resetActiveTool: () => {
this.activeTool$.next('crosshair');
},
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(state);
}
entity.setLocked(isLocked);
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity]);
}
if (shouldSelectAfterCreation) {
entity.waitForCreation().then(() => {
if (!this.drawings$.value.includes(entity)) {
return;
}
this.setSelectedDrawing(entity);
this.updateActiveTool();
this.DOM.refreshEntities();
});
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => ({
id: drawing.id,
drawingName: drawing.getDrawingName(),
state: drawing.getState(),
isLocked: drawing.isLocked(),
}));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = snapshot;
return;
}
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing(item.drawingName, {
id: item.id,
state: item.state,
isLocked: item.isLocked,
shouldUpdateDrawingsList: false,
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings);
this.activeTool$.next('crosshair');
this.updateSelectedDrawing();
this.DOM.refreshEntities();
}
public setEndlessDrawingMode = (value: boolean): void => {
if (value) {
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.setEndlessDrawingMode(false);
},
});
} else {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return this.selectedDrawing$.asObservable();
}
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;
}
drawing.setLocked(!drawing.isLocked());
}
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: () => drawing.updateSettings(settings),
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
this.setSelectedDrawing(null);
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
window.removeEventListener('pointerup', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}