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


import { clamp } from 'lodash-es';

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

import type { Coordinate, IChartApi, Logical, Time, TouchMouseEventData } 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): Time | 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 (isValidCoordinate(coordinate)) {
    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 (!isValidCoordinate(projectedCoordinate)) {
    return null;
  }

  return projectedCoordinate;
}

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 getRawPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
  const rect = container.getBoundingClientRect();

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

export function getPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
  return clampPointToContainer(getRawPointerPoint(container, event), 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 (!isValidCoordinate(coordinate)) {
    return null;
  }

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

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);
  const price = getPriceFromYCoordinate(series, point.y);

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

  return {
    time,
    price,
  };
}

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

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

  const points = getSeriesTimePoints(series);

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

  if (points.length === 1) {
    return points[0].logical;
  }

  const lastIndex = points.length - 1;

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

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

  let left = 0;
  let right = lastIndex;

  while (left <= right) {
    const middleIndex = Math.floor((left + right) / 2);
    const middlePoint = points[middleIndex];

    if (middlePoint.time === targetTime) {
      return middlePoint.logical;
    }

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

  const previousPoint = points[right];
  const nextPoint = points[left];

  return interpolateLogical(targetTime, previousPoint, nextPoint);
}

function interpolateLogical(targetTime: number, startPoint: TimePoint, endPoint: TimePoint): number {
  const timeRange = endPoint.time - startPoint.time;

  if (timeRange === 0) {
    return startPoint.logical;
  }

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

  return startPoint.logical + (endPoint.logical - startPoint.logical) * ratio;
}

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 getNumericTime(time: Time): number | null {
  if (typeof time !== 'number') {
    return null;
  }

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

function isValidCoordinate(coordinate: Coordinate | null): coordinate is Coordinate {
  return coordinate !== null && Number.isFinite(Number(coordinate));
}

















import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  ISeriesApi,
  ISeriesPrimitive,
  ISeriesPrimitiveAxisView,
  Logical,
  MouseEventParams,
  PrimitiveHoveredItem,
  PrimitivePaneViewZOrder,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  SeriesType,
  Time,
  TouchMouseEventData,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import { getPointerPoint as getPointerPointFromEvent, getRawPointerPoint } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';

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

import type { DrawingHandle } from '@core/Drawings/handles';

export interface DrawingInteraction {
  selected$: Observable<boolean>;
  locked$: Observable<boolean>;

  isSelected(): boolean;
  isLocked(): boolean;

  select(): void;
  deselect(): void;
}

export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
  show(): void;
  hide(): void;
  rebind(series: ISeriesApi<SeriesType>): void;
  destroy(): void;

  waitTillReady(): Promise<void>;
  isCreationPending(): boolean;
  shouldShowInObjectTree(): boolean;

  getState(): unknown;
  setState(state: unknown): void;

  getSettings(): SettingsValues;
  getSettingsTabs(): SettingsTab[];
  updateSettings(settings: SettingsValues): void;

  subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;

  isHit(event: MouseEvent): boolean;
  pointerDown(event: PointerEvent): void;
  click(event: MouseEvent): void;
  doubleClick(event: MouseEvent): void;
  contextMenu(event: MouseEvent): void;

  getRenderData(): unknown;
}

export type StartPoint = unknown;

interface SeriesDrawingBaseParams {
  container: HTMLElement;
  chart: IChartApi;
  series: SeriesApi;
  interaction: DrawingInteraction;
}

export interface BaseDrawingParams {
  chart: IChartApi;
  series: SeriesApi;
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
  initialEvent?: MouseEventParams;
}

