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


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;
  channelWidth: number;
  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,
    channelWidth: 2,
    showMiddleLine: true,
    showCorrelation: true,
  };
}

export function getRegressionTrendSettingsTabs(settings: RegressionTrendSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Channel'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
    {
      key: 'middleLineColor',
      label: t('Middle line'),
      type: 'color',
      defaultValue: settings.middleLineColor,
    },
    {
      key: 'upperFillColor',
      label: t('Upper zone'),
      type: 'color',
      defaultValue: settings.upperFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'lowerFillColor',
      label: t('Lower zone'),
      type: 'color',
      defaultValue: settings.lowerFillColor,
      toolbar: {
        control: 'color',
        role: 'fill',
      },
    },
    {
      key: 'channelWidth',
      label: t('Channel width'),
      type: 'number',
      defaultValue: settings.channelWidth,
      min: 0.1,
      max: 10,
    },
    {
      key: 'showMiddleLine',
      label: t('Middle line'),
      type: 'boolean',
      defaultValue: settings.showMiddleLine,
    },
    {
      key: 'showCorrelation',
      label: t('Correlation'),
      type: 'boolean',
      defaultValue: settings.showCorrelation,
    },
  ];

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



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 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 {
  showHandles: boolean;
}

const HIT_TOLERANCE = 8;
const MIN_BAR_DISTANCE = 1;

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,
      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: geometry.top,
        to: geometry.bottom,
        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 geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const price = kind === '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.dragStateSnapshot = this.getState();
    this.activeDragTarget = dragTarget;
    this.dragPointerId = event.pointerId;
    this.dragStartPoint = point;
    this.mode = 'dragging';

    this.hideCrosshair();
  };

  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 {
    const regression = this.getRegression();

    if (!regression || this.startTime === null || this.endTime === null) {
      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((point) => point.x)),
      right: Math.max(...points.map((point) => point.x)),
      top: Math.min(...points.map((point) => point.y)),
      bottom: Math.max(...points.map((point) => point.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 dragStartIndex = this.getBarIndexAtX(this.dragStartPoint.x);
    const currentIndex = this.getBarIndexAtX(point.x);

    const snapshotStartIndex = this.getBarIndex(snapshot.startTime);
    const snapshotEndIndex = this.getBarIndex(snapshot.endTime);

    if (
      dragStartIndex === null ||
      currentIndex === null ||
      snapshotStartIndex === null ||
      snapshotEndIndex === null
    ) {
      return;
    }

    const offset = currentIndex - dragStartIndex;

    const nextStartTime = this.getTimeByBarIndex(snapshotStartIndex + offset);
    const nextEndTime = this.getTimeByBarIndex(snapshotEndIndex + offset);

    if (nextStartTime === null || nextEndTime === null) {
      return;
    }

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
  }

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

    if (!range) {
      return null;
    }

    const values: number[] = [];

    for (let index = range.left; index <= range.right; index += 1) {
      const value = getSeriesValue(this.series.dataByIndex(index));

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

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

    const { startValue, endValue, deviation, correlation } = calculateRegression(values);
    const offset = deviation * Math.max(this.settings.channelWidth, 0);

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

    return {
      baseStartPrice,
      baseEndPrice,

      upperStartPrice: baseStartPrice + offset,
      upperEndPrice: baseEndPrice + offset,

      lowerStartPrice: baseStartPrice - offset,
      lowerEndPrice: baseEndPrice - offset,

      correlation,
    };
  }

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

    const startIndex = this.getBarIndex(this.startTime);
    const endIndex = this.getBarIndex(this.endTime);

    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 (time === null) {
      return null;
    }

    const index = this.getBarIndex(time);

    return index === null ? null : this.getTimeByBarIndex(index);
  }

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

    return time === null ? null : this.getBarIndex(time);
  }

  private getBarIndex(time: Time): number | null {
    const coordinate = getXCoordinateFromTime(this.chart, time, this.series);

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

    const logical = this.chart.timeScale().coordinateToLogical(coordinate);

    return logical === null ? null : Math.round(Number(logical));
  }

  private getTimeByBarIndex(index: number): Time | null {
    return this.series.dataByIndex(index)?.time ?? null;
  }

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

    return this.containsPoint(point) ? 'body' : null;
  }

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

    if (!geometry) {
      return false;
    }

    return (
      isPointInPolygon(point, [
        geometry.upperStartPoint,
        geometry.upperEndPoint,
        geometry.lowerEndPoint,
        geometry.lowerStartPoint,
      ]) ||
      getDistanceToSegment(point, geometry.upperStartPoint, geometry.upperEndPoint) <= HIT_TOLERANCE ||
      getDistanceToSegment(point, geometry.lowerStartPoint, geometry.lowerEndPoint) <= HIT_TOLERANCE ||
      getDistanceToSegment(point, geometry.baseStartPoint, geometry.baseEndPoint) <= HIT_TOLERANCE
    );
  }
}

function calculateRegression(values: 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 residuals = 0;

  for (let index = 0; index < count; index += 1) {
    const expected = intercept + slope * index;
    const delta = values[index] - expected;

    residuals += delta * delta;
  }

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

  return {
    startValue: intercept,
    endValue: intercept + slope * (count - 1),
    deviation: Math.sqrt(residuals / count),
    correlation: denominator === 0 ? 0 : sumXY / denominator,
  };
}

function getSeriesValue(data: unknown): number | null {
  if (!data || typeof data !== 'object') {
    return null;
  }

  if ('close' in data && typeof data.close === 'number') {
    return data.close;
  }

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

  return null;
}

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

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

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

  const x = start.x + ratio * dx;
  const y = start.y + ratio * dy;

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

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

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

    const intersects =
      currentPoint.y > point.y !== previousPoint.y > point.y &&
      point.x <
        ((previousPoint.x - currentPoint.x) * (point.y - currentPoint.y)) /
          (previousPoint.y - currentPoint.y) +
          currentPoint.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,
  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();

      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) {
        context.save();

        context.strokeStyle = data.middleLineColor;
        context.lineWidth = UI.middleLineWidth * pixelRatio;
        context.setLineDash([5 * pixelRatio, 4 * pixelRatio]);

        drawLine(context, baseStart, baseEnd);

        context.restore();
      }

      if (data.showCorrelation) {
        drawCorrelation(
          context,
          data.correlation,
          lowerStart,
          data.lineColor,
          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 {
  context.save();

  context.font = `${UI.correlationFontSize * verticalPixelRatio}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();
}



import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  PrimitivePaneViewZOrder,
} from 'lightweight-charts';

import { RegressionTrendPaneRenderer } from './paneRenderer';

import type { RegressionTrend } from './regressionTrend';

export class RegressionTrendPaneView implements IPrimitivePaneView {
  private readonly regressionTrend: RegressionTrend;

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

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new RegressionTrendPaneRenderer(this.regressionTrend);
  }

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


export { RegressionTrend } from './regressionTrend';

export type { RegressionTrendRenderData } from './regressionTrend';

export {
  createDefaultSettings,
  getRegressionTrendSettingsTabs,
} from './settings';

export type {
  RegressionTrendSettings,
  RegressionTrendStyle,
} from './settings';