Загрузка данных


import dayjs from 'dayjs';

import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
import { filter, take } from 'rxjs/operators';

import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { ensureDefined, getStartTime, normalizeSeriesData, parseTimeframe } from '@src/utils';

export interface SymbolSourceParams {
  symbol: string;
  getData: (timeframe: Timeframes, symbol: string, until?: Candle) => Promise<Candle[] | null>;
  getTimeframe: () => Timeframes;
}

export class SymbolSource {
  public readonly symbol: string;

  private readonly getData: SymbolSourceParams['getData'];
  private readonly getTimeframe: SymbolSourceParams['getTimeframe'];

  private readonly currentDataSubject = new BehaviorSubject<Candle[]>([]);
  private readonly realtimeSubject = new Subject<Candle>();
  private readonly lastCandleSubject = new BehaviorSubject<Candle | null>(null);
  private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
  private readonly isInitializedSubject = new BehaviorSubject<boolean>(false);

  private realtimeCache: Candle[] = [];
  private realtimeBuffer: Candle[] = [];

  private oldestCandle: Candle | null = null;
  private newestCandle: Candle | null = null;

  private loadSeq = 0;
  private loadingPromise: Promise<void> | null = null;

  private isEndOfData = false;

  constructor({ symbol, getData, getTimeframe }: SymbolSourceParams) {
    this.symbol = symbol;
    this.getData = getData;
    this.getTimeframe = getTimeframe;
  }

  public init(): void {
    this.reload(this.getTimeframe());
  }

  public data$(): Observable<Candle[]> {
    return this.currentDataSubject.asObservable();
  }

  public realtime$(): Observable<Candle> {
    return this.realtimeSubject.asObservable();
  }

  public lastCandle$(): Observable<Candle | null> {
    return this.lastCandleSubject.asObservable();
  }

  public isInitialized$(): Observable<boolean> {
    return this.isInitializedSubject.asObservable();
  }

  public isLoadingValue(): boolean {
    return this.isLoadingSubject.value;
  }

  public getOldestTime(): number | null {
    return this.oldestCandle?.time ?? null;
  }

  public getLastValue(): Candle | null {
    return this.lastCandleSubject.value;
  }

  public async ready(): Promise<void> {
    if (this.isInitializedSubject.value) return;
    await firstValueFrom(this.isInitializedSubject.pipe(filter(Boolean), take(1)));
  }

  public destroy(): void {
    this.loadSeq += 1;
    this.loadingPromise = null;

    this.currentDataSubject.complete();
    this.realtimeSubject.complete();
    this.lastCandleSubject.complete();
    this.isLoadingSubject.complete();
    this.isInitializedSubject.complete();

    this.realtimeCache = [];
    this.realtimeBuffer = [];
    this.oldestCandle = null;
    this.newestCandle = null;
  }

  public pushRealtime(next: Candle): void {
    const tf = this.getTimeframe();
    const aligned = this.alignCandle(tf, next);

    const res = SymbolSource.stackCandles(tf, aligned, this.newestCandle, this.realtimeCache);
    this.newestCandle = res.newestCandle;
    this.realtimeCache = res.realtimeCache;

    this.lastCandleSubject.next(res.newestCandle);

    if (!this.isInitializedSubject.value) {
      this.realtimeBuffer.push(res.newestCandle);
      return;
    }

    this.realtimeSubject.next(res.newestCandle);
  }

  public saveRealtimeCache(): void {
    if (this.realtimeCache.length === 0) return;

    const tf = this.getTimeframe();
    this.realtimeCache = this.normalizeList(tf, this.realtimeCache);

    const left = this.currentDataSubject.value;
    const right = this.realtimeCache;

    if (left.length === 0 || right.length === 0) {
      this.realtimeCache = [];
      return;
    }

    const lastLeft = left[left.length - 1];
    const firstRight = right[0];

    const next =
      lastLeft && firstRight && lastLeft.time === firstRight.time
        ? left
            .slice(0, -1)
            .concat(SymbolSource.combineCandles(lastLeft, firstRight, true))
            .concat(right.slice(1))
        : left.concat(right);

    const normalizedNext = this.normalizeList(tf, next);

    this.currentDataSubject.next(normalizedNext);
    this.newestCandle = normalizedNext[normalizedNext.length - 1] ?? null;
    this.realtimeCache = [];

    this.lastCandleSubject.next(this.newestCandle);
  }

