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


import {
  IChartApi,
  IPaneApi,
  IPriceScaleApi,
  PriceScaleMode,
  Time,
} from 'lightweight-charts';

import { Direction } from '@src/types';

import type {
  PriceScaleSide,
  PriceScaleSnapshot,
} from '@src/types/snapshot';

interface PriceScaleParams {
  paneId: number;
  side: PriceScaleSide;
  chart: IChartApi;
  pane: IPaneApi<Time>;
  initialMode?: PriceScaleMode;
  hasVisibleSeriesData: () => boolean;
}

const PRICE_SCALE_MINIMUM_WIDTH = 72;

export class PriceScale {
  public readonly paneId: number;
  public readonly side: PriceScaleSide;

  private readonly chart: IChartApi;
  private readonly pane: IPaneApi<Time>;
  private readonly hasVisibleSeriesDataCallback: () => boolean;

  private mode: PriceScaleMode;
  private autoScaleEnabled = true;
  private visible: boolean;
  private initialized = false;

  constructor({
    paneId,
    side,
    chart,
    pane,
    initialMode = PriceScaleMode.Normal,
    hasVisibleSeriesData,
  }: PriceScaleParams) {
    this.paneId = paneId;
    this.side = side;
    this.chart = chart;
    this.pane = pane;
    this.mode = initialMode;
    this.visible = side === Direction.Right;
    this.hasVisibleSeriesDataCallback = hasVisibleSeriesData;
  }

  public initialize(): void {
    if (this.initialized) {
      return;
    }

    const priceScale = this.getPriceScaleApi();
    const options = priceScale.options();

    this.autoScaleEnabled = options.autoScale ?? true;
    this.initialized = true;

    priceScale.applyOptions({
      mode: this.mode,
      autoScale: this.autoScaleEnabled,
      visible: this.visible,
      borderVisible: false,
      minimumWidth: Math.max(
        options.minimumWidth ?? 0,
        PRICE_SCALE_MINIMUM_WIDTH,
      ),
    });
  }

  public getMode(): PriceScaleMode {
    return this.mode;
  }

  public setMode(mode: PriceScaleMode): void {
    if (this.mode === mode) {
      return;
    }

    this.mode = mode;

    if (!this.initialized) {
      return;
    }

    this.getPriceScaleApi().applyOptions({
      mode,
      autoScale: this.autoScaleEnabled,
    });
  }

  public toggleLogarithmic(): void {
    const nextMode =
      this.mode === PriceScaleMode.Logarithmic
        ? PriceScaleMode.Normal
        : PriceScaleMode.Logarithmic;

    this.setMode(nextMode);
  }

  public isAutoScaleEnabled(): boolean {
    return this.autoScaleEnabled;
  }

  public toggleAutoScale(): void {
    this.autoScaleEnabled = !this.autoScaleEnabled;

    if (this.initialized) {
      this.getPriceScaleApi().setAutoScale(
        this.autoScaleEnabled,
      );
    }
  }

  public enableAutoScale(): void {
    if (this.autoScaleEnabled) {
      return;
    }

    this.autoScaleEnabled = true;

    if (this.initialized) {
      this.getPriceScaleApi().setAutoScale(true);
    }
  }

  public isVisible(): boolean {
    return this.visible;
  }

  public setVisible(visible: boolean): void {
    const visibilityChanged = this.visible !== visible;

    this.visible = visible;

    if (!this.initialized || !visibilityChanged) {
      return;
    }

    this.getPriceScaleApi().applyOptions({
      visible,
      borderVisible: false,
    });
  }

  public getWidth(): number {
    if (
      !this.initialized ||
      !this.visible ||
      this.pane.paneIndex() < 0
    ) {
      return 0;
    }

    try {
      return this.getPriceScaleApi().width();
    } catch {
      return 0;
    }
  }

  public hasVisibleSeriesData(): boolean {
    return this.hasVisibleSeriesDataCallback();
  }

  public getSnapshot(): PriceScaleSnapshot {
    return {
      side: this.side,
      mode: this.mode,
    };
  }

  private getPriceScaleApi(): IPriceScaleApi {
    return this.chart.priceScale(
      this.side,
      this.pane.paneIndex(),
    );
  }
}





import { PriceScaleMode } from 'lightweight-charts';

import { PriceScaleControls as PriceScaleControlsView } from '@components/PriceScaleControls';
import { ReactRenderer } from '@core/ReactRenderer';
import { Direction } from '@src/types';

import { PriceScale } from './PriceScale';

interface PriceScaleControlsParams {
  chartContainer: HTMLElement;
  leftPriceScale: PriceScale;
  rightPriceScale: PriceScale;
  onPriceScaleChange: () => void;
}

export class PriceScaleControls {
  private readonly chartContainer: HTMLElement;
  private readonly leftPriceScale: PriceScale;
  private readonly rightPriceScale: PriceScale;
  private readonly onPriceScaleChange: () => void;
  private readonly container: HTMLElement;
  private readonly renderer: ReactRenderer;

  private paneElement: HTMLElement | null = null;
  private hoveredPriceScale: PriceScale | null = null;

