Загрузка данных
import {
Coordinate,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme/store';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
export type PriceLabelKind = 'current' | 'historical';
export interface PriceLabelState {
price: number | null;
value: string;
symbol?: string;
color: string;
kind: PriceLabelKind;
visible: boolean;
}
interface PriceLabelSeries {
priceToCoordinate(price: number): Coordinate | null;
options(): {
priceScaleId?: string;
};
}
interface PriceLabelViewState extends PriceLabelState {
coordinate: Coordinate | null;
}
const LABEL_HEIGHT = 20;
const LABEL_FONT_SIZE = 11;
const LABEL_MIN_FONT_SIZE = 9;
const LABEL_HORIZONTAL_PADDING = 5;
const LABEL_SECTION_GAP = 4;
const LABEL_SEPARATOR_HEIGHT = 12;
const LABEL_MIN_WIDTH = 34;
const LABEL_BORDER_WIDTH = 1;
const LABEL_AXIS_INSET = 1;
const FONT_FAMILY = 'Inter, sans-serif';
const SYMBOL_FONT_WEIGHT = 600;
const VALUE_FONT_WEIGHT = 500;
function getHexRgb(color: string): [number, number, number] | null {
const normalizedColor = removeAlphaFromHex(color).replace('#', '');
if (normalizedColor.length !== 6) {
return null;
}
const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);
if (
Number.isNaN(red) ||
Number.isNaN(green) ||
Number.isNaN(blue)
) {
return null;
}
return [red, green, blue];
}
function getRelativeLuminance(color: string): number {
const rgb = getHexRgb(color);
if (!rgb) {
return 0;
}
const channels = rgb.map((channel) => {
const value = channel / 255;
return value <= 0.03928
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
});
return (
channels[0] * 0.2126 +
channels[1] * 0.7152 +
channels[2] * 0.0722
);
}
function getContrastRatio(
firstColor: string,
secondColor: string,
): number {
const firstLuminance = getRelativeLuminance(firstColor);
const secondLuminance = getRelativeLuminance(secondColor);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function getContrastTextColor(
backgroundColor: string,
firstColor: string,
secondColor: string,
): string {
return getContrastRatio(backgroundColor, firstColor) >=
getContrastRatio(backgroundColor, secondColor)
? firstColor
: secondColor;
}
function fitText(
context: CanvasRenderingContext2D,
text: string,
maxWidth: number,
): string {
if (!text || maxWidth <= 0) {
return '';
}
if (context.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = '…';
if (context.measureText(ellipsis).width > maxWidth) {
return '';
}
let left = 0;
let right = text.length;
while (left < right) {
const middle = Math.ceil((left + right) / 2);
const candidate = `${text.slice(0, middle)}${ellipsis}`;
if (context.measureText(candidate).width <= maxWidth) {
left = middle;
} else {
right = middle - 1;
}
}
return `${text.slice(0, left)}${ellipsis}`;
}
class PriceLabelRenderer implements IPrimitivePaneRenderer {
constructor(
private readonly series: PriceLabelSeries,
private readonly getState: () => PriceLabelViewState,
) {}
public draw(
target: Parameters<IPrimitivePaneRenderer['draw']>[0],
): void {
const state = this.getState();
if (
!state.visible ||
state.coordinate === null ||
!state.value
) {
return;
}
target.useBitmapCoordinateSpace(
({
context,
bitmapSize,
horizontalPixelRatio,
verticalPixelRatio,
}) => {
const { colors } = getThemeStore();
const seriesColor = removeAlphaFromHex(state.color);
const isCurrent = state.kind === 'current';
const backgroundColor = isCurrent
? seriesColor
: colors.chartBackground;
const borderColor = seriesColor;
const textColor = isCurrent
? getContrastTextColor(
backgroundColor,
colors.chartBackground,
colors.chartTextPrimary,
)
: colors.chartTextPrimary;
const separatorColor = isCurrent
? textColor
: seriesColor;
const scaleBackgroundColor = colors.chartBackground;
const labelHeight = Math.round(
LABEL_HEIGHT * verticalPixelRatio,
);
const horizontalPadding = Math.round(
LABEL_HORIZONTAL_PADDING * horizontalPixelRatio,
);
const sectionGap = Math.round(
LABEL_SECTION_GAP * horizontalPixelRatio,
);
const separatorHeight = Math.round(
LABEL_SEPARATOR_HEIGHT * verticalPixelRatio,
);
const separatorWidth = Math.max(
1,
Math.round(horizontalPixelRatio),
);
const borderWidth = Math.max(
1,
Math.round(
LABEL_BORDER_WIDTH * horizontalPixelRatio,
),
);
const axisInset = Math.max(
1,
Math.round(LABEL_AXIS_INSET * horizontalPixelRatio),
);
const minimumLabelWidth = Math.round(
LABEL_MIN_WIDTH * horizontalPixelRatio,
);
const availableWidth = Math.max(
0,
bitmapSize.width - axisInset * 2,
);
if (availableWidth <= 0) {
return;
}
const rawY =
Number(state.coordinate) * verticalPixelRatio;
const top = Math.round(
Math.max(
0,
Math.min(
bitmapSize.height - labelHeight,
rawY - labelHeight / 2,
),
),
);
let fontSize = LABEL_FONT_SIZE * verticalPixelRatio;
const minimumFontSize =
LABEL_MIN_FONT_SIZE * verticalPixelRatio;
const symbol = state.symbol?.trim() ?? '';
const getValueFont = () =>
`${VALUE_FONT_WEIGHT} ${fontSize}px ${FONT_FAMILY}`;
const getSymbolFont = () =>
`${SYMBOL_FONT_WEIGHT} ${fontSize}px ${FONT_FAMILY}`;
context.save();
context.font = getValueFont();
let valueWidth = context.measureText(state.value).width;
let symbolWidth = 0;
if (symbol) {
context.font = getSymbolFont();
symbolWidth = context.measureText(symbol).width;
}
const getContentWidth = () =>
symbol
? symbolWidth +
sectionGap +
separatorWidth +
sectionGap +
valueWidth
: valueWidth;
let requiredWidth =
getContentWidth() + horizontalPadding * 2;
if (
requiredWidth > availableWidth &&
fontSize > minimumFontSize
) {
const scale = availableWidth / requiredWidth;
fontSize = Math.max(
minimumFontSize,
fontSize * scale,
);
context.font = getValueFont();
valueWidth = context.measureText(state.value).width;
if (symbol) {
context.font = getSymbolFont();
symbolWidth = context.measureText(symbol).width;
}
requiredWidth =
getContentWidth() + horizontalPadding * 2;
}
let fittedSymbol = symbol;
if (requiredWidth > availableWidth && symbol) {
const fixedWidth =
valueWidth +
sectionGap +
separatorWidth +
sectionGap +
horizontalPadding * 2;
const maxSymbolWidth = Math.max(
0,
availableWidth - fixedWidth,
);
context.font = getSymbolFont();
fittedSymbol = fitText(
context,
symbol,
maxSymbolWidth,
);
symbolWidth = fittedSymbol
? context.measureText(fittedSymbol).width
: 0;
requiredWidth =
(fittedSymbol
? symbolWidth +
sectionGap +
separatorWidth +
sectionGap
: 0) +
valueWidth +
horizontalPadding * 2;
}
const labelWidth = Math.min(
availableWidth,
Math.max(
minimumLabelWidth,
Math.ceil(requiredWidth),
),
);
const isLeftScale =
this.series.options().priceScaleId === 'left';
const left = isLeftScale
? axisInset
: bitmapSize.width - axisInset - labelWidth;
context.fillStyle = scaleBackgroundColor;
context.fillRect(
0,
top,
bitmapSize.width,
labelHeight,
);
context.fillStyle = backgroundColor;
context.fillRect(
left,
top,
labelWidth,
labelHeight,
);
context.strokeStyle = borderColor;
context.lineWidth = borderWidth;
context.strokeRect(
left + borderWidth / 2,
top + borderWidth / 2,
Math.max(0, labelWidth - borderWidth),
Math.max(0, labelHeight - borderWidth),
);
const textY = top + labelHeight / 2;
if (!fittedSymbol) {
context.font = getValueFont();
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(
state.value,
left + labelWidth / 2,
textY,
Math.max(
0,
labelWidth - horizontalPadding * 2,
),
);
context.restore();
return;
}
let currentX = left + horizontalPadding;
context.font = getSymbolFont();
context.fillStyle = textColor;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(
fittedSymbol,
currentX,
textY,
symbolWidth,
);
currentX += symbolWidth + sectionGap;
const separatorTop =
top + (labelHeight - separatorHeight) / 2;
context.save();
context.globalAlpha = 0.65;
context.strokeStyle = separatorColor;
context.lineWidth = separatorWidth;
context.beginPath();
context.moveTo(
currentX + separatorWidth / 2,
separatorTop,
);
context.lineTo(
currentX + separatorWidth / 2,
separatorTop + separatorHeight,
);
context.stroke();
context.restore();
currentX += separatorWidth + sectionGap;
context.font = getValueFont();
context.fillStyle = textColor;
context.textAlign = 'left';
context.textBaseline = 'middle';
const maxValueWidth = Math.max(
0,
left +
labelWidth -
horizontalPadding -
currentX,
);
context.fillText(
state.value,
currentX,
textY,
maxValueWidth,
);
context.restore();
},
);
}
}
class PriceLabelPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceLabelRenderer;
constructor(
series: PriceLabelSeries,
private readonly getState: () => PriceLabelViewState,
) {
this.rendererInstance = new PriceLabelRenderer(
series,
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 = {
price: null,
coordinate: null,
value: '',
symbol: undefined,
color: '#000000',
kind: 'historical',
visible: false,
};
constructor(private readonly series: PriceLabelSeries) {
this.views = [
new PriceLabelPaneView(
series,
() => this.state,
),
];
}
public attached(
param: SeriesAttachedParameter<Time>,
): void {
this.requestUpdate = param.requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public updateAllViews(): void {
this.updateCoordinate();
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.views;
}
public setState(nextState: PriceLabelState): void {
this.state = {
...nextState,
symbol: nextState.symbol?.trim() || undefined,
color: removeAlphaFromHex(nextState.color),
coordinate:
nextState.price === null
? null
: this.series.priceToCoordinate(nextState.price),
};
this.requestUpdate?.();
}
public hide(): void {
if (!this.state.visible) {
return;
}
this.state = {
...this.state,
visible: false,
};
this.requestUpdate?.();
}
private updateCoordinate(): void {
this.state = {
...this.state,
coordinate:
this.state.price === null
? null
: this.series.priceToCoordinate(
this.state.price,
),
};
}
}