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


import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';

import type { Coordinate, IChartApi, Logical, Time } from 'lightweight-charts';

interface SeriesTimeItem {
  time: Time;
}

interface TimePoint {
  time: number;
  logical: number;
}

export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
  return series.coordinateToPrice(yCoordinate as Coordinate);
}

export function getYCoordinateFromPrice(series: SeriesApi, price: number): Coordinate | null {
  return series.priceToCoordinate(price);
}

export function getTimeFromXCoordinate(chart: IChartApi, xCoordinate: number, series?: SeriesApi): Time | null {
  if (series) {
    const time = getNearestSeriesTimeFromXCoordinate(chart, series, xCoordinate);

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

    return null;
  }

  return chart.timeScale().coordinateToTime(xCoordinate as Coordinate) ?? null;
}

export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: SeriesApi): Coordinate | null {
  const coordinate = chart.timeScale().timeToCoordinate(time);

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

  if (!series) {
    return null;
  }

  const logical = getLogicalFromTime(series, time);

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

  const projectedCoordinate = chart.timeScale().logicalToCoordinate(logical as Logical);

  if (projectedCoordinate === null || !Number.isFinite(Number(projectedCoordinate))) {
    return null;
  }

  return projectedCoordinate;
}

export function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(value, max));
}

export function getContainerSize(container: HTMLElement): ContainerSize {
  const rect = container.getBoundingClientRect();

  return {
    width: rect.width,
    height: rect.height,
  };
}

export function clampPointToContainer(point: Point, container: HTMLElement): Point {
  const { width, height } = getContainerSize(container);

  return {
    x: clamp(point.x, 0, width),
    y: clamp(point.y, 0, height),
  };
}

export function getPointerPoint(container: HTMLElement, event: PointerEvent): Point {
  const rect = container.getBoundingClientRect();

  return clampPointToContainer(
    {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    },
    container,
  );
}

export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
  return Math.abs(point.x - x) <= tolerance && Math.abs(point.y - y) <= tolerance;
}

export function isPointInBounds(point: Point, bounds: Bounds, tolerance = 0): boolean {
  return (
    point.x >= bounds.left - tolerance &&
    point.x <= bounds.right + tolerance &&
    point.y >= bounds.top - tolerance &&
    point.y <= bounds.bottom + tolerance
  );
}

export function normalizeBounds(
  left: number,
  right: number,
  top: number,
  bottom: number,
  container: HTMLElement,
): Bounds {
  const { width, height } = getContainerSize(container);

  return {
    left: clamp(Math.min(left, right), 0, width),
    right: clamp(Math.max(left, right), 0, width),
    top: clamp(Math.min(top, bottom), 0, height),
    bottom: clamp(Math.max(top, bottom), 0, height),
  };
}

export function shiftTimeByPixels(chart: IChartApi, time: Time, offsetX: number, series?: SeriesApi): Time | null {
  const coordinate = getXCoordinateFromTime(chart, time, series);

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

  return getTimeFromXCoordinate(chart, Number(coordinate) + offsetX, series);
}

export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
  const fromPrice = getPriceFromYCoordinate(series, fromY);
  const toPrice = getPriceFromYCoordinate(series, toY);

  if (fromPrice === null || toPrice === null) {
    return 0;
  }

  return toPrice - fromPrice;
}

export function getPriceRangeInContainer(
  series: SeriesApi,
  container: HTMLElement,
): { min: number; max: number } | null {
  const { height } = getContainerSize(container);

  if (!height) {
    return null;
  }

  const topPrice = getPriceFromYCoordinate(series, 0);
  const bottomPrice = getPriceFromYCoordinate(series, height);

  if (topPrice === null || bottomPrice === null) {
    return null;
  }

  return {
    min: Math.min(topPrice, bottomPrice),
    max: Math.max(topPrice, bottomPrice),
  };
}

export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
  const time = getTimeFromXCoordinate(chart, point.x, series);
  const price = getPriceFromYCoordinate(series, point.y);

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

  return {
    time,
    price,
  };
}

function getSeriesTimePoints(series: SeriesApi): TimePoint[] {
  const data = series.data() as readonly SeriesTimeItem[];

  return data.reduce<TimePoint[]>((points, item, logical) => {
    const time = getNumericTime(item.time);

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

    points.push({
      time,
      logical,
    });

    return points;
  }, []);
}

function getLogicalFromTime(series: SeriesApi, time: Time): number | null {
  const targetTime = getNumericTime(time);

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

  const points = getSeriesTimePoints(series);

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

  const lastIndex = points.length - 1;

  if (targetTime <= points[0].time) {
    return interpolateLogical(points[0], points[1], targetTime);
  }

  if (targetTime >= points[lastIndex].time) {
    return interpolateLogical(points[lastIndex - 1], points[lastIndex], targetTime);
  }

  let left = 0;
  let right = lastIndex;

  while (left <= right) {
    const middle = Math.floor((left + right) / 2);
    const middleTime = points[middle].time;

    if (middleTime === targetTime) {
      return points[middle].logical;
    }

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

  return interpolateLogical(points[right], points[left], targetTime);
}

function getTimeFromLogical(series: SeriesApi, logical: number): Time | null {
  const points = getSeriesTimePoints(series);

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

  const lastIndex = points.length - 1;

  if (logical <= points[0].logical) {
    return interpolateTime(points[0], points[1], logical);
  }

  if (logical >= points[lastIndex].logical) {
    return interpolateTime(points[lastIndex - 1], points[lastIndex], logical);
  }

  const leftIndex = Math.floor(logical);
  const rightIndex = Math.ceil(logical);

  if (leftIndex === rightIndex) {
    return points[leftIndex].time as Time;
  }

  return interpolateTime(points[leftIndex], points[rightIndex], logical);
}

function interpolateLogical(leftPoint: TimePoint, rightPoint: TimePoint, targetTime: number): number | null {
  const timeRange = rightPoint.time - leftPoint.time;

  if (timeRange === 0) {
    return null;
  }

  const ratio = (targetTime - leftPoint.time) / timeRange;

  return leftPoint.logical + (rightPoint.logical - leftPoint.logical) * ratio;
}

function interpolateTime(leftPoint: TimePoint, rightPoint: TimePoint, logical: number): Time | null {
  const logicalRange = rightPoint.logical - leftPoint.logical;

  if (logicalRange === 0) {
    return null;
  }

  const ratio = (logical - leftPoint.logical) / logicalRange;
  const time = leftPoint.time + (rightPoint.time - leftPoint.time) * ratio;

  return Math.round(time) as Time;
}

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

  return Number.isFinite(time) ? time : null;
}

