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


import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';

import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface RegressionTrendStyle {
  lineColor: string;
  middleLineColor: string;
  upperFillColor: string;
  lowerFillColor: string;
  showMiddleLine: boolean;
  showCorrelation: boolean;
}

export type RegressionTrendSettings = SettingsValues & RegressionTrendStyle;

export function createDefaultSettings(): RegressionTrendSettings {
  const { colors } = getThemeStore();

  return {
    lineColor: colors.chartLineColor,
    middleLineColor: colors.chartCandleDown,
    upperFillColor: colors.sliderPositiveFill,
    lowerFillColor: colors.sliderNegativeFill,
    showMiddleLine: true,
    showCorrelation: true,
  };
}

export function getRegressionTrendSettingsTabs(
  settings: RegressionTrendSettings,
): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Channel line color'),
      type: 'color',
      defaultValue: settings.lineColor,
    },
    {
      key: 'middleLineColor',
      label: t('Middle line color'),
      type: 'color',
      defaultValue: settings.middleLineColor,
    },
    {
      key: 'upperFillColor',
      label: t('Upper zone color'),
      type: 'color',
      defaultValue: settings.upperFillColor,
    },
    {
      key: 'lowerFillColor',
      label: t('Lower zone color'),
      type: 'color',
      defaultValue: settings.lowerFillColor,
    },
    {
      key: 'showMiddleLine',
      label: t('Show middle line'),
      type: 'boolean',
      defaultValue: settings.showMiddleLine,
    },
    {
      key: 'showCorrelation',
      label: t('Show correlation'),
      type: 'boolean',
      defaultValue: settings.showCorrelation,
    },
  ];

  return [
    {
      key: 'style',
      label: t('Style'),
      fields: styleFields,
    },
  ];
}


'Channel line color': 'Цвет границ канала',
'Middle line color': 'Цвет средней линии',
'Upper zone color': 'Цвет верхней зоны',
'Lower zone color': 'Цвет нижней зоны',
'Show middle line': 'Показывать среднюю линию',
'Show correlation': 'Показывать корреляцию',


import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { RegressionTrendPaneView } from './paneView';
import {
  createDefaultSettings,
  getRegressionTrendSettingsTabs,
  RegressionTrendSettings,
  RegressionTrendStyle,
} from './settings';

import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
  IChartApi,
  IPrimitivePaneView,
  PrimitiveHoveredItem,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

type RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';

type DragTarget = 'start' | 'end' | 'body' | null;

type TimeLabelKind = 'start' | 'end';

type PriceLabelKind = 'start' | 'end';

interface RegressionTrendParams {
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  openSettings?: () => void;
}

interface RegressionTrendState {
  hidden: boolean;
  mode: RegressionTrendMode;
  startTime: Time | null;
  endTime: Time | null;
  settings: RegressionTrendSettings;
}

interface RegressionSeriesData {
  time: Time;
  close?: number;
  value?: number;
}

interface RegressionResult {
  baseStartPrice: number;
  baseEndPrice: number;

  upperStartPrice: number;
  upperEndPrice: number;

  lowerStartPrice: number;
  lowerEndPrice: number;

  correlation: number;
}

interface RegressionTrendGeometry {
  baseStartPoint: Point;
  baseEndPoint: Point;

  upperStartPoint: Point;
  upperEndPoint: Point;

  lowerStartPoint: Point;
  lowerEndPoint: Point;

  baseStartPrice: number;
  baseEndPrice: number;

