Загрузка данных
import { getThemeStore } from '@src/theme';
import { Direction } from '@src/types';
import { layoutPriceAxisLabels } from './PriceAxisLabelsLayout';
import { getAxisSideBySeries, getContrastTextColor } from './utils';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
ChartOptions,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import type { LaidOutPriceAxisLabel, MeasuredPriceAxisLabel, PriceAxisLabel, PriceAxisSide } from './types';
type ChartLayoutOptions = Readonly<ChartOptions['layout']>;
interface PriceAxisLabelsRenderState {
labels: PriceAxisLabel[];
reservedCoordinate: number | null;
chart: SeriesAttachedParameter<Time>['chart'] | null;
series: SeriesAttachedParameter<Time>['series'] | null;
}
interface LabelGeometry {
label: LaidOutPriceAxisLabel;
left: number;
top: number;
textX: number;
textY: number;
}
const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3.75 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = 0.5 / 12;
const LABEL_EDGE_GAP = 1;
function getFont(layout: ChartLayoutOptions): string {
return `${layout.fontSize}px ${layout.fontFamily}`;
}
function getLabelTextColor(label: PriceAxisLabel): string {
if (label.style === 'outlined') {
return label.color;
}
return getContrastTextColor(label.color);
}
function areLabelsEqual(currentLabels: PriceAxisLabel[], nextLabels: PriceAxisLabel[]): boolean {
return (
currentLabels.length === nextLabels.length &&
currentLabels.every((currentLabel, index) => {
const nextLabel = nextLabels[index];
return (
currentLabel.id === nextLabel.id &&
currentLabel.desiredCoordinate === nextLabel.desiredCoordinate &&
currentLabel.text === nextLabel.text &&
currentLabel.color === nextLabel.color &&
currentLabel.style === nextLabel.style &&
currentLabel.priority === nextLabel.priority
);
})
);
}
function measureLabels(
context: CanvasRenderingContext2D,
labels: PriceAxisLabel[],
layout: ChartLayoutOptions,
): MeasuredPriceAxisLabel[] {
const horizontalPadding = layout.fontSize * HORIZONTAL_PADDING_RATIO;
const verticalPadding = layout.fontSize * VERTICAL_PADDING_RATIO;
return labels.map((label) => {
const textMetrics = context.measureText(label.text);
const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent || layout.fontSize;
return {
...label,
width: Math.ceil(textMetrics.width) + horizontalPadding * 2,
height: textHeight + verticalPadding * 2,
};
});
}
function createLabelGeometries(
labels: MeasuredPriceAxisLabel[],
axisWidth: number,
axisHeight: number,
side: PriceAxisSide,
layout: ChartLayoutOptions,
reservedCoordinate: number | null,
): LabelGeometry[] {
const reservedHeight = labels.reduce(
(maximumHeight, label) => Math.max(maximumHeight, label.height),
layout.fontSize + layout.fontSize * VERTICAL_PADDING_RATIO * 2,
);
const laidOutLabels =
reservedCoordinate === null
? layoutPriceAxisLabels(labels, axisHeight)
: layoutPriceAxisLabels(labels, axisHeight, {
reservedCoordinate,
reservedHeight,
});
return laidOutLabels.map((label) => {
const left =
side === Direction.Right ? LABEL_EDGE_GAP : Math.max(LABEL_EDGE_GAP, axisWidth - label.width - LABEL_EDGE_GAP);
return {
label,
left,
top: label.coordinate - label.height / 2,
textX: left + label.width / 2,
textY: label.coordinate + layout.fontSize * TEXT_VERTICAL_OFFSET_RATIO,
};
});
}
function drawLabelBackgrounds(target: CanvasRenderingTarget2D, geometries: LabelGeometry[]): void {
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const { colors } = getThemeStore();
context.save();
geometries.forEach(({ label, left, top }) => {
const bitmapLeft = Math.round(left * horizontalPixelRatio);
const bitmapTop = Math.round(top * verticalPixelRatio);
const bitmapWidth = Math.round(label.width * horizontalPixelRatio);
const bitmapHeight = Math.round(label.height * verticalPixelRatio);
context.fillStyle = label.style === 'outlined' ? colors.chartBackground : label.color;
context.fillRect(bitmapLeft, bitmapTop, bitmapWidth, bitmapHeight);
if (label.style !== 'outlined') {
return;
}
const borderWidth = Math.max(1, Math.floor(Math.min(horizontalPixelRatio, verticalPixelRatio)));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
bitmapLeft + borderWidth / 2,
bitmapTop + borderWidth / 2,
Math.max(0, bitmapWidth - borderWidth),
Math.max(0, bitmapHeight - borderWidth),
);
});
context.restore();
});
}
function drawLabelTexts(
target: CanvasRenderingTarget2D,
geometries: LabelGeometry[],
layout: ChartLayoutOptions,
): void {
target.useMediaCoordinateSpace(({ context }) => {
context.save();
context.font = getFont(layout);
context.textAlign = 'center';
context.textBaseline = 'middle';
geometries.forEach(({ label, textX, textY }) => {
context.fillStyle = getLabelTextColor(label);
context.fillText(label.text, textX, textY);
});
context.restore();
});
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(private readonly state: PriceAxisLabelsRenderState) {}
public draw(target: CanvasRenderingTarget2D): void {
const { labels, chart, series, reservedCoordinate } = this.state;
const layout = chart?.options().layout;
if (!layout || labels.length === 0) {
return;
}
let geometries: LabelGeometry[] = [];
target.useMediaCoordinateSpace(({ context, mediaSize }) => {
if (mediaSize.width <= 0 || mediaSize.height <= 0) {
return;
}
context.save();
context.font = getFont(layout);
const measuredLabels = measureLabels(context, labels, layout);
geometries = createLabelGeometries(
measuredLabels,
mediaSize.width,
mediaSize.height,
getAxisSideBySeries(series),
layout,
reservedCoordinate,
);
context.restore();
});
if (geometries.length === 0) {
return;
}
drawLabelBackgrounds(target, geometries);
drawLabelTexts(target, geometries, layout);
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(private readonly state: PriceAxisLabelsRenderState) {
this.rendererInstance = new PriceAxisLabelsRenderer(state);
}
public renderer(): IPrimitivePaneRenderer | null {
return this.state.labels.length > 0 ? this.rendererInstance : null;
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
private readonly state: PriceAxisLabelsRenderState = {
labels: [],
reservedCoordinate: null,
chart: null,
series: null,
};
private readonly priceAxisPaneView = new PriceAxisLabelsPaneView(this.state);
private readonly priceAxisPaneViewList: IPrimitivePaneView[] = [this.priceAxisPaneView];
private requestUpdate: (() => void) | null = null;
public attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
this.state.chart = chart;
this.state.series = series;
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.state.chart = null;
this.state.series = null;
this.state.labels = [];
this.state.reservedCoordinate = null;
this.requestUpdate = null;
}
public priceAxisPaneViews(): IPrimitivePaneView[] {
return this.priceAxisPaneViewList;
}
public updateAllViews(): void {}
public setLabels(labels: PriceAxisLabel[], reservedCoordinate: number | null = null): void {
if (areLabelsEqual(this.state.labels, labels) && this.state.reservedCoordinate === reservedCoordinate) {
return;
}
this.state.labels = labels;
this.state.reservedCoordinate = reservedCoordinate;
this.requestUpdate?.();
}
public clear(): void {
if (this.state.labels.length === 0 && this.state.reservedCoordinate === null) {
return;
}
this.state.labels = [];
this.state.reservedCoordinate = null;
this.requestUpdate?.();
}
}
import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { Direction } from '@src/types';
import type { PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
interface PriceScaleParams {
paneId: number;
side: PriceScaleSide;
chart: IChartApi;
pane: IPaneApi<Time>;
initialMode?: PriceScaleMode;
hasVisibleSeriesData: () => boolean;
}
const PRICE_SCALE_MINIMUM_WIDTH = 72;
export class PriceScale {
public readonly paneId: number;
public readonly side: PriceScaleSide;
private readonly chart: IChartApi;
private readonly pane: IPaneApi<Time>;
private readonly hasVisibleSeriesDataCallback: () => boolean;
private mode: PriceScaleMode;
private autoScaleEnabled = true;
private visible: boolean;
private initialized = false;
private autoScaleWasChanged = false;
private visibilityWasChanged = false;
constructor({
paneId,
side,
chart,
pane,
initialMode = PriceScaleMode.Normal,
hasVisibleSeriesData,
}: PriceScaleParams) {
this.paneId = paneId;
this.side = side;
this.chart = chart;
this.pane = pane;
this.mode = initialMode;
this.visible = side === Direction.Right;
this.hasVisibleSeriesDataCallback = hasVisibleSeriesData;
}
public initialize(): void {
if (this.initialized) {
return;
}
const priceScale = this.getPriceScaleApi();
const options = priceScale.options();
if (!this.autoScaleWasChanged) {
this.autoScaleEnabled = options.autoScale ?? true;
}
if (!this.visibilityWasChanged) {
this.visible = options.visible !== false;
}
this.initialized = true;
priceScale.applyOptions({
mode: this.mode,
autoScale: this.autoScaleEnabled,
visible: this.visible,
borderVisible: false,
minimumWidth: Math.max(options.minimumWidth ?? 0, PRICE_SCALE_MINIMUM_WIDTH),
});
}
public getMode(): PriceScaleMode {
return this.mode;
}
public setMode(mode: PriceScaleMode): void {
if (this.mode === mode) {
return;
}
this.mode = mode;
if (!this.initialized) {
return;
}
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.autoScaleWasChanged = true;
if (this.initialized) {
this.getPriceScaleApi().setAutoScale(this.autoScaleEnabled);
}
}
public enableAutoScale(): void {
if (this.autoScaleEnabled) {
return;
}
this.autoScaleEnabled = true;
this.autoScaleWasChanged = true;
if (this.initialized) {
this.getPriceScaleApi().setAutoScale(true);
}
}
public isVisible(): boolean {
return this.visible;
}
public setVisible(visible: boolean): void {
if (!this.initialized) {
this.visible = visible;
this.visibilityWasChanged = true;
return;
}
if (this.visible === visible) {
return;
}
this.visible = visible;
this.visibilityWasChanged = true;
this.getPriceScaleApi().applyOptions({
visible,
borderVisible: false,
});
}
public getWidth(): number {
if (!this.initialized) {
return 0;
}
return this.getPriceScaleApi().width();
}
public hasVisibleSeriesData(): boolean {
return this.hasVisibleSeriesDataCallback();
}
public getSnapshot(): PriceScaleSnapshot {
return {
side: this.side,
mode: this.mode,
};
}
private getPriceScaleApi() {
return this.chart.priceScale(this.side, this.pane.paneIndex());
}
}
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 {
chartContainer: HTMLElement;
leftPriceScale: PriceScale;
rightPriceScale: PriceScale;
onPriceScaleChange: () => void;
}
export class PriceScaleControls {
private readonly chartContainer: HTMLElement;
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({
chartContainer,
leftPriceScale,
rightPriceScale,
onPriceScaleChange,
}: PriceScaleControlsParams) {
this.chartContainer = chartContainer;
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.zIndex = '10';
this.container.style.pointerEvents = 'none';
this.renderer = new ReactRenderer(this.container);
}
public mount(paneElement: HTMLElement): void {
if (this.paneElement === paneElement && this.container.parentElement === this.chartContainer) {
this.refresh();
return;
}
this.unmount();
this.paneElement = paneElement;
this.chartContainer.appendChild(this.container);
this.chartContainer.addEventListener('pointermove', this.handlePointerMove);
this.chartContainer.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();
this.container.remove();
}
private handlePointerMove = (event: PointerEvent): void => {
if (!this.paneElement) {
return;
}
const chartRect = this.chartContainer.getBoundingClientRect();
const paneRect = this.paneElement.getBoundingClientRect();
const isInsidePane = event.clientY >= paneRect.top && event.clientY < paneRect.bottom;
const nextPriceScale = isInsidePane
? this.getHoveredPriceScale(event.clientX - chartRect.left, chartRect.width)
: null;
if (this.hoveredPriceScale === nextPriceScale) {
if (nextPriceScale) {
this.updatePosition(nextPriceScale);
}
return;
}
this.hoveredPriceScale = nextPriceScale;
this.refresh();
};
private handlePointerLeave = (): void => {
this.hoveredPriceScale = null;
this.refresh();
};
private render(priceScale: PriceScale): void {
if (!this.canShowControls(priceScale)) {
this.hide();
return;
}
this.updatePosition(priceScale);
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(pointerX: number, chartWidth: number): PriceScale | null {
const leftWidth = this.leftPriceScale.getWidth();
const rightWidth = this.rightPriceScale.getWidth();
if (this.canShowControls(this.leftPriceScale) && pointerX >= 0 && pointerX <= leftWidth) {
return this.leftPriceScale;
}
if (this.canShowControls(this.rightPriceScale) && pointerX >= chartWidth - rightWidth && pointerX <= chartWidth) {
return this.rightPriceScale;
}
return null;
}
private updatePosition(priceScale: PriceScale): void {
if (!this.paneElement) {
return;
}
const chartRect = this.chartContainer.getBoundingClientRect();
const paneRect = this.paneElement.getBoundingClientRect();
const bottomOffset = Math.max(0, chartRect.bottom - paneRect.bottom);
this.container.style.width = `${priceScale.getWidth()}px`;
this.container.style.bottom = `${bottomOffset}px`;
if (priceScale.side === Direction.Left) {
this.container.style.left = '0';
this.container.style.right = '';
return;
}
this.container.style.left = '';
this.container.style.right = '0';
}
private canShowControls(priceScale: PriceScale): boolean {
return priceScale.isVisible() && priceScale.getWidth() > 0 && priceScale.hasVisibleSeriesData();
}
private unmount(): void {
this.chartContainer.removeEventListener('pointermove', this.handlePointerMove);
this.chartContainer.removeEventListener('pointerleave', this.handlePointerLeave);
this.paneElement = null;
this.hoveredPriceScale = null;
this.hide();
}
}
import { IChartApi, IPaneApi, PriceScaleMode, 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, 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, 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 { 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 {
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;
modalRenderer: ModalRenderer;
initialPriceScales?: PriceScaleSnapshot[];
onPriceScaleStateChange: () => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
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 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 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,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
}: PaneParams) {
this.onDelete = onDelete;
this.onPriceScaleStateChange = onPriceScaleStateChange;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane;
this.id = id;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales);
this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales);
this.priceScaleControls = new PriceScaleControls({
chartContainer,
leftPriceScale: this.leftPriceScale,
rightPriceScale: this.rightPriceScale,
onPriceScaleChange: this.handlePriceScaleStateChange,
});
this.initializeLegend({ ohlcConfig });
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,
modalRenderer: this.modalRenderer,
paneId: this.id,
});
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public isMainPane = () => {
return this.isMain;
};
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.drawingsManager.setSnapshot(snapshot);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
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.onDelete();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
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[]): PriceScale {
const initialMode =
initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;
return new PriceScale({
paneId: this.id,
side,
chart: this.lwcChart,
pane: this.lwcPane,
initialMode,
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);
this.schedulePaneContainerSync();
// 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,
paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsConfig(),
},
]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey="arguments"
/>,
{
size: 'sm',
title: indicatorLabelById()[indicatorId],
onSave: () => indicator.updateSettings(settings),
},
);
},
// todo: throw isMainPane
});
this.legendRenderer.renderComponent(
<LegendComponent
ohlcConfig={this.legend.getConfig()}
viewModel={this.legend.getLegendViewModel()}
/>,
);
}
private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
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);
this.priceScaleControls.refresh();
});
}
private syncPaneContainers(): void {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) {
this.schedulePaneContainerSync();
return;
}
this.leftPriceScale.initialize();
this.rightPriceScale.initialize();
lwcPaneElement.style.position = 'relative';
lwcPaneElement.appendChild(this.legendContainer);
lwcPaneElement.appendChild(this.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { ISerializable, PaneSnapshot, 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'
> {
panesSnapshot: PaneSnapshot[];
}
interface PriceAxisLabelsSources {
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly sharedPaneParams: SharedPaneParams;
private readonly panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
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,
});
this.panesMap.set(mainPaneId, this.mainPane);
if (mainPaneSnapshot) {
this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
}
const greatestPaneId = panesSnapshot.reduce(
(greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
mainPaneId,
);
this.nextPaneId = greatestPaneId + 1;
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
});
this.syncPaneContainers();
}
public initializePriceAxisLabels({ compareEntities$, indicatorEntities$ }: PriceAxisLabelsSources): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$: this.mainPane.getMainSerie().asObservable(),
mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
}
public invalidatePriceAxisLabels(): void {
this.priceAxisLabels?.invalidate();
}
public getPaneById(id: number): Pane | undefined {
return this.panesMap.get(id);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes(): Map<number, Pane> {
return this.panesMap;
}
public getMainPane = (): Pane => {
return this.mainPane;
};
public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(this.nextPaneId, id + 1);
const pane = new Pane({
...this.sharedPaneParams,
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
onPriceScaleStateChange: this.handlePriceScaleStateChange,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public refreshPriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.refreshPriceScaleControls();
});
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
public getSnapshot(): PaneSnapshot[] {
const snapshot: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
snapshot.push(pane.getSnapshot());
});
return snapshot;
}
public destroy(): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = null;
this.panesMap.forEach((pane) => {
pane.destroy();
});
this.panesMap.clear();
}
private destroyPane(id: number): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
const paneIndex = pane.paneIndex();
this.panesMap.delete(id);
pane.destroy();
if (paneIndex >= 0) {
this.sharedPaneParams.lwcChart.removePane(paneIndex);
}
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
private handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}
import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';
import { flatten } from 'lodash-es';
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 { 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';
import { createFallbackColor, normalizeColor, normalizeSymbol } from '@src/utils';
interface CompareEntry {
key: string;
symbol: string;
mode: CompareMode;
symbol$: BehaviorSubject<string>;
entity: Indicator;
}
interface CompareManagerParams {
chart: IChartApi;
eventManager: EventManager;
dataSource: DataSource;
indicatorManager: IndicatorManager;
paneManager: PaneManager;
initialIndicators?: IndicatorSnapshot[];
}
export class CompareManager {
private readonly chart: IChartApi;
private readonly eventManager: EventManager;
private readonly dataSource: DataSource;
private readonly indicatorManager: IndicatorManager;
private readonly paneManager: PaneManager;
private readonly entries = new Map<string, CompareEntry>();
private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
private readonly subscriptions = new Subscription();
private hasPercentageComparison = false;
private restoringInitialIndicators = false;
constructor({
chart,
eventManager,
dataSource,
indicatorManager,
paneManager,
initialIndicators = [],
}: CompareManagerParams) {
this.chart = chart;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.indicatorManager = indicatorManager;
this.paneManager = paneManager;
this.subscriptions.add(
this.eventManager.timeframe().subscribe(() => {
this.applyPolicy();
}),
);
void this.setup(initialIndicators);
}
public itemsObs(): Observable<CompareItem[]> {
return this.itemsSubject.asObservable();
}
public entities(): Observable<Indicator[]> {
return this.entitiesSubject.asObservable();
}
public clear(): void {
const keys = Array.from(this.entries.keys());
for (let index = 0; index < keys.length; index += 1) {
this.removeEntry(keys[index]);
}
this.commitEntriesChange();
}
public async setSymbolMode(
seriesType: SeriesType,
symbolRaw: string,
mode: CompareMode,
paneId?: number,
): Promise<void> {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) {
return;
}
if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
return;
}
const key = makeKey(symbol, mode);
if (this.entries.has(key)) {
return;
}
const symbol$ = new BehaviorSubject(symbol);
const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
const usedColorsByCompare = this.entitiesSubject.value.map(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(indicator) => indicator.getConfig().series?.[0]?.seriesOptions?.color,
);
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
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 =
mode === CompareMode.NewPane
? paneId !== undefined
? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
: this.paneManager.addPane()
: this.paneManager.getMainPane();
return new Indicator({
id: key,
lwcChart: this.chart,
mainSymbol$: symbol$,
dataSource: this.dataSource,
associatedPane,
config: {
...config,
series: [
{
...config.series[0],
seriesOptions: {
...config.series[0]?.seriesOptions,
priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
},
},
],
newPane: mode === CompareMode.NewPane,
},
zIndex,
onDelete: () => {
if (this.removeEntry(key)) {
this.commitEntriesChange();
}
},
moveUp,
moveDown,
paneId: associatedPane.getId(),
});
});
this.entries.set(key, {
key,
symbol,
mode,
symbol$,
entity,
});
this.commitEntriesChange();
await this.dataSource.isReady(symbol);
}
public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) {
return;
}
if (this.removeEntry(makeKey(symbol, mode))) {
this.commitEntriesChange();
}
}
public removeSymbol(symbolRaw: string): void {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) {
return;
}
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 {
return this.itemsSubject.value.length > 0;
}
public isNewScaleDisabledObservable(): Observable<boolean> {
return this.itemsSubject.pipe(
map((items) => items.length > 0),
distinctUntilChanged(),
);
}
public getAllEntities() {
return Array.from(this.entries.values()).map(({ symbol, entity, mode }) => ({
symbol,
entity,
mode,
}));
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.clear();
this.itemsSubject.complete();
this.entitiesSubject.complete();
}
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 false;
}
this.entries.delete(key);
this.indicatorManager.removeEntity(entry.entity);
entry.entity.destroy();
entry.symbol$.complete();
return true;
}
private commitEntriesChange(): void {
this.publish();
this.applyPolicy();
}
private publish(): void {
const values = Array.from(this.entries.values());
const items: CompareItem[] = [];
const entities: Indicator[] = [];
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 syncPercentageMode(priceScale: PriceScale, hasPercentageComparison: boolean): void {
if (this.restoringInitialIndicators) {
this.hasPercentageComparison = hasPercentageComparison;
return;
}
if (this.hasPercentageComparison === hasPercentageComparison) {
return;
}
const hadPercentageComparison = this.hasPercentageComparison;
this.hasPercentageComparison = hasPercentageComparison;
if (hasPercentageComparison && !hadPercentageComparison) {
priceScale.setMode(PriceScaleMode.Percentage);
return;
}
if (!hasPercentageComparison && hadPercentageComparison && priceScale.getMode() === PriceScaleMode.Percentage) {
priceScale.setMode(PriceScaleMode.Normal);
}
}
private applyPolicy(): void {
const entries = Array.from(this.entries.values());
const mainPane = this.paneManager.getMainPane();
let hasPercentageComparison = false;
const leftScalePaneIds = new Set<number>();
const additionalPaneIds = new Set<number>();
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const pane = entry.entity.getPane();
if (entry.mode === CompareMode.Percentage) {
hasPercentageComparison = true;
}
if (entry.mode === CompareMode.NewScale) {
leftScalePaneIds.add(pane.getId());
}
if (!pane.isMainPane()) {
additionalPaneIds.add(pane.getId());
}
}
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const pane = entry.entity.getPane();
// [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
const series = Array.from(entry.entity.getSeriesMap().values())[0];
if (!series) {
continue;
}
series.applyOptions({
priceScaleId:
pane.isMainPane() && entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
});
}
const mainLeftPriceScale = mainPane.getPriceScale(Direction.Left);
const mainRightPriceScale = mainPane.getPriceScale(Direction.Right);
mainLeftPriceScale.setVisible(leftScalePaneIds.has(mainPane.getId()));
mainRightPriceScale.setVisible(true);
this.syncPercentageMode(mainRightPriceScale, hasPercentageComparison);
const additionalPaneIdsList = Array.from(additionalPaneIds);
for (let index = 0; index < additionalPaneIdsList.length; index += 1) {
const pane = this.paneManager.getPaneById(additionalPaneIdsList[index]);
if (!pane) {
continue;
}
pane.getPriceScale(Direction.Right).setVisible(true);
pane.getPriceScale(Direction.Left).setVisible(leftScalePaneIds.has(pane.getId()));
}
this.paneManager.refreshPriceScaleControls();
this.paneManager.invalidatePriceAxisLabels();
}
}
function makeKey(symbol: string, mode: CompareMode): string {
return `${symbol}|${mode}`;
}
function getPaletteColorFromIndex(usedColors: Set<string>, startIndex: number): string {
for (let offset = 0; offset < COMPARE_COLOR_PALETTE.length; offset += 1) {
const color = COMPARE_COLOR_PALETTE[(startIndex + offset) % COMPARE_COLOR_PALETTE.length];
if (!usedColors.has(normalizeColor(color))) {
return color;
}
}
return createFallbackColor(usedColors.size);
}
const getDefaultCompareIndicatorConfig = (symbol: string, usedColors: string[]): IndicatorConfig => {
const reservedColors = new Set(usedColors.map(normalizeColor));
return {
newPane: true,
label: symbol,
series: [
{
name: 'Line', // todo: change with enum
id: `compare-${crypto.randomUUID()}`,
seriesOptions: {
visible: true,
color: getPaletteColorFromIndex(reservedColors, 0),
},
},
],
};
};