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


import {
  IChartApi,
  ISeriesApi,
  ISeriesPrimitive,
  LogicalRange,
  MismatchDirection,
  PriceScaleMode,
  SeriesDataItemTypeMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

import { distinctUntilChanged, Observable, Subscription } from 'rxjs';

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

import { PriceLabelPrimitive } from './PriceLabelPrimitive';

interface PrimitiveHost {
  attachPrimitive(primitive: ISeriesPrimitive<Time>): void;
  detachPrimitive(primitive: ISeriesPrimitive<Time>): void;
}

interface PriceLabelHost {
  getLwcSeries(): PrimitiveHost;
}

interface SeriesPriceLabelsParams<TSeries extends SeriesType, THost extends PriceLabelHost> {
  chart: IChartApi;
  series: ISeriesApi<TSeries>;
  mainSerie$: Observable<THost | null>;
  isMainSeries: boolean;
  isCompareSeries: boolean;
  isVolumeSeries: boolean;
}

const DEFAULT_LABEL_COLOR = '#000000';

function getDataPrice(data: unknown): number | null {
  if (!data || typeof data !== 'object') {
    return null;
  }

  if ('close' in data && typeof data.close === 'number') {
    return data.close;
  }

  if ('value' in data && typeof data.value === 'number') {
    return data.value;
  }

  return null;
}

function getDataColor(data: unknown, options: Record<string, unknown>): string {
  if (data && typeof data === 'object' && 'color' in data && typeof data.color === 'string') {
    return removeAlphaFromHex(data.color);
  }

  if (
    data &&
    typeof data === 'object' &&
    'open' in data &&
    'close' in data &&
    typeof data.open === 'number' &&
    typeof data.close === 'number'
  ) {
    const color = data.close >= data.open ? options.upColor : options.downColor;

    if (typeof color === 'string') {
      return removeAlphaFromHex(color);
    }
  }

  if (typeof options.color === 'string') {
    return removeAlphaFromHex(options.color);
  }

  return DEFAULT_LABEL_COLOR;
}

export class SeriesPriceLabels<
  TSeries extends SeriesType,
  THost extends PriceLabelHost = PriceLabelHost,
> {
  private readonly chart: IChartApi;
  private readonly series: ISeriesApi<TSeries>;
  private readonly mainSerie$: Observable<THost | null>;

  private readonly isMainSeries: boolean;
  private readonly isCompareSeries: boolean;
  private readonly isVolumeSeries: boolean;

  private readonly subscriptions = new Subscription();

  private historicalPriceLabel: PriceLabelPrimitive | null = null;
  private currentPriceLabel: PriceLabelPrimitive | null = null;
  private volumePriceLabel: PriceLabelPrimitive | null = null;
  private volumePriceLabelHost: THost | null = null;

  private visibleLogicalRange: LogicalRange | null = null;
  private isHistoricalMode = false;
  private defaultLastValueVisible: boolean;
  private updateFrame: number | null = null;
  private initialized = false;

  constructor({
    chart,
    series,
    mainSerie$,
    isMainSeries,
    isCompareSeries,
    isVolumeSeries,
  }: SeriesPriceLabelsParams<TSeries, THost>) {
    this.chart = chart;
    this.series = series;
    this.mainSerie$ = mainSerie$;
    this.isMainSeries = isMainSeries;
    this.isCompareSeries = isCompareSeries;
    this.isVolumeSeries = isVolumeSeries;

    this.defaultLastValueVisible = this.series.options().lastValueVisible ?? true;

    this.initialize();
  }

  public onOptionsApplied(options: SeriesPartialOptionsMap[TSeries]): void {
    const commonOptions = options as {
      lastValueVisible?: boolean;
    };

    if (typeof commonOptions.lastValueVisible === 'boolean') {
      this.defaultLastValueVisible = commonOptions.lastValueVisible;
    }

    if (this.isVolumeSeries || this.isHistoricalMode) {
      this.setBuiltInLastValueVisible(false);
    }

    this.scheduleUpdate();
  }

  public scheduleUpdate(): void {
    if (!this.initialized || this.updateFrame !== null) {
      return;
    }

    if (typeof requestAnimationFrame !== 'function') {
      this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();
      this.refresh();

      return;
    }

    this.updateFrame = requestAnimationFrame(() => {
      this.updateFrame = null;
      this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();

      this.refresh();
    });
  }

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

    this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);

    if (this.updateFrame !== null && typeof cancelAnimationFrame === 'function') {
      cancelAnimationFrame(this.updateFrame);
      this.updateFrame = null;
    }

    if (this.historicalPriceLabel) {
      this.series.detachPrimitive(this.historicalPriceLabel);
      this.historicalPriceLabel = null;
    }

    if (this.currentPriceLabel) {
      this.series.detachPrimitive(this.currentPriceLabel);
      this.currentPriceLabel = null;
    }

    this.detachVolumePriceLabel();

    this.volumePriceLabel = null;
    this.subscriptions.unsubscribe();
    this.initialized = false;
  }

  private initialize(): void {
    const supportsHistoricalPriceLabels = this.isMainSeries || this.isCompareSeries;

    if (!supportsHistoricalPriceLabels && !this.isVolumeSeries) {
      return;
    }

    this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();

    this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);

    this.initialized = true;

    if (supportsHistoricalPriceLabels) {
      this.historicalPriceLabel = new PriceLabelPrimitive(this.series);
      this.series.attachPrimitive(this.historicalPriceLabel);
    }

    if (this.isMainSeries) {
      this.currentPriceLabel = new PriceLabelPrimitive(this.series);
      this.series.attachPrimitive(this.currentPriceLabel);
    }

    if (this.isVolumeSeries) {
      this.volumePriceLabel = new PriceLabelPrimitive(this.series);

      this.setBuiltInLastValueVisible(false);

      this.subscriptions.add(
        this.mainSerie$
          .pipe(distinctUntilChanged())
          .subscribe((series) => {
            if (!series) {
              this.detachVolumePriceLabel();

              return;
            }

            this.attachVolumePriceLabel(series);
          }),
      );
    }

    this.scheduleUpdate();
  }

  private handleVisibleLogicalRangeChange = (range: LogicalRange | null): void => {
    this.visibleLogicalRange = range;
    this.scheduleUpdate();
  };

  private refresh(): void {
    if (this.series.options().visible === false) {
      this.hideCustomPriceLabels();

      return;
    }

    if (this.isVolumeSeries) {
      this.updateVolumePriceLabel();

      return;
    }

    if (!this.historicalPriceLabel) {
      return;
    }

    const range = this.visibleLogicalRange;
    const barsInfo = range ? this.series.barsInLogicalRange(range) : null;
    const nextHistoricalMode = (barsInfo?.barsAfter ?? 0) > 0;

    this.setHistoricalMode(nextHistoricalMode);

    if (!nextHistoricalMode || !range) {
      this.historicalPriceLabel.hide();
      this.currentPriceLabel?.hide();

      return;
    }

    this.updateHistoricalPriceLabel(range);
    this.updateCurrentPriceLabel(range);
  }

  private updateHistoricalPriceLabel(range: LogicalRange): void {
    if (!this.historicalPriceLabel) {
      return;
    }

    const data = this.getVisibleData(range);
    const price = getDataPrice(data);

    if (price === null) {
      this.historicalPriceLabel.hide();

      return;
    }

    this.historicalPriceLabel.setState({
      color: this.getPriceLabelColor(data),
      price,
      text: this.formatPriceLabel(price, range),
      variant: 'outlined',
      visible: true,
    });
  }

  private updateCurrentPriceLabel(range: LogicalRange): void {
    if (!this.currentPriceLabel) {
      return;
    }

    const data = this.getLastData();
    const price = getDataPrice(data);

    if (price === null) {
      this.currentPriceLabel.hide();

      return;
    }

    this.currentPriceLabel.setState({
      color: this.getPriceLabelColor(data),
      price,
      text: this.formatPriceLabel(price, range),
      variant: 'filled',
      visible: true,
    });
  }

  private updateVolumePriceLabel(): void {
    if (!this.volumePriceLabel || !this.volumePriceLabelHost) {
      return;
    }

    const data = this.visibleLogicalRange
      ? this.getVisibleData(this.visibleLogicalRange)
      : this.getLastData();

    const price = getDataPrice(data);

    if (price === null) {
      this.volumePriceLabel.hide();

      return;
    }

    this.volumePriceLabel.setState({
      color: this.getPriceLabelColor(data),
      price,
      text: this.series.priceFormatter().format(price),
      variant: 'filled',
      visible: true,
    });
  }

  private getVisibleData(range: LogicalRange): SeriesDataItemTypeMap<Time>[TSeries] | null {
    return this.series.dataByIndex(
      Math.floor(range.to),
      MismatchDirection.NearestLeft,
    );
  }

  private getFirstVisiblePrice(range: LogicalRange): number | null {
    const data = this.series.dataByIndex(
      Math.ceil(range.from),
      MismatchDirection.NearestRight,
    );

    return getDataPrice(data);
  }

  private getLastData(): SeriesDataItemTypeMap<Time>[TSeries] | null {
    const data = this.series.data();

    return data.length ? data[data.length - 1] : null;
  }

  private getPriceLabelColor(data: unknown): string {
    return getDataColor(
      data,
      this.series.options() as unknown as Record<string, unknown>,
    );
  }

  private formatPriceLabel(price: number, range: LogicalRange): string {
    const mode = this.series.priceScale().options().mode;

    if (
      mode === PriceScaleMode.Percentage ||
      mode === PriceScaleMode.IndexedTo100
    ) {
      const firstVisiblePrice = this.getFirstVisiblePrice(range);

      if (firstVisiblePrice !== null && firstVisiblePrice !== 0) {
        const value =
          mode === PriceScaleMode.Percentage
            ? ((price - firstVisiblePrice) / firstVisiblePrice) * 100
            : (price / firstVisiblePrice) * 100;

        return mode === PriceScaleMode.Percentage
          ? `${value.toFixed(2)}%`
          : value.toFixed(2);
      }
    }

    return this.series.priceFormatter().format(price);
  }

  private setHistoricalMode(nextHistoricalMode: boolean): void {
    if (this.isHistoricalMode === nextHistoricalMode) {
      return;
    }

    this.isHistoricalMode = nextHistoricalMode;

    this.setBuiltInLastValueVisible(
      nextHistoricalMode
        ? false
        : this.defaultLastValueVisible,
    );
  }

  private setBuiltInLastValueVisible(visible: boolean): void {
    this.series.applyOptions({
      lastValueVisible: visible,
    } as SeriesPartialOptionsMap[TSeries]);
  }

  private attachVolumePriceLabel(series: THost): void {
    if (
      !this.volumePriceLabel ||
      this.volumePriceLabelHost === series
    ) {
      return;
    }

    this.detachVolumePriceLabel();

    series
      .getLwcSeries()
      .attachPrimitive(this.volumePriceLabel);

    this.volumePriceLabelHost = series;

    this.scheduleUpdate();
  }

  private detachVolumePriceLabel(): void {
    if (!this.volumePriceLabel || !this.volumePriceLabelHost) {
      return;
    }

    try {
      this.volumePriceLabelHost
        .getLwcSeries()
        .detachPrimitive(this.volumePriceLabel);
    } catch {
      // Главная серия могла быть удалена перед переключением её типа.
    }

    this.volumePriceLabelHost = null;
  }

  private hideCustomPriceLabels(): void {
    this.historicalPriceLabel?.hide();
    this.currentPriceLabel?.hide();
    this.volumePriceLabel?.hide();
  }
}