  correlation: number;

  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface RegressionTrendRenderData
  extends RegressionTrendGeometry,
    RegressionTrendStyle {
  showChannel: boolean;
  showHandles: boolean;
}

const HIT_TOLERANCE = 8;

const MIN_BAR_DISTANCE = 1;

// Ширина regression channel.
// Пока это часть самого инструмента, а не пользовательская настройка.
const REGRESSION_DEVIATION = 2;

export class RegressionTrend
  extends SeriesDrawingBase<RegressionTrendSettings>
  implements ISeriesDrawing
{
  private openSettings?: () => void;

  protected settings: RegressionTrendSettings = createDefaultSettings();

  protected mode: RegressionTrendMode = 'idle';

  private startTime: Time | null = null;
  private endTime: Time | null = null;

  private activeDragTarget: DragTarget = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: RegressionTrendState | null = null;

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  private paneView: RegressionTrendPaneView;

  private timeAxisPaneView: CustomTimeAxisPaneView;
  private priceAxisPaneView: CustomPriceAxisPaneView;

  private startTimeAxisView: CustomTimeAxisView;
  private endTimeAxisView: CustomTimeAxisView;

  private startPriceAxisView: CustomPriceAxisView;
  private endPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    {
      container,
      interaction,
      formatObservable,
      openSettings,
    }: RegressionTrendParams,
  ) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.openSettings = openSettings;

    this.paneView = new RegressionTrendPaneView(this);

