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


<SeasonalityControls
  stateObs={this.seasonalityManager.getState()}
  setDisplayMode={this.seasonalityManager.setDisplayMode}
  setScale={this.seasonalityManager.setScale}
  setRange={this.seasonalityManager.setRange}
  setAverageVisible={this.seasonalityManager.setAverageVisible}
  copyImage={
    config.chartCollectionPreset.showSnapshotButton
      ? () => this.seasonalityManager.copyImage()
      : undefined
  }
/>

















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

import type {
  DailyClose,
  SeasonalityChange,
  SeasonalityRange,
  SeasonalityScale,
  SeasonalityTableRow,
  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),
);

const ISO_DATE_MONTH_START = 5;
const ISO_DATE_MONTH_END = 7;

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

export function buildSeasonalityAverage(
  years: readonly SeasonalityYear[],
  scale: SeasonalityScale,
): SeriesDatum[] {
  const values = new Map<string, number[]>();
  const isPercentage = scale === PriceScaleMode.Percentage;

  for (const year of years) {
    for (const point of year.points) {
      const value = isPercentage ? point.percentage : point.price;

      if (value === null) {
        continue;
      }

      const dailyValues = values.get(point.chartDate);

      if (dailyValues) {
        dailyValues.push(value);
      } else {
        values.set(point.chartDate, [value]);
      }
    }
  }

  return CALENDAR.map((time) => {
    const value = calculateAverage(values.get(time) ?? []);

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

export function buildSeasonalityTable(years: readonly SeasonalityYear[]): SeasonalityTableRow[] {
  const closes = new Map<number, number>();

  for (const year of years) {
    for (const point of year.points) {
      const month = Number(point.date.slice(ISO_DATE_MONTH_START, ISO_DATE_MONTH_END)) - 1;

      closes.set(year.year * 12 + month, point.price);
    }
  }

  return years
    .map((year) => {
      const firstMonth = year.year * 12;
      const baselineMonth = Number(year.baselineDate.slice(ISO_DATE_MONTH_START, ISO_DATE_MONTH_END)) - 1;
      const lastPoint = year.points[year.points.length - 1];
      const baselinePrice = closes.get(firstMonth - 1) ?? year.baselinePrice;

      return {
        year: year.year,
        months: Array.from({ length: 12 }, (_, month) => {
          const key = firstMonth + month;
          const fromPrice = closes.get(key - 1) ?? (month === baselineMonth ? year.baselinePrice : undefined);

          return calculateChange(fromPrice, closes.get(key));
        }),
        total: calculateChange(baselinePrice, lastPoint.price),
      };
    })
    .reverse();
}

function calculateChange(from: number | undefined, to: number | undefined): SeasonalityChange | null {
  if (from === undefined || to === undefined) {
    return null;
  }

  return {
    absolute: to - from,
    percentage: from > 0 ? (to / from - 1) * 100 : null,
  };
}

export function buildSeasonalityTableAverage(
  rows: readonly SeasonalityTableRow[],
): Pick<SeasonalityTableRow, 'months' | 'total'> | null {
  if (rows.length === 0) {
    return null;
  }

  return {
    months: Array.from({ length: 12 }, (_, month) => averageChanges(rows.map((row) => row.months[month]))),
    total: averageChanges(rows.map((row) => row.total)),
  };
}

function averageChanges(
  changes: readonly (SeasonalityChange | null | undefined)[],
): SeasonalityChange | null {
  const available = changes.filter(
    (change): change is SeasonalityChange => change !== null && change !== undefined,
  );

  if (available.length === 0) {
    return null;
  }

  return {
    absolute: available.reduce((sum, change) => sum + change.absolute, 0) / available.length,
    percentage: calculateAverage(available.map((change) => change.percentage)),
  };
}

function calculateAverage(values: readonly (number | null)[]): number | null {
  let sum = 0;
  let count = 0;

  for (const value of values) {
    if (value !== null) {
      sum += value;
      count += 1;
    }
  }

  return count === 0 ? null : sum / count;
}


















import dayjs from 'dayjs';
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, DEFAULT_SEASONALITY_YEARS } from '@src/constants';
import { ZIndex } from '@src/core/ContainerManager';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { buildSeasonalityAverage } from '@src/utils/seasonality';
import {
  buildSeasonality,
  buildSeasonalityTable,
  canvasToBlob,
  drawTextLayer,
  formatCompactNumber,
  formatPercent,
  getPercentPrecisionStep,
  getPricePrecisionStep,
  normalizeRange,
  toSeriesData,
} from '@src/utils';

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

interface SeasonalityManagerParams {
  container: HTMLElement;
  getData: DataSourceParams['getData'];
}

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

interface SeasonalityAveragePlot {
  series: ISeriesApi<'Line'>;
  legend: HTMLSpanElement;
  legendValue: HTMLSpanElement;
  data: ReturnType<typeof buildSeasonalityAverage>;
}

export class SeasonalityManager {
  private readonly currentYear = dayjs().year();
  private readonly state = new BehaviorSubject(createInitialSeasonalityState());
  private readonly chartContainer: HTMLDivElement;
  private readonly legend: HTMLDivElement;
  private readonly statusElement: HTMLDivElement;
  private readonly plots = new Map<number, SeasonalityPlot>();

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

  constructor(private readonly params: SeasonalityManagerParams) {
    const { chartContainer, legend, statusElement } = createSeasonalityElements(params.container);

    this.chartContainer = chartContainer;
    this.legend = legend;
    this.statusElement = statusElement;

    this.updateTheme();
  }

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

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

    const requestId = ++this.requestId;

    this.reset();

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

    this.state.next({
      ...this.state.value,
      symbolId,
      status: 'loading',
      tableRows: [],
      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.tableRows = buildSeasonalityTable(this.allYears);
      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',
        tableRows: [],
        error: String(error),
      });

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

  public setRange = (range: SeasonalityRange): void => {
    if (this.destroyed || 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.allYears.length > 0) {
        this.renderRange();
      }
    });
  };

  public setDisplayMode = (displayMode: SeasonalityViewType): void => {
    if (this.destroyed || this.state.value.displayMode === displayMode) {
      return;
    }

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

  public setAverageVisible = (showAverage: boolean): void => {
    if (this.destroyed || this.state.value.showAverage === showAverage) {
      return;
    }

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

    this.updateAveragePlot(this.getSelectedYears());
    this.renderLegend();
    this.updateLegend();
  };

  public setScale = (scale: SeasonalityScale): void => {
    if (this.destroyed || 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.updateAveragePlot(this.getSelectedYears());

    this.fitCalendar();

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

  public clear = (): void => {
    if (this.destroyed) {
      return;
    }

    this.requestId += 1;

    this.reset();

    this.state.next({
      ...this.state.value,
      status: 'idle',
      tableRows: [],
      error: null,
    });
  };

  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());

    if (this.averagePlot) {
      this.averagePlot.series.applyOptions({ color: colors.chartTextPrimary });
      this.averagePlot.legendValue.style.color = colors.chartTextPrimary;
    }
  }

  public async copyImage(): Promise<void> {
    if (
      !this.chart ||
      this.state.value.status !== 'ready' ||
      this.state.value.displayMode !== SeasonalityViewType.Chart
    ) {
      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 (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>();

    const loadPage = async (cursor?: Candle): Promise<void> => {
      if (!this.isCurrent(requestId)) {
        return;
      }

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

      if (!this.isCurrent(requestId) || !loaded?.length) {
        return;
      }

      const { oldest, newestTime } = mergeHistoryPage(loaded, candles);

      if (cursor && oldest.time >= cursor.time) {
        const isBoundaryOnly = oldest.time === cursor.time && newestTime === cursor.time;

        if (isBoundaryOnly) {
          return;
        }

        throw new Error(
          `History pagination made no progress: cursor=${cursor.time}, ` +
            `oldest=${oldest.time}, newest=${newestTime}, count=${loaded.length}`,
        );
      }

      return loadPage(oldest);
    };

    await loadPage();

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

    return Array.from(candles.values(), (candle) => ({
      date: dayjs.unix(candle.time).format('YYYY-MM-DD'),
      close: candle.close,
    }));
  }

  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_SEASONALITY_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(),
      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.getSelectedYears();

    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.updateAveragePlot(years);
    this.renderLegend();

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

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

    this.state.next({
      ...this.state.value,
      status: plots.length > 0 ? 'ready' : 'empty',
      tableRows: this.tableRows.filter((row) => row.year >= fromYear && row.year <= toYear),
    });
  }

  private getSelectedYears(): SeasonalityYear[] {
    const { fromYear, toYear } = this.state.value;

    return this.allYears.filter((year) => year.year >= fromYear && year.year <= toYear);
  }

  private renderLegend(): void {
    const plots = [...this.plots.values()].sort((a, b) => a.year.year - b.year.year);
    const elements = plots.flatMap((plot) => [plot.legend, plot.legendValue]);

    if (this.averagePlot) {
      elements.push(this.averagePlot.legend, this.averagePlot.legendValue);
    }

    this.legend.replaceChildren(...elements);
  }

  private updateAveragePlot(years: readonly SeasonalityYear[]): void {
    if (!this.chart) {
      return;
    }

    if (!this.state.value.showAverage || years.length === 0) {
      if (this.averagePlot) {
        this.chart.removeSeries(this.averagePlot.series);
        this.averagePlot = null;
      }

      return;
    }

    if (!this.averagePlot) {
      const legend = document.createElement('span');
      const legendValue = document.createElement('span');
      const color = getThemeStore().colors.chartTextPrimary;

      legend.textContent = t('Average');

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

      this.averagePlot = {
        legend,
        legendValue,
        data: [],
        series: this.chart.addSeries(LineSeries, {
          title: t('Average'),
          color,
          lineStyle: LineStyle.Dashed,
          lineWidth: 2,
          priceLineVisible: false,
          lastValueVisible: true,
          pointMarkersVisible: false,
          crosshairMarkerVisible: true,
        }),
      };
    }

    this.updatePriceFormat(this.averagePlot.series);

    this.averagePlot.data = buildSeasonalityAverage(years, this.state.value.scale);
    this.averagePlot.series.setData(this.averagePlot.data);
  }

  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;

    this.updatePriceFormat(series);

    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 updatePriceFormat(series: ISeriesApi<'Line'>): void {
    const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;

    series.applyOptions({
      priceFormat: {
        type: 'custom',
        minMove: isPercentage ? getPercentPrecisionStep() : getPricePrecisionStep(),
        formatter: (value: number) => (isPercentage ? formatPercent(value) : formatCompactNumber(value)),
      },
    });
  }

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

    const average = this.averagePlot;

    if (average) {
      let value: number | null = null;

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

        value = point && 'value' in point && typeof point.value === 'number' ? point.value : null;
      } else {
        for (const point of average.data) {
          if ('value' in point) {
            value = point.value;
          }
        }
      }

      average.legend.textContent = t('Average');
      average.legendValue.textContent = value === null ? '—' : average.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.averagePlot = null;
    this.allYears = [];
    this.tableRows = [];

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

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

function mergeHistoryPage(
  loaded: Candle[],
  candles: Map<number, Candle>,
): { oldest: Candle; newestTime: number } {
  let oldest = loaded[0];
  let oldestTime = Infinity;
  let newestTime = -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 };

    newestTime = Math.max(newestTime, time);

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

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

  return { oldest, newestTime };
}

function getThemeOptions() {
  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: colors.chartCrosshairLine,
        labelBackgroundColor: colors.chartCrosshairLabel,
        style: LineStyle.Solid,
      },
      horzLine: {
        color: colors.chartCrosshairLine,
        labelBackgroundColor: 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];
}

function createSeasonalityElements(container: HTMLElement) {
  const chartContainer = document.createElement('div');
  const legend = document.createElement('div');
  const statusElement = document.createElement('div');

  Object.assign(chartContainer.style, {
    position: 'absolute',
    inset: 0,
  });

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

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

  container.append(chartContainer, legend, statusElement);

  return { chartContainer, legend, statusElement };
}

















import { PriceScaleMode } from 'lightweight-charts';

import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { CameraIcon, LineIcon, TableIcon } from '@src/components/Icon';
import { AverageIcon } from '@src/components/Icon/AverageIcon';
import { SnapshotMenu } from '@src/components/Menu';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
import { Tooltip } from '@src/components/Tooltip';

import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';

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

import type { SeasonalityRange, SeasonalityScale, SeasonalityState } from '@src/types/seasonality';
import type { Observable } from 'rxjs';

interface SeasonalityControlsProps {
  stateObs: Observable<SeasonalityState>;
  setDisplayMode: (displayMode: SeasonalityViewType) => void;
  setScale: (scale: SeasonalityScale) => void;
  setRange: (range: SeasonalityRange) => void;
  setAverageVisible: (visible: boolean) => void;
  copyImage?: () => void | Promise<void>;
}

const MAX_SCALE_LABELS = 5;

function createYearMarks(minYear: number, maxYear: number): number[] {
  const span = maxYear - minYear;

  if (span <= 0) return [minYear];

  const intervals = Math.min(MAX_SCALE_LABELS - 1, span);

  return Array.from(
    { length: intervals + 1 },
    (_, index) => minYear + Math.floor((span * index) / intervals),
  );
}

export function SeasonalityControls({
  stateObs,
  setDisplayMode,
  setScale,
  setRange,
  setAverageVisible,
  copyImage,
}: SeasonalityControlsProps) {
  const state = useObservable(stateObs);

  if (!state || state.status === 'idle') {
    return null;
  }

  const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');

  const isRangeDisabled = state.status === 'loading' || state.status === 'error';
  const canCopyImage = state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart;

  const marks = createYearMarks(state.minYear, state.maxYear);

  const handleRangeChange = ([fromYear, toYear]: [number, number]): void => {
    setRange({ fromYear, toYear });
  };

  return (
    <div className={styles.controls}>
      <div className={styles.controls_header}>
        <div className={styles.controls_views}>
          <Button
            label={<LineIcon />}
            pressed={state.displayMode === SeasonalityViewType.Chart}
            onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
          />
          <Button
            label={<TableIcon />}
            pressed={state.displayMode === SeasonalityViewType.Table}
            onClick={() => setDisplayMode(SeasonalityViewType.Table)}
          />
        </div>

        <div className={styles.controls_actions}>
          <Button
            label={
              <span className={styles.controls_buttonContent}>
                <AverageIcon />
                <span>{t('Average')}</span>
              </span>
            }
            pressed={state.showAverage}
            disabled={state.status !== 'ready'}
            onClick={() => setAverageVisible(!state.showAverage)}
          />

          <div className={styles.controls_scale}>
            <Dropdown
              selectedValue={selectedScaleLabel}
              renderTrigger={({ isOpen, onToggle }) => (
                <Button
                  pressed={isOpen}
                  onClick={onToggle}
                  label={
                    <span className={styles.controls_buttonContent}>
                      <span>{selectedScaleLabel}</span>
                      <span
                        className={`${styles.controls_chevron} ${isOpen ? styles.controls_chevronOpen : ''}`}
                      />
                    </span>
                  }
                />
              )}
            >
              <SeasonalityScaleMenu selectedScale={state.scale} onClick={setScale} />
            </Dropdown>
          </div>

          {copyImage && canCopyImage && (
            <Tooltip location="bottom" label={t('Snapshot')}>
              <Dropdown selectedValue={<CameraIcon />}>
                <SnapshotMenu onCopyImage={copyImage} />
              </Dropdown>
            </Tooltip>
          )}
        </div>
      </div>

      <div className={styles.controls_range}>
        <RangeField
          mode="range"
          value={[state.fromYear, state.toYear]}
          min={state.minYear}
          max={state.maxYear}
          marks={marks}
          step={1}
          suffix=""
          size="sm"
          disabled={isRangeDisabled}
          onRangeChange={handleRangeChange}
        />
      </div>
    </div>
  );
}

















.controls {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: var(--space-0500);

  &_header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--space-0500);
    min-width: 0;
  }

  &_views {
    display: flex;
    align-items: center;
    gap: var(--space-0125);
    border: var(--borderWidth-md) solid var(--neutral-7);
    border-radius: var(--space-0250);
  }

  &_scale {
    border: var(--borderWidth-md) solid var(--neutral-7);
    border-radius: var(--space-0250);
  }

  &_actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    flex-wrap: wrap;
    gap: var(--space-0250);
    margin-left: auto;
  }

  &_buttonContent {
    display: inline-flex;
    align-items: center;
    gap: var(--space-0250);
    white-space: nowrap;
  }

  &_chevron {
    display: inline-block;
    width: var(--space-0375, 6px);
    height: var(--space-0375, 6px);
    border-right: var(--borderWidth-md) solid currentColor;
    border-bottom: var(--borderWidth-md) solid currentColor;
    transform: translateY(-1px) rotate(45deg);
  }

  &_chevronOpen {
    transform: translateY(1px) rotate(225deg);
  }

  &_range {
    min-width: 0;
  }
}

