  public async loadMoreHistory(): Promise<void> {
    await this.ready();

    if (this.isEndOfData) return;

    if (this.loadingPromise) {
      return this.loadingPromise;
    }

    this.loadSeq += 1;

    if (!this.oldestCandle) return;

    const tf = ensureDefined(this.getTimeframe());

    const task = (async () => {
      this.isLoadingSubject.next(true);

      try {
        this.saveRealtimeCache();
        if (!this.oldestCandle) return;

        const olderData = await this.getData(tf, this.symbol, this.oldestCandle);
        if (olderData === null) {
          this.isEndOfData = true;
          return;
        }

        const older = this.normalizeList(tf, olderData);
        if (older.length === 0) return;

        const current = this.currentDataSubject.value;
        const combinedRaw = SymbolSource.mergeHistory(older, current);
        const combined = this.normalizeList(tf, combinedRaw);

        const nextOldest = combined[0] ?? null;

        if (!nextOldest || !this.oldestCandle || nextOldest.time >= this.oldestCandle.time) {
          return;
        }

        this.oldestCandle = nextOldest;
        this.newestCandle = combined[combined.length - 1] ?? this.newestCandle;

        this.currentDataSubject.next(combined);
        this.lastCandleSubject.next(this.newestCandle);
      } catch (error) {
        console.error('[DataSource] Ошибка при догрузке истории:', error);
      } finally {
        this.isLoadingSubject.next(false);
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  public async loadAllHistory(): Promise<void> {
    await this.ready();

    if (this.isEndOfData) return;

    if (this.loadingPromise) {
      await this.loadingPromise;
    }

    const seq = ++this.loadSeq;
    const tf = ensureDefined(this.getTimeframe());

    const task = (async () => {
      this.isLoadingSubject.next(true);

      try {
        this.saveRealtimeCache();

        let current = this.currentDataSubject.value;
        let oldest = current[0] ?? null;

        if (!oldest) return;

        const seenOldestTimes = new Set<number>();

        while (oldest) {
          if (seq !== this.loadSeq) return;

          if (seenOldestTimes.has(oldest.time)) {
            break;
          }

          seenOldestTimes.add(oldest.time);

          // eslint-disable-next-line no-await-in-loop
          const olderData = await this.getData(tf, this.symbol, oldest);
          if (seq !== this.loadSeq) return;
          if (olderData === null) {
            this.isEndOfData = true;
            break;
          }

          const older = this.normalizeList(tf, olderData);
          if (older.length === 0) break;

          const combinedRaw = SymbolSource.mergeHistory(older, current);
          const combined = this.normalizeList(tf, combinedRaw);

          const nextOldest = combined[0] ?? null;

          if (!nextOldest || nextOldest.time >= oldest.time) {
            break;
          }

          oldest = nextOldest;
          current = combined;
        }

        this.oldestCandle = current[0] ?? null;
        this.newestCandle = current[current.length - 1] ?? null;

        this.currentDataSubject.next(current);
        this.saveRealtimeCache();
      } catch (error) {
        console.error('[DataSource] Ошибка при полной загрузке истории:', error);
      } finally {
        if (seq === this.loadSeq) {
          this.isLoadingSubject.next(false);
        }
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) {
        this.loadingPromise = null;
      }
    });

    await task;
  }

  public async loadTill(time: number): Promise<void> {
    await this.ready();

    while (this.oldestCandle && this.oldestCandle.time >= time) {
      const before = this.oldestCandle.time;

      // eslint-disable-next-line no-await-in-loop
      await this.loadMoreHistory();

      if (!this.oldestCandle) break;
      if (this.oldestCandle.time === before) break;
    }
  }

  public async reload(tf: Timeframes): Promise<void> {
    this.loadSeq += 1;
    const seq = this.loadSeq;

    this.isInitializedSubject.next(false);
    this.isLoadingSubject.next(true);

    this.currentDataSubject.next([]);
    this.realtimeCache = [];
    this.realtimeBuffer = [];
    this.oldestCandle = null;
    this.newestCandle = null;
    this.isEndOfData = false;
    this.lastCandleSubject.next(null);

    const task = (async () => {
      try {
        const loaded = (await this.getData(tf, this.symbol)) ?? [];
        if (seq !== this.loadSeq) return;

        const normalized = this.normalizeList(tf, loaded);

        this.oldestCandle = normalized[0] ?? null;
        this.newestCandle = normalized[normalized.length - 1] ?? null;

        this.currentDataSubject.next(normalized);
        this.lastCandleSubject.next(this.newestCandle);

        this.isInitializedSubject.next(true);
        this.flushRealtimeBuffer();
      } catch (error) {
        console.error('[DataSource] Ошибка при загрузке данных:', error);
      } finally {
        if (seq === this.loadSeq) this.isLoadingSubject.next(false);
      }
    })();

    this.loadingPromise = task;

    task.finally(() => {
      if (this.loadingPromise === task) this.loadingPromise = null;
    });

    await task;
  }

  private flushRealtimeBuffer(): void {
    if (this.realtimeBuffer.length === 0) return;

    const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;
    const filtered = this.realtimeBuffer.filter((c) => c.time >= newestTime);

    const map = new Map<number, Candle>();

    for (const c of filtered) {
      map.set(c.time, c);
    }

    const unique = Array.from(map.values()).sort((a, b) => a.time - b.time);

    for (const c of unique) {
      this.realtimeSubject.next(c);
    }

    this.realtimeBuffer = [];
  }

  private alignCandle(tf: Timeframes, c: Candle): Candle {
    const timeMS = c.time > 1e10 ? Math.floor(c.time) : Math.floor(c.time * 1000);
    const startMS = getStartTime(tf, timeMS);

    return { ...c, time: startMS };
  }

  private normalizeList(tf: Timeframes, list: Candle[]): Candle[] {
    const aligned = list.map((c) => this.alignCandle(tf, c)).sort((a, b) => a.time - b.time);

    return normalizeSeriesData(aligned);
  }

  private static stackCandles(
    timeframe: Timeframes,
    next: Candle,
    newestCandle: Candle | null,
    realtimeCache: Candle[],
  ): { realtimeCache: Candle[]; newestCandle: Candle } {
    const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);

    const historicalUpdate = newestCandle
      ? next.time - newestCandle.time < dayjs.duration(candleWidth, dayjsUnit).as('s')
      : false;

    if (!historicalUpdate) {
      const nextNewestCandle = { ...next, time: getStartTime(timeframe, next.time * 1000) };
      realtimeCache.push(nextNewestCandle);
      return { realtimeCache, newestCandle: nextNewestCandle };
    }

    const dataToSet = SymbolSource.combineCandles(ensureDefined(newestCandle), next);

    if (realtimeCache.length === 0) {
      realtimeCache.push(dataToSet);
      return { realtimeCache, newestCandle: dataToSet };
    }

    realtimeCache[realtimeCache.length - 1] = dataToSet;

    return { realtimeCache, newestCandle: dataToSet };
  }

  private static mergeHistory(older: Candle[], current: Candle[]): Candle[] {
    if (current.length === 0) return older;
    if (older.length === 0) return current;

    const lastOlder = older[older.length - 1];
    const firstCurrent = current[0];

    if (lastOlder && firstCurrent && lastOlder.time === firstCurrent.time) {
      const merged = SymbolSource.combineCandles(lastOlder, firstCurrent, true);
      return older.slice(0, -1).concat(merged).concat(current.slice(1));
    }

    return older.concat(current);
  }

  private static combineCandles(left: Candle, right: Candle, volumeStacked?: boolean): Candle {
    return {
      time: left.time,
      open: left.open,
      high: Math.max(left.high, right.high),
      low: Math.min(left.low, right.low),
      close: right.close,
      volume: volumeStacked ? (right.volume ?? 0) : (left.volume ?? 0) + (right.volume ?? 0),
    };
  }
}



import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import { ISerializable, PaneSnapshot } from '@src/types/snapshot';

interface PaneManagerParams extends Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'> {
  panesSnapshot: PaneSnapshot[];
}

// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном

export class PaneManager implements ISerializable<PaneSnapshot[]> {
  private mainPane: Pane;
  private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
  private panesMap: Map<number, Pane> = new Map<number, Pane>();
  private panesIdIterator = 0;

  constructor(params: PaneManagerParams) {
    this.paneChartInheritedParams = { ...params, isMainPane: false };

    this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });

    this.panesMap.set(this.panesIdIterator++, this.mainPane);
    this.setup(params.panesSnapshot);
  }