function getNearestSeriesTimeFromXCoordinate(chart: IChartApi, series: SeriesApi, xCoordinate: number): Time | null {
  const logical = chart.timeScale().coordinateToLogical(xCoordinate as Coordinate);

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

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

  if (!data.length) {
    return null;
  }

  const logicalIndex = Number(logical);
  const lastIndex = data.length - 1;

  if (logicalIndex < 0 || logicalIndex > lastIndex) {
    return null;
  }

  const nearestIndex = Math.round(logicalIndex);
  const item = data[nearestIndex];

  return item?.time ?? null;
}


import { Observable, Subscription } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPointerPoint as getPointerPointFromEvent,
  getPriceDelta as getPriceDeltaFromCoordinates,
  getPriceFromYCoordinate,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
  isPointInBounds,
  normalizeBounds,
  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 { RectanglePaneView } from './paneView';
import {
  createDefaultSettings,
  getRectangleSettingsTabs,
  RectangleSettings,
  RectangleStyle,
  RectangleTextStyle,
} from './settings';

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

type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';

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

interface RectangleState {
  hidden: boolean;
  isActive: boolean;
  mode: RectangleMode;
  startTime: Time | null;
  endTime: Time | null;
  startPrice: number | null;
  endPrice: number | null;
  settings: RectangleSettings;
}

interface RectangleGeometry {
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  handles: Record<RectangleHandleKey, Point>;
}

export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
  showFill: boolean;
  showHandles: boolean;
}

const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;

export class Rectangle implements ISeriesDrawing {
  private chart: IChartApi;
  private series: SeriesApi;
  private container: HTMLElement;
  private removeSelf?: () => void;
  private openSettings?: () => void;

  private settings: RectangleSettings = createDefaultSettings();

  private requestUpdate: (() => void) | null = null;
  private isBound = false;
  private subscriptions = new Subscription();

  private hidden = false;
  private isActive = false;
  private mode: RectangleMode = 'idle';

  private startTime: Time | null = null;
  private endTime: Time | null = null;
  private startPrice: number | null = null;
  private endPrice: number | null = null;

  private activeDragTarget: RectangleHandle = null;
  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: RectangleState | null = null;
  private dragGeometrySnapshot: RectangleGeometry | null = null;

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

  private readonly paneView: RectanglePaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;
  private readonly leftTimeAxisView: CustomTimeAxisView;
  private readonly rightTimeAxisView: CustomTimeAxisView;
  private readonly topPriceAxisView: CustomPriceAxisView;
  private readonly bottomPriceAxisView: CustomPriceAxisView;

  constructor(
    chart: IChartApi,
    series: SeriesApi,
    { container, formatObservable, removeSelf, openSettings }: RectangleParams,
  ) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new RectanglePaneView(this);

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

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

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

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

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

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

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

