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


import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  shiftTimeByPixels,
} 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,
  RegressionTrendTextStyle,
} 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';

type RegressionTrendMode =
  | 'idle'
  | 'drawing'
  | 'ready'
  | 'dragging-start'
  | 'dragging-end'
  | 'dragging-body';

type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';

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

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

interface RegressionTrendGeometry {
  startPoint: Point;
  endPoint: Point;

  startPrice: number;
  endPrice: number;

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

interface RegressionResult {
  startPrice: number;
  endPrice: number;
}

interface SeriesDataItem {
  time: Time;
  [key: string]: unknown;
}

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

const LINE_HIT_TOLERANCE = 6;
const HANDLE_HIT_TOLERANCE = 8;
const MIN_LINE_SIZE = 4;

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

  protected settings: RegressionTrendSettings = createDefaultSettings();
  protected mode: RegressionTrendMode = 'idle';

  private startTime: Time | null = null;
  private endTime: Time | null = 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 readonly paneView: RegressionTrendPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly startTimeAxisView: CustomTimeAxisView;
  private readonly endTimeAxisView: CustomTimeAxisView;
  private readonly startPriceAxisView: CustomPriceAxisView;
  private readonly endPriceAxisView: CustomPriceAxisView;

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

    this.removeSelf = removeSelf;
    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 nextState = state as Partial<RegressionTrendState>;