  private setup(panesSnapshot: PaneSnapshot[]) {
    panesSnapshot.forEach((paneSnap: PaneSnapshot) => {
      const { isMain, id, indicators, drawings } = paneSnap;

      this.panesMap.get(id)?.destroy();

      if (isMain) {
        this.mainPane = new Pane({ ...this.paneChartInheritedParams, isMainPane: true, id: 0, onDelete: () => {} });

        this.panesMap.set(id, this.mainPane);

        this.mainPane.setDrawingsSnapshot(drawings);
      } else {
        const pane = this.addPane();
        pane.setDrawingsSnapshot(drawings);
      }
      const lastPane = Array.from(this.panesMap.values()).at(-1);

      lastPane?.setIsLast(true);
    });
  }

  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() {
    return this.panesMap;
  }

  public getMainPane: () => Pane = () => {
    return this.mainPane;
  };

  public addPane(dataSource?: DataSource): Pane {
    const id = this.panesIdIterator++;

    const newPane = new Pane({
      ...this.paneChartInheritedParams,
      id,
      dataSource: dataSource ?? null,
      basedOn: dataSource ? undefined : this.mainPane,
      onDelete: () => {
        this.panesIdIterator--;
        this.panesMap.delete(id);

        const prevPane = Array.from(this.panesMap.values()).at(-1);
        prevPane?.setIsLast(true);
      },
    });

    const prevPane = Array.from(this.panesMap.values()).at(-1);

    prevPane?.setIsLast(false);
    newPane.setIsLast(true);

    this.panesMap.set(id, newPane);

    return newPane;
  }