  constructor({
    chartContainer,
    leftPriceScale,
    rightPriceScale,
    onPriceScaleChange,
  }: PriceScaleControlsParams) {
    this.chartContainer = chartContainer;
    this.leftPriceScale = leftPriceScale;
    this.rightPriceScale = rightPriceScale;
    this.onPriceScaleChange = onPriceScaleChange;

    this.container = document.createElement('div');
    this.container.className =
      'moex-price-scale-controls';
    this.container.style.position = 'absolute';
    this.container.style.zIndex = '10';
    this.container.style.pointerEvents = 'none';

    this.renderer = new ReactRenderer(this.container);
  }

  public mount(paneElement: HTMLElement): void {
    if (
      this.paneElement === paneElement &&
      this.container.parentElement ===
        this.chartContainer
    ) {
      this.refresh();
      return;
    }

    this.unmount();

    this.paneElement = paneElement;

    this.chartContainer.appendChild(this.container);
    this.chartContainer.addEventListener(
      'pointermove',
      this.handlePointerMove,
    );
    this.chartContainer.addEventListener(
      'pointerleave',
      this.handlePointerLeave,
    );

    this.refresh();
  }

  public refresh(): void {
    if (!this.hoveredPriceScale) {
      this.hide();
      return;
    }

    this.render(this.hoveredPriceScale);
  }

  public destroy(): void {
    this.unmount();
    this.renderer.destroy();
    this.container.remove();
  }

  private handlePointerMove = (
    event: PointerEvent,
  ): void => {
    if (!this.paneElement) {
      return;
    }

    const chartRect =
      this.chartContainer.getBoundingClientRect();
    const paneRect =
      this.paneElement.getBoundingClientRect();

    const isInsidePane =
      event.clientY >= paneRect.top &&
      event.clientY < paneRect.bottom;

    const nextPriceScale = isInsidePane
      ? this.getHoveredPriceScale(
          event.clientX - chartRect.left,
          chartRect.width,
        )
      : null;

    if (
      this.hoveredPriceScale === nextPriceScale
    ) {
      if (nextPriceScale) {
        this.updatePosition(nextPriceScale);
      }

      return;
    }

    this.hoveredPriceScale = nextPriceScale;
    this.refresh();
  };

  private handlePointerLeave = (): void => {
    this.hoveredPriceScale = null;
    this.refresh();
  };

  private render(priceScale: PriceScale): void {
    const width = this.getAvailableWidth(priceScale);

    if (width === 0) {
      this.hide();
      return;
    }

    this.updatePosition(priceScale, width);

    this.renderer.renderComponent(
      <PriceScaleControlsView
        isAutoScale={
          priceScale.isAutoScaleEnabled()
        }
        isLogarithmic={
          priceScale.getMode() ===
          PriceScaleMode.Logarithmic
        }
        onToggleAutoScale={() => {
          priceScale.toggleAutoScale();
          this.refresh();
          this.onPriceScaleChange();
        }}
        onToggleLogarithmic={() => {
          priceScale.toggleLogarithmic();
          this.refresh();
          this.onPriceScaleChange();
        }}
      />,
    );

    this.container.classList.add(
      'moex-price-scale-controls_visible',
    );
  }

  private hide(): void {
    this.container.classList.remove(
      'moex-price-scale-controls_visible',
    );
  }

  private getHoveredPriceScale(
    pointerX: number,
    chartWidth: number,
  ): PriceScale | null {
    const leftWidth = this.getAvailableWidth(
      this.leftPriceScale,
    );

    if (
      leftWidth > 0 &&
      pointerX >= 0 &&
      pointerX <= leftWidth
    ) {
      return this.leftPriceScale;
    }

    const rightWidth = this.getAvailableWidth(
      this.rightPriceScale,
    );

    if (
      rightWidth > 0 &&
      pointerX >= chartWidth - rightWidth &&
      pointerX <= chartWidth
    ) {
      return this.rightPriceScale;
    }

    return null;
  }

  private getAvailableWidth(
    priceScale: PriceScale,
  ): number {
    if (
      !priceScale.isVisible() ||
      !priceScale.hasVisibleSeriesData()
    ) {
      return 0;
    }

    return priceScale.getWidth();
  }

  private updatePosition(
    priceScale: PriceScale,
    width = priceScale.getWidth(),
  ): void {
    if (!this.paneElement || width === 0) {
      return;
    }

    const chartRect =
      this.chartContainer.getBoundingClientRect();
    const paneRect =
      this.paneElement.getBoundingClientRect();

    const bottomOffset = Math.max(
      0,
      chartRect.bottom - paneRect.bottom,
    );

    this.container.style.width = `${width}px`;
    this.container.style.bottom =
      `${bottomOffset}px`;

    if (priceScale.side === Direction.Left) {
      this.container.style.left = '0';
      this.container.style.right = '';
      return;
    }

    this.container.style.left = '';
    this.container.style.right = '0';
  }

  private unmount(): void {
    this.chartContainer.removeEventListener(
      'pointermove',
      this.handlePointerMove,
    );
    this.chartContainer.removeEventListener(
      'pointerleave',
      this.handlePointerLeave,
    );

    this.paneElement = null;
    this.hoveredPriceScale = null;

    this.hide();
  }
}