import { PriceScaleMode } from 'lightweight-charts';
import { useMemo } from 'react';

import { Table, type TableColumn } from '@src/components/Table';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { getPercentPrecision, getPricePrecision, useObservable } from '@src/utils';
import { buildSeasonalityTableAverage } from '@src/utils/seasonality';

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

import type { SeasonalityChange, SeasonalityState, SeasonalityTableRow } from '@src/types';
import type { ReactNode } from 'react';
import type { Observable } from 'rxjs';

const MONTHS = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
] as const;

interface SeasonalityTableProps {
  stateObs: Observable<SeasonalityState>;
}

type SeasonalityDisplayRow = Pick<SeasonalityTableRow, 'months' | 'total'> & {
  year: number | 'average';
};

export function SeasonalityTable({ stateObs }: SeasonalityTableProps) {
  const state = useObservable(stateObs);
  const locale = getLocale();
  const fractionDigits =
    state?.scale === PriceScaleMode.Percentage ? getPercentPrecision() : getPricePrecision();

  const formatter = useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: fractionDigits,
        maximumFractionDigits: fractionDigits,
      }),
    [locale, fractionDigits],
  );

  const rows = useMemo<SeasonalityDisplayRow[]>(() => {
    const tableRows = state?.tableRows ?? [];

    if (!state?.showAverage) {
      return tableRows;
    }

    const average = buildSeasonalityTableAverage(tableRows);

    return average ? [...tableRows, { year: 'average', ...average }] : tableRows;
  }, [state?.tableRows, state?.showAverage]);

  if (!state || state.status === 'idle' || state.displayMode !== SeasonalityViewType.Table) {
    return null;
  }

  const valueKey = state.scale === PriceScaleMode.Percentage ? 'percentage' : 'absolute';
  const suffix = state.scale === PriceScaleMode.Percentage ? '%' : '';

  const formatChange = (change: SeasonalityChange | null): string => {
    const value = change?.[valueKey] ?? null;

    return value === null ? '—' : `${formatter.format(value)}${suffix}`;
  };

  const getChangeClassName = (change: SeasonalityChange | null): string | undefined => {
    const value = change?.[valueKey] ?? null;

    if (value !== null && value > 0) {
      return styles.positive;
    }

    if (value !== null && value < 0) {
      return styles.negative;
    }

    return undefined;
  };

  const createChangeColumn = (
    id: string,
    header: ReactNode,
    getChange: (row: SeasonalityDisplayRow) => SeasonalityChange | null,
    className?: string,
  ): TableColumn<SeasonalityDisplayRow> => ({
    id,
    header,
    align: 'right',
    className,
    renderCell: (row) => formatChange(getChange(row)),
    getCellClassName: (row) =>
      [getChangeClassName(getChange(row)), row.year === 'average' && styles.average]
        .filter(Boolean)
        .join(' ') || undefined,
  });

  const columns: TableColumn<SeasonalityDisplayRow>[] = [
    {
      id: 'date',
      header: t('Date'),
      rowHeader: true,
      renderCell: (row) => (row.year === 'average' ? t('Average') : row.year),
      getCellClassName: (row) => (row.year === 'average' ? styles.average : undefined),
    },
    ...MONTHS.map((month, index) => createChangeColumn(month, t(month), (row) => row.months[index])),
    createChangeColumn('year', t('Year'), (row) => row.total, styles.total),
  ];

  const emptyContent = getEmptyContent(state.status);

  return (
    <Table
      className={styles.seasonality}
      columns={columns}
      rows={rows}
      getRowKey={(row) => row.year}
      emptyContent={emptyContent}
      stickyFirstColumn
    />
  );
}