  public getDrawingsManager(): DrawingsManager {
    // todo: temp
    return this.mainPane.getDrawingManager();
  }

  public getSnapshot(): PaneSnapshot[] {
    const res: PaneSnapshot[] = [];
    this.panesMap.forEach((pane) => {
      res.push(pane.getSnapshot());
    });

    return res;
  }
}



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 { 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;
}

export interface DrawingSnapshotItem {
  id: string;
  drawingName: DrawingsNames;
  state: unknown;
}

interface CreateDrawingOptions {
  id?: string;
  state?: unknown;
  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 activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
  private endlessMode$ = new BehaviorSubject(false);
  private recreateScheduled = false;
  private pendingSnapshot: DrawingsManagerSnapshot | null = null;
  private paneId: number;

  constructor({ eventManager, mainSeries$, lwcChart, DOM, container, modalRenderer, paneId }: DrawingsManagerParams) {
    this.DOM = DOM;
    this.eventManager = eventManager;
    this.paneId = paneId;
    this.lwcChart = lwcChart;
    this.container = container;
    this.modalRenderer = modalRenderer;

    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);
  }

  private handlePointerDown = (): void => {
    this.DOM.refreshEntities();
  };

  private handlePointerUp = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
  };

  private handleClick = (): void => {
    this.DOM.refreshEntities();
    this.updateActiveTool();
  };

  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;
    }

    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 = (name: DrawingsNames): void => {
    this.removePendingDrawings(false);

    if (drawingsMap[name].singleInstance) {
      this.removeDrawingsByName(name, false);
    }

    this.activeTool$.next(name);
    this.createDrawing(name);
    this.DOM.refreshEntities();
  };

  private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
    if (!this.mainSeries) {
      throw new Error('[Drawings] main series is not defined');
    }

    const { id, state, shouldUpdateDrawingsList = true } = options;

    const config = drawingsMap[name];
    const drawingId = id ?? crypto.randomUUID();

    let createdDrawing: Drawing | null = null;

    const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) =>
      config.construct({
        chart,
        series,
        eventManager: this.eventManager,
        container: this.container,
        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,
      });

    const entity = this.DOM.setEntity<Drawing>(drawingFactory);
    createdDrawing = entity;

    if (state !== undefined) {
      entity.setState(state);
    }

    if (shouldUpdateDrawingsList) {
      this.drawings$.next([...this.drawings$.value, entity]);
    }

    return entity;
  }

  public getSnapshot(): DrawingsManagerSnapshot {
    return this.drawings$.value
      .filter((drawing) => !drawing.isCreationPending())
      .map((drawing) => ({
        id: drawing.id,
        drawingName: drawing.getDrawingName(),
        state: drawing.getState(),
      }));
  }

  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, shouldUpdateDrawingsList: false }),
      );

      return drawings;
    }, []);

    this.drawings$.next(restoredDrawings);
    this.activeTool$.next('crosshair');
    this.DOM.refreshEntities();
  }

  public setEndlessDrawingMode = (value: boolean): void => {
    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();
  }

  private openSettings = (drawing: Drawing) => {
    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.drawings$.value.forEach((drawing) => drawing.hide());
    this.DOM.refreshEntities();
  }

  public destroy(): void {
    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.activeTool$.complete();
    this.endlessMode$.complete();
  }
}



import { combineLatest, Subscription } from 'rxjs';

import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';

import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';

import Toolbar from '@src/components/Toolbar';
import { IndicatorsIds } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';

import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Candle, ChartSeriesType, ChartTypeOptions, OHLCConfig, TooltipConfig } from '@src/types';
import { ISerializable, MoexChartSnapshot } from '@src/types/snapshot';
import { Timeframes } from '@src/types/timeframes';

