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


import {
  ColorType,
  createChart,
  createSeriesMarkers,
  CrosshairMode,
  type IChartApi,
  type ISeriesApi,
  type ISeriesMarkersPluginApi,
  LineSeries,
  LineStyle,
  type MouseEventParams,
  PriceScaleMode,
  type Time,
} from 'lightweight-charts';
import { BehaviorSubject } from 'rxjs';

import { createInitialSeasonalityState } from '@src/constants';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { ChartViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer, formatCompactNumber } from '@src/utils';

import {
  buildSeasonality,
  normalizeRange,
  toSeriesData,
} from './seasonality.utils';

import type { DataSourceParams } from '@src/core/DataSource';
import type {
  Candle,
  ChartTypeOptions,
  DailyClose,
  SeasonalityRange,
  SeasonalityScale,
  SeasonalityYear,
} from '@src/types';

interface SeasonalityManagerParams {
  container: HTMLElement;
  getData: DataSourceParams['getData'];
  precision?: number;
  chartOptions?: ChartTypeOptions;
  onOpen: () => void;
  onBack: () => void;
}

interface SeasonalityPlot {
  year: SeasonalityYear;
  series: ISeriesApi<'Line'>;
  legend: HTMLSpanElement;
  legendValue: HTMLSpanElement;
  markers?: ISeriesMarkersPluginApi<Time>;
}

const DEFAULT_YEARS = 5;
const MAX_HISTORY_PAGES = 1000;

export class SeasonalityManager {
  private readonly currentYear = new Date().getUTCFullYear();
  private readonly state = new BehaviorSubject(createInitialSeasonalityState());
  private readonly chartContainer = document.createElement('div');
  private readonly legend = document.createElement('div');
  private readonly statusElement = document.createElement('div');
  private readonly plots = new Map<number, SeasonalityPlot>();

  private chart: IChartApi | null = null;
  private allYears: SeasonalityYear[] = [];
  private requestId = 0;
  private renderFrame: number | null = null;
  private destroyed = false;

  constructor(private readonly params: SeasonalityManagerParams) {
    Object.assign(this.chartContainer.style, {
      position: 'absolute',
      inset: '0',
    });

    Object.assign(this.legend.style, {
      position: 'absolute',
      top: 'var(--space-0500)',
      left: 'var(--space-0500)',
      width: 'max-content',
      display: 'grid',
      gridTemplateColumns: 'max-content max-content',
      justifyContent: 'start',
      alignItems: 'center',
      gap: 'var(--space-0375) var(--space-1000)',
      pointerEvents: 'none',
      fontSize: 'var(--space-0750)',
      fontVariantNumeric: 'tabular-nums',
      zIndex: '2',
    });

    Object.assign(this.statusElement.style, {
      position: 'absolute',
      inset: '0',
      display: 'none',
      alignItems: 'center',
      justifyContent: 'center',
      padding: 'var(--space-1000)',
      textAlign: 'center',
      pointerEvents: 'none',
    });

    params.container.append(
      this.chartContainer,
      this.legend,
      this.statusElement,
    );

    this.updateTheme();
  }

  public getState() {
    return this.state.asObservable();
  }

  public isActive(): boolean {
    return this.state.value.view === ChartViewType.Seasonality;
  }

  public async show(
    symbolId: string,
    range?: SeasonalityRange,
  ): Promise<void> {
    if (this.destroyed || this.reuseHistory(symbolId, range)) {
      return;
    }

    const requestId = ++this.requestId;

    this.reset();

    if (!this.isActive()) {
      this.params.onOpen();
    }

    this.showStatus(t('Loading seasonality'));

    this.state.next({
      ...this.state.value,
      symbolId,
      view: ChartViewType.Seasonality,
      status: 'loading',
      error: null,
    });

    try {
      if (!symbolId) {
        throw new Error('Select an instrument');
      }

      const rows = await this.loadHistory(symbolId, requestId);

      if (!this.isCurrent(requestId)) {
        return;
      }

      this.allYears = buildSeasonality(rows);
      this.setInitialRange(range);
    } catch (error) {
      if (!this.isCurrent(requestId)) {
        return;
      }

      this.reset();
      this.showStatus(t('Unable to load seasonality'));

      this.state.next({
        ...this.state.value,
        status: 'error',
        error: String(error),
      });

      console.error('[Seasonality] History loading failed:', error);
    }
  }

