Загрузка данных
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();
}