import { setPricePrecision } from '@src/utils';

import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';

import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';

// todo: forbid @lib in /src
export interface ChartCollectionPreset {
  undoRedoEnabled?: boolean;
  showMenuButton?: boolean;
  showBottomPanel?: boolean;
  showControlBar?: boolean;
  showFullscreenButton?: boolean;
  showSettingsButton?: boolean;
  showCompareButton?: boolean;
  /**
   * Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
   * При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
   *
   * Полная кастомизация:
   * @example
   * ```typescript
   *    tooltipConfig: {
   *      time: { visible: true, label: 'Дата и время' },
   *      symbol: { visible: true, label: 'Инструмент' },
   *      close: { visible: true, label: 'Курс' },
   *      change: { visible: true, label: 'Изменение' },
   *      volume: { visible: true, label: 'Объем' },
   *      open: { visible: false },
   *      high: { visible: false },
   *      low: { visible: false }
   *    }
   *```
   */
  tooltipConfig?: TooltipConfig; // todo: wrap into ChartCollectionSettings

  size?:
    | {
        width: number;
        height: number;
      }
    | false;
  supportedTimeframes: Timeframes[];
  supportedChartSeriesTypes: ChartSeriesType[];
  getDataSource: DataSourceParams['getData'];
  startRealtime: (
    getSymbols: () => string[],
    getTimeframe: () => Timeframes,
    update: (symbol: string, candle: Candle) => void,
    periodMs?: number,
  ) => () => void;
  theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
  ohlc: OHLCConfig;
  mode?: ThemeMode; // 'light' | 'dark'
  openCompareModal?: () => void;
}

export interface IMoexChart {
  snapshot: MoexChartSnapshot; // todo: combine with snapshot
  chartCollectionPreset: ChartCollectionPreset;

  container: HTMLElement;
  lwcInheritedChartOptions?: ChartTypeOptions;
}

export class MoexChart implements ISerializable<MoexChartSnapshot> {
  private chart!: Chart;
  private resizeObserver?: ResizeObserver;
  private eventManager!: EventManager;
  private rootContainer!: HTMLElement;

  private headerRenderer!: UIRenderer;
  private modalRenderer!: ModalRenderer;
  private toolbarRenderer: UIRenderer | undefined;
  private controlBarRenderer?: UIRenderer;
  private footerRenderer?: UIRenderer;

  private timeScaleHoverController!: TimeScaleHoverController;
  private dataSource!: DataSource;

  private subscriptions = new Subscription();

  private fullscreen!: FullscreenController;

  private chartCollectionPresetSettings!: ChartCollectionPreset;

  constructor(config: IMoexChart) {
    this.setup(config);
  }

