Загрузка данных
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { layoutPriceAxisLabels } from './layout';
import { LaidOutPriceAxisLabel, PriceAxisLabel } from './types';
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 normalizedColor = color.replace('#', '').slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
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 '#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) {
return '';
}
if (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 areLabelsEqual(currentLabels: readonly PriceAxisLabel[], nextLabels: readonly PriceAxisLabel[]): boolean {
if (currentLabels.length !== nextLabels.length) {
return false;
}
return currentLabels.every((currentLabel, index) => {
const nextLabel = nextLabels[index];
return (
currentLabel.id === nextLabel.id &&
currentLabel.collisionGroup === nextLabel.collisionGroup &&
currentLabel.desiredCoordinate === nextLabel.desiredCoordinate &&
currentLabel.text === nextLabel.text &&
currentLabel.color === nextLabel.color &&
currentLabel.style === nextLabel.style &&
currentLabel.symbol === nextLabel.symbol &&
currentLabel.priority === nextLabel.priority &&
currentLabel.height === nextLabel.height
);
});
}
function drawLabel(
context: CanvasRenderingContext2D,
label: LaidOutPriceAxisLabel,
width: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const { colors } = getThemeStore();
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,
Math.max(0, width - borderWidth),
Math.max(0, labelHeight - borderWidth),
);
}
context.fillStyle = textColor;
if (!label.symbol) {
const maxTextWidth = width - horizontalPadding * 2;
const text = fitText(context, label.text, maxTextWidth);
context.textAlign = 'center';
context.fillText(text, 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 textAreaWidth = width - separatorX;
const textMaxWidth = textAreaWidth - horizontalPadding * 2;
const symbol = fitText(context, label.symbol, symbolMaxWidth);
const text = fitText(context, label.text, textMaxWidth);
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(text, separatorX + textAreaWidth / 2, coordinate);
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const labels = this.getLabels();
if (labels.length === 0) {
return;
}
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
if (bitmapSize.width <= 0 || bitmapSize.height <= 0) {
return;
}
const laidOutLabels = layoutPriceAxisLabels(labels, bitmapSize.height / verticalPixelRatio);
if (laidOutLabels.length === 0) {
return;
}
context.save();
context.font = `${FONT_WEIGHT} ${Math.round(FONT_SIZE * verticalPixelRatio)}px Inter, sans-serif`;
context.textBaseline = 'middle';
laidOutLabels.forEach((label) => {
drawLabel(context, label, bitmapSize.width, horizontalPixelRatio, verticalPixelRatio);
});
context.restore();
});
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {
this.rendererInstance = new PriceAxisLabelsRenderer(getLabels);
}
public renderer(): IPrimitivePaneRenderer | null {
return this.getLabels().length > 0 ? this.rendererInstance : null;
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
private readonly priceAxisPaneView: PriceAxisLabelsPaneView;
private readonly priceAxisPaneViewList: readonly IPrimitivePaneView[];
private labels: readonly PriceAxisLabel[] = [];
private requestUpdate: (() => void) | null = null;
constructor() {
this.priceAxisPaneView = new PriceAxisLabelsPaneView(() => this.labels);
this.priceAxisPaneViewList = [this.priceAxisPaneView];
}
public attached({ requestUpdate }: SeriesAttachedParameter<Time>): void {
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewList;
}
public updateAllViews(): void {}
public setLabels(labels: readonly PriceAxisLabel[]): void {
if (areLabelsEqual(this.labels, labels)) {
return;
}
this.labels = labels;
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.requestUpdate?.();
}
}