Загрузка данных
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { ParallelChannelPaneView } from './paneView';
import {
createDefaultSettings,
getParallelChannelSettingsTabs,
ParallelChannelSettings,
ParallelChannelStyle,
ParallelChannelTextStyle,
} from './settings';
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type ParallelChannelMode =
| 'idle'
| 'drawing-line'
| 'drawing-channel'
| 'ready'
| 'dragging';
type ParallelChannelDragTarget =
| 'main-start'
| 'main-middle'
| 'main-end'
| 'parallel-start'
| 'parallel-middle'
| 'parallel-end'
| 'body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind =
| 'main-start'
| 'main-end'
| 'parallel-start'
| 'parallel-end';
interface ParallelChannelParams {
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
openSettings?: () => void;
}
interface ParallelChannelState {
hidden: boolean;
mode: ParallelChannelMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
priceOffset: number | null;
settings: ParallelChannelSettings;
}
interface ParallelChannelGeometry {
startPoint: Point;
mainMiddlePoint: Point;
endPoint: Point;
parallelStartPoint: Point;
parallelMiddlePoint: Point;
parallelEndPoint: Point;
middleStartPoint: Point;
middleEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface ParallelChannelRenderData
extends ParallelChannelGeometry,
ParallelChannelStyle,
ParallelChannelTextStyle {
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;
const VERTICAL_LINE_TOLERANCE = 0.001;
export class ParallelChannel extends SeriesDrawingBase<ParallelChannelSettings> implements ISeriesDrawing {
private openSettings?: () => void;
protected settings: ParallelChannelSettings = createDefaultSettings();
protected mode: ParallelChannelMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private priceOffset: number | null = null;
private activeDragTarget: ParallelChannelDragTarget | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: ParallelChannelState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private paneView: ParallelChannelPaneView;
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
private mainStartPriceAxisView: CustomPriceAxisView;
private mainEndPriceAxisView: CustomPriceAxisView;
private parallelStartPriceAxisView: CustomPriceAxisView;
private parallelEndPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{
container,
interaction,
formatObservable,
openSettings,
}: ParallelChannelParams,
) {
super({
chart,
series,
container,
interaction,
});
this.openSettings = openSettings;
this.paneView = new ParallelChannelPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.mainStartPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main-start',
});
this.mainEndPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main-end',
});
this.parallelStartPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'parallel-start',
});
this.parallelEndPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'parallel-end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return (
this.mode === 'idle' ||
this.mode === 'drawing-line' ||
this.mode === 'drawing-channel'
);
}
public getState(): ParallelChannelState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
priceOffset: this.priceOffset,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<ParallelChannelState>;
if (typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if (nextState.mode) {
this.mode = nextState.mode === 'dragging' ? 'ready' : nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('priceOffset' in nextState) {
this.priceOffset = nextState.priceOffset ?? null;
}
if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getParallelChannelSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.mainStartPriceAxisView,
this.mainEndPriceAxisView,
this.parallelStartPriceAxisView,
this.parallelEndPriceAxisView,
]);
}
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.mainStartPriceAxisView,
this.mainEndPriceAxisView,
this.parallelStartPriceAxisView,
this.parallelEndPriceAxisView,
];
}
public getRenderData(): ParallelChannelRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
showHandles: this.shouldShowHandles(),
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
}
const point = { x, y };
const pointTarget = this.getPointTarget(point);
const isChannelHit = this.isPointOnChannel(point);
if (!pointTarget && !isChannelHit) {
return null;
}
if (!this.isSelected()) {
return {
cursorStyle: 'pointer',
externalId: 'parallel-channel',
zOrder: 'top',
};
}
return {
cursorStyle: pointTarget ? 'move' : 'grab',
externalId: 'parallel-channel',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (
(!this.isSelected() && !this.isCreationPending()) ||
(kind !== 'start' && kind !== 'end')
) {
return null;
}
const anchor = kind === 'start'
? this.startAnchor
: this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(
this.chart,
anchor.time,
this.series,
);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (
(!this.isSelected() && !this.isCreationPending()) ||
!isPriceLabelKind(kind)
) {
return null;
}
const price = this.getPriceLabelValue(kind);
if (price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(
this.series,
price,
);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (
this.hidden ||
this.mode !== 'ready' ||
!this.isSelected()
) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (
!this.isPointOnChannel(point) &&
!this.getPointTarget(point)
) {
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') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing-line') {
event.preventDefault();
event.stopPropagation();
this.setEndAnchor(point);
if (!this.hasValidMainLine()) {
this.render();
return;
}
this.priceOffset = 0;
this.mode = 'drawing-channel';
this.render();
return;
}
if (this.mode === 'drawing-channel') {
event.preventDefault();
event.stopPropagation();
this.setPriceOffset(point);
if (!this.hasValidChannelWidth()) {
this.render();
return;
}
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
const isChannelHit = this.isPointOnChannel(point);
const isDrawingHit = pointTarget !== null || isChannelHit;
if (!this.isSelected()) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (pointTarget) {
event.preventDefault();
event.stopPropagation();
this.startDragging(
pointTarget,
point,
event.pointerId,
);
return;
}
if (isChannelHit) {
event.preventDefault();
event.stopPropagation();
this.startDragging(
'body',
point,
event.pointerId,
);
return;
}
this.deselect();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing-line') {
this.setEndAnchor(point);
this.render();
return;
}
if (this.mode === 'drawing-channel') {
this.setPriceOffset(point);
this.render();
return;
}
if (
this.mode !== 'dragging' ||
this.dragPointerId !== event.pointerId ||
!this.activeDragTarget
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.applyDrag(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (
this.mode !== 'dragging' ||
this.dragPointerId !== event.pointerId
) {
return;
}
this.finishDragging();
};
protected getGeometry(): ParallelChannelGeometry | null {
if (
!this.startAnchor ||
!this.endAnchor ||
this.priceOffset === null
) {
return null;
}
const startPoint = this.getAnchorPoint(this.startAnchor);
const endPoint = this.getAnchorPoint(this.endAnchor);
const parallelStartPoint = this.getAnchorPoint({
time: this.startAnchor.time,
price: this.startAnchor.price + this.priceOffset,
});
const parallelEndPoint = this.getAnchorPoint({
time: this.endAnchor.time,
price: this.endAnchor.price + this.priceOffset,
});
if (
!startPoint ||
!endPoint ||
!parallelStartPoint ||
!parallelEndPoint
) {
return null;
}
const mainMiddlePoint = getMiddlePoint(
startPoint,
endPoint,
);
const parallelMiddlePoint = getMiddlePoint(
parallelStartPoint,
parallelEndPoint,
);
const middleStartPoint = getMiddlePoint(
startPoint,
parallelStartPoint,
);
const middleEndPoint = getMiddlePoint(
endPoint,
parallelEndPoint,
);
const points = [
startPoint,
endPoint,
parallelStartPoint,
parallelEndPoint,
];
return {
startPoint,
mainMiddlePoint,
endPoint,
parallelStartPoint,
parallelMiddlePoint,
parallelEndPoint,
middleStartPoint,
middleEndPoint,
left: Math.min(...points.map((point) => point.x)),
right: Math.max(...points.map((point) => point.x)),
top: Math.min(...points.map((point) => point.y)),
bottom: Math.max(...points.map((point) => point.y)),
};
}
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.priceOffset = 0;
this.mode = 'drawing-line';
this.render();
}
private finishDrawing(): void {
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(
target: ParallelChannelDragTarget,
point: Point,
pointerId: number,
): void {
this.mode = 'dragging';
this.activeDragTarget = target;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private applyDrag(point: Point): void {
switch (this.activeDragTarget) {
case 'main-start':
this.moveMainEdge('start', point);
break;
case 'main-middle':
this.moveMainMiddle(point);
break;
case 'main-end':
this.moveMainEdge('end', point);
break;
case 'parallel-start':
this.moveParallelEdge('start', point);
break;
case 'parallel-middle':
this.moveParallelMiddle(point);
break;
case 'parallel-end':
this.moveParallelEdge('end', point);
break;
case 'body':
this.moveBody(point);
break;
default:
break;
}
}
private moveMainEdge(
kind: TimeLabelKind,
point: Point,
): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
const previousAnchor = kind === 'start'
? this.startAnchor
: this.endAnchor;
if (kind === 'start') {
this.startAnchor = anchor;
} else {
this.endAnchor = anchor;
}
if (this.hasValidMainLine()) {
return;
}
if (kind === 'start') {
this.startAnchor = previousAnchor;
} else {
this.endAnchor = previousAnchor;
}
}
private moveParallelEdge(
kind: TimeLabelKind,
point: Point,
): void {
const snapshot = this.dragStateSnapshot;
const anchor = this.createAnchor(point);
if (
!snapshot ||
snapshot.priceOffset === null ||
!anchor
) {
return;
}
const previousAnchor = kind === 'start'
? this.startAnchor
: this.endAnchor;
const baseAnchor: Anchor = {
time: anchor.time,
price: anchor.price - snapshot.priceOffset,
};
if (kind === 'start') {
this.startAnchor = baseAnchor;
} else {
this.endAnchor = baseAnchor;
}
if (this.hasValidMainLine()) {
return;
}
if (kind === 'start') {
this.startAnchor = previousAnchor;
} else {
this.endAnchor = previousAnchor;
}
}
private moveMainMiddle(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (
!snapshot?.startAnchor ||
!snapshot.endAnchor ||
snapshot.priceOffset === null
) {
return;
}
const pointerPrice = getPriceFromYCoordinate(
this.series,
point.y,
);
const linePrice = this.getLinePriceAtX(
snapshot.startAnchor,
snapshot.endAnchor,
point.x,
);
if (
pointerPrice === null ||
linePrice === null
) {
return;
}
const priceDelta = pointerPrice - linePrice;
this.startAnchor = {
...snapshot.startAnchor,
price: snapshot.startAnchor.price + priceDelta,
};
this.endAnchor = {
...snapshot.endAnchor,
price: snapshot.endAnchor.price + priceDelta,
};
this.priceOffset = snapshot.priceOffset - priceDelta;
if (this.hasValidChannelWidth()) {
return;
}
this.startAnchor = snapshot.startAnchor;
this.endAnchor = snapshot.endAnchor;
this.priceOffset = snapshot.priceOffset;
}
private moveParallelMiddle(point: Point): void {
const previousOffset = this.priceOffset;
this.setPriceOffset(point);
if (this.hasValidChannelWidth()) {
return;
}
this.priceOffset = previousOffset;
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (
!snapshot?.startAnchor ||
!snapshot.endAnchor ||
snapshot.priceOffset === null ||
!this.dragStartPoint
) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceDelta = getPriceDeltaFromCoordinates(
this.series,
this.dragStartPoint.y,
point.y,
);
const startTime = shiftTimeByPixels(
this.chart,
snapshot.startAnchor.time,
offsetX,
this.series,
);
const endTime = shiftTimeByPixels(
this.chart,
snapshot.endAnchor.time,
offsetX,
this.series,
);
if (
startTime === null ||
endTime === null
) {
return;
}
this.startAnchor = {
time: startTime,
price: snapshot.startAnchor.price + priceDelta,
};
this.endAnchor = {
time: endTime,
price: snapshot.endAnchor.price + priceDelta,
};
this.priceOffset = snapshot.priceOffset;
}
private setEndAnchor(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
}
private setPriceOffset(point: Point): void {
if (
!this.startAnchor ||
!this.endAnchor
) {
return;
}
const pointerPrice = getPriceFromYCoordinate(
this.series,
point.y,
);
const linePrice = this.getLinePriceAtX(
this.startAnchor,
this.endAnchor,
point.x,
);
if (
pointerPrice === null ||
linePrice === null
) {
return;
}
this.priceOffset = pointerPrice - linePrice;
}
private getLinePriceAtX(
startAnchor: Anchor,
endAnchor: Anchor,
x: number,
): number | null {
const startPoint = this.getAnchorPoint(startAnchor);
const endPoint = this.getAnchorPoint(endAnchor);
if (!startPoint || !endPoint) {
return null;
}
const deltaX = endPoint.x - startPoint.x;
if (Math.abs(deltaX) <= VERTICAL_LINE_TOLERANCE) {
return getPriceFromYCoordinate(
this.series,
(startPoint.y + endPoint.y) / 2,
);
}
const ratio = (x - startPoint.x) / deltaX;
const y =
startPoint.y +
(endPoint.y - startPoint.y) * ratio;
return getPriceFromYCoordinate(
this.series,
y,
);
}
private hasValidMainLine(): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return (
getDistance(
geometry.startPoint,
geometry.endPoint,
) >= MIN_LINE_SIZE
);
}
private hasValidChannelWidth(): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return (
getDistance(
geometry.startPoint,
geometry.parallelStartPoint,
) >= MIN_CHANNEL_WIDTH
);
}
private getPointTarget(
point: Point,
): ParallelChannelDragTarget | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const targets: Array<[ParallelChannelDragTarget, Point]> = [
['main-start', geometry.startPoint],
['main-middle', geometry.mainMiddlePoint],
['main-end', geometry.endPoint],
['parallel-start', geometry.parallelStartPoint],
['parallel-middle', geometry.parallelMiddlePoint],
['parallel-end', geometry.parallelEndPoint],
];
for (const [target, targetPoint] of targets) {
if (
isNearPoint(
point,
targetPoint.x,
targetPoint.y,
HANDLE_HIT_TOLERANCE,
)
) {
return target;
}
}
return null;
}
private isPointOnChannel(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (
getDistanceToSegment(
point,
geometry.startPoint,
geometry.endPoint,
) <= LINE_HIT_TOLERANCE
) {
return true;
}
if (
getDistanceToSegment(
point,
geometry.parallelStartPoint,
geometry.parallelEndPoint,
) <= LINE_HIT_TOLERANCE
) {
return true;
}
if (
this.settings.showMiddleLine &&
getDistanceToSegment(
point,
geometry.middleStartPoint,
geometry.middleEndPoint,
) <= LINE_HIT_TOLERANCE
) {
return true;
}
return isPointInPolygon(
point,
[
geometry.startPoint,
geometry.endPoint,
geometry.parallelEndPoint,
geometry.parallelStartPoint,
],
);
}
private getPriceLabelValue(
kind: PriceLabelKind,
): number | null {
if (
!this.startAnchor ||
!this.endAnchor ||
this.priceOffset === null
) {
return null;
}
switch (kind) {
case 'main-start':
return this.startAnchor.price;
case 'main-end':
return this.endAnchor.price;
case 'parallel-start':
return this.startAnchor.price + this.priceOffset;
case 'parallel-end':
return this.endAnchor.price + this.priceOffset;
default:
return null;
}
}
private getAnchorPoint(anchor: Anchor): Point | 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: Number(x),
y: Number(y),
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(
this.chart,
this.series,
point,
);
}
}
function isPriceLabelKind(
kind: string,
): kind is PriceLabelKind {
return (
kind === 'main-start' ||
kind === 'main-end' ||
kind === 'parallel-start' ||
kind === 'parallel-end'
);
}
function getMiddlePoint(
startPoint: Point,
endPoint: Point,
): Point {
return {
x: (startPoint.x + endPoint.x) / 2,
y: (startPoint.y + endPoint.y) / 2,
};
}
function getDistance(
startPoint: Point,
endPoint: Point,
): number {
return Math.hypot(
endPoint.x - startPoint.x,
endPoint.y - startPoint.y,
);
}
function getDistanceToSegment(
point: Point,
startPoint: Point,
endPoint: Point,
): number {
const deltaX = endPoint.x - startPoint.x;
const deltaY = endPoint.y - startPoint.y;
if (deltaX === 0 && deltaY === 0) {
return getDistance(point, startPoint);
}
const ratio = Math.max(
0,
Math.min(
1,
(
(point.x - startPoint.x) * deltaX +
(point.y - startPoint.y) * deltaY
) /
(
deltaX * deltaX +
deltaY * deltaY
),
),
);
const projectionX =
startPoint.x +
ratio * deltaX;
const projectionY =
startPoint.y +
ratio * deltaY;
return Math.hypot(
point.x - projectionX,
point.y - projectionY,
);
}
function isPointInPolygon(
point: Point,
polygon: Point[],
): boolean {
let isInside = false;
for (
let index = 0, previousIndex = polygon.length - 1;
index < polygon.length;
previousIndex = index, index += 1
) {
const currentPoint = polygon[index];
const previousPoint = polygon[previousIndex];
const intersects =
currentPoint.y > point.y !==
previousPoint.y > point.y &&
point.x <
(
(previousPoint.x - currentPoint.x) *
(point.y - currentPoint.y)
) /
(
previousPoint.y -
currentPoint.y
) +
currentPoint.x;
if (intersects) {
isInside = !isInside;
}
}
return isInside;
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import type { ParallelChannel } from './parallelChannel';
import type { Point } from '@core/Drawings/types';
const UI = {
lineWidth: 2,
middleLineWidth: 1,
middleLineDash: 6,
middleLineGap: 4,
handleRadius: 5,
handleBorderWidth: 2,
textLineHeightMultiplier: 1.2,
textOffset: 5,
textStartGap: 5,
};
export class ParallelChannelPaneRenderer implements IPrimitivePaneRenderer {
private parallelChannel: ParallelChannel;
constructor(parallelChannel: ParallelChannel) {
this.parallelChannel = parallelChannel;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.parallelChannel.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(
({
context,
horizontalPixelRatio,
verticalPixelRatio,
}) => {
const pixelRatio = Math.max(
horizontalPixelRatio,
verticalPixelRatio,
);
const startPoint = scalePoint(
data.startPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const mainMiddlePoint = scalePoint(
data.mainMiddlePoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const endPoint = scalePoint(
data.endPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const parallelStartPoint = scalePoint(
data.parallelStartPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const parallelMiddlePoint = scalePoint(
data.parallelMiddlePoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const parallelEndPoint = scalePoint(
data.parallelEndPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const middleStartPoint = scalePoint(
data.middleStartPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const middleEndPoint = scalePoint(
data.middleEndPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
context.save();
drawChannelFill(
context,
[
startPoint,
endPoint,
parallelEndPoint,
parallelStartPoint,
],
data.backgroundColor,
);
context.strokeStyle = data.lineColor;
context.lineWidth = UI.lineWidth * pixelRatio;
drawLine(
context,
startPoint,
endPoint,
);
drawLine(
context,
parallelStartPoint,
parallelEndPoint,
);
if (data.showMiddleLine) {
context.save();
context.lineWidth =
UI.middleLineWidth *
pixelRatio;
context.setLineDash([
UI.middleLineDash * pixelRatio,
UI.middleLineGap * pixelRatio,
]);
drawLine(
context,
middleStartPoint,
middleEndPoint,
);
context.restore();
}
if (data.text.trim()) {
const channelCenterPoint = getMiddlePoint(
mainMiddlePoint,
parallelMiddlePoint,
);
const isMainLineAbove =
mainMiddlePoint.y <=
parallelMiddlePoint.y;
drawTextAlongLine(context, {
startPoint: isMainLineAbove
? startPoint
: parallelStartPoint,
endPoint: isMainLineAbove
? endPoint
: parallelEndPoint,
channelCenterPoint,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
pixelRatio,
verticalPixelRatio,
});
}
if (data.showHandles) {
const { colors } = getThemeStore();
context.fillStyle =
colors.chartBackground;
context.strokeStyle =
colors.chartLineColor;
context.lineWidth =
UI.handleBorderWidth *
pixelRatio;
const radius =
UI.handleRadius *
pixelRatio;
drawHandle(context, startPoint, radius);
drawHandle(context, mainMiddlePoint, radius);
drawHandle(context, endPoint, radius);
drawHandle(context, parallelStartPoint, radius);
drawHandle(context, parallelMiddlePoint, radius);
drawHandle(context, parallelEndPoint, radius);
}
context.restore();
},
);
}
}
function scalePoint(
point: Point,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): Point {
return {
x: point.x * horizontalPixelRatio,
y: point.y * verticalPixelRatio,
};
}
function drawChannelFill(
context: CanvasRenderingContext2D,
points: Point[],
color: string,
): void {
const [
startPoint,
endPoint,
parallelEndPoint,
parallelStartPoint,
] = points;
context.save();
context.fillStyle = color;
context.beginPath();
context.moveTo(startPoint.x, startPoint.y);
context.lineTo(endPoint.x, endPoint.y);
context.lineTo(parallelEndPoint.x, parallelEndPoint.y);
context.lineTo(parallelStartPoint.x, parallelStartPoint.y);
context.closePath();
context.fill();
context.restore();
}
function drawLine(
context: CanvasRenderingContext2D,
startPoint: Point,
endPoint: Point,
): void {
context.beginPath();
context.moveTo(startPoint.x, startPoint.y);
context.lineTo(endPoint.x, endPoint.y);
context.stroke();
}
function drawHandle(
context: CanvasRenderingContext2D,
point: Point,
radius: number,
): void {
context.beginPath();
context.arc(
point.x,
point.y,
radius,
0,
Math.PI * 2,
);
context.fill();
context.stroke();
}
function drawTextAlongLine(
context: CanvasRenderingContext2D,
params: {
startPoint: Point;
endPoint: Point;
channelCenterPoint: Point;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
pixelRatio: number;
verticalPixelRatio: number;
},
): void {
const {
startPoint,
endPoint,
channelCenterPoint,
text,
fontSize,
isBold,
isItalic,
textColor,
pixelRatio,
verticalPixelRatio,
} = params;
let renderStartPoint = startPoint;
let renderEndPoint = endPoint;
let deltaX =
renderEndPoint.x -
renderStartPoint.x;
let deltaY =
renderEndPoint.y -
renderStartPoint.y;
let angle = Math.atan2(
deltaY,
deltaX,
);
if (
angle > Math.PI / 2 ||
angle < -Math.PI / 2
) {
renderStartPoint = endPoint;
renderEndPoint = startPoint;
deltaX =
renderEndPoint.x -
renderStartPoint.x;
deltaY =
renderEndPoint.y -
renderStartPoint.y;
angle = Math.atan2(
deltaY,
deltaX,
);
}
const lineLength = Math.hypot(
deltaX,
deltaY,
);
if (!lineLength) {
return;
}
const directionX =
deltaX /
lineLength;
const directionY =
deltaY /
lineLength;
const lineMiddlePoint = getMiddlePoint(
startPoint,
endPoint,
);
const outwardX =
lineMiddlePoint.x -
channelCenterPoint.x;
const outwardY =
lineMiddlePoint.y -
channelCenterPoint.y;
const outwardLength = Math.hypot(
outwardX,
outwardY,
);
const normalX = outwardLength
? outwardX / outwardLength
: 0;
const normalY = outwardLength
? outwardY / outwardLength
: -1;
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx =
safeFontSize *
verticalPixelRatio;
const lineHeight =
safeFontSize *
UI.textLineHeightMultiplier *
verticalPixelRatio;
const fontWeight = isBold
? '700 '
: '';
const fontStyle = isItalic
? 'italic '
: '';
context.save();
context.font =
`${fontStyle}${fontWeight}` +
`${fontSizePx}px Inter, sans-serif`;
const textWidth = lines.reduce(
(maxWidth, line) => {
return Math.max(
maxWidth,
context.measureText(line || ' ').width,
);
},
0,
);
const blockHeight =
lines.length *
lineHeight;
const startPadding =
(
UI.handleRadius * 2 +
UI.textStartGap
) *
pixelRatio;
const desiredDistance =
startPadding +
textWidth / 2;
const availableDistance =
lineLength -
textWidth / 2 -
UI.textStartGap * pixelRatio;
const distanceAlongLine =
availableDistance >= desiredDistance
? desiredDistance
: lineLength / 2;
const outwardDistance =
blockHeight / 2 +
UI.textOffset *
verticalPixelRatio;
const textCenterX =
renderStartPoint.x +
directionX *
distanceAlongLine +
normalX *
outwardDistance;
const textCenterY =
renderStartPoint.y +
directionY *
distanceAlongLine +
normalY *
outwardDistance;
context.translate(
textCenterX,
textCenterY,
);
context.rotate(angle);
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
const firstLineY =
-(
lines.length -
1
) *
lineHeight /
2;
lines.forEach((line, index) => {
context.fillText(
line,
0,
firstLineY +
index *
lineHeight,
);
});
context.restore();
}
function getMiddlePoint(
startPoint: Point,
endPoint: Point,
): Point {
return {
x: (startPoint.x + endPoint.x) / 2,
y: (startPoint.y + endPoint.y) / 2,
};
}