    this.timeAxisPaneView = new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
    });

    this.priceAxisPaneView = new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
    });

    this.startTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'start',
    });

    this.endTimeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'end',
    });

    this.startPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'start',
    });

    this.endPriceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'end',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    this.series.attachPrimitive(this);
  }

  public isCreationPending(): boolean {
    return this.mode === 'idle' || this.mode === 'drawing';
  }

  public getState(): RegressionTrendState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }

    const next = state as Partial<RegressionTrendState>;

    this.hidden = next.hidden ?? this.hidden;

    if (next.mode) {
      this.mode = next.mode === 'dragging' ? 'ready' : next.mode;
    }

    if ('startTime' in next) {
      this.startTime = next.startTime ?? null;
    }

    if ('endTime' in next) {
      this.endTime = next.endTime ?? null;
    }

    if (next.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...next.settings,
      };
    }

    this.render();
  }

  public getSettingsTabs(): SettingsTab[] {
    return getRegressionTrendSettingsTabs(this.settings);
  }

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.startTimeAxisView,
      this.endTimeAxisView,
      this.startPriceAxisView,
      this.endPriceAxisView,
    ]);
  }

  public paneViews(): readonly IPrimitivePaneView[] {
    return [this.paneView];
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [this.timeAxisPaneView];
  }

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

  public timeAxisViews() {
    return [this.startTimeAxisView, this.endTimeAxisView];
  }

  public priceAxisViews() {
    return [this.startPriceAxisView, this.endPriceAxisView];
  }

  public getRenderData(): RegressionTrendRenderData | null {
    if (this.hidden) {
      return null;
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
      showChannel: this.mode === 'ready' || this.mode === 'dragging',
      showHandles: this.shouldShowHandles(),
    };
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.mode !== 'ready') {
      return null;
    }

    const point = { x, y };

    if (!this.isSelected()) {
      if (!this.containsPoint(point)) {
        return null;
      }

      return {
        cursorStyle: 'pointer',
        externalId: 'regression-trend',
        zOrder: 'top',
      };
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      return null;
    }

    return {
      cursorStyle: dragTarget === 'body' ? 'grab' : 'ew-resize',
      externalId: 'regression-trend',
      zOrder: 'top',
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    if (!this.isSelected() && !this.isCreationPending()) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.left,
        to: geometry.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    if (!this.isSelected() && !this.isCreationPending()) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: Math.min(
          geometry.baseStartPoint.y,
          geometry.baseEndPoint.y,
        ),
        to: Math.max(
          geometry.baseStartPoint.y,
          geometry.baseEndPoint.y,
        ),
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (
      (!this.isSelected() && !this.isCreationPending()) ||
      (kind !== 'start' && kind !== 'end')
    ) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;

    const time = labelKind === 'start' ? this.startTime : this.endTime;

    if (typeof time !== 'number') {
      return null;
    }

    const coordinate = getXCoordinateFromTime(
      this.chart,
      time,
      this.series,
    );

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        time as UTCTimestamp,
        this.displayFormat.dateFormat,
        this.displayFormat.timeFormat,
        this.displayFormat.showTime,
      ),
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (
      (!this.isSelected() && !this.isCreationPending()) ||
      (kind !== 'start' && kind !== 'end')
    ) {
      return null;
    }

    const labelKind = kind as PriceLabelKind;

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const price =
      labelKind === 'start'
        ? geometry.baseStartPrice
        : geometry.baseEndPrice;

    const coordinate = getYCoordinateFromPrice(
      this.series,
      price,
    );

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatPrice(price) ?? '',
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (
      this.hidden ||
      this.mode !== 'ready' ||
      !this.isSelected()
    ) {
      return;
    }

    const point = this.getEventPoint(event as PointerEvent);

    if (!this.containsPoint(point)) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.openSettings?.();
  };

  protected handlePointerDown = (event: PointerEvent): void => {
    if (this.hidden || event.button !== 0) {
      return;
    }

    const point = this.getEventPoint(event);

    if (this.mode === 'idle') {
      const time = this.getBarTime(point.x);

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

      event.preventDefault();
      event.stopPropagation();

      this.startTime = time;
      this.endTime = time;
      this.mode = 'drawing';

      this.render();

      return;
    }

    if (this.mode === 'drawing') {
      const time = this.getBarTime(point.x);

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

      event.preventDefault();
      event.stopPropagation();

      this.endTime = time;

      if (!this.hasValidRange()) {
        this.render();

        return;
      }

      this.mode = 'ready';
      this.resolveReady?.();

      this.render();

      return;
    }

    if (this.mode !== 'ready') {
      return;
    }

    if (!this.isSelected()) {
      if (!this.containsPoint(point)) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      this.select();

      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.deselect();

      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();

    this.mode = 'dragging';

    this.hideCrosshair();

    this.render();
  };

  protected handlePointerMove = (event: PointerEvent): void => {
    const point = this.getEventPoint(event);

    if (this.mode === 'drawing') {
      const time = this.getBarTime(point.x);

      if (time !== null) {
        this.endTime = time;
        this.render();
      }

      return;
    }

    if (
      this.mode !== 'dragging' ||
      this.dragPointerId !== event.pointerId ||
      !this.dragStateSnapshot
    ) {
      return;
    }

    event.preventDefault();

    this.applyDrag(point);

    this.render();
  };

  protected handlePointerUp = (event: PointerEvent): void => {
    if (
      this.mode !== 'dragging' ||
      this.dragPointerId !== event.pointerId
    ) {
      return;
    }

    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;

    this.mode = 'ready';

    this.showCrosshair();

    this.render();
  };

  protected getGeometry(): RegressionTrendGeometry | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

    const regression = this.getRegression();

    if (!regression) {
      return null;
    }

    const baseStartPoint = this.getPoint(
      this.startTime,
      regression.baseStartPrice,
    );

    const baseEndPoint = this.getPoint(
      this.endTime,
      regression.baseEndPrice,
    );

    const upperStartPoint = this.getPoint(
      this.startTime,
      regression.upperStartPrice,
    );

    const upperEndPoint = this.getPoint(
      this.endTime,
      regression.upperEndPrice,
    );

    const lowerStartPoint = this.getPoint(
      this.startTime,
      regression.lowerStartPrice,
    );

    const lowerEndPoint = this.getPoint(
      this.endTime,
      regression.lowerEndPrice,
    );

    if (
      !baseStartPoint ||
      !baseEndPoint ||
      !upperStartPoint ||
      !upperEndPoint ||
      !lowerStartPoint ||
      !lowerEndPoint
    ) {
      return null;
    }

    const points = [
      upperStartPoint,
      upperEndPoint,
      lowerStartPoint,
      lowerEndPoint,
    ];

    return {
      baseStartPoint,
      baseEndPoint,

      upperStartPoint,
      upperEndPoint,

      lowerStartPoint,
      lowerEndPoint,

      baseStartPrice: regression.baseStartPrice,
      baseEndPrice: regression.baseEndPrice,

      correlation: regression.correlation,

      left: Math.min(...points.map((item) => item.x)),
      right: Math.max(...points.map((item) => item.x)),
      top: Math.min(...points.map((item) => item.y)),
      bottom: Math.max(...points.map((item) => item.y)),
    };
  }

  private applyDrag(point: Point): void {
    const snapshot = this.dragStateSnapshot;

    if (
      !snapshot ||
      snapshot.startTime === null ||
      snapshot.endTime === null
    ) {
      return;
    }

    switch (this.activeDragTarget) {
      case 'start':
        this.moveEdge('start', point);
        break;

      case 'end':
        this.moveEdge('end', point);
        break;

      case 'body':
        this.moveWhole(snapshot, point);
        break;

      default:
        break;
    }
  }

  private moveEdge(kind: TimeLabelKind, point: Point): void {
    const time = this.getBarTime(point.x);

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

    const previousTime =
      kind === 'start'
        ? this.startTime
        : this.endTime;

    if (kind === 'start') {
      this.startTime = time;
    } else {
      this.endTime = time;
    }

    if (this.hasValidRange()) {
      return;
    }

    if (kind === 'start') {
      this.startTime = previousTime;
    } else {
      this.endTime = previousTime;
    }
  }

  private moveWhole(
    snapshot: RegressionTrendState,
    point: Point,
  ): void {
    if (
      !this.dragStartPoint ||
      snapshot.startTime === null ||
      snapshot.endTime === null
    ) {
      return;
    }

    const data = this.getSeriesData();

    const dragStartIndex = this.getBarIndexByX(
      this.dragStartPoint.x,
      data,
    );

    const currentIndex = this.getBarIndexByX(
      point.x,
      data,
    );

    const startIndex = this.getBarIndex(
      snapshot.startTime,
      data,
    );

    const endIndex = this.getBarIndex(
      snapshot.endTime,
      data,
    );

    if (
      dragStartIndex === null ||
      currentIndex === null ||
      startIndex === null ||
      endIndex === null
    ) {
      return;
    }

    const rawOffset = currentIndex - dragStartIndex;

    const minIndex = Math.min(startIndex, endIndex);
    const maxIndex = Math.max(startIndex, endIndex);

    const minOffset = -minIndex;
    const maxOffset = data.length - 1 - maxIndex;

    const offset = Math.max(
      minOffset,
      Math.min(rawOffset, maxOffset),
    );

    this.startTime = data[startIndex + offset].time;
    this.endTime = data[endIndex + offset].time;
  }

  private getRegression(): RegressionResult | null {
    const data = this.getSeriesData();
    const range = this.getBarRange(data);

    if (!range) {
      return null;
    }

    const values: number[] = [];

    for (
      let index = range.left;
      index <= range.right;
      index += 1
    ) {
      const value = getSeriesValue(data[index]);

      if (value !== null) {
        values.push(value);
      }
    }

    if (values.length < 2) {
      return null;
    }

    const regression = calculateRegression(values);

    const offset =
      regression.deviation *
      REGRESSION_DEVIATION;

    const baseStartPrice =
      range.reversed
        ? regression.endValue
        : regression.startValue;

    const baseEndPrice =
      range.reversed
        ? regression.startValue
        : regression.endValue;

    return {
      baseStartPrice,
      baseEndPrice,

      upperStartPrice:
        baseStartPrice + offset,

      upperEndPrice:
        baseEndPrice + offset,

      lowerStartPrice:
        baseStartPrice - offset,

      lowerEndPrice:
        baseEndPrice - offset,

      correlation: regression.correlation,
    };
  }

  private getBarRange(
    data = this.getSeriesData(),
  ): {
    left: number;
    right: number;
    reversed: boolean;
  } | null {
    if (
      this.startTime === null ||
      this.endTime === null
    ) {
      return null;
    }

    const startIndex = this.getBarIndex(
      this.startTime,
      data,
    );

    const endIndex = this.getBarIndex(
      this.endTime,
      data,
    );

    if (
      startIndex === null ||
      endIndex === null
    ) {
      return null;
    }

    return {
      left: Math.min(startIndex, endIndex),
      right: Math.max(startIndex, endIndex),
      reversed: startIndex > endIndex,
    };
  }

  private hasValidRange(): boolean {
    const range = this.getBarRange();

    if (!range) {
      return false;
    }

    return (
      range.right -
        range.left >=
      MIN_BAR_DISTANCE
    );
  }

  private getBarTime(x: number): Time | null {
    const time = getTimeFromXCoordinate(
      this.chart,
      x,
    );

    if (typeof time !== 'number') {
      return null;
    }

    const data = this.getSeriesData();

    const index = findNearestBarIndex(
      data,
      time,
    );

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

    return data[index].time;
  }

  private getBarIndexByX(
    x: number,
    data: readonly RegressionSeriesData[],
  ): number | null {
    const time = getTimeFromXCoordinate(
      this.chart,
      x,
    );

    if (typeof time !== 'number') {
      return null;
    }

    return findNearestBarIndex(
      data,
      time,
    );
  }

  private getBarIndex(
    time: Time,
    data: readonly RegressionSeriesData[],
  ): number | null {
    if (typeof time !== 'number') {
      return null;
    }

    return findNearestBarIndex(
      data,
      time,
    );
  }

  private getSeriesData(): readonly RegressionSeriesData[] {
    return this.series.data() as readonly RegressionSeriesData[];
  }

  private getPoint(
    time: Time,
    price: number,
  ): Point | null {
    const x = getXCoordinateFromTime(
      this.chart,
      time,
      this.series,
    );

    const y = getYCoordinateFromPrice(
      this.series,
      price,
    );

    if (x === null || y === null) {
      return null;
    }

    return {
      x: Number(x),
      y: Number(y),
    };
  }

  private getDragTarget(point: Point): DragTarget {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    if (
      isNearPoint(
        point,
        geometry.baseStartPoint.x,
        geometry.baseStartPoint.y,
        HIT_TOLERANCE,
      )
    ) {
      return 'start';
    }

    if (
      isNearPoint(
        point,
        geometry.baseEndPoint.x,
        geometry.baseEndPoint.y,
        HIT_TOLERANCE,
      )
    ) {
      return 'end';
    }

    if (this.containsPoint(point)) {
      return 'body';
    }

    return null;
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    if (
      getDistanceToSegment(
        point,
        geometry.upperStartPoint,
        geometry.upperEndPoint,
      ) <= HIT_TOLERANCE
    ) {
      return true;
    }

    if (
      getDistanceToSegment(
        point,
        geometry.lowerStartPoint,
        geometry.lowerEndPoint,
      ) <= HIT_TOLERANCE
    ) {
      return true;
    }

    if (
      getDistanceToSegment(
        point,
        geometry.baseStartPoint,
        geometry.baseEndPoint,
      ) <= HIT_TOLERANCE
    ) {
      return true;
    }

    return isPointInPolygon(
      point,
      [
        geometry.upperStartPoint,
        geometry.upperEndPoint,
        geometry.lowerEndPoint,
        geometry.lowerStartPoint,
      ],
    );
  }
}

