Загрузка данных
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { DiapsonPaneView } from './paneView';
import {
createDefaultSettings,
DiapsonSettings,
DiapsonStyle,
DiapsonTextStyle,
getDiapsonSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams extends BaseDrawingParams {
rangeMode: DiapsonRangeMode;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
mode: DiapsonMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
labelLines: string[];
}
interface DateMetrics {
barsCount: number;
elapsedText: string;
volumeText: string;
}
interface PriceMetrics {
delta: number;
percent: number;
steps: number;
}
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: DiapsonSettings = createDefaultSettings();
protected mode: DiapsonMode = 'idle';
private rangeMode: DiapsonRangeMode;
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: DiapsonHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: DiapsonGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly stepSize: number;
private readonly stepLabel: string;
private readonly paneView: DiapsonPaneView;
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,
series,
container,
interaction,
rangeMode,
formatObservable,
removeSelf,
openSettings,
stepSize = 1,
stepLabel = '',
initialEvent,
}: DiapsonParams) {
super({
chart,
series,
container,
interaction,
});
this.rangeMode = rangeMode;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.stepSize = stepSize > 0 ? stepSize : 1;
this.stepLabel = stepLabel;
this.paneView = new DiapsonPaneView(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);
if (initialEvent && initialEvent.sourceEvent) {
const point = this.getEventPoint(initialEvent.sourceEvent);
this.startDrawing(point);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public setRangeMode(nextMode: DiapsonRangeMode): void {
if (this.rangeMode === nextMode) {
return;
}
this.rangeMode = nextMode;
this.resetToIdle();
}
public getState(): DiapsonState {
return {
hidden: this.hidden,
mode: this.mode,
rangeMode: this.rangeMode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<DiapsonState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
this.rangeMode = nextState.rangeMode ?? this.rangeMode;
this.startTime = 'startTime' in nextState ? (nextState.startTime ?? null) : this.startTime;
this.endTime = 'endTime' in nextState ? (nextState.endTime ?? null) : this.endTime;
this.startPrice = 'startPrice' in nextState ? (nextState.startPrice ?? null) : this.startPrice;
this.endPrice = 'endPrice' in nextState ? (nextState.endPrice ?? null) : this.endPrice;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(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(): DiapsonRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
rangeMode: this.rangeMode,
showFill: true,
labelLines: this.getLabelLines(),
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
shape: 'circle',
size: 12,
borderWidth: 2,
},
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
size: 12,
borderWidth: 2,
},
];
}
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 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: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
const handleTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !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') {
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(this.clampPointToContainer(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 anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
this.resetToIdle();
return;
}
if (geometry.width < MIN_RECTANGLE_WIDTH || geometry.height < MIN_RECTANGLE_HEIGHT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
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.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.deselect();
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextStartPoint = this.clampPointToContainer({
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
});
const nextEndPoint = this.clampPointToContainer({
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
});
this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const nextPoint = this.clampPointToContainer(point);
if (this.activeDragTarget === 'start') {
this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
return;
}
this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return;
}
this.startTime = startAnchor.time;
this.startPrice = startAnchor.price;
this.endTime = endAnchor.time;
this.endPrice = endAnchor.price;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): DiapsonGeometry | 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 { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const top = Math.round(Math.min(startPoint.y, endPoint.y));
const bottom = Math.round(Math.max(startPoint.y, endPoint.y));
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
startPoint,
endPoint,
};
}
private getLabelLines(): string[] {
if (this.rangeMode === 'date') {
const metrics = this.getDateMetrics();
if (!metrics) {
return [];
}
const firstLine = metrics.elapsedText
? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
: `${metrics.barsCount} ${t('bars')}`;
if (!metrics.volumeText) {
return [firstLine];
}
return [firstLine, `${t('Vol')} ${metrics.volumeText}`];
}
const metrics = this.getPriceMetrics();
if (!metrics) {
return [];
}
const percentText =
metrics.percent < 0 ? `-${formatPercent(Math.abs(metrics.percent))}` : formatPercent(Math.abs(metrics.percent));
const absSteps = Math.abs(metrics.steps);
let stepsText = '';
if (Number.isInteger(absSteps)) {
stepsText = absSteps.toString();
} else if (absSteps >= 1000) {
stepsText = absSteps.toFixed(0);
} else if (absSteps >= 100) {
stepsText = absSteps.toFixed(1);
} else {
stepsText = absSteps.toFixed(2);
}
if (metrics.steps < 0) {
stepsText = `-${stepsText}`;
}
const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';
return [`${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`];
}
private getDateMetrics(): DateMetrics | null {
const leftTime = this.getLeftTimeValue();
const rightTime = this.getRightTimeValue();
if (!leftTime || !rightTime) {
return null;
}
const barsCount = this.getBarsCount();
const durationSeconds = Math.max(0, Math.floor(Math.abs(Number(rightTime) - Number(leftTime))));
const days = Math.floor(durationSeconds / 86400);
const hours = Math.floor((durationSeconds % 86400) / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
const elapsedParts: string[] = [];
if (days > 0) {
elapsedParts.push(`${days}d`);
}
if (hours > 0) {
elapsedParts.push(`${hours}h`);
}
if (minutes > 0) {
elapsedParts.push(`${minutes}m`);
}
if (elapsedParts.length === 0) {
elapsedParts.push(`${seconds}s`);
}
const volume = this.getVolumeInRange();
return {
barsCount,
elapsedText: elapsedParts.slice(0, 2).join(' '),
volumeText: volume > 0 ? formatVolume(volume) : '',
};
}
private getPriceMetrics(): PriceMetrics | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const delta = this.endPrice - this.startPrice;
return {
delta,
percent: this.startPrice !== 0 ? (delta / Math.abs(this.startPrice)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
return Math.abs(endIndex - startIndex);
}
private getVolumeInRange(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => Number(item.time) === Number(time));
}
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 = kind === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
if (!time) {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getLeftTimeValue(): 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 this.startTime;
}
return Number(startX) <= Number(endX) ? this.startTime : this.endTime;
}
private getRightTimeValue(): 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 this.endTime;
}
return Number(startX) <= Number(endX) ? this.endTime : this.startTime;
}
private getTopPriceValue(): 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 Math.max(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.startPrice : this.endPrice;
}
private getBottomPriceValue(): 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 Math.min(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<DiapsonHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
if (handle === 'body') {
return 'move';
}
const geometry = this.getGeometry();
if (!geometry) {
return 'default';
}
const sameDirection =
(geometry.endPoint.x - geometry.startPoint.x >= 0 && geometry.endPoint.y - geometry.startPoint.y >= 0) ||
(geometry.endPoint.x - geometry.startPoint.x < 0 && geometry.endPoint.y - geometry.startPoint.y < 0);
return sameDirection ? 'nwse-resize' : 'nesw-resize';
}
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 { Diapson, DiapsonRenderData } from './diapson';
import type { DiapsonTextStyle } from './settings';
const UI = {
lineWidth: 1,
labelPadding: 4,
labelRadius: 2,
labelBottomOffset: 8,
arrowSize: 7,
lineHeightMultiplier: 1.2,
};
export class DiapsonPaneRenderer implements IPrimitivePaneRenderer {
private readonly diapson: Diapson;
constructor(diapson: Diapson) {
this.diapson = diapson;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.diapson.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const bottom = data.bottom * verticalPixelRatio;
const width = Math.max(right - left, 0);
const height = Math.max(bottom - top, 0);
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = UI.lineWidth * pixelRatio;
const arrowSize = UI.arrowSize * pixelRatio;
context.save();
if (data.showFill) {
context.fillStyle = data.fillColor;
context.fillRect(left, top, width, height);
}
context.strokeStyle = data.borderColor;
context.lineWidth = lineWidth;
if (data.rangeMode === 'date') {
drawVerticalBoundary(context, left, top, bottom);
drawVerticalBoundary(context, right, top, bottom);
drawHorizontalArrow(
context,
left,
right,
(top + bottom) / 2,
arrowSize,
data.startPoint.x <= data.endPoint.x ? 'right' : 'left',
);
} else {
drawHorizontalBoundary(context, top, left, right);
drawHorizontalBoundary(context, bottom, left, right);
drawVerticalArrow(
context,
(left + right) / 2,
top,
bottom,
arrowSize,
data.startPoint.y <= data.endPoint.y ? 'down' : 'up',
);
}
if (data.labelLines.length > 0) {
drawLabel(context, data, horizontalPixelRatio, verticalPixelRatio, data);
}
context.restore();
});
}
}
function drawVerticalBoundary(context: CanvasRenderingContext2D, x: number, top: number, bottom: number): void {
context.beginPath();
context.moveTo(x, top);
context.lineTo(x, bottom);
context.stroke();
}
function drawHorizontalBoundary(context: CanvasRenderingContext2D, y: number, left: number, right: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawHorizontalArrow(
context: CanvasRenderingContext2D,
left: number,
right: number,
y: number,
arrowSize: number,
direction: 'left' | 'right',
): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
if (direction === 'right') {
context.beginPath();
context.moveTo(right, y);
context.lineTo(right - arrowSize, y - arrowSize);
context.moveTo(right, y);
context.lineTo(right - arrowSize, y + arrowSize);
context.stroke();
return;
}
context.beginPath();
context.moveTo(left, y);
context.lineTo(left + arrowSize, y - arrowSize);
context.moveTo(left, y);
context.lineTo(left + arrowSize, y + arrowSize);
context.stroke();
}
function drawVerticalArrow(
context: CanvasRenderingContext2D,
x: number,
top: number,
bottom: number,
arrowSize: number,
direction: 'up' | 'down',
): void {
context.beginPath();
context.moveTo(x, top);
context.lineTo(x, bottom);
context.stroke();
if (direction === 'down') {
context.beginPath();
context.moveTo(x, bottom);
context.lineTo(x - arrowSize, bottom - arrowSize);
context.moveTo(x, bottom);
context.lineTo(x + arrowSize, bottom - arrowSize);
context.stroke();
return;
}
context.beginPath();
context.moveTo(x, top);
context.lineTo(x - arrowSize, top + arrowSize);
context.moveTo(x, top);
context.lineTo(x + arrowSize, top + arrowSize);
context.stroke();
}
function drawLabel(
context: CanvasRenderingContext2D,
data: DiapsonRenderData,
horizontalPixelRatio: number,
verticalPixelRatio: number,
textStyle: DiapsonTextStyle,
): void {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const fontSize = textStyle.fontSize * pixelRatio;
const padding = UI.labelPadding * pixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const radius = UI.labelRadius * pixelRatio;
const bottomOffset = UI.labelBottomOffset * verticalPixelRatio;
const paneLeft = data.left * horizontalPixelRatio;
const paneRight = data.right * horizontalPixelRatio;
const paneBottom = data.bottom * verticalPixelRatio;
context.save();
context.font = getLabelFont(textStyle, fontSize);
const maxTextWidth = data.labelLines.reduce((maxWidth, line) => {
return Math.max(maxWidth, context.measureText(line).width);
}, 0);
const labelWidth = maxTextWidth + padding * 2;
const labelHeight = data.labelLines.length * lineHeight + padding * 2;
const rangeCenterX = (paneLeft + paneRight) / 2;
const maxLeft = Math.max(4, context.canvas.width - labelWidth - 4);
const maxTop = Math.max(4, context.canvas.height - labelHeight - 4);
const boxLeft = clampNumber(rangeCenterX - labelWidth / 2, 4, maxLeft);
const boxTop = clampNumber(paneBottom + bottomOffset, 4, maxTop);
context.fillStyle = textStyle.labelBackgroundColor;
fillRoundedRect(context, boxLeft, boxTop, labelWidth, labelHeight, radius);
context.fillStyle = textStyle.labelTextColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
data.labelLines.forEach((line, index) => {
const lineY = boxTop + padding + lineHeight * index + lineHeight / 2;
context.fillText(line, boxLeft + labelWidth / 2, lineY);
});
context.restore();
}
function getLabelFont(style: DiapsonTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
function fillRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
context.fill();
}
function clampNumber(value: number, min: number, max: number): number {
if (max < min) {
return min;
}
return Math.max(min, Math.min(value, max));
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { Diapson } from './diapson';
import { DiapsonPaneRenderer } from './paneRenderer';
export class DiapsonPaneView implements IPrimitivePaneView {
private readonly paneRenderer: DiapsonPaneRenderer;
constructor(diapson: Diapson) {
this.paneRenderer = new DiapsonPaneRenderer(diapson);
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return this.paneRenderer;
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface DiapsonStyle {
borderColor: string;
fillColor: string;
}
export interface DiapsonTextStyle {
labelTextColor: string;
labelBackgroundColor: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
}
export type DiapsonSettings = SettingsValues & DiapsonStyle & DiapsonTextStyle;
export function createDefaultSettings(): DiapsonSettings {
const { colors } = getThemeStore();
return {
borderColor: colors.diapsonStrokeFill,
fillColor: colors.diapsonAreaFill,
labelTextColor: colors.axisRangeTooltipText,
labelBackgroundColor: colors.axisRangeTooltipFill,
fontSize: 10,
isBold: false,
isItalic: false,
};
}
export function getDiapsonSettingsTabs(settings: DiapsonSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'borderColor',
label: t('Border'),
type: 'color',
defaultValue: settings.borderColor,
toolbar: {
control: 'color',
role: 'line',
},
},
{
key: 'fillColor',
label: t('Background'),
type: 'color',
defaultValue: settings.fillColor,
toolbar: {
control: 'color',
role: 'fill',
},
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italic'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'labelTextColor',
label: t('Text'),
type: 'color',
defaultValue: settings.labelTextColor,
toolbar: {
control: 'color',
role: 'text',
},
},
{
key: 'labelBackgroundColor',
label: t('Background'),
type: 'color',
defaultValue: settings.labelBackgroundColor,
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}
import { isNearPoint } from '@core/Drawings/helpers';
import { getThemeStore } from '@src/theme';
import type { Point } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
export type DrawingHandleShape = 'circle' | 'rounded';
export interface DrawingHandle<TId extends string = string> {
id: TId;
x: number;
y: number;
shape?: DrawingHandleShape;
size?: number;
borderWidth?: number;
strokeColor?: string;
}
const DEFAULT_HANDLE_SIZE = 12;
const DEFAULT_HANDLE_BORDER_WIDTH = 2;
const HANDLE_HIT_TOLERANCE = 8;
const HANDLE_RADIUS = 2;
export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
private series: SeriesAttachedParameter<Time>['series'] | null = null;
private readonly paneRenderer: IPrimitivePaneRenderer = {
draw: (target) => this.drawPane(target),
};
private readonly timeAxisRenderer: IPrimitivePaneRenderer = {
draw: (target) => this.drawTimeAxis(target),
};
private readonly paneViewsList: readonly IPrimitivePaneView[] = [
{
renderer: () => this.paneRenderer,
zOrder: () => 'top',
},
];
private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[] = [
{
renderer: () => this.timeAxisRenderer,
zOrder: () => 'top',
},
];
constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {}
public attached({ chart, series }: SeriesAttachedParameter<Time>): void {
this.chart = chart;
this.series = series;
}
public detached(): void {
this.chart = null;
this.series = null;
}
public updateAllViews(): void {}
public paneViews(): readonly IPrimitivePaneView[] {
return this.paneViewsList;
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.timeAxisPaneViewsList;
}
public findHandle(point: Point): DrawingHandle<TId> | null {
const handles = this.getHandles();
for (let index = handles.length - 1; index >= 0; index -= 1) {
const handle = handles[index];
if (isNearPoint(point, handle.x, handle.y, getHitTolerance(handle))) {
return handle;
}
}
return null;
}
public isTimeAxisHit(point: Point): boolean {
if (!this.isTimeAxisAdjacent()) {
return false;
}
const paneHeight = this.getPaneHeight();
if (paneHeight <= 0 || point.y < paneHeight) {
return false;
}
const handle = this.findHandle(point);
return handle !== null && intersectsTimeAxis(handle, paneHeight);
}
private drawPane(target: CanvasRenderingTarget2D): void {
const handles = this.getHandles();
if (!handles.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
for (const handle of handles) {
drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
}
});
}
private drawTimeAxis(target: CanvasRenderingTarget2D): void {
if (!this.isTimeAxisAdjacent()) {
return;
}
const paneHeight = this.getPaneHeight();
if (paneHeight <= 0) {
return;
}
const handles = this.getHandles();
if (!handles.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
const axisHeight = bitmapSize.height / verticalPixelRatio;
for (const handle of handles) {
if (!intersectsTimeAxis(handle, paneHeight)) {
continue;
}
const axisY = handle.y - paneHeight;
const halfSize = getHandleSize(handle) / 2;
if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
continue;
}
drawHandle(context, { ...handle, y: axisY }, horizontalPixelRatio, verticalPixelRatio);
}
});
}
private getPaneHeight(): number {
return this.series?.getPane().getHeight() ?? 0;
}
private isTimeAxisAdjacent(): boolean {
if (!this.chart || !this.series) {
return false;
}
return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
}
}
function getHandleSize(handle: DrawingHandle): number {
return handle.size ?? DEFAULT_HANDLE_SIZE;
}
function getHitTolerance(handle: DrawingHandle): number {
return Math.max(HANDLE_HIT_TOLERANCE, getHandleSize(handle) / 2);
}
function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
const halfSize = getHandleSize(handle) / 2;
return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
}
function drawHandle(
context: CanvasRenderingContext2D,
handle: DrawingHandle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const size = getHandleSize(handle);
const shape = handle.shape ?? 'circle';
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = (handle.borderWidth ?? DEFAULT_HANDLE_BORDER_WIDTH) * pixelRatio;
const width = size * horizontalPixelRatio;
const height = size * verticalPixelRatio;
const x = handle.x * horizontalPixelRatio;
const y = handle.y * verticalPixelRatio;
const inset = lineWidth / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = handle.strokeColor ?? colors.chartLineColor;
context.lineWidth = lineWidth;
context.beginPath();
if (shape === 'circle') {
const radius = Math.max(Math.min(width, height) / 2 - inset, 0);
context.arc(x, y, radius, 0, Math.PI * 2);
} else {
const left = x - width / 2 + inset;
const top = y - height / 2 + inset;
const drawWidth = Math.max(width - lineWidth, 0);
const drawHeight = Math.max(height - lineWidth, 0);
drawRoundedRect(context, left, top, drawWidth, drawHeight, HANDLE_RADIUS * pixelRatio);
}
context.fill();
context.stroke();
context.restore();
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}
import { clamp } from 'lodash-es';
import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';
import type { Coordinate, IChartApi, Logical, Time, TouchMouseEventData } from 'lightweight-charts';
interface SeriesTimeItem {
time: Time;
}
interface TimePoint {
time: number;
logical: number;
}
export function getPriceFromYCoordinate(series: SeriesApi, yCoordinate: number): number | null {
return series.coordinateToPrice(yCoordinate as Coordinate);
}
export function getYCoordinateFromPrice(series: SeriesApi, price: number): Coordinate | null {
return series.priceToCoordinate(price);
}
export function getTimeFromXCoordinate(chart: IChartApi, xCoordinate: number): Time | null {
return chart.timeScale().coordinateToTime(xCoordinate as Coordinate) ?? null;
}
export function getXCoordinateFromTime(chart: IChartApi, time: Time, series?: SeriesApi): Coordinate | null {
const coordinate = chart.timeScale().timeToCoordinate(time);
if (isValidCoordinate(coordinate)) {
return coordinate;
}
if (!series) {
return null;
}
const logical = getNearestLogicalFromTime(series, time);
if (logical === null) {
return null;
}
const projectedCoordinate = chart.timeScale().logicalToCoordinate(logical as Logical);
if (!isValidCoordinate(projectedCoordinate)) {
return null;
}
return projectedCoordinate;
}
export function getContainerSize(container: HTMLElement): ContainerSize {
const rect = container.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
};
}
export function clampPointToContainer(point: Point, container: HTMLElement): Point {
const { width, height } = getContainerSize(container);
return {
x: clamp(point.x, 0, width),
y: clamp(point.y, 0, height),
};
}
export function getRawPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
const rect = container.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
export function getPointerPoint(container: HTMLElement, event: MouseEvent | TouchMouseEventData): Point {
return clampPointToContainer(getRawPointerPoint(container, event), container);
}
export function isNearPoint(point: Point, x: number, y: number, tolerance: number): boolean {
return Math.abs(point.x - x) <= tolerance && Math.abs(point.y - y) <= tolerance;
}
export function isPointInBounds(point: Point, bounds: Bounds, tolerance = 0): boolean {
return (
point.x >= bounds.left - tolerance &&
point.x <= bounds.right + tolerance &&
point.y >= bounds.top - tolerance &&
point.y <= bounds.bottom + tolerance
);
}
export function normalizeBounds(
left: number,
right: number,
top: number,
bottom: number,
container: HTMLElement,
): Bounds {
const { width, height } = getContainerSize(container);
return {
left: clamp(Math.min(left, right), 0, width),
right: clamp(Math.max(left, right), 0, width),
top: clamp(Math.min(top, bottom), 0, height),
bottom: clamp(Math.max(top, bottom), 0, height),
};
}
export function shiftTimeByPixels(chart: IChartApi, time: Time, offsetX: number, series?: SeriesApi): Time | null {
const coordinate = getXCoordinateFromTime(chart, time, series);
if (!isValidCoordinate(coordinate)) {
return null;
}
return getTimeFromXCoordinate(chart, Number(coordinate) + offsetX);
}
export function getPriceDelta(series: SeriesApi, fromY: number, toY: number): number {
const fromPrice = getPriceFromYCoordinate(series, fromY);
const toPrice = getPriceFromYCoordinate(series, toY);
if (fromPrice === null || toPrice === null) {
return 0;
}
return toPrice - fromPrice;
}
export function getPriceRangeInContainer(
series: SeriesApi,
container: HTMLElement,
): { min: number; max: number } | null {
const { height } = getContainerSize(container);
if (!height) {
return null;
}
const topPrice = getPriceFromYCoordinate(series, 0);
const bottomPrice = getPriceFromYCoordinate(series, height);
if (topPrice === null || bottomPrice === null) {
return null;
}
return {
min: Math.min(topPrice, bottomPrice),
max: Math.max(topPrice, bottomPrice),
};
}
export function getAnchorFromPoint(chart: IChartApi, series: SeriesApi, point: Point): Anchor | null {
const time = getTimeFromXCoordinate(chart, point.x);
const price = getPriceFromYCoordinate(series, point.y);
if (time === null || price === null) {
return null;
}
return {
time,
price,
};
}
function getNearestLogicalFromTime(series: SeriesApi, time: Time): number | null {
const targetTime = getNumericTime(time);
if (targetTime === null) {
return null;
}
const points = getSeriesTimePoints(series);
if (!points.length) {
return null;
}
const lastIndex = points.length - 1;
if (targetTime <= points[0].time) {
return points[0].logical;
}
if (targetTime >= points[lastIndex].time) {
return points[lastIndex].logical;
}
let left = 0;
let right = lastIndex;
while (left <= right) {
const middleIndex = Math.floor((left + right) / 2);
const middleTime = points[middleIndex].time;
if (middleTime === targetTime) {
return points[middleIndex].logical;
}
if (middleTime < targetTime) {
left = middleIndex + 1;
} else {
right = middleIndex - 1;
}
}
// Если точного времени нет на текущем таймфрейме, left и right становятся соседними свечами вокруг targetTime
// Для отображения дровинга берём ближайшую существующую свечу, но исходный state дровинга не меняем
const previousPoint = points[right] ?? null;
const nextPoint = points[left] ?? null;
return getNearestLogicalByTime(targetTime, previousPoint, nextPoint);
}
function getNearestLogicalByTime(
targetTime: number,
previousPoint: TimePoint | null,
nextPoint: TimePoint | null,
): number | null {
if (!previousPoint && !nextPoint) {
return null;
}
if (!previousPoint) {
return nextPoint?.logical ?? null;
}
if (!nextPoint) {
return previousPoint.logical;
}
const previousDistance = Math.abs(targetTime - previousPoint.time);
const nextDistance = Math.abs(nextPoint.time - targetTime);
return nextDistance < previousDistance ? nextPoint.logical : previousPoint.logical;
}
function getSeriesTimePoints(series: SeriesApi): TimePoint[] {
const data = series.data() as readonly SeriesTimeItem[];
return data.reduce<TimePoint[]>((points, item, logical) => {
const time = getNumericTime(item.time);
if (time === null) {
return points;
}
points.push({
time,
logical,
});
return points;
}, []);
}
function getNumericTime(time: Time): number | null {
if (typeof time !== 'number') {
return null;
}
return Number.isFinite(time) ? time : null;
}
function isValidCoordinate(coordinate: Coordinate | null): coordinate is Coordinate {
return coordinate !== null && Number.isFinite(Number(coordinate));
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
MouseEventParams,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import { getPointerPoint as getPointerPointFromEvent, getRawPointerPoint } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type { DrawingHandle } from '@core/Drawings/handles';
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;
isHit(event: MouseEvent): boolean;
pointerDown(event: PointerEvent): void;
click(event: MouseEvent): void;
doubleClick(event: MouseEvent): void;
contextMenu(event: MouseEvent): void;
getRenderData(): unknown;
}
export type StartPoint = unknown;
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
export interface BaseDrawingParams {
chart: IChartApi;
series: SeriesApi;
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
initialEvent?: MouseEventParams;
}
export abstract class SeriesDrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> 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 readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
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;
this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
if (this.hidden || !this.shouldShowHandles()) {
return [];
}
return this.getDrawingHandles();
});
}
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.series.attachPrimitive(this.handlesPrimitive);
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
this.series.detachPrimitive(this.handlesPrimitive);
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 isHit(event: MouseEvent): boolean {
if (!this.isEventInside(event)) {
return false;
}
const point = this.getEventPoint(event as PointerEvent);
return this.getHoveredItem(point.x, point.y) !== null;
}
public pointerDown(event: PointerEvent): void {
if (!this.isEventInside(event)) {
return;
}
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
if (this.getHoveredItem(point.x, point.y)) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
}
public click(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleClick(event);
}
public doubleClick(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleDoubleClick(event);
}
public contextMenu(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleContextMenu(event);
}
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 getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
return [];
}
protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
return this.handlesPrimitive.findHandle(point);
}
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 | TouchMouseEventData): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
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;
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleClick(event: MouseEvent): void {}
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected handlePointerDown(event: PointerEvent | TouchMouseEventData): 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 isEventInside(event: MouseEvent): boolean {
if (event.target instanceof Node && this.container.contains(event.target)) {
return true;
}
return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
}
}
import { DrawingsNames } from '@src/constants';
import { Keys } from '@src/core/Hotkeys';
import type { MouseEventParams } from 'lightweight-charts';
type DrawingPointerModifier = Keys.shift | Keys.alt | Keys.control | Keys.meta;
interface DrawingKeyboardShortcut {
drawingName: DrawingsNames;
keys: readonly Keys[];
}
interface DrawingPointerShortcut {
drawingName: DrawingsNames;
modifiers: readonly DrawingPointerModifier[];
}
export const DRAWING_KEYBOARD_SHORTCUTS: readonly DrawingKeyboardShortcut[] = [
{
drawingName: DrawingsNames.trendLine,
keys: [Keys.alt, Keys.t],
},
{
drawingName: DrawingsNames.horizontalLine,
keys: [Keys.alt, Keys.h],
},
{
drawingName: DrawingsNames.verticalLine,
keys: [Keys.alt, Keys.v],
},
{
drawingName: DrawingsNames.fibonacciRetracement,
keys: [Keys.alt, Keys.f],
},
{
drawingName: DrawingsNames.rectangle,
keys: [Keys.alt, Keys.shift, Keys.r],
},
];
export const DRAWING_POINTER_SHORTCUTS: readonly DrawingPointerShortcut[] = [
{
drawingName: DrawingsNames.ruler,
modifiers: [Keys.shift],
},
];
export function findDrawingPointerShortcut(event: MouseEventParams): DrawingPointerShortcut | undefined {
const { sourceEvent } = event;
if (!sourceEvent) {
return undefined;
}
return DRAWING_POINTER_SHORTCUTS.find(
({ modifiers }) =>
sourceEvent.shiftKey === modifiers.includes(Keys.shift) &&
sourceEvent.altKey === modifiers.includes(Keys.alt) &&
sourceEvent.ctrlKey === modifiers.includes(Keys.control) &&
sourceEvent.metaKey === modifiers.includes(Keys.meta),
);
}
import type { ISeriesApi, SeriesOptionsMap, Time } from 'lightweight-charts';
export type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
export interface Point {
x: number;
y: number;
}
export interface Bounds {
left: number;
right: number;
top: number;
bottom: number;
}
export interface ContainerSize {
width: number;
height: number;
}
export interface Anchor {
time: Time;
price: number;
}
export interface AxisSegment {
from: number;
to: number;
color: string;
}
export interface AxisLabel {
coordinate: number;
text: string;
textColor: string;
backgroundColor: string;
}
export interface UpdatableView {
update(): void;
}
import type { UpdatableView } from './types';
export function updateViews(views: readonly UpdatableView[]): void {
for (const view of views) {
view.update();
}
}
export function getOrderedSideValue<T>(
startValue: T | null,
endValue: T | null,
startCoordinate: number | null,
endCoordinate: number | null,
side: 'start' | 'end',
): T | null {
if (startValue === null || endValue === null) {
return null;
}
if (startCoordinate === null || endCoordinate === null) {
return side === 'start' ? startValue : endValue;
}
const startFirst = startCoordinate <= endCoordinate;
if (side === 'start') {
return startFirst ? startValue : endValue;
}
return startFirst ? endValue : startValue;
}