  public setRange = (range: SeasonalityRange): void => {
    if (
      this.destroyed ||
      !this.isActive() ||
      this.allYears.length === 0
    ) {
      return;
    }

    const state = this.state.value;
    const next = normalizeRange(range, state.minYear, state.maxYear);

    if (
      next.fromYear === state.fromYear &&
      next.toYear === state.toYear
    ) {
      return;
    }

    this.state.next({ ...state, ...next });

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

    this.renderFrame = requestAnimationFrame(() => {
      this.renderFrame = null;

      if (!this.destroyed && this.isActive()) {
        this.renderRange();
      }
    });
  };

  public setScale = (scale: SeasonalityScale): void => {
    if (
      this.destroyed ||
      !this.isActive() ||
      this.state.value.scale === scale
    ) {
      return;
    }

    this.state.next({ ...this.state.value, scale });

    if (!this.chart) {
      return;
    }

    this.plots.forEach((plot) => this.updateSeries(plot));

    this.fitCalendar();

    this.updateStatus();
    this.updateLegend();
  };

  public back = (): void => {
    if (this.destroyed || !this.isActive()) {
      return;
    }

    this.requestId += 1;

    this.reset();

    this.state.next({
      ...this.state.value,
      view: ChartViewType.Chart,
      status: 'idle',
      error: null,
    });

    this.params.onBack();
  };

  public updateTheme(): void {
    const { colors } = getThemeStore();

    Object.assign(this.params.container.style, {
      backgroundColor: colors.chartBackground,
    });

    Object.assign(this.statusElement.style, {
      color: colors.chartTextPrimary,
    });

    this.legend.style.color = colors.chartTextPrimary;

    this.chart?.applyOptions(
      getThemeOptions(this.params.chartOptions),
    );
  }

  public async copyImage(): Promise<void> {
    if (!this.chart || this.state.value.status !== 'ready') {
      return;
    }

    const canvas = this.chart.takeScreenshot(true, false);

    drawTextLayer(canvas, this.legend, this.chart.chartElement());

    const blob = await canvasToBlob(canvas);

    await navigator.clipboard.write([
      new ClipboardItem({ 'image/png': blob }),
    ]);
  }

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

    this.destroyed = true;
    this.requestId += 1;

    this.reset();

    this.chartContainer.remove();
    this.legend.remove();
    this.statusElement.remove();