export abstract class SeriesDrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
> implements ISeriesDrawing
{
  protected hidden = false;
  protected chart: IChartApi;
  protected series: SeriesApi;
  protected subscriptions = new Subscription();
  protected abstract mode: unknown; // todo: хочется иметь единый mode
  protected abstract settings: TSettings;
  protected readonly container: HTMLElement;
  protected isBound = false;

  private readonly interaction: DrawingInteraction;
  private readonly settingsSubject = new Subject<SettingsValues>();
  private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
  private isInteractionBound = false;

  protected readyPromise: Promise<void> | null = null;
  protected resolveReady: (() => void) | null = null;
  protected requestUpdate: (() => void) | null = null;

  constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
    this.chart = chart;
    this.series = series;
    this.container = container;
    this.interaction = interaction;

    this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
      if (this.hidden || !this.shouldShowHandles()) {
        return [];
      }

      return this.getDrawingHandles();
    });
  }

  public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
    callback(this.getSettings());

    return this.settingsSubject.subscribe(callback);
  }

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

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

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

    this.showCrosshair();
    this.unbindEvents();
    this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);

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

    this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
    this.render();
  }

  public destroy(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.subscriptions.unsubscribe();
    this.settingsSubject.complete();
    this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
    this.requestUpdate = null;
    this.resolveReady?.();
  }

  public waitTillReady(): Promise<void> {
    if (this.mode === 'ready') {
      return Promise.resolve();
    }

    if (!this.readyPromise) {
      this.readyPromise = new Promise((resolve) => {
        this.resolveReady = resolve;
      });
    }

    return this.readyPromise;
  }

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

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

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

    this.settingsSubject.next(this.getSettings());
    this.render();
  }

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

  public detached(): void {
    this.series.detachPrimitive(this.handlesPrimitive);
    this.showCrosshair();
    this.unbindEvents();
    this.requestUpdate = null;
  }

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

  public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
    const hoveredItem = this.getHoveredItem(x, y);

    if (!hoveredItem || !this.isLocked()) {
      return hoveredItem;
    }

    return {
      ...hoveredItem,
      cursorStyle: 'pointer',
    };
  }

  public isHit(event: MouseEvent): boolean {
    if (!this.isEventInside(event)) {
      return false;
    }

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

    return this.getHoveredItem(point.x, point.y) !== null;
  }

  public pointerDown(event: PointerEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
      this.handlePointerDown(event);

      return;
    }

    const point = this.getEventPoint(event);

    if (this.getHoveredItem(point.x, point.y)) {
      this.select();

      return;
    }

    if (this.isSelected()) {
      this.deselect();
    }
  }

  public click(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleClick(event);
  }

  public doubleClick(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleDoubleClick(event);
  }

  public contextMenu(event: MouseEvent): void {
    if (!this.isEventInside(event)) {
      return;
    }

    this.handleContextMenu(event);
  }

  public abstract getRenderData(): unknown; // todo: make proper type
  public abstract getState(): unknown;
  public abstract getSettingsTabs(): SettingsTab[];
  public abstract isCreationPending(): boolean;
  public abstract setState(state: unknown): void;
  public abstract updateAllViews(): void;
  public abstract paneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
  public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
  public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];

  protected isSelected(): boolean {
    return this.interaction.isSelected();
  }

  protected isLocked(): boolean {
    return this.interaction.isLocked();
  }

  protected isAxisLabelAvailable(): boolean {
    return !this.hidden && !this.isCreationPending();
  }

  protected shouldShowInteractiveAxis(): boolean {
    return this.isSelected() || this.isCreationPending();
  }

  protected select(): void {
    this.interaction.select();
  }

  protected deselect(): void {
    this.interaction.deselect();
  }

  protected shouldShowHandles(): boolean {
    return !this.isLocked() && (this.isSelected() || this.isCreationPending());
  }

  protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
    return [];
  }

  protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
    return this.handlesPrimitive.findHandle(point);
  }

  protected createTimeAxisView(labelKind: string): CustomTimeAxisView {
    return new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind,
      viewport: this.container,
    });
  }

  protected createPriceAxisView(labelKind: string): CustomPriceAxisView {
    return new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind,
      viewport: this.container,
    });
  }

  protected createTimeAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomTimeAxisPaneView {
    return new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
      zOrder,
    });
  }

  protected createPriceAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomPriceAxisPaneView {
    return new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
      zOrder,
    });
  }

  protected subscribeFormat(
    formatObservable: Observable<ChartOptionsModel> | undefined,
    callback: (format: ChartOptionsModel) => void,
  ): void {
    if (!formatObservable) {
      return;
    }

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

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

  protected hideCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Hidden,
      },
    });
  }

  protected showCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Normal,
      },
    });
  }

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

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

    this.isBound = true;

    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
  }

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

    this.isBound = false;

    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

  // todo: хочется общую реализацию для каждой кнопки
  protected handleClick(event: MouseEvent): void {}
  protected handleContextMenu(event: MouseEvent): void {}
  protected handleDoubleClick(event: MouseEvent): void {}
  protected handlePointerMove(event: PointerEvent): void {}
  protected handlePointerUp(event: PointerEvent): void {}
  protected handlePointerDown(event: PointerEvent | TouchMouseEventData): void {}

  protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
  protected abstract getGeometry(): unknown; // todo: make proper type
  protected abstract getTimeAxisSegments(): AxisSegment[];
  protected abstract getPriceAxisSegments(): AxisSegment[];
  protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
  protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;

  private bindInteraction(): void {
    if (this.isInteractionBound) {
      return;
    }

    this.isInteractionBound = true;

    this.subscriptions.add(
      this.interaction.selected$.subscribe(() => {
        this.render();
      }),
    );

    this.subscriptions.add(
      this.interaction.locked$.subscribe((isLocked) => {
        if (isLocked) {
          this.showCrosshair();
        }

        this.render();
      }),
    );
  }

  private isEventInside(event: MouseEvent): boolean {
    if (event.target instanceof Node && this.container.contains(event.target)) {
      return true;
    }

    return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
  }
}

















