Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { SettingsTab, SettingsValues } from '@src/types';
export interface DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
isSelected(): boolean;
isLocked(): boolean;
select(): void;
deselect(): void;
}
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
waitTillReady(): Promise<void>;
isCreationPending(): boolean;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
getSettingsTabs(): SettingsTab[];
updateSettings(settings: SettingsValues): void;
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isBound = false;
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
private isInteractionBound = false;
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.settingsSubject.next(this.getSettings());
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public abstract getRenderData(): unknown; // todo: make proper type
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
this.container.addEventListener('contextmenu', this.handleContextMenu);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerDown(event: PointerEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private bindInteraction(): void {
if (this.isInteractionBound) {
return;
}
this.isInteractionBound = true;
this.subscriptions.add(
this.interaction.selected$.subscribe(() => {
this.render();
}),
);
this.subscriptions.add(
this.interaction.locked$.subscribe((isLocked) => {
if (isLocked) {
this.showCrosshair();
}
this.render();
}),
);
}
private handlePointerDownEvent = (event: PointerEvent): void => {
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (isDrawingHit) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
};
}
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: [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,
};
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface ParallelChannelStyle {
lineColor: string;
backgroundColor: string;
showMiddleLine: boolean;
}
export interface ParallelChannelTextStyle {
fontSize: number;
text: string;
isBold: boolean;
isItalic: boolean;
textColor: string;
}
export type ParallelChannelSettings = ParallelChannelStyle & ParallelChannelTextStyle & SettingsValues;
export function createDefaultSettings(): ParallelChannelSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
backgroundColor: colors.axisMarkerAreaFill,
showMiddleLine: true,
fontSize: 14,
text: '',
isBold: false,
isItalic: false,
textColor: colors.chartLineColor,
};
}
export function getParallelChannelSettingsTabs(settings: ParallelChannelSettings): SettingsTab[] {
const styleFields: 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: 'showMiddleLine',
label: t('Middle line'),
type: 'boolean',
defaultValue: settings.showMiddleLine,
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'text',
label: t('Text'),
type: 'textarea',
defaultValue: settings.text,
placeholder: t('Enter text'),
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italic'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text'),
type: 'color',
defaultValue: settings.textColor,
toolbar: {
control: 'color',
role: 'text',
},
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
normalizeBounds,
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 { RectanglePaneView } from './paneView';
import {
createDefaultSettings,
getRectangleSettingsTabs,
RectangleSettings,
RectangleStyle,
RectangleTextStyle,
} from './settings';
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
type RectangleMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type RectangleHandle = 'body' | 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | null;
type RectangleHandleKey = Exclude<RectangleHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface RectangleParams {
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface RectangleState {
hidden: boolean;
mode: RectangleMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: RectangleSettings;
}
interface RectangleGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
handles: Record<RectangleHandleKey, Point>;
}
export interface RectangleRenderData extends RectangleGeometry, RectangleStyle, RectangleTextStyle {
showFill: boolean;
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_SIZE = 6;
export class Rectangle extends SeriesDrawingBase<RectangleSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: RectangleSettings = createDefaultSettings();
protected mode: RectangleMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: RectangleHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RectangleState | null = null;
private dragGeometrySnapshot: RectangleGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: RectanglePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, interaction, formatObservable, removeSelf, openSettings }: RectangleParams,
) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new RectanglePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
this.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
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';
}
public getState(): RectangleState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RectangleState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startTime' in nextState) {
this.startTime = nextState.startTime ?? null;
}
if ('endTime' in nextState) {
this.endTime = nextState.endTime ?? null;
}
if ('startPrice' in nextState) {
this.startPrice = nextState.startPrice ?? null;
}
if ('endPrice' in nextState) {
this.endPrice = nextState.endPrice ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
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.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showFill: true,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: 'rectangle-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'rectangle-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.containsPoint(point) && !this.getHandleTarget(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') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const clampedPoint = this.clampPointToContainer(point);
const anchor = this.createAnchor(clampedPoint);
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_RECTANGLE_SIZE || geometry.height < MIN_RECTANGLE_SIZE) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<RectangleHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<RectangleHandle, null> | null {
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return handleTarget;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + priceOffset;
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const clampedPoint = this.clampPointToContainer(point);
let { left } = geometry;
let { right } = geometry;
let { top } = geometry;
let { bottom } = geometry;
switch (this.activeDragTarget) {
case 'nw':
left = clampedPoint.x;
top = clampedPoint.y;
break;
case 'n':
top = clampedPoint.y;
break;
case 'ne':
right = clampedPoint.x;
top = clampedPoint.y;
break;
case 'e':
right = clampedPoint.x;
break;
case 'se':
right = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 's':
bottom = clampedPoint.y;
break;
case 'sw':
left = clampedPoint.x;
bottom = clampedPoint.y;
break;
case 'w':
left = clampedPoint.x;
break;
default:
return;
}
this.setRectangleBounds(left, right, top, bottom);
}
private setRectangleBounds(left: number, right: number, top: number, bottom: number): boolean {
const bounds = normalizeBounds(left, right, top, bottom, this.container);
const startTime = getTimeFromXCoordinate(this.chart, bounds.left);
const endTime = getTimeFromXCoordinate(this.chart, bounds.right);
const startPrice = getPriceFromYCoordinate(this.series, bounds.top);
const endPrice = getPriceFromYCoordinate(this.series, bounds.bottom);
if (startTime === null || endTime === null || startPrice === null || endPrice === null) {
return false;
}
this.startTime = startTime;
this.endTime = endTime;
this.startPrice = startPrice;
this.endPrice = endPrice;
return true;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): RectangleGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const left = Math.round(Math.min(Number(startX), Number(endX)));
const right = Math.round(Math.max(Number(startX), Number(endX)));
const top = Math.round(Math.min(Number(startY), Number(endY)));
const bottom = Math.round(Math.max(Number(startY), Number(endY)));
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
handles: {
nw: { x: left, y: top },
n: { x: centerX, y: top },
ne: { x: right, y: top },
e: { x: right, y: centerY },
se: { x: right, y: bottom },
s: { x: centerX, y: bottom },
sw: { x: left, y: bottom },
w: { x: left, y: centerY },
},
};
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.bottom,
};
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
private getTimeText(kind: TimeLabelKind): string {
const time = this.getTimeValueForLabel(kind);
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getTimeValueForLabel(kind: TimeLabelKind): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return kind === 'left' ? this.startTime : this.endTime;
}
const startIsLeft = Number(startX) <= Number(endX);
if (kind === 'left') {
return startIsLeft ? this.startTime : this.endTime;
}
return startIsLeft ? this.endTime : this.startTime;
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return kind === 'top' ? Math.max(this.startPrice, this.endPrice) : Math.min(this.startPrice, this.endPrice);
}
const startIsTop = Number(startY) <= Number(endY);
if (kind === 'top') {
return startIsTop ? this.startPrice : this.endPrice;
}
return startIsTop ? this.endPrice : this.startPrice;
}
private getHandleTarget(point: Point): RectangleHandleKey | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const handleOrder: RectangleHandleKey[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
for (const handleName of handleOrder) {
const handle = geometry.handles[handleName];
if (isNearPoint(point, handle.x, handle.y, HANDLE_HIT_TOLERANCE)) {
return handleName;
}
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<RectangleHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
switch (handle) {
case 'nw':
case 'se':
return 'nwse-resize';
case 'ne':
case 'sw':
return 'nesw-resize';
case 'n':
case 's':
return 'ns-resize';
case 'e':
case 'w':
return 'ew-resize';
case 'body':
return 'grab';
default:
return 'default';
}
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { RegressionTrend } from './regressionTrend';
const UI = {
lineWidth: 2,
handleRadius: 5,
handleBorderWidth: 2,
textLineHeightMultiplier: 1.2,
textOffset: 4,
};
export class RegressionTrendPaneRenderer implements IPrimitivePaneRenderer {
private readonly regressionTrend: RegressionTrend;
constructor(regressionTrend: RegressionTrend) {
this.regressionTrend = regressionTrend;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.regressionTrend.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const startX = data.startPoint.x * horizontalPixelRatio;
const startY = data.startPoint.y * verticalPixelRatio;
const endX = data.endPoint.x * horizontalPixelRatio;
const endY = data.endPoint.y * verticalPixelRatio;
context.save();
context.lineWidth = UI.lineWidth * pixelRatio;
context.strokeStyle = data.lineColor;
context.beginPath();
context.moveTo(startX, startY);
context.lineTo(endX, endY);
context.stroke();
if (data.text.trim()) {
drawTextAlongLine(context, {
startX,
startY,
endX,
endY,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
verticalPixelRatio,
});
}
if (data.showHandles) {
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * pixelRatio;
drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
drawHandle(context, endX, endY, UI.handleRadius * pixelRatio);
}
context.restore();
});
}
}
function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
}
function drawTextAlongLine(
context: CanvasRenderingContext2D,
{
startX,
startY,
endX,
endY,
text,
fontSize,
isBold,
isItalic,
textColor,
verticalPixelRatio,
}: {
startX: number;
startY: number;
endX: number;
endY: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
verticalPixelRatio: number;
},
): void {
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * verticalPixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;
const dx = endX - startX;
const dy = endY - startY;
let angle = Math.atan2(dy, dx);
if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
angle += Math.PI;
}
const centerX = (startX + endX) / 2;
const centerY = (startY + endY) / 2;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
context.save();
context.translate(centerX, centerY);
context.rotate(angle);
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
const blockHeight = lines.length * lineHeight;
const textOffset = UI.textOffset * verticalPixelRatio;
const textCenterY = -(blockHeight / 2 + textOffset);
const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, 0, startLineY + index * lineHeight);
});
context.restore();
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface RegressionTrendStyle {
lineColor: string;
}
export interface RegressionTrendTextStyle {
fontSize: number;
text: string;
isBold: boolean;
isItalic: boolean;
textColor: string;
}
export type RegressionTrendSettings = RegressionTrendStyle & RegressionTrendTextStyle & SettingsValues;
export function createDefaultSettings(): RegressionTrendSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
fontSize: 14,
text: '',
isBold: false,
isItalic: false,
textColor: colors.chartLineColor,
};
}
export function getRegressionTrendSettingsTabs(settings: RegressionTrendSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line'),
type: 'color',
defaultValue: settings.lineColor,
toolbar: {
control: 'color',
role: 'line',
},
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'text',
label: t('Text'),
type: 'textarea',
defaultValue: settings.text,
placeholder: t('Enter text'),
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italic'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text'),
type: 'color',
defaultValue: settings.textColor,
toolbar: {
control: 'color',
role: 'text',
},
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}