Загрузка данных
import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { getPriceFromYCoordinate } from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { SettingsTab, SettingsValues } from '@src/types/settings';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RulerPaneView } from './paneView';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { TwoPointGeometry } from '@core/Drawings/TwoPointDrawingBase';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
import type {
AutoscaleInfo,
Coordinate,
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
Logical,
MouseEventHandler,
MouseEventParams,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type RulerMode = 'idle' | 'placingEnd' | 'ready';
type RulerAnchorKind = 'start' | 'end';
interface RulerState {
hidden: boolean;
mode: RulerMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface RulerParams extends BaseDrawingParams {
resetTriggers?: Observable<unknown>[];
}
export interface RulerRenderData {
hidden: boolean;
startPoint: Point | null;
endPoint: Point | null;
lineColor: string;
fillColor: string;
textColor: string;
infoLines: string[];
horizontalArrowSide: Direction.Left | Direction.Right | null;
verticalArrowSide: Direction.Top | Direction.Bottom | null;
}
export class Ruler extends TwoPointDrawingBase<SettingsValues> implements ISeriesDrawing {
private removeSelf?: () => void;
protected settings: SettingsValues = {};
protected mode: RulerMode = 'idle';
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly clickHandler: MouseEventHandler<Time>;
private readonly moveHandler: MouseEventHandler<Time>;
private readonly paneView: RulerPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
resetTriggers = [],
formatObservable,
removeSelf,
container,
interaction,
initialEvent,
}: RulerParams) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.clickHandler = (params) => this.handleChartClick(params);
this.moveHandler = (params) => this.handleMove(params);
this.paneView = new RulerPaneView(this);
this.timeAxisPaneView = this.createTimeAxisPaneView();
this.priceAxisPaneView = this.createPriceAxisPaneView();
this.startTimeAxisView = this.createTimeAxisView('start');
this.endTimeAxisView = this.createTimeAxisView('end');
this.startPriceAxisView = this.createPriceAxisView('start');
this.endPriceAxisView = this.createPriceAxisView('end');
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'placingEnd';
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public getState(): RulerState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<RulerState>;
this.hidden = nextState.hidden ?? this.hidden;
this.mode = nextState.mode ?? this.mode;
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
this.render();
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
if (this.hidden || this.mode === 'placingEnd' || !this.startAnchor || !this.endAnchor) {
return null;
}
const startCoordinate = this.getAnchorTimeCoordinate('start');
const endCoordinate = this.getAnchorTimeCoordinate('end');
if (startCoordinate === null || endCoordinate === null) {
return null;
}
const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);
if (startLogical === null || endLogical === null) {
return null;
}
const leftLogical = Math.min(Number(startLogical), Number(endLogical));
const rightLogical = Math.max(Number(startLogical), Number(endLogical));
if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
return null;
}
return {
priceRange: {
minValue: Math.min(this.startAnchor.price, this.endAnchor.price),
maxValue: Math.max(this.startAnchor.price, this.endAnchor.price),
},
};
}
public getRenderData(): RulerRenderData {
const startPoint = this.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
const startPrice = this.startAnchor?.price ?? 0;
const endPrice = this.endAnchor?.price ?? 0;
const priceDiff = endPrice - startPrice;
const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;
const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;
const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
const volume = this.getVolumeInRange();
const isLong = priceDiff >= 0;
const horizontalArrowSide =
startPoint && endPoint && startPoint.x !== endPoint.x
? endPoint.x > startPoint.x
? Direction.Right
: Direction.Left
: null;
const verticalArrowSide =
startPoint && endPoint && startPoint.y !== endPoint.y
? endPoint.y > startPoint.y
? Direction.Bottom
: Direction.Top
: null;
const { colors } = getThemeStore();
return {
hidden: this.hidden,
startPoint,
endPoint,
lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
textColor: colors.chartPriceLineText,
infoLines: [
`${formatPrice(Math.abs(priceDiff))} (${
percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`
})`,
`${barsCount} ${t('bars')},`,
`${t('Vol')} ${formatVolume(volume)}`,
],
horizontalArrowSide,
verticalArrowSide,
};
}
public getTimeCoordinate(kind: RulerAnchorKind): Coordinate | null {
const coordinate = this.getAnchorTimeCoordinate(kind);
return coordinate === null ? null : (coordinate as Coordinate);
}
public getPriceCoordinate(kind: RulerAnchorKind): Coordinate | null {
const coordinate = this.getAnchorPriceCoordinate(kind);
return coordinate === null ? null : (coordinate as Coordinate);
}
public getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
public getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.bottom,
};
}
public getTimeText(kind: RulerAnchorKind): string {
const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceText(kind: RulerAnchorKind): string {
const anchor = this.getAnchor(kind);
return anchor ? formatPrice(anchor.price) ?? '' : '';
}
public shouldShowInObjectTree(): boolean {
return false;
}
protected getHoveredItem(_x: number, _y: number): PrimitiveHoveredItem | null {
return null;
}
protected getGeometry(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
return bounds ? [this.createAxisSegment(bounds.left, bounds.right)] : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.getPriceBounds();
return bounds ? [this.createAxisSegment(bounds.top, bounds.bottom)] : [];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'start' && kind !== 'end') {
return null;
}
return this.createAxisLabel(
this.getAnchorTimeCoordinate(kind),
this.getTimeText(kind),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'start' && kind !== 'end') {
return null;
}
return this.createAxisLabel(
this.getAnchorPriceCoordinate(kind),
this.getPriceText(kind),
);
}
protected bindEvents(): void {
// todo: попробовать привести к виду базового класса
if (this.isBound) {
return;
}
this.isBound = true;
this.chart.subscribeClick(this.clickHandler);
this.chart.subscribeCrosshairMove(this.moveHandler);
}
protected unbindEvents(): void {
// todo: попробовать привести к виду базового класса
if (!this.isBound) {
return;
}
this.isBound = false;
this.chart.unsubscribeClick(this.clickHandler);
this.chart.unsubscribeCrosshairMove(this.moveHandler);
}
private handleChartClick(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point || !params.sourceEvent) {
return;
}
if (this.mode === 'ready') {
this.removeSelf?.();
return;
}
const anchor = this.createAnchorFromParams(params);
if (!anchor) {
return;
}
if (this.mode === 'idle') {
this.startDrawing(this.getEventPoint(params.sourceEvent));
return;
}
if (this.mode === 'placingEnd') {
this.endAnchor = anchor;
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
}
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
this.setAnchors(anchor, anchor);
this.mode = 'placingEnd';
this.hideCrosshair();
this.render();
}
private handleMove(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point || this.mode !== 'placingEnd') {
return;
}
const anchor = this.createAnchorFromParams(params);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private createAnchorFromParams({ time, point }: MouseEventParams<Time>): Anchor | null {
if (!point || time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, point.y);
return price === null ? null : { time, price };
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => {
return typeof item.time === 'number' && typeof time === 'number' && item.time === time;
});
}
private getVolumeInRange(): number {
if (!this.startAnchor || !this.endAnchor) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startAnchor.time);
const endIndex = this.findIndexByTime(this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
}
import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getPriceFromYCoordinate,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { getDistanceToSegment, updateViews } from '@core/Drawings/utils';
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 { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
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 ParallelChannelHandleKey = Exclude<ParallelChannelDragTarget, 'body'>;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind =
| 'main-start'
| 'main-end'
| 'parallel-start'
| 'parallel-end';
type ParallelChannelParams = BaseDrawingParams;
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 {}
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 TwoPointDrawingBase<ParallelChannelSettings, ParallelChannelHandleKey>
implements ISeriesDrawing
{
private openSettings?: () => void;
protected settings: ParallelChannelSettings = createDefaultSettings();
protected mode: ParallelChannelMode = 'idle';
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 readonly paneView: ParallelChannelPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly mainStartPriceAxisView: CustomPriceAxisView;
private readonly mainEndPriceAxisView: CustomPriceAxisView;
private readonly parallelStartPriceAxisView: CustomPriceAxisView;
private readonly parallelEndPriceAxisView: CustomPriceAxisView;
constructor({
container,
interaction,
formatObservable,
openSettings,
chart,
series,
initialEvent,
}: ParallelChannelParams) {
super({ chart, series, container, interaction });
this.openSettings = openSettings;
this.paneView = new ParallelChannelPaneView(this);
this.timeAxisPaneView = this.createTimeAxisPaneView();
this.priceAxisPaneView = this.createPriceAxisPaneView();
this.startTimeAxisView = this.createTimeAxisView('start');
this.endTimeAxisView = this.createTimeAxisView('end');
this.mainStartPriceAxisView = this.createPriceAxisView('main-start');
this.mainEndPriceAxisView = this.createPriceAxisView('main-end');
this.parallelStartPriceAxisView = this.createPriceAxisView('parallel-start');
this.parallelEndPriceAxisView = this.createPriceAxisView('parallel-end');
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
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,
};
}
protected getDrawingHandles(): readonly DrawingHandle<ParallelChannelHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'parallel-end',
...geometry.parallelEndPoint,
shape: 'circle',
},
{
id: 'parallel-middle',
...geometry.parallelMiddlePoint,
shape: 'circle',
},
{
id: 'parallel-start',
...geometry.parallelStartPoint,
shape: 'circle',
},
{
id: 'main-end',
...geometry.endPoint,
shape: 'circle',
},
{
id: 'main-middle',
...geometry.mainMiddlePoint,
shape: 'circle',
},
{
id: 'main-start',
...geometry.startPoint,
shape: 'circle',
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
}
const point = { x, y };
const pointTarget = this.getDrawingHandleAtPoint(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.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
return geometry ? [this.createAxisSegment(geometry.left, geometry.right)] : [];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getGeometry();
return geometry ? [this.createAxisSegment(geometry.top, geometry.bottom)] : [];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = this.getAnchor(kind);
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
return this.createAxisLabel(
this.getAnchorTimeCoordinate(kind),
formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || !isPriceLabelKind(kind)) {
return null;
}
const price = this.getPriceLabelValue(kind);
if (price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, price);
return this.createAxisLabel(
coordinate === null ? null : Number(coordinate),
formatPrice(price) ?? '',
);
}
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.getDrawingHandleAtPoint(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);
switch (this.mode) {
case 'idle':
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
case '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;
case 'drawing-channel':
event.preventDefault();
event.stopPropagation();
this.setPriceOffset(point);
if (!this.hasValidChannelWidth()) {
this.render();
return;
}
this.finishDrawing();
return;
case 'ready':
break;
default:
return;
}
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isChannelHit = this.isPointOnChannel(point);
const isSelected = this.isSelected();
if (!isSelected && !pointTarget && !isChannelHit) {
return;
}
if (!isSelected) {
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (pointTarget || isChannelHit) {
event.preventDefault();
event.stopPropagation();
this.startDragging(pointTarget ?? '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 mainGeometry = this.getTwoPointGeometry();
if (!mainGeometry) {
return null;
}
const parallelStartPoint = this.getPointFromAnchor({
time: this.startAnchor.time,
price: this.startAnchor.price + this.priceOffset,
});
const parallelEndPoint = this.getPointFromAnchor({
time: this.endAnchor.time,
price: this.endAnchor.price + this.priceOffset,
});
if (!parallelStartPoint || !parallelEndPoint) {
return null;
}
const startPoint = mainGeometry.startPoint;
const endPoint = mainGeometry.endPoint;
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.setAnchors(anchor, 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 = this.getAnchor(kind);
this.setAnchor(kind, anchor);
if (!this.hasValidMainLine()) {
this.setAnchor(kind, 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 = this.getAnchor(kind);
const baseAnchor: Anchor = {
time: anchor.time,
price: anchor.price - snapshot.priceOffset,
};
this.setAnchor(kind, baseAnchor);
if (!this.hasValidMainLine()) {
this.setAnchor(kind, 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()) {
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()) {
this.priceOffset = previousOffset;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot || snapshot.priceOffset === null) {
return;
}
this.moveAnchorsByPixels(
snapshot.startAnchor,
snapshot.endAnchor,
this.dragStartPoint,
point,
);
this.priceOffset = snapshot.priceOffset;
}
private setEndAnchor(point: Point): void {
this.setAnchorFromPoint('end', point);
}
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.getPointFromAnchor(startAnchor);
const endPoint = this.getPointFromAnchor(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();
return (
geometry !== null &&
getDistance(geometry.startPoint, geometry.endPoint) >= MIN_LINE_SIZE
);
}
private hasValidChannelWidth(): boolean {
const geometry = this.getGeometry();
return (
geometry !== null &&
getDistance(geometry.startPoint, geometry.parallelStartPoint) >=
MIN_CHANNEL_WIDTH
);
}
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;
}
}
}
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 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;
}