  private setup = (config: IMoexChart) => {
    this.chartCollectionPresetSettings = config.chartCollectionPreset;
    setPricePrecision(config.chartCollectionPreset.ohlc.precision);

    this.eventManager = new EventManager({
      initialTimeframe: config.snapshot.charts[0].timeframe,
      initialSeries: config.snapshot.charts[0].chartSeriesType,
      initialSymbol: config.snapshot.charts[0].symbol,
      initialChartOptions: config.lwcInheritedChartOptions,
    });

    // todo: сюда прокидывается не подходящий под сигнатуру интерфейс. Функция не работает
    // if (config.lwcInheritedChartOptions) {
    //   this.setSettings(config.lwcInheritedChartOptions);
    // }

    this.dataSource = new DataSource({
      getData: config.chartCollectionPreset.getDataSource,
      eventManager: this.eventManager,
    });

    this.rootContainer = config.container;

    this.fullscreen = new FullscreenController(this.rootContainer);

    const store = configureThemeStore(config.chartCollectionPreset);

    const {
      chartAreaContainer,
      toolBarContainer,
      headerContainer,
      modalContainer,
      controlBarContainer,
      footerContainer,
      toggleToolbar, // todo: move this function to toolbarModel
    } = ContainerManager.createContainers({
      parentContainer: this.rootContainer,
      showBottomPanel: config.chartCollectionPreset.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
      showMenuButton: config.chartCollectionPreset.showMenuButton,
    });

    this.modalRenderer = new ModalRenderer(modalContainer);

    this.chart = new Chart({
      params: {
        dataSource: this.dataSource,
        eventManager: this.eventManager,
        modalRenderer: this.modalRenderer,
        ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
        tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
        panes: config.snapshot.charts[0].panes,
      },
      lwcChartConfig: {
        container: chartAreaContainer,
        seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
        theme: store.theme,
        mode: store.mode,
        chartOptions: config.lwcInheritedChartOptions, // todo: remove, use only model from eventManager
      },
    });

    this.subscriptions.add(
      combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
        this.chart.updateTheme(theme, mode);

        document.documentElement.dataset.theme = theme;
        document.documentElement.dataset.mode = mode;
      }),
    );

    const realtimeParams = this.chart.getRealtimeApi();

    this.subscriptions.add(
      config.chartCollectionPreset.startRealtime(
        realtimeParams.getSymbols,
        realtimeParams.getTimeframe,
        realtimeParams.update,
      ),
    );

    this.headerRenderer = new ReactRenderer(headerContainer);
    this.toolbarRenderer = new ReactRenderer(toolBarContainer);

    if (config.chartCollectionPreset.showControlBar) {
      this.controlBarRenderer = new ReactRenderer(controlBarContainer);
    }

    if (config.chartCollectionPreset.showBottomPanel) {
      this.footerRenderer = new ReactRenderer(footerContainer);
    }

    this.timeScaleHoverController = new TimeScaleHoverController({
      eventManager: this.eventManager,
      controlBarContainer,
      chartContainer: chartAreaContainer,
    });

    this.renderAttachments(config, toggleToolbar);
  };

  public setSettings(settings: ChartSettingsSource): void {
    this.eventManager.importChartSettings(settings);
  }

  public getSettings(): ChartSettings {
    return this.eventManager.exportChartSettings();
  }

  public getRealtimeApi() {
    return this.chart.getRealtimeApi();
  }

  // todo: описать в доке
  public getCompareManager(): CompareManager {
    return this.chart.getCompareManager();
  }

  // todo: описать в доке
  public setSnapshot(snap: MoexChartSnapshot) {
    const configConstructorLike: IMoexChart = {
      snapshot: snap,
      chartCollectionPreset: this.chartCollectionPresetSettings,
      container: this.rootContainer,
    };
    this.destroy();

    this.setup(configConstructorLike);
  }

  // todo: описать в доке
  public getSnapshot(): MoexChartSnapshot {
    const res = {
      settings: this.getSettings(),
      charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
    };

    return res;
  }

  public setSymbol(symbol: string): void {
    if (!symbol) return;

    this.eventManager.setSymbol(symbol);
  }

  private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
    this.headerRenderer.renderComponent(
      <Header
        timeframes={config.chartCollectionPreset.supportedTimeframes}
        selectedTimeframeObs={this.eventManager.getTimeframeObs()}
        setTimeframe={(value) => {
          this.eventManager.setTimeframe(value);
        }}
        seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
        selectedSeriesObs={this.eventManager.getSelectedSeries()}
        setSelectedSeries={(value) => {
          this.eventManager.setSeriesSelected(value);
        }}
        showSettingsModal={
          config.chartCollectionPreset.showSettingsButton
            ? () =>
                this.modalRenderer.renderComponent(
                  <SettingsModal
                    // todo: deal with onSave
                    changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
                    changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
                    chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
                  />,
                  { title: 'Настройки' },
                )
            : undefined
        }
        addIndicatorToChart={(indicatorType: IndicatorsIds) =>
          this.chart.getIndicatorManager().addIndicator({ indicatorType })
        }
        showMenuButton={!!config.chartCollectionPreset.showMenuButton}
        showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
        fullscreen={this.fullscreen}
        undoRedo={config.chartCollectionPreset.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
        toggleToolbarVisible={toggleToolbar}
        showCompareButton={!!config.chartCollectionPreset.showCompareButton}
        openCompareModal={
          config.chartCollectionPreset.openCompareModal ? config.chartCollectionPreset.openCompareModal : undefined
        }
        isMXT={config.chartCollectionPreset.theme === 'mxt'}
      />,
    );

    if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
      this.toolbarRenderer.renderComponent(
        <Toolbar
          toggleDOM={this.chart.getDom().toggleDOM}
          addDrawing={(name) => {
            // todo: deal with new panes logic
            this.chart.getDrawingsManager().addDrawingForce(name);
          }}
          setEndlessDrawingsMode={this.chart.getDrawingsManager().setEndlessDrawingMode}
          isEndlessDrawingsMode$={this.chart.getDrawingsManager().isEndlessDrawingsMode()}
          activateCrosshair={() => this.chart.getDrawingsManager().activateCrosshair()}
          activeTool$={this.chart.getDrawingsManager().getActiveTool()}
        />,
      );
    }

    if (this.controlBarRenderer && config.chartCollectionPreset.showControlBar) {
      this.controlBarRenderer.renderComponent(
        <ControlBar
          scroll={this.chart.scrollTimeScale}
          zoom={this.chart.zoomTimeScale}
          reset={this.chart.resetZoom}
          visible={this.eventManager.getControlBarVisible()}
        />,
      );
    }

    if (this.footerRenderer && config.chartCollectionPreset.showBottomPanel) {
      this.footerRenderer.renderComponent(
        <Footer
          supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
          setInterval={this.eventManager.setInterval}
          intervalObs={this.eventManager.getInterval()}
        />,
      );
    }
  }

  /**
   * Уничтожение графика и очистка ресурсов
   * @returns void
   */
  destroy(): void {
    this.headerRenderer.destroy();
    this.subscriptions.unsubscribe();
    this.timeScaleHoverController.destroy();

    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
      this.resizeObserver = undefined;
    }

    if (this.controlBarRenderer) {
      this.controlBarRenderer.destroy();
    }

    if (this.footerRenderer) {
      this.footerRenderer.destroy();
    }

    if (this.chart) {
      this.chart.destroy();
    }

    if (this.eventManager) {
      this.eventManager.destroy();
    }

    this.dataSource.destroy();

    ContainerManager.clearContainers(this.rootContainer);
  }
}