function calculateRegression(
  values: readonly number[],
): {
  startValue: number;
  endValue: number;
  deviation: number;
  correlation: number;
} {
  const count = values.length;

  const meanX = (count - 1) / 2;

  const meanY =
    values.reduce(
      (sum, value) => sum + value,
      0,
    ) / count;

  let sumXX = 0;
  let sumXY = 0;
  let sumYY = 0;

  for (
    let index = 0;
    index < count;
    index += 1
  ) {
    const x = index - meanX;
    const y = values[index] - meanY;

    sumXX += x * x;
    sumXY += x * y;
    sumYY += y * y;
  }

  const slope =
    sumXX === 0
      ? 0
      : sumXY / sumXX;

  const intercept =
    meanY -
    slope * meanX;

  let residualSum = 0;

  for (
    let index = 0;
    index < count;
    index += 1
  ) {
    const expected =
      intercept +
      slope * index;

    const residual =
      values[index] -
      expected;

    residualSum +=
      residual *
      residual;
  }

  const denominator =
    Math.sqrt(
      sumXX *
      sumYY,
    );

  return {
    startValue: intercept,

    endValue:
      intercept +
      slope *
        (count - 1),

    deviation:
      Math.sqrt(
        residualSum /
        count,
      ),

    correlation:
      denominator === 0
        ? 0
        : sumXY /
          denominator,
  };
}

