Загрузка данных
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface RegressionTrendStyle {
lineColor: string;
middleLineColor: string;
upperFillColor: string;
lowerFillColor: string;
showMiddleLine: boolean;
showCorrelation: boolean;
}
export type RegressionTrendSettings = SettingsValues & RegressionTrendStyle;
export function createDefaultSettings(): RegressionTrendSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
middleLineColor: colors.chartCandleDown,
upperFillColor: colors.sliderPositiveFill,
lowerFillColor: colors.sliderNegativeFill,
showMiddleLine: true,
showCorrelation: true,
};
}
export function getRegressionTrendSettingsTabs(
settings: RegressionTrendSettings,
): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Channel line color'),
type: 'color',
defaultValue: settings.lineColor,
},
{
key: 'middleLineColor',
label: t('Middle line color'),
type: 'color',
defaultValue: settings.middleLineColor,
},
{
key: 'upperFillColor',
label: t('Upper zone color'),
type: 'color',
defaultValue: settings.upperFillColor,
},
{
key: 'lowerFillColor',
label: t('Lower zone color'),
type: 'color',
defaultValue: settings.lowerFillColor,
},
{
key: 'showMiddleLine',
label: t('Show middle line'),
type: 'boolean',
defaultValue: settings.showMiddleLine,
},
{
key: 'showCorrelation',
label: t('Show correlation'),
type: 'boolean',
defaultValue: settings.showCorrelation,
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
];
}
'Channel line color': 'Цвет границ канала',
'Middle line color': 'Цвет средней линии',
'Upper zone color': 'Цвет верхней зоны',
'Lower zone color': 'Цвет нижней зоны',
'Show middle line': 'Показывать среднюю линию',
'Show correlation': 'Показывать корреляцию',
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} 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 { RegressionTrendPaneView } from './paneView';
import {
createDefaultSettings,
getRegressionTrendSettingsTabs,
RegressionTrendSettings,
RegressionTrendStyle,
} 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 RegressionTrendMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'start' | 'end' | 'body' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface RegressionTrendParams {
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
openSettings?: () => void;
}
interface RegressionTrendState {
hidden: boolean;
mode: RegressionTrendMode;
startTime: Time | null;
endTime: Time | null;
settings: RegressionTrendSettings;
}
interface RegressionSeriesData {
time: Time;
close?: number;
value?: number;
}
interface RegressionResult {
baseStartPrice: number;
baseEndPrice: number;
upperStartPrice: number;
upperEndPrice: number;
lowerStartPrice: number;
lowerEndPrice: number;
correlation: number;
}
interface RegressionTrendGeometry {
baseStartPoint: Point;
baseEndPoint: Point;
upperStartPoint: Point;
upperEndPoint: Point;
lowerStartPoint: Point;
lowerEndPoint: Point;
baseStartPrice: number;
baseEndPrice: number;
correlation: number;
left: number;
right: number;
top: number;
bottom: number;
}
export interface RegressionTrendRenderData
extends RegressionTrendGeometry,
RegressionTrendStyle {
showChannel: boolean;
showHandles: boolean;
}
const HIT_TOLERANCE = 8;
const MIN_BAR_DISTANCE = 1;
// Ширина regression channel.
// Пока это часть самого инструмента, а не пользовательская настройка.
const REGRESSION_DEVIATION = 2;
export class RegressionTrend
extends SeriesDrawingBase<RegressionTrendSettings>
implements ISeriesDrawing
{
private openSettings?: () => void;
protected settings: RegressionTrendSettings = createDefaultSettings();
protected mode: RegressionTrendMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: RegressionTrendState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private paneView: RegressionTrendPaneView;
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
private startPriceAxisView: CustomPriceAxisView;
private endPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{
container,
interaction,
formatObservable,
openSettings,
}: RegressionTrendParams,
) {
super({
chart,
series,
container,
interaction,
});
this.openSettings = openSettings;
this.paneView = new RegressionTrendPaneView(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.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: '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';
}
public getState(): RegressionTrendState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<RegressionTrendState>;
this.hidden = next.hidden ?? this.hidden;
if (next.mode) {
this.mode = next.mode === 'dragging' ? 'ready' : next.mode;
}
if ('startTime' in next) {
this.startTime = next.startTime ?? null;
}
if ('endTime' in next) {
this.endTime = next.endTime ?? null;
}
if (next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getRegressionTrendSettingsTabs(this.settings);
}
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() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): RegressionTrendRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
showChannel: this.mode === 'ready' || this.mode === 'dragging',
showHandles: this.shouldShowHandles(),
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode !== 'ready') {
return null;
}
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'regression-trend',
zOrder: 'top',
};
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
return null;
}
return {
cursorStyle: dragTarget === 'body' ? 'grab' : 'ew-resize',
externalId: 'regression-trend',
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: Math.min(
geometry.baseStartPoint.y,
geometry.baseEndPoint.y,
),
to: Math.max(
geometry.baseStartPoint.y,
geometry.baseEndPoint.y,
),
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (
(!this.isSelected() && !this.isCreationPending()) ||
(kind !== 'start' && kind !== 'end')
) {
return null;
}
const labelKind = kind as TimeLabelKind;
const time = labelKind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(
this.chart,
time,
this.series,
);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
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()) ||
(kind !== 'start' && kind !== 'end')
) {
return null;
}
const labelKind = kind as PriceLabelKind;
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const price =
labelKind === 'start'
? geometry.baseStartPrice
: geometry.baseEndPrice;
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.containsPoint(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') {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
event.preventDefault();
event.stopPropagation();
this.startTime = time;
this.endTime = time;
this.mode = 'drawing';
this.render();
return;
}
if (this.mode === 'drawing') {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
event.preventDefault();
event.stopPropagation();
this.endTime = time;
if (!this.hasValidRange()) {
this.render();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
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.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.mode = 'dragging';
this.hideCrosshair();
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
const time = this.getBarTime(point.x);
if (time !== null) {
this.endTime = time;
this.render();
}
return;
}
if (
this.mode !== 'dragging' ||
this.dragPointerId !== event.pointerId ||
!this.dragStateSnapshot
) {
return;
}
event.preventDefault();
this.applyDrag(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (
this.mode !== 'dragging' ||
this.dragPointerId !== event.pointerId
) {
return;
}
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.mode = 'ready';
this.showCrosshair();
this.render();
};
protected getGeometry(): RegressionTrendGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const regression = this.getRegression();
if (!regression) {
return null;
}
const baseStartPoint = this.getPoint(
this.startTime,
regression.baseStartPrice,
);
const baseEndPoint = this.getPoint(
this.endTime,
regression.baseEndPrice,
);
const upperStartPoint = this.getPoint(
this.startTime,
regression.upperStartPrice,
);
const upperEndPoint = this.getPoint(
this.endTime,
regression.upperEndPrice,
);
const lowerStartPoint = this.getPoint(
this.startTime,
regression.lowerStartPrice,
);
const lowerEndPoint = this.getPoint(
this.endTime,
regression.lowerEndPrice,
);
if (
!baseStartPoint ||
!baseEndPoint ||
!upperStartPoint ||
!upperEndPoint ||
!lowerStartPoint ||
!lowerEndPoint
) {
return null;
}
const points = [
upperStartPoint,
upperEndPoint,
lowerStartPoint,
lowerEndPoint,
];
return {
baseStartPoint,
baseEndPoint,
upperStartPoint,
upperEndPoint,
lowerStartPoint,
lowerEndPoint,
baseStartPrice: regression.baseStartPrice,
baseEndPrice: regression.baseEndPrice,
correlation: regression.correlation,
left: Math.min(...points.map((item) => item.x)),
right: Math.max(...points.map((item) => item.x)),
top: Math.min(...points.map((item) => item.y)),
bottom: Math.max(...points.map((item) => item.y)),
};
}
private applyDrag(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (
!snapshot ||
snapshot.startTime === null ||
snapshot.endTime === null
) {
return;
}
switch (this.activeDragTarget) {
case 'start':
this.moveEdge('start', point);
break;
case 'end':
this.moveEdge('end', point);
break;
case 'body':
this.moveWhole(snapshot, point);
break;
default:
break;
}
}
private moveEdge(kind: TimeLabelKind, point: Point): void {
const time = this.getBarTime(point.x);
if (time === null) {
return;
}
const previousTime =
kind === 'start'
? this.startTime
: this.endTime;
if (kind === 'start') {
this.startTime = time;
} else {
this.endTime = time;
}
if (this.hasValidRange()) {
return;
}
if (kind === 'start') {
this.startTime = previousTime;
} else {
this.endTime = previousTime;
}
}
private moveWhole(
snapshot: RegressionTrendState,
point: Point,
): void {
if (
!this.dragStartPoint ||
snapshot.startTime === null ||
snapshot.endTime === null
) {
return;
}
const data = this.getSeriesData();
const dragStartIndex = this.getBarIndexByX(
this.dragStartPoint.x,
data,
);
const currentIndex = this.getBarIndexByX(
point.x,
data,
);
const startIndex = this.getBarIndex(
snapshot.startTime,
data,
);
const endIndex = this.getBarIndex(
snapshot.endTime,
data,
);
if (
dragStartIndex === null ||
currentIndex === null ||
startIndex === null ||
endIndex === null
) {
return;
}
const rawOffset = currentIndex - dragStartIndex;
const minIndex = Math.min(startIndex, endIndex);
const maxIndex = Math.max(startIndex, endIndex);
const minOffset = -minIndex;
const maxOffset = data.length - 1 - maxIndex;
const offset = Math.max(
minOffset,
Math.min(rawOffset, maxOffset),
);
this.startTime = data[startIndex + offset].time;
this.endTime = data[endIndex + offset].time;
}
private getRegression(): RegressionResult | null {
const data = this.getSeriesData();
const range = this.getBarRange(data);
if (!range) {
return null;
}
const values: number[] = [];
for (
let index = range.left;
index <= range.right;
index += 1
) {
const value = getSeriesValue(data[index]);
if (value !== null) {
values.push(value);
}
}
if (values.length < 2) {
return null;
}
const regression = calculateRegression(values);
const offset =
regression.deviation *
REGRESSION_DEVIATION;
const baseStartPrice =
range.reversed
? regression.endValue
: regression.startValue;
const baseEndPrice =
range.reversed
? regression.startValue
: regression.endValue;
return {
baseStartPrice,
baseEndPrice,
upperStartPrice:
baseStartPrice + offset,
upperEndPrice:
baseEndPrice + offset,
lowerStartPrice:
baseStartPrice - offset,
lowerEndPrice:
baseEndPrice - offset,
correlation: regression.correlation,
};
}
private getBarRange(
data = this.getSeriesData(),
): {
left: number;
right: number;
reversed: boolean;
} | null {
if (
this.startTime === null ||
this.endTime === null
) {
return null;
}
const startIndex = this.getBarIndex(
this.startTime,
data,
);
const endIndex = this.getBarIndex(
this.endTime,
data,
);
if (
startIndex === null ||
endIndex === null
) {
return null;
}
return {
left: Math.min(startIndex, endIndex),
right: Math.max(startIndex, endIndex),
reversed: startIndex > endIndex,
};
}
private hasValidRange(): boolean {
const range = this.getBarRange();
if (!range) {
return false;
}
return (
range.right -
range.left >=
MIN_BAR_DISTANCE
);
}
private getBarTime(x: number): Time | null {
const time = getTimeFromXCoordinate(
this.chart,
x,
);
if (typeof time !== 'number') {
return null;
}
const data = this.getSeriesData();
const index = findNearestBarIndex(
data,
time,
);
if (index === null) {
return null;
}
return data[index].time;
}
private getBarIndexByX(
x: number,
data: readonly RegressionSeriesData[],
): number | null {
const time = getTimeFromXCoordinate(
this.chart,
x,
);
if (typeof time !== 'number') {
return null;
}
return findNearestBarIndex(
data,
time,
);
}
private getBarIndex(
time: Time,
data: readonly RegressionSeriesData[],
): number | null {
if (typeof time !== 'number') {
return null;
}
return findNearestBarIndex(
data,
time,
);
}
private getSeriesData(): readonly RegressionSeriesData[] {
return this.series.data() as readonly RegressionSeriesData[];
}
private getPoint(
time: Time,
price: number,
): Point | null {
const x = getXCoordinateFromTime(
this.chart,
time,
this.series,
);
const y = getYCoordinateFromPrice(
this.series,
price,
);
if (x === null || y === null) {
return null;
}
return {
x: Number(x),
y: Number(y),
};
}
private getDragTarget(point: Point): DragTarget {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (
isNearPoint(
point,
geometry.baseStartPoint.x,
geometry.baseStartPoint.y,
HIT_TOLERANCE,
)
) {
return 'start';
}
if (
isNearPoint(
point,
geometry.baseEndPoint.x,
geometry.baseEndPoint.y,
HIT_TOLERANCE,
)
) {
return 'end';
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
if (
getDistanceToSegment(
point,
geometry.upperStartPoint,
geometry.upperEndPoint,
) <= HIT_TOLERANCE
) {
return true;
}
if (
getDistanceToSegment(
point,
geometry.lowerStartPoint,
geometry.lowerEndPoint,
) <= HIT_TOLERANCE
) {
return true;
}
if (
getDistanceToSegment(
point,
geometry.baseStartPoint,
geometry.baseEndPoint,
) <= HIT_TOLERANCE
) {
return true;
}
return isPointInPolygon(
point,
[
geometry.upperStartPoint,
geometry.upperEndPoint,
geometry.lowerEndPoint,
geometry.lowerStartPoint,
],
);
}
}
function calculateRegression(
values: readonly number[],
): {
startValue: number;
endValue: number;
deviation: number;
correlation: number;
} {
const count = values.length;
const meanX = (count - 1) / 2;
const meanY =
values.reduce(
(sum, value) => sum + value,
0,
) / count;
let sumXX = 0;
let sumXY = 0;
let sumYY = 0;
for (
let index = 0;
index < count;
index += 1
) {
const x = index - meanX;
const y = values[index] - meanY;
sumXX += x * x;
sumXY += x * y;
sumYY += y * y;
}
const slope =
sumXX === 0
? 0
: sumXY / sumXX;
const intercept =
meanY -
slope * meanX;
let residualSum = 0;
for (
let index = 0;
index < count;
index += 1
) {
const expected =
intercept +
slope * index;
const residual =
values[index] -
expected;
residualSum +=
residual *
residual;
}
const denominator =
Math.sqrt(
sumXX *
sumYY,
);
return {
startValue: intercept,
endValue:
intercept +
slope *
(count - 1),
deviation:
Math.sqrt(
residualSum /
count,
),
correlation:
denominator === 0
? 0
: sumXY /
denominator,
};
}
function getSeriesValue(
data: RegressionSeriesData,
): number | null {
if (
typeof data.close === 'number' &&
Number.isFinite(data.close)
) {
return data.close;
}
if (
typeof data.value === 'number' &&
Number.isFinite(data.value)
) {
return data.value;
}
return null;
}
function findNearestBarIndex(
data: readonly RegressionSeriesData[],
targetTime: number,
): number | null {
if (!data.length) {
return null;
}
let left = 0;
let right = data.length - 1;
while (left <= right) {
const middle = Math.floor(
(left + right) / 2,
);
const time = data[middle].time;
if (typeof time !== 'number') {
return findNearestBarIndexLinear(
data,
targetTime,
);
}
if (time === targetTime) {
return middle;
}
if (time < targetTime) {
left = middle + 1;
} else {
right = middle - 1;
}
}
const nextIndex = Math.min(
left,
data.length - 1,
);
const previousIndex = Math.max(
0,
nextIndex - 1,
);
const nextTime = data[nextIndex].time;
const previousTime = data[previousIndex].time;
if (
typeof nextTime !== 'number' ||
typeof previousTime !== 'number'
) {
return findNearestBarIndexLinear(
data,
targetTime,
);
}
return Math.abs(previousTime - targetTime) <=
Math.abs(nextTime - targetTime)
? previousIndex
: nextIndex;
}
function findNearestBarIndexLinear(
data: readonly RegressionSeriesData[],
targetTime: number,
): number | null {
let result: number | null = null;
let minDistance = Number.POSITIVE_INFINITY;
data.forEach((item, index) => {
if (typeof item.time !== 'number') {
return;
}
const distance = Math.abs(
item.time -
targetTime,
);
if (distance >= minDistance) {
return;
}
minDistance = distance;
result = index;
});
return result;
}
function getDistanceToSegment(
point: Point,
start: Point,
end: Point,
): number {
const deltaX = end.x - start.x;
const deltaY = end.y - start.y;
if (deltaX === 0 && deltaY === 0) {
return Math.hypot(
point.x - start.x,
point.y - start.y,
);
}
const ratio = Math.max(
0,
Math.min(
1,
(
(point.x - start.x) *
deltaX +
(point.y - start.y) *
deltaY
) /
(
deltaX *
deltaX +
deltaY *
deltaY
),
),
);
const x =
start.x +
ratio * deltaX;
const y =
start.y +
ratio * deltaY;
return Math.hypot(
point.x - x,
point.y - y,
);
}
function isPointInPolygon(
point: Point,
polygon: Point[],
): boolean {
let inside = false;
for (
let index = 0,
previousIndex = polygon.length - 1;
index < polygon.length;
previousIndex = index,
index += 1
) {
const current = polygon[index];
const previous = polygon[previousIndex];
const intersects =
current.y > point.y !==
previous.y > point.y &&
point.x <
(
(previous.x - current.x) *
(point.y - current.y)
) /
(previous.y - current.y) +
current.x;
if (intersects) {
inside = !inside;
}
}
return inside;
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import type { Point } from '@core/Drawings/types';
import type { RegressionTrend } from './regressionTrend';
const UI = {
lineWidth: 2,
middleLineWidth: 1,
middleLineDash: 5,
middleLineGap: 4,
handleRadius: 5,
handleBorderWidth: 2,
correlationFontSize: 11,
correlationOffset: 6,
};
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;
}
target.useBitmapCoordinateSpace(
({
context,
horizontalPixelRatio,
verticalPixelRatio,
}) => {
const pixelRatio = Math.max(
horizontalPixelRatio,
verticalPixelRatio,
);
const baseStart = scalePoint(
data.baseStartPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const baseEnd = scalePoint(
data.baseEndPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const upperStart = scalePoint(
data.upperStartPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const upperEnd = scalePoint(
data.upperEndPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const lowerStart = scalePoint(
data.lowerStartPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
const lowerEnd = scalePoint(
data.lowerEndPoint,
horizontalPixelRatio,
verticalPixelRatio,
);
context.save();
if (data.showChannel) {
drawArea(
context,
upperStart,
upperEnd,
baseEnd,
baseStart,
data.upperFillColor,
);
drawArea(
context,
baseStart,
baseEnd,
lowerEnd,
lowerStart,
data.lowerFillColor,
);
context.strokeStyle =
data.lineColor;
context.lineWidth =
UI.lineWidth *
pixelRatio;
drawLine(
context,
upperStart,
upperEnd,
);
drawLine(
context,
lowerStart,
lowerEnd,
);
}
if (
data.showMiddleLine ||
!data.showChannel
) {
context.save();
context.strokeStyle =
data.middleLineColor;
context.lineWidth =
UI.middleLineWidth *
pixelRatio;
context.setLineDash([
UI.middleLineDash *
pixelRatio,
UI.middleLineGap *
pixelRatio,
]);
drawLine(
context,
baseStart,
baseEnd,
);
context.restore();
}
if (
data.showChannel &&
data.showCorrelation
) {
drawCorrelation(
context,
data.correlation,
lowerStart,
data.middleLineColor,
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,
baseStart,
radius,
);
drawHandle(
context,
baseEnd,
radius,
);
}
context.restore();
},
);
}
}
function scalePoint(
point: Point,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): Point {
return {
x:
point.x *
horizontalPixelRatio,
y:
point.y *
verticalPixelRatio,
};
}
function drawLine(
context: CanvasRenderingContext2D,
start: Point,
end: Point,
): void {
context.beginPath();
context.moveTo(
start.x,
start.y,
);
context.lineTo(
end.x,
end.y,
);
context.stroke();
}
function drawArea(
context: CanvasRenderingContext2D,
first: Point,
second: Point,
third: Point,
fourth: Point,
color: string,
): void {
context.fillStyle = color;
context.beginPath();
context.moveTo(
first.x,
first.y,
);
context.lineTo(
second.x,
second.y,
);
context.lineTo(
third.x,
third.y,
);
context.lineTo(
fourth.x,
fourth.y,
);
context.closePath();
context.fill();
}
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 drawCorrelation(
context: CanvasRenderingContext2D,
correlation: number,
point: Point,
color: string,
verticalPixelRatio: number,
): void {
const fontSize =
UI.correlationFontSize *
verticalPixelRatio;
context.save();
context.font =
`${fontSize}px Inter, sans-serif`;
context.fillStyle = color;
context.textAlign = 'left';
context.textBaseline = 'top';
context.fillText(
correlation.toFixed(6),
point.x,
point.y +
UI.correlationOffset *
verticalPixelRatio,
);
context.restore();
}