Загрузка данных
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
ChartOptions,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { layoutPriceAxisLabels } from './layout';
import { LaidOutPriceAxisLabel, PriceAxisLabel } from './types';
type PriceAxisSide = 'left' | 'right';
type ChartLayoutOptions = Readonly<ChartOptions['layout']>;
interface PreparedLabel {
text: string;
width: number;
height: number;
ascent: number;
}
const HORIZONTAL_PADDING = 5;
const VERTICAL_PADDING = 2;
function parseHexColor(color: string): [number, number, number] | null {
const normalizedColor = color.replace('#', '');
if (normalizedColor.length === 3) {
const red = Number.parseInt(`${normalizedColor[0]}${normalizedColor[0]}`, 16);
const green = Number.parseInt(`${normalizedColor[1]}${normalizedColor[1]}`, 16);
const blue = Number.parseInt(`${normalizedColor[2]}${normalizedColor[2]}`, 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return null;
}
return [red, green, blue];
}
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 getContrastTextColor(color: string): string {
const rgb = parseHexColor(color);
if (!rgb) {
return '#FFFFFF';
}
const [red, green, blue] = rgb;
const brightness = 0.199 * red + 0.687 * green + 0.114 * blue;
return brightness >= 128 ? '#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.priority === nextLabel.priority
);
});
}
function getPreparedLabels(
context: CanvasRenderingContext2D,
labels: readonly PriceAxisLabel[],
axisWidth: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
fontSize: number,
): Map<string, PreparedLabel> {
const preparedLabels = new Map<string, PreparedLabel>();
const horizontalPadding = HORIZONTAL_PADDING * horizontalPixelRatio;
const verticalPadding = VERTICAL_PADDING * verticalPixelRatio;
const maxTextWidth = Math.max(0, axisWidth - horizontalPadding * 2);
labels.forEach((label) => {
const text = fitText(context, label.text, maxTextWidth);
const metrics = context.measureText(text);
const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.75;
const descent = metrics.actualBoundingBoxDescent || fontSize * 0.25;
const textHeight = ascent + descent;
preparedLabels.set(label.id, {
text,
width: Math.ceil(metrics.width + horizontalPadding * 2),
height: Math.ceil(textHeight + verticalPadding * 2),
ascent,
});
});
return preparedLabels;
}
function drawLabel(
context: CanvasRenderingContext2D,
label: LaidOutPriceAxisLabel,
preparedLabel: PreparedLabel,
axisWidth: number,
side: PriceAxisSide,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const { colors } = getThemeStore();
const coordinate = Math.round(label.coordinate * verticalPixelRatio);
const width = Math.min(axisWidth, preparedLabel.width);
const height = preparedLabel.height;
const left = side === 'left' ? 0 : axisWidth - width;
const top = Math.round(coordinate - height / 2);
const verticalPadding = VERTICAL_PADDING * verticalPixelRatio;
const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
const textColor = label.style === 'outlined' ? label.color : getContrastTextColor(label.color);
context.fillStyle = backgroundColor;
context.fillRect(left, top, width, height);
if (label.style === 'outlined') {
const borderWidth = Math.max(1, Math.round(Math.min(horizontalPixelRatio, verticalPixelRatio)));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
left + borderWidth / 2,
top + borderWidth / 2,
Math.max(0, width - borderWidth),
Math.max(0, height - borderWidth),
);
}
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'alphabetic';
context.fillText(
preparedLabel.text,
left + width / 2,
top + verticalPadding + preparedLabel.ascent,
);
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(
private readonly getLabels: () => readonly PriceAxisLabel[],
private readonly getLayout: () => ChartLayoutOptions | null,
private readonly getSide: () => PriceAxisSide,
) {}
public draw(target: CanvasRenderingTarget2D): void {
const labels = this.getLabels();
const layout = this.getLayout();
if (labels.length === 0 || !layout) {
return;
}
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
if (bitmapSize.width <= 0 || bitmapSize.height <= 0) {
return;
}
const fontSize = layout.fontSize * verticalPixelRatio;
context.save();
context.font = `${fontSize}px ${layout.fontFamily}`;
const preparedLabels = getPreparedLabels(
context,
labels,
bitmapSize.width,
horizontalPixelRatio,
verticalPixelRatio,
fontSize,
);
const measuredLabels = labels.map((label) => {
const preparedLabel = preparedLabels.get(label.id);
return {
...label,
height: preparedLabel ? preparedLabel.height / verticalPixelRatio : label.height,
};
});
const laidOutLabels = layoutPriceAxisLabels(
measuredLabels,
bitmapSize.height / verticalPixelRatio,
);
laidOutLabels.forEach((label) => {
const preparedLabel = preparedLabels.get(label.id);
if (!preparedLabel) {
return;
}
drawLabel(
context,
label,
preparedLabel,
bitmapSize.width,
this.getSide(),
horizontalPixelRatio,
verticalPixelRatio,
);
});
context.restore();
});
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(
private readonly getLabels: () => readonly PriceAxisLabel[],
getLayout: () => ChartLayoutOptions | null,
getSide: () => PriceAxisSide,
) {
this.rendererInstance = new PriceAxisLabelsRenderer(getLabels, getLayout, getSide);
}
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 chart: SeriesAttachedParameter<Time>['chart'] | null = null;
private series: SeriesAttachedParameter<Time>['series'] | null = null;
private requestUpdate: (() => void) | null = null;
constructor() {
this.priceAxisPaneView = new PriceAxisLabelsPaneView(
() => this.labels,
() => this.chart?.options().layout ?? null,
() => (this.series?.options().priceScaleId === 'left' ? 'left' : 'right'),
);
this.priceAxisPaneViewList = [this.priceAxisPaneView];
}
public attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
this.chart = chart;
this.series = series;
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.chart = null;
this.series = null;
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?.();
}
}