    this.state.complete();
  }

  private isCurrent(requestId: number): boolean {
    return !this.destroyed && requestId === this.requestId;
  }

  private reuseHistory(
    symbolId: string,
    range?: SeasonalityRange,
  ): boolean {
    const state = this.state.value;

    if (!this.isActive() || state.symbolId !== symbolId) {
      return false;
    }

    if (state.status === 'loading') {
      return true;
    }

    if (this.allYears.length === 0) {
      return false;
    }

    if (range) {
      this.setRange(range);
    }

    return true;
  }

  private async loadHistory(
    symbolId: string,
    requestId: number,
  ): Promise<DailyClose[]> {
    const candles = new Map<number, Candle>();

    let cursor: Candle | undefined;

    for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
      if (!this.isCurrent(requestId)) {
        return [];
      }

      const loaded = await this.params.getData(
        Timeframes['1d'],
        symbolId,
        cursor,
      );

      if (!this.isCurrent(requestId)) {
        return [];
      }

      if (!loaded?.length) {
        return Array.from(candles.values(), (candle) => ({
          date: new Date(candle.time * 1000).toISOString().slice(0, 10),
          close: candle.close,
        }));
      }

      const oldest = mergeHistoryPage(loaded, candles);

      if (cursor && oldest.time >= cursor.time) {
        throw new Error('History pagination made no progress');
      }

      cursor = oldest;
    }

    throw new Error('History page limit exceeded');
  }

  private setInitialRange(range?: SeasonalityRange): void {
    if (this.allYears.length === 0) {
      this.showStatus(t('No seasonality data'));
      this.state.next({
        ...this.state.value,
        status: 'empty',
      });

      return;
    }

    const minYear = this.allYears[0].year;
    const maxYear = this.allYears[this.allYears.length - 1].year;

    const selected = normalizeRange(
      range ?? {
        fromYear: maxYear - DEFAULT_YEARS + 1,
        toYear: maxYear,
      },
      minYear,
      maxYear,
    );

    this.state.next({
      ...this.state.value,
      ...selected,
      minYear,
      maxYear,
    });

    this.renderRange();
  }

  private createChart(): IChartApi {
    const chart = createChart(this.chartContainer, {
      ...getThemeOptions(this.params.chartOptions),
      autoSize: true,
      handleScroll: false,
      handleScale: false,

      kineticScroll: {
        mouse: false,
        touch: false,
      },

      rightPriceScale: {
        mode: PriceScaleMode.Normal,
        autoScale: true,
        alignLabels: true,
        visible: true,
        borderVisible: false,
      },

      leftPriceScale: {
        visible: false,
      },

      timeScale: {
        timeVisible: false,
        secondsVisible: false,
        borderVisible: false,
        allowBoldLabels: false,
        rightOffset: 0,
        tickMarkFormatter: formatSeasonDate,
        lockVisibleTimeRangeOnResize: true,
      },

      localization: {
        locale: getLocale(),
        timeFormatter: formatSeasonDate,
      },
    });

    chart.subscribeCrosshairMove(this.onCrosshairMove);

    this.chart = chart;

    return chart;
  }

  private renderRange(): void {
    const chart = this.chart ?? this.createChart();
    const { fromYear, toYear } = this.state.value;

    const years = this.allYears.filter(
      (year) => year.year >= fromYear && year.year <= toYear,
    );

    this.plots.forEach((plot, year) => {
      if (year < fromYear || year > toYear) {
        plot.markers?.detach();
        chart.removeSeries(plot.series);
        this.plots.delete(year);
      }
    });

    const plots = years.map((year) => {
      const existing = this.plots.get(year.year);

      if (existing) {
        return existing;
      }

      const plot = this.createPlot(chart, year);

      this.plots.set(year.year, plot);

      return plot;
    });

    this.legend.replaceChildren(
      ...plots.flatMap((plot) => [
        plot.legend,
        plot.legendValue,
      ]),
    );

    if (plots.length > 0) {
      this.fitCalendar();
    }

    this.updateStatus();
    this.updateLegend();

    this.state.next({
      ...this.state.value,
      status: plots.length > 0 ? 'ready' : 'empty',
    });
  }

  private fitCalendar(): void {
    this.chart?.timeScale().setVisibleRange({
      from: '2000-01-01',
      to: '2000-12-31',
    });
  }

  private createPlot(
    chart: IChartApi,
    year: SeasonalityYear,
  ): SeasonalityPlot {
    const color = getYearColor(year.year);
    const legend = document.createElement('span');
    const legendValue = document.createElement('span');

    legend.textContent = String(year.year);

    Object.assign(legendValue.style, {
      color,
      textAlign: 'right',
    });

    const plot: SeasonalityPlot = {
      year,
      legend,
      legendValue,
      series: chart.addSeries(LineSeries, {
        title: String(year.year),
        color,
        priceLineVisible: false,
        lastValueVisible: true,
        lineWidth: year.year === this.currentYear ? 3 : 1,
        pointMarkersVisible: false,
        crosshairMarkerVisible: true,
      }),
    };

    if (year.year === this.currentYear) {
      plot.markers = createSeriesMarkers(plot.series, [], {
        autoScale: false,
      });
    }

    this.updateSeries(plot);

    return plot;
  }

  private updateSeries({
    series,
    year,
    markers,
  }: SeasonalityPlot): void {
    const { scale } = this.state.value;
    const isPercentage = scale === PriceScaleMode.Percentage;

    series.applyOptions({
      priceFormat: {
        type: 'custom',
        minMove: isPercentage
          ? 0.01
          : 10 ** -(this.params.precision ?? 2),
        formatter: (value: number) =>
          isPercentage
            ? `${value.toFixed(2)}%`
            : formatCompactNumber(value),
      },
    });

    series.setData(toSeriesData(year, scale));

    if (markers) {
      const lastPoint = year.points[year.points.length - 1];
      const value = isPercentage
        ? lastPoint.percentage
        : lastPoint.price;

      markers.setMarkers(
        value === null
          ? []
          : [
              {
                time: lastPoint.chartDate,
                position: 'inBar',
                shape: 'circle',
                color: getYearColor(year.year),
                size: 1,
              },
            ],
      );
    }
  }

  private updateStatus(): void {
    if (this.plots.size === 0) {
      this.showStatus(t('No seasonality data'));

      return;
    }

    const isPercentage =
      this.state.value.scale === PriceScaleMode.Percentage;

    const hasBaseline = Array.from(this.plots.values()).some(
      (plot) => plot.year.baselinePrice > 0,
    );

    if (isPercentage && !hasBaseline) {
      this.showStatus(t('Percentage requires a positive baseline'));

      return;
    }

    this.statusElement.style.display = 'none';
  }

  private onCrosshairMove = (event: MouseEventParams): void => {
    this.updateLegend(event);
  };

  private updateLegend(event?: MouseEventParams): void {
    const isPercentage =
      this.state.value.scale === PriceScaleMode.Percentage;

    this.plots.forEach(({ year, series, legendValue }) => {
      const lastPoint = year.points[year.points.length - 1];

      let value = isPercentage
        ? lastPoint.percentage
        : lastPoint.price;

      if (event?.time !== undefined) {
        const point = event.seriesData.get(series);

        value =
          point && 'value' in point && typeof point.value === 'number'
            ? point.value
            : null;
      }

      legendValue.textContent =
        value === null
          ? '—'
          : series.priceFormatter().format(value);
    });
  }

  private showStatus(message: string): void {
    this.statusElement.textContent = message;
    this.statusElement.style.display = 'flex';
  }

  private reset(): void {
    if (this.renderFrame !== null) {
      cancelAnimationFrame(this.renderFrame);
      this.renderFrame = null;
    }

    this.chart?.unsubscribeCrosshairMove(this.onCrosshairMove);
    this.plots.forEach((plot) => plot.markers?.detach());
    this.chart?.remove();

    this.chart = null;
    this.allYears = [];

    this.plots.clear();
    this.legend.replaceChildren();

    this.statusElement.style.display = 'none';
  }
}