import {
  clampPointToContainer,
  getAnchorFromPoint,
  getContainerSize,
  getPriceDelta,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { SeriesDrawingBase } from '@core/Drawings/SeriesDrawingBase';

import type { Anchor, Bounds, ContainerSize, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';

export type TwoPointAnchorKind = 'start' | 'end';

export interface TwoPointGeometry extends Bounds {
  startPoint: Point;
  endPoint: Point;
}

export abstract class TwoPointDrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
> extends SeriesDrawingBase<TSettings, THandleId> {
  protected startAnchor: Anchor | null = null;
  protected endAnchor: Anchor | null = null;

  protected getAnchor(kind: TwoPointAnchorKind): Anchor | null {
    return kind === 'start' ? this.startAnchor : this.endAnchor;
  }

  protected setAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
    if (kind === 'start') {
      this.startAnchor = anchor;
      return;
    }

    this.endAnchor = anchor;
  }

  protected setAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
    this.startAnchor = startAnchor;
    this.endAnchor = endAnchor;
  }

  protected createAnchor(point: Point): Anchor | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

  protected getPointFromAnchor(anchor: Anchor | null): Point | null {
    if (!anchor) {
      return null;
    }

    const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
    const y = getYCoordinateFromPrice(this.series, anchor.price);

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

    return {
      x: Math.round(Number(x)),
      y: Math.round(Number(y)),
    };
  }

  protected setAnchorFromPoint(kind: TwoPointAnchorKind, point: Point): boolean {
    const anchor = this.createAnchor(point);

    if (!anchor) {
      return false;
    }

    this.setAnchor(kind, anchor);

    return true;
  }

  protected setAnchorsFromPoints(startPoint: Point, endPoint: Point): boolean {
    const startAnchor = this.createAnchor(startPoint);
    const endAnchor = this.createAnchor(endPoint);

    if (!startAnchor || !endAnchor) {
      return false;
    }

    this.setAnchors(startAnchor, endAnchor);

    return true;
  }

  protected getAnchorTimeCoordinate(kind: TwoPointAnchorKind): number | null {
    const anchor = this.getAnchor(kind);

    if (!anchor) {
      return null;
    }

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

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

  protected getAnchorPriceCoordinate(kind: TwoPointAnchorKind): number | null {
    const anchor = this.getAnchor(kind);

    if (!anchor) {
      return null;
    }

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

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

  protected moveAnchorsByPixels(
    startAnchor: Anchor | null,
    endAnchor: Anchor | null,
    dragStartPoint: Point | null,
    point: Point,
  ): boolean {
    if (!startAnchor || !endAnchor || !dragStartPoint) {
      return false;
    }

    const offsetX = point.x - dragStartPoint.x;
    const priceOffset = getPriceDelta(this.series, dragStartPoint.y, point.y);

    const nextStartTime = shiftTimeByPixels(this.chart, startAnchor.time, offsetX, this.series);
    const nextEndTime = shiftTimeByPixels(this.chart, endAnchor.time, offsetX, this.series);

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

    this.startAnchor = {
      time: nextStartTime,
      price: startAnchor.price + priceOffset,
    };

    this.endAnchor = {
      time: nextEndTime,
      price: endAnchor.price + priceOffset,
    };

    return true;
  }

  protected getContainerSize(): ContainerSize {
    return getContainerSize(this.container);
  }

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

  protected getTwoPointGeometry(): TwoPointGeometry | null {
    const startPoint = this.getPointFromAnchor(this.startAnchor);
    const endPoint = this.getPointFromAnchor(this.endAnchor);

    if (!startPoint || !endPoint) {
      return null;
    }

    return {
      startPoint,
      endPoint,
      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),
    };
  }

  protected getGeometry(): TwoPointGeometry | null {
    return this.getTwoPointGeometry();
  }
}
















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

import { getDistanceToSegment } from '@core/Drawings/utils';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisSegment, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';

export abstract class LinearDrawingBase<
  TSettings extends SettingsValues = SettingsValues,
  THandleId extends string = string,
> extends TwoPointDrawingBase<TSettings, THandleId> {
  protected abstract getStartHandleId(): THandleId;

  protected abstract getEndHandleId(): THandleId;

  protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return [];
    }

    return [
      {
        id: this.getStartHandleId(),
        ...geometry.startPoint,
        shape: 'circle',
      },
      {
        id: this.getEndHandleId(),
        ...geometry.endPoint,
        shape: 'circle',
      },
    ];
  }

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

    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

  protected isPointNearLine(point: Point, tolerance: number): boolean {
    const geometry = this.getTwoPointGeometry();

    if (!geometry) {
      return false;
    }

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
















import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';

import {
  CustomPriceAxisPaneView,
  CustomPriceAxisView,
  CustomTimeAxisPaneView,
  CustomTimeAxisView,
} from '@core/Drawings/axis';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { updateViews } from '@core/Drawings/utils';

import { getThemeStore } from '@src/theme';
import { type ChartOptionsModel, LineMarker, type SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { LineDrawingPaneView } from './paneView';
import {
  createDefaultSettings,
  getLineDrawingSettingsTabs,
  LineDrawingMarkers,
  LineDrawingSettings,
  LineDrawingStyle,
  LineDrawingTextStyle,
} from './settings';

import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { TwoPointGeometry } from '@core/Drawings/TwoPointDrawingBase';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';

type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type LineDrawingHandleKey = 'start' | 'end';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';

interface LineDrawingParams extends BaseDrawingParams {
  defaultMarkers?: Partial<LineDrawingMarkers>;
}

interface LineDrawingState {
  hidden: boolean;
  mode: LineDrawingMode;
  startAnchor: Anchor | null;
  endAnchor: Anchor | null;
  settings: LineDrawingSettings;
}

export interface LineDrawingRenderData extends TwoPointGeometry, LineDrawingStyle, LineDrawingTextStyle {}

const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;

export class LineDrawing
  extends LinearDrawingBase<LineDrawingSettings, LineDrawingHandleKey>
  implements ISeriesDrawing
{
  private removeSelf?: () => void;
  private openSettings?: () => void;
  private readonly defaultMarkers: LineDrawingMarkers;

  protected settings: LineDrawingSettings;
  protected mode: LineDrawingMode = 'idle';

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragStateSnapshot: LineDrawingState | null = null;

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

  private readonly paneView: LineDrawingPaneView;
  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,
    series,
    container,
    interaction,
    formatObservable,
    removeSelf,
    openSettings,
    initialEvent,
    defaultMarkers = {},
  }: LineDrawingParams) {
    super({
      chart,
      series,
      container,
      interaction,
    });

    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.defaultMarkers = {
      startMarker: LineMarker.normal,
      endMarker: LineMarker.normal,
      ...defaultMarkers,
    };

    this.settings = createDefaultSettings(this.defaultMarkers);

    this.paneView = new LineDrawingPaneView(this);

    this.timeAxisPaneView = this.createTimeAxisPaneView();
    this.priceAxisPaneView = this.createPriceAxisPaneView();

    this.startTimeAxisView = this.createTimeAxisView('start');
    this.endTimeAxisView = this.createTimeAxisView('end');

    this.startPriceAxisView = this.createPriceAxisView('start');
    this.endPriceAxisView = this.createPriceAxisView('end');

    this.subscribeFormat(formatObservable, (format) => {
      this.displayFormat = format;
    });

    this.series.attachPrimitive(this);

    if (initialEvent?.sourceEvent) {
      this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
    }
  }

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

  public getState(): LineDrawingState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      startAnchor: this.startAnchor,
      endAnchor: this.endAnchor,
      settings: { ...this.settings },
    };
  }

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

    const nextState = state as Partial<LineDrawingState>;

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

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

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

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

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

    this.render();
  }

  public getSettingsTabs(): SettingsTab[] {
    return getLineDrawingSettingsTabs(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(): LineDrawingRenderData | null {
    if (this.hidden) {
      return null;
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
    };
  }

  protected getStartHandleId(): LineDrawingHandleKey {
    return 'start';
  }

  protected getEndHandleId(): LineDrawingHandleKey {
    return 'end';
  }

  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.getDrawingHandleAtPoint(point)) {
      return {
        cursorStyle: 'move',
        externalId: 'line-drawing',
        zOrder: 'top',
      };
    }

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

    return {
      cursorStyle: 'grab',
      externalId: 'line-drawing',
      zOrder: 'top',
    };
  }

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

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

    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.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text,
      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.getDrawingHandleAtPoint(point) && !this.isPointNearLine(point, LINE_HIT_TOLERANCE)) {
      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' || this.mode === 'drawing') {
      event.preventDefault();
      event.stopPropagation();

      if (this.mode === 'idle') {
        this.startDrawing(point);
      } else {
        this.updateDrawing(point);
        this.finishDrawing();
      }

      return;
    }

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

    const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
    const isNearLine = this.isPointNearLine(point, LINE_HIT_TOLERANCE);
    const isDrawingHit = pointTarget !== null || isNearLine;
    const isSelected = this.isSelected();

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

      return;
    }

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

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

    const dragMode: LineDrawingMode = `dragging-${pointTarget ?? 'body'}`;
    this.startDragging(dragMode, point, event.pointerId);
  };

  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 (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
      this.finishDragging();
    }
  };

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

    if (!anchor) {
      return;
    }

    this.setAnchors(anchor, anchor);
    this.mode = 'drawing';

    this.render();
  }

  private updateDrawing(point: Point): void {
    if (!this.setAnchorFromPoint('end', point)) {
      return;
    }

    this.render();
  }

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

    if (!geometry) {
      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: LineDrawingMode, point: Point, pointerId: number): void {
    this.mode = mode;
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragStateSnapshot = this.getState();

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

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

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

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

  private movePoint(point: Point): void {
    if (this.mode === 'dragging-start') {
      this.setAnchorFromPoint('start', point);
      return;
    }

    if (this.mode === 'dragging-end') {
      this.setAnchorFromPoint('end', point);
    }
  }

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

    if (!snapshot) {
      return;
    }

    this.moveAnchorsByPixels(
      snapshot.startAnchor,
      snapshot.endAnchor,
      this.dragStartPoint,
      point,
    );
  }

  private getTimeText(kind: TimeLabelKind): string {
    const anchor = this.getAnchor(kind);

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

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

  private getPriceText(kind: PriceLabelKind): string {
    const anchor = this.getAnchor(kind);

    if (!anchor) {
      return '';
    }

    return formatPrice(anchor.price) ?? '';
  }
}