function getSeriesValue(
  data: RegressionSeriesData,
): number | null {
  if (
    typeof data.close === 'number' &&
    Number.isFinite(data.close)
  ) {
    return data.close;
  }

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

  return null;
}

function findNearestBarIndex(
  data: readonly RegressionSeriesData[],
  targetTime: number,
): number | null {
  if (!data.length) {
    return null;
  }

  let left = 0;
  let right = data.length - 1;

  while (left <= right) {
    const middle = Math.floor(
      (left + right) / 2,
    );

    const time = data[middle].time;

    if (typeof time !== 'number') {
      return findNearestBarIndexLinear(
        data,
        targetTime,
      );
    }

    if (time === targetTime) {
      return middle;
    }

    if (time < targetTime) {
      left = middle + 1;
    } else {
      right = middle - 1;
    }
  }

  const nextIndex = Math.min(
    left,
    data.length - 1,
  );

  const previousIndex = Math.max(
    0,
    nextIndex - 1,
  );

  const nextTime = data[nextIndex].time;
  const previousTime = data[previousIndex].time;

  if (
    typeof nextTime !== 'number' ||
    typeof previousTime !== 'number'
  ) {
    return findNearestBarIndexLinear(
      data,
      targetTime,
    );
  }

  return Math.abs(previousTime - targetTime) <=
    Math.abs(nextTime - targetTime)
    ? previousIndex
    : nextIndex;
}