function mergeHistoryPage(
  loaded: Candle[],
  candles: Map<number, Candle>,
): Candle {
  let oldest = loaded[0];
  let oldestTime = Infinity;

  for (let index = loaded.length - 1; index >= 0; index -= 1) {
    const candle = loaded[index];

    if (
      !Number.isFinite(candle.time) ||
      !Number.isFinite(candle.close)
    ) {
      throw new Error('Invalid daily candle');
    }

    const time = Math.floor(
      candle.time > 1e10 ? candle.time / 1000 : candle.time,
    );

    const normalized =
      time === candle.time ? candle : { ...candle, time };

    if (time < oldestTime) {
      oldest = normalized;
      oldestTime = time;
    }

    if (!candles.has(time)) {
      candles.set(time, normalized);
    }
  }

  return oldest;
}

function getThemeOptions(options: ChartTypeOptions = {}) {
  const { colors } = getThemeStore();

  return {
    layout: {
      background: {
        type: ColorType.Solid,
        color: colors.chartBackground,
      },
      textColor: colors.chartTextPrimary,
      panes: {
        separatorColor: colors.chartPaneSeparator,
        separatorHoverColor: colors.chartPaneSeparator,
        enableResize: false,
      },
    },

    grid: {
      vertLines: {
        color: colors.chartGridLine,
      },
      horzLines: {
        color: colors.chartGridLine,
      },
    },

    crosshair: {
      mode: CrosshairMode.Normal,
      vertLine: {
        color:
          options.crosshairLineXColor ?? colors.chartCrosshairLine,
        labelBackgroundColor:
          options.crosshairLabelXBgColor ?? colors.chartCrosshairLabel,
        style: LineStyle.Solid,
      },
      horzLine: {
        color:
          options.crosshairLineYColor ?? colors.chartCrosshairLine,
        labelBackgroundColor:
          options.crosshairLabelYBgColor ?? colors.chartCrosshairLabel,
        style: LineStyle.Dashed,
      },
    },
  };
}

