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


import {
  Coordinate,
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  SeriesAttachedParameter,
  Time,
} from 'lightweight-charts';

import { hexWithAplha } from '@src/theme/helpers';

export type PriceLabelVariant = 'filled' | 'outlined';

export interface PriceLabelState {
  color: string;
  price: number | null;
  text: string;
  variant: PriceLabelVariant;
  visible: boolean;
}

interface PriceCoordinateSource {
  priceToCoordinate(price: number): Coordinate | null;
}

interface PriceLabelViewState extends PriceLabelState {
  coordinate: Coordinate | null;
}

const LABEL_HEIGHT = 22;
const LABEL_HORIZONTAL_PADDING = 4;
const LABEL_FONT_SIZE = 12;
const OUTLINED_BACKGROUND_ALPHA = 0.12;

class PriceLabelRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly getState: () => PriceLabelViewState) {}

  public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
    const state = this.getState();

    if (!state.visible || state.coordinate === null || !state.text) {
      return;
    }

    target.useBitmapCoordinateSpace(
      ({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
        const labelHeight = Math.round(LABEL_HEIGHT * verticalPixelRatio);
        const horizontalInset = Math.max(1, Math.round(horizontalPixelRatio));
        const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));
        const rawY = Number(state.coordinate) * verticalPixelRatio;

        const top = Math.max(
          0,
          Math.min(bitmapSize.height - labelHeight, rawY - labelHeight / 2),
        );

        const left = horizontalInset;
        const width = Math.max(
          0,
          bitmapSize.width - horizontalInset * 2,
        );

        if (width === 0) {
          return;
        }

        const backgroundColor =
          state.variant === 'outlined'
            ? hexWithAplha(state.color, OUTLINED_BACKGROUND_ALPHA)
            : state.color;

        const textColor =
          state.variant === 'outlined'
            ? state.color
            : '#FFFFFF';

        context.save();

        context.fillStyle = backgroundColor;
        context.fillRect(left, top, width, labelHeight);

        if (state.variant === 'outlined') {
          context.strokeStyle = state.color;
          context.lineWidth = borderWidth;

          context.strokeRect(
            left + borderWidth / 2,
            top + borderWidth / 2,
            Math.max(0, width - borderWidth),
            Math.max(0, labelHeight - borderWidth),
          );
        }

        const horizontalPadding =
          LABEL_HORIZONTAL_PADDING * horizontalPixelRatio;

        const maxTextWidth = Math.max(
          0,
          width - horizontalPadding * 2,
        );

        let fontSize = LABEL_FONT_SIZE * verticalPixelRatio;

        context.font = `500 ${fontSize}px Inter, sans-serif`;

        const measuredTextWidth =
          context.measureText(state.text).width;

        if (
          measuredTextWidth > maxTextWidth &&
          measuredTextWidth > 0
        ) {
          fontSize *= maxTextWidth / measuredTextWidth;
          context.font = `500 ${fontSize}px Inter, sans-serif`;
        }

        context.fillStyle = textColor;
        context.textAlign = 'center';
        context.textBaseline = 'middle';

        context.fillText(
          state.text,
          bitmapSize.width / 2,
          top + labelHeight / 2,
          maxTextWidth,
        );

        context.restore();
      },
    );
  }
}

class PriceLabelPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: PriceLabelRenderer;

  constructor(private readonly getState: () => PriceLabelViewState) {
    this.rendererInstance = new PriceLabelRenderer(getState);
  }

  public renderer(): IPrimitivePaneRenderer | null {
    return this.getState().visible
      ? this.rendererInstance
      : null;
  }

  public zOrder(): 'top' {
    return 'top';
  }
}

export class PriceLabelPrimitive implements ISeriesPrimitive<Time> {
  private readonly views: readonly IPrimitivePaneView[];

  private requestUpdate: (() => void) | null = null;

  private state: PriceLabelViewState = {
    color: '#000000',
    coordinate: null,
    price: null,
    text: '',
    variant: 'outlined',
    visible: false,
  };

