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


import { isNearPoint } from '@core/Drawings/helpers';
import { getThemeStore } from '@src/theme';

import type { Point } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitive,
  SeriesAttachedParameter,
  Time,
} from 'lightweight-charts';

export type DrawingHandleShape = 'circle' | 'rounded';

export interface DrawingHandle<TId extends string = string> {
  id: TId;
  x: number;
  y: number;
  shape?: DrawingHandleShape;
  size?: number;
  borderWidth?: number;
  strokeColor?: string;
}

const DEFAULT_HANDLE_SIZE = 12;
const DEFAULT_HANDLE_BORDER_WIDTH = 2;
const HANDLE_HIT_TOLERANCE = 8;
const HANDLE_RADIUS = 3;
const CIRCLE_SIZE_FACTOR = 1.1;

export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
  private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
  private series: SeriesAttachedParameter<Time>['series'] | null = null;

  private readonly paneRenderer: IPrimitivePaneRenderer = {
    draw: (target) => this.drawPane(target),
  };

  private readonly timeAxisRenderer: IPrimitivePaneRenderer = {
    draw: (target) => this.drawTimeAxis(target),
  };

  private readonly paneViewsList: readonly IPrimitivePaneView[] = [
    {
      renderer: () => this.paneRenderer,
      zOrder: () => 'top',
    },
  ];

  private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[] = [
    {
      renderer: () => this.timeAxisRenderer,
      zOrder: () => 'top',
    },
  ];

  constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {}

  public attached({ chart, series }: SeriesAttachedParameter<Time>): void {
    this.chart = chart;
    this.series = series;
  }

  public detached(): void {
    this.chart = null;
    this.series = null;
  }

  public updateAllViews(): void {}

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

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

  public findHandle(point: Point): DrawingHandle<TId> | null {
    const handles = this.getHandles();

    for (let index = handles.length - 1; index >= 0; index -= 1) {
      const handle = handles[index];

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

    return null;
  }

  public isTimeAxisHit(point: Point): boolean {
    if (!this.isTimeAxisAdjacent()) {
      return false;
    }

    const paneHeight = this.getPaneHeight();

    if (paneHeight <= 0 || point.y < paneHeight) {
      return false;
    }

    const handle = this.findHandle(point);

    return handle !== null && intersectsTimeAxis(handle, paneHeight);
  }

  private drawPane(target: CanvasRenderingTarget2D): void {
    const handles = this.getHandles();

    if (!handles.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      for (const handle of handles) {
        drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
      }
    });
  }

  private drawTimeAxis(target: CanvasRenderingTarget2D): void {
    if (!this.isTimeAxisAdjacent()) {
      return;
    }

    const paneHeight = this.getPaneHeight();

    if (paneHeight <= 0) {
      return;
    }

    const handles = this.getHandles();

    if (!handles.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
      const axisHeight = bitmapSize.height / verticalPixelRatio;

      for (const handle of handles) {
        if (!intersectsTimeAxis(handle, paneHeight)) {
          continue;
        }

        const axisY = handle.y - paneHeight;
        const halfSize = getVisualHandleSize(handle) / 2;

        if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
          continue;
        }

        drawHandle(context, { ...handle, y: axisY }, horizontalPixelRatio, verticalPixelRatio);
      }
    });
  }

  private getPaneHeight(): number {
    return this.series?.getPane().getHeight() ?? 0;
  }

  private isTimeAxisAdjacent(): boolean {
    if (!this.chart || !this.series) {
      return false;
    }

    return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
  }
}

function getVisualHandleSize(handle: DrawingHandle): number {
  const size = handle.size ?? DEFAULT_HANDLE_SIZE;

  return (handle.shape ?? 'circle') === 'circle' ? size * CIRCLE_SIZE_FACTOR : size;
}

function getHitTolerance(handle: DrawingHandle): number {
  return Math.max(HANDLE_HIT_TOLERANCE, getVisualHandleSize(handle) / 2);
}

function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
  const halfSize = getVisualHandleSize(handle) / 2;

  return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
}

function drawHandle(
  context: CanvasRenderingContext2D,
  handle: DrawingHandle,
  horizontalPixelRatio: number,
  verticalPixelRatio: number,
): void {
  const size = getVisualHandleSize(handle);
  const shape = handle.shape ?? 'circle';
  const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
  const lineWidth = (handle.borderWidth ?? DEFAULT_HANDLE_BORDER_WIDTH) * pixelRatio;
  const width = size * horizontalPixelRatio;
  const height = size * verticalPixelRatio;
  const x = handle.x * horizontalPixelRatio;
  const y = handle.y * verticalPixelRatio;
  const inset = lineWidth / 2;
  const { colors } = getThemeStore();

  context.save();
  context.fillStyle = colors.chartBackground;
  context.strokeStyle = handle.strokeColor ?? colors.chartLineColor;
  context.lineWidth = lineWidth;
  context.beginPath();

  if (shape === 'circle') {
    const radius = Math.max(Math.min(width, height) / 2 - inset, 0);

    context.arc(x, y, radius, 0, Math.PI * 2);
  } else {
    const left = x - width / 2 + inset;
    const top = y - height / 2 + inset;
    const drawWidth = Math.max(width - lineWidth, 0);
    const drawHeight = Math.max(height - lineWidth, 0);

    drawRoundedRect(context, left, top, drawWidth, drawHeight, HANDLE_RADIUS * pixelRatio);
  }

  context.fill();
  context.stroke();
  context.restore();
}

function drawRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}