function findNearestBarIndexLinear(
  data: readonly RegressionSeriesData[],
  targetTime: number,
): number | null {
  let result: number | null = null;
  let minDistance = Number.POSITIVE_INFINITY;

  data.forEach((item, index) => {
    if (typeof item.time !== 'number') {
      return;
    }

    const distance = Math.abs(
      item.time -
      targetTime,
    );

    if (distance >= minDistance) {
      return;
    }

    minDistance = distance;
    result = index;
  });

  return result;
}

function getDistanceToSegment(
  point: Point,
  start: Point,
  end: Point,
): number {
  const deltaX = end.x - start.x;
  const deltaY = end.y - start.y;

  if (deltaX === 0 && deltaY === 0) {
    return Math.hypot(
      point.x - start.x,
      point.y - start.y,
    );
  }

  const ratio = Math.max(
    0,
    Math.min(
      1,
      (
        (point.x - start.x) *
          deltaX +
        (point.y - start.y) *
          deltaY
      ) /
        (
          deltaX *
            deltaX +
          deltaY *
            deltaY
        ),
    ),
  );

  const x =
    start.x +
    ratio * deltaX;

  const y =
    start.y +
    ratio * deltaY;

  return Math.hypot(
    point.x - x,
    point.y - y,
  );
}

function isPointInPolygon(
  point: Point,
  polygon: Point[],
): boolean {
  let inside = false;

  for (
    let index = 0,
      previousIndex = polygon.length - 1;
    index < polygon.length;
    previousIndex = index,
      index += 1
  ) {
    const current = polygon[index];
    const previous = polygon[previousIndex];

    const intersects =
      current.y > point.y !==
        previous.y > point.y &&
      point.x <
        (
          (previous.x - current.x) *
          (point.y - current.y)
        ) /
          (previous.y - current.y) +
          current.x;

    if (intersects) {
      inside = !inside;
    }
  }

  return inside;
}


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { getThemeStore } from '@src/theme';

import type { Point } from '@core/Drawings/types';
import type { RegressionTrend } from './regressionTrend';

const UI = {
  lineWidth: 2,
  middleLineWidth: 1,

  middleLineDash: 5,
  middleLineGap: 4,

  handleRadius: 5,
  handleBorderWidth: 2,

  correlationFontSize: 11,
  correlationOffset: 6,
};

