Загрузка данных
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,
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,
} 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-start'
| 'dragging-end'
| 'dragging-offset'
| 'dragging-body';
type ParallelChannelDragMode = 'dragging-start' | 'dragging-end' | 'dragging-offset' | 'dragging-body';
type AnchorKind = 'start' | 'end' | 'offset';
interface ParallelChannelParams {
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
openSettings?: () => void;
}
interface ParallelChannelState {
hidden: boolean;
mode: ParallelChannelMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
offsetAnchor: Anchor | null;
settings: ParallelChannelSettings;
}
interface ParallelChannelGeometry {
startPoint: Point;
endPoint: Point;
offsetPoint: Point;
parallelStartPoint: Point;
parallelEndPoint: Point;
middleStartPoint: Point;
middleEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface ParallelChannelRenderData extends ParallelChannelGeometry, ParallelChannelStyle {
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const MIN_CHANNEL_WIDTH = 4;
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 offsetAnchor: Anchor | 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 offsetTimeAxisView: CustomTimeAxisView;
private startPriceAxisView: CustomPriceAxisView;
private endPriceAxisView: CustomPriceAxisView;
private offsetPriceAxisView: 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.offsetTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'offset',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
this.offsetPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'offset',
});
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,
offsetAnchor: this.offsetAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<ParallelChannelState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (nextState.mode) {
this.mode = isDraggingMode(nextState.mode) ? 'ready' : nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('offsetAnchor' in nextState) {
this.offsetAnchor = nextState.offsetAnchor ?? 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.offsetTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
this.offsetPriceAxisView,
]);
}
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, this.offsetTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView, this.offsetPriceAxisView];
}
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',
};
}
if (pointTarget) {
return {
cursorStyle: 'move',
externalId: 'parallel-channel',
zOrder: 'top',
};
}
return {
cursorStyle: '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()) || !isAnchorKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
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()) || !isAnchorKind(kind)) {
return null;
}
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(anchor.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)) {
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.offsetAnchor = this.endAnchor;
this.mode = 'drawing-channel';
this.render();
return;
}
if (this.mode === 'drawing-channel') {
event.preventDefault();
event.stopPropagation();
this.setOffsetAnchor(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 === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (pointTarget === 'offset') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-offset', point, event.pointerId);
return;
}
if (isChannelHit) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-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.setOffsetAnchor(point);
this.render();
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-offset') {
event.preventDefault();
event.stopPropagation();
this.moveAnchor(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId || !isDraggingMode(this.mode)) {
return;
}
this.finishDragging();
};
protected getGeometry(): ParallelChannelGeometry | null {
if (!this.startAnchor || !this.endAnchor || !this.offsetAnchor) {
return null;
}
const startPoint = this.getAnchorPoint(this.startAnchor);
const endPoint = this.getAnchorPoint(this.endAnchor);
const offsetPoint = this.getAnchorPoint(this.offsetAnchor);
if (!startPoint || !endPoint || !offsetPoint) {
return null;
}
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
return {
startPoint,
endPoint,
offsetPoint,
parallelStartPoint: startPoint,
parallelEndPoint: endPoint,
middleStartPoint: startPoint,
middleEndPoint: endPoint,
left: startPoint.x,
right: startPoint.x,
top: startPoint.y,
bottom: startPoint.y,
};
}
const offsetDx = offsetPoint.x - startPoint.x;
const offsetDy = offsetPoint.y - startPoint.y;
const projectionRatio = (offsetDx * dx + offsetDy * dy) / lengthSquared;
const projectionPoint = {
x: startPoint.x + dx * projectionRatio,
y: startPoint.y + dy * projectionRatio,
};
const channelOffset = {
x: offsetPoint.x - projectionPoint.x,
y: offsetPoint.y - projectionPoint.y,
};
const parallelStartPoint = {
x: startPoint.x + channelOffset.x,
y: startPoint.y + channelOffset.y,
};
const parallelEndPoint = {
x: endPoint.x + channelOffset.x,
y: endPoint.y + channelOffset.y,
};
const middleStartPoint = {
x: (startPoint.x + parallelStartPoint.x) / 2,
y: (startPoint.y + parallelStartPoint.y) / 2,
};
const middleEndPoint = {
x: (endPoint.x + parallelEndPoint.x) / 2,
y: (endPoint.y + parallelEndPoint.y) / 2,
};
const points = [
startPoint,
endPoint,
offsetPoint,
parallelStartPoint,
parallelEndPoint,
];
return {
startPoint,
endPoint,
offsetPoint,
parallelStartPoint,
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.offsetAnchor = anchor;
this.mode = 'drawing-line';
this.render();
}
private finishDrawing(): void {
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: ParallelChannelDragMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private moveAnchor(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
return;
}
if (this.mode === 'dragging-end') {
this.endAnchor = anchor;
return;
}
if (this.mode === 'dragging-offset') {
this.offsetAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !snapshot.offsetAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = 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);
const offsetTime = shiftTimeByPixels(this.chart, snapshot.offsetAnchor.time, offsetX, this.series);
if (startTime === null || endTime === null || offsetTime === null) {
return;
}
this.startAnchor = {
time: startTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: endTime,
price: snapshot.endAnchor.price + priceOffset,
};
this.offsetAnchor = {
time: offsetTime,
price: snapshot.offsetAnchor.price + priceOffset,
};
}
private setEndAnchor(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
}
private setOffsetAnchor(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.offsetAnchor = anchor;
}
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): AnchorKind | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'end';
}
if (isNearPoint(point, geometry.offsetPoint.x, geometry.offsetPoint.y, HANDLE_HIT_TOLERANCE)) {
return 'offset';
}
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 getAnchor(kind: AnchorKind): Anchor | null {
if (kind === 'start') {
return this.startAnchor;
}
if (kind === 'end') {
return this.endAnchor;
}
return this.offsetAnchor;
}
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 isAnchorKind(kind: string): kind is AnchorKind {
return kind === 'start' || kind === 'end' || kind === 'offset';
}
function isDraggingMode(mode: ParallelChannelMode): mode is ParallelChannelDragMode {
return (
mode === 'dragging-start' ||
mode === 'dragging-end' ||
mode === 'dragging-offset' ||
mode === 'dragging-body'
);
}
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 dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return getDistance(point, startPoint);
}
const ratio = Math.max(
0,
Math.min(
1,
((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) /
(dx * dx + dy * dy),
),
);
const projectionX = startPoint.x + ratio * dx;
const projectionY = startPoint.y + ratio * dy;
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 { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import type { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface ParallelChannelStyle {
lineColor: string;
backgroundColor: string;
lineWidth: number;
showMiddleLine: boolean;
}
export type ParallelChannelSettings = SettingsValues & ParallelChannelStyle;
export function createDefaultSettings(): ParallelChannelSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
backgroundColor: colors.axisMarkerAreaFill,
lineWidth: 2,
showMiddleLine: true,
};
}
export function getParallelChannelSettingsTabs(settings: ParallelChannelSettings): SettingsTab[] {
const fields: SettingField[] = [
{
key: 'lineColor',
label: t('Line'),
type: 'color',
defaultValue: settings.lineColor,
toolbar: {
control: 'color',
role: 'line',
},
},
{
key: 'backgroundColor',
label: t('Background'),
type: 'color',
defaultValue: settings.backgroundColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
{
key: 'lineWidth',
label: t('Line width'),
type: 'number',
defaultValue: settings.lineWidth,
min: 1,
max: 4,
},
{
key: 'showMiddleLine',
label: t('Middle line'),
type: 'boolean',
defaultValue: settings.showMiddleLine,
},
];
return [
{
key: 'style',
label: t('Style'),
fields,
},
];
}
[DrawingsNames.parallelChannel]: {
construct: ({ chart, series, container, eventManager, interaction, openSettings }) => {
return new ParallelChannel(chart, series, {
container,
interaction,
formatObservable: eventManager.getChartOptionsModel(),
openSettings,
});
},
},