    this.series.attachPrimitive(this);
  }

  public show(): void {
    this.hidden = false;
    this.render();
  }

  public hide(): void {
    this.hidden = true;
    this.render();
  }

  public destroy(): void {
    this.unbindEvents();
    this.subscriptions.unsubscribe();
    this.series.detachPrimitive(this);
    this.requestUpdate = null;
  }

  public rebind(series: SeriesApi): void {
    if (this.series === series) {
      return;
    }

    this.unbindEvents();
    this.series.detachPrimitive(this);

    this.series = series;
    this.requestUpdate = null;

    this.series.attachPrimitive(this);
    this.render();
  }

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

  public shouldShowInObjectTree(): boolean {
    return this.mode !== 'idle';
  }

  public getState(): RectangleState {
    return {
      hidden: this.hidden,
      isActive: this.isActive,
      mode: this.mode,
      startTime: this.startTime,
      endTime: this.endTime,
      startPrice: this.startPrice,
      endPrice: this.endPrice,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    const nextState = state as Partial<RectangleState>;

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

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

    if ('mode' in nextState && nextState.mode) {
      this.mode = nextState.mode;
    }

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

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

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

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

    if ('settings' in nextState && nextState.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...nextState.settings,
      };
    }

    this.render();
  }

  public getSettings(): SettingsValues {
    return { ...this.settings };
  }

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

  public updateSettings(settings: SettingsValues): void {
    this.settings = {
      ...this.settings,
      ...settings,
    };

    this.render();
  }

  public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.requestUpdate = param.requestUpdate;
    this.bindEvents();
  }

  public detached(): void {
    this.unbindEvents();
    this.requestUpdate = null;
  }

  public updateAllViews(): void {
    updateViews([
      this.paneView,
      this.timeAxisPaneView,
      this.priceAxisPaneView,
      this.leftTimeAxisView,
      this.rightTimeAxisView,
      this.topPriceAxisView,
      this.bottomPriceAxisView,
    ]);
  }

  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.leftTimeAxisView, this.rightTimeAxisView];
  }

  public priceAxisViews() {
    return [this.topPriceAxisView, this.bottomPriceAxisView];
  }

  public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
    return null;
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showFill: true,
      showHandles: this.isActive,
      ...this.settings,
    };
  }

  public getTimeAxisSegments(): AxisSegment[] {
    if (!this.isActive) {
      return [];
    }

    const bounds = this.getTimeBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

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

  public getPriceAxisSegments(): AxisSegment[] {
    if (!this.isActive) {
      return [];
    }

    const bounds = this.getPriceBounds();

    if (!bounds) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: bounds.top,
        to: bounds.bottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  public getTimeAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive || (kind !== 'left' && kind !== 'right')) {
      return null;
    }

    const labelKind = kind as TimeLabelKind;
    const coordinate = this.getTimeCoordinate(labelKind);
    const text = this.getTimeText(labelKind);

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  public getPriceAxisLabel(kind: string): AxisLabel | null {
    if (!this.isActive || (kind !== 'top' && kind !== 'bottom')) {
      return null;
    }

    const labelKind = kind as PriceLabelKind;
    const coordinate = this.getPriceCoordinate(labelKind);
    const text = this.getPriceText(labelKind);

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

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

    const point = { x, y };

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

      return {
        cursorStyle: 'pointer',
        externalId: 'rectangle-position',
        zOrder: 'top',
      };
    }

    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return {
        cursorStyle: this.getCursorStyle(handleTarget),
        externalId: 'rectangle-position',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      externalId: 'rectangle-position',
      zOrder: 'top',
    };
  }

  private bindEvents(): void {
    if (this.isBound) {
      return;
    }

    this.isBound = true;

    this.container.addEventListener('dblclick', this.handleDoubleClick);
    this.container.addEventListener('pointerdown', this.handlePointerDown);
    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
  }

  private unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.isBound = false;

    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    this.container.removeEventListener('pointerdown', this.handlePointerDown);
    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

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

    const rect = this.container.getBoundingClientRect();
    const point = {
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    };

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

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

    this.openSettings?.();
  };

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

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

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

      this.isActive = true;
      this.render();
      return;
    }

    const dragTarget = this.getDragTarget(point);

    if (!dragTarget) {
      this.isActive = false;
      this.render();
      return;
    }

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

    this.startDragging(point, event.pointerId, dragTarget);
  };

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

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

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

    event.preventDefault();

    if (this.activeDragTarget === 'body') {
      this.moveWhole(point);
      this.render();
      return;
    }

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

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

    this.finishDragging();
  };

  private startDrawing(point: Point): void {
    const anchor = this.createAnchor(point);

    if (!anchor) {
      return;
    }

    this.startTime = anchor.time;
    this.endTime = anchor.time;
    this.startPrice = anchor.price;
    this.endPrice = anchor.price;

    this.isActive = true;
    this.mode = 'drawing';

    this.render();
  }

  private updateDrawing(point: Point): void {
    const clampedPoint = this.clampPointToContainer(point);
    const anchor = this.createAnchor(clampedPoint);

    if (!anchor) {
      return;
    }

    this.endTime = anchor.time;
    this.endPrice = anchor.price;

    this.render();
  }

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

    if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
      if (this.removeSelf) {
        this.removeSelf();
        return;
      }

      this.resetToIdle();
      return;
    }

    this.mode = 'ready';

    this.render();
  }

  private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
    this.mode = 'dragging';

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

    this.render();
  }

  private finishDragging(): void {
    this.mode = 'ready';

    this.clearInteractionState();
    this.render();
  }

  private clearInteractionState(): void {
    this.activeDragTarget = null;
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragStateSnapshot = null;
    this.dragGeometrySnapshot = null;
  }

  private resetToIdle(): void {
    this.hidden = false;
    this.isActive = false;
    this.mode = 'idle';

    this.startTime = null;
    this.endTime = null;
    this.startPrice = null;
    this.endPrice = null;
    this.clearInteractionState();
    this.render();
  }

  private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
    const handleTarget = this.getHandleTarget(point);

    if (handleTarget) {
      return handleTarget;
    }

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

    return null;
  }

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

    if (!snapshot || !this.dragStartPoint) {
      return;
    }

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

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

    const nextStartTime = this.shiftTime(snapshot.startTime, offsetX);
    const nextEndTime = this.shiftTime(snapshot.endTime, offsetX);

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

    const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + offsetY);

    this.startTime = nextStartTime;
    this.endTime = nextEndTime;
    this.startPrice = snapshot.startPrice + priceOffset;
    this.endPrice = snapshot.endPrice + priceOffset;
  }

  private resizeRectangle(point: Point): void {
    const geometry = this.dragGeometrySnapshot;

    if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
      return;
    }

    const clampedPoint = this.clampPointToContainer(point);

    let { left } = geometry;
    let { right } = geometry;
    let { top } = geometry;
    let { bottom } = geometry;

    switch (this.activeDragTarget) {
      case 'nw':
        left = clampedPoint.x;
        top = clampedPoint.y;
        break;
      case 'n':
        top = clampedPoint.y;
        break;
      case 'ne':
        right = clampedPoint.x;
        top = clampedPoint.y;
        break;
      case 'e':
        right = clampedPoint.x;
        break;
      case 'se':
        right = clampedPoint.x;
        bottom = clampedPoint.y;
        break;
      case 's':
        bottom = clampedPoint.y;
        break;
      case 'sw':
        left = clampedPoint.x;
        bottom = clampedPoint.y;
        break;
      case 'w':
        left = clampedPoint.x;
        break;
      default:
        return;
    }

    this.setRectangleBounds(left, right, top, bottom);
  }

  private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
    const bounds = normalizeBounds(left, right, top, bottom, this.container);

    const startTime = getTimeFromXCoordinate(this.chart, bounds.left, this.series);
    const endTime = getTimeFromXCoordinate(this.chart, bounds.right, this.series);
    const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
    const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);

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

    this.startTime = startTime;
    this.endTime = endTime;
    this.startPrice = startPrice;
    this.endPrice = endPrice;

    return true;
  }

  private createAnchor(point: Point): { time: Time; price: number } | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

  private getGeometry(): RectangleGeometry | null {
    if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
      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, this.startPrice);
    const endY = getYCoordinateFromPrice(this.series, this.endPrice);

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

    const left = Math.min(Number(startX), Number(endX));
    const right = Math.max(Number(startX), Number(endX));
    const top = Math.min(Number(startY), Number(endY));
    const bottom = Math.max(Number(startY), Number(endY));

    const centerX = (left + right) / 2;
    const centerY = (top + bottom) / 2;

    return {
      left,
      right,
      top,
      bottom,
      width: right - left,
      height: bottom - top,
      handles: {
        nw: { x: left, y: top },
        n: { x: centerX, y: top },
        ne: { x: right, y: top },
        e: { x: right, y: centerY },
        se: { x: right, y: bottom },
        s: { x: centerX, y: bottom },
        sw: { x: left, y: bottom },
        w: { x: left, y: centerY },
      },
    };
  }

  private getTimeBounds(): { left: number; right: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      left: geometry.left,
      right: geometry.right,
    };
  }

  private getPriceBounds(): { top: number; bottom: number } | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      top: geometry.top,
      bottom: geometry.bottom,
    };
  }

  private getTimeCoordinate(kind: TimeLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'left' ? geometry.left : geometry.right;
  }

  private getPriceCoordinate(kind: PriceLabelKind): number | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return kind === 'top' ? geometry.top : geometry.bottom;
  }

  private getTimeText(kind: TimeLabelKind): string {
    const time = this.getTimeValueForLabel(kind);

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

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

  private getPriceText(kind: PriceLabelKind): string {
    const price = this.getPriceValueForLabel(kind);

    if (price === null) {
      return '';
    }

    return formatPrice(price) ?? '';
  }

  private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
    if (this.startTime === null || this.endTime === null) {
      return null;
    }

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

    if (startX === null || endX === null) {
      return kind === 'left' ? this.startTime : this.endTime;
    }

    const startIsLeft = Number(startX) <= Number(endX);

    if (kind === 'left') {
      return startIsLeft ? this.startTime : this.endTime;
    }

    return startIsLeft ? this.endTime : this.startTime;
  }

  private getPriceValueForLabel(kind: PriceLabelKind): number | null {
    if (this.startPrice === null || this.endPrice === null) {
      return null;
    }

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

    if (startY === null || endY === null) {
      return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
    }

    const startIsTop = Number(startY) <= Number(endY);

    if (kind === 'top') {
      return startIsTop ? this.startPrice : this.endPrice;
    }

    return startIsTop ? this.endPrice : this.startPrice;
  }

  private getHandleTarget(point: Point): RectangleHandleKey | null {
    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];

    for (const handleName of handleOrder) {
      const handle = geometry.handles[handleName];

      if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
        return handleName;
      }
    }

    return null;
  }

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

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
  }

  private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
    switch (handle) {
      case 'nw':
      case 'se':
        return 'nwse-resize';
      case 'ne':
      case 'sw':
        return 'nesw-resize';
      case 'n':
      case 's':
        return 'ns-resize';
      case 'e':
      case 'w':
        return 'ew-resize';
      case 'body':
        return 'grab';
      default:
        return 'default';
    }
  }

  private shiftTime(time: Time, offsetX: number): Time | null {
    return shiftTimeByPixels(this.chart, time, offsetX, this.series);
  }

  private getPriceDelta(fromY: number, toY: number): number {
    return getPriceDeltaFromCoordinates(this.series, fromY, toY);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

  private clampPointToContainer(point: Point): Point {
    return clampPointToContainerInElement(point, this.container);
  }

  private getEventPoint(event: PointerEvent): Point {
    return getPointerPointFromEvent(this.container, event);
  }

  private render(): void {
    this.updateAllViews();
    this.requestUpdate?.();
  }
}



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

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

