Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
isCreationPending(): boolean;
waitTillReady(): Promise<void>;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
updateSettings(settings: SettingsValues): void;
getSettingsTabs(): SettingsTab[];
subscribeIsSelected(cb: (isSelected: boolean) => void): void;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown;
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isActive: BehaviorSubject<boolean> = new BehaviorSubject(false);
protected readyPromise: null | Promise<void> = null;
protected resolveReady: null | (() => void) = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
}
public subscribeIsSelected(cb: (isSelected: boolean) => void) {
return this.isActive.pipe(distinctUntilChanged()).subscribe(cb);
}
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.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 as () => void;
});
}
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.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
public abstract getRenderData(): any; // todo: make proper type // todo: добавить в интерфейс\точно ли паблик
public abstract hitTest(x: number, y: number): PrimitiveHoveredItem | null; // todo: добавить в интерфейс\точно ли паблик
public abstract getTimeAxisSegments(): AxisSegment[]; // todo: добавить в интерфейс\точно ли паблик
public abstract getPriceAxisSegments(): AxisSegment[]; // todo: добавить в интерфейс\точно ли паблик
public abstract getTimeAxisLabel(kind: string): AxisLabel | null; // todo: добавить в интерфейс\точно ли паблик
public abstract getPriceAxisLabel(kind: string): AxisLabel | null; // todo: добавить в интерфейс\точно ли паблик
protected abstract bindEvents(): void;
public abstract updateAllViews(): void;
protected abstract unbindEvents(): void;
protected abstract getGeometry(): any; // todo: make proper type // todo: добавить в интерфейс\точно ли паблик
public abstract getSettingsTabs(): SettingsTab[];
public abstract getState(): unknown;
public abstract isCreationPending(): boolean;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract setState(state: unknown): void;
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
}
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): 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 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 (!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 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;
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { ISeriesDrawing } from '@core/Drawings/common';
import { DrawingSnapshotItem } from '@core/DrawingsManager';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues } from '@src/types/settings';
type IDrawing = DOMObject;
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>) => ISeriesDrawing;
hotkeys: Hotkeys;
setCopyPasteBuffer: (copiedObject: DrawingSnapshotItem) => void;
resetActiveTool: () => void;
}
export class Drawing extends DOMObject implements IDrawing {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private escapeUnregisterHash: string | null = null;
private deleteUnregisterHash: string | null = null;
private copyUnregisterHash: string | null = null;
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
zIndex,
moveUp,
moveDown,
construct,
paneId,
hotkeys,
setCopyPasteBuffer,
resetActiveTool,
}: DrawingParams) {
super({ id, name, zIndex, onDelete, moveUp, moveDown, paneId });
this.hotkeys = hotkeys;
this.lwcDrawing = construct(lwcChart, mainSeries);
this.onDelete = onDelete;
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.lwcDrawing.subscribeIsSelected((isSelected) => {
if (isSelected) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.control, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
const copiedObject = {
id: this.id,
drawingName: this.getDrawingName(),
state: this.getState(),
};
setCopyPasteBuffer(copiedObject);
}
},
});
} else {
hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
}
});
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.afterCreation(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
});
}
public delete() {
this.destroy();
super.delete();
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getLwcDrawing() {
return this.lwcDrawing;
}
public show() {
this.lwcDrawing.show();
super.show();
}
public hide() {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies) => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public async waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
public shouldShowInObjectTree(): boolean {
return this.lwcDrawing.shouldShowInObjectTree();
}
public getState(): unknown {
return this.lwcDrawing.getState();
}
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy() {
this.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.control, Keys.c],
hash: this.copyUnregisterHash,
});
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private async afterCreation(cb: () => void) {
await this.lwcDrawing.waitTillReady();
cb();
}
}
import { BehaviorSubject } from 'rxjs';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';
enum DOMObjectType {
Drawing = 'Drawing',
Indicator = 'Indicator',
}
export interface IDOMObject {
id: string;
hidden: BehaviorSubject<boolean>;
zIndex: number;
type: DOMObjectType;
name: string;
delete(): void;
hide(): void;
show(): void;
lastUpdated(): void;
moveUp(): void;
moveDown(): void;
setZIndex(next: number): void;
shouldShowInObjectTree(): boolean;
}
export interface DOMObjectParams {
id: string;
paneId: number; // todo: implement for drawings
zIndex: number;
onDelete: (id: string) => void;
moveUp: (id: string) => void;
moveDown: (id: string) => void;
name?: string;
}
export class DOMObject implements IDOMObject, ISerializable<DOMObjectSnapshot> {
public readonly id: string;
public name: string;
public zIndex: number;
public hidden = new BehaviorSubject(false);
public moveUp: () => void;
public moveDown: () => void;
public paneId: number;
protected onDelete: (id: string) => void;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: DOMObjectType;
constructor({ id, name, zIndex, onDelete, moveUp, moveDown, paneId }: DOMObjectParams) {
this.id = id;
this.name = name ?? id;
this.zIndex = zIndex;
this.paneId = paneId;
this.onDelete = onDelete;
this.moveUp = () => moveUp(this.id);
this.moveDown = () => moveDown(this.id);
}
delete(): void {
this.onDelete(this.id);
}
hide(): void {
this.hidden.next(true);
}
show(): void {
this.hidden.next(false);
}
lastUpdated(): void {}
setZIndex(next: number): void {
this.zIndex = next;
}
shouldShowInObjectTree(): boolean {
return true;
}
public getSnapshot(): DOMObjectSnapshot {
return {
id: this.id,
name: this.name,
zIndex: this.zIndex,
hidden: this.hidden.value,
paneId: this.paneId,
};
}
}
import { BehaviorSubject, map, Observable } from 'rxjs';
import { DOM } from '@components/DOM';
import { IDOMObject } from '@core/DOMObject';
import { ModalRenderer } from '@core/ModalRenderer';
import { t } from '@src/translations';
interface DOMModelParams {
modalRenderer: ModalRenderer;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class DOMModel {
// ∈ symbol&pane
private modalRenderer: ModalRenderer;
private lastZIndex = 0;
private entities: BehaviorSubject<IDOMObject[]> = new BehaviorSubject<IDOMObject[]>([]); // drawings/indicators/series
// private panes: Panes[]; // todo: пока что на каждый пейн будет один objectTree
constructor({ modalRenderer }: DOMModelParams) {
this.modalRenderer = modalRenderer;
}
public removeEntity = <T extends IDOMObject>(entity: T): void => {
this.entities.next(this.entities.value.filter((d) => d.id !== entity.id));
};
public setEntity = <T extends IDOMObject>(
cb: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T => {
const entity = cb(this.lastZIndex++, this.moveUp, this.moveDown);
this.entities.next([...this.entities.value, entity].sort((a, b) => a.zIndex - b.zIndex));
return entity;
};
private moveUp = (id: string): void => {
const entities = this.entities.value;
const curr = entities.find((e) => e.id === id);
if (!curr) {
return;
}
const next = entities.find((e) => e.zIndex === curr.zIndex + 1);
const ent = entities.filter((e) => e.id !== id && e.zIndex !== curr.zIndex + 1);
if (!next) {
return;
}
const [left, right] = [curr.zIndex, next.zIndex];
curr.setZIndex(right);
next.setZIndex(left);
this.entities.next([...ent, curr, next].sort((a, b) => a.zIndex - b.zIndex));
};
private moveDown = (id: string): void => {
const entities = this.entities.value;
const curr = entities.find((e) => e.id === id);
if (!curr) {
return;
}
const prev = entities.find((e) => e.zIndex === curr.zIndex - 1);
const ent = entities.filter((e) => e.id !== id && e.zIndex !== curr.zIndex - 1);
if (!prev) {
return;
}
const [left, right] = [curr.zIndex, prev.zIndex];
curr.setZIndex(right);
prev.setZIndex(left);
this.entities.next([...ent, curr, prev].sort((a, b) => a.zIndex - b.zIndex));
};
public getEntities = (): Observable<IDOMObject[]> => {
return this.entities.pipe(map((entities) => entities.filter((entity) => entity.shouldShowInObjectTree())));
};
public refreshEntities = (): void => {
this.entities.next([...this.entities.value]);
};
public toggleDOM = () => {
this.modalRenderer.renderComponent(<DOM elementsObs={this.getEntities()} />, {
title: t('DOM tree'),
onSave: () => console.warn('dom state saved'),
acceptLabel: '',
rejectLabel: '',
});
};
public destroy(): void {
// todo implement
}
}
import { IChartApi, IRange, LogicalRange, MouseEventParams, Time } from 'lightweight-charts';
export interface ChartMouseEventsParams {
lwcChart: IChartApi;
container: HTMLElement;
}
export interface ChartEventsMap {
crosshairMove: MouseEventParams;
visibleTimeRangeChange: IRange<Time> | null;
visibleLogicalRangeChange: LogicalRange | null;
wheel: WheelEvent;
pointerDown: PointerEvent;
pointerMove: PointerEvent;
pointerUp: PointerEvent;
pointerCancel: PointerEvent;
}
export type ChartEvent = keyof ChartEventsMap;
export class ChartMouseEvents {
private eventCallbacks = new Map<ChartEvent, ((data: any) => void)[]>();
private lwcChart: IChartApi;
private container: HTMLElement;
constructor({ lwcChart, container }: ChartMouseEventsParams) {
this.lwcChart = lwcChart;
this.container = container;
this.setupDomEvents();
this.setupInternalEvents();
}
public unsubscribe<K extends ChartEvent>(event: K, callback: (data: ChartEventsMap[K]) => void): void {
const callbacks = this.eventCallbacks.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
public subscribe<K extends ChartEvent>(event: K, callback: (data: ChartEventsMap[K]) => void): () => void {
if (!this.eventCallbacks.has(event)) {
this.eventCallbacks.set(event, []);
}
this.eventCallbacks.get(event)!.push(callback);
// Возвращаем функцию отписки
return () => this.unsubscribe(event, callback);
}
public destroy() {
this.teardownDomEvents();
this.eventCallbacks.clear();
}
private setupInternalEvents(): void {
// Подписываемся на события и сохраняем функции отписки
this.lwcChart.subscribeCrosshairMove((param) => {
this.emitChartEvent('crosshairMove', param);
});
this.lwcChart.timeScale().subscribeVisibleTimeRangeChange((range) => {
this.emitChartEvent('visibleTimeRangeChange', range);
});
this.lwcChart.timeScale().subscribeVisibleLogicalRangeChange((logicalRange) => {
this.emitChartEvent('visibleLogicalRangeChange', logicalRange);
});
}
private onWheel = (e: WheelEvent) => this.emitChartEvent('wheel', e);
private onPointerDown = (e: PointerEvent) => this.emitChartEvent('pointerDown', e);
private onPointerMove = (e: PointerEvent) => this.emitChartEvent('pointerMove', e);
private onPointerUp = (e: PointerEvent) => this.emitChartEvent('pointerUp', e);
private onPointerCancel = (e: PointerEvent) => this.emitChartEvent('pointerCancel', e);
private setupDomEvents(): void {
this.container.addEventListener('wheel', this.onWheel, { passive: true });
this.container.addEventListener('pointerdown', this.onPointerDown, { passive: true });
this.container.addEventListener('pointermove', this.onPointerMove, { passive: true });
window.addEventListener('pointerup', this.onPointerUp, { passive: true });
window.addEventListener('pointercancel', this.onPointerCancel, { passive: true });
}
private teardownDomEvents(): void {
this.container.removeEventListener('wheel', this.onWheel);
this.container.removeEventListener('pointerdown', this.onPointerDown);
this.container.removeEventListener('pointermove', this.onPointerMove);
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointercancel', this.onPointerCancel);
}
private emitChartEvent<K extends ChartEvent>(event: K, data: ChartEventsMap[K]): void {
const callbacks = this.eventCallbacks.get(event);
if (callbacks) {
callbacks.forEach((callback) => callback(data));
}
}
}
import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { Direction } from '@src/types';
import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
import type { Indicator } from '@core/Indicator';
import type { LogicalRange } from 'lightweight-charts';
import type { Observable } from 'rxjs';
interface PaneManagerParams
extends Omit<
PaneParams,
| 'id'
| 'isMainPane'
| 'basedOn'
| 'onDelete'
| 'initialPriceScales'
| 'onPriceScaleStateChange'
| 'leftPriceScaleVisible'
| 'rightPriceScaleVisible'
> {
panesSnapshot: PaneSnapshot[];
}
interface PriceAxisLabelsSources {
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly sharedPaneParams: SharedPaneParams;
private readonly panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
private leftPriceScaleVisible = false;
private rightPriceScaleVisible = true;
constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
this.sharedPaneParams = sharedPaneParams;
const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
this.mainPane = new Pane({
...this.sharedPaneParams,
id: mainPaneId,
isMainPane: true,
onDelete: () => {},
initialPriceScales: mainPaneSnapshot?.priceScales,
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
});
this.panesMap.set(mainPaneId, this.mainPane);
if (mainPaneSnapshot) {
this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
}
const greatestPaneId = panesSnapshot.reduce(
(greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
mainPaneId,
);
this.nextPaneId = greatestPaneId + 1;
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
});
this.syncPaneContainers();
}
public start({ compareEntities$, indicatorEntities$ }: PriceAxisLabelsSources): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$: this.mainPane.getMainSerie().asObservable(),
mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
}
public invalidate(): void {
this.priceAxisLabels?.invalidate();
this.refreshPriceScaleControls();
}
public setPriceScaleSideVisible(side: PriceScaleSide, visible: boolean): void {
if (side === Direction.Left) {
this.leftPriceScaleVisible = visible;
} else {
this.rightPriceScaleVisible = visible;
}
this.panesMap.forEach((pane) => {
pane.getPriceScale(side).setVisible(visible);
});
}
public getPaneById(id: number): Pane | undefined {
return this.panesMap.get(id);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes(): Map<number, Pane> {
return this.panesMap;
}
public getMainPane = (): Pane => {
return this.mainPane;
};
public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(this.nextPaneId, id + 1);
const pane = new Pane({
...this.sharedPaneParams,
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
public getSnapshot(): PaneSnapshot[] {
const snapshot: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
snapshot.push(pane.getSnapshot());
});
return snapshot;
}
public destroy(): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = null;
this.panesMap.forEach((pane) => {
pane.destroy();
});
this.panesMap.clear();
}
private refreshPriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.refreshPriceScaleControls();
});
}
private destroyPane(id: number): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
const paneIndex = pane.paneIndex();
this.panesMap.delete(id);
pane.destroy();
if (paneIndex >= 0) {
this.sharedPaneParams.lwcChart.removePane(paneIndex);
}
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
private handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { Hotkeys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { IndicatorsIds } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Locale, setLocale, t } from '@src/translations';
import { Candle, ChartSeriesType, ChartTypeOptions, OHLCConfig, SymbolInfoInput, TooltipConfig } from '@src/types';
import { ISerializable, MoexChartSnapshot, MoexChartSnapshotInput } from '@src/types/snapshot';
import { Timeframes } from '@src/types/timeframes';
import { setPricePrecision } from '@src/utils';
import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';
import '../styles/global.scss';
// todo: forbid @lib in /src
export interface ChartCollectionPreset {
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
showSymbolSearchButton?: boolean;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @example
* ```typescript
* tooltipConfig: {
* time: { visible: true, label: 'Дата и время' },
* symbol: { visible: true, label: 'Инструмент' },
* close: { visible: true, label: 'Курс' },
* change: { visible: true, label: 'Изменение' },
* volume: { visible: true, label: 'Объем' },
* open: { visible: false },
* high: { visible: false },
* low: { visible: false }
* }
*```
*/
tooltipConfig?: TooltipConfig;
size?:
| {
width: number;
height: number;
}
| false;
supportedTimeframes: Timeframes[];
supportedChartSeriesTypes: ChartSeriesType[];
getDataSource: DataSourceParams['getData'];
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => Timeframes,
update: (symbolId: string, candle: Candle) => void,
periodMs?: number,
) => () => void;
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
locale: Locale;
mode?: ThemeMode; // 'light' | 'dark'
openCompareModal?: () => void;
openSymbolSearchModal?: () => void;
}
export interface IMoexChart {
snapshot: MoexChartSnapshotInput;
chartCollectionPreset: ChartCollectionPreset;
container: HTMLElement;
lwcInheritedChartOptions?: ChartTypeOptions;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private hotkeys!: Hotkeys;
private rootContainer!: HTMLElement;
private headerRenderer!: UIRenderer;
private modalRenderer!: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private timeScaleHoverController!: TimeScaleHoverController;
private dataSource!: DataSource;
private subscriptions = new Subscription();
private fullscreen!: FullscreenController;
private chartCollectionPresetSettings!: ChartCollectionPreset;
constructor(config: IMoexChart) {
setLocale(config.chartCollectionPreset.locale);
this.setup(config);
}
private setup = (config: IMoexChart) => {
this.chartCollectionPresetSettings = config.chartCollectionPreset;
setPricePrecision(config.chartCollectionPreset.ohlc.precision);
const { chartSeriesType, symbolId, symbol, symbolName, timeframe, interval, dateFormat, timeFormat } =
config.snapshot.charts[0];
this.eventManager = new EventManager({
initialTimeframe: timeframe,
initialSeries: chartSeriesType,
initialSymbolInfo: {
symbolId,
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});
// todo: сюда прокидывается не подходящий под сигнатуру интерфейс. Функция не работает
// if (config.lwcInheritedChartOptions) {
// this.setSettings(config.lwcInheritedChartOptions);
// }
this.dataSource = new DataSource({
getData: config.chartCollectionPreset.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config.chartCollectionPreset);
const {
chartAreaContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarModel
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.chartCollectionPreset.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.chartCollectionPreset.showMenuButton,
});
this.hotkeys = new Hotkeys();
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
params: {
dataSource: this.dataSource,
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
panes: config.snapshot.charts[0].panes,
hotkeys: this.hotkeys,
},
lwcChartConfig: {
container: chartAreaContainer,
seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
theme: store.theme,
mode: store.mode,
chartOptions: config.lwcInheritedChartOptions, // todo: remove, use only model from eventManager
},
});
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
const realtimeParams = this.chart.getRealtimeApi();
this.subscriptions.add(
config.chartCollectionPreset.startRealtime(
realtimeParams.getSymbols,
realtimeParams.getTimeframe,
realtimeParams.update,
),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
if (config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
public setSettings(settings: ChartSettingsSource): void {
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
// todo: описать подробнее в доке. Точно ли public?
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать подробнее в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
public setSnapshot(snapshot: MoexChartSnapshotInput) {
const configConstructorLike: IMoexChart = {
snapshot,
chartCollectionPreset: this.chartCollectionPresetSettings,
container: this.rootContainer,
};
this.destroy();
this.setup(configConstructorLike);
}
// todo: описать в доке
public getSnapshot(): MoexChartSnapshot {
const res = {
settings: this.getSettings(),
charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
};
return res;
}
public setSymbol(symbolInfo: SymbolInfoInput): void {
this.eventManager.setSymbol(symbolInfo);
}
private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
this.headerRenderer.renderComponent(
<Header
timeframes={config.chartCollectionPreset.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.chartCollectionPreset.showSettingsButton
? () =>
this.modalRenderer.renderComponent(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: t('Settings') },
)
: undefined
}
addIndicatorToChart={(indicatorType: IndicatorsIds) =>
this.chart.getIndicatorManager().addIndicator({ indicatorType })
}
showMenuButton={!!config.chartCollectionPreset.showMenuButton}
showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={config.chartCollectionPreset.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.chartCollectionPreset.showCompareButton}
openCompareModal={
config.chartCollectionPreset.openCompareModal ? config.chartCollectionPreset.openCompareModal : undefined
}
showSymbolSearchButton={!!config.chartCollectionPreset.openSymbolSearchModal}
openSymbolSearchModal={config.chartCollectionPreset.openSymbolSearchModal}
isMXT={config.chartCollectionPreset.theme === 'mxt'}
/>,
);
if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={this.chart.getDrawingsManager().addDrawingForce} // todo: deal with new panes logic
setEndlessDrawingsMode={this.chart.getDrawingsManager().setEndlessDrawingMode}
isEndlessDrawingsMode$={this.chart.getDrawingsManager().isEndlessDrawingsMode()}
activateCrosshair={() => this.chart.getDrawingsManager().activateCrosshair()}
activeTool$={this.chart.getDrawingsManager().getActiveTool()}
hotkeys={this.hotkeys}
/>,
);
}
if (this.controlBarRenderer && config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer.renderComponent(
<ControlBar
scroll={this.chart.scrollTimeScale}
zoom={this.chart.zoomTimeScale}
reset={this.chart.resetZoom}
visible={this.eventManager.getControlBarVisible()}
/>,
);
}
if (this.footerRenderer && config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.headerRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
if (this.eventManager) {
this.eventManager.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
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 { Hotkeys } from '@core/Hotkeys';
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 { getLocale } from '@src/translations';
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 { formatCompactNumber } from '@src/utils';
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[];
hotkeys: Hotkeys;
};
lwcChartConfig: ChartConfig;
}
function splitIndicatorSnapshots(panes: PaneSnapshot[]) {
const snapshots = panes.flatMap(({ id, indicators }) =>
indicators.map((indicator) => ({
...indicator,
paneId: id,
})),
);
return {
indicatorSnapshots: snapshots.filter(({ indicatorType }) => indicatorType !== undefined),
compareSnapshots: snapshots.filter(({ indicatorType }) => indicatorType === undefined),
};
}
/**
* Абстракция над библиотекой для построения графиков
*/
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: ChartConfig;
private mainSeries: BehaviorSubject<SeriesStrategies | 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 activeSymbolIds: 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 }) => {
this.chartConfig = {
...this.chartConfig,
dateFormat,
timeFormat,
showTime,
};
this.lwcChart.applyOptions({
...getOptions(this.chartConfig),
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,
hotkeys: params.hotkeys,
});
this.mainSeries = this.paneManager.getMainPane().getMainSerie();
const { indicatorSnapshots, compareSnapshots } = splitIndicatorSnapshots(panesSnapshot);
this.indicatorManager = new IndicatorManager({
eventManager,
initialIndicators: indicatorSnapshots,
DOM: this.DOM,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
initialIndicators: compareSnapshots,
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
paneManager: this.paneManager,
});
this.paneManager.start({
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
this.paneManager.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
this.paneManager.invalidate();
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.chartConfig = {
...this.chartConfig,
theme,
mode,
};
this.lwcChart.applyOptions(getOptions(this.chartConfig));
this.paneManager.invalidate();
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.mouseEvents.destroy();
this.compareManager.destroy();
this.paneManager.destroy();
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.paneManager.resetPriceScalesAutoScale();
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbolIds,
update: (symbolId: string, candle: Candle) => {
this.dataSource.updateRealtime(symbolId, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
const { seriesSelected, timeframe, dateFormat, timeFormat, interval, symbolInfo } =
this.eventManager.exportChartSettings();
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: seriesSelected,
timeframe,
dateFormat,
timeFormat,
interval,
...symbolInfo,
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbolIds = this.activeSymbolIds.slice();
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadMoreHistory(symbolId))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs(): void {
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;
};
const warmupSymbolIds = (symbolIds: string[]): void => {
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbolIds$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
map(([mainSymbolId, items]) => Array.from(new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbolIds$))
.subscribe(([interval, symbolIds]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({
from: from as Time,
to: to as Time,
});
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbolIds$.subscribe((symbolIds) => {
const previousSymbolIds = new Set(this.activeSymbolIds);
this.activeSymbolIds = symbolIds;
this.dataSource.setSymbols(symbolIds);
const addedSymbolIds = symbolIds.filter((symbolId) => !previousSymbolIds.has(symbolId));
if (addedSymbolIds.length) {
warmupSymbolIds(addedSymbolIds);
}
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.paneManager.setVisibleLogicalRange(logicalRange);
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();
const localization: LocalizationOptionsBase = {
locale: getLocale(),
priceFormatter: (priceValue: BarPrice) => {
return formatCompactNumber(priceValue);
},
};
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,
rightOffset: 25,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
localization,
};
}