Загрузка данных
import {
Coordinate,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { hexWithAplha } from '@src/theme/helpers';
export type PriceLabelVariant = 'filled' | 'outlined';
export interface PriceLabelState {
color: string;
price: number | null;
text: string;
variant: PriceLabelVariant;
visible: boolean;
}
interface PriceCoordinateSource {
priceToCoordinate(price: number): Coordinate | null;
}
interface PriceLabelViewState extends PriceLabelState {
coordinate: Coordinate | null;
}
const LABEL_HEIGHT = 22;
const LABEL_HORIZONTAL_PADDING = 4;
const LABEL_FONT_SIZE = 12;
const OUTLINED_BACKGROUND_ALPHA = 0.12;
class PriceLabelRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getState: () => PriceLabelViewState) {}
public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
const state = this.getState();
if (!state.visible || state.coordinate === null || !state.text) {
return;
}
target.useBitmapCoordinateSpace(
({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const labelHeight = Math.round(LABEL_HEIGHT * verticalPixelRatio);
const horizontalInset = Math.max(1, Math.round(horizontalPixelRatio));
const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));
const rawY = Number(state.coordinate) * verticalPixelRatio;
const top = Math.max(
0,
Math.min(bitmapSize.height - labelHeight, rawY - labelHeight / 2),
);
const left = horizontalInset;
const width = Math.max(
0,
bitmapSize.width - horizontalInset * 2,
);
if (width === 0) {
return;
}
const backgroundColor =
state.variant === 'outlined'
? hexWithAplha(state.color, OUTLINED_BACKGROUND_ALPHA)
: state.color;
const textColor =
state.variant === 'outlined'
? state.color
: '#FFFFFF';
context.save();
context.fillStyle = backgroundColor;
context.fillRect(left, top, width, labelHeight);
if (state.variant === 'outlined') {
context.strokeStyle = state.color;
context.lineWidth = borderWidth;
context.strokeRect(
left + borderWidth / 2,
top + borderWidth / 2,
Math.max(0, width - borderWidth),
Math.max(0, labelHeight - borderWidth),
);
}
const horizontalPadding =
LABEL_HORIZONTAL_PADDING * horizontalPixelRatio;
const maxTextWidth = Math.max(
0,
width - horizontalPadding * 2,
);
let fontSize = LABEL_FONT_SIZE * verticalPixelRatio;
context.font = `500 ${fontSize}px Inter, sans-serif`;
const measuredTextWidth =
context.measureText(state.text).width;
if (
measuredTextWidth > maxTextWidth &&
measuredTextWidth > 0
) {
fontSize *= maxTextWidth / measuredTextWidth;
context.font = `500 ${fontSize}px Inter, sans-serif`;
}
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(
state.text,
bitmapSize.width / 2,
top + labelHeight / 2,
maxTextWidth,
);
context.restore();
},
);
}
}
class PriceLabelPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceLabelRenderer;
constructor(private readonly getState: () => PriceLabelViewState) {
this.rendererInstance = new PriceLabelRenderer(getState);
}
public renderer(): IPrimitivePaneRenderer | null {
return this.getState().visible
? this.rendererInstance
: null;
}
public zOrder(): 'top' {
return 'top';
}
}
export class PriceLabelPrimitive implements ISeriesPrimitive<Time> {
private readonly views: readonly IPrimitivePaneView[];
private requestUpdate: (() => void) | null = null;
private state: PriceLabelViewState = {
color: '#000000',
coordinate: null,
price: null,
text: '',
variant: 'outlined',
visible: false,
};
constructor(
private readonly coordinateSource: PriceCoordinateSource,
) {
this.views = [
new PriceLabelPaneView(() => this.state),
];
}
public attached(
param: SeriesAttachedParameter<Time>,
): void {
this.requestUpdate = param.requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public updateAllViews(): void {
this.state = {
...this.state,
coordinate:
this.state.price === null
? null
: this.coordinateSource.priceToCoordinate(
this.state.price,
),
};
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.views;
}
public setState(nextState: PriceLabelState): void {
this.state = {
...nextState,
coordinate:
nextState.price === null
? null
: this.coordinateSource.priceToCoordinate(
nextState.price,
),
};
this.requestUpdate?.();
}
public hide(): void {
if (!this.state.visible) {
return;
}
this.state = {
...this.state,
visible: false,
};
this.requestUpdate?.();
}
}
import {
IChartApi,
ISeriesApi,
LogicalRange,
MismatchDirection,
PriceScaleMode,
SeriesDataItemTypeMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import {
BehaviorSubject,
distinctUntilChanged,
filter,
Subscription,
} from 'rxjs';
import type { Indicator } from '@core/Indicator';
import { PriceLabelPrimitive } from '@core/Series/PriceLabelPrimitive';
import { IndicatorsIds } from '@src/constants';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
interface SeriesPriceLabelsParams<
TSeries extends SeriesType,
> {
chart: IChartApi;
series: ISeriesApi<TSeries>;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
indicatorReference: Indicator | null;
}
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 removeAlphaFromHex(
getThemeStore().colors.chartLineColor,
);
}
export class SeriesPriceLabels<
TSeries extends SeriesType,
> {
private readonly chart: IChartApi;
private readonly series: ISeriesApi<TSeries>;
private readonly mainSerie$: BehaviorSubject<
SeriesStrategies | null
>;
private readonly indicatorReference: Indicator | null;
private readonly subscriptions = new Subscription();
private historicalPriceLabel: PriceLabelPrimitive | null =
null;
private currentPriceLabel: PriceLabelPrimitive | null =
null;
private volumePriceLabel: PriceLabelPrimitive | null =
null;
private volumePriceLabelHost: SeriesStrategies | 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$,
indicatorReference,
}: SeriesPriceLabelsParams<TSeries>) {
this.chart = chart;
this.series = series;
this.mainSerie$ = mainSerie$;
this.indicatorReference = indicatorReference;
this.defaultLastValueVisible =
this.series.options().lastValueVisible;
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.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,
);
}
if (this.currentPriceLabel) {
this.series.detachPrimitive(
this.currentPriceLabel,
);
}
this.detachVolumePriceLabel();
this.subscriptions.unsubscribe();
this.initialized = false;
}
private initialize(): void {
const supportsHistoricalPriceLabels =
this.isMainSeries() ||
this.isCompareSeries();
const isVolumeSeries = this.isVolumeSeries();
if (
!supportsHistoricalPriceLabels &&
!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 (isVolumeSeries) {
this.volumePriceLabel =
new PriceLabelPrimitive(this.series);
this.setBuiltInLastValueVisible(false);
this.subscriptions.add(
this.mainSerie$
.pipe(
filter(
(
series,
): series is SeriesStrategies =>
series !== null,
),
distinctUntilChanged(),
)
.subscribe((series) => {
this.attachVolumePriceLabel(series);
}),
);
}
this.scheduleUpdate();
}
private handleVisibleLogicalRangeChange = (
range: LogicalRange | null,
): void => {
this.visibleLogicalRange = range;
this.scheduleUpdate();
};
private refresh(): void {
if (!this.series.options().visible) {
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: SeriesStrategies,
): 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();
}
private isMainSeries(): boolean {
return this.indicatorReference === null;
}
private isCompareSeries(): boolean {
return (
this.indicatorReference !== null &&
this.indicatorReference.getType() === undefined
);
}
private isVolumeSeries(): boolean {
return (
this.indicatorReference?.getType() ===
IndicatorsIds.Volume
);
}
}