Загрузка данных
import type { Anchor, Bounds, ContainerSize, Point, SeriesApi } from './types';
import type { Coordinate, IChartApi, Logical, Time } 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, series?: SeriesApi): Time | null {
if (series) {
const time = getNearestSeriesTimeFromXCoordinate(chart, series, xCoordinate);
if (time !== null) {
return time;
}
return 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 (coordinate !== null) {
return coordinate;
}
if (!series) {
return null;
}
const logical = getLogicalFromTime(series, time);
if (logical === null) {
return null;
}
const projectedCoordinate = chart.timeScale().logicalToCoordinate(logical as Logical);
if (projectedCoordinate === null || !Number.isFinite(Number(projectedCoordinate))) {
return null;
}
return projectedCoordinate;
}
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
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 getPointerPoint(container: HTMLElement, event: PointerEvent): Point {
const rect = container.getBoundingClientRect();
return clampPointToContainer(
{
x: event.clientX - rect.left,
y: event.clientY - rect.top,
},
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 (coordinate === null) {
return null;
}
return getTimeFromXCoordinate(chart, Number(coordinate) + offsetX, series);
}
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, series);
const price = getPriceFromYCoordinate(series, point.y);
if (time === null || price === null) {
return null;
}
return {
time,
price,
};
}
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 getLogicalFromTime(series: SeriesApi, time: Time): number | null {
const targetTime = getNumericTime(time);
if (targetTime === null) {
return null;
}
const points = getSeriesTimePoints(series);
if (points.length < 2) {
return null;
}
const lastIndex = points.length - 1;
if (targetTime <= points[0].time) {
return interpolateLogical(points[0], points[1], targetTime);
}
if (targetTime >= points[lastIndex].time) {
return interpolateLogical(points[lastIndex - 1], points[lastIndex], targetTime);
}
let left = 0;
let right = lastIndex;
while (left <= right) {
const middle = Math.floor((left + right) / 2);
const middleTime = points[middle].time;
if (middleTime === targetTime) {
return points[middle].logical;
}
if (middleTime < targetTime) {
left = middle + 1;
} else {
right = middle - 1;
}
}
return interpolateLogical(points[right], points[left], targetTime);
}
function getTimeFromLogical(series: SeriesApi, logical: number): Time | null {
const points = getSeriesTimePoints(series);
if (points.length < 2) {
return null;
}
const lastIndex = points.length - 1;
if (logical <= points[0].logical) {
return interpolateTime(points[0], points[1], logical);
}
if (logical >= points[lastIndex].logical) {
return interpolateTime(points[lastIndex - 1], points[lastIndex], logical);
}
const leftIndex = Math.floor(logical);
const rightIndex = Math.ceil(logical);
if (leftIndex === rightIndex) {
return points[leftIndex].time as Time;
}
return interpolateTime(points[leftIndex], points[rightIndex], logical);
}
function interpolateLogical(leftPoint: TimePoint, rightPoint: TimePoint, targetTime: number): number | null {
const timeRange = rightPoint.time - leftPoint.time;
if (timeRange === 0) {
return null;
}
const ratio = (targetTime - leftPoint.time) / timeRange;
return leftPoint.logical + (rightPoint.logical - leftPoint.logical) * ratio;
}
function interpolateTime(leftPoint: TimePoint, rightPoint: TimePoint, logical: number): Time | null {
const logicalRange = rightPoint.logical - leftPoint.logical;
if (logicalRange === 0) {
return null;
}
const ratio = (logical - leftPoint.logical) / logicalRange;
const time = leftPoint.time + (rightPoint.time - leftPoint.time) * ratio;
return Math.round(time) as Time;
}
function getNumericTime(time: Time): number | null {
if (typeof time !== 'number') {
return null;
}
return Number.isFinite(time) ? time : null;
}
function getNearestSeriesTimeFromXCoordinate(chart: IChartApi, series: SeriesApi, xCoordinate: number): Time | null {
const logical = chart.timeScale().coordinateToLogical(xCoordinate as Coordinate);
if (logical === null) {
return null;
}
const data = series.data() as readonly SeriesTimeItem[];
if (!data.length) {
return null;
}
const logicalIndex = Number(logical);
const lastIndex = data.length - 1;
if (logicalIndex < 0 || logicalIndex > lastIndex) {
return null;
}
const nearestIndex = Math.round(logicalIndex);
const item = data[nearestIndex];
return item?.time ?? null;
}
import { Observable, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPointerPoint as getPointerPointFromEvent,
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 { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
AutoscaleInfo,
IChartApi,
IPrimitivePaneView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
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;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface RectangleState {
hidden: boolean;
isActive: 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 implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private container: HTMLElement;
private removeSelf?: () => void;
private openSettings?: () => void;
private settings: RectangleSettings = createDefaultSettings();
private requestUpdate: (() => void) | null = null;
private isBound = false;
private subscriptions = new Subscription();
private hidden = false;
private isActive = false;
private 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, formatObservable, removeSelf, openSettings }: RectangleParams,
) {
this.chart = chart;
this.series = series;
this.container = container;
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 show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.render();
}
public destroy(): void {
this.unbindEvents();
this.subscriptions.unsubscribe();
this.series.detachPrimitive(this);
this.requestUpdate = null;
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.unbindEvents();
this.series.detachPrimitive(this);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this);
this.render();
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getState(): RectangleState {
return {
hidden: this.hidden,
isActive: this.isActive,
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 ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive = nextState.isActive;
}
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 getSettings(): SettingsValues {
return { ...this.settings };
}
public getSettingsTabs(): SettingsTab[] {
return getRectangleSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.unbindEvents();
this.requestUpdate = null;
}
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 autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public getRenderData(): RectangleRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showFill: true,
showHandles: this.isActive,
...this.settings,
};
}
public getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive || (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,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive || (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,
};
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isActive) {
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',
};
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
private unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private 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?.();
};
private 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.isActive) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive = false;
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
private 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();
};
private 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.isActive = true;
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.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.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.isActive = 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;
if (!snapshot || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const offsetY = point.y - this.dragStartPoint.y;
const nextStartTime = this.shiftTime(snapshot.startTime, offsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, offsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + offsetY);
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, this.series);
const endTime = getTimeFromXCoordinate(this.chart, bounds.right, this.series);
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);
}
private 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.min(Number(startX), Number(endX));
const right = Math.max(Number(startX), Number(endX));
const top = Math.min(Number(startY), Number(endY));
const bottom = 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);
}
private getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
private render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { Rectangle } from './rectangle';
const UI = {
borderWidth: 1,
handleSize: 10,
handleBorderWidth: 1,
textOffset: 4,
textLineHeightMultiplier: 1.2,
minVisibleSize: 6,
};
export class RectanglePaneRenderer implements IPrimitivePaneRenderer {
private readonly rectangle: Rectangle;
constructor(rectangle: Rectangle) {
this.rectangle = rectangle;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.rectangle.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const rawLeft = data.left * horizontalPixelRatio;
const rawRight = data.right * horizontalPixelRatio;
const rawTop = data.top * verticalPixelRatio;
const rawBottom = data.bottom * verticalPixelRatio;
const horizontalRange = getRangeWithMinSize(rawLeft, rawRight, UI.minVisibleSize * horizontalPixelRatio);
const verticalRange = getRangeWithMinSize(rawTop, rawBottom, UI.minVisibleSize * verticalPixelRatio);
const visibleLeft = clamp(horizontalRange.from, 0, bitmapSize.width);
const visibleRight = clamp(horizontalRange.to, 0, bitmapSize.width);
const visibleTop = clamp(verticalRange.from, 0, bitmapSize.height);
const visibleBottom = clamp(verticalRange.to, 0, bitmapSize.height);
if (visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
return;
}
context.save();
if (data.showFill) {
context.fillStyle = data.fillColor;
context.fillRect(visibleLeft, visibleTop, visibleRight - visibleLeft, visibleBottom - visibleTop);
}
context.lineWidth = UI.borderWidth * pixelRatio;
context.strokeStyle = data.borderColor;
context.strokeRect(visibleLeft, visibleTop, visibleRight - visibleLeft, visibleBottom - visibleTop);
drawRectangleText(context, {
left: visibleLeft,
top: visibleTop,
originalTop: rawTop,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
pixelRatio,
});
if (data.showHandles) {
for (const handle of Object.values(data.handles)) {
const x = handle.x * horizontalPixelRatio;
const y = handle.y * verticalPixelRatio;
if (!isPointVisible(x, y, bitmapSize.width, bitmapSize.height, UI.handleSize * pixelRatio)) {
continue;
}
drawHandle(
context,
x,
y,
horizontalPixelRatio,
verticalPixelRatio,
colors.chartLineColor,
colors.chartBackground,
);
}
}
context.restore();
});
}
}
function drawRectangleText(
context: CanvasRenderingContext2D,
params: {
left: number;
top: number;
originalTop: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
pixelRatio: number;
},
): void {
const { left, top, originalTop, text, fontSize, isBold, isItalic, textColor, pixelRatio } = params;
if (!text.trim()) {
return;
}
if (originalTop !== top) {
return;
}
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * pixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * pixelRatio;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
const textOffset = UI.textOffset * pixelRatio;
const textX = left;
const blockHeight = lines.length * lineHeight;
const firstLineY = top - textOffset - blockHeight + lineHeight / 2;
context.save();
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'left';
context.textBaseline = 'middle';
lines.forEach((line, index) => {
context.fillText(line, textX, firstLineY + index * lineHeight);
});
context.restore();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
strokeColor: string,
fillColor: string,
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
context.save();
context.fillStyle = fillColor;
context.strokeStyle = strokeColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
context.rect(left, top, width, height);
context.fill();
context.stroke();
context.restore();
}
function getRangeWithMinSize(from: number, to: number, minSize: number): { from: number; to: number } {
const left = Math.min(from, to);
const right = Math.max(from, to);
const size = right - left;
if (size >= minSize) {
return {
from: left,
to: right,
};
}
const center = (left + right) / 2;
const half = minSize / 2;
return {
from: center - half,
to: center + half,
};
}
function isPointVisible(x: number, y: number, width: number, height: number, tolerance: number): boolean {
return x >= -tolerance && x <= width + tolerance && y >= -tolerance && y <= height + tolerance;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}
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 {
BarData,
BarPrice,
BarsInfo,
Coordinate,
CreatePriceLineOptions,
CustomData,
DataChangedHandler,
DeepPartial,
HistogramData,
IChartApi,
IPaneApi,
IPriceFormatter,
IPriceLine,
IPriceScaleApi,
IRange,
ISeriesApi,
ISeriesPrimitive,
LineData,
MismatchDirection,
MouseEventParams,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesOptionsMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';
export interface SerieData {
time: Time;
customValues: Candle;
}
export interface CreateSeriesParams<TSeries extends SeriesType> {
chart: IChartApi;
seriesOptions?: SeriesPartialOptionsMap[TSeries];
paneIndex?: number;
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}
export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
getLwcSeries: () => ISeriesApi<TSeries>;
getLegendData: (
param?: MouseEventParams,
) => Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
}
export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
lwcChart: IChartApi;
dataSource: DataSource;
mainSymbol$: Observable<string>;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
seriesOptions?: SeriesPartialOptionsMap[TSeries];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
showSymbolLabel?: boolean;
paneIndex?: number;
indicatorReference?: Indicator;
}
function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
// todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
const offsetMinutes = -180; // utc.time - moscow.time in minutes
const secondsInMinute = 60;
return candles
.filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
.map((c) => ({
...c,
time: (c.time as number) - offsetMinutes * secondsInMinute,
}));
}
export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
protected lwcSeries: ISeriesApi<TSeries>;
protected customFormatter:
| undefined
| ((params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][]);
protected lwcChart: IChartApi;
protected mainSymbol$: Observable<string>;
protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
protected paneIndex: number | null = null;
protected indicatorReference: Indicator | null = null;
protected showSymbolLabel: boolean;
private subscriptions = new Subscription();
private dataSub: Subscription | null = null;
private realtimeSub: Subscription | null = null;
constructor({
lwcChart,
mainSymbol$,
mainSerie$,
customFormatter,
seriesOptions,
priceScaleOptions,
showSymbolLabel = true,
paneIndex,
indicatorReference,
}: BaseSeriesParams<TSeries>) {
this.lwcSeries = this.createSeries({
chart: lwcChart,
seriesOptions,
paneIndex,
priceScaleOptions,
});
this.lwcChart = lwcChart;
this.customFormatter = customFormatter;
this.mainSymbol$ = mainSymbol$;
this.mainSerie$ = mainSerie$;
this.showSymbolLabel = showSymbolLabel;
this.indicatorReference = indicatorReference ?? null;
}
public getLegendData = (
param?: MouseEventParams,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> => {
if (!param) {
const seriesData = this.data();
if (seriesData.length < 1) return {};
const dataToFormat = seriesData[seriesData.length - 1];
const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
return this.formatLegendValues(dataToFormat, prevBarData);
}
const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;
return this.formatLegendValues(dataToFormat, prevBarData);
};
public show(): void {
this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: true });
}
public hide(): void {
this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: false });
}
public isVisible(): boolean {
return this.lwcSeries.options().visible;
}
public destroy(): void {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.subscriptions.unsubscribe();
this.lwcChart.removeSeries(this.lwcSeries);
}
public getLwcSeries(): ISeriesApi<TSeries> {
return this.lwcSeries;
}
public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
this.lwcSeries.applyOptions(options);
}
public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.attachPrimitive(primitive);
}
public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
return this.lwcSeries.barsInLogicalRange(range);
}
public coordinateToPrice(coordinate: number): BarPrice | null {
return this.lwcSeries.coordinateToPrice(coordinate);
}
public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
return this.lwcSeries.createPriceLine(options);
}
public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
return this.lwcSeries.data();
}
public dataByIndex(
logicalIndex: number,
mismatchDirection?: MismatchDirection,
): SeriesDataItemTypeMap<Time>[TSeries] | null {
return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
}
public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.detachPrimitive(primitive);
}
public getPane(): IPaneApi<Time> {
return this.lwcSeries.getPane();
}
public moveToPane(paneIndex: number): void {
this.lwcSeries.moveToPane(paneIndex);
}
public options(): Readonly<SeriesOptionsMap[TSeries]> {
return this.lwcSeries.options();
}
public priceFormatter(): IPriceFormatter {
return this.lwcSeries.priceFormatter();
}
public priceLines(): IPriceLine[] {
return this.lwcSeries.priceLines();
}
public priceScale(): IPriceScaleApi {
return this.lwcSeries.priceScale();
}
public priceToCoordinate(price: number): Coordinate | null {
return this.lwcSeries.priceToCoordinate(price);
}
public removePriceLine(line: IPriceLine): void {
this.lwcSeries.removePriceLine(line);
}
public seriesOrder(): number {
return this.lwcSeries.seriesOrder();
}
public seriesType(): TSeries {
return this.lwcSeries.seriesType();
}
public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
const normalizedData = normalizeSeriesData(data);
this.lwcSeries.setData(normalizedData);
}
public setSeriesOrder(order: number): void {
this.lwcSeries.setSeriesOrder(order);
}
public subscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.subscribeDataChanged(handler);
}
public unsubscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.unsubscribeDataChanged(handler);
}
public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
const data = this.lwcSeries.data();
const last = data.length ? data[data.length - 1] : null;
if (!last) {
this.lwcSeries.update(bar, false);
return;
}
const lastTime = last.time;
const nextTime = bar.time;
const isHist =
historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);
this.lwcSeries.update(bar, isHist);
}
protected createSeries({
chart,
seriesOptions,
paneIndex = MAIN_PANE_INDEX,
priceScaleOptions = {},
}: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
this.paneIndex = paneIndex;
const defaultOptions = this.getDefaultOptions();
const mergedOptions = {
...defaultOptions,
...seriesOptions,
};
const series = chart.addSeries<TSeries>(this.seriesDefinition(), mergedOptions, paneIndex);
chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);
return series;
}
protected abstract dataSourceSubscription(next: Candle[]): void;
protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
protected abstract dataSourceRealtimeSubscription(next: Candle): void;
protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
protected abstract formatLegendValues(
currentBar: null | BarData | LineData | HistogramData | CustomData,
prevBar: null | BarData | LineData | HistogramData | CustomData,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
protected applyTimezone(data: Candle[]): Candle[] {
return applyMoscowTimezone(data);
}
protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
const formatter = this.customFormatter;
const data = this.applyTimezone(inputData);
if (formatter) {
const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
if (data.length === 1) {
return formatter({
mainSeriesData,
selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
candle: this.formatMainSerie(data)[0] as unknown as SerieData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
return formatter({
mainSeriesData,
indicatorReference: this.indicatorReference ?? undefined,
selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
});
}
return this.formatMainSerie(data);
}
protected subscribeDataSource = (dataSource: DataSource): void => {
const minMove = getPricePrecisionStep();
this.subscriptions.add(
this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
this.lwcSeries.applyOptions({
// todo: на каждый апдейт dataSource сеттим options. Не оптимально
title: this.showSymbolLabel ? symbol : '',
priceFormat: {
type: 'custom',
minMove,
formatter: (price: number) => formatPrice(price),
},
});
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.dataSub = dataSource.subscribe(symbol, (next) => {
this.dataSourceSubscription(next);
});
this.realtimeSub = dataSource.subscribeRealtime(symbol, (next: Candle) => {
this.dataSourceRealtimeSubscription(next);
});
}),
);
};
}
export function calcCandleChange(
prev: BarData | LineData | HistogramData | CustomData | null,
current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | null {
if (!current) {
return null;
}
if (!prev) {
return current;
}
if (isBarData(prev) && isBarData(current)) {
const absoluteChange = current.close - prev.close;
const percentageChange = ((current.close - prev.close) / prev.close) * 100;
return {
...current,
absoluteChange,
percentageChange,
};
}
if (isLineData(prev) && isLineData(current)) {
const absoluteChange = current.value - prev.value;
const percentageChange = ((current.value - prev.value) / prev.value) * 100;
return {
time: current.time,
value: current.value,
high: current.customValues?.high as number,
low: current.customValues?.low as number,
absoluteChange,
percentageChange,
customValues: current.customValues,
};
}
return null;
}
import dayjs from 'dayjs';
import {
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import {
Candle,
ChartOptionsModel,
ChartSeriesType,
ChartTypeOptions,
Direction,
OHLCConfig,
TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
export interface ChartConfig extends Partial<ChartOptionsModel> {
container: HTMLElement;
seriesTypes: ChartSeriesType[];
theme: ThemeKey;
mode?: ThemeMode;
chartOptions?: ChartTypeOptions;
localization?: LocalizationOptionsBase;
}
export enum Resize {
Shrink,
Expand,
}
const HISTORY_LOAD_THRESHOLD = 50;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
},
lwcChartConfig: ChartConfig
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class Chart implements ISerializable<ChartSnapshot>{
private lwcChart!: IChartApi;
private container: HTMLElement;
private eventManager: EventManager;
private paneManager!: PaneManager;
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
private indicatorManager: IndicatorManager;
private optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private DOM: DOMModel;
private isPointerDown = false;
private didResetOnDrag = false;
private subscriptions = new Subscription();
private currentInterval: Intervals | null = null;
private activeSymbols: string[] = [];
private historyBatchRunning = false;
constructor({
params,
lwcChartConfig
}: ChartParams) {
const {
eventManager,
dataSource,
modalRenderer,
ohlcConfig,
tooltipConfig,
panes: panesSnapshot,
} = params
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcChartConfig.container;
this.chartConfig = lwcChartConfig;
this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };
this.lwcChart.applyOptions({
...getOptions(configToApply),
localization: {
timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
},
});
});
this.subscriptions.add(this.optionsSubscription);
this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
this.DOM = new DOMModel({ modalRenderer });
this.paneManager = new PaneManager({
eventManager: this.eventManager,
panesSnapshot,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
modalRenderer,
});
this.indicatorManager = new IndicatorManager({
eventManager,
initialIndicators: flatten(panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id })) )),
DOM: this.DOM,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
initialIndicators: flatten(panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id })) )),
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
paneManager: this.paneManager,
});
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
public getPriceScaleWidth(direction: Direction): number {
try {
const priceScale = this.lwcChart.priceScale(direction);
return priceScale ? priceScale.width() : 0;
} catch {
return 0;
}
}
public getDrawingsManager = (): DrawingsManager => {
return this.paneManager.getDrawingsManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({ history: false });
};
private onPointerDown = () => {
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.isPointerDown) return;
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
this.eventManager.resetInterval({ history: false });
};
private onPointerUp = () => {
this.isPointerDown = false;
};
public getDom(): DOMModel {
return this.DOM;
}
public getMainSeries(): Observable<SeriesStrategies | null> {
return this.mainSeries.asObservable();
}
public getCompareManager(): CompareManager {
return this.compareManager;
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
}
public destroy(): void {
this.mouseEvents.destroy();
this.compareManager.clear();
this.subscriptions.unsubscribe();
this.lwcChart.remove();
}
public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
this.mouseEvents.subscribe(event, callback);
public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
this.mouseEvents.unsubscribe(event, callback);
};
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
this.eventManager.resetInterval({ history: false });
const diff = direction === Direction.Left ? -2 : 2;
const currentPosition = this.lwcChart.timeScale().scrollPosition();
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
this.eventManager.resetInterval({ history: false });
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
this.eventManager.resetInterval({ history: false });
this.lwcChart.timeScale().resetTimeScale();
this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbols,
update: (symbol: string, candle: Candle) => {
this.dataSource.updateRealtime(symbol, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
timeframe: this.eventManager.exportChartSettings().timeframe,
symbol: this.activeSymbols[0]
}
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbols = this.activeSymbols.slice();
Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs() {
const getWarmupFrom = (): number => {
if (this.currentInterval && this.currentInterval !== Intervals.All) {
return getIntervalRange(this.currentInterval).from;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) return 0;
const { from } = range as IRange<number>;
return from as number;
};
const warmupSymbols = (symbols: string[]) => {
if (!symbols.length) return;
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbols$))
.subscribe(([interval, symbols]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbols$.subscribe((symbols) => {
const prevSymbols = this.activeSymbols;
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
const prevSet = new Set(prevSymbols);
const added: string[] = [];
for (let i = 0; i < symbols.length; i += 1) {
const s = symbols[i];
if (!s) continue;
if (prevSet.has(s)) continue;
added.push(s);
}
if (added.length) {
warmupSymbols(added);
}
}),
);
this.subscriptions.add(
combineLatest([
this.eventManager.symbol(),
this.compareManager.itemsObs(),
this.mainSeries.asObservable(),
]).subscribe(([main, items, serie]) => {
if (!serie) return;
const title = items.length ? main : '';
serie.getLwcSeries().applyOptions({ title });
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
if (!logicalRange) return;
if (this.currentInterval === Intervals.All) return;
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
if (!needsMoreData) return;
this.scheduleHistoryBatch();
});
}
}
function getIntervalRange(interval: Intervals): { from: number; to: number } {
const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;
const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
const to = Math.floor(dayjs().valueOf() / 1000);
return { from, to };
}
function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
const timeFormat = config.timeFormat ?? Defaults.timeFormat;
const showTime = config.showTime ?? Defaults.showTime;
const use12HourFormat = timeFormat === '12h';
const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';
const { colors } = getThemeStore();
return {
width: config.container.clientWidth,
height: config.container.clientHeight,
autoSize: true,
layout: {
background: { color: colors.chartBackground },
textColor: colors.chartTextPrimary,
},
grid: {
vertLines: { color: colors.chartGridLine },
horzLines: { color: colors.chartGridLine },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
},
timeScale: {
timeVisible: showTime,
secondsVisible: false,
tickMarkFormatter: createTickMarkFormatter(timeFormatString),
borderVisible: false,
allowBoldLabels: false,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
};
}