Загрузка данных
diff --git a/src/components/Menu/TimeframesMenu/constants.ts b/src/components/Menu/TimeframesMenu/constants.ts
index 062b13e..47ec3f1 100644
--- a/src/components/Menu/TimeframesMenu/constants.ts
+++ b/src/components/Menu/TimeframesMenu/constants.ts
@@ -1,96 +1,54 @@
-import { t } from '@src/translations';
import { Timeframes } from '@src/types/timeframes';
+import { formatTimeframeLabel, TimeframeUnit } from '@src/utils';
+
+const timeframeOption = (value: Timeframes, amount: number, unit: TimeframeUnit) => ({
+ value,
+ label: formatTimeframeLabel(amount, unit),
+});
export const timeframes = () => {
return {
ticks: [
- { value: Timeframes['1t'], label: `1 ${t('tick')}` },
- { value: Timeframes['10t'], label: `10 ${t('ticks')}` },
- { value: Timeframes['100t'], label: `100 ${t('ticks')}` },
- { value: Timeframes['1000t'], label: `1000 ${t('ticks')}` },
+ timeframeOption(Timeframes['1t'], 1, 'tick'),
+ timeframeOption(Timeframes['10t'], 10, 'tick'),
+ timeframeOption(Timeframes['100t'], 100, 'tick'),
+ timeframeOption(Timeframes['1000t'], 1000, 'tick'),
],
seconds: [
- { value: Timeframes['1s'], label: `1 ${t('second')}` },
- { value: Timeframes['5s'], label: `5 ${t('seconds')}` },
- { value: Timeframes['10s'], label: `10 ${t('seconds')}` },
- { value: Timeframes['15s'], label: `15 ${t('seconds')}` },
- { value: Timeframes['30s'], label: `30 ${t('seconds')}` },
- { value: Timeframes['45s'], label: `45 ${t('seconds')}` },
+ timeframeOption(Timeframes['1s'], 1, 'second'),
+ timeframeOption(Timeframes['5s'], 5, 'second'),
+ timeframeOption(Timeframes['10s'], 10, 'second'),
+ timeframeOption(Timeframes['15s'], 15, 'second'),
+ timeframeOption(Timeframes['30s'], 30, 'second'),
+ timeframeOption(Timeframes['45s'], 45, 'second'),
],
minutes: [
- { value: Timeframes['1m'], label: `1 ${t('minute')}` },
- { value: Timeframes['2m'], label: `2 ${t('minutes')}` },
- { value: Timeframes['3m'], label: `3 ${t('minutes')}` },
- { value: Timeframes['5m'], label: `5 ${t('minutes')}` },
- { value: Timeframes['10m'], label: `10 ${t('minutes')}` },
- { value: Timeframes['15m'], label: `15 ${t('minutes')}` },
- { value: Timeframes['30m'], label: `30 ${t('minutes')}` },
- { value: Timeframes['45m'], label: `45 ${t('minutes')}` },
+ timeframeOption(Timeframes['1m'], 1, 'minute'),
+ timeframeOption(Timeframes['2m'], 2, 'minute'),
+ timeframeOption(Timeframes['3m'], 3, 'minute'),
+ timeframeOption(Timeframes['5m'], 5, 'minute'),
+ timeframeOption(Timeframes['10m'], 10, 'minute'),
+ timeframeOption(Timeframes['15m'], 15, 'minute'),
+ timeframeOption(Timeframes['30m'], 30, 'minute'),
+ timeframeOption(Timeframes['45m'], 45, 'minute'),
],
hours: [
- { value: Timeframes['1h'], label: `1 ${t('hour')}` },
- { value: Timeframes['2h'], label: `2 ${t('hours')}` },
- { value: Timeframes['3h'], label: `3 ${t('hours')}` },
- { value: Timeframes['4h'], label: `4 ${t('hours')}` },
- { value: Timeframes['8h'], label: `8 ${t('hours')}` },
- { value: Timeframes['12h'], label: `12 ${t('hours')}` },
+ timeframeOption(Timeframes['1h'], 1, 'hour'),
+ timeframeOption(Timeframes['2h'], 2, 'hour'),
+ timeframeOption(Timeframes['3h'], 3, 'hour'),
+ timeframeOption(Timeframes['4h'], 4, 'hour'),
+ timeframeOption(Timeframes['8h'], 8, 'hour'),
+ timeframeOption(Timeframes['12h'], 12, 'hour'),
],
days: [
- { value: Timeframes['1d'], label: `1 ${t('day')}` },
- { value: Timeframes['3d'], label: `3 ${t('days')}` },
- { value: Timeframes['5d'], label: `5 ${t('days')}` },
- ],
- weeks: [{ value: Timeframes['1w'], label: `1 ${t('week')}` }],
- months: [
- { value: Timeframes['1М'], label: `1 ${t('month')}` },
- { value: Timeframes['3М'], label: `3 ${t('months')}` },
+ timeframeOption(Timeframes['1d'], 1, 'day'),
+ timeframeOption(Timeframes['3d'], 3, 'day'),
+ timeframeOption(Timeframes['5d'], 5, 'day'),
],
+ weeks: [timeframeOption(Timeframes['1w'], 1, 'week')],
+ months: [timeframeOption(Timeframes['1М'], 1, 'month'), timeframeOption(Timeframes['3М'], 3, 'month')],
Range: [],
};
};
-export const TIMEFRAMES = {
- ticks: [
- { value: Timeframes['1t'], label: `1 ${t('tick')}` },
- { value: Timeframes['10t'], label: `10 ${t('ticks')}` },
- { value: Timeframes['100t'], label: `100 ${t('ticks')}` },
- { value: Timeframes['1000t'], label: `1000 ${t('ticks')}` },
- ],
- seconds: [
- { value: Timeframes['1s'], label: `1 ${t('second')}` },
- { value: Timeframes['5s'], label: `5 ${t('seconds')}` },
- { value: Timeframes['10s'], label: `10 ${t('seconds')}` },
- { value: Timeframes['15s'], label: `15 ${t('seconds')}` },
- { value: Timeframes['30s'], label: `30 ${t('seconds')}` },
- { value: Timeframes['45s'], label: `45 ${t('seconds')}` },
- ],
- minutes: [
- { value: Timeframes['1m'], label: `1 ${t('minute')}` },
- { value: Timeframes['2m'], label: `2 ${t('minutes')}` },
- { value: Timeframes['3m'], label: `3 ${t('minutes')}` },
- { value: Timeframes['5m'], label: `5 ${t('minutes')}` },
- { value: Timeframes['10m'], label: `10 ${t('minutes')}` },
- { value: Timeframes['15m'], label: `15 ${t('minutes')}` },
- { value: Timeframes['30m'], label: `30 ${t('minutes')}` },
- { value: Timeframes['45m'], label: `45 ${t('minutes')}` },
- ],
- hours: [
- { value: Timeframes['1h'], label: `1 ${t('hour')}` },
- { value: Timeframes['2h'], label: `2 ${t('hours')}` },
- { value: Timeframes['3h'], label: `3 ${t('hours')}` },
- { value: Timeframes['4h'], label: `4 ${t('hours')}` },
- { value: Timeframes['8h'], label: `8 ${t('hours')}` },
- { value: Timeframes['12h'], label: `12 ${t('hours')}` },
- ],
- days: [
- { value: Timeframes['1d'], label: `1 ${t('day')}` },
- { value: Timeframes['3d'], label: `3 ${t('days')}` },
- { value: Timeframes['5d'], label: `5 ${t('days')}` },
- ],
- weeks: [{ value: Timeframes['1w'], label: `1 ${t('week')}` }],
- months: [
- { value: Timeframes['1М'], label: `1 ${t('month')}` },
- { value: Timeframes['3М'], label: `3 ${t('months')}` },
- ],
- Range: [],
-} as const;
+export const TIMEFRAMES = timeframes();
diff --git a/src/components/PriceScaleControls/index.module.scss b/src/components/PriceScaleControls/index.module.scss
new file mode 100644
index 0000000..7afd802
--- /dev/null
+++ b/src/components/PriceScaleControls/index.module.scss
@@ -0,0 +1,24 @@
+@use '../../theme/mixins' as m;
+
+.tooltip {
+ @include m.tooltipHint;
+}
+
+.root {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-0250);
+ padding: var(--space-0250);
+ background-color: var(--neutral-0);
+ pointer-events: none;
+
+ .button {
+ @include m.buttonBase;
+
+ min-width: var(--space-1250);
+ height: var(--space-1500);
+ flex-shrink: 0;
+ pointer-events: auto;
+ }
+}
diff --git a/src/components/PriceScaleControls/index.tsx b/src/components/PriceScaleControls/index.tsx
new file mode 100644
index 0000000..7f342dc
--- /dev/null
+++ b/src/components/PriceScaleControls/index.tsx
@@ -0,0 +1,63 @@
+import classNames from 'classnames';
+import { Button, Tooltip } from 'exchange-elements/v2';
+
+import { t } from '@src/translations';
+
+import styles from './index.module.scss';
+
+import type { SyntheticEvent } from 'react';
+
+interface PriceScaleControlsProps {
+ isAutoScale: boolean;
+ isLogarithmic: boolean;
+ onToggleAutoScale: () => void;
+ onToggleLogarithmic: () => void;
+}
+
+export function PriceScaleControls({
+ isAutoScale,
+ isLogarithmic,
+ onToggleAutoScale,
+ onToggleLogarithmic,
+}: PriceScaleControlsProps) {
+ const stopPropagation = (event: SyntheticEvent) => {
+ event.stopPropagation();
+ };
+
+ return (
+ <section
+ className={classNames(styles.root, 'moex-price-scale-controls-content')}
+ onPointerDown={stopPropagation}
+ onMouseDown={stopPropagation}
+ onClick={stopPropagation}
+ >
+ <Tooltip
+ tooltipClassName={styles.tooltip}
+ label={t('AutoScale')}
+ >
+ <Button
+ size="sm"
+ className={classNames(styles.button, {
+ [styles.pressed]: isAutoScale,
+ })}
+ onClick={onToggleAutoScale}
+ label="A"
+ />
+ </Tooltip>
+
+ <Tooltip
+ tooltipClassName={styles.tooltip}
+ label={t('Logarithmic')}
+ >
+ <Button
+ size="sm"
+ className={classNames(styles.button, {
+ [styles.pressed]: isLogarithmic,
+ })}
+ onClick={onToggleLogarithmic}
+ label="L"
+ />
+ </Tooltip>
+ </section>
+ );
+}
diff --git a/src/core/Chart.ts b/src/core/Chart.ts
index 63309e4..3622e5e 100644
--- a/src/core/Chart.ts
+++ b/src/core/Chart.ts
@@ -26,7 +26,6 @@ import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
-import { PriceAxisLabelsController } from '@core/PriceAxisLabels';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
@@ -43,7 +42,6 @@ import {
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
-
import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
@@ -87,7 +85,6 @@ export class Chart implements ISerializable<ChartSnapshot> {
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
private indicatorManager: IndicatorManager;
- private priceAxisLabelsController: PriceAxisLabelsController;
private optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
@@ -118,7 +115,12 @@ export class Chart implements ISerializable<ChartSnapshot> {
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
- const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };
+ const configToApply = {
+ ...lwcChartConfig,
+ dateFormat,
+ timeFormat,
+ showTime,
+ };
this.lwcChart.applyOptions({
...getOptions(configToApply),
@@ -130,7 +132,10 @@ export class Chart implements ISerializable<ChartSnapshot> {
this.subscriptions.add(this.optionsSubscription);
- this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
+ this.mouseEvents = new ChartMouseEvents({
+ lwcChart: this.lwcChart,
+ container: this.container,
+ });
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
@@ -138,7 +143,9 @@ export class Chart implements ISerializable<ChartSnapshot> {
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
- this.DOM = new DOMModel({ modalRenderer });
+ this.DOM = new DOMModel({
+ modalRenderer,
+ });
this.paneManager = new PaneManager({
eventManager: this.eventManager,
@@ -158,7 +165,12 @@ export class Chart implements ISerializable<ChartSnapshot> {
this.indicatorManager = new IndicatorManager({
eventManager,
initialIndicators: flatten(
- panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
+ panesSnapshot.map((pane) =>
+ pane.indicators.map((indicator) => ({
+ ...indicator,
+ paneId: pane.id,
+ })),
+ ),
),
DOM: this.DOM,
dataSource: this.dataSource,
@@ -170,7 +182,12 @@ export class Chart implements ISerializable<ChartSnapshot> {
this.compareManager = new CompareManager({
chart: this.lwcChart,
initialIndicators: flatten(
- panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
+ panesSnapshot.map((pane) =>
+ pane.indicators.map((indicator) => ({
+ ...indicator,
+ paneId: pane.id,
+ })),
+ ),
),
eventManager: this.eventManager,
dataSource: this.dataSource,
@@ -178,14 +195,13 @@ export class Chart implements ISerializable<ChartSnapshot> {
paneManager: this.paneManager,
});
- this.priceAxisLabelsController = new PriceAxisLabelsController({
- mainSeries$: this.paneManager.getMainPane().getMainSerie().asObservable(),
- mainSymbol$: this.eventManager.symbol(),
+ this.paneManager.initializePriceAxisLabels({
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
- this.priceAxisLabelsController.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
+ this.paneManager.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
+ this.paneManager.refreshPriceScaleControls();
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
@@ -210,7 +226,9 @@ export class Chart implements ISerializable<ChartSnapshot> {
};
private onWheel = () => {
- this.eventManager.resetInterval({ history: false });
+ this.eventManager.resetInterval({
+ history: false,
+ });
};
private onPointerDown = () => {
@@ -223,7 +241,10 @@ export class Chart implements ISerializable<ChartSnapshot> {
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
- this.eventManager.resetInterval({ history: false });
+
+ this.eventManager.resetInterval({
+ history: false,
+ });
};
private onPointerUp = () => {
@@ -243,15 +264,22 @@ export class Chart implements ISerializable<ChartSnapshot> {
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
- this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
- this.priceAxisLabelsController.invalidate();
+ this.lwcChart.applyOptions(
+ getOptions({
+ ...this.chartConfig,
+ theme,
+ mode,
+ }),
+ );
+
+ this.paneManager.invalidatePriceAxisLabels();
}
public destroy(): void {
- this.priceAxisLabelsController.destroy();
this.mouseEvents.destroy();
- this.compareManager.clear();
this.subscriptions.unsubscribe();
+ this.compareManager.destroy();
+ this.paneManager.destroy();
this.lwcChart.remove();
}
@@ -264,36 +292,50 @@ export class Chart implements ISerializable<ChartSnapshot> {
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
- this.eventManager.resetInterval({ history: false });
+ this.eventManager.resetInterval({
+ history: false,
+ });
+
const diff = direction === Direction.Left ? -2 : 2;
+
const currentPosition = this.lwcChart.timeScale().scrollPosition();
+
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
- this.eventManager.resetInterval({ history: false });
+ this.eventManager.resetInterval({
+ history: false,
+ });
+
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
+
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
+
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
+
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
- this.eventManager.resetInterval({ history: false });
+ this.eventManager.resetInterval({
+ history: false,
+ });
+
this.lwcChart.timeScale().resetTimeScale();
- this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
- this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
+
+ this.paneManager.resetPriceScalesAutoScale();
};
public getRealtimeApi() {
@@ -323,10 +365,11 @@ export class Chart implements ISerializable<ChartSnapshot> {
requestAnimationFrame(() => {
const symbols = this.activeSymbols.slice();
- Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
+ Promise.all(symbols.map((symbol) => this.dataSource.loadMoreHistory(symbol))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
+
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
@@ -341,9 +384,11 @@ export class Chart implements ISerializable<ChartSnapshot> {
}
const range = this.lwcChart.timeScale().getVisibleRange();
+
if (!range) return 0;
const { from } = range as IRange<number>;
+
return from as number;
};
@@ -351,14 +396,16 @@ export class Chart implements ISerializable<ChartSnapshot> {
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)]))),
+ map(([main, items]) => Array.from(new Set([main, ...items.map((item) => item.symbol)]))),
);
this.subscriptions.add(
@@ -371,7 +418,7 @@ export class Chart implements ISerializable<ChartSnapshot> {
if (!interval) return;
if (interval === Intervals.All) {
- Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
+ Promise.all(symbols.map((symbol) => this.dataSource.loadAllHistory(symbol)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
@@ -382,9 +429,12 @@ export class Chart implements ISerializable<ChartSnapshot> {
const { from, to } = getIntervalRange(interval);
- Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
+ Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from)))
.then(() => {
- this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
+ this.lwcChart.timeScale().setVisibleRange({
+ from: from as Time,
+ to: to as Time,
+ });
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
@@ -394,23 +444,28 @@ export class Chart implements ISerializable<ChartSnapshot> {
this.subscriptions.add(
symbols$.subscribe((symbols) => {
- const prevSymbols = this.activeSymbols;
+ const previousSymbols = this.activeSymbols;
+
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
- const prevSet = new Set(prevSymbols);
- const added: string[] = [];
+ const previousSymbolsSet = new Set(previousSymbols);
+ const addedSymbols: string[] = [];
+
+ for (let index = 0; index < symbols.length; index += 1) {
+ const symbol = symbols[index];
+
+ if (!symbol) continue;
+ if (previousSymbolsSet.has(symbol)) {
+ continue;
+ }
- for (let i = 0; i < symbols.length; i += 1) {
- const s = symbols[i];
- if (!s) continue;
- if (prevSet.has(s)) continue;
- added.push(s);
+ addedSymbols.push(symbol);
}
- if (added.length) {
- warmupSymbols(added);
+ if (addedSymbols.length) {
+ warmupSymbols(addedSymbols);
}
}),
);
@@ -419,12 +474,16 @@ export class Chart implements ISerializable<ChartSnapshot> {
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
- this.priceAxisLabelsController.setVisibleLogicalRange(logicalRange);
+ this.paneManager.setVisibleLogicalRange(logicalRange);
if (!logicalRange) return;
- if (this.currentInterval === Intervals.All) return;
+
+ if (this.currentInterval === Intervals.All) {
+ return;
+ }
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
+
if (!needsMoreData) return;
this.scheduleHistoryBatch();
@@ -432,13 +491,19 @@ export class Chart implements ISerializable<ChartSnapshot> {
}
}
-function getIntervalRange(interval: Intervals): { from: number; to: number } {
+function getIntervalRange(interval: Intervals): {
+ from: number;
+ to: number;
+} {
const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;
const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
const to = Math.floor(dayjs().valueOf() / 1000);
- return { from, to };
+ return {
+ from,
+ to,
+ };
}
function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
@@ -462,17 +527,31 @@ function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
height: config.container.clientHeight,
autoSize: true,
layout: {
- background: { color: colors.chartBackground },
+ background: {
+ color: colors.chartBackground,
+ },
textColor: colors.chartTextPrimary,
},
grid: {
- vertLines: { color: colors.chartGridLine },
- horzLines: { color: colors.chartGridLine },
+ vertLines: {
+ color: colors.chartGridLine,
+ },
+ horzLines: {
+ color: colors.chartGridLine,
+ },
},
crosshair: {
mode: CrosshairMode.Normal,
- vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
- horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
+ vertLine: {
+ color: colors.chartCrosshairLine,
+ labelBackgroundColor: colors.chartCrosshairLabel,
+ style: 0,
+ },
+ horzLine: {
+ color: colors.chartCrosshairLine,
+ labelBackgroundColor: colors.chartCrosshairLabel,
+ style: 2,
+ },
},
timeScale: {
timeVisible: showTime,
diff --git a/src/core/CompareManager.ts b/src/core/CompareManager.ts
index e289018..9069066 100644
--- a/src/core/CompareManager.ts
+++ b/src/core/CompareManager.ts
@@ -1,14 +1,14 @@
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
+
import { flatten } from 'lodash-es';
-import { BehaviorSubject, distinctUntilChanged, map, Observable } from 'rxjs';
+import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
-
-import { MAIN_PANE_INDEX } from '@src/constants';
+import { PriceScale } from '@core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
@@ -18,7 +18,6 @@ interface CompareEntry {
key: string;
symbol: string;
mode: CompareMode;
- paneIndex: number;
symbol$: BehaviorSubject<string>;
entity: Indicator;
}
@@ -38,10 +37,13 @@ export class CompareManager {
private readonly dataSource: DataSource;
private readonly indicatorManager: IndicatorManager;
private readonly paneManager: PaneManager;
-
private readonly entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
+ private readonly subscriptions = new Subscription();
+
+ private hasPercentageComparison = false;
+ private restoringInitialIndicators = false;
constructor({
chart,
@@ -57,36 +59,15 @@ export class CompareManager {
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
- this.eventManager.timeframe().subscribe(() => {
- this.applyPolicy();
- });
-
- if (!initialIndicators) {
- return;
- }
+ this.subscriptions.add(
+ this.eventManager.timeframe().subscribe(() => {
+ this.applyPolicy();
+ }),
+ );
this.setup(initialIndicators);
}
- private async setup(initialIndicators: IndicatorSnapshot[]) {
- for (const indicator of initialIndicators) {
- if (indicator.config && indicator.config.label) {
- // условие проверки compare ли это
- const serie = indicator.config.series[0];
-
- const compareMode =
- serie.seriesOptions?.priceScaleId === Direction.Left
- ? CompareMode.NewScale
- : indicator.config.newPane
- ? CompareMode.NewPane
- : CompareMode.Percentage;
-
- // eslint-disable-next-line no-await-in-loop
- await this.setSymbolMode(serie.name, indicator.config.label, compareMode, indicator.paneId);
- }
- }
- }
-
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
@@ -97,11 +78,12 @@ export class CompareManager {
public clear(): void {
const keys = Array.from(this.entries.keys());
- for (let i = 0; i < keys.length; i += 1) {
- this.removeByKey(keys[i]);
+
+ for (let index = 0; index < keys.length; index += 1) {
+ this.removeEntry(keys[index]);
}
- this.applyPolicy();
- this.publish();
+
+ this.commitEntriesChange();
}
public async setSymbolMode(
@@ -111,14 +93,20 @@ export class CompareManager {
paneId?: number,
): Promise<void> {
const symbol = normalizeSymbol(symbolRaw);
- if (!symbol) return;
- if (mode === CompareMode.NewScale && this.isNewScaleDisabled()) {
+ if (!symbol) {
+ return;
+ }
+
+ if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
return;
}
const key = makeKey(symbol, mode);
- if (this.entries.has(key)) return;
+
+ if (this.entries.has(key)) {
+ return;
+ }
const symbol$ = new BehaviorSubject(symbol);
@@ -126,17 +114,19 @@ export class CompareManager {
const usedColorsByCompare = this.entitiesSubject.value.map(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- (ind) => ind.getConfig().series?.[0]?.seriesOptions?.color,
+ (indicator) => indicator.getConfig().series?.[0]?.seriesOptions?.color,
);
- const existIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
- const usedColorsByIndicatorsRaw = existIndicators.map((ind) =>
+
+ const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());
+
+ const usedColorsByIndicatorsRaw = existingIndicators.map((indicator) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
- ind.config?.series?.map((serie) => serie.seriesOptions?.color),
+ indicator.config?.series?.map((series) => series.seriesOptions?.color),
);
+
const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
-
const config = getDefaultCompareIndicatorConfig(symbol, usedColors);
const associatedPane =
@@ -166,42 +156,65 @@ export class CompareManager {
newPane: mode === CompareMode.NewPane,
},
zIndex,
- onDelete: () => this.removeByKey(key),
+ onDelete: () => {
+ if (this.removeEntry(key)) {
+ this.commitEntriesChange();
+ }
+ },
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
- this.entries.set(key, { key, symbol, mode, paneIndex: entity.getPane().getId(), symbol$, entity });
+ this.entries.set(key, {
+ key,
+ symbol,
+ mode,
+ symbol$,
+ entity,
+ });
- this.applyPolicy();
- this.publish();
+ this.commitEntriesChange();
await this.dataSource.isReady(symbol);
}
public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
const symbol = normalizeSymbol(symbolRaw);
- if (!symbol) return;
- this.removeByKey(makeKey(symbol, mode));
- this.applyPolicy();
- this.publish();
+ if (!symbol) {
+ return;
+ }
+
+ if (this.removeEntry(makeKey(symbol, mode))) {
+ this.commitEntriesChange();
+ }
}
public removeSymbol(symbolRaw: string): void {
const symbol = normalizeSymbol(symbolRaw);
- if (!symbol) return;
- const all = Array.from(this.entries.entries());
- for (let i = 0; i < all.length; i += 1) {
- const [key, entry] = all[i];
- if (entry.symbol === symbol) this.removeByKey(key);
+ if (!symbol) {
+ return;
}
- this.applyPolicy();
- this.publish();
+ const entries = Array.from(this.entries.entries());
+ let removed = false;
+
+ for (let index = 0; index < entries.length; index += 1) {
+ const [key, entry] = entries[index];
+
+ if (entry.symbol !== symbol) {
+ continue;
+ }
+
+ removed = this.removeEntry(key) || removed;
+ }
+
+ if (removed) {
+ this.commitEntriesChange();
+ }
}
public isNewScaleDisabled(): boolean {
@@ -224,117 +237,145 @@ export class CompareManager {
}
public destroy(): void {
+ this.subscriptions.unsubscribe();
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
- private removeByKey(key: string): void {
+ private async setup(initialIndicators: IndicatorSnapshot[]): Promise<void> {
+ this.restoringInitialIndicators = true;
+
+ try {
+ for (const indicator of initialIndicators) {
+ if (indicator.indicatorType !== undefined) {
+ continue;
+ }
+
+ if (!indicator.config?.label) {
+ continue;
+ }
+
+ const series = indicator.config.series[0];
+
+ const compareMode =
+ series.seriesOptions?.priceScaleId === Direction.Left
+ ? CompareMode.NewScale
+ : indicator.config.newPane
+ ? CompareMode.NewPane
+ : CompareMode.Percentage;
+
+ // eslint-disable-next-line no-await-in-loop
+ await this.setSymbolMode(series.name, indicator.config.label, compareMode, indicator.paneId);
+ }
+ } finally {
+ this.restoringInitialIndicators = false;
+ }
+ }
+
+ private removeEntry(key: string): boolean {
const entry = this.entries.get(key);
- if (!entry) return;
+
+ if (!entry) {
+ return false;
+ }
this.entries.delete(key);
this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
entry.symbol$.complete();
+ return true;
+ }
+
+ private commitEntriesChange(): void {
this.applyPolicy();
this.publish();
}
private publish(): void {
const values = Array.from(this.entries.values());
-
const items: CompareItem[] = [];
const entities: Indicator[] = [];
- for (let i = 0; i < values.length; i += 1) {
- items.push({ symbol: values[i].symbol, mode: values[i].mode });
- entities.push(values[i].entity);
+ for (let index = 0; index < values.length; index += 1) {
+ items.push({
+ symbol: values[index].symbol,
+ mode: values[index].mode,
+ });
+
+ entities.push(values[index].entity);
}
this.itemsSubject.next(items);
this.entitiesSubject.next(entities);
}
- private applyPolicy(): void {
- const list = Array.from(this.entries.values());
-
- let percentEnabled = false;
- for (let i = 0; i < list.length; i += 1) {
- if (list[i].mode === CompareMode.Percentage) {
- percentEnabled = true;
- break;
- }
+ private syncPercentageMode(priceScale: PriceScale, hasPercentageComparison: boolean): void {
+ if (this.restoringInitialIndicators) {
+ this.hasPercentageComparison = hasPercentageComparison;
+ return;
}
- let hasMainNewScale = false;
- for (let i = 0; i < list.length; i += 1) {
- // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
- const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
- if (paneIndex === MAIN_PANE_INDEX && list[i].mode === CompareMode.NewScale) {
- hasMainNewScale = true;
- break;
- }
+ if (this.hasPercentageComparison === hasPercentageComparison) {
+ return;
}
- const paneSet = new Set<number>();
- for (let i = 0; i < list.length; i += 1) {
- // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
- const paneIndex = Array.from(list[i].entity.getSeriesMap())[0][1].getPane().paneIndex();
+ const hadPercentageComparison = this.hasPercentageComparison;
- if (paneIndex !== MAIN_PANE_INDEX) paneSet.add(paneIndex);
- }
+ this.hasPercentageComparison = hasPercentageComparison;
- this.chart.applyOptions({
- leftPriceScale: { visible: hasMainNewScale, borderVisible: false },
- });
+ if (hasPercentageComparison && !hadPercentageComparison) {
+ priceScale.setMode(PriceScaleMode.Percentage);
+ return;
+ }
- this.chart.priceScale(Direction.Right, MAIN_PANE_INDEX).applyOptions({
- visible: true,
- mode: percentEnabled ? PriceScaleMode.Percentage : PriceScaleMode.Normal,
- });
+ if (!hasPercentageComparison && hadPercentageComparison && priceScale.getMode() === PriceScaleMode.Percentage) {
+ priceScale.setMode(PriceScaleMode.Normal);
+ }
+ }
- this.chart.priceScale(Direction.Left, MAIN_PANE_INDEX).applyOptions({
- visible: hasMainNewScale,
- mode: PriceScaleMode.Normal,
- borderVisible: false,
- });
+ private applyPolicy(): void {
+ const entries = Array.from(this.entries.values());
- const panes = Array.from(paneSet.values());
- for (let i = 0; i < panes.length; i += 1) {
- const paneIndex = panes[i];
+ let hasPercentageComparison = false;
+ let hasNewScaleComparison = false;
- this.chart.priceScale(Direction.Right, paneIndex).applyOptions({
- visible: true,
- mode: PriceScaleMode.Normal,
- });
+ for (let index = 0; index < entries.length; index += 1) {
+ if (entries[index].mode === CompareMode.Percentage) {
+ hasPercentageComparison = true;
+ }
- if (hasMainNewScale) {
- this.chart.priceScale(Direction.Left, paneIndex).applyOptions({
- visible: true,
- mode: PriceScaleMode.Normal,
- borderVisible: false,
- });
+ if (entries[index].mode === CompareMode.NewScale) {
+ hasNewScaleComparison = true;
}
}
- for (let i = 0; i < list.length; i += 1) {
- const entry = list[i];
+ for (let index = 0; index < entries.length; index += 1) {
+ const entry = entries[index];
+ const pane = entry.entity.getPane();
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
- const serie = Array.from(entry.entity.getSeriesMap())[0][1];
- const paneIndex = serie.getPane().paneIndex();
+ const series = Array.from(entry.entity.getSeriesMap().values())[0];
- if (paneIndex !== MAIN_PANE_INDEX) {
- serie.applyOptions({ priceScaleId: Direction.Right });
+ if (!series) {
continue;
}
- serie.applyOptions({
- priceScaleId: entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
+ series.applyOptions({
+ priceScaleId: pane.isMainPane() && entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
+
+ this.paneManager.setPriceScaleSideVisible(Direction.Left, hasNewScaleComparison);
+ this.paneManager.setPriceScaleSideVisible(Direction.Right, true);
+
+ const mainRightPriceScale = this.paneManager.getMainPane().getPriceScale(Direction.Right);
+
+ this.syncPercentageMode(mainRightPriceScale, hasPercentageComparison);
+
+ this.paneManager.refreshPriceScaleControls();
+ this.paneManager.invalidatePriceAxisLabels();
}
}
diff --git a/src/core/Drawings/diapson/diapson.ts b/src/core/Drawings/diapson/diapson.ts
index 0748bef..bfbef38 100644
--- a/src/core/Drawings/diapson/diapson.ts
+++ b/src/core/Drawings/diapson/diapson.ts
@@ -1001,7 +1001,7 @@ export class Diapson implements ISeriesDrawing {
let volume = 0;
for (let index = from; index <= to; index += 1) {
- const item = data.at(index) as Record<string, unknown> | undefined;
+ const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
diff --git a/src/core/Drawings/ruler/ruler.ts b/src/core/Drawings/ruler/ruler.ts
index 1ce3fd5..5850c13 100644
--- a/src/core/Drawings/ruler/ruler.ts
+++ b/src/core/Drawings/ruler/ruler.ts
@@ -672,7 +672,7 @@ export class Ruler implements ISeriesDrawing {
let volume = 0;
for (let index = from; index <= to; index += 1) {
- const item = data.at(index) as Record<string, unknown> | undefined;
+ const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
diff --git a/src/core/Indicator.ts b/src/core/Indicator.ts
index ebb7662..b0d9ee7 100644
--- a/src/core/Indicator.ts
+++ b/src/core/Indicator.ts
@@ -75,10 +75,6 @@ export class Indicator extends DOMObject implements ISerializable<IndicatorSnaps
});
}
- public deletable = (): boolean => {
- return this.associatedPane.isMainPane() || this.associatedPane.isLast();
- };
-
public getLabel = () => {
if (this.config.label) {
return this.config.label;
@@ -207,7 +203,7 @@ export class Indicator extends DOMObject implements ISerializable<IndicatorSnaps
mainSymbol$: this.mainSymbol$,
mainSerie$: this.associatedPane.getMainSerie(),
showSymbolLabel: false,
- paneIndex: this.associatedPane.getId(),
+ paneIndex: this.associatedPane.paneIndex(),
indicatorReference: this,
});
diff --git a/src/core/Legend.ts b/src/core/Legend.ts
index 68e535f..b57a6b1 100644
--- a/src/core/Legend.ts
+++ b/src/core/Legend.ts
@@ -17,6 +17,7 @@ export interface LegendParams {
subscribeChartEvent: ChartMouseEvents['subscribe'];
mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
paneId: number;
+ paneIndex: () => number;
openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
}
@@ -85,6 +86,7 @@ export class Legend {
private tooltipVisability = new BehaviorSubject<boolean>(false);
private tooltipPos = new BehaviorSubject<null | Point>(null);
private paneId: number;
+ private paneIndex: () => number;
private openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
private subscriptions = new Subscription();
@@ -98,16 +100,20 @@ export class Legend {
subscribeChartEvent,
mainSeries,
paneId,
+ paneIndex,
openIndicatorSettings,
}: LegendParams) {
this.config = config;
this.eventManager = eventManager;
this.paneId = paneId;
+ this.paneIndex = paneIndex;
this.openIndicatorSettings = openIndicatorSettings;
this.subscriptions.add(
this.eventManager.symbol().subscribe((symbol) => {
- this.mainSymbol = symbol.split(':').at(-1) || symbol;
+ const symbolParts = symbol.split(':');
+
+ this.mainSymbol = symbolParts[symbolParts.length - 1] || symbol;
this.updateWithLastCandle();
}),
);
@@ -210,8 +216,6 @@ export class Legend {
indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
}
- const remove = indicator.deletable() ? () => indicator.delete() : undefined;
-
model.push({
id: indicator.getId(),
name: indicator.getLabel(),
@@ -219,7 +223,7 @@ export class Legend {
Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
>,
isIndicator: true,
- remove,
+ remove: () => indicator.delete(),
settings:
indicatorType && indicator.hasSettings()
? () => this.openIndicatorSettings(indicatorType, indicator)
@@ -240,7 +244,7 @@ export class Legend {
return;
}
- if (this.paneId === param.paneIndex) {
+ if (this.paneIndex() === param.paneIndex) {
this.tooltipVisability.next(true);
} else {
this.tooltipVisability.next(false);
@@ -289,8 +293,6 @@ export class Legend {
indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
}
- const remove = indicator.deletable() ? () => indicator.delete() : undefined;
-
model.push({
id: indicator.getId(),
name: indicator.getLabel(),
@@ -298,7 +300,7 @@ export class Legend {
Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
>,
isIndicator: true,
- remove,
+ remove: () => indicator.delete(),
settings:
indicatorType && indicator.hasSettings()
? () => this.openIndicatorSettings(indicatorType, indicator)
diff --git a/src/core/Pane.tsx b/src/core/Pane.tsx
index 11749e0..1692edb 100644
--- a/src/core/Pane.tsx
+++ b/src/core/Pane.tsx
@@ -1,4 +1,4 @@
-import { IChartApi, IPaneApi, Time } from 'lightweight-charts';
+import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
@@ -12,16 +12,24 @@ import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager'
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
+import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
-import { DrawingsNames, indicatorLabelById } from '@src/constants';
+import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
-import { OHLCConfig, TooltipConfig } from '@src/types';
-import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
+import { Direction, OHLCConfig, TooltipConfig } from '@src/types';
+import {
+ DOMObjectSnapshot,
+ IndicatorSnapshot,
+ ISerializable,
+ PaneSnapshot,
+ PriceScaleSide,
+ PriceScaleSnapshot,
+} from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';
export interface PaneParams {
@@ -38,6 +46,10 @@ export interface PaneParams {
onDelete: () => void;
chartContainer: HTMLElement;
modalRenderer: ModalRenderer;
+ initialPriceScales?: PriceScaleSnapshot[];
+ onPriceScaleStateChange: () => void;
+ leftPriceScaleVisible: boolean;
+ rightPriceScaleVisible: boolean;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
@@ -48,33 +60,29 @@ export interface PaneParams {
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
- private isMain: boolean;
- private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
+ private readonly isMain: boolean;
+ private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private legend!: Legend;
private tooltip: TooltipService | undefined;
-
- private indicatorsMap: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject<Map<string, Indicator>>(
- new Map(),
- );
-
- private lwcPane: IPaneApi<Time>;
- private lwcChart: IChartApi;
-
- private eventManager: EventManager;
- private drawingsManager: DrawingsManager;
-
+ private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
+ private readonly lwcPane: IPaneApi<Time>;
+ private readonly lwcChart: IChartApi;
+ private readonly eventManager: EventManager;
+ private readonly drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
- private modalRenderer: ModalRenderer;
-
- private mainSerieSub!: Subscription;
- private subscribeChartEvent: ChartMouseEvents['subscribe'];
- private onDelete: () => void;
- private subscriptions = new Subscription();
-
- private last = false; // временное решение чтобы блочить удаление не последнего пейна
+ private readonly modalRenderer: ModalRenderer;
+ private readonly leftPriceScale: PriceScale;
+ private readonly rightPriceScale: PriceScale;
+ private readonly priceScaleControls: PriceScaleControls;
+ private mainSerieSub?: Subscription;
+ private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
+ private readonly onDelete: () => void;
+ private readonly onPriceScaleStateChange: () => void;
+ private readonly subscriptions = new Subscription();
+ private paneContainerSyncFrameId: number | null = null;
constructor({
lwcChart,
@@ -90,23 +98,37 @@ export class Pane implements ISerializable<PaneSnapshot> {
onDelete,
chartContainer,
modalRenderer,
+ initialPriceScales = [],
+ onPriceScaleStateChange,
+ leftPriceScaleVisible,
+ rightPriceScaleVisible,
}: PaneParams) {
this.onDelete = onDelete;
+ this.onPriceScaleStateChange = onPriceScaleStateChange;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
- this.isMain = isMainPane ?? false;
+ this.isMain = isMainPane;
this.id = id;
- this.initializeLegend({ ohlcConfig });
-
if (isMainPane) {
- this.lwcPane = this.lwcChart.panes()[this.id];
+ this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
+ this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales, leftPriceScaleVisible);
+ this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales, rightPriceScaleVisible);
+
+ this.priceScaleControls = new PriceScaleControls({
+ leftPriceScale: this.leftPriceScale,
+ rightPriceScale: this.rightPriceScale,
+ onPriceScaleChange: this.handlePriceScaleStateChange,
+ });
+
+ this.initializeLegend({ ohlcConfig });
+
this.tooltip = new TooltipService({
config: tooltipConfig,
legend: this.legend,
@@ -129,7 +151,7 @@ export class Pane implements ISerializable<PaneSnapshot> {
if (dataSource) {
this.initializeMainSerie({ lwcChart, dataSource });
} else if (basedOn) {
- this.mainSeries = basedOn?.getMainSerie();
+ this.mainSeries = basedOn.getMainSerie();
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
@@ -148,23 +170,16 @@ export class Pane implements ISerializable<PaneSnapshot> {
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
+
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
- public setIsLast(isLast: boolean): void {
- this.last = isLast;
- }
-
public isMainPane = () => {
return this.isMain;
};
- public isLast = () => {
- return this.last;
- };
-
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
@@ -181,23 +196,31 @@ export class Pane implements ISerializable<PaneSnapshot> {
return this.id;
};
+ public paneIndex = () => {
+ return this.lwcPane.paneIndex();
+ };
+
+ public getPriceScale(side: PriceScaleSide): PriceScale {
+ return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
+ }
+
public setIndicator(indicatorId: string, indicator: Indicator): void {
const map = this.indicatorsMap.value;
map.set(indicatorId, indicator);
-
this.indicatorsMap.next(map);
+ this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
const map = this.indicatorsMap.value;
map.delete(indicatorId);
-
this.indicatorsMap.next(map);
+ this.priceScaleControls.refresh();
if (map.size === 0 && !this.isMain) {
- this.destroy();
+ this.onDelete();
}
}
@@ -205,21 +228,123 @@ export class Pane implements ISerializable<PaneSnapshot> {
return this.drawingsManager;
}
- private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }) {
+ public schedulePaneContainerSync(): void {
+ if (this.paneContainerSyncFrameId !== null) {
+ return;
+ }
+
+ this.paneContainerSyncFrameId = requestAnimationFrame(() => {
+ this.paneContainerSyncFrameId = null;
+ this.syncPaneContainers();
+ });
+ }
+
+ public refreshPriceScaleControls(): void {
+ this.priceScaleControls.refresh();
+ }
+
+ public resetPriceScalesAutoScale(): void {
+ this.leftPriceScale.enableAutoScale();
+ this.rightPriceScale.enableAutoScale();
+ this.handlePriceScaleStateChange();
+ }
+
+ public getSnapshot(): PaneSnapshot {
+ const indicators: (DOMObjectSnapshot & IndicatorSnapshot)[] = [];
+
+ this.indicatorsMap.value.forEach((indicator) => {
+ indicators.push(indicator.getSnapshot());
+ });
+
+ return {
+ isMain: this.isMain,
+ id: this.id,
+ indicators,
+ drawings: this.getDrawingsSnapshot(),
+ priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
+ };
+ }
+
+ public destroy(): void {
+ if (this.paneContainerSyncFrameId !== null) {
+ cancelAnimationFrame(this.paneContainerSyncFrameId);
+ this.paneContainerSyncFrameId = null;
+ }
+
+ this.subscriptions.unsubscribe();
+ this.tooltip?.destroy();
+ this.legend?.destroy();
+ this.legendRenderer.destroy();
+ this.tooltipRenderer?.destroy();
+ this.priceScaleControls.destroy();
+ this.legendContainer.remove();
+ this.paneOverlayContainer.remove();
+ this.indicatorsMap.complete();
+ this.mainSerieSub?.unsubscribe();
+
+ if (this.isMain) {
+ this.mainSeries.value?.destroy();
+ this.mainSeries.complete();
+ }
+ }
+
+ private createPriceScale(
+ side: PriceScaleSide,
+ initialPriceScales: PriceScaleSnapshot[],
+ initialVisible: boolean,
+ ): PriceScale {
+ const initialMode =
+ initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;
+
+ return new PriceScale({
+ paneId: this.id,
+ side,
+ pane: this.lwcPane,
+ initialMode,
+ initialVisible,
+ hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
+ });
+ }
+
+ private hasVisibleSeriesData(side: PriceScaleSide): boolean {
+ const mainSeries = this.mainSeries.value;
+
+ if (this.isMain && side === Direction.Right && mainSeries?.isVisible() && mainSeries.data().length > 0) {
+ return true;
+ }
+
+ const indicators = Array.from(this.indicatorsMap.value.values());
+
+ for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
+ const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());
+
+ for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
+ const currentSeries = series[seriesIndex];
+ const options = currentSeries.options();
+ const seriesPriceScaleSide = options.priceScaleId ?? Direction.Right;
+
+ if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private handlePriceScaleStateChange = (): void => {
+ this.priceScaleControls.refresh();
+ this.onPriceScaleStateChange();
+ };
+
+ private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
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);
- });
+ this.schedulePaneContainerSync();
// todo: переписать код ниже под логику пейнов
// /*
@@ -265,12 +390,19 @@ export class Pane implements ISerializable<PaneSnapshot> {
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
+ paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
- tabs={[{ key: 'arguments', label: t('Arguments'), fields: indicator.getSettingsConfig() }]}
+ tabs={[
+ {
+ key: 'arguments',
+ label: t('Arguments'),
+ fields: indicator.getSettingsConfig(),
+ },
+ ]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
@@ -295,7 +427,7 @@ export class Pane implements ISerializable<PaneSnapshot> {
);
}
- private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }) {
+ private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
@@ -307,48 +439,30 @@ export class Pane implements ISerializable<PaneSnapshot> {
});
this.mainSeries.next(next);
+ this.priceScaleControls.refresh();
});
}
- public getSnapshot(): PaneSnapshot {
- const indicators: (DOMObjectSnapshot & IndicatorSnapshot)[] = [];
-
- this.indicatorsMap.value.forEach((ind) => {
- indicators.push(ind.getSnapshot());
- });
-
- const snap = {
- isMain: this.isMain,
- id: this.id,
- indicators,
- drawings: this.getDrawingsSnapshot(),
- };
-
- return snap;
- }
+ private syncPaneContainers(): void {
+ const lwcPaneElement = this.lwcPane.getHTMLElement();
- public destroy() {
- this.subscriptions.unsubscribe();
- this.tooltip?.destroy();
- this.legend?.destroy();
- this.legendRenderer.destroy();
-
- this.tooltipRenderer?.destroy();
- this.indicatorsMap.complete();
+ if (!lwcPaneElement) {
+ this.schedulePaneContainerSync();
+ return;
+ }
- this.mainSerieSub?.unsubscribe();
+ const cells = lwcPaneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
+ const chartCell = cells.item(1);
- if (this.isMain) {
- this.mainSeries.value?.destroy();
- this.mainSeries?.complete();
+ if (!chartCell) {
+ this.schedulePaneContainerSync();
+ return;
}
- try {
- this.lwcChart.removePane(this.id);
- } catch (e) {
- console.log(e);
- }
+ chartCell.style.position = 'relative';
+ chartCell.appendChild(this.legendContainer);
+ chartCell.appendChild(this.paneOverlayContainer);
- this.onDelete();
+ this.priceScaleControls.mount(lwcPaneElement);
}
}
diff --git a/src/core/PaneManager.ts b/src/core/PaneManager.ts
index 158738c..b1bc243 100644
--- a/src/core/PaneManager.ts
+++ b/src/core/PaneManager.ts
@@ -1,12 +1,36 @@
import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
-import { ISerializable, PaneSnapshot } from '@src/types/snapshot';
-
-interface PaneManagerParams extends Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'> {
+import { PriceAxisLabels } from '@core/PriceAxisLabels';
+import { Direction } from '@src/types';
+import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
+
+import type { Indicator } from '@core/Indicator';
+import type { LogicalRange } from 'lightweight-charts';
+import type { Observable } from 'rxjs';
+
+interface PaneManagerParams
+ extends Omit<
+ PaneParams,
+ | 'id'
+ | 'isMainPane'
+ | 'basedOn'
+ | 'onDelete'
+ | 'initialPriceScales'
+ | 'onPriceScaleStateChange'
+ | 'leftPriceScaleVisible'
+ | 'rightPriceScaleVisible'
+ > {
panesSnapshot: PaneSnapshot[];
}
+interface PriceAxisLabelsSources {
+ compareEntities$: Observable<Indicator[]>;
+ indicatorEntities$: Observable<Indicator[]>;
+}
+
+type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;
+
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
@@ -14,39 +38,86 @@ interface PaneManagerParams extends Omit<PaneParams, 'isMainPane' | 'id' | 'base
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
+ private readonly sharedPaneParams: SharedPaneParams;
+ private readonly panesMap = new Map<number, Pane>();
+
private mainPane: Pane;
- private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
- private panesMap: Map<number, Pane> = new Map<number, Pane>();
- private panesIdIterator = 0;
+ private nextPaneId: number;
+ private priceAxisLabels: PriceAxisLabels | null = null;
+ private leftPriceScaleVisible = false;
+ private rightPriceScaleVisible = true;
+
+ constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
+ this.sharedPaneParams = sharedPaneParams;
+
+ const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
+ const mainPaneId = mainPaneSnapshot?.id ?? 0;
+
+ this.mainPane = new Pane({
+ ...this.sharedPaneParams,
+ id: mainPaneId,
+ isMainPane: true,
+ onDelete: () => {},
+ initialPriceScales: mainPaneSnapshot?.priceScales,
+ onPriceScaleStateChange: this.handlePriceScaleStateChange,
+ leftPriceScaleVisible: this.leftPriceScaleVisible,
+ rightPriceScaleVisible: this.rightPriceScaleVisible,
+ });
+
+ this.panesMap.set(mainPaneId, this.mainPane);
+
+ if (mainPaneSnapshot) {
+ this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
+ }
+
+ const greatestPaneId = panesSnapshot.reduce(
+ (greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
+ mainPaneId,
+ );
+
+ this.nextPaneId = greatestPaneId + 1;
+
+ panesSnapshot.forEach((paneSnapshot) => {
+ if (paneSnapshot.isMain) {
+ return;
+ }
- constructor(params: PaneManagerParams) {
- this.paneChartInheritedParams = { ...params, isMainPane: false };
+ const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
- this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
+ pane.setDrawingsSnapshot(paneSnapshot.drawings);
+ });
- this.panesMap.set(this.panesIdIterator++, this.mainPane);
- this.setup(params.panesSnapshot);
+ this.syncPaneContainers();
}
- private setup(panesSnapshot: PaneSnapshot[]) {
- panesSnapshot.forEach((paneSnap: PaneSnapshot) => {
- const { isMain, id, indicators, drawings } = paneSnap;
+ public initializePriceAxisLabels({ compareEntities$, indicatorEntities$ }: PriceAxisLabelsSources): void {
+ this.priceAxisLabels?.destroy();
- this.panesMap.get(id)?.destroy();
+ this.priceAxisLabels = new PriceAxisLabels({
+ mainSeries$: this.mainPane.getMainSerie().asObservable(),
+ mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
+ compareEntities$,
+ indicatorEntities$,
+ });
+ }
- if (isMain) {
- this.mainPane = new Pane({ ...this.paneChartInheritedParams, isMainPane: true, id: 0, onDelete: () => {} });
+ public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
+ this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
+ }
- this.panesMap.set(id, this.mainPane);
+ public invalidatePriceAxisLabels(): void {
+ this.priceAxisLabels?.invalidate();
+ }
- this.mainPane.setDrawingsSnapshot(drawings);
- } else {
- const pane = this.addPane();
- pane.setDrawingsSnapshot(drawings);
- }
- const lastPane = Array.from(this.panesMap.values()).at(-1);
+ public setPriceScaleSideVisible(side: PriceScaleSide, visible: boolean): void {
+ if (side === Direction.Left) {
+ this.leftPriceScaleVisible = visible;
+ } else {
+ this.rightPriceScaleVisible = visible;
+ }
- lastPane?.setIsLast(true);
+ this.panesMap.forEach((pane) => {
+ pane.getPriceScale(side).setVisible(visible);
});
}
@@ -62,39 +133,49 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
this.mainPane.setDrawingsSnapshot(snapshot);
}
- public getPanes() {
+ public getPanes(): Map<number, Pane> {
return this.panesMap;
}
- public getMainPane: () => Pane = () => {
+ public getMainPane = (): Pane => {
return this.mainPane;
};
- public addPane(dataSource?: DataSource): Pane {
- const id = this.panesIdIterator++;
+ public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
+ const id = paneId ?? this.nextPaneId++;
+
+ this.nextPaneId = Math.max(this.nextPaneId, id + 1);
- const newPane = new Pane({
- ...this.paneChartInheritedParams,
+ const pane = new Pane({
+ ...this.sharedPaneParams,
id,
+ isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
- onDelete: () => {
- this.panesIdIterator--;
- this.panesMap.delete(id);
-
- const prevPane = Array.from(this.panesMap.values()).at(-1);
- prevPane?.setIsLast(true);
- },
+ onDelete: () => this.destroyPane(id),
+ initialPriceScales,
+ onPriceScaleStateChange: this.handlePriceScaleStateChange,
+ leftPriceScaleVisible: this.leftPriceScaleVisible,
+ rightPriceScaleVisible: this.rightPriceScaleVisible,
});
- const prevPane = Array.from(this.panesMap.values()).at(-1);
+ this.panesMap.set(id, pane);
+ this.syncPaneContainers();
+ this.priceAxisLabels?.invalidate();
- prevPane?.setIsLast(false);
- newPane.setIsLast(true);
+ return pane;
+ }
- this.panesMap.set(id, newPane);
+ public refreshPriceScaleControls(): void {
+ this.panesMap.forEach((pane) => {
+ pane.refreshPriceScaleControls();
+ });
+ }
- return newPane;
+ public resetPriceScalesAutoScale(): void {
+ this.panesMap.forEach((pane) => {
+ pane.resetPriceScalesAutoScale();
+ });
}
public getDrawingsManager(): DrawingsManager {
@@ -103,11 +184,53 @@ export class PaneManager implements ISerializable<PaneSnapshot[]> {
}
public getSnapshot(): PaneSnapshot[] {
- const res: PaneSnapshot[] = [];
+ const snapshot: PaneSnapshot[] = [];
+
+ this.panesMap.forEach((pane) => {
+ snapshot.push(pane.getSnapshot());
+ });
+
+ return snapshot;
+ }
+
+ public destroy(): void {
+ this.priceAxisLabels?.destroy();
+ this.priceAxisLabels = null;
+
this.panesMap.forEach((pane) => {
- res.push(pane.getSnapshot());
+ pane.destroy();
});
- return res;
+ this.panesMap.clear();
}
+
+ private destroyPane(id: number): void {
+ const pane = this.panesMap.get(id);
+
+ if (!pane) {
+ return;
+ }
+
+ const paneIndex = pane.paneIndex();
+
+ this.panesMap.delete(id);
+ pane.destroy();
+
+ if (paneIndex >= 0) {
+ this.sharedPaneParams.lwcChart.removePane(paneIndex);
+ }
+
+ this.syncPaneContainers();
+ this.priceAxisLabels?.invalidate();
+ }
+
+ private syncPaneContainers(): void {
+ this.panesMap.forEach((pane) => {
+ pane.schedulePaneContainerSync();
+ });
+ }
+
+ private handlePriceScaleStateChange = (): void => {
+ this.priceAxisLabels?.invalidate();
+ };
}
diff --git a/src/core/PriceAxisLabels/controller.ts b/src/core/PriceAxisLabels/PriceAxisLabels.ts
similarity index 95%
rename from src/core/PriceAxisLabels/controller.ts
rename to src/core/PriceAxisLabels/PriceAxisLabels.ts
index 46012f7..6a7fdcb 100644
--- a/src/core/PriceAxisLabels/controller.ts
+++ b/src/core/PriceAxisLabels/PriceAxisLabels.ts
@@ -1,16 +1,16 @@
import { MismatchDirection, PriceScaleMode } from 'lightweight-charts';
+
import { Subscription } from 'rxjs';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds, MAIN_PANE_INDEX } from '@src/constants';
-
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { Direction } from '@src/types';
import { formatCompactNumber, formatPercent, formatPrice } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
-import { PriceAxisLabelsPrimitive } from './primitive';
+import { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
import { getAxisSideBySeries, getContrastTextColor } from './utils';
import type { PriceAxisLabel } from './types';
@@ -22,7 +22,7 @@ type EntityCollection = 'compare' | 'indicator';
type SourceRole = 'main' | 'compare' | 'indicator' | 'volume';
type PriceAxisSide = Direction.Left | Direction.Right;
-interface PriceAxisLabelsControllerOptions {
+interface PriceAxisLabelsParams {
mainSeries$: Observable<SeriesStrategies | null>;
mainSymbol$: Observable<string>;
compareEntities$: Observable<Indicator[]>;
@@ -133,11 +133,12 @@ function getAxisSide(source: PriceLabelSource): PriceAxisSide {
return getAxisSideBySeries(source.series);
}
-export class PriceAxisLabelsController {
+export class PriceAxisLabels {
private subscriptions = new Subscription();
private entitySubscriptions = new Map<string, EntitySubscription>();
private sourceDefaults = new WeakMap<SeriesStrategies, SourceDefaults>();
private layers = new Map<string, AxisLayer>();
+
private mainSeries: SeriesStrategies | null = null;
private mainSeriesDataHandler: (() => void) | null = null;
private compareEntities: Indicator[] = [];
@@ -149,10 +150,13 @@ export class PriceAxisLabelsController {
private isHistoryMode = false;
private updateFrame: number | null = null;
- constructor({ mainSeries$, mainSymbol$, compareEntities$, indicatorEntities$ }: PriceAxisLabelsControllerOptions) {
+ constructor({ mainSeries$, mainSymbol$, compareEntities$, indicatorEntities$ }: PriceAxisLabelsParams) {
this.subscriptions.add(
mainSymbol$.subscribe((symbol) => {
- this.mainSymbol = symbol.split(':').at(-1) || symbol;
+ const symbolParts = symbol.split(':');
+
+ this.mainSymbol = symbolParts[symbolParts.length - 1] || symbol;
+
this.applyDisplayMode();
this.scheduleUpdate();
}),
@@ -179,6 +183,7 @@ export class PriceAxisLabelsController {
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.visibleLogicalRange = logicalRange;
+
this.refreshHistoryMode();
this.scheduleUpdate();
}
@@ -208,7 +213,7 @@ export class PriceAxisLabelsController {
try {
host.detachPrimitive(primitive);
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
});
@@ -261,7 +266,7 @@ export class PriceAxisLabelsController {
try {
this.mainSeries.unsubscribeDataChanged(this.mainSeriesDataHandler);
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
this.mainSeriesDataHandler = null;
@@ -307,6 +312,7 @@ export class PriceAxisLabelsController {
const subscription = entity.subscribeDataChange(() => {
this.applyDisplayModeToSources(this.getEntitySources(collection, entity));
+
this.scheduleUpdate();
});
@@ -370,6 +376,7 @@ export class PriceAxisLabelsController {
}
const options = series.options();
+
const defaults = {
lastValueVisible: options.lastValueVisible,
priceLineVisible: options.priceLineVisible,
@@ -391,7 +398,7 @@ export class PriceAxisLabelsController {
try {
series.applyOptions(defaults);
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
}
@@ -413,6 +420,7 @@ export class PriceAxisLabelsController {
lastValueVisible: false,
priceLineVisible: false,
});
+
return;
}
@@ -421,6 +429,7 @@ export class PriceAxisLabelsController {
lastValueVisible: this.isHistoryMode ? false : defaults.lastValueVisible,
title: this.isHistoryMode ? '' : this.mainSymbol || defaults.title,
});
+
return;
}
@@ -428,7 +437,7 @@ export class PriceAxisLabelsController {
lastValueVisible: this.isHistoryMode ? false : defaults.lastValueVisible,
});
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
});
}
@@ -480,6 +489,7 @@ export class PriceAxisLabelsController {
}
const paneIndex = source.series.getPane().paneIndex();
+
const side = getAxisSide(source);
const key = `${paneIndex}:${side}`;
const group = groups.get(key);
@@ -516,6 +526,7 @@ export class PriceAxisLabelsController {
}
const paneIndex = mainSource.series.getPane().paneIndex();
+
const side = getAxisSide(mainSource);
const group = groups.get(`${paneIndex}:${side}`);
@@ -555,7 +566,9 @@ export class PriceAxisLabelsController {
return source.series.dataByIndex(Math.floor(this.visibleLogicalRange.to), MismatchDirection.NearestLeft);
}
- return source.series.data().at(-1) ?? null;
+ const data = source.series.data();
+
+ return data[data.length - 1] ?? null;
}
private updateAxisLayers(groups: Map<string, AxisLabelsGroup>, sources: PriceLabelSource[]): void {
@@ -569,6 +582,7 @@ export class PriceAxisLabelsController {
}
activeKeys.add(key);
+
this.getOrCreateLayer(key, host).primitive.setLabels(group.labels, group.reservedCoordinate);
});
@@ -580,7 +594,7 @@ export class PriceAxisLabelsController {
try {
layer.host.detachPrimitive(layer.primitive);
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
this.layers.delete(key);
@@ -618,7 +632,7 @@ export class PriceAxisLabelsController {
try {
currentLayer.host.detachPrimitive(currentLayer.primitive);
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
}
@@ -641,7 +655,8 @@ export class PriceAxisLabelsController {
return;
}
- const lastData = mainSource.series.data().at(-1);
+ const data = mainSource.series.data();
+ const lastData = data[data.length - 1];
const price = getDataPrice(lastData);
if (price === null) {
@@ -667,7 +682,8 @@ export class PriceAxisLabelsController {
return null;
}
- const price = getDataPrice(this.mainSeries.data().at(-1));
+ const data = this.mainSeries.data();
+ const price = getDataPrice(data[data.length - 1]);
return price === null ? null : this.mainSeries.priceToCoordinate(price);
}
@@ -705,7 +721,7 @@ export class PriceAxisLabelsController {
try {
this.currentPriceLineHost.removePriceLine(this.currentPriceLine);
} catch {
- // Серия могла быть удалена раньше контроллера.
+ // Серия могла быть удалена раньше объекта PriceAxisLabels.
}
}
@@ -715,6 +731,7 @@ export class PriceAxisLabelsController {
private formatValue(series: SeriesStrategies, price: number): string {
const { mode } = series.priceScale().options();
+
const formattedPrice = formatPrice(price) ?? series.priceFormatter().format(price);
if (mode !== PriceScaleMode.Percentage && mode !== PriceScaleMode.IndexedTo100) {
diff --git a/src/core/PriceAxisLabels/layout.ts b/src/core/PriceAxisLabels/PriceAxisLabelsLayout.ts
similarity index 100%
rename from src/core/PriceAxisLabels/layout.ts
rename to src/core/PriceAxisLabels/PriceAxisLabelsLayout.ts
diff --git a/src/core/PriceAxisLabels/primitive.ts b/src/core/PriceAxisLabels/PriceAxisLabelsPrimitive.ts
similarity index 74%
rename from src/core/PriceAxisLabels/primitive.ts
rename to src/core/PriceAxisLabels/PriceAxisLabelsPrimitive.ts
index dad4dbe..ec43639 100644
--- a/src/core/PriceAxisLabels/primitive.ts
+++ b/src/core/PriceAxisLabels/PriceAxisLabelsPrimitive.ts
@@ -1,8 +1,7 @@
import { getThemeStore } from '@src/theme';
-
import { Direction } from '@src/types';
-import { layoutPriceAxisLabels } from './layout';
+import { layoutPriceAxisLabels } from './PriceAxisLabelsLayout';
import { getAxisSideBySeries, getContrastTextColor } from './utils';
import type { LaidOutPriceAxisLabel, MeasuredPriceAxisLabel, PriceAxisLabel, PriceAxisSide } from './types';
@@ -26,6 +25,7 @@ interface PriceAxisLabelsRenderState {
chart: SeriesAttachedParameter<Time>['chart'] | null;
series: SeriesAttachedParameter<Time>['series'] | null;
}
+
interface LabelGeometry {
label: LaidOutPriceAxisLabel;
left: number;
@@ -180,10 +180,7 @@ function drawLabelTexts(
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
- constructor(
- private state: PriceAxisLabelsRenderState,
- private setMinimumWidth: (width: number) => void,
- ) {}
+ constructor(private readonly state: PriceAxisLabelsRenderState) {}
public draw(target: CanvasRenderingTarget2D): void {
const { labels, chart, series, reservedCoordinate } = this.state;
@@ -204,12 +201,6 @@ class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
context.font = getFont(layout);
const measuredLabels = measureLabels(context, labels, layout);
- const minimumWidth = measuredLabels.reduce(
- (maximumWidth, label) => Math.max(maximumWidth, label.width + LABEL_EDGE_GAP * 2),
- 0,
- );
-
- this.setMinimumWidth(minimumWidth);
geometries = createLabelGeometries(
measuredLabels,
@@ -233,13 +224,10 @@ class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
- private rendererInstance: PriceAxisLabelsRenderer;
+ private readonly rendererInstance: PriceAxisLabelsRenderer;
- constructor(
- private state: PriceAxisLabelsRenderState,
- setMinimumWidth: (width: number) => void,
- ) {
- this.rendererInstance = new PriceAxisLabelsRenderer(state, setMinimumWidth);
+ constructor(private readonly state: PriceAxisLabelsRenderState) {
+ this.rendererInstance = new PriceAxisLabelsRenderer(state);
}
public renderer(): IPrimitivePaneRenderer | null {
@@ -252,49 +240,30 @@ class PriceAxisLabelsPaneView implements IPrimitivePaneView {
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
- private state: PriceAxisLabelsRenderState = {
+ private readonly state: PriceAxisLabelsRenderState = {
labels: [],
reservedCoordinate: null,
chart: null,
series: null,
};
- private priceAxisPaneView: PriceAxisLabelsPaneView;
- private priceAxisPaneViewList: IPrimitivePaneView[];
+ private readonly priceAxisPaneView = new PriceAxisLabelsPaneView(this.state);
+ private readonly priceAxisPaneViewList: IPrimitivePaneView[] = [this.priceAxisPaneView];
private requestUpdate: (() => void) | null = null;
- private initialMinimumWidth = 0;
- private minimumWidth = 0;
- private pendingMinimumWidth: number | null = null;
- private minimumWidthFrame: number | null = null;
-
- constructor() {
- this.priceAxisPaneView = new PriceAxisLabelsPaneView(this.state, (width) => {
- this.scheduleMinimumWidth(width);
- });
-
- this.priceAxisPaneViewList = [this.priceAxisPaneView];
- }
public attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
this.state.chart = chart;
this.state.series = series;
this.requestUpdate = requestUpdate;
- this.initialMinimumWidth = series.priceScale().options().minimumWidth ?? 0;
- this.minimumWidth = this.initialMinimumWidth;
this.requestUpdate();
}
public detached(): void {
- this.cancelMinimumWidthUpdate();
- this.applyMinimumWidth(this.initialMinimumWidth);
-
this.state.chart = null;
this.state.series = null;
- this.requestUpdate = null;
this.state.labels = [];
this.state.reservedCoordinate = null;
- this.initialMinimumWidth = 0;
- this.minimumWidth = 0;
+ this.requestUpdate = null;
}
public priceAxisPaneViews(): IPrimitivePaneView[] {
@@ -310,11 +279,6 @@ export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
this.state.labels = labels;
this.state.reservedCoordinate = reservedCoordinate;
-
- if (labels.length === 0) {
- this.scheduleMinimumWidth(0);
- }
-
this.requestUpdate?.();
}
@@ -325,54 +289,6 @@ export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
this.state.labels = [];
this.state.reservedCoordinate = null;
- this.scheduleMinimumWidth(0);
this.requestUpdate?.();
}
-
- private scheduleMinimumWidth(width: number): void {
- const minimumWidth = Math.max(this.initialMinimumWidth, Math.ceil(width));
-
- if (minimumWidth === this.minimumWidth || minimumWidth === this.pendingMinimumWidth) {
- return;
- }
-
- this.pendingMinimumWidth = minimumWidth;
-
- if (this.minimumWidthFrame !== null) {
- return;
- }
-
- this.minimumWidthFrame = requestAnimationFrame(() => {
- const { pendingMinimumWidth } = this;
-
- this.minimumWidthFrame = null;
- this.pendingMinimumWidth = null;
-
- if (pendingMinimumWidth !== null) {
- this.applyMinimumWidth(pendingMinimumWidth);
- }
- });
- }
-
- private applyMinimumWidth(minimumWidth: number): void {
- if (!this.state.series || minimumWidth === this.minimumWidth) {
- return;
- }
-
- this.state.series.priceScale().applyOptions({
- minimumWidth,
- });
-
- this.minimumWidth = minimumWidth;
- }
-
- private cancelMinimumWidthUpdate(): void {
- if (this.minimumWidthFrame === null) {
- return;
- }
-
- cancelAnimationFrame(this.minimumWidthFrame);
- this.minimumWidthFrame = null;
- this.pendingMinimumWidth = null;
- }
}
diff --git a/src/core/PriceAxisLabels/index.ts b/src/core/PriceAxisLabels/index.ts
index 4beada3..a14ccbd 100644
--- a/src/core/PriceAxisLabels/index.ts
+++ b/src/core/PriceAxisLabels/index.ts
@@ -1,6 +1,8 @@
-export { PriceAxisLabelsController } from './controller';
-export { layoutPriceAxisLabels } from './layout';
-export { PriceAxisLabelsPrimitive } from './primitive';
+export { PriceAxisLabels } from './PriceAxisLabels';
+
+export { layoutPriceAxisLabels } from './PriceAxisLabelsLayout';
+export { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
+
export type {
LaidOutPriceAxisLabel,
MeasuredPriceAxisLabel,
diff --git a/src/core/PriceScale/PriceScale.ts b/src/core/PriceScale/PriceScale.ts
new file mode 100644
index 0000000..77ece5d
--- /dev/null
+++ b/src/core/PriceScale/PriceScale.ts
@@ -0,0 +1,125 @@
+import { IPaneApi, IPriceScaleApi, PriceScaleMode, Time } from 'lightweight-charts';
+
+import type { PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
+
+interface PriceScaleParams {
+ paneId: number;
+ side: PriceScaleSide;
+ pane: IPaneApi<Time>;
+ initialMode?: PriceScaleMode;
+ initialVisible: boolean;
+ hasVisibleSeriesData: () => boolean;
+}
+
+export class PriceScale {
+ public readonly paneId: number;
+ public readonly side: PriceScaleSide;
+
+ private readonly pane: IPaneApi<Time>;
+ private readonly hasVisibleSeriesDataCallback: () => boolean;
+
+ private mode: PriceScaleMode;
+ private autoScaleEnabled: boolean;
+ private visible: boolean;
+
+ constructor({
+ paneId,
+ side,
+ pane,
+ initialMode = PriceScaleMode.Normal,
+ initialVisible,
+ hasVisibleSeriesData,
+ }: PriceScaleParams) {
+ this.paneId = paneId;
+ this.side = side;
+ this.pane = pane;
+ this.mode = initialMode;
+ this.visible = initialVisible;
+ this.hasVisibleSeriesDataCallback = hasVisibleSeriesData;
+
+ const priceScale = this.getPriceScaleApi();
+
+ this.autoScaleEnabled = priceScale.options().autoScale ?? true;
+
+ priceScale.applyOptions({
+ mode: this.mode,
+ autoScale: this.autoScaleEnabled,
+ visible: this.visible,
+ borderVisible: false,
+ });
+ }
+
+ public getMode(): PriceScaleMode {
+ return this.mode;
+ }
+
+ public setMode(mode: PriceScaleMode): void {
+ if (this.mode === mode) {
+ return;
+ }
+
+ this.mode = mode;
+
+ this.getPriceScaleApi().applyOptions({
+ mode,
+ autoScale: this.autoScaleEnabled,
+ });
+ }
+
+ public toggleLogarithmic(): void {
+ const nextMode = this.mode === PriceScaleMode.Logarithmic ? PriceScaleMode.Normal : PriceScaleMode.Logarithmic;
+
+ this.setMode(nextMode);
+ }
+
+ public isAutoScaleEnabled(): boolean {
+ return this.autoScaleEnabled;
+ }
+
+ public toggleAutoScale(): void {
+ this.autoScaleEnabled = !this.autoScaleEnabled;
+
+ this.getPriceScaleApi().setAutoScale(this.autoScaleEnabled);
+ }
+
+ public enableAutoScale(): void {
+ if (this.autoScaleEnabled) {
+ return;
+ }
+
+ this.autoScaleEnabled = true;
+ this.getPriceScaleApi().setAutoScale(true);
+ }
+
+ public isVisible(): boolean {
+ return this.visible;
+ }
+
+ public setVisible(visible: boolean): void {
+ if (this.visible === visible) {
+ return;
+ }
+
+ this.visible = visible;
+
+ this.getPriceScaleApi().applyOptions({
+ visible,
+ borderVisible: false,
+ });
+ }
+
+ public hasVisibleSeriesData(): boolean {
+ return this.hasVisibleSeriesDataCallback();
+ }
+
+ public getSnapshot(): PriceScaleSnapshot {
+ return {
+ side: this.side,
+ mode: this.mode,
+ };
+ }
+
+ private getPriceScaleApi(): IPriceScaleApi {
+ return this.pane.priceScale(this.side);
+ }
+}
diff --git a/src/core/PriceScale/PriceScaleControls.tsx b/src/core/PriceScale/PriceScaleControls.tsx
new file mode 100644
index 0000000..3162aa2
--- /dev/null
+++ b/src/core/PriceScale/PriceScaleControls.tsx
@@ -0,0 +1,181 @@
+import { PriceScaleMode } from 'lightweight-charts';
+
+import { PriceScaleControls as PriceScaleControlsView } from '@components/PriceScaleControls';
+import { ReactRenderer } from '@core/ReactRenderer';
+import { Direction } from '@src/types';
+
+import { PriceScale } from './PriceScale';
+
+interface PriceScaleControlsParams {
+ leftPriceScale: PriceScale;
+ rightPriceScale: PriceScale;
+ onPriceScaleChange: () => void;
+}
+
+export class PriceScaleControls {
+ private readonly leftPriceScale: PriceScale;
+ private readonly rightPriceScale: PriceScale;
+ private readonly onPriceScaleChange: () => void;
+ private readonly container: HTMLElement;
+ private readonly renderer: ReactRenderer;
+
+ private paneElement: HTMLElement | null = null;
+ private hoveredPriceScale: PriceScale | null = null;
+
+ constructor({ leftPriceScale, rightPriceScale, onPriceScaleChange }: PriceScaleControlsParams) {
+ this.leftPriceScale = leftPriceScale;
+ this.rightPriceScale = rightPriceScale;
+ this.onPriceScaleChange = onPriceScaleChange;
+
+ this.container = document.createElement('div');
+ this.container.className = 'moex-price-scale-controls';
+ this.container.style.position = 'absolute';
+ this.container.style.inset = '0';
+ this.container.style.display = 'flex';
+ this.container.style.alignItems = 'flex-end';
+ this.container.style.justifyContent = 'center';
+ this.container.style.zIndex = '10';
+ this.container.style.pointerEvents = 'none';
+
+ this.renderer = new ReactRenderer(this.container);
+ }
+
+ public mount(paneElement: HTMLElement): void {
+ if (this.paneElement === paneElement) {
+ this.refresh();
+ return;
+ }
+
+ this.unmount();
+
+ this.paneElement = paneElement;
+ this.paneElement.addEventListener('pointermove', this.handlePointerMove);
+ this.paneElement.addEventListener('pointerleave', this.handlePointerLeave);
+
+ this.refresh();
+ }
+
+ public refresh(): void {
+ if (!this.hoveredPriceScale) {
+ this.hide();
+ return;
+ }
+
+ this.render(this.hoveredPriceScale);
+ }
+
+ public destroy(): void {
+ this.unmount();
+ this.renderer.destroy();
+ }
+
+ private handlePointerMove = (event: PointerEvent): void => {
+ const nextPriceScale = this.getHoveredPriceScale(event.clientX);
+
+ if (this.hoveredPriceScale === nextPriceScale) {
+ return;
+ }
+
+ this.hoveredPriceScale = nextPriceScale;
+ this.refresh();
+ };
+
+ private handlePointerLeave = (): void => {
+ this.hoveredPriceScale = null;
+ this.refresh();
+ };
+
+ private render(priceScale: PriceScale): void {
+ const priceScaleElement = this.getPriceScaleElement(priceScale);
+
+ if (!priceScaleElement || !this.canShowControls(priceScale, priceScaleElement)) {
+ this.hide();
+ return;
+ }
+
+ priceScaleElement.style.position = 'relative';
+
+ if (this.container.parentElement !== priceScaleElement) {
+ priceScaleElement.appendChild(this.container);
+ }
+
+ this.renderer.renderComponent(
+ <PriceScaleControlsView
+ isAutoScale={priceScale.isAutoScaleEnabled()}
+ isLogarithmic={priceScale.getMode() === PriceScaleMode.Logarithmic}
+ onToggleAutoScale={() => {
+ priceScale.toggleAutoScale();
+ this.refresh();
+ this.onPriceScaleChange();
+ }}
+ onToggleLogarithmic={() => {
+ priceScale.toggleLogarithmic();
+ this.refresh();
+ this.onPriceScaleChange();
+ }}
+ />,
+ );
+
+ this.container.classList.add('moex-price-scale-controls_visible');
+ }
+
+ private hide(): void {
+ this.container.classList.remove('moex-price-scale-controls_visible');
+ }
+
+ private getHoveredPriceScale(clientX: number): PriceScale | null {
+ if (this.isPointerInsidePriceScale(this.leftPriceScale, clientX)) {
+ return this.leftPriceScale;
+ }
+
+ if (this.isPointerInsidePriceScale(this.rightPriceScale, clientX)) {
+ return this.rightPriceScale;
+ }
+
+ return null;
+ }
+
+ private isPointerInsidePriceScale(priceScale: PriceScale, clientX: number): boolean {
+ const priceScaleElement = this.getPriceScaleElement(priceScale);
+
+ if (!priceScaleElement || !this.canShowControls(priceScale, priceScaleElement)) {
+ return false;
+ }
+
+ const rect = priceScaleElement.getBoundingClientRect();
+
+ return clientX >= rect.left && clientX <= rect.right;
+ }
+
+ private canShowControls(priceScale: PriceScale, priceScaleElement: HTMLElement): boolean {
+ return (
+ priceScale.isVisible() && priceScale.hasVisibleSeriesData() && priceScaleElement.getBoundingClientRect().width > 0
+ );
+ }
+
+ private getPriceScaleElement(priceScale: PriceScale): HTMLTableCellElement | null {
+ if (!this.paneElement) {
+ return null;
+ }
+
+ const cells = this.paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
+
+ if (cells.length < 3) {
+ return null;
+ }
+
+ return priceScale.side === Direction.Left ? cells.item(0) : cells.item(cells.length - 1);
+ }
+
+ private unmount(): void {
+ if (this.paneElement) {
+ this.paneElement.removeEventListener('pointermove', this.handlePointerMove);
+ this.paneElement.removeEventListener('pointerleave', this.handlePointerLeave);
+ }
+
+ this.paneElement = null;
+ this.hoveredPriceScale = null;
+ this.container.remove();
+ this.hide();
+ }
+}
diff --git a/src/core/PriceScale/index.ts b/src/core/PriceScale/index.ts
new file mode 100644
index 0000000..872bcc3
--- /dev/null
+++ b/src/core/PriceScale/index.ts
@@ -0,0 +1,2 @@
+export { PriceScale } from './PriceScale';
+export { PriceScaleControls } from './PriceScaleControls';
diff --git a/src/core/Series/BaseSeries.ts b/src/core/Series/BaseSeries.ts
index 15c2f00..74e19e0 100644
--- a/src/core/Series/BaseSeries.ts
+++ b/src/core/Series/BaseSeries.ts
@@ -152,13 +152,13 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
> => {
if (!param) {
const seriesData = this.data();
- const currentBar = seriesData.at(-1);
+ const currentBar = seriesData[seriesData.length - 1];
if (!currentBar) {
return {};
}
- return this.formatLegendValues(currentBar, seriesData.at(-2) ?? null);
+ return this.formatLegendValues(currentBar, seriesData[seriesData.length - 2] ?? null);
}
const currentBar = param.seriesData.get(this.lwcSeries) ?? null;
@@ -286,7 +286,8 @@ export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSer
}
public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
- const lastBar = this.lwcSeries.data().at(-1);
+ const data = this.lwcSeries.data();
+ const lastBar = data[data.length - 1];
if (!lastBar) {
this.lwcSeries.update(bar, false);
diff --git a/src/styles/global.scss b/src/styles/global.scss
index 9366d3e..9c43790 100644
--- a/src/styles/global.scss
+++ b/src/styles/global.scss
@@ -18,3 +18,17 @@
.moex-chart-drawing-tooltip .tooltip-arrow-apca {
display: none;
}
+
+.moex-price-scale-controls {
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 120ms ease;
+}
+
+.moex-price-scale-controls_visible {
+ opacity: 1;
+}
+
+.moex-price-scale-controls_visible .moex-price-scale-controls-content {
+ pointer-events: auto;
+}
diff --git a/src/translations/russianDict.ts b/src/translations/russianDict.ts
index 97e706c..97958be 100644
--- a/src/translations/russianDict.ts
+++ b/src/translations/russianDict.ts
@@ -4,18 +4,32 @@ export const russian = {
'Add volume indicator': 'Добавить индикатор объёма',
tick: 'тик',
ticks: 'тики',
+ tick_few: 'тика',
+ tick_many: 'тиков',
second: 'секунда',
seconds: 'секунды',
+ second_few: 'секунды',
+ second_many: 'секунд',
minute: 'минута',
minutes: 'минуты',
+ minute_few: 'минуты',
+ minute_many: 'минут',
hour: 'час',
hours: 'часы',
+ hour_few: 'часа',
+ hour_many: 'часов',
day: 'день',
days: 'дни',
+ day_few: 'дня',
+ day_many: 'дней',
week: 'неделя',
weeks: 'недели',
- months: 'месяца',
+ week_few: 'недели',
+ week_many: 'недель',
month: 'месяц',
+ months: 'месяца',
+ month_few: 'месяца',
+ month_many: 'месяцев',
Bars: 'Бары',
Line: 'Линия',
Candles: 'Свечи',
@@ -139,6 +153,8 @@ export const russian = {
Stop: 'Стоп',
Vol: 'Объем',
bars: 'бары',
+ AutoScale: 'Автомасштаб',
+ Logarithmic: 'Логарифмическая',
'1t': '1т',
'10t': '10т',
diff --git a/src/types/snapshot.ts b/src/types/snapshot.ts
index e476039..bcd0a80 100644
--- a/src/types/snapshot.ts
+++ b/src/types/snapshot.ts
@@ -1,50 +1,60 @@
-import { ChartSettings as ChartSettingsSnapshot, ChartSettingsSource } from '@core/ChartSettings';
import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { ChartSeriesType, IndicatorsIds, Timeframes } from '@lib';
+import { Direction } from '@src/types/chart';
import { IndicatorConfig } from '@src/types/indicator';
+import type { PriceScaleMode } from 'lightweight-charts';
+
+export type PriceScaleSide = Direction.Left | Direction.Right;
+
export interface ISerializable<T extends object> {
getSnapshot: () => T;
}
-export type InitialSnapshot = {
+export interface InitialSnapshot {
timeframe: Timeframes; // todo: move to snap
chartSeriesType: ChartSeriesType; // todo: move to snap
symbol: string; // todo: move to snap
-};
+}
-export type MoexChartSnapshot = {
+export interface MoexChartSnapshot {
// settings: ChartSettingsSnapshot;
charts: ChartSnapshot[];
-};
+}
-export type ChartSnapshot = {
+export interface ChartSnapshot {
timeframe: Timeframes;
chartSeriesType: ChartSeriesType;
symbol: string;
panes: PaneSnapshot[];
-};
+}
-export type PaneSnapshot = {
+export interface PriceScaleSnapshot {
+ side: PriceScaleSide;
+ mode: PriceScaleMode;
+}
+
+export interface PaneSnapshot {
isMain: boolean;
id: number;
indicators: IndicatorSnapshot[];
drawings: DrawingsManagerSnapshot;
-};
+ priceScales?: PriceScaleSnapshot[];
+}
-export type IndicatorSnapshot = Partial<DOMObjectSnapshot> & {
+export interface IndicatorSnapshot extends Partial<DOMObjectSnapshot> {
dataSource?: DataSource;
indicatorType: IndicatorsIds | undefined; // if indicatorType is undefined, then its compareIndicator
config?: IndicatorConfig;
-};
+}
// todo: move DrawingsManagerSnapshot here
-export type DOMObjectSnapshot = {
+export interface DOMObjectSnapshot {
id: string;
name: string;
zIndex: number;
hidden: boolean;
paneId: number;
-};
+}
diff --git a/src/utils/formatTimeframeLabel.ts b/src/utils/formatTimeframeLabel.ts
new file mode 100644
index 0000000..9a5b99f
--- /dev/null
+++ b/src/utils/formatTimeframeLabel.ts
@@ -0,0 +1,83 @@
+import { getLocale, t } from '@src/translations';
+
+export type TimeframeUnit = 'tick' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
+
+interface UnitForms {
+ one: string;
+ few?: string;
+ many?: string;
+ other: string;
+}
+
+const pluralRulesCache = new Map<string, Intl.PluralRules>();
+
+const unitForms: Record<TimeframeUnit, UnitForms> = {
+ tick: {
+ one: 'tick',
+ few: 'tick_few',
+ many: 'tick_many',
+ other: 'ticks',
+ },
+ second: {
+ one: 'second',
+ few: 'second_few',
+ many: 'second_many',
+ other: 'seconds',
+ },
+ minute: {
+ one: 'minute',
+ few: 'minute_few',
+ many: 'minute_many',
+ other: 'minutes',
+ },
+ hour: {
+ one: 'hour',
+ few: 'hour_few',
+ many: 'hour_many',
+ other: 'hours',
+ },
+ day: {
+ one: 'day',
+ few: 'day_few',
+ many: 'day_many',
+ other: 'days',
+ },
+ week: {
+ one: 'week',
+ few: 'week_few',
+ many: 'week_many',
+ other: 'weeks',
+ },
+ month: {
+ one: 'month',
+ few: 'month_few',
+ many: 'month_many',
+ other: 'months',
+ },
+};
+
+const getPluralRules = (): Intl.PluralRules => {
+ const locale = getLocale();
+ const cachedRules = pluralRulesCache.get(locale);
+
+ if (cachedRules) {
+ return cachedRules;
+ }
+
+ const rules = new Intl.PluralRules(locale);
+
+ pluralRulesCache.set(locale, rules);
+
+ return rules;
+};
+
+const getUnitTranslationKey = (value: number, unit: TimeframeUnit): string => {
+ const form = getPluralRules().select(value);
+ const forms = unitForms[unit];
+
+ return forms[form as keyof UnitForms] ?? forms.other;
+};
+
+export const formatTimeframeLabel = (value: number, unit: TimeframeUnit): string => {
+ return `${value} ${t(getUnitTranslationKey(value, unit))}`;
+};
diff --git a/src/utils/index.ts b/src/utils/index.ts
index ab3614b..e589bb1 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,6 +2,7 @@ export * from './calcTooltipPosition';
export * from './delay';
export * from './enshureDefind';
export * from './formatter';
+export * from './formatTimeframeLabel';
export * from './formatValues';
export * from './getPortalHost';
export * from './getTimeframeByInterval';