Загрузка данных
import dayjs from 'dayjs';
import {
BarPrice,
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 { PriceLabelsManager } from '@src/core/PriceLabels/PriceLabelsManager';
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[];
};
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 priceLabelsManager: PriceLabelsManager;
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.priceLabelsManager = new PriceLabelsManager({
mainSeries$: this.mainSeries.asObservable(),
mainSymbol$: this.eventManager.symbol(),
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
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.priceLabelsManager.destroy();
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) => {
this.priceLabelsManager.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,
};
}
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
import { MAIN_PANE_INDEX } from '@src/constants';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { getPaletteColorFromIndex, normalizeColor, normalizeSymbol } from '@src/utils';
interface CompareEntry {
key: string;
symbol: string;
mode: CompareMode;
paneIndex: number;
symbol$: BehaviorSubject<string>;
entity: Indicator;
}
function makeKey(symbol: string, mode: CompareMode): string {
return `${symbol}|${mode}`;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
initialIndicators?: IndicatorSnapshot[];
}
const getDefaultCompareIndicatorConfig = (symbol: string, usedColors: string[]): IndicatorConfig => {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
newPane: true,
label: symbol,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
};
export class CompareManager {
private readonly chart: IChartApi;
private readonly eventManager: EventManager;
private readonly dataSource: DataSource;
private readonly indicatorManager: IndicatorManager;
private readonly paneManager: PaneManager;
private readonly entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
constructor({
chart,
eventManager,
dataSource,
indicatorManager,
paneManager,
initialIndicators = [],
}: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
});
if (!initialIndicators) {
return;
}
this.setup(initialIndicators);
}
private async setup(initialIndicators: IndicatorSnapshot[]) {
for (const indicator of initialIndicators) {
if (indicator.config && indicator.config.label) {
// условие проверки compare ли это
const serie = indicator.config.series[0];
const compareMode =
serie.seriesOptions?.priceScaleId === Direction.Left
? CompareMode.NewScale
: indicator.config.newPane
? CompareMode.NewPane
: CompareMode.Percentage;
// eslint-disable-next-line no-await-in-loop
await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
}
}
}
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
public entities(): Observable<Indicator[]> {
return this.entitiesSubject.asObservable();
}
public clear(): void {
const keys = Array.from(this.entries.keys());
for (let i = 0; i < keys.length; i += 1) {
this.removeByKey(keys[i]);
}
this.applyPolicy();
this.publish();
}
public async setSymbolMode(
seriesType: SeriesType,
symbolRaw: string,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
if (mode === CompareMode.NewScale && this.isNewScaleDisabled()) {
return;
}
const key = makeKey(symbol, mode);
if (this.entries.has(key)) return;
const symbol$ = new BehaviorSubject(symbol);
const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
const usedColorsByCompare = this.entitiesSubject.value.map(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
);
const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ind.config?.series?.map((serie) => serie.seriesOptions?.color),
);
const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
const config = getDefaultCompareIndicatorConfig(symbol, usedColors);
const associatedPane =
mode === CompareMode.NewPane
? paneId !== undefined
? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
: this.paneManager.addPane()
: this.paneManager.getMainPane();
return new Indicator({
id: key,
lwcChart: this.chart,
mainSymbol$: symbol$,
dataSource: this.dataSource,
associatedPane,
config: {
...config,
series: [
{
...config.series[0],
seriesOptions: {
...config.series[0]?.seriesOptions,
priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
},
},
],
newPane: mode === CompareMode.NewPane,
},
zIndex,
onDelete: () => this.removeByKey(key),
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), symbol$, entity });
this.applyPolicy();
this.publish();
await this.dataSource.isReady(symbol);
}
public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
this.removeByKey(makeKey(symbol, mode));
this.applyPolicy();
this.publish();
}
public removeSymbol(symbolRaw: string): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
const all = Array.from(this.entries.entries());
for (let i = 0; i < all.length; i += 1) {
const [key, entry] = all[i];
if (entry.symbol === symbol) this.removeByKey(key);
}
this.applyPolicy();
this.publish();
}
public isNewScaleDisabled(): boolean {
return this.itemsSubject.value.length > 0;
}
public isNewScaleDisabledObservable(): Observable<boolean> {
return this.itemsSubject.pipe(
map((items) => items.length > 0),
distinctUntilChanged(),
);
}
public getAllEntities() {
return Array.from(this.entries.values()).map(({ symbol, entity, mode }) => ({
symbol,
entity,
mode,
}));
}
public destroy(): void {
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
private removeByKey(key: string): void {
const entry = this.entries.get(key);
if (!entry) return;
this.entries.delete(key);
this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
entry.symbol$.complete();
this.applyPolicy();
this.publish();
}
private publish(): void {
const values = Array.from(this.entries.values());
const items: CompareItem[] = [];
const entities: Indicator[] = [];
for (let i = 0; i < values.length; i += 1) {
items.push({ symbol: values[i].symbol, mode: values[i].mode });
entities.push(values[i].entity);
}
this.itemsSubject.next(items);
this.entitiesSubject.next(entities);
}
private applyPolicy(): void {
const list = Array.from(this.entries.values());
let percentEnabled = false;
for (let i = 0; i < list.length; i += 1) {
if (list[i].mode === CompareMode.Percentage) {
percentEnabled = true;
break;
}
}
let hasMainNewScale = false;
for (let i = 0; i < list.length; i += 1) {
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
if (paneIndex === MAIN_PANE_INDEX && list[i].mode === CompareMode.NewScale) {
hasMainNewScale = true;
break;
}
}
const paneSet = new Set<number>();
for (let i = 0; i < list.length; i += 1) {
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
}
this.chart.applyOptions({
leftPriceScale: { visible: hasMainNewScale, borderVisible: false },
});
this.chart.priceScale(Direction.Right, MAIN_PANE_INDEX).applyOptions({
visible: true,
mode: percentEnabled ? PriceScaleMode.Percentage : PriceScaleMode.Normal,
});
this.chart.priceScale(Direction.Left, MAIN_PANE_INDEX).applyOptions({
visible: hasMainNewScale,
mode: PriceScaleMode.Normal,
borderVisible: false,
});
const panes = Array.from(paneSet.values());
for (let i = 0; i < panes.length; i += 1) {
const paneIndex = panes[i];
this.chart.priceScale(Direction.Right, paneIndex).applyOptions({
visible: true,
mode: PriceScaleMode.Normal,
});
if (hasMainNewScale) {
this.chart.priceScale(Direction.Left, paneIndex).applyOptions({
visible: true,
mode: PriceScaleMode.Normal,
borderVisible: false,
});
}
}
for (let i = 0; i < list.length; i += 1) {
const entry = list[i];
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const serie = Array.from(entry.entity.getSeriesMap())[0][1];
const paneIndex = serie.getPane().paneIndex();
if (paneIndex !== MAIN_PANE_INDEX) {
serie.applyOptions({ priceScaleId: Direction.Right });
continue;
}
serie.applyOptions({
priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
}
}
import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { indicatorsMap as indicatorsConfigMap } from '@src/core/Indicators';
import { ChartTypeOptions, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { applyNextIndicatorColors, getIndicatorColors } from '@src/utils';
interface SeriesParams {
eventManager: EventManager;
dataSource: DataSource;
lwcChart: IChartApi;
paneManager: PaneManager;
DOM: DOMModel;
initialIndicators?: IndicatorSnapshot[];
chartOptions?: ChartTypeOptions;
}
export class IndicatorManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private chartOptions?: ChartTypeOptions;
private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
private indicatorsMap$: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject(new Map()); // todo: заменить IndicatorsIds ключ на уникальный id индикатора
private DOM: DOMModel;
private dataSource: DataSource;
private paneManager: PaneManager;
constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, initialIndicators, paneManager }: SeriesParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
this.DOM = DOM;
this.dataSource = dataSource;
this.paneManager = paneManager;
this.indicatorsMap$ = new BehaviorSubject<Map<string, Indicator>>(new Map());
initialIndicators?.forEach((ind) => {
this.addIndicator(ind);
});
}
public addEntity<T extends Indicator>(
factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T {
return this.DOM.setEntity(factory);
}
public addIndicator(snap: Partial<IndicatorSnapshot>): void {
if (!snap.indicatorType) {
console.error('[IndicatorManager] Не был получен тип индиктора');
return;
}
const indicatorsMap = new Map(this.indicatorsMap$.value);
const id = snap.id ?? `${snap.indicatorType}-${crypto.randomUUID()}`;
const baseConfig = snap.config ?? (indicatorsConfigMap()[snap.indicatorType] as IndicatorConfig);
const config = snap.config
? baseConfig
: applyNextIndicatorColors(baseConfig, this.getUsedIndicatorColorsByType(snap.indicatorType));
const associatedPane =
snap.paneId !== undefined
? (this.paneManager.getPaneById(snap.paneId) ?? this.paneManager.addPane())
: config?.newPane
? this.paneManager.addPane()
: this.paneManager.getMainPane();
const indicatorToSet = this.addEntity<Indicator>(
(zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => {
return new Indicator({
id,
paneId: associatedPane.getId(),
zIndex,
onDelete: this.deleteIndicator,
moveUp,
moveDown,
mainSymbol$: this.eventManager.getSymbol(),
lwcChart: this.lwcChart,
dataSource: this.dataSource,
associatedPane,
config,
type: snap.indicatorType,
chartOptions: this.chartOptions,
});
},
);
indicatorsMap.set(id, indicatorToSet);
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
}
private getUsedIndicatorColorsByType(indicatorType: IndicatorSnapshot['indicatorType']): string[] {
const colors: string[] = [];
this.indicatorsMap$.value.forEach((indicator) => {
if (indicator.getIndicatorType() !== indicatorType) {
return;
}
colors.push(...getIndicatorColors(indicator.getConfig()));
});
return colors;
}
public getIndicators() {
return this.indicatorsMap$;
}
public removeEntity(entity: DOMObject): void {
this.DOM.removeEntity(entity);
}
public deleteIndicator = (id: string) => {
const indicatorsMap = new Map(this.indicatorsMap$.value);
const entity = indicatorsMap.get(id);
if (!entity) {
return;
}
this.removeEntity(entity);
entity.destroy();
indicatorsMap.delete(id);
this.indicatorsMap$.next(indicatorsMap);
this.entities$.next(Array.from(indicatorsMap.values()));
};
public entities(): Observable<Indicator[]> {
return this.entities$.asObservable();
}
}
import { IChartApi } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Pane } from '@core/Pane';
import { indicatorLabelById, indicatorSeriesLabelById, IndicatorsIds } from '@src/constants';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions, IndicatorConfig, SettingsValues } from '@src/types';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';
type IIndicator = DOMObject;
export interface IndicatorParams extends DOMObjectParams {
mainSymbol$: Observable<string>;
lwcChart: IChartApi;
dataSource: DataSource;
associatedPane: Pane;
config: IndicatorConfig;
type?: IndicatorsIds;
chartOptions?: ChartTypeOptions;
}
export class Indicator extends DOMObject implements ISerializable<IndicatorSnapshot> {
private indicatorType?: IndicatorsIds;
private series: SeriesStrategies[] = [];
private seriesMap: Map<string, SeriesStrategies> = new Map();
private lwcChart: IChartApi;
private dataSource: DataSource;
private mainSymbol$: Observable<string>;
private associatedPane: Pane;
private config: IndicatorConfig;
private settings: SettingsValues = {};
private dataChangeHandlers = new Set<() => void>();
private seriesSubscriptions = new Subscription();
constructor({
id,
type,
lwcChart,
dataSource,
zIndex,
onDelete,
moveUp,
moveDown,
mainSymbol$,
associatedPane,
paneId,
config,
}: IndicatorParams) {
super({ id, name: config?.label ?? id, zIndex, onDelete, moveUp, moveDown, paneId });
this.lwcChart = lwcChart;
this.dataSource = dataSource;
this.mainSymbol$ = mainSymbol$;
this.indicatorType = type;
this.config = config;
this.name = this.getLabel();
this.settings = this.getDefaultSettings();
this.associatedPane = associatedPane;
this.createSeries();
this.associatedPane.setIndicator(this.id, this);
}
public subscribeDataChange(handler: () => void): Subscription {
this.dataChangeHandlers.add(handler);
return new Subscription(() => {
this.dataChangeHandlers.delete(handler);
});
}
public deletable = (): boolean => {
return this.associatedPane.isMainPane() || this.associatedPane.isLast();
};
public getLabel = () => {
if (this.config.label) {
return this.config.label;
}
if (this.indicatorType) {
return indicatorLabelById()[this.indicatorType];
}
return this.id;
};
public getSeriesLabel(serieName: string): string | undefined {
if (this.config.seriesLabels?.[serieName]) {
return this.config.seriesLabels[serieName];
}
if (this.indicatorType) {
return indicatorSeriesLabelById[this.indicatorType]?.[serieName];
}
return undefined;
}
public getId(): string {
return this.id;
}
public getType(): IndicatorsIds | undefined {
return this.indicatorType;
}
public getPane(): Pane {
return this.associatedPane;
}
public getSeriesMap(): Map<string, SeriesStrategies> {
return this.seriesMap;
}
public getConfig(): IndicatorConfig {
return this.config;
}
public getIndicatorType(): IndicatorSnapshot['indicatorType'] {
return this.indicatorType;
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public getSettingsConfig() {
return this.config.settings ?? [];
}
public hasSettings(): boolean {
return Boolean(this.config.settings?.length);
}
public updateSettings(settings: SettingsValues): void {
this.settings = settings;
// todo: обновлять данные серий без удаления и повторного создания
this.destroySeries();
this.createSeries();
this.notifyDataChanged();
}
public show() {
this.series.forEach((s) => {
s.show();
});
super.show();
}
public hide() {
this.series.forEach((s) => {
s.hide();
});
super.hide();
}
public override getSnapshot(): DOMObjectSnapshot & IndicatorSnapshot {
const domSnap = super.getSnapshot();
return {
...domSnap,
dataSource: this.dataSource,
indicatorType: this.indicatorType,
config: this.config,
};
}
public setSnapshot(snap: IndicatorSnapshot): void {}
// destroy и delete принципиально отличаются!
// delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
public delete() {
super.delete();
}
// destroy и delete принципиально отличаются!
public destroy() {
this.destroySeries();
this.dataChangeHandlers.clear();
this.associatedPane.removeIndicator(this.id);
}
private createSeries(): void {
this.config.series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
const serie = SeriesFactory.create(name!)({
lwcChart: this.lwcChart,
dataSource: this.dataSource,
customFormatter: dataFormatter
? (params) =>
dataFormatter({
...params,
settings: this.settings,
indicatorReference: this,
})
: undefined,
seriesOptions,
priceScaleOptions,
mainSymbol$: this.mainSymbol$,
mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
paneIndex: this.associatedPane.getId(),
indicatorReference: this,
});
const handleDataChanged = () => {
this.notifyDataChanged();
};
serie.subscribeDataChanged(handleDataChanged);
this.seriesSubscriptions.add(() => {
serie.unsubscribeDataChanged(handleDataChanged);
});
this.seriesMap.set(serieId, serie);
this.series.push(serie);
});
}
private destroySeries(): void {
this.seriesSubscriptions.unsubscribe();
this.seriesSubscriptions = new Subscription();
this.series.forEach((s) => {
s.destroy();
});
this.series = [];
this.seriesMap.clear();
}
private notifyDataChanged(): void {
this.dataChangeHandlers.forEach((handler) => {
handler();
});
}
private getDefaultSettings(): SettingsValues {
const settings: SettingsValues = {};
this.config.settings?.forEach((field) => {
settings[field.key] = field.defaultValue;
});
return settings;
}
}
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 {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
export type PriceAxisLabelStyle = 'filled' | 'outlined';
export interface PriceAxisLabel {
id: string;
coordinate: number | null;
value: string;
color: string;
style: PriceAxisLabelStyle;
symbol?: string;
priority?: number;
}
interface ResolvedPriceAxisLabel extends PriceAxisLabel {
coordinate: number;
}
const LABEL_HEIGHT = 20;
const LABEL_GAP = 2;
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;
function getContrastTextColor(color: string): string {
const normalizedColor = color.replace('#', '').slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return '#FFFFFF';
}
const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;
return luminance > 0.6 ? '#000000' : '#FFFFFF';
}
function fitText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (maxWidth <= 0) {
return '';
}
if (context.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = '…';
let result = text;
while (result.length > 0 && context.measureText(`${result}${ellipsis}`).width > maxWidth) {
result = result.slice(0, -1);
}
return result.length > 0 ? `${result}${ellipsis}` : '';
}
function resolveLabelPositions(labels: PriceAxisLabel[], height: number): ResolvedPriceAxisLabel[] {
const halfLabelHeight = LABEL_HEIGHT / 2;
const minCoordinate = halfLabelHeight;
const maxCoordinate = Math.max(halfLabelHeight, height - halfLabelHeight);
const resolvedLabels = labels
.filter(
(
label,
): label is PriceAxisLabel & {
coordinate: number;
} => label.coordinate !== null && Number.isFinite(label.coordinate),
)
.sort((left, right) => {
if (left.coordinate === right.coordinate) {
return (right.priority ?? 0) - (left.priority ?? 0);
}
return left.coordinate - right.coordinate;
})
.map((label) => ({
...label,
coordinate: Math.min(Math.max(label.coordinate, minCoordinate), maxCoordinate),
}));
for (let index = 1; index < resolvedLabels.length; index += 1) {
const previousLabel = resolvedLabels[index - 1];
const currentLabel = resolvedLabels[index];
const minCurrentCoordinate = previousLabel.coordinate + LABEL_HEIGHT + LABEL_GAP;
if (currentLabel.coordinate < minCurrentCoordinate) {
currentLabel.coordinate = minCurrentCoordinate;
}
}
const lastLabel = resolvedLabels[resolvedLabels.length - 1];
if (lastLabel && lastLabel.coordinate > maxCoordinate) {
const offset = lastLabel.coordinate - maxCoordinate;
resolvedLabels.forEach((label) => {
label.coordinate -= offset;
});
}
for (let index = resolvedLabels.length - 2; index >= 0; index -= 1) {
const currentLabel = resolvedLabels[index];
const nextLabel = resolvedLabels[index + 1];
const maxCurrentCoordinate = nextLabel.coordinate - LABEL_HEIGHT - LABEL_GAP;
if (currentLabel.coordinate > maxCurrentCoordinate) {
currentLabel.coordinate = maxCurrentCoordinate;
}
}
const firstLabel = resolvedLabels[0];
if (firstLabel && firstLabel.coordinate < minCoordinate) {
const offset = minCoordinate - firstLabel.coordinate;
resolvedLabels.forEach((label) => {
label.coordinate += offset;
});
}
return resolvedLabels;
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {}
public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
const labels = this.getLabels();
if (labels.length === 0) {
return;
}
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const { width, height } = bitmapSize;
if (width <= 0 || height <= 0) {
return;
}
const resolvedLabels = resolveLabelPositions([...labels], height / verticalPixelRatio);
if (resolvedLabels.length === 0) {
return;
}
const { colors } = getThemeStore();
context.save();
context.font = `${FONT_WEIGHT} ${Math.round(FONT_SIZE * verticalPixelRatio)}px Inter, sans-serif`;
context.textBaseline = 'middle';
resolvedLabels.forEach((label) => {
const coordinate = Math.round(label.coordinate * verticalPixelRatio);
const labelHeight = Math.round(LABEL_HEIGHT * verticalPixelRatio);
const top = Math.round(coordinate - labelHeight / 2);
const horizontalPadding = Math.round(HORIZONTAL_PADDING * horizontalPixelRatio);
const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
const textColor = label.style === 'outlined' ? label.color : getContrastTextColor(label.color);
context.fillStyle = backgroundColor;
context.fillRect(0, top, width, labelHeight);
if (label.style === 'outlined') {
const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
borderWidth / 2,
top + borderWidth / 2,
Math.max(0, width - borderWidth),
Math.max(0, labelHeight - borderWidth),
);
}
context.fillStyle = textColor;
if (!label.symbol) {
const maxValueWidth = width - horizontalPadding * 2;
const value = fitText(context, label.value, maxValueWidth);
context.textAlign = 'center';
context.fillText(value, width / 2, coordinate);
return;
}
const availableWidth = width - horizontalPadding * 2;
const measuredSymbolWidth = context.measureText(label.symbol).width + horizontalPadding * 2;
const symbolWidth = Math.min(
Math.max(measuredSymbolWidth, availableWidth * SYMBOL_MIN_WIDTH_RATIO),
availableWidth * SYMBOL_MAX_WIDTH_RATIO,
);
const separatorX = Math.round(horizontalPadding + symbolWidth);
const separatorWidth = Math.max(1, Math.round(horizontalPixelRatio));
const symbolMaxWidth = symbolWidth - horizontalPadding * 2;
const valueAreaWidth = width - separatorX;
const valueMaxWidth = valueAreaWidth - horizontalPadding * 2;
const symbol = fitText(context, label.symbol, symbolMaxWidth);
const value = fitText(context, label.value, valueMaxWidth);
context.textAlign = 'center';
context.fillText(symbol, horizontalPadding + symbolWidth / 2, coordinate);
context.globalAlpha = 0.5;
context.fillRect(
separatorX,
top + Math.round(3 * verticalPixelRatio),
separatorWidth,
labelHeight - Math.round(6 * verticalPixelRatio),
);
context.globalAlpha = 1;
context.fillText(value, separatorX + valueAreaWidth / 2, coordinate);
});
context.restore();
});
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {
this.rendererInstance = new PriceAxisLabelsRenderer(getLabels);
}
public renderer(): IPrimitivePaneRenderer | null {
return this.getLabels().length > 0 ? this.rendererInstance : null;
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
private readonly priceAxisPaneView: PriceAxisLabelsPaneView;
private readonly priceAxisPaneViewList: readonly IPrimitivePaneView[];
private labels: readonly PriceAxisLabel[] = [];
private requestUpdate: (() => void) | null = null;
constructor() {
this.priceAxisPaneView = new PriceAxisLabelsPaneView(() => this.labels);
this.priceAxisPaneViewList = [this.priceAxisPaneView];
}
public attached({ requestUpdate }: SeriesAttachedParameter<Time>): void {
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewList;
}
public updateAllViews(): void {}
public setLabels(labels: readonly PriceAxisLabel[]): void {
this.labels = labels;
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.requestUpdate?.();
}
}
import { IPriceLine, LogicalRange, MismatchDirection, PriceScaleMode } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { Indicator } from '@core/Indicator';
import { PriceAxisLabel, PriceAxisLabelsPrimitive } from '@core/PriceLabels/PriceAxisLabelsPrimitive';
import { IndicatorsIds, MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { Direction } from '@src/types';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
type PriceLabelRole = 'main' | 'compare' | 'volume';
type PriceScaleSide = Direction.Left | Direction.Right;
interface PriceLabelsManagerParams {
mainSeries$: Observable<SeriesStrategies | null>;
mainSymbol$: Observable<string>;
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
interface SeriesRegistrationInput {
id: string;
role: PriceLabelRole;
series: SeriesStrategies;
symbol?: string;
}
interface RegisteredSeries extends SeriesRegistrationInput {
defaultLastValueVisible: boolean;
defaultPriceLineVisible: boolean;
handleDataChanged: () => void;
}
interface AxisLabelsGroup {
paneIndex: number;
side: PriceScaleSide;
labels: PriceAxisLabel[];
}
interface AxisLayer {
host: SeriesStrategies;
primitive: PriceAxisLabelsPrimitive;
}
function getDataPrice(data: unknown): number | null {
if (!data || typeof data !== 'object') {
return null;
}
if ('close' in data && typeof data.close === 'number') {
return data.close;
}
if ('value' in data && typeof data.value === 'number') {
return data.value;
}
return null;
}
function getSeriesColor(series: SeriesStrategies, data: unknown): string {
if (data && typeof data === 'object' && 'color' in data && typeof data.color === 'string') {
return removeAlphaFromHex(data.color);
}
const options = series.options() as unknown as Record<string, unknown>;
if (
data &&
typeof data === 'object' &&
'open' in data &&
'close' in data &&
typeof data.open === 'number' &&
typeof data.close === 'number'
) {
const candleColor = data.close >= data.open ? options.upColor : options.downColor;
if (typeof candleColor === 'string') {
return removeAlphaFromHex(candleColor);
}
}
if (typeof options.color === 'string') {
return removeAlphaFromHex(options.color);
}
return removeAlphaFromHex(getThemeStore().colors.chartLineColor);
}
function getContrastTextColor(color: string): string {
const normalizedColor = removeAlphaFromHex(color).replace('#', '').slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return '#FFFFFF';
}
const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;
return luminance > 0.6 ? '#000000' : '#FFFFFF';
}
function getShortSymbol(symbol: string): string {
const symbolParts = symbol.split(':');
return symbolParts[symbolParts.length - 1] || symbol;
}
function getScaleSide(registration: RegisteredSeries): PriceScaleSide {
if (registration.role === 'volume') {
return Direction.Right;
}
return registration.series.options().priceScaleId === Direction.Left ? Direction.Left : Direction.Right;
}
function getLayerKey(paneIndex: number, side: PriceScaleSide): string {
return `${paneIndex}:${side}`;
}
export class PriceLabelsManager {
private readonly registrations = new Map<string, RegisteredSeries>();
private readonly layers = new Map<string, AxisLayer>();
private readonly subscriptions = new Subscription();
private visibleLogicalRange: LogicalRange | null = null;
private currentPriceLine: IPriceLine | null = null;
private currentPriceLineHost: SeriesStrategies | null = null;
private mainSymbol = '';
private isHistoryMode = false;
private updateFrame: number | null = null;
constructor({ mainSeries$, mainSymbol$, compareEntities$, indicatorEntities$ }: PriceLabelsManagerParams) {
this.subscriptions.add(
mainSymbol$.subscribe((symbol) => {
this.mainSymbol = getShortSymbol(symbol);
this.scheduleUpdate();
}),
);
this.subscriptions.add(
mainSeries$.subscribe((series) => {
this.syncMainSeries(series);
}),
);
this.subscriptions.add(
compareEntities$.subscribe((entities) => {
this.syncCompareSeries(entities);
}),
);
this.subscriptions.add(
indicatorEntities$.subscribe((entities) => {
this.syncVolumeSeries(entities);
}),
);
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.visibleLogicalRange = logicalRange;
const nextHistoryMode = this.getHistoryMode(logicalRange);
if (this.isHistoryMode !== nextHistoryMode) {
this.isHistoryMode = nextHistoryMode;
this.applyHistoryMode();
}
this.scheduleUpdate();
}
public invalidate(): void {
this.scheduleUpdate();
}
public destroy(): void {
if (this.updateFrame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.updateFrame);
this.updateFrame = null;
}
this.subscriptions.unsubscribe();
Array.from(this.registrations.keys()).forEach((registrationId) => {
this.unregisterSeries(registrationId);
});
this.layers.forEach(({ host, primitive }) => {
try {
host.detachPrimitive(primitive);
} catch {
// Серия могла быть удалена раньше менеджера.
}
});
this.layers.clear();
this.removeCurrentPriceLine();
}
private syncMainSeries(series: SeriesStrategies | null): void {
const registrations: SeriesRegistrationInput[] = series
? [
{
id: 'main',
role: 'main',
series,
},
]
: [];
this.syncRegistrations('main', registrations);
this.recalculateHistoryMode();
}
private syncCompareSeries(entities: Indicator[]): void {
const registrations = entities.flatMap((entity) =>
Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
id: `compare:${entity.getId()}:${seriesId}`,
role: 'compare' as const,
series,
symbol: getShortSymbol(entity.getLabel()),
})),
);
this.syncRegistrations('compare', registrations);
}
private syncVolumeSeries(entities: Indicator[]): void {
const registrations = entities
.filter((entity) => entity.getType() === IndicatorsIds.Volume)
.flatMap((entity) =>
Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
id: `volume:${entity.getId()}:${seriesId}`,
role: 'volume' as const,
series,
})),
);
this.syncRegistrations('volume', registrations);
}
private syncRegistrations(role: PriceLabelRole, nextRegistrations: SeriesRegistrationInput[]): void {
const nextRegistrationIds = new Set(nextRegistrations.map(({ id }) => id));
this.registrations.forEach((registration, registrationId) => {
if (registration.role === role && !nextRegistrationIds.has(registrationId)) {
this.unregisterSeries(registrationId);
}
});
nextRegistrations.forEach((nextRegistration) => {
const currentRegistration = this.registrations.get(nextRegistration.id);
if (currentRegistration && currentRegistration.series === nextRegistration.series) {
currentRegistration.symbol = nextRegistration.symbol;
return;
}
if (currentRegistration) {
this.unregisterSeries(nextRegistration.id);
}
this.registerSeries(nextRegistration);
});
this.scheduleUpdate();
}
private registerSeries(registration: SeriesRegistrationInput): void {
const handleDataChanged = () => {
this.scheduleUpdate();
};
registration.series.subscribeDataChanged(handleDataChanged);
const registeredSeries: RegisteredSeries = {
...registration,
defaultLastValueVisible: registration.series.options().lastValueVisible,
defaultPriceLineVisible: registration.series.options().priceLineVisible,
handleDataChanged,
};
this.registrations.set(registration.id, registeredSeries);
if (registration.role === 'volume') {
registration.series.applyOptions({
lastValueVisible: false,
priceLineVisible: false,
});
return;
}
if (this.isHistoryMode) {
registration.series.applyOptions({
lastValueVisible: false,
});
}
}
private unregisterSeries(registrationId: string): void {
const registration = this.registrations.get(registrationId);
if (!registration) {
return;
}
registration.series.unsubscribeDataChanged(registration.handleDataChanged);
try {
registration.series.applyOptions({
lastValueVisible: registration.defaultLastValueVisible,
priceLineVisible: registration.defaultPriceLineVisible,
});
} catch {
// Серия могла быть удалена раньше регистрации.
}
if (registration.role === 'main' && this.currentPriceLineHost === registration.series) {
this.removeCurrentPriceLine();
}
this.registrations.delete(registrationId);
}
private getHistoryMode(logicalRange: LogicalRange | null): boolean {
if (!logicalRange) {
return false;
}
const mainRegistration = this.registrations.get('main');
if (!mainRegistration) {
return false;
}
const barsInfo = mainRegistration.series.barsInLogicalRange(logicalRange);
return (barsInfo?.barsAfter ?? 0) > 0;
}
private recalculateHistoryMode(): void {
const nextHistoryMode = this.getHistoryMode(this.visibleLogicalRange);
if (this.isHistoryMode === nextHistoryMode) {
this.scheduleUpdate();
return;
}
this.isHistoryMode = nextHistoryMode;
this.applyHistoryMode();
this.scheduleUpdate();
}
private applyHistoryMode(): void {
this.registrations.forEach((registration) => {
if (registration.role === 'volume') {
return;
}
registration.series.applyOptions({
lastValueVisible: this.isHistoryMode ? false : registration.defaultLastValueVisible,
});
});
if (!this.isHistoryMode) {
this.hideCurrentPriceLine();
}
}
private scheduleUpdate(): void {
if (this.updateFrame !== null) {
return;
}
if (typeof requestAnimationFrame !== 'function') {
this.update();
return;
}
this.updateFrame = requestAnimationFrame(() => {
this.updateFrame = null;
this.update();
});
}
private update(): void {
this.updateCurrentPriceLine();
const labelGroups = this.collectAxisLabels();
this.updateLayers(labelGroups);
}
private collectAxisLabels(): Map<string, AxisLabelsGroup> {
const labelGroups = new Map<string, AxisLabelsGroup>();
this.registrations.forEach((registration) => {
if (!registration.series.isVisible()) {
return;
}
if (registration.role === 'volume') {
const volumeLabel = this.createVolumeLabel(registration);
if (volumeLabel) {
this.addLabelToGroup(labelGroups, registration, volumeLabel, Direction.Right);
}
return;
}
if (!this.isHistoryMode) {
return;
}
const historicalLabel = this.createHistoricalLabel(registration);
if (!historicalLabel) {
return;
}
this.addLabelToGroup(labelGroups, registration, historicalLabel, getScaleSide(registration));
});
return labelGroups;
}
private createHistoricalLabel(registration: RegisteredSeries): PriceAxisLabel | null {
const logicalRange = this.visibleLogicalRange;
if (!logicalRange) {
return null;
}
const data = registration.series.dataByIndex(Math.floor(logicalRange.to), MismatchDirection.NearestLeft);
const price = getDataPrice(data);
if (price === null) {
return null;
}
const coordinate = registration.series.priceToCoordinate(price);
if (coordinate === null) {
return null;
}
return {
id: `${registration.id}:history`,
coordinate,
value: this.formatValue(registration.series, price, logicalRange),
symbol: registration.role === 'main' ? this.mainSymbol : registration.symbol,
color: getSeriesColor(registration.series, data),
style: 'outlined',
priority: registration.role === 'main' ? 100 : 50,
};
}
private createVolumeLabel(registration: RegisteredSeries): PriceAxisLabel | null {
const data = this.visibleLogicalRange
? registration.series.dataByIndex(Math.floor(this.visibleLogicalRange.to), MismatchDirection.NearestLeft)
: registration.series.data()[registration.series.data().length - 1];
const value = getDataPrice(data);
if (value === null) {
return null;
}
const coordinate = registration.series.priceToCoordinate(value);
if (coordinate === null) {
return null;
}
return {
id: `${registration.id}:volume`,
coordinate,
value: registration.series.priceFormatter().format(value),
color: getSeriesColor(registration.series, data),
style: 'filled',
priority: 10,
};
}
private addLabelToGroup(
labelGroups: Map<string, AxisLabelsGroup>,
registration: RegisteredSeries,
label: PriceAxisLabel,
side: PriceScaleSide,
): void {
const paneIndex = registration.series.getPane().paneIndex();
const layerKey = getLayerKey(paneIndex, side);
const currentGroup = labelGroups.get(layerKey);
if (currentGroup) {
currentGroup.labels.push(label);
return;
}
labelGroups.set(layerKey, {
paneIndex,
side,
labels: [label],
});
}
private updateLayers(labelGroups: Map<string, AxisLabelsGroup>): void {
const activeLayerKeys = new Set<string>();
labelGroups.forEach((labelGroup, layerKey) => {
const layerHost = this.getLayerHost(labelGroup.paneIndex, labelGroup.side);
if (!layerHost) {
return;
}
activeLayerKeys.add(layerKey);
const layer = this.getOrCreateLayer(layerKey, layerHost);
layer.primitive.setLabels(labelGroup.labels);
});
this.layers.forEach((layer, layerKey) => {
if (activeLayerKeys.has(layerKey)) {
return;
}
try {
layer.host.detachPrimitive(layer.primitive);
} catch {
// Серия могла быть удалена раньше слоя.
}
this.layers.delete(layerKey);
});
}
private getLayerHost(paneIndex: number, side: PriceScaleSide): SeriesStrategies | null {
const mainRegistration = this.registrations.get('main');
if (paneIndex === MAIN_PANE_INDEX && side === Direction.Right && mainRegistration) {
return mainRegistration.series;
}
const registration = Array.from(this.registrations.values()).find(
(currentRegistration) =>
currentRegistration.role !== 'volume' &&
currentRegistration.series.getPane().paneIndex() === paneIndex &&
getScaleSide(currentRegistration) === side,
);
return registration?.series ?? null;
}
private getOrCreateLayer(layerKey: string, layerHost: SeriesStrategies): AxisLayer {
const currentLayer = this.layers.get(layerKey);
if (currentLayer?.host === layerHost) {
return currentLayer;
}
if (currentLayer) {
try {
currentLayer.host.detachPrimitive(currentLayer.primitive);
} catch {
// Серия могла быть удалена раньше слоя.
}
}
const primitive = new PriceAxisLabelsPrimitive();
layerHost.attachPrimitive(primitive);
const nextLayer: AxisLayer = {
host: layerHost,
primitive,
};
this.layers.set(layerKey, nextLayer);
return nextLayer;
}
private updateCurrentPriceLine(): void {
const mainRegistration = this.registrations.get('main');
if (!this.isHistoryMode || !mainRegistration || !mainRegistration.series.isVisible()) {
this.hideCurrentPriceLine();
return;
}
const seriesData = mainRegistration.series.data();
const data = seriesData[seriesData.length - 1];
const price = getDataPrice(data);
if (price === null) {
this.hideCurrentPriceLine();
return;
}
const color = getSeriesColor(mainRegistration.series, data);
const priceLine = this.getCurrentPriceLine(mainRegistration.series, color);
priceLine.applyOptions({
price,
color,
lineVisible: false,
axisLabelVisible: true,
axisLabelColor: color,
axisLabelTextColor: getContrastTextColor(color),
title: this.mainSymbol,
});
}
private getCurrentPriceLine(series: SeriesStrategies, color: string): IPriceLine {
if (this.currentPriceLine && this.currentPriceLineHost === series) {
return this.currentPriceLine;
}
this.removeCurrentPriceLine();
this.currentPriceLine = series.createPriceLine({
price: 0,
color,
lineVisible: false,
axisLabelVisible: false,
title: '',
});
this.currentPriceLineHost = series;
return this.currentPriceLine;
}
private hideCurrentPriceLine(): void {
this.currentPriceLine?.applyOptions({
lineVisible: false,
axisLabelVisible: false,
title: '',
});
}
private removeCurrentPriceLine(): void {
if (this.currentPriceLine && this.currentPriceLineHost) {
try {
this.currentPriceLineHost.removePriceLine(this.currentPriceLine);
} catch {
// Серия могла быть удалена раньше price line.
}
}
this.currentPriceLine = null;
this.currentPriceLineHost = null;
}
private formatValue(series: SeriesStrategies, price: number, logicalRange: LogicalRange): string {
const priceScaleMode = series.priceScale().options().mode;
if (priceScaleMode !== PriceScaleMode.Percentage && priceScaleMode !== PriceScaleMode.IndexedTo100) {
return series.priceFormatter().format(price);
}
const firstVisibleData = series.dataByIndex(Math.ceil(logicalRange.from), MismatchDirection.NearestRight);
const firstVisiblePrice = getDataPrice(firstVisibleData);
if (firstVisiblePrice === null || firstVisiblePrice === 0) {
return series.priceFormatter().format(price);
}
if (priceScaleMode === PriceScaleMode.Percentage) {
const percentage = ((price - firstVisiblePrice) / firstVisiblePrice) * 100;
return `${percentage.toFixed(2)}%`;
}
return ((price / firstVisiblePrice) * 100).toFixed(2);
}
}
{
"name": "moex-chart",
"version": "0.1.0",
"description": "",
"type": "module",
"main": "dist/index.cjs",
"types": "types/index.d.ts",
"files": [
"dist",
"types"
],
"sideEffects": [
"**/*css",
"**/*.scss"
],
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./types/index.d.ts"
},
"./package.json": "./package.json",
"./dist/styles.css": {
"import": "./dist/styles.css",
"require": "./dist/styles.css"
}
},
"scripts": {
"postbuild:storybook": "sed -i 's#<head>#<head>\\n<base href='/storybook/'>#' ./storybook-static/index.html ./storybook-static/iframe.html",
"clean:dist": "powershell -NoProfile -Command \"if (Test-Path dist) { Remove-Item -Recurse -Force dist }\"",
"clean:types": "powershell -NoProfile -Command \"if (Test-Path types) { Remove-Item -Recurse -Force types }\"",
"build:lib:js": "webpack --config webpack.lib.config.cjs",
"build:lib:types": "tsc --project tsconfig.types.json && tsc-alias -p tsconfig.types.json",
"build:lib": "npm run clean:dist && npm run clean:types && npm run build:lib:js && npm run build:lib:types",
"test": "jest",
"coverage": "jest --coverage",
"lint:fix": "npx prettier src --write && npx eslint src --fix",
"lint": "npx eslint src",
"prettier-check": "npx prettier src --check",
"prettier-fix": "npx prettier src --write",
"prepare": "husky",
"storybook": "storybook dev -p 6006",
"storybook-local": "cross-env buildType=local storybook dev -p 6006",
"start": "npm run storybook-local",
"build-storybook": "storybook build",
"build-storybook-local": "cross-env buildType=local storybook build",
"typecheck": "npx tsc -p tsconfig.json --noEmit",
"typecheck:watch": "npx tsc -p tsconfig.json --noEmit -w"
},
"keywords": [],
"author": "MOEX",
"license": "UNLICENSED",
"devDependencies": {
"@babel/core": "^7.21.0",
"@babel/preset-env": "^7.28.3",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@storybook/addon-actions": "^7.6.13",
"@storybook/addon-controls": "^7.6.13",
"@storybook/addon-docs": "^7.6.13",
"@storybook/addon-essentials": "^7.6.13",
"@storybook/addon-interactions": "^7.6.13",
"@storybook/addon-links": "^7.6.13",
"@storybook/builder-webpack5": "^7.6.13",
"@storybook/components": "^7.6.13",
"@storybook/manager-webpack5": "^6.5.16",
"@storybook/preset-scss": "^1.0.3",
"@storybook/react": "^7.6.13",
"@storybook/react-webpack5": "^7.6.13",
"@storybook/testing-library": "^0.0.13",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@types/jest": "^27.5.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.0.1",
"@types/markdown-it-link-attributes": "^3.0.5",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.10",
"@types/react-window": "^1.8.5",
"@types/sanitize-html": "^2.11.0",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.21",
"babel-loader": "^8.3.0",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^3.4.1",
"dotenv-webpack": "^8.0.1",
"eslint": "^8.34.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.6.0",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^25.7.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"husky": "^9.1.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.5.0",
"json5": "^2.2.3",
"mini-css-extract-plugin": "^2.7.2",
"postcss": "^8.5.6",
"postcss-loader": "^4.3.0",
"prettier": "^3.3.3",
"react": "^18.2.0",
"react-docgen-typescript-plugin": "^0.5.1",
"react-dom": "^18.2.0",
"react-refresh": "^0.14.0",
"react-refresh-typescript": "^2.0.8",
"sass": "^1.58.1",
"sass-loader": "^13.2.0",
"storybook": "^7.6.13",
"storybook-addon-sass-postcss": "^0.1.3",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-jest": "^29.1.5",
"ts-loader": "^9.4.2",
"ts-node": "10.9.1",
"tsc-alias": "^1.8.16",
"type-fest": "^3.6.1",
"typescript": "^5.5.3",
"typescript-plugin-css-modules": "^4.1.1",
"webpack": "^5.75.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.11.1"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@stomp/stompjs": "^7.1.1",
"classnames": "^2.3.2",
"dayjs": "^1.11.7",
"dotenv": "^16.4.7",
"exchange-elements": "^0.0.257",
"fancy-canvas": "2.1.0",
"lightweight-charts": "^5.0.8",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.2",
"uuid": "^11.0.3"
},
"peerDependencies": {
"react": "18.2.0",
"react-dom": "18.2.0"
},
"overrides": {
"@storybook/mdx2-csf": "1.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,css,scss,md,html}": [
"npm run lint:fix"
]
}
}
import { CandlestickSeriesStrategy, LineSeriesStrategy } from '@core';
import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { BarSeriesStrategy } from '@src/core/Series/BarSeriesStrategy';
import { HistogramSeriesStrategy } from '@src/core/Series/HistogramSeriesStrategy';
import { ISeries } from '@src/modules/series-strategies/ISeries';
import { ChartSeriesType } from '@src/types';
export type SeriesStrategies =
| CandlestickSeriesStrategy
| LineSeriesStrategy
| HistogramSeriesStrategy
| BarSeriesStrategy
| ISeries<'Baseline'>
| ISeries<'Area'>
| ISeries<'Custom'>;
/**
x * Фабрика для создания стратегий серий
* Реализует паттерн Factory для создания нужной стратегии по типу графика
*/
export class SeriesFactory {
static create(
type: ChartSeriesType,
):
| ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
| ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
| ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
| ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy) {
if (type === 'Candlestick') {
return ((params) => new CandlestickSeriesStrategy(params)) as (
params: BaseSeriesParams<'Candlestick'>,
) => CandlestickSeriesStrategy;
}
if (type === 'Histogram') {
return ((params) => new HistogramSeriesStrategy(params)) as (
params: BaseSeriesParams<'Histogram'>,
) => HistogramSeriesStrategy;
}
if (type === 'Line') {
return ((params) => new LineSeriesStrategy(params)) as (params: BaseSeriesParams<'Line'>) => LineSeriesStrategy;
}
if (type === 'Bar') {
return ((params) => new BarSeriesStrategy(params)) as (params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy;
}
throw new Error(`Unsupported chart type: ${type}`);
}
}