function getEmptyContent(status: SeasonalityState['status']): string {
  if (status === 'loading') {
    return t('Loading seasonality');
  }

  if (status === 'error') {
    return t('Unable to load seasonality');
  }

  return t('No seasonality data');
}

















[data-theme='mxt'] {
  --cell-background-positive: #24a249;
  --cell-background-negative: #d71d38;
}

[data-theme='mb'] {
  --cell-background-positive: #3cae0b;
  --cell-background-negative: #d71d38;
}

[data-theme='tr'] {
  --cell-background-positive: #27ef0085;
  --cell-background-negative: #ff000085;
}

.seasonality {
  font-variant-numeric: tabular-nums;
}

.positive {
  --table-cell-background: color-mix(
    in srgb,
    var(--cell-background-positive) 35%,
    var(--table-background, var(--neutral-0))
  );
}

.negative {
  --table-cell-background: color-mix(
    in srgb,
    var(--cell-background-negative) 35%,
    var(--table-background, var(--neutral-0))
  );
}

.total {
  --table-cell-font-weight: 600;
}

.average {
  --table-cell-font-weight: 600;

  border-top: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
}

















import classNames from 'classnames';
import { type ChangeEvent, type PointerEvent, useId, useRef } from 'react';

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

interface CommonRangeFieldProps {
  label?: string;
  min?: number;
  max?: number;
  step?: number;
  color?: string;
  suffix?: string;
  size?: 'sm' | 'md';
  disabled?: boolean;
}