export class RegressionTrendPaneRenderer
  implements IPrimitivePaneRenderer
{
  private readonly regressionTrend: RegressionTrend;

  constructor(regressionTrend: RegressionTrend) {
    this.regressionTrend = regressionTrend;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.regressionTrend.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(
      ({
        context,
        horizontalPixelRatio,
        verticalPixelRatio,
      }) => {
        const pixelRatio = Math.max(
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const baseStart = scalePoint(
          data.baseStartPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const baseEnd = scalePoint(
          data.baseEndPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const upperStart = scalePoint(
          data.upperStartPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const upperEnd = scalePoint(
          data.upperEndPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const lowerStart = scalePoint(
          data.lowerStartPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        const lowerEnd = scalePoint(
          data.lowerEndPoint,
          horizontalPixelRatio,
          verticalPixelRatio,
        );

        context.save();

        if (data.showChannel) {
          drawArea(
            context,
            upperStart,
            upperEnd,
            baseEnd,
            baseStart,
            data.upperFillColor,
          );

          drawArea(
            context,
            baseStart,
            baseEnd,
            lowerEnd,
            lowerStart,
            data.lowerFillColor,
          );

          context.strokeStyle =
            data.lineColor;

          context.lineWidth =
            UI.lineWidth *
            pixelRatio;

          drawLine(
            context,
            upperStart,
            upperEnd,
          );

          drawLine(
            context,
            lowerStart,
            lowerEnd,
          );
        }

        if (
          data.showMiddleLine ||
          !data.showChannel
        ) {
          context.save();

          context.strokeStyle =
            data.middleLineColor;

          context.lineWidth =
            UI.middleLineWidth *
            pixelRatio;

          context.setLineDash([
            UI.middleLineDash *
              pixelRatio,
            UI.middleLineGap *
              pixelRatio,
          ]);

          drawLine(
            context,
            baseStart,
            baseEnd,
          );

          context.restore();
        }

        if (
          data.showChannel &&
          data.showCorrelation
        ) {
          drawCorrelation(
            context,
            data.correlation,
            lowerStart,
            data.middleLineColor,
            verticalPixelRatio,
          );
        }

        if (data.showHandles) {
          const { colors } = getThemeStore();

          context.fillStyle =
            colors.chartBackground;

          context.strokeStyle =
            colors.chartLineColor;

          context.lineWidth =
            UI.handleBorderWidth *
            pixelRatio;

          const radius =
            UI.handleRadius *
            pixelRatio;

          drawHandle(
            context,
            baseStart,
            radius,
          );

          drawHandle(
            context,
            baseEnd,
            radius,
          );
        }

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

function scalePoint(
  point: Point,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
): Point {
  return {
    x:
      point.x *
      horizontalPixelRatio,

    y:
      point.y *
      verticalPixelRatio,
  };
}

function drawLine(
  context: CanvasRenderingContext2D,
  start: Point,
  end: Point,
): void {
  context.beginPath();

  context.moveTo(
    start.x,
    start.y,
  );

  context.lineTo(
    end.x,
    end.y,
  );

  context.stroke();
}

function drawArea(
  context: CanvasRenderingContext2D,
  first: Point,
  second: Point,
  third: Point,
  fourth: Point,
  color: string,
): void {
  context.fillStyle = color;

  context.beginPath();

  context.moveTo(
    first.x,
    first.y,
  );

  context.lineTo(
    second.x,
    second.y,
  );

  context.lineTo(
    third.x,
    third.y,
  );

  context.lineTo(
    fourth.x,
    fourth.y,
  );

  context.closePath();
  context.fill();
}

function drawHandle(
  context: CanvasRenderingContext2D,
  point: Point,
  radius: number,
): void {
  context.beginPath();

  context.arc(
    point.x,
    point.y,
    radius,
    0,
    Math.PI * 2,
  );

  context.fill();
  context.stroke();
}

function drawCorrelation(
  context: CanvasRenderingContext2D,
  correlation: number,
  point: Point,
  color: string,
  verticalPixelRatio: number,
): void {
  const fontSize =
    UI.correlationFontSize *
    verticalPixelRatio;

  context.save();

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

  context.fillStyle = color;

  context.textAlign = 'left';
  context.textBaseline = 'top';

  context.fillText(
    correlation.toFixed(6),
    point.x,
    point.y +
      UI.correlationOffset *
        verticalPixelRatio,
  );

  context.restore();
}