import { Rectangle } from './rectangle';

const UI = {
  borderWidth: 1,
  handleSize: 10,
  handleBorderWidth: 1,
  textOffset: 4,
  textLineHeightMultiplier: 1.2,
  minVisibleSize: 6,
};

export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
  private readonly rectangle: Rectangle;

  constructor(rectangle: Rectangle) {
    this.rectangle = rectangle;
  }

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

    if (!data) {
      return;
    }

    const { colors } = getThemeStore();

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

      const rawLeft = data.left * horizontalPixelRatio;
      const rawRight = data.right * horizontalPixelRatio;
      const rawTop = data.top * verticalPixelRatio;
      const rawBottom = data.bottom * verticalPixelRatio;

      const horizontalRange = getRangeWithMinSize(rawLeft, rawRight, UI.minVisibleSize * horizontalPixelRatio);

      const verticalRange = getRangeWithMinSize(rawTop, rawBottom, UI.minVisibleSize * verticalPixelRatio);

      const visibleLeft = clamp(horizontalRange.from, 0, bitmapSize.width);
      const visibleRight = clamp(horizontalRange.to, 0, bitmapSize.width);
      const visibleTop = clamp(verticalRange.from, 0, bitmapSize.height);
      const visibleBottom = clamp(verticalRange.to, 0, bitmapSize.height);

      if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
        return;
      }

      context.save();

      if (data.showFill) {
        context.fillStyle = data.fillColor;
        context.fillRect(visibleLeft, visibleTop, visibleRight - visibleLeft, visibleBottom - visibleTop);
      }

      context.lineWidth = UI.borderWidth * pixelRatio;
      context.strokeStyle = data.borderColor;
      context.strokeRect(visibleLeft, visibleTop, visibleRight - visibleLeft, visibleBottom - visibleTop);

      drawRectangleText(context, {
        left: visibleLeft,
        top: visibleTop,
        originalTop: rawTop,
        text: data.text,
        fontSize: data.fontSize,
        isBold: data.isBold,
        isItalic: data.isItalic,
        textColor: data.textColor,
        pixelRatio,
      });

      if (data.showHandles) {
        for (const handle of Object.values(data.handles)) {
          const x = handle.x * horizontalPixelRatio;
          const y = handle.y * verticalPixelRatio;

          if (!isPointVisible(x, y, bitmapSize.width, bitmapSize.height, UI.handleSize * pixelRatio)) {
            continue;
          }

          drawHandle(
            context,
            x,
            y,
            horizontalPixelRatio,
            verticalPixelRatio,
            colors.chartLineColor,
            colors.chartBackground,
          );
        }
      }

      context.restore();
    });
  }
}