interface SingleRangeFieldProps extends CommonRangeFieldProps {
  mode?: 'single';
  value: number;
  onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}

interface IntervalRangeFieldProps extends CommonRangeFieldProps {
  mode: 'range';
  value: [number, number];
  marks?: readonly number[];
  onRangeChange: (value: [number, number]) => void;
}

interface RangeDragState {
  pointerId: number;
  pointerX: number;
  from: number;
  to: number;
  trackWidth: number;
}

type RangeFieldProps = SingleRangeFieldProps | IntervalRangeFieldProps;

export const RangeField = (props: RangeFieldProps) => {
  const {
    label,
    min = 0,
    max = 100,
    step = 1,
    color = 'var(--blue-1)',
    suffix = '%',
    size = 'md',
    disabled = false,
  } = props;

  const inputId = useId();
  const trackRef = useRef<HTMLDivElement>(null);
  const dragRef = useRef<RangeDragState | null>(null);

  if (props.mode !== 'range') {
    return (
      <div
        className={classNames(styles.range, {
          [styles.range_sm]: size === 'sm',
        })}
      >
        {label && (
          <label htmlFor={inputId} className={styles.range_label}>
            {label}
          </label>
        )}

        <div className={styles.range_wrapper}>
          <input
            id={inputId}
            type="range"
            min={min}
            max={max}
            step={step}
            value={props.value}
            className={styles.range_input}
            style={{ ['--range-color' as string]: color }}
            onChange={props.onChange}
            disabled={disabled}
          />

          <span className={styles.range_value}>
            {props.value}
            {suffix}
          </span>
        </div>
      </div>
    );
  }

  const {
    value: [from, to],
    marks = [],
    onRangeChange,
  } = props;

  const span = max - min;
  const left = span > 0 ? ((from - min) / span) * 100 : 0;
  const right = span > 0 ? ((to - min) / span) * 100 : 0;
  const isDisabled = disabled || span <= 0;

  const scaleMarks =
    span > 0
      ? [
          min,
          ...Array.from(new Set(marks))
            .filter((value) => value > min && value < max)
            .sort((a, b) => a - b),
          max,
        ]
      : [min];

  const handleDragEnd = (event: PointerEvent<HTMLElement>) => {
    if (dragRef.current?.pointerId === event.pointerId) {
      dragRef.current = null;
    }
  };

  const handleDragStart = (event: PointerEvent<HTMLElement>) => {
    if (isDisabled || event.button !== 0 || dragRef.current || !trackRef.current) return;

    const { width } = trackRef.current.getBoundingClientRect();

    if (width <= 0) return;

    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);

    dragRef.current = {
      pointerId: event.pointerId,
      pointerX: event.clientX,
      from,
      to,
      trackWidth: width,
    };
  };

  const handleDragMove = (event: PointerEvent<HTMLElement>) => {
    const drag = dragRef.current;

    if (!drag || drag.pointerId !== event.pointerId || isDisabled) return;

    const pointerOffset = ((event.clientX - drag.pointerX) / drag.trackWidth) * span;
    const steppedOffset = Math.round(pointerOffset / step) * step;
    const offset = Math.max(min - drag.from, Math.min(max - drag.to, steppedOffset));

    const nextFrom = drag.from + offset;
    const nextTo = drag.to + offset;

    if (nextFrom !== from || nextTo !== to) {
      onRangeChange([nextFrom, nextTo]);
    }
  };

  const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (
      isDisabled ||
      from !== to ||
      event.button !== 0 ||
      event.defaultPrevented ||
      event.target instanceof HTMLInputElement ||
      !trackRef.current
    ) {
      return;
    }

    const { left: trackLeft, width } = trackRef.current.getBoundingClientRect();

    if (width <= 0) return;

    const value = min + Math.round((((event.clientX - trackLeft) / width) * span) / step) * step;
    const next = Math.max(min, Math.min(max, value));

    event.preventDefault();

    if (next !== from) {
      onRangeChange([Math.min(from, next), Math.max(to, next)]);
    }
  };

  const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (dragRef.current) return;

    onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
  };

  const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
    if (dragRef.current) return;

    onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
  };

  const dragHandlers = {
    onPointerMove: handleDragMove,
    onPointerUp: handleDragEnd,
    onPointerCancel: handleDragEnd,
    onLostPointerCapture: handleDragEnd,
  };

  return (
    <div
      className={classNames(styles.range, styles.range_interval, {
        [styles.range_sm]: size === 'sm',
        [styles.range_disabled]: isDisabled,
      })}
    >
      {label && <span className={styles.range_label}>{label}</span>}

      <div
        ref={trackRef}
        className={styles.range_track}
        style={{ ['--range-color' as string]: color }}
        onPointerDown={handleTrackPointerDown}
      >
        <div className={styles.range_rail} />

        <div
          className={styles.range_selection}
          style={{
            left: `${left}%`,
            width: `${right - left}%`,
          }}
          onPointerDown={handleDragStart}
          {...dragHandlers}
        />

        <input
          id={`${inputId}-from`}
          type="range"
          min={min}
          max={max}
          step={step}
          value={from}
          disabled={isDisabled}
          className={classNames(styles.range_handle, styles.range_handle_start, {
            [styles.range_handle_behind]: from === to && from === min,
          })}
          onChange={handleFromChange}
          onPointerDown={from === to ? handleDragStart : undefined}
          {...dragHandlers}
        />

        <input
          id={`${inputId}-to`}
          type="range"
          min={min}
          max={max}
          step={step}
          value={to}
          disabled={isDisabled}
          className={classNames(styles.range_handle, styles.range_handle_end)}
          onChange={handleToChange}
          onPointerDown={from === to ? handleDragStart : undefined}
          {...dragHandlers}
        />
      </div>

      <div className={styles.range_scale}>
        {scaleMarks.map((value) => (
          <span
            key={value}
            className={styles.range_scale_label}
            style={{
              left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
            }}
          >
            {value}
            {suffix}
          </span>
        ))}
      </div>
    </div>
  );
};

















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

import type { HeaderControlsProps } from './types';

export function SeasonalityHeader({ viewControl }: HeaderControlsProps) {
  return <div className={styles.group}>{viewControl}</div>;
}