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


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 {
  const coordinate = xCoordinate as Coordinate;
  const time = chart.timeScale().coordinateToTime(coordinate);

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

  if (!series) {
    return null;
  }

  const logical = chart.timeScale().coordinateToLogical(coordinate);

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

  return getTimeFromLogical(series, Number(logical));
}

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

  return chart.timeScale().logicalToCoordinate(logical as Logical);
}

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





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

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 left = data.left * horizontalPixelRatio;
      const right = data.right * horizontalPixelRatio;
      const top = data.top * verticalPixelRatio;
      const bottom = data.bottom * verticalPixelRatio;

      const visibleLeft = clamp(left, 0, bitmapSize.width);
      const visibleRight = clamp(right, 0, bitmapSize.width);
      const visibleTop = clamp(top, 0, bitmapSize.height);
      const visibleBottom = clamp(bottom, 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);
      }

      drawVisibleBorders(context, {
        left,
        right,
        top,
        bottom,
        visibleLeft,
        visibleRight,
        visibleTop,
        visibleBottom,
        bitmapWidth: bitmapSize.width,
        bitmapHeight: bitmapSize.height,
        borderColor: data.borderColor,
        pixelRatio,
      });

      drawRectangleText(context, {
        left: visibleLeft,
        top: visibleTop,
        originalTop: top,
        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 drawVisibleBorders(
  context: CanvasRenderingContext2D,
  params: {
    left: number;
    right: number;
    top: number;
    bottom: number;
    visibleLeft: number;
    visibleRight: number;
    visibleTop: number;
    visibleBottom: number;
    bitmapWidth: number;
    bitmapHeight: number;
    borderColor: string;
    pixelRatio: number;
  },
): void {
  const {
    left,
    right,
    top,
    bottom,
    visibleLeft,
    visibleRight,
    visibleTop,
    visibleBottom,
    bitmapWidth,
    bitmapHeight,
    borderColor,
    pixelRatio,
  } = params;

  context.save();

  context.strokeStyle = borderColor;
  context.lineWidth = UI.borderWidth * pixelRatio;

  context.beginPath();

  if (left >= 0 && left <= bitmapWidth) {
    context.moveTo(left, visibleTop);
    context.lineTo(left, visibleBottom);
  }

  if (right >= 0 && right <= bitmapWidth) {
    context.moveTo(right, visibleTop);
    context.lineTo(right, visibleBottom);
  }

  if (top >= 0 && top <= bitmapHeight) {
    context.moveTo(visibleLeft, top);
    context.lineTo(visibleRight, top);
  }

  if (bottom >= 0 && bottom <= bitmapHeight) {
    context.moveTo(visibleLeft, bottom);
    context.lineTo(visibleRight, bottom);
  }

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