function drawRectangleText(
  context: CanvasRenderingContext2D,
  params: {
    left: number;
    top: number;
    originalTop: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    pixelRatio: number;
  },
): void {
  const { left, top, originalTop, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;

  if (!text.trim()) {
    return;
  }

  if (originalTop !== top) {
    return;
  }

  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);
  const fontSizePx = safeFontSize * pixelRatio;
  const lineHeight = safeFontSize * UI.textLineHeightMultiplier * pixelRatio;
  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';
  const textOffset = UI.textOffset * pixelRatio;

  const textX = left;
  const blockHeight = lines.length * lineHeight;
  const firstLineY = top - textOffset - blockHeight + lineHeight / 2;

  context.save();

  context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
  context.fillStyle = textColor;
  context.textAlign = 'left';
  context.textBaseline = 'middle';

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

  context.restore();
}

function drawHandle(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
  strokeColor: string,
  fillColor: string,
): void {
  const width = UI.handleSize * horizontalPixelRatio;
  const height = UI.handleSize * verticalPixelRatio;
  const left = x - width / 2;
  const top = y - height / 2;

  context.save();

  context.fillStyle = fillColor;
  context.strokeStyle = strokeColor;
  context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);

  context.beginPath();
  context.rect(left, top, width, height);
  context.fill();
  context.stroke();

  context.restore();
}

function getRangeWithMinSize(from: number, to: number, minSize: number): { from: number; to: number } {
  const left = Math.min(from, to);
  const right = Math.max(from, to);
  const size = right - left;

  if (size >= minSize) {
    return {
      from: left,
      to: right,
    };
  }

  const center = (left + right) / 2;
  const half = minSize / 2;

  return {
    from: center - half,
    to: center + half,
  };
}

function isPointVisible(x: number, y: number, width: number, height: number, tolerance: number): boolean {
  return x >= -tolerance && x <= width + tolerance && y >= -tolerance && y <= height + tolerance;
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(value, max));
}


import type { ISeriesApi, SeriesOptionsMap, Time } from 'lightweight-charts';

export type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;

export interface Point {
  x: number;
  y: number;
}

export interface Bounds {
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface ContainerSize {
  width: number;
  height: number;
}

export interface Anchor {
  time: Time;
  price: number;
}

export interface AxisSegment {
  from: number;
  to: number;
  color: string;
}

export interface AxisLabel {
  coordinate: number;
  text: string;
  textColor: string;
  backgroundColor: string;
}

export interface UpdatableView {
  update(): void;
}

import {
  BarData,
  BarPrice,
  BarsInfo,
  Coordinate,
  CreatePriceLineOptions,
  CustomData,
  DataChangedHandler,
  DeepPartial,
  HistogramData,
  IChartApi,
  IPaneApi,
  IPriceFormatter,
  IPriceLine,
  IPriceScaleApi,
  IRange,
  ISeriesApi,
  ISeriesPrimitive,
  LineData,
  MismatchDirection,
  MouseEventParams,
  PriceScaleOptions,
  SeriesDataItemTypeMap,
  SeriesDefinition,
  SeriesOptionsMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';

import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';

export interface SerieData {
  time: Time;
  customValues: Candle;
}

export interface CreateSeriesParams<TSeries extends SeriesType> {
  chart: IChartApi;
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  paneIndex?: number;
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}

export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
  getLwcSeries: () => ISeriesApi<TSeries>;
  getLegendData: (
    param?: MouseEventParams,
  ) => Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
}

export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
  lwcChart: IChartApi;
  dataSource: DataSource;
  mainSymbol$: Observable<string>;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
  seriesOptions?: SeriesPartialOptionsMap[TSeries];
  priceScaleOptions?: DeepPartial<PriceScaleOptions>;
  showSymbolLabel?: boolean;
  paneIndex?: number;
  indicatorReference?: Indicator;
}

function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
  // todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
  const offsetMinutes = -180; // utc.time - moscow.time in minutes
  const secondsInMinute = 60;

  return candles
    .filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
    .map((c) => ({
      ...c,
      time: (c.time as number) - offsetMinutes * secondsInMinute,
    }));
}