  constructor(
    private readonly coordinateSource: PriceCoordinateSource,
  ) {
    this.views = [
      new PriceLabelPaneView(() => this.state),
    ];
  }

  public attached(
    param: SeriesAttachedParameter<Time>,
  ): void {
    this.requestUpdate = param.requestUpdate;
    this.requestUpdate();
  }

  public detached(): void {
    this.requestUpdate = null;
  }

  public updateAllViews(): void {
    this.state = {
      ...this.state,
      coordinate:
        this.state.price === null
          ? null
          : this.coordinateSource.priceToCoordinate(
              this.state.price,
            ),
    };
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    return this.views;
  }

  public setState(nextState: PriceLabelState): void {
    this.state = {
      ...nextState,
      coordinate:
        nextState.price === null
          ? null
          : this.coordinateSource.priceToCoordinate(
              nextState.price,
            ),
    };

    this.requestUpdate?.();
  }

  public hide(): void {
    if (!this.state.visible) {
      return;
    }

    this.state = {
      ...this.state,
      visible: false,
    };

    this.requestUpdate?.();
  }
}









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

import {
  BehaviorSubject,
  distinctUntilChanged,
  filter,
  Subscription,
} from 'rxjs';

import type { Indicator } from '@core/Indicator';
import { PriceLabelPrimitive } from '@core/Series/PriceLabelPrimitive';
import { IndicatorsIds } from '@src/constants';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

interface SeriesPriceLabelsParams<
  TSeries extends SeriesType,
> {
  chart: IChartApi;
  series: ISeriesApi<TSeries>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  indicatorReference: Indicator | null;
}

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 removeAlphaFromHex(
    getThemeStore().colors.chartLineColor,
  );
}

export class SeriesPriceLabels<
  TSeries extends SeriesType,
> {
  private readonly chart: IChartApi;
  private readonly series: ISeriesApi<TSeries>;

  private readonly mainSerie$: BehaviorSubject<
    SeriesStrategies | null
  >;

  private readonly indicatorReference: Indicator | null;

  private readonly subscriptions = new Subscription();

  private historicalPriceLabel: PriceLabelPrimitive | null =
    null;

  private currentPriceLabel: PriceLabelPrimitive | null =
    null;

  private volumePriceLabel: PriceLabelPrimitive | null =
    null;

  private volumePriceLabelHost: SeriesStrategies | 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$,
    indicatorReference,
  }: SeriesPriceLabelsParams<TSeries>) {
    this.chart = chart;
    this.series = series;
    this.mainSerie$ = mainSerie$;
    this.indicatorReference = indicatorReference;

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

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

    if (this.currentPriceLabel) {
      this.series.detachPrimitive(
        this.currentPriceLabel,
      );
    }

    this.detachVolumePriceLabel();
    this.subscriptions.unsubscribe();

    this.initialized = false;
  }

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

    const isVolumeSeries = this.isVolumeSeries();

    if (
      !supportsHistoricalPriceLabels &&
      !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 (isVolumeSeries) {
      this.volumePriceLabel =
        new PriceLabelPrimitive(this.series);

      this.setBuiltInLastValueVisible(false);

      this.subscriptions.add(
        this.mainSerie$
          .pipe(
            filter(
              (
                series,
              ): series is SeriesStrategies =>
                series !== null,
            ),
            distinctUntilChanged(),
          )
          .subscribe((series) => {
            this.attachVolumePriceLabel(series);
          }),
      );
    }

    this.scheduleUpdate();
  }

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

  private refresh(): void {
    if (!this.series.options().visible) {
      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: SeriesStrategies,
  ): 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();
  }

  private isMainSeries(): boolean {
    return this.indicatorReference === null;
  }

  private isCompareSeries(): boolean {
    return (
      this.indicatorReference !== null &&
      this.indicatorReference.getType() === undefined
    );
  }

  private isVolumeSeries(): boolean {
    return (
      this.indicatorReference?.getType() ===
      IndicatorsIds.Volume
    );
  }
}