Загрузка данных
import { Subscription } from 'rxjs';
import type { HoveredSeriesMarker, IPriceLine, SeriesMarker } from 'lightweight-charts';
import { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
import type { PriceAxisLabel } from './types';
import type { ISeriesExtended } from '../Series';
interface HistoricalLabelContext {
value: string;
price: number;
color: string;
textColor: string;
source: string;
}
export interface PriceAxisLabelsControllerOptions {
getMainSeries: () => ISeriesExtended | null;
getCompareSeries: () => readonly ISeriesExtended[];
hoveredMarker$: {
subscribe: (handler: (marker: HoveredSeriesMarker | null) => void) => Subscription;
};
crosshairSeriesMarker$: {
subscribe: (handler: (marker: SeriesMarker<unknown> | null) => void) => Subscription;
};
}
export class PriceAxisLabelsController {
private readonly options: PriceAxisLabelsControllerOptions;
private readonly labelsPrimitive = new PriceAxisLabelsPrimitive();
private readonly labelsBySource = new Map<string, PriceAxisLabel>();
private readonly currentPriceLines = new Map<string, IPriceLine>();
private readonly subscriptions = new Subscription();
private hoveredMarker: HoveredSeriesMarker | null = null;
private crosshairMarker: SeriesMarker<unknown> | null = null;
constructor(options: PriceAxisLabelsControllerOptions) {
this.options = options;
this.subscriptions.add(
this.options.hoveredMarker$.subscribe((marker) => {
this.hoveredMarker = marker;
this.sync();
}),
);
this.subscriptions.add(
this.options.crosshairSeriesMarker$.subscribe((marker) => {
this.crosshairMarker = marker;
this.sync();
}),
);
}
attach(): void {
const mainSeries = this.options.getMainSeries();
if (mainSeries) {
mainSeries.getLwcSeries().attachPrimitive(this.labelsPrimitive);
}
}
detach(): void {
const mainSeries = this.options.getMainSeries();
if (mainSeries) {
mainSeries.getLwcSeries().detachPrimitive(this.labelsPrimitive);
}
this.clearCurrentPriceLines();
this.labelsPrimitive.clear();
this.labelsBySource.clear();
}
destroy(): void {
this.subscriptions.unsubscribe();
this.detach();
}
updateSeriesTitle(series: ISeriesExtended, title: string): void {
const line = this.currentPriceLines.get(this.getSourceKey(series));
if (line) {
line.applyOptions({ title });
}
}
sync(): void {
const mainSeries = this.options.getMainSeries();
if (!mainSeries) {
this.clearCurrentPriceLines();
this.labelsPrimitive.clear();
this.labelsBySource.clear();
return;
}
const seriesList = [mainSeries, ...this.options.getCompareSeries()];
const activeMarker = this.hoveredMarker ?? this.crosshairMarker;
if (!activeMarker) {
this.clearHistoricalLabels();
this.syncCurrentPriceLines(seriesList);
this.render();
return;
}
const nextHistoricalLabels = new Map<string, PriceAxisLabel>();
seriesList.forEach((series) => {
const label = this.buildHistoricalLabel(series, activeMarker);
if (label) {
nextHistoricalLabels.set(this.getSourceKey(series), label);
}
});
this.replaceHistoricalLabels(nextHistoricalLabels);
this.syncCurrentPriceLines(seriesList);
this.render();
}
private syncCurrentPriceLines(seriesList: readonly ISeriesExtended[]): void {
const actualSources = new Set(seriesList.map((series) => this.getSourceKey(series)));
Array.from(this.currentPriceLines.keys()).forEach((sourceKey) => {
if (!actualSources.has(sourceKey)) {
const series = this.findSeriesBySource(seriesList, sourceKey);
if (series) {
this.removeCurrentPriceLine(series);
} else {
const orphanLine = this.currentPriceLines.get(sourceKey);
if (orphanLine) {
const fallbackSeries = this.findSeriesBySource(
[this.options.getMainSeries(), ...this.options.getCompareSeries()].filter(Boolean) as ISeriesExtended[],
sourceKey,
);
fallbackSeries?.getLwcSeries().removePriceLine(orphanLine);
}
this.currentPriceLines.delete(sourceKey);
}
}
});
seriesList.forEach((series) => {
this.ensureCurrentPriceLine(series);
});
}
private ensureCurrentPriceLine(series: ISeriesExtended): void {
const sourceKey = this.getSourceKey(series);
const seriesApi = series.getLwcSeries();
const lastData = this.getLastSeriesData(series);
if (!lastData) {
this.removeCurrentPriceLine(series);
return;
}
const color = this.resolveSeriesColor(series);
const textColor = this.resolveLabelTextColor(color);
const title = this.resolveRealtimeTitle(series);
const existingLine = this.currentPriceLines.get(sourceKey);
if (existingLine) {
existingLine.applyOptions({
price: lastData.value,
color,
lineColor: color,
axisLabelColor: color,
axisLabelTextColor: textColor,
title,
lineVisible: true,
axisLabelVisible: true,
});
return;
}
const line = seriesApi.createPriceLine({
price: lastData.value,
color,
lineColor: color,
axisLabelColor: color,
axisLabelTextColor: textColor,
title,
lineVisible: true,
axisLabelVisible: true,
});
this.currentPriceLines.set(sourceKey, line);
}
private clearCurrentPriceLines(): void {
const seriesList = this.getAllSeries();
Array.from(this.currentPriceLines.entries()).forEach(([sourceKey, line]) => {
const series = this.findSeriesBySource(seriesList, sourceKey);
if (series) {
series.getLwcSeries().removePriceLine(line);
}
});
this.currentPriceLines.clear();
}
private removeCurrentPriceLine(series: ISeriesExtended): void {
const sourceKey = this.getSourceKey(series);
const line = this.currentPriceLines.get(sourceKey);
if (!line) {
return;
}
series.getLwcSeries().removePriceLine(line);
this.currentPriceLines.delete(sourceKey);
}
private replaceHistoricalLabels(nextHistoricalLabels: ReadonlyMap<string, PriceAxisLabel>): void {
Array.from(this.labelsBySource.keys()).forEach((sourceKey) => {
if (!nextHistoricalLabels.has(sourceKey)) {
this.labelsBySource.delete(sourceKey);
}
});
Array.from(nextHistoricalLabels.entries()).forEach(([sourceKey, label]) => {
this.labelsBySource.set(sourceKey, label);
});
}
private clearHistoricalLabels(): void {
this.labelsBySource.clear();
}
private buildHistoricalLabel(
series: ISeriesExtended,
marker: HoveredSeriesMarker | SeriesMarker<unknown>,
): PriceAxisLabel | null {
const markerSource = this.extractMarkerSource(marker);
const seriesSource = this.getSourceKey(series);
if (!markerSource || markerSource !== seriesSource) {
return null;
}
const historical = this.extractHistoricalLabelContext(series, marker);
if (!historical) {
return null;
}
const current = this.getCurrentPriceContext(series);
const shouldElevate = current ? Math.abs(current.price - historical.price) < this.getLabelCollisionThreshold(series) : false;
return {
source: seriesSource,
price: historical.price,
text: historical.value,
color: historical.color,
textColor: historical.textColor,
borderColor: historical.color,
style: 'outlined',
verticalOffset: shouldElevate ? -22 : -2,
};
}
private getCurrentPriceContext(series: ISeriesExtended): HistoricalLabelContext | null {
const lastData = this.getLastSeriesData(series);
if (!lastData) {
return null;
}
const color = this.resolveSeriesColor(series);
return {
source: this.getSourceKey(series),
value: this.formatValue(series, lastData.value),
price: lastData.value,
color,
textColor: this.resolveLabelTextColor(color),
};
}
private extractHistoricalLabelContext(
series: ISeriesExtended,
marker: HoveredSeriesMarker | SeriesMarker<unknown>,
): HistoricalLabelContext | null {
const price = this.extractMarkerPrice(marker);
if (price === null) {
return null;
}
const color = this.resolveSeriesColor(series);
return {
source: this.getSourceKey(series),
value: this.formatValue(series, price),
price,
color,
textColor: color,
};
}
private extractMarkerSource(marker: HoveredSeriesMarker | SeriesMarker<unknown>): string | null {
if ('data' in marker && marker.data) {
return String(marker.data);
}
if ('source' in marker && marker.source) {
return String(marker.source);
}
return null;
}
private extractMarkerPrice(marker: HoveredSeriesMarker | SeriesMarker<unknown>): number | null {
if ('logicalPrice' in marker && typeof marker.logicalPrice === 'number') {
return marker.logicalPrice;
}
if ('price' in marker && typeof marker.price === 'number') {
return marker.price;
}
return null;
}
private getLastSeriesData(series: ISeriesExtended): { value: number } | null {
const data = series.data();
if (!data.length) {
return null;
}
const lastItem = data[data.length - 1];
if ('value' in lastItem && typeof lastItem.value === 'number') {
return { value: lastItem.value };
}
if ('close' in lastItem && typeof lastItem.close === 'number') {
return { value: lastItem.close };
}
return null;
}
private formatValue(series: ISeriesExtended, price: number): string {
const formatter = series.getPriceFormatter();
return formatter ? formatter(price) : String(price);
}
private resolveSeriesColor(series: ISeriesExtended): string {
return series.getCurrentColor();
}
private resolveLabelTextColor(backgroundColor: string): string {
const normalized = backgroundColor.trim().replace('#', '');
if (normalized.length !== 6) {
return '#FFFFFF';
}
const red = Number.parseInt(normalized.slice(0, 2), 16);
const green = Number.parseInt(normalized.slice(2, 4), 16);
const blue = Number.parseInt(normalized.slice(4, 6), 16);
const luminance = (red * 299 + green * 587 + blue * 114) / 1000;
return luminance >= 140 ? '#0B0E11' : '#FFFFFF';
}
private getLabelCollisionThreshold(series: ISeriesExtended): number {
const current = this.getCurrentPriceContext(series);
if (!current) {
return 0;
}
const absPrice = Math.abs(current.price);
if (absPrice >= 10000) {
return absPrice * 0.001;
}
if (absPrice >= 1000) {
return absPrice * 0.002;
}
if (absPrice >= 100) {
return absPrice * 0.004;
}
if (absPrice >= 10) {
return absPrice * 0.008;
}
return 0.1;
}
private render(): void {
const labels = Array.from(this.labelsBySource.values());
const sorted = labels.slice().sort((a, b) => a.price - b.price);
const prepared = sorted.map((label, index, array) => {
const previous = index > 0 ? array[index - 1] : null;
const next = index < array.length - 1 ? array[index + 1] : null;
let verticalOffset = label.verticalOffset ?? 0;
if (previous && Math.abs(label.price - previous.price) < 0.0000001) {
verticalOffset += 18;
}
if (next && Math.abs(next.price - label.price) < 0.0000001) {
verticalOffset -= 18;
}
return {
...label,
verticalOffset,
};
});
this.labelsPrimitive.updateAll(prepared, prepared);
}
private getAllSeries(): ISeriesExtended[] {
const main = this.options.getMainSeries();
const compare = this.options.getCompareSeries();
return [main, ...compare].filter(Boolean) as ISeriesExtended[];
}
private findSeriesBySource(seriesList: readonly ISeriesExtended[], sourceKey: string): ISeriesExtended | null {
return seriesList.find((series) => this.getSourceKey(series) === sourceKey) ?? null;
}
private getSourceKey(series: ISeriesExtended): string {
return series.seriesSource() ?? series.seriesOptions().symbol;
}
private resolveRealtimeTitle(series: ISeriesExtended): string {
return series.seriesOptions().showSymbolLabel ? series.seriesOptions().symbol : '';
}
}