function formatSeasonDate(time: Time): string {
  if (typeof time === 'object') {
    const day = String(time.day).padStart(2, '0');
    const month = String(time.month).padStart(2, '0');

    return `${day}.${month}`;
  }

  const date =
    typeof time === 'number'
      ? new Date(time * 1000).toISOString()
      : time;

  return `${date.slice(8, 10)}.${date.slice(5, 7)}`;
}

function getYearColor(year: number): string {
  return COMPARE_COLOR_PALETTE[
    year % COMPARE_COLOR_PALETTE.length
  ];
}

export { buildSeasonality, normalizeRange, toSeriesData };


















import {
  PriceScaleMode,
  type LineData,
  type WhitespaceData,
} from 'lightweight-charts';

import type {
  DailyClose,
  SeasonalityRange,
  SeasonalityScale,
  SeasonalityYear,
} from '@src/types';

type SeriesDatum = LineData | WhitespaceData;

const CALENDAR = Array.from({ length: 366 }, (_, day) =>
  new Date(Date.UTC(2000, 0, day + 1)).toISOString().slice(0, 10),
);

export function normalizeRange(
  range: SeasonalityRange,
  minYear: number,
  maxYear: number,
): SeasonalityRange {
  if (
    !Number.isFinite(range.fromYear) ||
    !Number.isFinite(range.toYear)
  ) {
    throw new Error('Invalid year range');
  }

  const fromYear = Math.max(
    minYear,
    Math.min(maxYear, Math.round(range.fromYear)),
  );

  const toYear = Math.max(
    fromYear,
    Math.min(maxYear, Math.round(range.toYear)),
  );

  return { fromYear, toYear };
}

export function buildSeasonality(
  rows: readonly DailyClose[],
): SeasonalityYear[] {
  const years: SeasonalityYear[] = [];
  const sorted = [...rows].sort(
    (a, b) => a.date.localeCompare(b.date),
  );

  for (const row of sorted) {
    const year = Number(row.date.slice(0, 4));

    let current = years[years.length - 1];

    if (!current || current.year !== year) {
      current = {
        year,
        baselineDate: row.date,
        baselinePrice: row.close,
        points: [],
      };

      years.push(current);
    }

    if (current.points[current.points.length - 1]?.date === row.date) {
      throw new Error(`Multiple daily candles on ${row.date}`);
    }

    const percentage =
      current.baselinePrice > 0
        ? (row.close / current.baselinePrice - 1) * 100
        : null;

    if (percentage !== null && !Number.isFinite(percentage)) {
      throw new Error('Invalid percentage');
    }

    current.points.push({
      date: row.date,
      chartDate: `2000-${row.date.slice(5)}`,
      price: row.close,
      percentage,
    });
  }

  return years;
}

export function toSeriesData(
  year: SeasonalityYear,
  scale: SeasonalityScale,
): SeriesDatum[] {
  const isPercentage = scale === PriceScaleMode.Percentage;

  let index = 0;

  return CALENDAR.map((time) => {
    const point = year.points[index];

    if (point?.chartDate !== time) {
      return { time };
    }

    index += 1;

    const value = isPercentage ? point.percentage : point.price;

    return value === null ? { time } : { time, value };
  });
}