import { Button, Divider } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';
import { Observable } from 'rxjs';

import { IndicatorsSelect } from '@components/IndicatorsSelect';

import { IndicatorsIds } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
import { UndoRedo } from '@src/core/UndoRedo';
import { ChartSeriesType } from '@src/types';

import { Timeframes } from '@src/types/timeframes';

import { useObservable } from '@src/utils';

import { Dropdown } from '../Dropdown';
import {
  BarIcon,
  BurgerMenuIcon,
  CandleStickIcon,
  FullscreenIcon,
  LineIcon,
  PlusCircleIcon,
  RedoIcon,
  UndoIcon,
} from '../Icon';

import { SeriesMenu, TimeframesMenu } from '../Menu';

import styles from './index.module.scss';

interface HeaderProps {
  timeframes: Timeframes[];
  selectedTimeframeObs: Observable<Timeframes>;
  setTimeframe: (value: Timeframes) => void;
  seriesTypes: ChartSeriesType[];
  setSelectedSeries: (next: ChartSeriesType) => void;
  selectedSeriesObs: Observable<ChartSeriesType>;
  showSettingsModal: (() => void) | undefined;
  addIndicatorToChart: (id: IndicatorsIds) => void;
  toggleToolbarVisible: () => boolean;
  showCompareButton: boolean;
  undoRedo: UndoRedo | undefined;
  showMenuButton?: boolean;
  showFullscreenButton?: boolean;
  fullscreen: FullscreenController;
  openCompareModal?: () => void;
  isMXT: boolean;
}

