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


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

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

import { Text } from './text';

const UI = {
  handleRadius: 5,
  handleBorderWidth: 2,
};

export class TextPaneRenderer implements IPrimitivePaneRenderer {
  private readonly text: Text;

  constructor(text: Text) {
    this.text = text;
  }

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

    if (!data) {
      return;
    }

    const { colors } = getThemeStore();

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

      context.save();

      if (!data.point) {
        return;
      }

      if (data.point) {
        context.save();
        const options = {
          // todo: take this from this.text.getRenderData();
          font: '24px Arial',
          color: 'white',
          align: 'left',
          baseline: 'alphabetic',
          stroke: false,
          strokeColor: 'white',
          lineWidth: 1,
          shadow: null,
        };

        context.font = options.font;
        context.fillStyle = options.color;
        context.textAlign = options.align as CanvasTextAlign;
        context.textBaseline = options.baseline as CanvasTextBaseline;

        const x = data.point.x * horizontalPixelRatio;
        const y = data.point.y * verticalPixelRatio;

        context.fillText('text', x, y); // todo: take text from this.text.getRenderData();

        context.restore();
      }

      if (data.showHandles) {
        context.save();

        context.fillStyle = colors.chartBackground;
        context.strokeStyle = colors.chartLineColor;
        context.lineWidth = handleBorderWidth;
        drawHandle(context, data.point.x * horizontalPixelRatio, data.point.y * verticalPixelRatio, handleRadius);

        context.restore();
      }

      context.restore();
    });
  }
}

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




import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
} from 'lightweight-charts';
import { Observable } from 'rxjs';

import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPointerPoint as getPointerPointFromEvent,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isNearPoint,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

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

import { TextPaneView } from './paneView';

import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';

type TextMode = 'idle' | 'dragging' | 'ready';

export interface TextState {
  hidden: boolean;
  isActive: boolean;
  mode: TextMode;
  point: Anchor | null;
}

interface TextGeometry {
  point: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
}

export interface TextRenderData extends TextGeometry {
  showHandles: boolean;
}

const POINT_HIT_TOLERANCE = 8;

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

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

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

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

  private point: Anchor | null = null;

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragGeometrySnapshot: TextGeometry | null = null;

  private readonly paneView: TextPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;

  constructor(chart: IChartApi, series: SeriesApi, params: TextParams) {
    const { container, removeSelf } = params;

    this.chart = chart;
    this.series = series;
    this.container = container;
    this.removeSelf = removeSelf;

    this.paneView = new TextPaneView(this);

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

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

    this.series.attachPrimitive(this);
  }

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

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

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

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

    this.showCrosshair();
    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';
  }

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

  public getState(): TextState {
    return {
      hidden: this.hidden,
      isActive: this.isActive,
      mode: this.mode,
      point: this.point,
    };
  }

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

    const nextState = state as Partial<TextState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    this.isActive = typeof nextState.isActive === 'boolean' ? nextState.isActive : this.isActive;
    this.mode = nextState.mode ?? this.mode;
    this.point = nextState.point ?? this.point;

    this.render();
  }

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

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

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

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

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

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

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

  public autoscaleInfo(): AutoscaleInfo | null {
    return null;
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      showHandles: this.isActive,
    };
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

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

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

    const point = { x, y };
    const isClickedOnThisDrawing = this.isClickedOnThisDrawing(point);

    if (!isClickedOnThisDrawing) {
      return null;
    }

    return {
      cursorStyle: 'move',
      externalId: 'text',
      zOrder: 'top',
    };
  }

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

    this.isBound = true;

    this.container.addEventListener('pointerdown', this.handlePointerDown);
    this.container.addEventListener('dblclick', this.handleDoubleClick);
    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('pointerdown', this.handlePointerDown);
    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

  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.setPoint(point);
      return;
    }

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

    const isClickedOnThisDrawing = this.isClickedOnThisDrawing(point);

    if (!this.isActive) {
      if (!isClickedOnThisDrawing) {
        return;
      }

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

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

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

      this.startDragging(point, event.pointerId);
      return;
    }
    this.isActive = false;
    this.render();
  };

  private handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden) {
      return;
    }

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

    this.openModalOptions();
  };

  private openModalOptions = (): void => {
    // todo: open modal with settings (text just for now)
    console.log('openModalOptions');
  };

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

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

    if (this.mode === 'dragging') {
      event.preventDefault();
      this.movePoint(point);
      this.render();
    }
  };

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

    if (this.mode === 'dragging') {
      this.finishDragging();
    }
  };

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

    if (!anchor) {
      return;
    }

    this.point = anchor;
    this.isActive = true;
    this.mode = 'ready';
    this.render();
  }

  private startDragging(point: Point, pointerId: number): void {
    this.mode = 'dragging';
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragGeometrySnapshot = this.getGeometry();

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

  private finishDragging(): void {
    this.mode = 'ready';
    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragGeometrySnapshot = null;

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

  private movePoint(eventPoint: Point): void {
    const geometry = this.dragGeometrySnapshot;

    if (!geometry) {
      return;
    }

    const nextPoint = this.clampPointToContainer(eventPoint);

    this.point = this.createAnchor(nextPoint);
  }

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

  private getGeometry(): TextGeometry | null {
    if (!this.point) {
      return null;
    }

    const { width, height } = this.getContainerSize();

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

    const screenPoint: Point = {
      x: clamp(Math.round(Number(x)), 0, width),
      y: clamp(Math.round(Number(y)), 0, height),
    };

    const left = Math.round(screenPoint.x);
    const right = Math.round(screenPoint.x);
    const top = Math.round(screenPoint.y);
    const bottom = Math.round(screenPoint.y);

    return {
      point: screenPoint,
      left,
      right,
      top,
      bottom,
    };
  }

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

    if (!geometry) {
      return false;
    }

    return isNearPoint(point, geometry.point.x, geometry.point.y, POINT_HIT_TOLERANCE);
  }

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

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

  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?.();
  }
}