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


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 {
  leftPriceScale: PriceScale;
  rightPriceScale: PriceScale;
  onPriceScaleChange: () => void;
}

interface PriceScaleElements {
  left: HTMLTableCellElement;
  right: HTMLTableCellElement;
}

export class PriceScaleControls {
  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 leftPriceScaleElement: HTMLTableCellElement | null = null;
  private rightPriceScaleElement: HTMLTableCellElement | null = null;
  private hoveredPriceScale: PriceScale | null = null;

  private isPriceScaleInteractionActive = false;
  private refreshFrameId: number | null = null;
  private notifyPriceScaleChangeAfterRefresh = false;

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

    this.container = document.createElement('div');
    this.container.className = 'moex-price-scale-controls';
    this.renderer = new ReactRenderer(this.container);
  }

  public mount(paneElement: HTMLElement): void {
    const priceScaleElements = this.getPriceScaleElements(paneElement);

    if (!priceScaleElements) {
      this.unmount();
      return;
    }

    if (this.paneElement !== paneElement) {
      this.unmount();

      this.paneElement = paneElement;
      this.paneElement.addEventListener('pointerdown', this.handlePointerDown);
      this.paneElement.addEventListener('pointermove', this.handlePointerMove);
      this.paneElement.addEventListener('pointerleave', this.handlePointerLeave);
      this.paneElement.addEventListener('dblclick', this.handleDoubleClick);

      window.addEventListener('pointerup', this.handlePointerUp);
      window.addEventListener('pointercancel', this.handlePointerUp);
    }

    this.leftPriceScaleElement = priceScaleElements.left;
    this.rightPriceScaleElement = priceScaleElements.right;

    this.leftPriceScaleElement.style.position = 'relative';
    this.rightPriceScaleElement.style.position = 'relative';

    this.refresh();
  }

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

    this.render(this.hoveredPriceScale);
  }

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

  private handlePointerDown = (event: PointerEvent): void => {
    this.isPriceScaleInteractionActive = this.getHoveredPriceScale(event.clientX) !== null;
  };

  private handlePointerMove = (event: PointerEvent): void => {
    const nextPriceScale = this.getHoveredPriceScale(event.clientX);

    if (this.hoveredPriceScale !== nextPriceScale) {
      this.hoveredPriceScale = nextPriceScale;
      this.refresh();
    }

    if (this.isPriceScaleInteractionActive && nextPriceScale) {
      this.scheduleRefresh();
    }
  };

  private handlePointerUp = (): void => {
    if (!this.isPriceScaleInteractionActive) {
      return;
    }

    this.isPriceScaleInteractionActive = false;
    this.scheduleRefresh(true);
  };

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

  private handleDoubleClick = (event: MouseEvent): void => {
    const priceScale = this.getHoveredPriceScale(event.clientX);

    if (!priceScale) {
      return;
    }

    this.hoveredPriceScale = priceScale;
    this.scheduleRefresh(true);
  };

  private scheduleRefresh(notifyPriceScaleChange = false): void {
    if (notifyPriceScaleChange) {
      this.notifyPriceScaleChangeAfterRefresh = true;
    }

    if (this.refreshFrameId !== null) {
      return;
    }

    this.refreshFrameId = requestAnimationFrame(() => {
      this.refreshFrameId = null;
      this.refresh();

      if (!this.notifyPriceScaleChangeAfterRefresh) {
        return;
      }

      this.notifyPriceScaleChangeAfterRefresh = false;
      this.onPriceScaleChange();
    });
  }

  private render(priceScale: PriceScale): void {
    const priceScaleElement = this.getPriceScaleElement(priceScale);

    if (!priceScaleElement || !this.canShowControls(priceScale, priceScaleElement)) {
      this.hide();
      return;
    }

    if (this.container.parentElement !== priceScaleElement) {
      priceScaleElement.appendChild(this.container);
    }

    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(clientX: number): PriceScale | null {
    if (this.isPointerInsidePriceScale(this.leftPriceScale, clientX)) {
      return this.leftPriceScale;
    }

    if (this.isPointerInsidePriceScale(this.rightPriceScale, clientX)) {
      return this.rightPriceScale;
    }

    return null;
  }

  private isPointerInsidePriceScale(priceScale: PriceScale, clientX: number): boolean {
    const priceScaleElement = this.getPriceScaleElement(priceScale);

    if (!priceScaleElement || !this.canShowControls(priceScale, priceScaleElement)) {
      return false;
    }

    const priceScaleRect = priceScaleElement.getBoundingClientRect();

    return clientX >= priceScaleRect.left && clientX <= priceScaleRect.right;
  }

  private canShowControls(priceScale: PriceScale, priceScaleElement: HTMLTableCellElement): boolean {
    return priceScale.isVisible() && priceScale.hasVisibleSeriesData() && priceScaleElement.clientWidth > 0;
  }

  private getPriceScaleElement(priceScale: PriceScale): HTMLTableCellElement | null {
    return priceScale.side === Direction.Left ? this.leftPriceScaleElement : this.rightPriceScaleElement;
  }

  private getPriceScaleElements(paneElement: HTMLElement): PriceScaleElements | null {
    if (paneElement.children.length < 3) {
      return null;
    }

    const leftPriceScaleElement = paneElement.children.item(0);
    const rightPriceScaleElement = paneElement.children.item(paneElement.children.length - 1);

    if (
      !(leftPriceScaleElement instanceof HTMLTableCellElement) ||
      !(rightPriceScaleElement instanceof HTMLTableCellElement)
    ) {
      return null;
    }

    return {
      left: leftPriceScaleElement,
      right: rightPriceScaleElement,
    };
  }

  private unmount(): void {
    if (this.paneElement) {
      this.paneElement.removeEventListener('pointerdown', this.handlePointerDown);
      this.paneElement.removeEventListener('pointermove', this.handlePointerMove);
      this.paneElement.removeEventListener('pointerleave', this.handlePointerLeave);
      this.paneElement.removeEventListener('dblclick', this.handleDoubleClick);

      window.removeEventListener('pointerup', this.handlePointerUp);
      window.removeEventListener('pointercancel', this.handlePointerUp);
    }

    if (this.refreshFrameId !== null) {
      cancelAnimationFrame(this.refreshFrameId);
      this.refreshFrameId = null;
    }

    this.paneElement = null;
    this.leftPriceScaleElement = null;
    this.rightPriceScaleElement = null;
    this.hoveredPriceScale = null;
    this.isPriceScaleInteractionActive = false;
    this.notifyPriceScaleChangeAfterRefresh = false;

    this.container.remove();
    this.hide();
  }
}