export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
  protected lwcSeries: ISeriesApi<TSeries>;
  protected customFormatter:
    | undefined
    | ((params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][]);

  protected lwcChart: IChartApi;
  protected mainSymbol$: Observable<string>;
  protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  protected paneIndex: number | null = null;
  protected indicatorReference: Indicator | null = null;
  protected showSymbolLabel: boolean;

  private subscriptions = new Subscription();
  private dataSub: Subscription | null = null;
  private realtimeSub: Subscription | null = null;

  constructor({
    lwcChart,
    mainSymbol$,
    mainSerie$,
    customFormatter,
    seriesOptions,
    priceScaleOptions,
    showSymbolLabel = true,
    paneIndex,
    indicatorReference,
  }: BaseSeriesParams<TSeries>) {
    this.lwcSeries = this.createSeries({
      chart: lwcChart,
      seriesOptions,
      paneIndex,
      priceScaleOptions,
    });
    this.lwcChart = lwcChart;
    this.customFormatter = customFormatter;
    this.mainSymbol$ = mainSymbol$;
    this.mainSerie$ = mainSerie$;
    this.showSymbolLabel = showSymbolLabel;
    this.indicatorReference = indicatorReference ?? null;
  }

  public getLegendData = (
    param?: MouseEventParams,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> => {
    if (!param) {
      const seriesData = this.data();
      if (seriesData.length < 1) return {};

      const dataToFormat = seriesData[seriesData.length - 1];
      const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
      return this.formatLegendValues(dataToFormat, prevBarData);
    }

    const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
    const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;

    return this.formatLegendValues(dataToFormat, prevBarData);
  };

  public show(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: true });
  }

  public hide(): void {
    this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: false });
  }

  public isVisible(): boolean {
    return this.lwcSeries.options().visible;
  }

  public destroy(): void {
    this.dataSub?.unsubscribe();
    this.realtimeSub?.unsubscribe();
    this.subscriptions.unsubscribe();
    this.lwcChart.removeSeries(this.lwcSeries);
  }

  public getLwcSeries(): ISeriesApi<TSeries> {
    return this.lwcSeries;
  }

  public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
    this.lwcSeries.applyOptions(options);
  }

  public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.attachPrimitive(primitive);
  }

  public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
    return this.lwcSeries.barsInLogicalRange(range);
  }

  public coordinateToPrice(coordinate: number): BarPrice | null {
    return this.lwcSeries.coordinateToPrice(coordinate);
  }

  public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
    return this.lwcSeries.createPriceLine(options);
  }

  public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
    return this.lwcSeries.data();
  }

  public dataByIndex(
    logicalIndex: number,
    mismatchDirection?: MismatchDirection,
  ): SeriesDataItemTypeMap<Time>[TSeries] | null {
    return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
  }

  public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
    this.lwcSeries.detachPrimitive(primitive);
  }

  public getPane(): IPaneApi<Time> {
    return this.lwcSeries.getPane();
  }

  public moveToPane(paneIndex: number): void {
    this.lwcSeries.moveToPane(paneIndex);
  }

  public options(): Readonly<SeriesOptionsMap[TSeries]> {
    return this.lwcSeries.options();
  }

  public priceFormatter(): IPriceFormatter {
    return this.lwcSeries.priceFormatter();
  }

  public priceLines(): IPriceLine[] {
    return this.lwcSeries.priceLines();
  }

  public priceScale(): IPriceScaleApi {
    return this.lwcSeries.priceScale();
  }

  public priceToCoordinate(price: number): Coordinate | null {
    return this.lwcSeries.priceToCoordinate(price);
  }

  public removePriceLine(line: IPriceLine): void {
    this.lwcSeries.removePriceLine(line);
  }

  public seriesOrder(): number {
    return this.lwcSeries.seriesOrder();
  }

  public seriesType(): TSeries {
    return this.lwcSeries.seriesType();
  }

  public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
    const normalizedData = normalizeSeriesData(data);
    this.lwcSeries.setData(normalizedData);
  }

  public setSeriesOrder(order: number): void {
    this.lwcSeries.setSeriesOrder(order);
  }

  public subscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.subscribeDataChanged(handler);
  }

  public unsubscribeDataChanged(handler: DataChangedHandler): void {
    this.lwcSeries.unsubscribeDataChanged(handler);
  }

  public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
    const data = this.lwcSeries.data();
    const last = data.length ? data[data.length - 1] : null;

    if (!last) {
      this.lwcSeries.update(bar, false);
      return;
    }

    const lastTime = last.time;
    const nextTime = bar.time;

    const isHist =
      historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);

    this.lwcSeries.update(bar, isHist);
  }

  protected createSeries({
    chart,
    seriesOptions,
    paneIndex = MAIN_PANE_INDEX,
    priceScaleOptions = {},
  }: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
    this.paneIndex = paneIndex;

    const defaultOptions = this.getDefaultOptions();
    const mergedOptions = {
      ...defaultOptions,
      ...seriesOptions,
    };

    const series = chart.addSeries<TSeries>(this.seriesDefinition(), mergedOptions, paneIndex);

    chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);

    return series;
  }

  protected abstract dataSourceSubscription(next: Candle[]): void;
  protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
  protected abstract dataSourceRealtimeSubscription(next: Candle): void;
  protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
  protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
  protected abstract formatLegendValues(
    currentBar: null | BarData | LineData | HistogramData | CustomData,
    prevBar: null | BarData | LineData | HistogramData | CustomData,
  ): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;

  protected applyTimezone(data: Candle[]): Candle[] {
    return applyMoscowTimezone(data);
  }

  protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    const formatter = this.customFormatter;

    const data = this.applyTimezone(inputData);

    if (formatter) {
      const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];

      if (data.length === 1) {
        return formatter({
          mainSeriesData,
          selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
          candle: this.formatMainSerie(data)[0] as unknown as SerieData,
          indicatorReference: this.indicatorReference ?? undefined,
        });
      }

      return formatter({
        mainSeriesData,
        indicatorReference: this.indicatorReference ?? undefined,
        selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
      });
    }

    return this.formatMainSerie(data);
  }

  protected subscribeDataSource = (dataSource: DataSource): void => {
    const minMove = getPricePrecisionStep();

    this.subscriptions.add(
      this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
        this.lwcSeries.applyOptions({
          // todo: на каждый апдейт dataSource сеттим options. Не оптимально
          title: this.showSymbolLabel ? symbol : '',
          priceFormat: {
            type: 'custom',
            minMove,
            formatter: (price: number) => formatPrice(price),
          },
        });

        this.dataSub?.unsubscribe();
        this.realtimeSub?.unsubscribe();

        this.dataSub = dataSource.subscribe(symbol, (next) => {
          this.dataSourceSubscription(next);
        });

        this.realtimeSub = dataSource.subscribeRealtime(symbol, (next: Candle) => {
          this.dataSourceRealtimeSubscription(next);
        });
      }),
    );
  };
}

