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


import { SyntheticEvent } from 'react';
import classNames from 'classnames';
import { Button } from 'exchange-elements/v2';

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

interface PriceScaleControlsProps {
  isAutoScale: boolean;
  isLogarithmic: boolean;
  onAutoScale: () => void;
  onToggleLogarithmic: () => void;
  onRefresh: () => void;
}

export function PriceScaleControls({
  isAutoScale,
  isLogarithmic,
  onAutoScale,
  onToggleLogarithmic,
  onRefresh,
}: PriceScaleControlsProps) {
  const stopPropagation = (event: SyntheticEvent) => {
    event.stopPropagation();
  };

  return (
    <section
      className={classNames(styles.root, 'moex-price-scale-controls-content')}
      onMouseEnter={onRefresh}
      onFocus={onRefresh}
      onPointerDown={stopPropagation}
      onMouseDown={stopPropagation}
      onClick={stopPropagation}
    >
      <Button
        size="sm"
        className={classNames(styles.button, {
          [styles.button_active]: isAutoScale,
        })}
        onClick={onAutoScale}
        label="A"
      />

      <Button
        size="sm"
        className={classNames(styles.button, {
          [styles.button_active]: isLogarithmic,
        })}
        onClick={onToggleLogarithmic}
        label="L"
      />
    </section>
  );
}



.root {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-0250);
  pointer-events: none;
}

.button {
  min-width: 24px;
  height: 24px;
  padding: 0 var(--space-0250);
  pointer-events: auto;
}

.button_active {
  font-weight: 600;
}



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

import { ReactRenderer } from '@core/ReactRenderer';
import { PriceScaleControls } from '@src/components/PriceScaleControls';
import { PriceScaleTarget } from '@src/core/PriceScale';
import { Direction } from '@src/types';

interface PriceScaleControlsControllerParams {
  paneId: number;
  getPaneElement: () => HTMLElement | null;
  getPriceScale: (priceScaleId: Direction) => IPriceScaleApi;
  getPriceScaleTarget: (priceScaleId: Direction) => PriceScaleTarget;
  getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
  togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;
}

export class PriceScaleControlsController {
  private readonly paneId: number;
  private readonly getPaneElement: () => HTMLElement | null;
  private readonly getPriceScale: (priceScaleId: Direction) => IPriceScaleApi;
  private readonly getPriceScaleTarget: (priceScaleId: Direction) => PriceScaleTarget;
  private readonly getSavedPriceScaleMode: (target: PriceScaleTarget) => PriceScaleMode;
  private readonly togglePriceScaleMode: (target: PriceScaleTarget, mode: PriceScaleMode) => void;

  private readonly container: HTMLElement;
  private readonly renderer: ReactRenderer;

  private paneElement: HTMLElement | null = null;
  private hoveredPriceScaleId: Direction | null = null;

  constructor({
    paneId,
    getPaneElement,
    getPriceScale,
    getPriceScaleTarget,
    getSavedPriceScaleMode,
    togglePriceScaleMode,
  }: PriceScaleControlsControllerParams) {
    this.paneId = paneId;
    this.getPaneElement = getPaneElement;
    this.getPriceScale = getPriceScale;
    this.getPriceScaleTarget = getPriceScaleTarget;
    this.getSavedPriceScaleMode = getSavedPriceScaleMode;
    this.togglePriceScaleMode = togglePriceScaleMode;

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

  public mount(): void {
    const nextPaneElement = this.getPaneElement();

    if (!nextPaneElement) {
      return;
    }

    if (this.paneElement === nextPaneElement && this.container.parentElement === nextPaneElement) {
      return;
    }

    this.unmountEvents();

    this.paneElement = nextPaneElement;
    this.paneElement.style.position = 'relative';
    this.paneElement.appendChild(this.container);

    this.paneElement.addEventListener('pointermove', this.handlePointerMove);
    this.paneElement.addEventListener('pointerleave', this.handlePointerLeave);

    this.update();
  }

  public update(): void {
    if (!this.hoveredPriceScaleId) {
      this.hide();
      return;
    }

    this.render(this.hoveredPriceScaleId);
  }

  public resetAutoScale(): void {
    this.setAutoScale(Direction.Left);
    this.setAutoScale(Direction.Right);
    this.update();
  }

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

  private createContainer(): HTMLElement {
    const container = document.createElement('div');

    container.className = 'moex-price-scale-controls';
    container.style.position = 'absolute';
    container.style.bottom = 'var(--space-0500)';
    container.style.zIndex = '10';
    container.style.pointerEvents = 'none';

    return container;
  }

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

    const paneRect = this.paneElement.getBoundingClientRect();
    const pointerX = event.clientX - paneRect.left;
    const nextPriceScaleId = this.getHoveredPriceScaleId(pointerX, paneRect.width);

    if (this.hoveredPriceScaleId === nextPriceScaleId) {
      if (nextPriceScaleId) {
        this.updatePosition(nextPriceScaleId);
      }

      return;
    }

    this.hoveredPriceScaleId = nextPriceScaleId;
    this.update();
  };