    if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }

    if ('mode' in nextState && nextState.mode) {
      this.mode = isDraggingMode(nextState.mode)
        ? 'ready'
        : nextState.mode;
    }

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

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

    if ('settings' in nextState && nextState.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...nextState.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,
      showHandles: this.shouldShowHandles(),
      ...this.settings,
    };
  }

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

    const point = { x, y };

    if (this.getPointTarget(point)) {
      return {
        cursorStyle: 'move',
        externalId: 'regression-trend',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      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 coordinate = this.getTimeCoordinate(kind);
    const text = this.getTimeText(kind);

    if (coordinate === null || !text) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      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.startPrice
      : geometry.endPrice;

    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 === 'idle' || this.mode === 'drawing') {
      return;
    }

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

    if (!this.isPointNearLine(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') {
      event.preventDefault();
      event.stopPropagation();

      this.startDrawing(point);

      return;
    }

    if (this.mode === 'drawing') {
      event.preventDefault();
      event.stopPropagation();

      this.updateDrawing(point);
      this.finishDrawing();

      return;
    }

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

    const pointTarget = this.getPointTarget(point);
    const isNearLine = this.isPointNearLine(point);
    const isDrawingHit = pointTarget !== null || isNearLine;

    if (!this.isSelected()) {
      if (!isDrawingHit) {
        return;
      }

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

      this.select();

      return;
    }

    if (pointTarget === 'start') {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging('dragging-start', point, event.pointerId);

      return;
    }

    if (pointTarget === 'end') {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging('dragging-end', point, event.pointerId);

      return;
    }

    if (isNearLine) {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging('dragging-body', point, event.pointerId);

      return;
    }

    this.deselect();
  };

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

    if (this.mode === 'drawing') {
      this.updateDrawing(point);

      return;
    }

    if (this.dragPointerId !== event.pointerId) {
      return;
    }

    if (
      this.mode === 'dragging-start' ||
      this.mode === 'dragging-end'
    ) {
      event.preventDefault();
      event.stopPropagation();

      this.movePoint(point);
      this.render();

      return;
    }

    if (this.mode === 'dragging-body') {
      event.preventDefault();
      event.stopPropagation();

      this.moveBody(point);
      this.render();
    }
  };

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

    if (isDraggingMode(this.mode)) {
      this.finishDragging();
    }
  };

  private startDrawing(point: Point): void {
    const time = this.createTime(point);

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

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

    this.render();
  }

  private updateDrawing(point: Point): void {
    const time = this.createTime(point);

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

    this.endTime = time;
    this.render();
  }

  private finishDrawing(): void {
    const geometry = this.getGeometry();

    if (!geometry) {
      this.removeSelf?.();

      return;
    }

    const lineSize = Math.hypot(
      geometry.endPoint.x - geometry.startPoint.x,
      geometry.endPoint.y - geometry.startPoint.y,
    );

    if (lineSize < MIN_LINE_SIZE) {
      this.removeSelf?.();

      return;
    }

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

    this.render();
  }

  private startDragging(
    mode: RegressionTrendMode,
    point: Point,
    pointerId: number,
  ): void {
    this.dragStateSnapshot = this.getState();

    this.mode = mode;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;

    this.hideCrosshair();
    this.render();
  }

  private finishDragging(): void {
    if (!this.getGeometry() && this.dragStateSnapshot) {
      this.startTime = this.dragStateSnapshot.startTime;
      this.endTime = this.dragStateSnapshot.endTime;
    }

    this.mode = 'ready';

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

    this.showCrosshair();
    this.render();
  }

  private movePoint(point: Point): void {
    const time = this.createTime(point);

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

    if (this.mode === 'dragging-start') {
      this.startTime = time;
    }

    if (this.mode === 'dragging-end') {
      this.endTime = time;
    }
  }

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

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

    const offsetX = point.x - this.dragStartPoint.x;

    const nextStartTime = shiftTimeByPixels(
      this.chart,
      snapshot.startTime,
      offsetX,
      this.series,
    );

    const nextEndTime = shiftTimeByPixels(
      this.chart,
      snapshot.endTime,
      offsetX,
      this.series,
    );

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

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

  private createTime(point: Point): Time | null {
    return getTimeFromXCoordinate(this.chart, point.x);
  }

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

    const regression = this.calculateRegression();

    if (!regression) {
      return null;
    }

    const startX = getXCoordinateFromTime(
      this.chart,
      this.startTime,
      this.series,
    );

    const endX = getXCoordinateFromTime(
      this.chart,
      this.endTime,
      this.series,
    );

    const startY = getYCoordinateFromPrice(
      this.series,
      regression.startPrice,
    );

    const endY = getYCoordinateFromPrice(
      this.series,
      regression.endPrice,
    );

    if (
      startX === null ||
      endX === null ||
      startY === null ||
      endY === null
    ) {
      return null;
    }

    const startPoint = {
      x: Math.round(Number(startX)),
      y: Math.round(Number(startY)),
    };

    const endPoint = {
      x: Math.round(Number(endX)),
      y: Math.round(Number(endY)),
    };

    return {
      startPoint,
      endPoint,

      startPrice: regression.startPrice,
      endPrice: regression.endPrice,

      left: Math.min(startPoint.x, endPoint.x),
      right: Math.max(startPoint.x, endPoint.x),
      top: Math.min(startPoint.y, endPoint.y),
      bottom: Math.max(startPoint.y, endPoint.y),
    };
  }

  private calculateRegression(): RegressionResult | null {
    const startTime = getNumericTime(this.startTime);
    const endTime = getNumericTime(this.endTime);

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

    const rangeStart = Math.min(startTime, endTime);
    const rangeEnd = Math.max(startTime, endTime);

    const prices: number[] = [];

    const data = this.series.data() as readonly SeriesDataItem[];

    for (const item of data) {
      const time = getNumericTime(item.time);

      if (time === null || time < rangeStart || time > rangeEnd) {
        continue;
      }

      const price = getDataPrice(item);

      if (price !== null) {
        prices.push(price);
      }
    }

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

    const { slope, intercept } = calculateLinearRegression(prices);

    const firstPrice = intercept;
    const lastPrice = intercept + slope * (prices.length - 1);

    if (startTime <= endTime) {
      return {
        startPrice: firstPrice,
        endPrice: lastPrice,
      };
    }

    return {
      startPrice: lastPrice,
      endPrice: firstPrice,
    };
  }

  private getPointTarget(point: Point): 'start' | 'end' | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

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

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

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return (
      getDistanceToSegment(
        point,
        geometry.startPoint,
        geometry.endPoint,
      ) <= LINE_HIT_TOLERANCE
    );
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const time = kind === 'start'
      ? this.startTime
      : this.endTime;

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

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

    return coordinate === null
      ? null
      : Number(coordinate);
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = kind === 'start'
      ? this.startTime
      : this.endTime;

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

    return formatDate(
      time as UTCTimestamp,
      this.displayFormat.dateFormat,
      this.displayFormat.timeFormat,
      this.displayFormat.showTime,
    );
  }
}

function calculateLinearRegression(prices: readonly number[]): {
  slope: number;
  intercept: number;
} {
  const count = prices.length;

  const meanX = (count - 1) / 2;
  const meanY =
    prices.reduce((sum, price) => sum + price, 0) / count;

  let numerator = 0;
  let denominator = 0;

  for (let index = 0; index < count; index += 1) {
    const xOffset = index - meanX;

    numerator += xOffset * (prices[index] - meanY);
    denominator += xOffset * xOffset;
  }

  const slope = denominator === 0
    ? 0
    : numerator / denominator;

  return {
    slope,
    intercept: meanY - slope * meanX,
  };
}

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

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

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

  return null;
}

function getNumericTime(time: Time | null): number | null {
  if (typeof time !== 'number' || !Number.isFinite(time)) {
    return null;
  }

  return time;
}

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

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

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

  const projectionX = startPoint.x + projection * dx;
  const projectionY = startPoint.y + projection * dy;

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

