Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { Observable, 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 DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
isSelected(): boolean;
isLocked(): boolean;
select(): void;
deselect(): void;
beginChange(): void;
commitChange(): void;
}
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;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
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 isBound = false;
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
private isInteractionBound = false;
private isChangeStarted = false;
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
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.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
this.isChangeStarted = false;
}
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.bindInteraction();
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 isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || 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.handlePointerUpEvent);
window.addEventListener('pointercancel', this.handlePointerUpEvent);
}
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.handlePointerUpEvent);
window.removeEventListener('pointercancel', this.handlePointerUpEvent);
}
// 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 bindInteraction(): void {
if (this.isInteractionBound) {
return;
}
this.isInteractionBound = true;
this.subscriptions.add(
this.interaction.selected$.subscribe(() => {
this.render();
}),
);
this.subscriptions.add(
this.interaction.locked$.subscribe((isLocked) => {
if (isLocked) {
this.showCrosshair();
}
this.render();
}),
);
}
private handlePointerDownEvent = (event: PointerEvent): void => {
if (this.isLocked() && !this.isCreationPending() && event.button === 0) {
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (isDrawingHit) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
return;
}
if (this.shouldBeginChange(event)) {
this.interaction.beginChange();
this.isChangeStarted = true;
}
this.handlePointerDown(event);
};
private handlePointerUpEvent = (event: PointerEvent): void => {
this.handlePointerUp(event);
if (!this.isChangeStarted) {
return;
}
this.isChangeStarted = false;
this.interaction.commitChange();
};
private shouldBeginChange(event: PointerEvent): boolean {
if (
event.button !== 0 ||
this.isLocked() ||
this.isCreationPending() ||
!this.isSelected()
) {
return false;
}
const point = this.getEventPoint(event);
return this.getHoveredItem(point.x, point.y) !== 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 '@core/Drawings/common';
import type { DrawingSnapshot } from '@core/Drawings/snapshot';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (
chart: IChartApi,
series: ISeriesApi<SeriesType>,
interaction: DrawingInteraction,
) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
select: () => void;
deselect: () => void;
beginChange: () => void;
commitChange: () => void;
isLocked?: boolean;
hotkeys: Hotkeys;
setCopyPasteBuffer: (snapshot: DrawingSnapshot) => void;
resetActiveTool: () => void;
}
export class Drawing extends DOMObject implements IDrawing {
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 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,
selected$,
isSelected,
select,
deselect,
beginChange,
commitChange,
isLocked = false,
paneId,
hotkeys,
setCopyPasteBuffer,
resetActiveTool,
}: 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,
beginChange,
commitChange,
};
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.onDelete = onDelete;
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.subscriptions.add(
selected$.subscribe((isSelectedDrawing) => {
if (isSelectedDrawing) {
this.registerSelectedDrawingHotkeys(setCopyPasteBuffer);
return;
}
this.unregisterSelectedDrawingHotkeys();
}),
);
this.afterCreation(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
});
}
public delete(): void {
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.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 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);
const hidden = getHiddenState(state);
if (hidden !== null) {
this.hidden.next(hidden);
}
this.settingsSubject.next(this.lwcDrawing.getSettings());
}
public getSnapshot(): DrawingSnapshot {
return {
id: this.id,
drawingName: this.drawingName,
state: this.getState(),
isLocked: this.isLocked(),
zIndex: this.zIndex,
};
}
public applySnapshot(snapshot: DrawingSnapshot): void {
this.setState(snapshot.state);
this.setLocked(snapshot.isLocked ?? false);
if (snapshot.zIndex !== undefined) {
this.setZIndex(snapshot.zIndex);
}
}
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.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
this.lockedSubject.complete();
this.settingsSubject.complete();
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private registerSelectedDrawingHotkeys(
setCopyPasteBuffer: (snapshot: DrawingSnapshot) => void,
): void {
this.unregisterSelectedDrawingHotkeys();
this.deleteUnregisterHash = this.hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = this.hotkeys.register({
keys: [Keys.mod, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
setCopyPasteBuffer(this.getSnapshot());
}
},
});
}
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;
}
private async afterCreation(callback: () => void): Promise<void> {
await this.lwcDrawing.waitTillReady();
callback();
}
}
function getHiddenState(state: unknown): boolean | null {
if (
typeof state !== 'object' ||
state === null ||
!('hidden' in state)
) {
return null;
}
const { hidden } = state as { hidden?: unknown };
return typeof hidden === 'boolean' ? hidden : null;
}
import { BehaviorSubject, map, Observable } from 'rxjs';
import { DOM } from '@components/DOM';
import { IDOMObject } from '@core/DOMObject';
import { ModalRenderer } from '@core/ModalRenderer';
import { t } from '@src/translations';
interface DOMModelParams {
modalRenderer: ModalRenderer;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class DOMModel {
// ∈ symbol&pane
private modalRenderer: ModalRenderer;
private lastZIndex = 0;
private entities: BehaviorSubject<IDOMObject[]> = new BehaviorSubject<IDOMObject[]>([]); // drawings/indicators/series
// private panes: Panes[]; // todo: пока что на каждый пейн будет один objectTree
constructor({ modalRenderer }: DOMModelParams) {
this.modalRenderer = modalRenderer;
}
public removeEntity = <T extends IDOMObject>(entity: T): void => {
this.entities.next(
this.entities.value.filter((item) => item.id !== entity.id),
);
};
public setEntity = <T extends IDOMObject>(
callback: (
zIndex: number,
moveUp: (id: string) => void,
moveDown: (id: string) => void,
) => T,
zIndex?: number,
): T => {
const entityZIndex = zIndex ?? this.lastZIndex;
this.lastZIndex = Math.max(
this.lastZIndex,
entityZIndex + 1,
);
const entity = callback(
entityZIndex,
this.moveUp,
this.moveDown,
);
this.entities.next(
[...this.entities.value, entity].sort(
(left, right) => left.zIndex - right.zIndex,
),
);
return entity;
};
private moveUp = (id: string): void => {
this.moveEntity(id, 1);
};
private moveDown = (id: string): void => {
this.moveEntity(id, -1);
};
private moveEntity(id: string, direction: -1 | 1): void {
const entities = [...this.entities.value].sort(
(left, right) => left.zIndex - right.zIndex,
);
const currentIndex = entities.findIndex(
(entity) => entity.id === id,
);
if (currentIndex === -1) {
return;
}
const targetIndex = currentIndex + direction;
const target = entities[targetIndex];
if (!target) {
return;
}
const current = entities[currentIndex];
const currentZIndex = current.zIndex;
current.setZIndex(target.zIndex);
target.setZIndex(currentZIndex);
this.entities.next(
entities.sort(
(left, right) => left.zIndex - right.zIndex,
),
);
}
public getEntities = (): Observable<IDOMObject[]> => {
return this.entities.pipe(
map((entities) =>
entities.filter((entity) =>
entity.shouldShowInObjectTree(),
),
),
);
};
public refreshEntities = (): void => {
this.entities.next([...this.entities.value]);
};
public toggleDOM = () => {
this.modalRenderer.renderComponent(
<DOM elementsObs={this.getEntities()} />,
{
title: t('DOM tree'),
onSave: () => console.warn('dom state saved'),
acceptLabel: '',
rejectLabel: '',
},
);
};
public destroy(): void {
// todo implement
}
}
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',
'z' = 'keyz',
'y' = 'keyy',
'c' = 'keyc',
'mousedown' = 'mousedown',
}
type HotkeyCallback = () => void | Promise<void>;
interface RegisterHotkeyParams {
keys: Keys[];
callback: HotkeyCallback;
}
interface UnregisterHotkeyParams {
keys: Keys[];
hash?: string | null;
}
interface HotkeyRegistration {
keys: Keys[];
callback: HotkeyCallback;
}
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>();
constructor() {
document.addEventListener('keydown', this.handleKeyDown);
}
public register({
keys,
callback,
}: RegisterHotkeyParams): string | null {
if (keys.length === 0) {
console.error('[Hotkeys] попытка задать пустой хоткей');
return null;
}
const hash = `hotkeyCallback-${crypto.randomUUID()}`;
this.registrations.set(hash, {
keys,
callback,
});
return hash;
}
public unregister({
hash,
}: UnregisterHotkeyParams): void {
if (!hash) {
return;
}
this.registrations.delete(hash);
}
public destroy(): void {
document.removeEventListener(
'keydown',
this.handleKeyDown,
);
this.registrations.clear();
}
private handleKeyDown = async (
event: KeyboardEvent,
): Promise<void> => {
if (
event.repeat ||
isEditableElement(event.target)
) {
return;
}
const callbacks = [...this.registrations.values()]
.filter(({ keys }) => matchesHotkey(event, keys))
.map(({ callback }) => callback)
.reverse();
if (callbacks.length === 0) {
return;
}
event.preventDefault();
for (const callback of callbacks) {
// eslint-disable-next-line no-await-in-loop
await callback();
}
};
}
function matchesHotkey(
event: KeyboardEvent,
keys: Keys[],
): boolean {
const mainKeys = keys.filter(
(key) => !isModifier(key),
);
if (
mainKeys.length !== 1 ||
mainKeys[0] !== normalizeCode(event.code)
) {
return false;
}
const hasMod = keys.includes(Keys.mod);
if (hasMod) {
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 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
);
}