export function Header({
  setTimeframe,
  selectedTimeframeObs,
  setSelectedSeries,
  selectedSeriesObs,
  timeframes,
  seriesTypes,
  showSettingsModal,
  addIndicatorToChart,
  toggleToolbarVisible,
  showCompareButton,
  fullscreen,
  undoRedo,
  showFullscreenButton,
  showMenuButton,
  openCompareModal,
  isMXT,
}: HeaderProps) {
  const [isToolbarOpen, setIsToolbarOpen] = useState(false);
  const [isFullscreen, setIsFullscreen] = useState(fullscreen.isFullscreen);

  useEffect(() => {
    const unsubscribe = fullscreen.onChange(() => setIsFullscreen(fullscreen.isFullscreen));

    return unsubscribe;
  }, [fullscreen]);

  const selectedTimeframe = useObservable(selectedTimeframeObs);
  const selectedSeries = useObservable(selectedSeriesObs);

  const seriesDropdownValue =
    selectedSeries === 'Line' ? <LineIcon /> : selectedSeries === 'Bar' ? <BarIcon /> : <CandleStickIcon />;

  const handleOpenToolbar = () => setIsToolbarOpen(() => toggleToolbarVisible());

  return (
    <header className={styles.header}>
      <div className={styles.group}>
        {showMenuButton && (
          <Button
            size="sm"
            className={`${styles.button} ${isToolbarOpen ? styles.pressed : ''}`}
            onClick={handleOpenToolbar}
            label={<BurgerMenuIcon />}
          />
        )}
        {showCompareButton && (
          <Button
            size="sm"
            className={styles.button}
            onClick={openCompareModal}
            label={<PlusCircleIcon />}
          />
        )}
      </div>

      {(showMenuButton || showCompareButton) && (
        <Divider
          direction="vertical"
          pt={{ divider: { className: styles.divider } }}
        />
      )}

      <Dropdown
        menuClassName={styles.menu}
        selectedValue={selectedTimeframe}
      >
        <TimeframesMenu
          onClick={setTimeframe}
          {...{ selectedTimeframe, timeframes }}
        />
      </Dropdown>

      <Divider
        direction="vertical"
        pt={{ divider: { className: styles.divider } }}
      />

      <div className={styles.group}>
        <IndicatorsSelect
          addIndicatorToChart={addIndicatorToChart}
          isMXT={isMXT}
        />
        {showSettingsModal && (
          <Button
            size="sm"
            className={`${styles.button}`}
            onClick={() => showSettingsModal()}
            label="Настройки"
          />
        )}
        <Dropdown
          menuClassName={styles.menu}
          selectedValue={seriesDropdownValue}
        >
          <SeriesMenu
            selectedType={selectedSeries}
            seriesTypes={seriesTypes}
            onClick={setSelectedSeries}
          />
        </Dropdown>
      </div>

      {(!!undoRedo || showFullscreenButton) && (
        <Divider
          direction="vertical"
          pt={{ divider: { className: styles.divider } }}
        />
      )}

      {!!undoRedo && (
        <div className={styles.group}>
          <Button
            size="sm"
            className={styles.button}
            onClick={() => undoRedo?.undo()}
            disabled={!undoRedo?.canUndo()}
            label={<UndoIcon />}
          />
          <Button
            size="sm"
            className={styles.button}
            onClick={() => undoRedo?.redo()}
            disabled={!undoRedo?.canRedo()}
            label={<RedoIcon />}
          />
        </div>
      )}

      {showFullscreenButton && (
        <Button
          size="sm"
          className={`${styles.button} ${isFullscreen ? styles.pressed : ''}`}
          onClick={() => fullscreen.toggle()}
          label={<FullscreenIcon />}
        />
      )}
    </header>
  );
}


import { SeriesType, Time } from 'lightweight-charts';

import { DateFormat } from '@src/utils/formatter';

import { TimeFormat } from './timeScale';
/**
 * Типы графиков, поддерживаемые библиотекой
 */
export type ChartSeriesType = SeriesType;

/**
 * Универсальный формат данных для всех типов графиков
 */
export interface ChartDataPoint {
  time?: Time;
  value?: number;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  volume?: number;
}

interface CandlestickChartOptions {
  upColor?: string;
  downColor?: string;
  wickUpColor?: string;
  wickDownColor?: string;
  borderVisible?: boolean;
}

interface LineChartOptions {
  lineStyle?: 'solid' | 'dotted' | 'dashed';
}

interface AreaChartOptions {
  topColor?: string;
  bottomColor?: string;
}

interface BaselineChartOptions {
  baseValue?: number;
  topLineColor?: string;
  bottomLineColor?: string;
  topFillColor1?: string;
  topFillColor2?: string;
  bottomFillColor1?: string;
  bottomFillColor2?: string;
}

interface TimescaleOptions {
  timeVisible?: boolean;
  secondsVisible?: boolean;
  timeFormat?: TimeFormat;
  dateFormat?: DateFormat;
}

interface CrosshairOptions {
  crosshairLineXColor?: string;
  crosshairLineYColor?: string;
  crosshairLabelXBgColor?: string;
  crosshairLabelYBgColor?: string;
}

interface HistogramOptions {
  scaleMargins?: {
    top: number;
    bottom: number;
  };
}

/**
 * @deprecated
 * Опции для разных типов графиков
 */
export interface ChartTypeOptions // todo: неверный интерфейс
  extends HistogramOptions,
    CrosshairOptions,
    TimescaleOptions,
    BaselineChartOptions,
    AreaChartOptions,
    LineChartOptions,
    CandlestickChartOptions {
  // Общие опции
  color?: string;
  lineWidth?: number;
}

export interface LineCandle {
  time: number;
  value: number;
  volume?: number;
}

export interface Candle {
  time: number;
  open: number;
  high: number;
  low: number;
  close: number;
  volume?: number; // todo: volume & future metaData to customFields
}

export type Destroyable = {
  destroy(): void;
};

export enum Direction {
  Top = 'top',
  Bottom = 'bottom',
  Left = 'left',
  Right = 'right',
}

// Настройки графика, которые могут изменяться в рантайме
export interface ChartOptionsModel {
  dateFormat: DateFormat;
  timeFormat: TimeFormat;
  showTime: boolean;
}