export function calcCandleChange(
  prev: BarData | LineData | HistogramData | CustomData | null,
  current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | null {
  if (!current) {
    return null;
  }

  if (!prev) {
    return current;
  }

  if (isBarData(prev) && isBarData(current)) {
    const absoluteChange = current.close - prev.close;
    const percentageChange = ((current.close - prev.close) / prev.close) * 100;

    return {
      ...current,
      absoluteChange,
      percentageChange,
    };
  }

  if (isLineData(prev) && isLineData(current)) {
    const absoluteChange = current.value - prev.value;
    const percentageChange = ((current.value - prev.value) / prev.value) * 100;

    return {
      time: current.time,
      value: current.value,
      high: current.customValues?.high as number,
      low: current.customValues?.low as number,
      absoluteChange,
      percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}


import dayjs from 'dayjs';

import {
  ChartOptions,
  createChart,
  CrosshairMode,
  DeepPartial,
  IChartApi,
  IRange,
  LocalizationOptionsBase,
  LogicalRange,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';

import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import {
  Candle,
  ChartOptionsModel,
  ChartSeriesType,
  ChartTypeOptions,
  Direction,
  OHLCConfig,
  TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';

import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';

export interface ChartConfig extends Partial<ChartOptionsModel> {
  container: HTMLElement;
  seriesTypes: ChartSeriesType[];
  theme: ThemeKey;
  mode?: ThemeMode;
  chartOptions?: ChartTypeOptions;
  localization?: LocalizationOptionsBase;
}

export enum Resize {
  Shrink,
  Expand,
}

const HISTORY_LOAD_THRESHOLD = 50;

interface ChartParams {
  params: {
    dataSource: DataSource;
    eventManager: EventManager;
    modalRenderer: ModalRenderer;
    ohlcConfig: OHLCConfig;
    tooltipConfig: TooltipConfig;
    panes: PaneSnapshot[];
  },
  lwcChartConfig: ChartConfig
}

/**
 * Абстракция над библиотекой для построения графиков
 */
export class Chart implements ISerializable<ChartSnapshot>{
  private lwcChart!: IChartApi;
  private container: HTMLElement;
  private eventManager: EventManager;
  private paneManager!: PaneManager;
  private compareManager: CompareManager;
  private mouseEvents: ChartMouseEvents;
  private indicatorManager: IndicatorManager;
  private optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private DOM: DOMModel;

  private isPointerDown = false;
  private didResetOnDrag = false;

  private subscriptions = new Subscription();

  private currentInterval: Intervals | null = null;

  private activeSymbols: string[] = [];

  private historyBatchRunning = false;

  constructor({
    params,
    lwcChartConfig
  }: ChartParams) {

    const {
      eventManager,
      dataSource,
      modalRenderer,
      ohlcConfig,
      tooltipConfig,
      panes: panesSnapshot,
    } = params

    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.container = lwcChartConfig.container;
    this.chartConfig = lwcChartConfig;

    this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));

    this.optionsSubscription = this.eventManager
      .getChartOptionsModel()
      .subscribe(({ dateFormat, timeFormat, showTime }) => {
        const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };

        this.lwcChart.applyOptions({
          ...getOptions(configToApply),
          localization: {
            timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
          },
        });
      });

    this.subscriptions.add(this.optionsSubscription);

    this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });

    this.mouseEvents.subscribe('wheel', this.onWheel);
    this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
    this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
    this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
    this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);

    this.DOM = new DOMModel({ modalRenderer });

    this.paneManager = new PaneManager({
      eventManager: this.eventManager,
      panesSnapshot,
      lwcChart: this.lwcChart,
      dataSource,
      DOM: this.DOM,
      ohlcConfig,
      subscribeChartEvent: this.subscribeChartEvent,
      chartContainer: this.container,
      tooltipConfig,
      modalRenderer,
    });

    this.indicatorManager = new IndicatorManager({
      eventManager,
      initialIndicators: flatten(panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id })) )),
      DOM: this.DOM,
      dataSource: this.dataSource,
      lwcChart: this.lwcChart,
      paneManager: this.paneManager,
      chartOptions: lwcChartConfig.chartOptions,
    });

    this.compareManager = new CompareManager({
      chart: this.lwcChart,
      initialIndicators: flatten(panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id })) )),
      eventManager: this.eventManager,
      dataSource: this.dataSource,
      indicatorManager: this.indicatorManager,
      paneManager: this.paneManager,
    });

    this.setupDataSourceSubs();
    this.setupHistoricalDataLoading();
  }

  public getPriceScaleWidth(direction: Direction): number {
    try {
      const priceScale = this.lwcChart.priceScale(direction);

      return priceScale ? priceScale.width() : 0;
    } catch {
      return 0;
    }
  }

  public getDrawingsManager = (): DrawingsManager => {
    return this.paneManager.getDrawingsManager();
  };

  public getIndicatorManager = (): IndicatorManager => {
    return this.indicatorManager;
  };

  private onWheel = () => {
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerDown = () => {
    this.isPointerDown = true;
    this.didResetOnDrag = false;
  };

  private onPointerMove = () => {
    if (!this.isPointerDown) return;
    if (this.didResetOnDrag) return;

    this.didResetOnDrag = true;
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerUp = () => {
    this.isPointerDown = false;
  };

  public getDom(): DOMModel {
    return this.DOM;
  }

  public getMainSeries(): Observable<SeriesStrategies | null> {
    return this.mainSeries.asObservable();
  }

  public getCompareManager(): CompareManager {
    return this.compareManager;
  }

  public updateTheme(theme: ThemeKey, mode: ThemeMode) {
    this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
  }

  public destroy(): void {
    this.mouseEvents.destroy();
    this.compareManager.clear();
    this.subscriptions.unsubscribe();
    this.lwcChart.remove();
  }

  public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
    this.mouseEvents.subscribe(event, callback);

  public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
    this.mouseEvents.unsubscribe(event, callback);
  };

  // todo: add/move to undo/redo model(eventManager)
  public scrollTimeScale = (direction: Direction) => {
    this.eventManager.resetInterval({ history: false });
    const diff = direction === Direction.Left ? -2 : 2;
    const currentPosition = this.lwcChart.timeScale().scrollPosition();
    this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
  };

  // todo: add/move to undo/redo model(eventManager)
  public zoomTimeScale = (resize: Resize) => {
    this.eventManager.resetInterval({ history: false });
    const diff = resize === Resize.Shrink ? -1 : 1;

    const currentRange = this.lwcChart.timeScale().getVisibleRange();
    if (!currentRange) return;

    const { from, to } = currentRange as IRange<number>;
    if (!from || !to) return;

    const next: IRange<Time> = {
      from: (from + (to - from) * 0.1 * diff) as Time,
      to: to as Time,
    };
    this.lwcChart.timeScale().setVisibleRange(next);
  };

  // todo: add to undo/redo model(eventManager)
  public resetZoom = () => {
    this.eventManager.resetInterval({ history: false });
    this.lwcChart.timeScale().resetTimeScale();
    this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
    this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
  };

  public getRealtimeApi() {
    return {
      getTimeframe: () => this.eventManager.getTimeframe(),
      getSymbols: () => this.activeSymbols,
      update: (symbol: string, candle: Candle) => {
        this.dataSource.updateRealtime(symbol, candle);
      },
    };
  }

  public getSnapshot(): ChartSnapshot {
    return {
      panes: this.paneManager.getSnapshot(),
      chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
      timeframe: this.eventManager.exportChartSettings().timeframe,
      symbol: this.activeSymbols[0]
    }
  }

  private scheduleHistoryBatch = () => {
    if (this.historyBatchRunning) return;

    this.historyBatchRunning = true;

    requestAnimationFrame(() => {
      const symbols = this.activeSymbols.slice();

      Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
        this.historyBatchRunning = false;

        const range = this.lwcChart.timeScale().getVisibleLogicalRange();
        if (range && range.from < HISTORY_LOAD_THRESHOLD) {
          this.scheduleHistoryBatch();
        }
      });
    });
  };

  private setupDataSourceSubs() {
    const getWarmupFrom = (): number => {
      if (this.currentInterval && this.currentInterval !== Intervals.All) {
        return getIntervalRange(this.currentInterval).from;
      }

      const range = this.lwcChart.timeScale().getVisibleRange();
      if (!range) return 0;

      const { from } = range as IRange<number>;
      return from as number;
    };

    const warmupSymbols = (symbols: string[]) => {
      if (!symbols.length) return;

      const from = getWarmupFrom();
      if (!from) return;

      Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
        console.error('[Chart] Ошибка при прогреве символов:', error);
      });
    };
    const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
      map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
    );

    this.subscriptions.add(
      this.eventManager
        .getInterval()
        .pipe(withLatestFrom(symbols$))
        .subscribe(([interval, symbols]) => {
          this.currentInterval = interval;

          if (!interval) return;

          if (interval === Intervals.All) {
            Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
              .then(() => {
                requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
              })
              .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

            return;
          }

          const { from, to } = getIntervalRange(interval);

          Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
            .then(() => {
              this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
            })
            .catch((error) => {
              console.error('[Chart] Ошибка при применении интервала:', error);
            });
        }),
    );

    this.subscriptions.add(
      symbols$.subscribe((symbols) => {
        const prevSymbols = this.activeSymbols;
        this.activeSymbols = symbols;

        this.dataSource.setSymbols(symbols);

        const prevSet = new Set(prevSymbols);
        const added: string[] = [];

        for (let i = 0; i < symbols.length; i += 1) {
          const s = symbols[i];
          if (!s) continue;
          if (prevSet.has(s)) continue;
          added.push(s);
        }

        if (added.length) {
          warmupSymbols(added);
        }
      }),
    );

    this.subscriptions.add(
      combineLatest([
        this.eventManager.symbol(),
        this.compareManager.itemsObs(),
        this.mainSeries.asObservable(),
      ]).subscribe(([main, items, serie]) => {
        if (!serie) return;

        const title = items.length ? main : '';
        serie.getLwcSeries().applyOptions({ title });
      }),
    );
  }

  private setupHistoricalDataLoading(): void {
    // todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
    this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
      if (!logicalRange) return;
      if (this.currentInterval === Intervals.All) return;

      const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
      if (!needsMoreData) return;

      this.scheduleHistoryBatch();
    });
  }
}

function getIntervalRange(interval: Intervals): { from: number; to: number } {
  const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;

  const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
  const to = Math.floor(dayjs().valueOf() / 1000);

  return { from, to };
}

function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
  const timeFormat = config.timeFormat ?? Defaults.timeFormat;
  const showTime = config.showTime ?? Defaults.showTime;

  const use12HourFormat = timeFormat === '12h';
  const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';

  const { colors } = getThemeStore();

  return {
    width: config.container.clientWidth,
    height: config.container.clientHeight,
    autoSize: true,
    layout: {
      background: { color: colors.chartBackground },
      textColor: colors.chartTextPrimary,
    },
    grid: {
      vertLines: { color: colors.chartGridLine },
      horzLines: { color: colors.chartGridLine },
    },
    crosshair: {
      mode: CrosshairMode.Normal,
      vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
      horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
    },
    timeScale: {
      timeVisible: showTime,
      secondsVisible: false,
      tickMarkFormatter: createTickMarkFormatter(timeFormatString),
      borderVisible: false,
      allowBoldLabels: false,
    },
    rightPriceScale: {
      textColor: colors.chartTextPrimary,
      borderVisible: false,
    },
  };
}