  private handlePointerLeave = (): void => {
    this.hoveredPriceScaleId = null;
    this.update();
  };

  private render(priceScaleId: Direction): void {
    if (!this.isAvailable(priceScaleId)) {
      this.hide();
      return;
    }

    this.updatePosition(priceScaleId);

    const target = this.getPriceScaleTarget(priceScaleId);
    const savedMode = this.getSavedPriceScaleMode(target);

    this.renderer.renderComponent(
      <PriceScaleControls
        isAutoScale={this.isAutoScale(priceScaleId)}
        isLogarithmic={savedMode === PriceScaleMode.Logarithmic}
        onAutoScale={() => {
          this.setAutoScale(priceScaleId);
          this.update();
        }}
        onToggleLogarithmic={() => {
          this.togglePriceScaleMode(target, PriceScaleMode.Logarithmic);
          this.update();
        }}
        onRefresh={() => {
          this.update();
        }}
      />,
    );

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

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

  private getHoveredPriceScaleId(pointerX: number, paneWidth: number): Direction | null {
    const leftWidth = this.getWidth(Direction.Left);
    const rightWidth = this.getWidth(Direction.Right);

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

    if (this.isAvailable(Direction.Right) && pointerX >= paneWidth - rightWidth && pointerX <= paneWidth) {
      return Direction.Right;
    }

    return null;
  }

  private updatePosition(priceScaleId: Direction): void {
    const width = this.getWidth(priceScaleId);

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

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

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

  private setAutoScale(priceScaleId: Direction): void {
    try {
      this.getPriceScale(priceScaleId).setAutoScale(true);
    } catch {
      // ignore inactive price scale
    }
  }

  private isAutoScale(priceScaleId: Direction): boolean {
    try {
      return this.getPriceScale(priceScaleId).options().autoScale ?? true;
    } catch {
      return true;
    }
  }

  private isAvailable(priceScaleId: Direction): boolean {
    try {
      const priceScale = this.getPriceScale(priceScaleId);
      const options = priceScale.options();

      if (options.visible === false) {
        return false;
      }

      return this.getWidth(priceScaleId) > 0;
    } catch {
      return false;
    }
  }

  private getWidth(priceScaleId: Direction): number {
    try {
      return this.getPriceScale(priceScaleId).width();
    } catch {
      return 0;
    }
  }

  private unmountEvents(): void {
    if (!this.paneElement) {
      return;
    }

    this.paneElement.removeEventListener('pointermove', this.handlePointerMove);
    this.paneElement.removeEventListener('pointerleave', this.handlePointerLeave);
    this.paneElement = null;
  }
}


.moex-price-scale-controls {
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms ease;
}

.moex-price-scale-controls_visible {
  opacity: 1;
}

.moex-price-scale-controls_visible .moex-price-scale-controls-content {
  pointer-events: auto;
}