Загрузка данных
diff --git a/package-lock.json b/package-lock.json
index d5702bd461cbc3d3cdb5e9c9eca9dc8af4fdc1ed..9957620897d235428a3934816d2f61e32cd9855f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11917,7 +11917,7 @@
"version": "1.11.6",
"resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@swc/core/-/core-1.11.6.tgz",
"integrity": "sha1-EcYPZy8plHwFLjkg4USzrRxkXkk=",
- "devOptional": true,
+ "dev": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.25"
@@ -11949,6 +11949,7 @@
"version": "1.15.1",
"resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.1.tgz",
"integrity": "sha1-Ao/bHkGda8HUIvsn6Vko0LnXt4I=",
+ "dev": true,
"optional": true,
"engines": {
"node": ">=10"
@@ -11958,13 +11959,13 @@
"version": "0.1.3",
"resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha1-zHRjvQKUlhHGMpWW/M0rDseCsOk=",
- "devOptional": true
+ "dev": true
},
"node_modules/@swc/helpers": {
"version": "0.5.13",
"resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@swc/helpers/-/helpers-0.5.13.tgz",
"integrity": "sha1-M+Y/880MreVXZyvXiIo5zn0RWow=",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -11975,7 +11976,7 @@
"version": "0.1.25",
"resolved": "https://nexus-dev.tech.moex.com/repository/trade-radar-npm-private-group/@swc/types/-/types-0.1.25.tgz",
"integrity": "sha1-tReypg/rN92TPlQtkwk3GeTPEHg=",
- "devOptional": true,
+ "dev": true,
"dependencies": {
"@swc/counter": "^0.1.3"
}
diff --git a/src/components/ChartTooltip/index.tsx b/src/components/ChartTooltip/index.tsx
index 3024f9ca7bebdec851927b73162eb042b615d353..0d386f8a9d4cbba1290b3a11ba51b4bbb30fb0c7 100644
--- a/src/components/ChartTooltip/index.tsx
+++ b/src/components/ChartTooltip/index.tsx
@@ -1,9 +1,11 @@
import { Divider } from 'exchange-elements/v2';
-import { UTCTimestamp } from 'lightweight-charts';
+import { Time, UTCTimestamp } from 'lightweight-charts';
import { FC, useLayoutEffect, useRef, useState } from 'react';
import { Observable } from 'rxjs';
+import { Ohlc } from '@core/Legend';
+
import { TooltipVM } from '@core/Tooltip';
import { getThemeStore } from '@src/theme';
@@ -22,7 +24,6 @@ export interface TooltipProps {
ohlcConfig: OHLCConfig;
}
-// const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const TOOLTIP_MARGIN = 15;
export const ChartTooltip: FC<TooltipProps> = ({
@@ -41,7 +42,7 @@ export const ChartTooltip: FC<TooltipProps> = ({
const { colors } = getThemeStore();
- const { visible, position, ohlc, viewport } = viewModel ?? {};
+ const { visible, position, viewport, vm: vmLegend } = viewModel ?? {};
const showTime = timeframe ? shouldShowTime(timeframe) : true;
useLayoutEffect(() => {
@@ -53,7 +54,7 @@ export const ChartTooltip: FC<TooltipProps> = ({
setTooltipSize({ width, height });
}, [visible]);
- if (!visible || !ohlc || !position || !viewport) {
+ if (!visible || !position || !viewport) {
return null;
}
const coords = calcTooltipPosition({
@@ -84,58 +85,89 @@ export const ChartTooltip: FC<TooltipProps> = ({
style={style}
ref={refTooltip}
>
- {tooltipConfig.time?.visible &&
- ohlc.time &&
- renderRow(
- tooltipConfig.time.label,
- formatDate(
- ohlc.time as UTCTimestamp,
- format?.dateFormat ?? Defaults.dateFormat,
- format?.timeFormat ?? Defaults.timeFormat,
- showTime,
- ),
- )}
-
- {(tooltipConfig.close?.visible || tooltipConfig.open?.visible) && (!!ohlc.close || !!ohlc.open) && (
- <Divider
- direction="horizontal"
- pt={{ divider: { className: styles.divider } }}
- />
- )}
-
- {tooltipConfig.close?.visible &&
- ohlc.close &&
- renderRow(tooltipConfig.close.label, formatPrice(ohlc.close, precision))}
- {/* {tooltipConfig.value?.visible &&
- ohlc.value &&
- renderRow(tooltipConfig.value.label, formatPrice(ohlc.value, precision))} */}
- {tooltipConfig.open?.visible &&
- ohlc.open &&
- renderRow(tooltipConfig.open.label, formatPrice(ohlc.open, precision))}
-
- {(tooltipConfig.high?.visible || tooltipConfig.low?.visible) && (!!ohlc.high || !!ohlc.low) && (
- <Divider
- direction="horizontal"
- pt={{ divider: { className: styles.divider } }}
- />
- )}
-
- {tooltipConfig.high?.visible &&
- ohlc.high &&
- renderRow(tooltipConfig.high.label, formatPrice(ohlc.high, precision))}
- {tooltipConfig.low?.visible && ohlc.low && renderRow(tooltipConfig.low.label, formatPrice(ohlc.low, precision))}
- {/* {tooltipConfig.change?.visible && (isNum(ohlc.absoluteChange) || isNum(ohlc.percentageChange)) && (
- <div className={styles.row}>
- <span className={styles.label}>{tooltipConfig.change.label}:</span>
- <span
- className={styles.value}
- style={{ color: priceColor }}
- >
- {`${formatPrice(ohlc.absoluteChange)} (${formatPrice(ohlc.percentageChange)}%)`}
- </span>
- </div>
- )} */}
- {/* {tooltipConfig.volume?.visible && volume && renderRow(tooltipConfig.volume.label, volume)} */}
+ {vmLegend?.map((indicator) => {
+ if (indicator.isIndicator) {
+ const entries = Array.from(
+ indicator.values as Map<keyof Ohlc, { value: number | string | Time; color: string; name: string }>,
+ );
+
+ return (
+ <>
+ {entries.map((x) => {
+ const [key, entry] = x as [keyof Ohlc, { value: number | string | Time; color: string; name: string }];
+
+ return renderRow(key as string, entry.value as string);
+ })}
+ </>
+ );
+ }
+
+ const entries: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> =
+ Object.fromEntries(indicator.values as any);
+
+ return (
+ <>
+ {tooltipConfig.time?.visible &&
+ entries.time?.value &&
+ renderRow(
+ tooltipConfig.time.label,
+ formatDate(
+ entries.time.value as UTCTimestamp,
+ format?.dateFormat ?? Defaults.dateFormat,
+ format?.timeFormat ?? Defaults.timeFormat,
+ showTime,
+ ),
+ )}
+
+ {((tooltipConfig.close?.visible && !!entries.close?.value) ||
+ (tooltipConfig.open?.visible && !!entries.open?.value) ||
+ (tooltipConfig.value?.visible && !!entries.value?.value)) && (
+ <Divider
+ direction="horizontal"
+ pt={{ divider: { className: styles.divider } }}
+ />
+ )}
+
+ {tooltipConfig.close?.visible &&
+ entries.close?.value &&
+ renderRow(tooltipConfig.close.label, formatPrice(entries.close?.value as string, precision))}
+
+ {tooltipConfig.value?.visible &&
+ entries.value?.value &&
+ renderRow(tooltipConfig.value.label, formatPrice(entries.value.value as string, precision))}
+
+ {tooltipConfig.open?.visible &&
+ entries.open?.value &&
+ renderRow(tooltipConfig.open.label, formatPrice(entries.open.value as string, precision))}
+
+ {((tooltipConfig.high?.visible && !!entries.high?.value) ||
+ (tooltipConfig.low?.visible && !!entries.low?.value) ||
+ (tooltipConfig.change?.visible && !!entries.absoluteChange?.value) ||
+ (tooltipConfig.change?.visible && !!entries.percentageChange?.value)) && (
+ <Divider
+ direction="horizontal"
+ pt={{ divider: { className: styles.divider } }}
+ />
+ )}
+
+ {tooltipConfig.high?.visible &&
+ entries.high?.value &&
+ renderRow(tooltipConfig.high.label, formatPrice(entries.high.value as string, precision))}
+
+ {tooltipConfig.low?.visible &&
+ entries.low?.value &&
+ renderRow(tooltipConfig.low.label, formatPrice(entries.low.value as string, precision))}
+
+ {tooltipConfig.change?.visible &&
+ entries.absoluteChange?.value &&
+ renderRow('Абсолютное изменение', entries.absoluteChange.value as string)}
+
+ {tooltipConfig.change?.visible &&
+ entries.percentageChange?.value &&
+ renderRow('Относительное изменение', entries.percentageChange.value as string)}
+ </>
+ );
+ })}
</div>
);
};
diff --git a/src/components/Legend/index.module.scss b/src/components/Legend/index.module.scss
index 63292a4fcdfda0ab3c7a1d640b3ffa6efc946138..edbd0605f1fe3f6a693673d2320d616e28061965 100644
--- a/src/components/Legend/index.module.scss
+++ b/src/components/Legend/index.module.scss
@@ -1,3 +1,5 @@
+@use '../../theme/mixins' as m;
+
.legend {
display: flex;
width: 100%;
@@ -8,13 +10,13 @@
.row {
display: flex;
justify-content: start;
- align-items: center;
+ align-items: baseline;
flex-wrap: wrap;
gap: var(--space-0500);
}
.item {
- display: flex;
+ display: inline-flex;
gap: var(--space-0250);
}
@@ -24,4 +26,14 @@
line-height: var(--space-1000);
font-weight: var(--font-medium);
}
+
+ & .button {
+ @include m.buttonBase;
+
+ &Content {
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ }
+ }
}
diff --git a/src/components/Legend/index.tsx b/src/components/Legend/index.tsx
index f6c7ca6842750b31410c5605dedd53f5935c8dee..d1755cdac931d2273783ed66fbed4d1ad81a1b08 100644
--- a/src/components/Legend/index.tsx
+++ b/src/components/Legend/index.tsx
@@ -1,70 +1,75 @@
+import { Button } from 'exchange-elements/v2';
+import { Time } from 'lightweight-charts';
import React from 'react';
import { Observable } from 'rxjs';
-import { LegendVM } from '@core/Legend';
+import { TrashIcon } from '@components/Icon';
-import { CompareLegendRow } from '@src/components/CompareLegendRow';
+import { LegendModel, Ohlc, ohlcValuesToShowForMainSerie } from '@core/Legend';
-import { CompareMode, OHLCConfig } from '../../types';
-import { formatPrice, formatVolume, getPriceColor, useObservable } from '../../utils';
+import { OHLCConfig } from '../../types';
+import { useObservable } from '../../utils';
import styles from './index.module.scss';
export interface LegendProps {
ohlcConfig?: OHLCConfig;
- viewModel: Observable<LegendVM>;
- onCompareRemove?: (symbol: string, mode: CompareMode) => void;
+ viewModel: Observable<LegendModel>;
}
-export const LegendComponent: React.FC<LegendProps> = ({ ohlcConfig, viewModel: vm, onCompareRemove }) => {
- const viewModel = useObservable(vm);
- const ohlc = viewModel?.ohlc ?? null;
- const symbol = viewModel?.symbol ?? null;
- const volume = viewModel?.volume ?? undefined;
- const compareItems = viewModel?.compareItems ?? [];
-
- const showVolume = ohlcConfig?.showVolume;
+export const LegendComponent: React.FC<LegendProps> = ({ ohlcConfig, viewModel }) => {
+ const model = useObservable(viewModel);
const showOhlc = ohlcConfig?.show;
- const priceColor = getPriceColor(ohlc);
-
- const renderLegendItem = (label: string, value?: string | number, show = true) => {
- if (!show || !value) return null;
-
- return (
- <div className={styles.item}>
- {label} <span style={{ color: priceColor }}>{value}</span>
- </div>
- );
- };
-
return (
<section className={styles.legend}>
- <div className={styles.row}>
- {symbol && <div className={styles.symbol}>{symbol}</div>}
- {showOhlc && (
- <>
- {renderLegendItem('Отк.', formatPrice(ohlc?.open), !!ohlc?.open)}
- {renderLegendItem('Макс.', formatPrice(ohlc?.high), !!ohlc?.high)}
- {renderLegendItem('Мин.', formatPrice(ohlc?.low), !!ohlc?.low)}
- {renderLegendItem('Закр.', formatPrice(ohlc?.close), !!ohlc?.close)}
- {renderLegendItem(
- 'Изм.',
- `${formatPrice(ohlc?.percentageChange)}%`,
- !!ohlc?.absoluteChange && !!ohlc?.percentageChange,
- )}
- </>
- )}
- </div>
- <div className={styles.row}>{renderLegendItem('Объем.', formatVolume(volume), showVolume)}</div>
- {compareItems.map((item) => (
- <CompareLegendRow
- key={`${item.symbol}|${item.mode}`}
- item={item}
- onRemove={onCompareRemove}
- />
- ))}
+ {model?.map((indicator) => {
+ const inds = Array.from(
+ indicator.values as Map<keyof Ohlc, { value: number | string | Time; color: string; name: string }>,
+ );
+ return (
+ <div
+ key={`indicator-${indicator.name}`}
+ className={styles.row}
+ >
+ {indicator.name}
+ {showOhlc &&
+ inds?.map((x, index) => {
+ const [key, serie] = x as [keyof Ohlc, { value: number | string | Time; color: string; name: string }];
+ if (!indicator.isIndicator) {
+ if (!ohlcValuesToShowForMainSerie.includes(key)) {
+ return null;
+ }
+
+ return (
+ <div
+ key={serie.name === '' ? `${indicator.name}${index}` : serie.name}
+ className={styles.item}
+ >
+ {serie.name} <span style={{ color: serie.color }}>{serie.value as string}</span>
+ </div>
+ );
+ }
+
+ return (
+ <div
+ key={serie.name === '' ? `${indicator.name}${index}` : serie.name}
+ className={styles.item}
+ >
+ {serie.name} <span style={{ color: serie.color }}>{serie.value as string}</span>
+ </div>
+ );
+ })}
+ <Button
+ size="sm"
+ className={styles.button}
+ onClick={() => indicator.remove()}
+ label={<TrashIcon />}
+ />
+ </div>
+ );
+ })}
</section>
);
};
diff --git a/src/core/Chart.ts b/src/core/Chart.ts
index d4bf4632e89a129faa2627fac050de03be44cf73..ca7441b218f9d89443d421961880e16f69712085 100644
--- a/src/core/Chart.ts
+++ b/src/core/Chart.ts
@@ -23,11 +23,20 @@ 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 { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
-import { Candle, ChartOptionsModel, ChartSeriesType, ChartTypeOptions, Direction } from '@src/types';
+import {
+ Candle,
+ ChartOptionsModel,
+ ChartSeriesType,
+ ChartTypeOptions,
+ Direction,
+ OHLCConfig,
+ TooltipConfig,
+} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ensureDefined } from '@src/utils';
@@ -54,6 +63,8 @@ interface ChartParams extends ChartConfig {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
+ ohlcConfig: OHLCConfig;
+ tooltipConfig: TooltipConfig;
}
/**
@@ -63,9 +74,9 @@ export class Chart {
private lwcChart!: IChartApi;
private container: HTMLElement;
private eventManager: EventManager;
+ private paneManager!: PaneManager;
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
- private drawingsManager: DrawingsManager;
private indicatorManager: IndicatorManager;
private optionsSubscription: Subscription;
private dataSource: DataSource;
@@ -84,7 +95,7 @@ export class Chart {
private historyBatchRunning = false;
- constructor({ eventManager, dataSource, modalRenderer, ...lwcConfig }: ChartParams) {
+ constructor({ eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, ...lwcConfig }: ChartParams) {
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcConfig.container;
@@ -107,22 +118,6 @@ export class Chart {
this.subscriptions.add(this.optionsSubscription);
- this.subscriptions.add(
- this.eventManager.subscribeSeriesSelected((nextSeries) => {
- this.mainSeries.value?.destroy();
-
- const next = ensureDefined(SeriesFactory.create(nextSeries))({
- lwcChart: this.lwcChart,
- dataSource,
- mainSymbol$: this.eventManager.getSymbol(),
- mainSerie$: this.mainSeries,
- chartOptions: lwcConfig.chartOptions,
- });
-
- this.mainSeries.next(next);
- }),
- );
-
this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
this.mouseEvents.subscribe('wheel', this.onWheel);
@@ -133,21 +128,24 @@ export class Chart {
this.DOM = new DOMModel({ modalRenderer });
- this.indicatorManager = new IndicatorManager({
- eventManager,
- DOM: this.DOM,
- mainSerie$: this.mainSeries,
- dataSource: this.dataSource,
+ this.paneManager = new PaneManager({
+ eventManager: this.eventManager,
lwcChart: this.lwcChart,
- chartOptions: lwcConfig.chartOptions,
+ dataSource,
+ DOM: this.DOM,
+ ohlcConfig,
+ subscribeChartEvent: this.subscribeChartEvent,
+ chartContainer: this.container,
+ tooltipConfig,
});
- this.drawingsManager = new DrawingsManager({
+ this.indicatorManager = new IndicatorManager({
eventManager,
DOM: this.DOM,
- mainSeries$: this.mainSeries.asObservable(),
+ mainSerie$: this.mainSeries,
+ dataSource: this.dataSource,
lwcChart: this.lwcChart,
- container: this.container,
+ paneManager: this.paneManager,
chartOptions: lwcConfig.chartOptions,
});
@@ -156,102 +154,10 @@ export class Chart {
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
+ paneManager: this.paneManager,
});
- 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(
- 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(
- 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(
- 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 });
- }),
- );
-
+ this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
@@ -266,7 +172,7 @@ export class Chart {
}
public getDrawingsManager = (): DrawingsManager => {
- return this.drawingsManager;
+ return this.paneManager.getDrawingsManager();
};
public getIndicatorManager = (): IndicatorManager => {
@@ -387,6 +293,101 @@ export class Chart {
});
};
+ 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) => {
diff --git a/src/core/CompareIndicator.ts b/src/core/CompareIndicator.ts
deleted file mode 100644
index d605ae7e1ed6f6f0dc84742f27512888719c6443..0000000000000000000000000000000000000000
--- a/src/core/CompareIndicator.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { IChartApi, SeriesPartialOptionsMap, SeriesType } from 'lightweight-charts';
-import { BehaviorSubject } from 'rxjs';
-
-import { DataSource } from '@core/DataSource';
-import { DOMObjectParams } from '@core/DOMObject';
-
-import { SeriesObject } from '@src/core/SeriesObject';
-import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
-import { ChartTypeOptions } from '@src/types';
-
-interface CompareIndicatorParams extends DOMObjectParams {
- lwcChart: IChartApi;
- dataSource: DataSource;
- mainSerie$: BehaviorSubject<SeriesStrategies | null>;
- symbol$: BehaviorSubject<string>;
- seriesType: SeriesType;
- seriesOptions?: SeriesPartialOptionsMap[SeriesType];
- showSymbolLabel?: boolean;
- paneIndex?: number;
-}
-
-export class CompareIndicator extends SeriesObject {
- constructor(params: CompareIndicatorParams) {
- super({
- ...params,
- });
- }
-}
diff --git a/src/core/CompareManager.ts b/src/core/CompareManager.ts
index b0bafa4b7ac32fd2da377a163c9cca00640f89c0..3c1522eac2426eb28bfdf227c795c676b68b874a 100644
--- a/src/core/CompareManager.ts
+++ b/src/core/CompareManager.ts
@@ -3,11 +3,16 @@ 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 { IndicatorConfig, IndicatorDataFormatter } from '@core/Indicators';
+import { emaIndicator } from '@core/Indicators/ema';
+import { PaneManager } from '@core/PaneManager';
import { MAIN_PANE_INDEX } from '@src/constants';
-import { CompareIndicator } from '@src/core/CompareIndicator';
+// import { CompareIndicator } from '@src/core/CompareIndicator';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
+import { getThemeStore } from '@src/theme';
import { CompareItem, CompareMode, Direction } from '@src/types';
import { normalizeSymbol } from '@src/utils';
@@ -17,35 +22,52 @@ interface CompareEntry {
mode: CompareMode;
paneIndex: number;
symbol$: BehaviorSubject<string>;
- entity: CompareIndicator;
+ entity: Indicator;
}
function makeKey(symbol: string, mode: CompareMode): string {
return `${symbol}|${mode}`;
}
+interface CompareManagerParams {
+ chart: IChartApi;
+ eventManager: EventManager;
+ dataSource: DataSource;
+ indicatorManager: IndicatorManager;
+ paneManager: PaneManager;
+}
+
+const defaultCompareIndicator: IndicatorConfig = {
+ newPane: true,
+ series: [
+ {
+ name: 'Line', // todo: change with enum
+ id: `compare-${Math.random()}`,
+ seriesOptions: {
+ visible: true,
+ color: getThemeStore().colors.chartPriceLineText,
+ },
+ },
+ ],
+};
+
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<CompareIndicator[]>([]);
+ private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
- private nextPaneIndex = MAIN_PANE_INDEX + 1;
-
- constructor(params: {
- chart: IChartApi;
- eventManager: EventManager;
- dataSource: DataSource;
- indicatorManager: IndicatorManager;
- }) {
- this.chart = params.chart;
- this.eventManager = params.eventManager;
- this.dataSource = params.dataSource;
- this.indicatorManager = params.indicatorManager;
+ constructor({ chart, eventManager, dataSource, indicatorManager, paneManager }: CompareManagerParams) {
+ this.chart = chart;
+ this.eventManager = eventManager;
+ this.dataSource = dataSource;
+ this.indicatorManager = indicatorManager;
+ this.paneManager = paneManager;
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
@@ -56,7 +78,7 @@ export class CompareManager {
return this.itemsSubject.asObservable();
}
- public entitiesObs(): Observable<CompareIndicator[]> {
+ public entitiesObs(): Observable<Indicator[]> {
return this.entitiesSubject.asObservable();
}
@@ -87,30 +109,34 @@ export class CompareManager {
const { paneIndex, priceScaleId } = this.getPlacement(mode);
const symbol$ = new BehaviorSubject(symbol);
- const entity = this.indicatorManager.addEntity<CompareIndicator>(
+ const entity = this.indicatorManager.addEntity<Indicator>(
(zIndex, moveUp, moveDown) =>
- new CompareIndicator({
+ new Indicator({
id: key,
+ lwcChart: this.chart,
+ mainSymbol$: new BehaviorSubject<string>(symbol),
+ dataSource: this.dataSource,
zIndex,
+ onDelete: () => this.removeByKey(key),
moveUp,
moveDown,
- onDelete: () => this.removeByKey(key),
- lwcChart: this.chart,
- dataSource: this.dataSource,
- mainSerie$: new BehaviorSubject<SeriesStrategies | null>(null),
- symbol$,
- seriesType,
- paneIndex,
- seriesOptions: {
- priceScaleId,
+ paneManager: this.paneManager,
+ config: {
+ ...defaultCompareIndicator,
+ series: [
+ {
+ ...defaultCompareIndicator.series[0],
+ seriesOptions: {
+ ...defaultCompareIndicator.series[0]?.seriesOptions,
+ priceScaleId,
+ },
+ },
+ ],
+ newPane: mode === CompareMode.NewPane,
},
}),
);
- if (mode === CompareMode.NewPane) {
- entity.getStrategy().getLwcSeries().moveToPane(paneIndex);
- }
-
this.entries.set(key, { key, symbol, mode, paneIndex, symbol$, entity });
this.applyPolicy();
@@ -173,7 +199,6 @@ export class CompareManager {
this.entries.delete(key);
- this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
entry.symbol$.complete();
@@ -185,7 +210,7 @@ export class CompareManager {
const values = Array.from(this.entries.values());
const items: CompareItem[] = [];
- const entities: CompareIndicator[] = [];
+ const entities: Indicator[] = [];
for (let i = 0; i < values.length; i += 1) {
items.push({ symbol: values[i].symbol, mode: values[i].mode });
@@ -209,8 +234,8 @@ export class CompareManager {
let hasMainNewScale = false;
for (let i = 0; i < list.length; i += 1) {
- const series = list[i].entity.getStrategy().getLwcSeries();
- const paneIndex = series.getPane().paneIndex();
+ // [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;
@@ -219,7 +244,9 @@ export class CompareManager {
const paneSet = new Set<number>();
for (let i = 0; i < list.length; i += 1) {
- const paneIndex = list[i].entity.getStrategy().getLwcSeries().getPane().paneIndex();
+ // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
+ const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
+
if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
}
@@ -258,15 +285,17 @@ export class CompareManager {
for (let i = 0; i < list.length; i += 1) {
const entry = list[i];
- const series = entry.entity.getStrategy().getLwcSeries();
- const paneIndex = series.getPane().paneIndex();
+
+ // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
+ const serie = Array.from(entry.entity.getSeriesMap())[0][1];
+ const paneIndex = serie.getPane().paneIndex();
if (paneIndex !== MAIN_PANE_INDEX) {
- series.applyOptions({ priceScaleId: Direction.Right });
+ serie.applyOptions({ priceScaleId: Direction.Right });
continue;
}
- series.applyOptions({
+ serie.applyOptions({
priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
@@ -274,7 +303,7 @@ export class CompareManager {
private getPlacement(mode: CompareMode): { paneIndex: number; priceScaleId: Direction } {
const priceScaleId = mode === CompareMode.NewScale ? Direction.Left : Direction.Right;
- const paneIndex = mode === CompareMode.NewPane ? this.nextPaneIndex++ : MAIN_PANE_INDEX;
+ const paneIndex = mode === CompareMode.NewPane ? this.paneManager.getPanes().size - 1 : MAIN_PANE_INDEX;
return { paneIndex, priceScaleId };
}
}
diff --git a/src/core/ContainerManager.ts b/src/core/ContainerManager.ts
index 609b714da5867acda0133dafee0859ab6c7c36c1..6c9b1de15bff24365fea2616156786026c4d4aec 100644
--- a/src/core/ContainerManager.ts
+++ b/src/core/ContainerManager.ts
@@ -81,20 +81,6 @@ export class ContainerManager {
toolBarContainer.style.minHeight = '0';
toolBarContainer.style.overflow = 'hidden';
- const legendContainer = document.createElement('div');
- legendContainer.style.width = '100%';
- legendContainer.style.position = 'absolute';
- legendContainer.style.top = '0';
- legendContainer.style.left = '0';
- legendContainer.style.zIndex = ZIndex.Base;
-
- const overlayContainer = document.createElement('div');
- overlayContainer.className = 'moex-chart-overlay-container';
- overlayContainer.style.position = 'absolute';
- overlayContainer.style.inset = '0';
- overlayContainer.style.zIndex = ZIndex.Base;
- overlayContainer.style.pointerEvents = 'none';
-
const controlBarContainer = document.createElement('div');
controlBarContainer.style.width = '250px';
controlBarContainer.style.position = 'absolute';
@@ -111,7 +97,7 @@ export class ContainerManager {
modalContainer.style.zIndex = ZIndex.Modal;
modalContainer.style.pointerEvents = 'none';
- chartAreaContainer.append(overlayContainer, controlBarContainer, modalContainer);
+ chartAreaContainer.append(controlBarContainer, modalContainer);
chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
chartContainer.append(chartAreaContainer);
@@ -151,9 +137,7 @@ export class ContainerManager {
chartAreaContainer,
toolBarContainer,
- legendContainer,
modalContainer,
- overlayContainer,
controlBarContainer,
toggleToolbar,
@@ -166,4 +150,25 @@ export class ContainerManager {
static clearContainers(parentContainer: HTMLElement): void {
parentContainer.innerHTML = '';
}
+
+ static createPaneContainers() {
+ const legendContainer = document.createElement('div');
+ legendContainer.style.width = '100%';
+ legendContainer.style.position = 'absolute';
+ legendContainer.style.top = '0';
+ legendContainer.style.left = '0';
+ legendContainer.style.zIndex = ZIndex.Base;
+
+ const paneOverlayContainer = document.createElement('div');
+ paneOverlayContainer.className = 'moex-chart-pane-overlay-container';
+ paneOverlayContainer.style.position = 'absolute';
+ paneOverlayContainer.style.inset = '0';
+ paneOverlayContainer.style.zIndex = ZIndex.Base;
+ paneOverlayContainer.style.pointerEvents = 'none';
+
+ return {
+ legendContainer,
+ paneOverlayContainer,
+ };
+ }
}
diff --git a/src/core/DrawingsManager.ts b/src/core/DrawingsManager.ts
index 57dfc8829896a927cd7f083c3b36ff5d6fbccab5..21b5fb35c683337509a1ae48977e11e5edddfa7c 100644
--- a/src/core/DrawingsManager.ts
+++ b/src/core/DrawingsManager.ts
@@ -24,7 +24,7 @@ interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
- chartOptions?: ChartTypeOptions;
+ // chartOptions?: ChartTypeOptions;
DOM: DOMModel;
container: HTMLElement;
}
@@ -166,7 +166,7 @@ export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
- private chartOptions?: ChartTypeOptions;
+ // private chartOptions?: ChartTypeOptions;
private drawingsQty = 0; // todo: replace with hash
private DOM: DOMModel;
private container: HTMLElement;
@@ -175,11 +175,11 @@ export class DrawingsManager {
private subscriptions = new Subscription();
private drawings$: BehaviorSubject<Drawing[]> = new BehaviorSubject<Drawing[]>([]);
- constructor({ eventManager, mainSeries$, lwcChart, chartOptions, DOM, container }: DrawingsManagerParams) {
+ constructor({ eventManager, mainSeries$, lwcChart, DOM, container }: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
- this.chartOptions = chartOptions;
+ // this.chartOptions = chartOptions;
this.container = container;
this.subscriptions.add(
diff --git a/src/core/HoverController.ts b/src/core/HoverController.ts
deleted file mode 100644
index 0dddcb1149a50e8f3c72ecce5ca13fbe7f582237..0000000000000000000000000000000000000000
--- a/src/core/HoverController.ts
+++ /dev/null
@@ -1,161 +0,0 @@
-import { BarData, CustomData, HistogramData, LineData, MouseEventParams } from 'lightweight-charts';
-import { Observable, Subscription } from 'rxjs';
-
-import { CompareLegendItem, Legend } from '@core/Legend';
-
-import { TooltipService } from '@core/Tooltip';
-
-import { CompareManager } from '@src/core/CompareManager';
-import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
-
-import { CompareMode } from '@src/types';
-import { formatPrice, getPriceColor } from '@src/utils';
-
-import { ChartMouseEvents } from './ChartMouseEvents';
-
-interface HoverControllerParams {
- legend: Legend;
- tooltip?: TooltipService;
- chartEventSub: ChartMouseEvents['subscribe'];
- chartEventUnsub: ChartMouseEvents['unsubscribe'];
- mainSeriesObs: Observable<SeriesStrategies | null>;
- compareManager: CompareManager;
-}
-
-// todo: первый кандидат на рефактор
-// todo: не нужно тянуть сюда tooltip и legend. Hover нужно наоборот прокидывать в них
-export class HoverController {
- private unsubChartHover: () => void = () => {};
- private unsubSeries: () => void = () => {};
- private mainSeriesSubscription: Subscription;
- private isChartHovered = false;
- private tooltip: TooltipService | undefined;
- private legend: Legend;
- private compareManager: CompareManager;
-
- constructor({
- legend,
- tooltip,
- chartEventSub,
- chartEventUnsub,
- mainSeriesObs,
- compareManager,
- }: HoverControllerParams) {
- this.tooltip = tooltip;
- this.legend = legend;
- this.compareManager = compareManager;
-
- this.mainSeriesSubscription = mainSeriesObs.subscribe((nextSeries: SeriesStrategies | null) => {
- if (!nextSeries) return;
-
- this.unsubChartHover();
- this.unsubSeries();
-
- const onCrosshairMove = (param: MouseEventParams) => {
- this.handleCrosshairMove(nextSeries, param);
- };
-
- chartEventSub('crosshairMove', onCrosshairMove);
- this.unsubChartHover = () => chartEventUnsub('crosshairMove', onCrosshairMove);
-
- const onDataChanged = () => this.updateWithLastCandle(nextSeries);
- nextSeries.subscribeDataChanged(onDataChanged);
- this.unsubSeries = () => nextSeries.unsubscribeDataChanged(onDataChanged);
- });
- }
-
- public destroy(): void {
- this.unsubChartHover();
- this.unsubSeries();
- this.mainSeriesSubscription.unsubscribe();
- }
-
- private handleCrosshairMove(mainSeries: SeriesStrategies, param: MouseEventParams) {
- if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
- this.tooltip?.setVisible(false);
- this.isChartHovered = false;
- this.updateWithLastCandle(mainSeries);
- return;
- }
-
- this.isChartHovered = true;
-
- const { prevSeriesData, currentSeriesData } = this.processChartHover(param, mainSeries);
- const compareItems = this.getCompareItems(param.logical!);
-
- this.legend.updateLegendView(prevSeriesData, currentSeriesData, compareItems);
-
- this.tooltip?.setVisible(true);
- this.tooltip?.setPosition(param.point);
- }
-
- private getCompareItems(logical: number): CompareLegendItem[] {
- const entities = this.compareManager.getAllEntities();
-
- return entities.map(({ symbol, entity, mode }) => {
- const strategy = entity.getStrategy();
- const data = strategy.dataByIndex(logical);
-
- return this.formatCompareItem(symbol, mode, data);
- });
- }
-
- private getCompareItemsLast(): CompareLegendItem[] {
- const entities = this.compareManager.getAllEntities();
-
- return entities.map(({ symbol, entity, mode }) => {
- const strategy = entity.getStrategy();
- const allData = strategy.data();
- const lastData = allData[allData.length - 1];
-
- return this.formatCompareItem(symbol, mode, lastData);
- });
- }
-
- private formatCompareItem(symbol: string, mode: CompareMode, data: any): CompareLegendItem {
- let valueStr = '-';
- const priceColor = getPriceColor(data);
-
- if (data) {
- const value = data.close ?? data.value;
-
- if (value) {
- valueStr = formatPrice(value) as string;
- }
- }
-
- return {
- symbol,
- mode,
- value: valueStr,
- color: priceColor,
- };
- }
-
- private processChartHover(
- param: MouseEventParams,
- mainSeries: SeriesStrategies,
- ): {
- prevSeriesData: BarData | LineData | HistogramData | CustomData | null;
- currentSeriesData: BarData | LineData | HistogramData | CustomData | null;
- } {
- const currentSeriesData = param.seriesData.get(mainSeries.getLwcSeries()) ?? null;
- const prevSeriesData = mainSeries.dataByIndex(param.logical! - 1) ?? null;
-
- return { prevSeriesData, currentSeriesData };
- }
-
- private updateWithLastCandle(series: SeriesStrategies): void {
- if (this.isChartHovered) return;
-
- const seriesData = series.data();
- if (seriesData.length < 1) return;
-
- const currentSeriesData = seriesData[seriesData.length - 1];
- const prevSeriesData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
-
- const compareItems = this.getCompareItemsLast();
-
- this.legend.updateLegendView(prevSeriesData, currentSeriesData, compareItems);
- }
-}
diff --git a/src/core/Indicator.ts b/src/core/Indicator.ts
index 793ca19f9f0bcf4cedd80be6d970354ffbd08a85..e08834d1465c147798a21034cedc3063ad8de412 100644
--- a/src/core/Indicator.ts
+++ b/src/core/Indicator.ts
@@ -1,9 +1,11 @@
import { IChartApi } from 'lightweight-charts';
-import { BehaviorSubject, Observable } from 'rxjs';
+import { BehaviorSubject, Observable, throttleTime } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
-import { IndicatorsIds, indicatorsMap } from '@core/Indicators';
+import { IndicatorConfig, IndicatorsIds, indicatorsMap } from '@core/Indicators';
+import { Pane } from '@core/Pane';
+import { PaneManager } from '@core/PaneManager';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions } from '@src/types';
@@ -11,35 +13,41 @@ import { ensureDefined } from '@src/utils';
type IIndicator = DOMObject;
-interface IndicatorParams extends DOMObjectParams {
+export interface IndicatorParams extends DOMObjectParams {
mainSymbol$: Observable<string>;
- mainSerie$: BehaviorSubject<SeriesStrategies | null>;
lwcChart: IChartApi;
dataSource: DataSource;
+ paneManager: PaneManager;
chartOptions?: ChartTypeOptions;
+ config?: IndicatorConfig;
}
export class Indicator extends DOMObject implements IIndicator {
private series: SeriesStrategies[] = [];
private seriesMap: Map<string, SeriesStrategies> = new Map();
+ private lwcChart: IChartApi;
+ private associatedPane: Pane;
constructor({
id,
lwcChart,
dataSource,
- chartOptions,
zIndex,
onDelete,
moveUp,
moveDown,
- mainSerie$,
mainSymbol$,
+ paneManager,
+ config,
}: IndicatorParams) {
super({ id: id as string, zIndex, onDelete, moveUp, moveDown });
+ this.lwcChart = lwcChart;
- const indicatorConfig = indicatorsMap[id as IndicatorsIds];
+ const indicatorConfig = indicatorsMap[id as IndicatorsIds] ?? config;
- const { series, paneIndex } = ensureDefined(indicatorConfig);
+ const { series, newPane } = ensureDefined(indicatorConfig);
+
+ this.associatedPane = newPane ? paneManager.addPane() : paneManager.getMainPane();
series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
const serie = SeriesFactory.create(name!)({
@@ -47,12 +55,11 @@ export class Indicator extends DOMObject implements IIndicator {
dataSource,
customFormatter: dataFormatter,
seriesOptions,
- chartOptions,
priceScaleOptions,
mainSymbol$,
- mainSerie$,
+ mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
- paneIndex,
+ paneIndex: this.associatedPane.getId(),
indicatorReference: this,
});
@@ -60,14 +67,32 @@ export class Indicator extends DOMObject implements IIndicator {
this.series.push(serie);
});
+
+ this.associatedPane.setIndicator(id as IndicatorsIds, this);
}
- public getSeriesMap() {
+ public subscribeDataChange(handler: () => void): void {
+ // todo: возможно нужно переписать завязавшись гна this.associatedPane.getMainSerie()
+ const firer = new BehaviorSubject<number>(0);
+ this.series.forEach((serie) => {
+ serie.subscribeDataChanged(() => {
+ firer.next(firer.value + 1);
+ });
+ });
+
+ firer.pipe(throttleTime(500)).subscribe(() => {
+ handler();
+ });
+ }
+
+ public getSeriesMap(): Map<string, SeriesStrategies> {
return this.seriesMap;
}
public delete() {
super.delete();
+
+ this.associatedPane.removeIndicator(this.id as IndicatorsIds);
}
public show() {
diff --git a/src/core/IndicatorManager.ts b/src/core/IndicatorManager.ts
index 1fca145ef84e3dd780cc91c0b18195287791c0eb..183b8b21f1a06eeb8d040d8be600c9223b9ba28c 100644
--- a/src/core/IndicatorManager.ts
+++ b/src/core/IndicatorManager.ts
@@ -6,6 +6,7 @@ import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@core/Indicators';
+import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions } from '@src/types';
@@ -15,6 +16,7 @@ interface SeriesParams {
dataSource: DataSource;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
lwcChart: IChartApi;
+ paneManager: PaneManager;
DOM: DOMModel;
chartOptions?: ChartTypeOptions;
}
@@ -28,7 +30,7 @@ export class IndicatorManager {
private indicatorSeriesMap$: BehaviorSubject<Map<IndicatorsIds, Indicator>> = new BehaviorSubject(new Map());
private DOM: DOMModel;
- constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, mainSerie$ }: SeriesParams) {
+ constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, mainSerie$, paneManager }: SeriesParams) {
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.chartOptions = chartOptions;
@@ -45,13 +47,13 @@ export class IndicatorManager {
id,
lwcChart,
mainSymbol$: this.eventManager.getSymbol(),
- mainSerie$,
dataSource,
chartOptions,
zIndex,
onDelete: this.deleteIndicator,
moveUp,
moveDown,
+ paneManager,
}),
);
@@ -80,7 +82,7 @@ export class IndicatorManager {
});
}
- public addEntity<T extends DOMObject>(
+ public addEntity<T extends Indicator>(
factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
): T {
return this.DOM.setEntity(factory);
diff --git a/src/core/Indicators/index.tsx b/src/core/Indicators/index.tsx
index 3b2b10fc58b2901017d6d7bd002a1cb4294037b2..85542e5fa4f1b0e8e72247c083599b731d66046b 100644
--- a/src/core/Indicators/index.tsx
+++ b/src/core/Indicators/index.tsx
@@ -26,9 +26,9 @@ export interface IndicatorConfig {
seriesOptions?: SeriesPartialOptionsMap[ChartSeriesType];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
priceScaleId?: string;
- dataFormatter<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
+ dataFormatter?<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
}[];
- paneIndex?: number;
+ newPane?: boolean;
}
export enum IndicatorsIds {
@@ -101,7 +101,7 @@ export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
],
},
[IndicatorsIds.macd]: {
- paneIndex: 1, // todo: временное решение , пока нет поддержки пейнов
+ newPane: true,
series: [
{
name: 'Line', // todo: change with enum
diff --git a/src/core/Legend.ts b/src/core/Legend.ts
index f3eeaf0c4682be6f9768e92e2c6b6615c5837887..29d3d77725714f7154ff9938ff0eb94ce5a8d4e7 100644
--- a/src/core/Legend.ts
+++ b/src/core/Legend.ts
@@ -1,17 +1,23 @@
-import { BarData, CustomData, HistogramData, LineData, Time } from 'lightweight-charts';
+import { MouseEventParams, Point, Time } from 'lightweight-charts';
-import { BehaviorSubject, combineLatest, map, Observable } from 'rxjs';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
-import { DataSource } from '@core/DataSource';
+import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { EventManager } from '@core/EventManager';
+import { Indicator } from '@core/Indicator';
+import { IndicatorsIds } from '@core/Indicators';
+import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, OHLCConfig } from '@src/types';
-import { isBarData, isLineData } from '@src/utils';
+import { ensureDefined } from '@src/utils';
export interface LegendParams {
config: OHLCConfig;
eventManager: EventManager;
- dataSource: DataSource;
+ indicators: BehaviorSubject<Map<IndicatorsIds, Indicator>>;
+ subscribeChartEvent: ChartMouseEvents['subscribe'];
+ mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
+ paneId: number;
}
export interface Ohlc {
@@ -33,99 +39,235 @@ export interface CompareLegendItem {
}
export interface LegendVM {
- ohlc: Ohlc | null;
- symbol: string;
- volume: number | null;
- compareItems: CompareLegendItem[];
+ vm: LegendModel;
}
-// todo: принести в легенду данные из indicatorManager
+export type LegendModel = {
+ name: IndicatorsIds | string;
+ isIndicator: boolean;
+ values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
+ remove: () => void;
+}[];
+
+export const ohlcValuesToShowForMainSerie = [
+ // 'time',
+ 'open',
+ 'high',
+ 'low',
+ 'close',
+ 'value',
+ // 'absoluteChange',
+ 'percentageChange',
+];
+
+export const ohlcValuesToShowForIndicators = [
+ // 'time',
+ 'open',
+ // 'high',
+ // 'low',
+ // 'close',
+ 'value',
+ // 'absoluteChange',
+ // 'percentageChange'
+];
+
export class Legend {
private eventManager: EventManager;
- private dataSource: DataSource;
- private ohlc$ = new BehaviorSubject<Ohlc | null>(null);
- private volume$ = new BehaviorSubject<number | null>(null); // fixme should be map
- private compareItems$ = new BehaviorSubject<CompareLegendItem[]>([]);
+ private indicators: Map<IndicatorsIds, Indicator> = new Map();
private config: OHLCConfig;
- constructor({ config, eventManager, dataSource }: LegendParams) {
+ private mainSeries!: SeriesStrategies;
+ private isChartHovered = false;
+ private model$ = new BehaviorSubject<LegendModel>([]);
+ private tooltipVisability = new BehaviorSubject<boolean>(false);
+ private tooltipPos = new BehaviorSubject<null | Point>(null);
+ private paneId: number;
+
+ constructor({ config, eventManager, indicators, subscribeChartEvent, mainSeries, paneId }: LegendParams) {
this.config = config;
this.eventManager = eventManager;
- this.dataSource = dataSource;
+ this.paneId = paneId;
+
+ if (!mainSeries) {
+ indicators.subscribe((value: Map<IndicatorsIds, Indicator>) => {
+ this.indicators = value;
+ this.handleIndicatorSeriesDataChange();
+ });
+ } else {
+ combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
+ if (!mainSerie) {
+ return;
+ }
+ this.mainSeries = mainSerie;
+ this.indicators = inds;
+ this.handleMainSeriesDataChange();
+ });
+ }
+ subscribeChartEvent('crosshairMove', this.handleCrosshairMove);
}
- public getConfig(): OHLCConfig {
- return this.config;
+ public subscribeCursorPosition(cb: (point: Point | null) => void) {
+ this.tooltipPos.subscribe(cb);
}
- public getLegendViewModel(): Observable<LegendVM> {
- return combineLatest([this.ohlc$, this.eventManager.getSymbol(), this.volume$, this.compareItems$]).pipe(
- map(([ohlc, symbol, volume, compareItems]): LegendVM => <LegendVM>{ ohlc, symbol, volume, compareItems }),
- );
+ public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
+ this.tooltipVisability.subscribe(cb);
}
- public updateLegendView(
- prev: BarData | LineData | HistogramData | CustomData | null,
- current: BarData | LineData | HistogramData | CustomData | null,
- compareItems: CompareLegendItem[] = [],
- ): void {
- const preparedData = calcCandleChange(prev, current);
- if (!preparedData) return;
+ private handleIndicatorSeriesDataChange = () => {
+ for (const [_, indicator] of this.indicators) {
+ indicator?.subscribeDataChange(this.updateWithLastCandle);
+ }
+ };
- const { customValues, ...ohlc } = preparedData;
+ private handleMainSeriesDataChange = () => {
+ this.mainSeries?.subscribeDataChanged(() => {
+ this.updateWithLastCandle();
+ });
+ };
- this.ohlc$.next(ohlc as Ohlc);
+ private updateWithLastCandle = () => {
+ if (this.isChartHovered) return;
- if (!customValues) return;
- this.volume$.next(customValues.volume ?? null);
+ const model: LegendModel = [];
- this.compareItems$.next(compareItems);
- }
+ if (this.mainSeries) {
+ const series = new Map();
- public destroy() {
- this.volume$.complete();
- this.ohlc$.complete();
- this.compareItems$.complete();
- }
-}
+ const serieData = this.mainSeries.getLegendData();
-function calcCandleChange(
- prev: BarData | LineData | HistogramData | CustomData | null,
- current: BarData | LineData | HistogramData | CustomData | null,
-): (Ohlc & { customValues?: Record<string, any> }) | null {
- if (!current) {
- return null;
- }
+ Object.entries(serieData).forEach(([key, sd]) => {
+ series.set(`${key}`, {
+ ...sd,
+ });
+ });
- if (!prev) {
- return current;
- }
+ model.push({
+ name: this.mainSeries.options().title,
+ values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
+ isIndicator: false,
+ remove: () => {
+ // serieData.remove() // todo: запретить удалять главную серию на главном пейне
+ },
+ });
+ }
+
+ if (!this.indicators) {
+ return;
+ }
+
+ for (const [indicatorName, indicator] of this.indicators) {
+ const indicatorSeries = new Map();
+
+ for (const [serieName, serie] of indicator.getSeriesMap()) {
+ if (!serie.isVisible()) {
+ continue;
+ }
+
+ const serieData = serie.getLegendData();
+
+ const value = serieData.value ?? serieData.close;
+
+ indicatorSeries.set(serieName, { ...value });
+ }
- if (isBarData(prev) && isBarData(current)) {
- const absoluteChange = current.close - prev.close;
- const percentageChange = ((current.close - prev.close) / prev.close) * 100;
+ model.push({
+ name: indicatorName,
+ values: indicatorSeries as Partial<
+ Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
+ >,
+ isIndicator: true,
+ remove: () => indicator.delete(),
+ });
+ }
- return {
- ...current,
- absoluteChange,
- percentageChange,
- };
+ this.model$.next(model);
+ };
+
+ private handleCrosshairMove = (param: MouseEventParams) => {
+ // todo: есть одинаковый код с updateWithLastCandle
+ if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
+ this.tooltipVisability.next(false);
+
+ this.isChartHovered = false;
+ this.updateWithLastCandle();
+ return;
+ }
+
+ if (this.paneId === param.paneIndex) {
+ this.tooltipVisability.next(true);
+ } else {
+ this.tooltipVisability.next(false);
+ }
+
+ this.isChartHovered = true;
+
+ const model: LegendModel = [];
+
+ if (this.mainSeries) {
+ const series = new Map();
+ const serieData = this.mainSeries.getLegendData(param);
+
+ Object.entries(serieData).forEach(([key, sd]) => {
+ series.set(`${key}`, {
+ ...sd,
+ });
+ });
+
+ model.push({
+ name: this.mainSeries.options().title,
+ values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
+ isIndicator: false,
+ remove: () => {
+ // serieData.remove() // todo: запретить удалять главную серию на главном пейне
+ },
+ });
+ }
+
+ if (!this.indicators) {
+ return;
+ }
+
+ for (const [indicatorName, indicator] of this.indicators) {
+ const indicatorSeries = new Map();
+
+ for (const [serieName, serie] of indicator.getSeriesMap()) {
+ if (!serie.isVisible()) {
+ continue;
+ }
+
+ const serieData = serie.getLegendData(param);
+
+ const value = ensureDefined(serieData.value ?? serieData.close);
+
+ indicatorSeries.set(serieName, value);
+ }
+
+ model.push({
+ name: indicatorName,
+ values: indicatorSeries as Partial<
+ Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
+ >,
+ isIndicator: true,
+ remove: () => indicator.delete(),
+ });
+ }
+
+ this.model$.next(model);
+ this.tooltipPos.next(param.point);
+ };
+
+ public getConfig(): OHLCConfig {
+ return this.config;
}
- 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,
- };
+ public getLegendViewModel(): Observable<LegendModel> {
+ return this.model$;
}
- return null;
+ public destroy = () => {
+ this.model$.complete();
+ this.tooltipVisability.complete();
+ this.tooltipPos.complete();
+ };
}
diff --git a/src/core/MoexChart.tsx b/src/core/MoexChart.tsx
index 98d38d2391a724d34529e0ae37bf32b078937e32..efea78409cd9f167355002fe08310b4502431fdd 100644
--- a/src/core/MoexChart.tsx
+++ b/src/core/MoexChart.tsx
@@ -4,18 +4,12 @@ import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
-import { LegendComponent } from '@components/Legend';
import { DataSource, DataSourceParams } from '@core/DataSource';
-import { Legend } from '@core/Legend';
import { ModalRenderer } from '@core/ModalRenderer';
-import { TooltipService } from '@core/Tooltip';
-
-import { ChartTooltip } from '@src/components/ChartTooltip';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { CompareManager } from '@src/core/CompareManager';
-import { DrawingsNames } from '@src/core/DrawingsManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { IndicatorsIds } from '@src/core/Indicators';
@@ -30,7 +24,6 @@ import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
-import { HoverController } from './HoverController';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
@@ -94,23 +87,18 @@ export class MoexChart {
private chart: Chart;
private resizeObserver?: ResizeObserver;
private eventManager: EventManager;
- private legend: Legend;
private rootContainer: HTMLElement;
private headerRenderer: UIRenderer;
- private legendRenderer: UIRenderer;
private modalRenderer: ModalRenderer;
- private tooltipRenderer: UIRenderer | undefined;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
- private hoverController: HoverController;
private timeScaleHoverController: TimeScaleHoverController;
private dataSource: DataSource;
private subscriptions = new Subscription();
- private tooltip: TooltipService | undefined;
private fullscreen: FullscreenController;
@@ -138,16 +126,6 @@ export class MoexChart {
eventManager: this.eventManager,
});
- this.legend = new Legend({
- config: config.ohlc,
- dataSource: this.dataSource,
- eventManager: this.eventManager,
- });
-
- if (config.tooltipConfig) {
- this.tooltip = new TooltipService({ config: config.tooltipConfig, legend: this.legend });
- }
-
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
@@ -158,9 +136,7 @@ export class MoexChart {
chartAreaContainer,
toolBarContainer,
headerContainer,
- legendContainer,
modalContainer,
- overlayContainer,
controlBarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarRenderer
@@ -181,47 +157,20 @@ export class MoexChart {
seriesTypes: config.supportedChartSeriesTypes,
dataSource: this.dataSource,
chartOptions: config.chartOptions, // todo: remove, use only model from eventManager
- });
-
- /*
- Внутри lightweight-chart DOM построен как таблица из 3 td
- [0] left priceScale, [1] center chart, [2] right priceScale
- Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
- */
- requestAnimationFrame(() => {
- const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
- const table = root?.querySelector('table');
- const centerId = table?.getElementsByTagName('td')?.[1];
-
- if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
- centerId.appendChild(legendContainer);
- }
+ ohlcConfig: config.ohlc, // todo: omptimize
+ tooltipConfig: config.tooltipConfig ?? {},
});
this.subscriptions.add(
- combineLatest([store.theme$, store.mode$]).subscribe(([t, m]) => {
- this.chart.updateTheme(t, m);
+ combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
+ this.chart.updateTheme(theme, mode);
- document.documentElement.dataset.theme = t;
- document.documentElement.dataset.mode = m;
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.dataset.mode = mode;
}),
);
- this.subscriptions.add(
- this.chart
- .getDrawingsManager()
- .entities()
- .subscribe((drawings) => {
- const hasRuler = drawings.some((drawing) => drawing.id.startsWith(DrawingsNames.ruler));
- legendContainer.style.display = hasRuler ? 'none' : '';
- }),
- );
-
this.headerRenderer = new ReactRenderer(headerContainer);
- this.legendRenderer = new ReactRenderer(legendContainer);
- if (this.tooltip) {
- this.tooltipRenderer = new ReactRenderer(overlayContainer);
- }
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
if (config.showControlBar) {
@@ -232,13 +181,6 @@ export class MoexChart {
this.footerRenderer = new ReactRenderer(footerContainer);
}
- if (this.tooltip) {
- this.tooltip.setViewport({
- width: this.rootContainer.clientWidth,
- height: this.rootContainer.clientHeight,
- });
- }
-
this.headerRenderer.renderComponent(
<Header
timeframes={config.supportedTimeframes}
@@ -279,46 +221,18 @@ export class MoexChart {
/>,
);
- this.hoverController = new HoverController({
- legend: this.legend,
- tooltip: this.tooltip,
- chartEventSub: this.chart.subscribeChartEvent,
- chartEventUnsub: this.chart.unsubscribeChartEvent,
- mainSeriesObs: this.chart.getMainSeries(),
- compareManager: this.chart.getCompareManager(),
- });
-
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
- this.legendRenderer.renderComponent(
- <LegendComponent
- ohlcConfig={this.legend.getConfig()}
- viewModel={this.legend.getLegendViewModel()}
- onCompareRemove={(symbol, mode) => this.chart.getCompareManager().removeSymbolMode(symbol, mode)}
- />,
- );
-
- if (this.tooltipRenderer && this.tooltip) {
- this.tooltipRenderer.renderComponent(
- <ChartTooltip
- formatObs={this.eventManager.getChartOptionsModel()}
- timeframeObs={this.eventManager.getTimeframeObs()}
- viewModel={this.tooltip.getTooltipViewModel()}
- ohlcConfig={this.legend.getConfig()}
- tooltipConfig={this.tooltip.getConfig()}
- />,
- );
- }
-
if (config.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={(name) => {
+ // todo: deal with new panes logic
this.chart.getDrawingsManager().addDrawing(name);
}}
/>,
@@ -345,10 +259,6 @@ export class MoexChart {
/>,
);
}
-
- if (!config.size) {
- this.setupAutoResize(chartAreaContainer);
- }
}
public setSettings(settings: ChartSettingsSource): void {
@@ -363,23 +273,6 @@ export class MoexChart {
return this.chart.getRealtimeApi();
}
- /**
- * Настройка автоматического изменения размера
- */
- private setupAutoResize(chartAreaContainer: HTMLDivElement): void {
- if (typeof ResizeObserver !== 'undefined') {
- this.resizeObserver = new ResizeObserver(() => {
- const { width, height } = chartAreaContainer.getBoundingClientRect();
-
- this.tooltip?.setViewport({
- width: Math.floor(width),
- height: Math.floor(height),
- });
- });
- this.resizeObserver.observe(chartAreaContainer);
- }
- }
-
// todo: описать в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
@@ -396,11 +289,7 @@ export class MoexChart {
* @returns void
*/
destroy(): void {
- this.tooltip?.destroy();
- this.legend.destroy();
this.headerRenderer.destroy();
- this.legendRenderer.destroy();
- this.hoverController.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
diff --git a/src/core/Pane.tsx b/src/core/Pane.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ef094f89d7fb5bcc63ae85b8f80e060c17a4499a
--- /dev/null
+++ b/src/core/Pane.tsx
@@ -0,0 +1,274 @@
+import { IChartApi, IPaneApi, Time } from 'lightweight-charts';
+
+import { BehaviorSubject, Subscription } from 'rxjs';
+
+import { ChartTooltip } from '@components/ChartTooltip';
+import { LegendComponent } from '@components/Legend';
+import { ChartMouseEvents } from '@core/ChartMouseEvents';
+import { ContainerManager } from '@core/ContainerManager';
+import { DataSource } from '@core/DataSource';
+import { DOMModel } from '@core/DOMModel';
+import { DrawingsManager, DrawingsNames } from '@core/DrawingsManager';
+import { EventManager } from '@core/EventManager';
+import { Indicator } from '@core/Indicator';
+import { IndicatorsIds } from '@core/Indicators';
+import { Legend } from '@core/Legend';
+import { ReactRenderer } from '@core/ReactRenderer';
+import { TooltipService } from '@core/Tooltip';
+import { UIRenderer } from '@core/UIRenderer';
+import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
+import { OHLCConfig, TooltipConfig } from '@src/types';
+import { ensureDefined } from '@src/utils';
+
+export interface PaneParams {
+ id: number;
+ lwcChart: IChartApi;
+ eventManager: EventManager;
+ DOM: DOMModel;
+ isMainPane: boolean;
+ ohlcConfig: OHLCConfig;
+ dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
+ basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
+ subscribeChartEvent: ChartMouseEvents['subscribe'];
+ tooltipConfig: TooltipConfig;
+ onDelete: () => void;
+ chartContainer: HTMLElement;
+}
+
+// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
+// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
+// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
+// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
+// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
+
+export class Pane {
+ private id: number;
+ private isMain: boolean;
+ private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
+ private legend!: Legend;
+ private tooltip: TooltipService | undefined;
+
+ private indicatorsMap: BehaviorSubject<Map<IndicatorsIds, Indicator>> = new BehaviorSubject<
+ Map<IndicatorsIds, Indicator>
+ >(new Map());
+
+ private lwcPane: IPaneApi<Time>;
+ private lwcChart: IChartApi;
+
+ private eventManager: EventManager;
+ private drawingsManager: DrawingsManager;
+
+ private legendContainer!: HTMLElement;
+ private paneOverlayContainer!: HTMLElement;
+ private legendRenderer!: UIRenderer;
+ private tooltipRenderer: UIRenderer | undefined;
+
+ private mainSerieSub!: Subscription;
+ private subscribeChartEvent: ChartMouseEvents['subscribe'];
+ private onDelete: () => void;
+ private subscriptions = new Subscription();
+
+ constructor({
+ lwcChart,
+ eventManager,
+ dataSource,
+ DOM,
+ isMainPane,
+ ohlcConfig,
+ id,
+ basedOn,
+ subscribeChartEvent,
+ tooltipConfig,
+ onDelete,
+ chartContainer,
+ }: PaneParams) {
+ this.onDelete = onDelete;
+ this.eventManager = eventManager;
+ this.lwcChart = lwcChart;
+ this.subscribeChartEvent = subscribeChartEvent;
+ this.isMain = isMainPane ?? false;
+ this.id = id;
+
+ this.initializeLegend({ ohlcConfig });
+
+ if (isMainPane) {
+ this.lwcPane = this.lwcChart.panes()[this.id];
+ } else {
+ this.lwcPane = this.lwcChart.addPane(true);
+ }
+
+ this.tooltip = new TooltipService({
+ config: tooltipConfig,
+ legend: this.legend,
+ paneOverlayContainer: this.paneOverlayContainer,
+ });
+
+ this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
+
+ this.tooltipRenderer.renderComponent(
+ <ChartTooltip
+ formatObs={this.eventManager.getChartOptionsModel()}
+ timeframeObs={this.eventManager.getTimeframeObs()}
+ viewModel={this.tooltip.getTooltipViewModel()}
+ // ohlcConfig={this.legend.getConfig()}
+ ohlcConfig={ohlcConfig}
+ tooltipConfig={this.tooltip.getConfig()}
+ />,
+ );
+
+ if (dataSource) {
+ this.initializeMainSerie({ lwcChart, dataSource });
+ } else if (basedOn) {
+ this.mainSeries = basedOn?.getMainSerie();
+ } else {
+ console.error('[Pane]: There is no any mainSerie for new pane');
+ }
+
+ this.drawingsManager = new DrawingsManager({
+ // todo: менеджер дровингов должен быть один на чарт, не на пейн
+ eventManager,
+ DOM,
+ mainSeries$: this.mainSeries.asObservable(),
+ lwcChart,
+ container: chartContainer,
+ });
+
+ this.subscriptions.add(
+ // todo: переедет в пейн
+ this.drawingsManager.entities().subscribe((drawings) => {
+ const hasRuler = drawings.some((drawing) => drawing.id.startsWith(DrawingsNames.ruler));
+ this.legendContainer.style.display = hasRuler ? 'none' : '';
+ }),
+ );
+ }
+
+ public getMainSerie = () => {
+ return this.mainSeries;
+ };
+
+ public getId = () => {
+ return this.id;
+ };
+
+ public setIndicator(indicatorName: IndicatorsIds, indicator: Indicator): void {
+ const map = this.indicatorsMap.value;
+
+ map.set(indicatorName, indicator);
+
+ this.indicatorsMap.next(map);
+ }
+
+ public removeIndicator(indicatorName: IndicatorsIds): void {
+ const map = this.indicatorsMap.value;
+
+ map.delete(indicatorName);
+
+ this.indicatorsMap.next(map);
+
+ if (map.size === 0 && !this.isMain) {
+ this.destroy();
+ }
+ }
+
+ public getDrawingManager(): DrawingsManager {
+ return this.drawingsManager;
+ }
+
+ private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }) {
+ const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
+ this.legendContainer = legendContainer;
+ this.paneOverlayContainer = paneOverlayContainer;
+ this.legendRenderer = new ReactRenderer(legendContainer);
+
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ const lwcPaneElement = this.lwcPane.getHTMLElement();
+ if (!lwcPaneElement) return;
+ lwcPaneElement.style.position = 'relative';
+ lwcPaneElement.appendChild(legendContainer);
+ lwcPaneElement.appendChild(paneOverlayContainer);
+ }, 0);
+ });
+
+ // todo: переписать код ниже под логику пейнов
+ // /*
+ // Внутри lightweight-chart DOM построен как таблица из 3 td
+ // [0] left priceScale, [1] center chart, [2] right priceScale
+ // Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
+ // */
+ // requestAnimationFrame(() => {
+ // const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
+ // console.log(root)
+ // const table = root?.querySelector('table');
+ // console.log(table)
+ //
+ // const htmlCollectionOfPanes = table?.getElementsByTagName('td')
+ // console.log(htmlCollectionOfPanes)
+ //
+ // const centerId = htmlCollectionOfPanes?.[1];
+ // console.log(centerId)
+ //
+ // if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
+ // centerId.appendChild(legendContainer);
+ // }
+ // });
+ // /*
+ // Внутри lightweight-chart DOM построен как таблица из 3 td
+ // [0] left priceScale, [1] center chart, [2] right priceScale
+ // Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
+ // */
+ // requestAnimationFrame(() => {
+ // const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
+ // const table = root?.querySelector('table');
+ // const centerId = table?.getElementsByTagName('td')?.[1];
+ //
+ // if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
+ // centerId.appendChild(legendContainer);
+ // }
+ // });
+
+ this.legend = new Legend({
+ config: ohlcConfig,
+ indicators: this.indicatorsMap,
+ eventManager: this.eventManager,
+ subscribeChartEvent: this.subscribeChartEvent,
+ mainSeries: this.isMain ? this.mainSeries : null,
+ paneId: this.id,
+ // todo: throw isMainPane
+ });
+
+ this.legendRenderer.renderComponent(
+ <LegendComponent
+ ohlcConfig={this.legend.getConfig()}
+ viewModel={this.legend.getLegendViewModel()}
+ />,
+ );
+ }
+
+ private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }) {
+ this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
+ this.mainSeries.value?.destroy();
+
+ const next = ensureDefined(SeriesFactory.create(nextSeries))({
+ lwcChart,
+ dataSource,
+ mainSymbol$: this.eventManager.getSymbol(),
+ mainSerie$: this.mainSeries,
+ });
+
+ this.mainSeries.next(next);
+ });
+ }
+
+ public destroy() {
+ this.subscriptions.unsubscribe();
+ this.tooltip?.destroy();
+ this.legend.destroy();
+ this.legendRenderer.destroy();
+
+ this.mainSerieSub?.unsubscribe();
+ this.lwcChart.removePane(this.id);
+
+ this.onDelete();
+ }
+}
diff --git a/src/core/PaneManager.ts b/src/core/PaneManager.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1721a6bdddeed149c420d5bf623d4b0849762d2a
--- /dev/null
+++ b/src/core/PaneManager.ts
@@ -0,0 +1,56 @@
+import { DataSource } from '@core/DataSource';
+import { DrawingsManager } from '@core/DrawingsManager';
+import { Pane, PaneParams } from '@core/Pane';
+
+type PaneManagerParams = Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'>;
+
+// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
+// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
+// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
+// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
+// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
+
+export class PaneManager {
+ private mainPane: Pane;
+ private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
+ private panesMap: Map<number, Pane> = new Map<number, Pane>();
+
+ constructor(params: PaneManagerParams) {
+ this.paneChartInheritedParams = { ...params, isMainPane: false };
+
+ this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
+
+ this.panesMap.set(0, this.mainPane);
+ }
+
+ public getPanes() {
+ return this.panesMap;
+ }
+
+ public getMainPane: () => Pane = () => {
+ return this.mainPane;
+ };
+
+ public addPane(dataSource?: DataSource): Pane {
+ const panesOverallCount = this.panesMap.size;
+
+ const pane = new Pane({
+ ...this.paneChartInheritedParams,
+ id: panesOverallCount,
+ dataSource: dataSource ?? null,
+ basedOn: dataSource ? undefined : this.mainPane,
+ onDelete: () => {
+ this.panesMap.delete(panesOverallCount);
+ },
+ });
+
+ this.panesMap.set(panesOverallCount, pane);
+
+ return pane;
+ }
+
+ public getDrawingsManager(): DrawingsManager {
+ // todo: temp
+ return this.mainPane.getDrawingManager();
+ }
+}
diff --git a/src/core/Series/BarSeriesStrategy.ts b/src/core/Series/BarSeriesStrategy.ts
index 1bba182c8158f7a746624594a78fbc3746f24685..a86f493fb44ec4e519912744e23dd8f506038f75 100644
--- a/src/core/Series/BarSeriesStrategy.ts
+++ b/src/core/Series/BarSeriesStrategy.ts
@@ -1,9 +1,22 @@
-import { BarSeries, SeriesDataItemTypeMap, SeriesDefinition, SeriesPartialOptionsMap, Time } from 'lightweight-charts';
+import {
+ BarData,
+ BarSeries,
+ CustomData,
+ HistogramData,
+ LineData,
+ SeriesDataItemTypeMap,
+ SeriesDefinition,
+ SeriesPartialOptionsMap,
+ Time,
+} from 'lightweight-charts';
-import { BaseSeries, BaseSeriesParams } from '@src/core/Series/BaseSeries';
+import { Ohlc } from '@core/Legend';
+
+import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@src/core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme';
import { Candle, LineCandle } from '@src/types';
+import { ensureDefined, formatPrice, isBarData } from '@src/utils';
export class BarSeriesStrategy extends BaseSeries<'Bar'> implements ISeries<'Bar'> {
constructor(params: BaseSeriesParams<'Bar'>) {
@@ -72,4 +85,55 @@ export class BarSeriesStrategy extends BaseSeries<'Bar'> implements ISeries<'Bar
customValues: point as unknown as Record<string, unknown>,
}));
}
+
+ protected 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 }>> {
+ if (!currentBar || !isBarData(currentBar)) {
+ return {};
+ }
+
+ const color = currentBar.close < currentBar.open ? 'red' : 'green';
+
+ const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
+
+ return {
+ open: {
+ value: formatPrice(currentBar.open) ?? '',
+ name: 'Откр.',
+ color,
+ },
+ high: {
+ value: formatPrice(currentBar.high) ?? '',
+ name: 'Макс.',
+ color,
+ },
+ low: {
+ value: formatPrice(currentBar.low) ?? '',
+ name: 'Мин.',
+ color,
+ },
+ close: {
+ value: formatPrice(currentBar.close) ?? '',
+ name: 'Закр.',
+ color,
+ },
+ absoluteChange: {
+ value: `${formatPrice(absoluteChange)}%`,
+ name: 'Изм.',
+ color,
+ },
+ percentageChange: {
+ value: `${formatPrice(percentageChange)}%`,
+ name: 'Изм.',
+ color,
+ },
+ time: {
+ value: time,
+ name: 'Время',
+ color,
+ },
+ };
+ }
}
diff --git a/src/core/Series/BaseSeries.ts b/src/core/Series/BaseSeries.ts
index 13dd5331aba35ad48c398ef042f8e2f6971204de..5d7b10ae7ffb6eb57cabd539a110ebd4d54cc6af 100644
--- a/src/core/Series/BaseSeries.ts
+++ b/src/core/Series/BaseSeries.ts
@@ -1,10 +1,13 @@
import {
+ BarData,
BarPrice,
BarsInfo,
Coordinate,
CreatePriceLineOptions,
+ CustomData,
DataChangedHandler,
DeepPartial,
+ HistogramData,
IChartApi,
IPaneApi,
IPriceFormatter,
@@ -13,7 +16,9 @@ import {
IRange,
ISeriesApi,
ISeriesPrimitive,
+ LineData,
MismatchDirection,
+ MouseEventParams,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesDefinition,
@@ -28,11 +33,12 @@ import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from
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, ChartTypeOptions, Direction } from '@src/types';
-import { formatPrice, getPricePrecisionStep, normalizeSeriesData } from '@src/utils';
+import { Candle, Direction } from '@src/types';
+import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';
export interface SerieData {
time: Time;
@@ -48,6 +54,9 @@ export interface CreateSeriesParams<TSeries extends SeriesType> {
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> {
@@ -56,7 +65,6 @@ export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
mainSymbol$: Observable<string>;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
- chartOptions?: ChartTypeOptions;
seriesOptions?: SeriesPartialOptionsMap[TSeries];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
showSymbolLabel?: boolean;
@@ -100,7 +108,6 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
mainSerie$,
customFormatter,
seriesOptions,
- chartOptions,
priceScaleOptions,
showSymbolLabel = true,
paneIndex,
@@ -108,7 +115,7 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
}: BaseSeriesParams<TSeries>) {
this.lwcSeries = this.createSeries({
chart: lwcChart,
- seriesOptions: { ...chartOptions, ...seriesOptions } as SeriesPartialOptionsMap[TSeries],
+ seriesOptions,
paneIndex,
priceScaleOptions,
});
@@ -120,6 +127,24 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
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 });
}
@@ -128,6 +153,10 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
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();
@@ -275,6 +304,10 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
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);
@@ -336,3 +369,44 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
);
};
}
+
+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;
+}
diff --git a/src/core/Series/CandlestickSeriesStrategy.ts b/src/core/Series/CandlestickSeriesStrategy.ts
index eb3e36e1dfb5c0d6281498c4cd1de59e4320c28f..6e102ce7761c94c10e63af76fb8546185b2544d6 100644
--- a/src/core/Series/CandlestickSeriesStrategy.ts
+++ b/src/core/Series/CandlestickSeriesStrategy.ts
@@ -1,17 +1,24 @@
import {
+ BarData,
CandlestickSeries,
+ CustomData,
+ HistogramData,
+ LineData,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesPartialOptionsMap,
Time,
} from 'lightweight-charts';
-import { BaseSeries, BaseSeriesParams } from '@core/Series/BaseSeries';
+import { Ohlc } from '@core/Legend';
+
+import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme/store';
import { Candle, LineCandle } from '@src/types';
+import { ensureDefined, formatPrice, isBarData } from '@src/utils';
export class CandlestickSeriesStrategy extends BaseSeries<'Candlestick'> implements ISeries<'Candlestick'> {
constructor(params: BaseSeriesParams<'Candlestick'>) {
@@ -92,4 +99,55 @@ export class CandlestickSeriesStrategy extends BaseSeries<'Candlestick'> impleme
customValues: point as unknown as Record<string, unknown>,
}));
}
+
+ protected 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 }>> {
+ if (!currentBar || !isBarData(currentBar)) {
+ return {};
+ }
+
+ const color = currentBar.close < currentBar.open ? 'red' : 'green';
+
+ const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
+
+ return {
+ open: {
+ value: formatPrice(currentBar.open) ?? '',
+ name: 'Откр.',
+ color,
+ },
+ high: {
+ value: formatPrice(currentBar.high) ?? '',
+ name: 'Макс.',
+ color,
+ },
+ low: {
+ value: formatPrice(currentBar.low) ?? '',
+ name: 'Мин.',
+ color,
+ },
+ close: {
+ value: formatPrice(currentBar.close) ?? '',
+ name: 'Закр.',
+ color,
+ },
+ absoluteChange: {
+ value: `${formatPrice(absoluteChange)}%`,
+ name: 'Изм.',
+ color,
+ },
+ percentageChange: {
+ value: `${formatPrice(percentageChange)}%`,
+ name: 'Изм.',
+ color,
+ },
+ time: {
+ value: time,
+ name: 'Время',
+ color,
+ },
+ };
+ }
}
diff --git a/src/core/Series/HistogramSeriesStrategy.ts b/src/core/Series/HistogramSeriesStrategy.ts
index 601407b900a37dc6fd3cb2958394fd9af23a5e05..cec33581edce7c6312923dd0988909b2e6d465d3 100644
--- a/src/core/Series/HistogramSeriesStrategy.ts
+++ b/src/core/Series/HistogramSeriesStrategy.ts
@@ -1,15 +1,22 @@
import {
+ BarData,
+ CustomData,
+ HistogramData,
HistogramSeries,
+ LineData,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesPartialOptionsMap,
Time,
} from 'lightweight-charts';
-import { BaseSeries, BaseSeriesParams } from '@core/Series/BaseSeries';
+import { Ohlc } from '@core/Legend';
+
+import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { Candle, LineCandle } from '@src/types';
+import { ensureDefined, formatPrice, isHistogramData } from '@src/utils';
export class HistogramSeriesStrategy extends BaseSeries<'Histogram'> implements ISeries<'Histogram'> {
constructor(params: BaseSeriesParams<'Histogram'>) {
@@ -65,4 +72,38 @@ export class HistogramSeriesStrategy extends BaseSeries<'Histogram'> implements
customValues: point as unknown as Record<string, unknown>,
}));
}
+
+ protected 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 }>> {
+ if (!currentBar || !isHistogramData(currentBar)) {
+ return {};
+ }
+
+ const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
+
+ return {
+ value: {
+ value: formatPrice(currentBar.value) ?? '',
+ name: '',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ absoluteChange: {
+ value: `${formatPrice(absoluteChange)}%`,
+ name: 'Изм.',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ percentageChange: {
+ value: `${formatPrice(percentageChange)}%`,
+ name: 'Изм.',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ time: {
+ value: time,
+ name: 'Время',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ };
+ }
}
diff --git a/src/core/Series/LineSeriesStrategy.ts b/src/core/Series/LineSeriesStrategy.ts
index 7596d25e21e5cfb74573426b887af5feefbf4c1b..c512e4c60a8e6c01da5aef1f235a395fb1c1fdb7 100644
--- a/src/core/Series/LineSeriesStrategy.ts
+++ b/src/core/Series/LineSeriesStrategy.ts
@@ -1,4 +1,8 @@
import {
+ BarData,
+ CustomData,
+ HistogramData,
+ LineData,
LineSeries,
LineStyle,
SeriesDataItemTypeMap,
@@ -7,12 +11,15 @@ import {
Time,
} from 'lightweight-charts';
-import { BaseSeries, BaseSeriesParams } from '@core/Series/BaseSeries';
+import { Ohlc } from '@core/Legend';
+
+import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme/store';
import { Candle, LineCandle } from '@src/types';
+import { ensureDefined, formatPrice, isLineData } from '@src/utils';
export class LineSeriesStrategy extends BaseSeries<'Line'> implements ISeries<'Line'> {
constructor(params: BaseSeriesParams<'Line'>) {
@@ -74,4 +81,38 @@ export class LineSeriesStrategy extends BaseSeries<'Line'> implements ISeries<'L
customValues: point as unknown as Record<string, unknown>,
}));
}
+
+ protected 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 }>> {
+ if (!currentBar || !isLineData(currentBar)) {
+ return {};
+ }
+
+ const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
+
+ return {
+ value: {
+ value: formatPrice(currentBar.value) ?? '',
+ name: '',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ absoluteChange: {
+ value: `${formatPrice(absoluteChange)}%`,
+ name: 'Изм.',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ percentageChange: {
+ value: `${formatPrice(percentageChange)}%`,
+ name: 'Изм.',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ time: {
+ value: time,
+ name: 'Время',
+ color: currentBar.color?.slice(0, -2) ?? this.options().color,
+ },
+ };
+ }
}
diff --git a/src/core/SeriesObject.ts b/src/core/SeriesObject.ts
deleted file mode 100644
index 99f9aef479585e7c378e4083cd7b0b60379436c4..0000000000000000000000000000000000000000
--- a/src/core/SeriesObject.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { IChartApi, SeriesPartialOptionsMap, SeriesType } from 'lightweight-charts';
-import { BehaviorSubject, Observable } from 'rxjs';
-
-import { DataSource } from '@core/DataSource';
-import { DOMObject, DOMObjectParams } from '@core/DOMObject';
-
-import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
-
-interface SeriesObjectParams extends DOMObjectParams {
- lwcChart: IChartApi;
- dataSource: DataSource;
- symbol$: Observable<string>;
- mainSerie$: BehaviorSubject<SeriesStrategies | null>;
- seriesType: SeriesType;
- seriesOptions?: SeriesPartialOptionsMap[SeriesType];
- customFormatter?: any;
- showSymbolLabel?: boolean;
- paneIndex?: number;
-}
-
-export class SeriesObject extends DOMObject {
- protected strategy: SeriesStrategies;
-
- constructor({
- id,
- zIndex,
- onDelete,
- moveUp,
- moveDown,
- lwcChart,
- dataSource,
- symbol$,
- mainSerie$,
- seriesType,
- seriesOptions,
- customFormatter,
- showSymbolLabel,
- paneIndex,
- }: SeriesObjectParams) {
- super({ id, zIndex, onDelete, moveUp, moveDown });
-
- this.strategy = SeriesFactory.create(seriesType)({
- lwcChart,
- dataSource,
- mainSymbol$: symbol$,
- mainSerie$,
- customFormatter,
- seriesOptions,
- showSymbolLabel,
- paneIndex,
- });
- }
-
- public getStrategy(): SeriesStrategies {
- return this.strategy;
- }
-
- public show(): void {
- this.strategy.show();
- super.show();
- }
-
- public hide(): void {
- this.strategy.hide();
- super.hide();
- }
-
- public destroy(): void {
- this.strategy.destroy();
- }
-}
diff --git a/src/core/Tooltip.ts b/src/core/Tooltip.ts
index 3278667b1eccf31162ca11b889e52ae7596e5473..abe917b95cc6d243b78f274f9d6708f707bd76d2 100644
--- a/src/core/Tooltip.ts
+++ b/src/core/Tooltip.ts
@@ -10,6 +10,7 @@ import { Point, Size } from '@src/utils';
interface TooltipServiceParams {
config: TooltipConfig | undefined;
legend: Legend;
+ paneOverlayContainer: HTMLElement;
}
export interface TooltipVM extends LegendVM {
@@ -34,9 +35,25 @@ export class TooltipService {
private config: TooltipConfig;
- constructor({ config, legend }: TooltipServiceParams) {
+ private resizeObserver?: ResizeObserver;
+
+ constructor({ config, legend, paneOverlayContainer }: TooltipServiceParams) {
this.legend = legend;
+
+ this.setViewport({
+ width: paneOverlayContainer.clientWidth,
+ height: paneOverlayContainer.clientHeight,
+ });
+
+ this.legend.subscribeCursorPosition((point) => {
+ this.setPosition(point ?? { x: 0, y: 0 });
+ });
+ this.legend.subscribeCursorVisability((isVisible) => {
+ this.setVisible(isVisible);
+ });
this.config = merge(DEFAULT_TOOLTIPS_CONFIG, config);
+
+ this.setupAutoResize(paneOverlayContainer);
}
public getConfig(): TooltipConfig {
@@ -50,12 +67,14 @@ export class TooltipService {
this.getViewport(),
this.legend.getLegendViewModel(),
]).pipe(
- map(([hoverVisible, position, viewport, legendVM]) => ({
- ...legendVM,
- visible: Boolean(this.config.showTooltip) && hoverVisible,
- position,
- viewport,
- })),
+ map(([hoverVisible, position, viewport, legendVM]) => {
+ return {
+ vm: legendVM,
+ visible: Boolean(this.config.showTooltip) && hoverVisible,
+ position,
+ viewport,
+ };
+ }),
);
}
@@ -63,11 +82,25 @@ export class TooltipService {
this.visible$.next(value);
}
- public setPosition(pos: { x: number; y: number }): void {
+ private setPosition(pos: { x: number; y: number }): void {
this.position$.next(pos);
}
- public setViewport(viewport: { width: number; height: number }): void {
+ private setupAutoResize(paneContainer: HTMLElement): void {
+ if (typeof ResizeObserver !== 'undefined') {
+ this.resizeObserver = new ResizeObserver(() => {
+ const { width, height } = paneContainer.getBoundingClientRect();
+
+ this.setViewport({
+ width: Math.floor(width),
+ height: Math.floor(height),
+ });
+ });
+ this.resizeObserver.observe(paneContainer);
+ }
+ }
+
+ private setViewport(viewport: { width: number; height: number }): void {
this.viewport$.next(viewport);
}
@@ -87,5 +120,10 @@ export class TooltipService {
this.visible$.complete();
this.position$.complete();
this.viewport$.complete();
+
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect();
+ this.resizeObserver = undefined;
+ }
}
}
diff --git a/src/modules/series-strategies/ISeries.ts b/src/modules/series-strategies/ISeries.ts
index 564b4e1f6866278fbabec814c289cad2f6fad800..cf559ca1a4b3a6a808b279bf8941ba35897f14f7 100644
--- a/src/modules/series-strategies/ISeries.ts
+++ b/src/modules/series-strategies/ISeries.ts
@@ -7,6 +7,8 @@ import { Candle, Destroyable, LineCandle } from '../../types/chart';
export interface ISeries<TSeries extends SeriesType> extends IBaseSeries<TSeries>, Destroyable {
show(): void;
+ isVisible(): boolean;
+
hide(): void;
validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean;
diff --git a/src/utils/typeGuards.ts b/src/utils/typeGuards.ts
index 7c205eea0d202bdbfb443d3357d114f2b1cf81be..a95c4ea082fece10d33c16d507c9c1deecc0e9fd 100644
--- a/src/utils/typeGuards.ts
+++ b/src/utils/typeGuards.ts
@@ -1,9 +1,18 @@
import { BarData, CustomData, HistogramData, LineData } from 'lightweight-charts';
export function isBarData(value: BarData | LineData | HistogramData | CustomData): value is BarData {
- return (value as BarData).close !== undefined;
+ return (
+ (value as BarData).open !== undefined &&
+ (value as BarData).high !== undefined &&
+ (value as BarData).low !== undefined &&
+ (value as BarData).close !== undefined
+ );
}
-export function isLineData(value: BarData | LineData | HistogramData | CustomData): value is LineData | HistogramData {
+export function isLineData(value: BarData | LineData | HistogramData | CustomData): value is LineData {
+ return (value as LineData | HistogramData).value !== undefined;
+}
+
+export function isHistogramData(value: BarData | LineData | HistogramData | CustomData): value is HistogramData {
return (value as LineData | HistogramData).value !== undefined;
}
diff --git a/stories/MXT/MXT.stories.tsx b/stories/MXT/MXT.stories.tsx
index d8e153216b5ab1377638b83e570b8b396bccf1d3..65d5688ec88b4228d78ce0d3c6bcd3556cfb65d0 100644
--- a/stories/MXT/MXT.stories.tsx
+++ b/stories/MXT/MXT.stories.tsx
@@ -89,7 +89,7 @@ const args: MXTProps = {
getDataSource: dataSourceProvider.generateCandles.bind(dataSourceProvider),
theme: 'mxt',
ohlc: {
- show: true,
+ show: false,
showVolume: true,
precision: 6,
},
diff --git a/stories/TradeRadar/TradeRadar.stories.tsx b/stories/TradeRadar/TradeRadar.stories.tsx
index a8c1fb8b64226338ab15c4fe9fcace52067c8711..c40b902e84a0c1ac7d1d5b4b354cb5affdc510ae 100644
--- a/stories/TradeRadar/TradeRadar.stories.tsx
+++ b/stories/TradeRadar/TradeRadar.stories.tsx
@@ -128,7 +128,7 @@ const args: TRProps = {
showSettingsButton: true,
showControlBar: true,
tooltipConfig: {
- showTooltip: false,
+ showTooltip: true,
time: { visible: true, label: 'Время' },
close: { visible: true, label: 'Закр.' },
change: { visible: true, label: 'Изм.' },