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


import { Subscription } from 'rxjs';

import type { HoveredSeriesMarker, IPriceLine, SeriesMarker } from 'lightweight-charts';

import { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
import type { PriceAxisLabel } from './types';

import type { ISeriesExtended } from '../Series';

interface HistoricalLabelContext {
  value: string;
  price: number;
  color: string;
  textColor: string;
  source: string;
}

export interface PriceAxisLabelsControllerOptions {
  getMainSeries: () => ISeriesExtended | null;
  getCompareSeries: () => readonly ISeriesExtended[];
  hoveredMarker$: {
    subscribe: (handler: (marker: HoveredSeriesMarker | null) => void) => Subscription;
  };
  crosshairSeriesMarker$: {
    subscribe: (handler: (marker: SeriesMarker<unknown> | null) => void) => Subscription;
  };
}

export class PriceAxisLabelsController {
  private readonly options: PriceAxisLabelsControllerOptions;

  private readonly labelsPrimitive = new PriceAxisLabelsPrimitive();

  private readonly labelsBySource = new Map<string, PriceAxisLabel>();

  private readonly currentPriceLines = new Map<string, IPriceLine>();

  private readonly subscriptions = new Subscription();

  private hoveredMarker: HoveredSeriesMarker | null = null;

  private crosshairMarker: SeriesMarker<unknown> | null = null;

  constructor(options: PriceAxisLabelsControllerOptions) {
    this.options = options;

    this.subscriptions.add(
      this.options.hoveredMarker$.subscribe((marker) => {
        this.hoveredMarker = marker;
        this.sync();
      }),
    );

    this.subscriptions.add(
      this.options.crosshairSeriesMarker$.subscribe((marker) => {
        this.crosshairMarker = marker;
        this.sync();
      }),
    );
  }

  attach(): void {
    const mainSeries = this.options.getMainSeries();

    if (mainSeries) {
      mainSeries.getLwcSeries().attachPrimitive(this.labelsPrimitive);
    }
  }

  detach(): void {
    const mainSeries = this.options.getMainSeries();

    if (mainSeries) {
      mainSeries.getLwcSeries().detachPrimitive(this.labelsPrimitive);
    }

    this.clearCurrentPriceLines();
    this.labelsPrimitive.clear();
    this.labelsBySource.clear();
  }

  destroy(): void {
    this.subscriptions.unsubscribe();
    this.detach();
  }

  updateSeriesTitle(series: ISeriesExtended, title: string): void {
    const line = this.currentPriceLines.get(this.getSourceKey(series));

    if (line) {
      line.applyOptions({ title });
    }
  }

  sync(): void {
    const mainSeries = this.options.getMainSeries();

    if (!mainSeries) {
      this.clearCurrentPriceLines();
      this.labelsPrimitive.clear();
      this.labelsBySource.clear();
      return;
    }

    const seriesList = [mainSeries, ...this.options.getCompareSeries()];
    const activeMarker = this.hoveredMarker ?? this.crosshairMarker;

    if (!activeMarker) {
      this.clearHistoricalLabels();
      this.syncCurrentPriceLines(seriesList);
      this.render();
      return;
    }

    const nextHistoricalLabels = new Map<string, PriceAxisLabel>();

    seriesList.forEach((series) => {
      const label = this.buildHistoricalLabel(series, activeMarker);

      if (label) {
        nextHistoricalLabels.set(this.getSourceKey(series), label);
      }
    });

    this.replaceHistoricalLabels(nextHistoricalLabels);
    this.syncCurrentPriceLines(seriesList);
    this.render();
  }

  private syncCurrentPriceLines(seriesList: readonly ISeriesExtended[]): void {
    const actualSources = new Set(seriesList.map((series) => this.getSourceKey(series)));

    Array.from(this.currentPriceLines.keys()).forEach((sourceKey) => {
      if (!actualSources.has(sourceKey)) {
        const series = this.findSeriesBySource(seriesList, sourceKey);

        if (series) {
          this.removeCurrentPriceLine(series);
        } else {
          const orphanLine = this.currentPriceLines.get(sourceKey);

          if (orphanLine) {
            const fallbackSeries = this.findSeriesBySource(
              [this.options.getMainSeries(), ...this.options.getCompareSeries()].filter(Boolean) as ISeriesExtended[],
              sourceKey,
            );

            fallbackSeries?.getLwcSeries().removePriceLine(orphanLine);
          }

          this.currentPriceLines.delete(sourceKey);
        }
      }
    });

    seriesList.forEach((series) => {
      this.ensureCurrentPriceLine(series);
    });
  }

  private ensureCurrentPriceLine(series: ISeriesExtended): void {
    const sourceKey = this.getSourceKey(series);
    const seriesApi = series.getLwcSeries();
    const lastData = this.getLastSeriesData(series);

    if (!lastData) {
      this.removeCurrentPriceLine(series);
      return;
    }

    const color = this.resolveSeriesColor(series);
    const textColor = this.resolveLabelTextColor(color);
    const title = this.resolveRealtimeTitle(series);
    const existingLine = this.currentPriceLines.get(sourceKey);

    if (existingLine) {
      existingLine.applyOptions({
        price: lastData.value,
        color,
        lineColor: color,
        axisLabelColor: color,
        axisLabelTextColor: textColor,
        title,
        lineVisible: true,
        axisLabelVisible: true,
      });
      return;
    }

    const line = seriesApi.createPriceLine({
      price: lastData.value,
      color,
      lineColor: color,
      axisLabelColor: color,
      axisLabelTextColor: textColor,
      title,
      lineVisible: true,
      axisLabelVisible: true,
    });

    this.currentPriceLines.set(sourceKey, line);
  }

  private clearCurrentPriceLines(): void {
    const seriesList = this.getAllSeries();

    Array.from(this.currentPriceLines.entries()).forEach(([sourceKey, line]) => {
      const series = this.findSeriesBySource(seriesList, sourceKey);

      if (series) {
        series.getLwcSeries().removePriceLine(line);
      }
    });

    this.currentPriceLines.clear();
  }

  private removeCurrentPriceLine(series: ISeriesExtended): void {
    const sourceKey = this.getSourceKey(series);
    const line = this.currentPriceLines.get(sourceKey);

    if (!line) {
      return;
    }

    series.getLwcSeries().removePriceLine(line);
    this.currentPriceLines.delete(sourceKey);
  }

  private replaceHistoricalLabels(nextHistoricalLabels: ReadonlyMap<string, PriceAxisLabel>): void {
    Array.from(this.labelsBySource.keys()).forEach((sourceKey) => {
      if (!nextHistoricalLabels.has(sourceKey)) {
        this.labelsBySource.delete(sourceKey);
      }
    });

    Array.from(nextHistoricalLabels.entries()).forEach(([sourceKey, label]) => {
      this.labelsBySource.set(sourceKey, label);
    });
  }

  private clearHistoricalLabels(): void {
    this.labelsBySource.clear();
  }

  private buildHistoricalLabel(
    series: ISeriesExtended,
    marker: HoveredSeriesMarker | SeriesMarker<unknown>,
  ): PriceAxisLabel | null {
    const markerSource = this.extractMarkerSource(marker);
    const seriesSource = this.getSourceKey(series);

    if (!markerSource || markerSource !== seriesSource) {
      return null;
    }

    const historical = this.extractHistoricalLabelContext(series, marker);

    if (!historical) {
      return null;
    }

    const current = this.getCurrentPriceContext(series);
    const shouldElevate = current ? Math.abs(current.price - historical.price) < this.getLabelCollisionThreshold(series) : false;

    return {
      source: seriesSource,
      price: historical.price,
      text: historical.value,
      color: historical.color,
      textColor: historical.textColor,
      borderColor: historical.color,
      style: 'outlined',
      verticalOffset: shouldElevate ? -22 : -2,
    };
  }

  private getCurrentPriceContext(series: ISeriesExtended): HistoricalLabelContext | null {
    const lastData = this.getLastSeriesData(series);

    if (!lastData) {
      return null;
    }

    const color = this.resolveSeriesColor(series);

    return {
      source: this.getSourceKey(series),
      value: this.formatValue(series, lastData.value),
      price: lastData.value,
      color,
      textColor: this.resolveLabelTextColor(color),
    };
  }

  private extractHistoricalLabelContext(
    series: ISeriesExtended,
    marker: HoveredSeriesMarker | SeriesMarker<unknown>,
  ): HistoricalLabelContext | null {
    const price = this.extractMarkerPrice(marker);

    if (price === null) {
      return null;
    }

    const color = this.resolveSeriesColor(series);

    return {
      source: this.getSourceKey(series),
      value: this.formatValue(series, price),
      price,
      color,
      textColor: color,
    };
  }

  private extractMarkerSource(marker: HoveredSeriesMarker | SeriesMarker<unknown>): string | null {
    if ('data' in marker && marker.data) {
      return String(marker.data);
    }

    if ('source' in marker && marker.source) {
      return String(marker.source);
    }

    return null;
  }

  private extractMarkerPrice(marker: HoveredSeriesMarker | SeriesMarker<unknown>): number | null {
    if ('logicalPrice' in marker && typeof marker.logicalPrice === 'number') {
      return marker.logicalPrice;
    }

    if ('price' in marker && typeof marker.price === 'number') {
      return marker.price;
    }

    return null;
  }

  private getLastSeriesData(series: ISeriesExtended): { value: number } | null {
    const data = series.data();

    if (!data.length) {
      return null;
    }

    const lastItem = data[data.length - 1];

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

    if ('close' in lastItem && typeof lastItem.close === 'number') {
      return { value: lastItem.close };
    }

    return null;
  }

  private formatValue(series: ISeriesExtended, price: number): string {
    const formatter = series.getPriceFormatter();

    return formatter ? formatter(price) : String(price);
  }

  private resolveSeriesColor(series: ISeriesExtended): string {
    return series.getCurrentColor();
  }

  private resolveLabelTextColor(backgroundColor: string): string {
    const normalized = backgroundColor.trim().replace('#', '');

    if (normalized.length !== 6) {
      return '#FFFFFF';
    }

    const red = Number.parseInt(normalized.slice(0, 2), 16);
    const green = Number.parseInt(normalized.slice(2, 4), 16);
    const blue = Number.parseInt(normalized.slice(4, 6), 16);
    const luminance = (red * 299 + green * 587 + blue * 114) / 1000;

    return luminance >= 140 ? '#0B0E11' : '#FFFFFF';
  }

  private getLabelCollisionThreshold(series: ISeriesExtended): number {
    const current = this.getCurrentPriceContext(series);

    if (!current) {
      return 0;
    }

    const absPrice = Math.abs(current.price);

    if (absPrice >= 10000) {
      return absPrice * 0.001;
    }

    if (absPrice >= 1000) {
      return absPrice * 0.002;
    }

    if (absPrice >= 100) {
      return absPrice * 0.004;
    }

    if (absPrice >= 10) {
      return absPrice * 0.008;
    }

    return 0.1;
  }

  private render(): void {
    const labels = Array.from(this.labelsBySource.values());
    const sorted = labels.slice().sort((a, b) => a.price - b.price);

    const prepared = sorted.map((label, index, array) => {
      const previous = index > 0 ? array[index - 1] : null;
      const next = index < array.length - 1 ? array[index + 1] : null;
      let verticalOffset = label.verticalOffset ?? 0;

      if (previous && Math.abs(label.price - previous.price) < 0.0000001) {
        verticalOffset += 18;
      }

      if (next && Math.abs(next.price - label.price) < 0.0000001) {
        verticalOffset -= 18;
      }

      return {
        ...label,
        verticalOffset,
      };
    });

    this.labelsPrimitive.updateAll(prepared, prepared);
  }

  private getAllSeries(): ISeriesExtended[] {
    const main = this.options.getMainSeries();
    const compare = this.options.getCompareSeries();

    return [main, ...compare].filter(Boolean) as ISeriesExtended[];
  }

  private findSeriesBySource(seriesList: readonly ISeriesExtended[], sourceKey: string): ISeriesExtended | null {
    return seriesList.find((series) => this.getSourceKey(series) === sourceKey) ?? null;
  }

  private getSourceKey(series: ISeriesExtended): string {
    return series.seriesSource() ?? series.seriesOptions().symbol;
  }

  private resolveRealtimeTitle(series: ISeriesExtended): string {
    return series.seriesOptions().showSymbolLabel ? series.seriesOptions().symbol : '';
  }
}