Загрузка данных
import {
IChartApi,
ISeriesApi,
ISeriesPrimitive,
LogicalRange,
MismatchDirection,
PriceScaleMode,
SeriesDataItemTypeMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { distinctUntilChanged, Observable, Subscription } from 'rxjs';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
import { PriceLabelPrimitive } from './PriceLabelPrimitive';
interface PrimitiveHost {
attachPrimitive(primitive: ISeriesPrimitive<Time>): void;
detachPrimitive(primitive: ISeriesPrimitive<Time>): void;
}
interface PriceLabelHost {
getLwcSeries(): PrimitiveHost;
}
interface SeriesPriceLabelsParams<TSeries extends SeriesType, THost extends PriceLabelHost> {
chart: IChartApi;
series: ISeriesApi<TSeries>;
mainSerie$: Observable<THost | null>;
isMainSeries: boolean;
isCompareSeries: boolean;
isVolumeSeries: boolean;
}
const DEFAULT_LABEL_COLOR = '#000000';
function getDataPrice(data: unknown): number | null {
if (!data || typeof data !== 'object') {
return null;
}
if ('close' in data && typeof data.close === 'number') {
return data.close;
}
if ('value' in data && typeof data.value === 'number') {
return data.value;
}
return null;
}
function getDataColor(data: unknown, options: Record<string, unknown>): string {
if (data && typeof data === 'object' && 'color' in data && typeof data.color === 'string') {
return removeAlphaFromHex(data.color);
}
if (
data &&
typeof data === 'object' &&
'open' in data &&
'close' in data &&
typeof data.open === 'number' &&
typeof data.close === 'number'
) {
const color = data.close >= data.open ? options.upColor : options.downColor;
if (typeof color === 'string') {
return removeAlphaFromHex(color);
}
}
if (typeof options.color === 'string') {
return removeAlphaFromHex(options.color);
}
return DEFAULT_LABEL_COLOR;
}
export class SeriesPriceLabels<
TSeries extends SeriesType,
THost extends PriceLabelHost = PriceLabelHost,
> {
private readonly chart: IChartApi;
private readonly series: ISeriesApi<TSeries>;
private readonly mainSerie$: Observable<THost | null>;
private readonly isMainSeries: boolean;
private readonly isCompareSeries: boolean;
private readonly isVolumeSeries: boolean;
private readonly subscriptions = new Subscription();
private historicalPriceLabel: PriceLabelPrimitive | null = null;
private currentPriceLabel: PriceLabelPrimitive | null = null;
private volumePriceLabel: PriceLabelPrimitive | null = null;
private volumePriceLabelHost: THost | null = null;
private visibleLogicalRange: LogicalRange | null = null;
private isHistoricalMode = false;
private defaultLastValueVisible: boolean;
private updateFrame: number | null = null;
private initialized = false;
constructor({
chart,
series,
mainSerie$,
isMainSeries,
isCompareSeries,
isVolumeSeries,
}: SeriesPriceLabelsParams<TSeries, THost>) {
this.chart = chart;
this.series = series;
this.mainSerie$ = mainSerie$;
this.isMainSeries = isMainSeries;
this.isCompareSeries = isCompareSeries;
this.isVolumeSeries = isVolumeSeries;
this.defaultLastValueVisible = this.series.options().lastValueVisible ?? true;
this.initialize();
}
public onOptionsApplied(options: SeriesPartialOptionsMap[TSeries]): void {
const commonOptions = options as {
lastValueVisible?: boolean;
};
if (typeof commonOptions.lastValueVisible === 'boolean') {
this.defaultLastValueVisible = commonOptions.lastValueVisible;
}
if (this.isVolumeSeries || this.isHistoricalMode) {
this.setBuiltInLastValueVisible(false);
}
this.scheduleUpdate();
}
public scheduleUpdate(): void {
if (!this.initialized || this.updateFrame !== null) {
return;
}
if (typeof requestAnimationFrame !== 'function') {
this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();
this.refresh();
return;
}
this.updateFrame = requestAnimationFrame(() => {
this.updateFrame = null;
this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();
this.refresh();
});
}
public destroy(): void {
if (!this.initialized) {
return;
}
this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
if (this.updateFrame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.updateFrame);
this.updateFrame = null;
}
if (this.historicalPriceLabel) {
this.series.detachPrimitive(this.historicalPriceLabel);
this.historicalPriceLabel = null;
}
if (this.currentPriceLabel) {
this.series.detachPrimitive(this.currentPriceLabel);
this.currentPriceLabel = null;
}
this.detachVolumePriceLabel();
this.volumePriceLabel = null;
this.subscriptions.unsubscribe();
this.initialized = false;
}
private initialize(): void {
const supportsHistoricalPriceLabels = this.isMainSeries || this.isCompareSeries;
if (!supportsHistoricalPriceLabels && !this.isVolumeSeries) {
return;
}
this.visibleLogicalRange = this.chart.timeScale().getVisibleLogicalRange();
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.initialized = true;
if (supportsHistoricalPriceLabels) {
this.historicalPriceLabel = new PriceLabelPrimitive(this.series);
this.series.attachPrimitive(this.historicalPriceLabel);
}
if (this.isMainSeries) {
this.currentPriceLabel = new PriceLabelPrimitive(this.series);
this.series.attachPrimitive(this.currentPriceLabel);
}
if (this.isVolumeSeries) {
this.volumePriceLabel = new PriceLabelPrimitive(this.series);
this.setBuiltInLastValueVisible(false);
this.subscriptions.add(
this.mainSerie$
.pipe(distinctUntilChanged())
.subscribe((series) => {
if (!series) {
this.detachVolumePriceLabel();
return;
}
this.attachVolumePriceLabel(series);
}),
);
}
this.scheduleUpdate();
}
private handleVisibleLogicalRangeChange = (range: LogicalRange | null): void => {
this.visibleLogicalRange = range;
this.scheduleUpdate();
};
private refresh(): void {
if (this.series.options().visible === false) {
this.hideCustomPriceLabels();
return;
}
if (this.isVolumeSeries) {
this.updateVolumePriceLabel();
return;
}
if (!this.historicalPriceLabel) {
return;
}
const range = this.visibleLogicalRange;
const barsInfo = range ? this.series.barsInLogicalRange(range) : null;
const nextHistoricalMode = (barsInfo?.barsAfter ?? 0) > 0;
this.setHistoricalMode(nextHistoricalMode);
if (!nextHistoricalMode || !range) {
this.historicalPriceLabel.hide();
this.currentPriceLabel?.hide();
return;
}
this.updateHistoricalPriceLabel(range);
this.updateCurrentPriceLabel(range);
}
private updateHistoricalPriceLabel(range: LogicalRange): void {
if (!this.historicalPriceLabel) {
return;
}
const data = this.getVisibleData(range);
const price = getDataPrice(data);
if (price === null) {
this.historicalPriceLabel.hide();
return;
}
this.historicalPriceLabel.setState({
color: this.getPriceLabelColor(data),
price,
text: this.formatPriceLabel(price, range),
variant: 'outlined',
visible: true,
});
}
private updateCurrentPriceLabel(range: LogicalRange): void {
if (!this.currentPriceLabel) {
return;
}
const data = this.getLastData();
const price = getDataPrice(data);
if (price === null) {
this.currentPriceLabel.hide();
return;
}
this.currentPriceLabel.setState({
color: this.getPriceLabelColor(data),
price,
text: this.formatPriceLabel(price, range),
variant: 'filled',
visible: true,
});
}
private updateVolumePriceLabel(): void {
if (!this.volumePriceLabel || !this.volumePriceLabelHost) {
return;
}
const data = this.visibleLogicalRange
? this.getVisibleData(this.visibleLogicalRange)
: this.getLastData();
const price = getDataPrice(data);
if (price === null) {
this.volumePriceLabel.hide();
return;
}
this.volumePriceLabel.setState({
color: this.getPriceLabelColor(data),
price,
text: this.series.priceFormatter().format(price),
variant: 'filled',
visible: true,
});
}
private getVisibleData(range: LogicalRange): SeriesDataItemTypeMap<Time>[TSeries] | null {
return this.series.dataByIndex(
Math.floor(range.to),
MismatchDirection.NearestLeft,
);
}
private getFirstVisiblePrice(range: LogicalRange): number | null {
const data = this.series.dataByIndex(
Math.ceil(range.from),
MismatchDirection.NearestRight,
);
return getDataPrice(data);
}
private getLastData(): SeriesDataItemTypeMap<Time>[TSeries] | null {
const data = this.series.data();
return data.length ? data[data.length - 1] : null;
}
private getPriceLabelColor(data: unknown): string {
return getDataColor(
data,
this.series.options() as unknown as Record<string, unknown>,
);
}
private formatPriceLabel(price: number, range: LogicalRange): string {
const mode = this.series.priceScale().options().mode;
if (
mode === PriceScaleMode.Percentage ||
mode === PriceScaleMode.IndexedTo100
) {
const firstVisiblePrice = this.getFirstVisiblePrice(range);
if (firstVisiblePrice !== null && firstVisiblePrice !== 0) {
const value =
mode === PriceScaleMode.Percentage
? ((price - firstVisiblePrice) / firstVisiblePrice) * 100
: (price / firstVisiblePrice) * 100;
return mode === PriceScaleMode.Percentage
? `${value.toFixed(2)}%`
: value.toFixed(2);
}
}
return this.series.priceFormatter().format(price);
}
private setHistoricalMode(nextHistoricalMode: boolean): void {
if (this.isHistoricalMode === nextHistoricalMode) {
return;
}
this.isHistoricalMode = nextHistoricalMode;
this.setBuiltInLastValueVisible(
nextHistoricalMode
? false
: this.defaultLastValueVisible,
);
}
private setBuiltInLastValueVisible(visible: boolean): void {
this.series.applyOptions({
lastValueVisible: visible,
} as SeriesPartialOptionsMap[TSeries]);
}
private attachVolumePriceLabel(series: THost): void {
if (
!this.volumePriceLabel ||
this.volumePriceLabelHost === series
) {
return;
}
this.detachVolumePriceLabel();
series
.getLwcSeries()
.attachPrimitive(this.volumePriceLabel);
this.volumePriceLabelHost = series;
this.scheduleUpdate();
}
private detachVolumePriceLabel(): void {
if (!this.volumePriceLabel || !this.volumePriceLabelHost) {
return;
}
try {
this.volumePriceLabelHost
.getLwcSeries()
.detachPrimitive(this.volumePriceLabel);
} catch {
// Главная серия могла быть удалена перед переключением её типа.
}
this.volumePriceLabelHost = null;
}
private hideCustomPriceLabels(): void {
this.historicalPriceLabel?.hide();
this.currentPriceLabel?.hide();
this.volumePriceLabel?.hide();
}
}