Загрузка данных
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
export type PriceAxisLabelStyle = 'filled' | 'outlined';
export interface PriceAxisLabel {
id: string;
coordinate: number | null;
value: string;
color: string;
style: PriceAxisLabelStyle;
symbol?: string;
priority?: number;
}
interface ResolvedPriceAxisLabel extends PriceAxisLabel {
coordinate: number;
}
const LABEL_HEIGHT = 20;
const LABEL_GAP = 2;
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;
function getContrastTextColor(color: string): string {
const normalized = color.replace('#', '').slice(0, 6);
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);
if ([red, green, blue].some(Number.isNaN)) {
return '#FFFFFF';
}
const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;
return luminance > 0.6 ? '#000000' : '#FFFFFF';
}
function fitText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (maxWidth <= 0 || context.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = '…';
let result = text;
while (result.length > 0 && context.measureText(`${result}${ellipsis}`).width > maxWidth) {
result = result.slice(0, -1);
}
return result.length > 0 ? `${result}${ellipsis}` : '';
}
function resolveLabelPositions(labels: PriceAxisLabel[], height: number): ResolvedPriceAxisLabel[] {
const halfHeight = LABEL_HEIGHT / 2;
const minCoordinate = halfHeight;
const maxCoordinate = Math.max(halfHeight, height - halfHeight);
const resolved = labels
.filter(
(label): label is PriceAxisLabel & { coordinate: number } =>
label.coordinate !== null && Number.isFinite(label.coordinate),
)
.sort((left, right) => {
if (left.coordinate === right.coordinate) {
return (right.priority ?? 0) - (left.priority ?? 0);
}
return left.coordinate - right.coordinate;
})
.map((label) => ({
...label,
coordinate: Math.min(Math.max(label.coordinate, minCoordinate), maxCoordinate),
}));
for (let index = 1; index < resolved.length; index += 1) {
const previous = resolved[index - 1];
const current = resolved[index];
const minimumCoordinate = previous.coordinate + LABEL_HEIGHT + LABEL_GAP;
if (current.coordinate < minimumCoordinate) {
current.coordinate = minimumCoordinate;
}
}
const lastLabel = resolved.at(-1);
if (lastLabel && lastLabel.coordinate > maxCoordinate) {
const offset = lastLabel.coordinate - maxCoordinate;
resolved.forEach((label) => {
label.coordinate -= offset;
});
}
for (let index = resolved.length - 2; index >= 0; index -= 1) {
const current = resolved[index];
const next = resolved[index + 1];
const maximumCoordinate = next.coordinate - LABEL_HEIGHT - LABEL_GAP;
if (current.coordinate > maximumCoordinate) {
current.coordinate = maximumCoordinate;
}
}
const firstLabel = resolved[0];
if (firstLabel && firstLabel.coordinate < minCoordinate) {
const offset = minCoordinate - firstLabel.coordinate;
resolved.forEach((label) => {
label.coordinate += offset;
});
}
return resolved;
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(private readonly labels: PriceAxisLabel[]) {}
public draw(target: Parameters<IPrimitivePaneRenderer['draw']>[0]): void {
target.useBitmapCoordinateSpace(
({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const width = bitmapSize.width;
const height = bitmapSize.height;
if (width <= 0 || height <= 0 || this.labels.length === 0) {
return;
}
const resolvedLabels = resolveLabelPositions(
this.labels,
height / verticalPixelRatio,
);
if (resolvedLabels.length === 0) {
return;
}
const { colors } = getThemeStore();
context.save();
context.font = `${FONT_WEIGHT} ${Math.round(FONT_SIZE * verticalPixelRatio)}px Inter, sans-serif`;
context.textBaseline = 'middle';
resolvedLabels.forEach((label) => {
const coordinate = Math.round(label.coordinate * verticalPixelRatio);
const labelHeight = Math.round(LABEL_HEIGHT * verticalPixelRatio);
const top = Math.round(coordinate - labelHeight / 2);
const horizontalPadding = Math.round(HORIZONTAL_PADDING * horizontalPixelRatio);
const backgroundColor =
label.style === 'outlined' ? colors.chartBackground : label.color;
const textColor =
label.style === 'outlined' ? label.color : getContrastTextColor(label.color);
context.fillStyle = backgroundColor;
context.fillRect(0, top, width, labelHeight);
if (label.style === 'outlined') {
const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
borderWidth / 2,
top + borderWidth / 2,
width - borderWidth,
labelHeight -Width = borderWidth;
context.strokeRect(
borderWidth / 2,
top + borderWidth / 2,
width - borderWidth,
labelHeight - borderWidth,
);
}
context.fillStyle = textColor;
if (!label.symbol) {
const maxValueWidth = width - horizontalPadding * 2;
const value = fitText(context, label.value, maxValueWidth);
context.textAlign = 'center';
context.fillText(value, width / 2, coordinate);
return;
}
const availableWidth = width - horizontalPadding * 2;
const measuredSymbolWidth =
context.measureText(label.symbol).width + horizontalPadding * 2;
const symbolWidth = Math.min(
Math.max(
measuredSymbolWidth,
availableWidth * SYMBOL_MIN_WIDTH_RATIO,
),
availableWidth * SYMBOL_MAX_WIDTH_RATIO,
);
const separatorX = Math.round(horizontalPadding + symbolWidth);
const separatorWidth = Math.max(1, Math.round(horizontalPixelRatio));
const symbolMaxWidth = symbolWidth - horizontalPadding * 2;
const valueAreaWidth = width - separatorX;
const valueMaxWidth = valueAreaWidth - horizontalPadding * 2;
const symbol = fitText(context, label.symbol, symbolMaxWidth);
const value = fitText(context, label.value, valueMaxWidth);
context.textAlign = 'center';
context.fillText(
symbol,
horizontalPadding + symbolWidth / 2,
coordinate,
);
context.globalAlpha = 0.5;
context.fillRect(
separatorX,
top + Math.round(3 * verticalPixelRatio),
separatorWidth,
labelHeight - Math.round(6 * verticalPixelRatio),
);
context.globalAlpha = 1;
context.fillText(
value,
separatorX + valueAreaWidth / 2,
coordinate,
);
});
context.restore();
},
);
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private labels: PriceAxisLabel[] = [];
public update(labels: PriceAxisLabel[]): void {
this.labels = labels;
}
public renderer(): IPrimitivePaneRenderer {
return new PriceAxisLabelsRenderer(this.labels);
}
public zOrder(): 'top' {
return 'top';
}
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
private readonly paneView = new PriceAxisLabelsPaneView();
private readonly paneViews = [this.paneView];
private labels: PriceAxisLabel[] = [];
private requestUpdate: (() => void) | null = null;
public attached({ requestUpdate }: SeriesAttachedParameter<Time>): void {
this.requestUpdate = requestUpdate;
}
public detached(): void {
this.requestUpdate = null;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.paneViews;
}
public updateAllViews(): void {
this.paneView.update(this.labels);
}
public setLabels(labels: PriceAxisLabel[]): void {
this.labels = labels;
this.paneView.update(labels);
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.paneView.update([]);
this.requestUpdate?.();
}
}