function isDraggingMode(
  mode: RegressionTrendMode,
): boolean {
  return (
    mode === 'dragging-start' ||
    mode === 'dragging-end' ||
    mode === 'dragging-body'
  );
}



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

import { RegressionTrendPaneRenderer } from './paneRenderer';
import { 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';
  }
}


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

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

import { RegressionTrend } from './regressionTrend';

const UI = {
  lineWidth: 2,
  handleRadius: 5,
  handleBorderWidth: 2,
  textLineHeightMultiplier: 1.2,
  textOffset: 4,
};

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

    const { colors } = getThemeStore();

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

        const startX =
          data.startPoint.x * horizontalPixelRatio;
        const startY =
          data.startPoint.y * verticalPixelRatio;
        const endX =
          data.endPoint.x * horizontalPixelRatio;
        const endY =
          data.endPoint.y * verticalPixelRatio;

        context.save();

        context.lineWidth = UI.lineWidth * pixelRatio;
        context.strokeStyle = data.lineColor;

        context.beginPath();
        context.moveTo(startX, startY);
        context.lineTo(endX, endY);
        context.stroke();

        if (data.text.trim()) {
          drawTextAlongLine(context, {
            startX,
            startY,
            endX,
            endY,
            text: data.text,
            fontSize: data.fontSize,
            isBold: data.isBold,
            isItalic: data.isItalic,
            textColor: data.textColor,
            verticalPixelRatio,
          });
        }

        if (data.showHandles) {
          context.fillStyle = colors.chartBackground;
          context.strokeStyle = colors.chartLineColor;
          context.lineWidth =
            UI.handleBorderWidth * pixelRatio;

          drawHandle(
            context,
            startX,
            startY,
            UI.handleRadius * pixelRatio,
          );

          drawHandle(
            context,
            endX,
            endY,
            UI.handleRadius * pixelRatio,
          );
        }

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

function drawHandle(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  radius: number,
): void {
  context.beginPath();
  context.arc(x, y, radius, 0, Math.PI * 2);
  context.fill();
  context.stroke();
}

function drawTextAlongLine(
  context: CanvasRenderingContext2D,
  {
    startX,
    startY,
    endX,
    endY,
    text,
    fontSize,
    isBold,
    isItalic,
    textColor,
    verticalPixelRatio,
  }: {
    startX: number;
    startY: number;
    endX: number;
    endY: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    verticalPixelRatio: number;
  },
): void {
  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);

  const fontSizePx =
    safeFontSize * verticalPixelRatio;

  const lineHeight =
    safeFontSize *
    UI.textLineHeightMultiplier *
    verticalPixelRatio;

  const dx = endX - startX;
  const dy = endY - startY;

  let angle = Math.atan2(dy, dx);

  if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
    angle += Math.PI;
  }

  const centerX = (startX + endX) / 2;
  const centerY = (startY + endY) / 2;

  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';

  context.save();

  context.translate(centerX, centerY);
  context.rotate(angle);

  context.font =
    `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;

  context.fillStyle = textColor;
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  const blockHeight = lines.length * lineHeight;
  const textOffset = UI.textOffset * verticalPixelRatio;
  const textCenterY = -(blockHeight / 2 + textOffset);
  const startLineY =
    textCenterY - blockHeight / 2 + lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(
      line,
      0,
      startLineY + index * lineHeight,
    );
  });

  context.restore();
}


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

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

export interface RegressionTrendStyle {
  lineColor: string;
}

export interface RegressionTrendTextStyle {
  fontSize: number;
  text: string;
  isBold: boolean;
  isItalic: boolean;
  textColor: string;
}

export type RegressionTrendSettings =
  RegressionTrendStyle &
  RegressionTrendTextStyle &
  SettingsValues;

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

  return {
    lineColor: colors.chartLineColor,
    fontSize: 14,
    text: '',
    isBold: false,
    isItalic: false,
    textColor: colors.chartLineColor,
  };
}

export function getRegressionTrendSettingsTabs(
  settings: RegressionTrendSettings,
): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'text',
      label: t('Text'),
      type: 'textarea',
      defaultValue: settings.text,
      placeholder: t('Enter text'),
    },
    {
      key: 'isBold',
      label: t('Bold'),
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: t('Italic'),
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: t('Text'),
      type: 'color',
      defaultValue: settings.textColor,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
  ];

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


import { RegressionTrendPaneRenderer } from './paneRenderer';
import { RegressionTrendPaneView } from './paneView';
import {
  RegressionTrend,
  RegressionTrendRenderData,
} from './regressionTrend';

export {
  RegressionTrend,
  RegressionTrendPaneRenderer,
  RegressionTrendPaneView,
};

export type { RegressionTrendRenderData };