Загрузка данных
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
ChartOptions,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { layoutPriceAxisLabels } from './layout';
import type { LaidOutPriceAxisLabel, PriceAxisLabel } from './types';
type PriceAxisSide = 'left' | 'right';
type ChartLayoutOptions = Readonly<ChartOptions['layout']>;
interface MeasuredLabel {
label: PriceAxisLabel;
text: string;
width: number;
height: number;
ascent: number;
}
interface LabelGeometry {
label: LaidOutPriceAxisLabel;
text: string;
left: number;
top: number;
width: number;
height: number;
textX: number;
textY: number;
}
const HORIZONTAL_PADDING_RATIO = 7 / 12;
const VERTICAL_PADDING_RATIO = 2.5 / 12;
const CONTRAST_THRESHOLD = 160;
function parseColor(color: string): [number, number, number] | null {
const normalizedColor = color.trim();
if (normalizedColor.startsWith('#')) {
const hex = normalizedColor.slice(1);
if (hex.length === 3 || hex.length === 4) {
const red = Number.parseInt(`${hex[0]}${hex[0]}`, 16);
const green = Number.parseInt(`${hex[1]}${hex[1]}`, 16);
const blue = Number.parseInt(`${hex[2]}${hex[2]}`, 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return null;
}
return [red, green, blue];
}
if (hex.length === 6 || hex.length === 8) {
const red = Number.parseInt(hex.slice(0, 2), 16);
const green = Number.parseInt(hex.slice(2, 4), 16);
const blue = Number.parseInt(hex.slice(4, 6), 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return null;
}
return [red, green, blue];
}
return null;
}
const rgbMatch = normalizedColor.match(
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/i,
);
if (!rgbMatch) {
return null;
}
const red = Number(rgbMatch[1]);
const green = Number(rgbMatch[2]);
const blue = Number(rgbMatch[3]);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return null;
}
return [red, green, blue];
}
function normalizeCanvasColor(context: CanvasRenderingContext2D, color: string): string {
const previousFillStyle = context.fillStyle;
context.fillStyle = '#000000';
context.fillStyle = color;
const normalizedColor = String(context.fillStyle);
context.fillStyle = previousFillStyle;
return normalizedColor;
}
function getFilledLabelTextColor(context: CanvasRenderingContext2D, color: string): string {
const normalizedColor = normalizeCanvasColor(context, color);
const rgb = parseColor(normalizedColor);
if (!rgb) {
return '#FFFFFF';
}
const [red, green, blue] = rgb;
const grayscale = 0.199 * red + 0.687 * green + 0.114 * blue;
return grayscale > CONTRAST_THRESHOLD ? '#000000' : '#FFFFFF';
}
function getLabelTextColor(context: CanvasRenderingContext2D, label: PriceAxisLabel): string {
if (label.style === 'outlined') {
return label.color;
}
return getFilledLabelTextColor(context, label.color);
}
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 getFont(layout: ChartLayoutOptions): string {
return `${layout.fontSize}px ${layout.fontFamily}`;
}
function getHorizontalPadding(layout: ChartLayoutOptions): number {
return layout.fontSize * HORIZONTAL_PADDING_RATIO;
}
function getVerticalPadding(layout: ChartLayoutOptions): number {
return layout.fontSize * VERTICAL_PADDING_RATIO;
}
function getLabelLeft(side: PriceAxisSide, axisWidth: number, labelWidth: number): number {
return side === 'right' ? 0 : axisWidth - labelWidth;
}
function measureLabels(
context: CanvasRenderingContext2D,
labels: readonly PriceAxisLabel[],
axisWidth: number,
layout: ChartLayoutOptions,
): MeasuredLabel[] {
const horizontalPadding = getHorizontalPadding(layout);
const verticalPadding = getVerticalPadding(layout);
const maxTextWidth = Math.max(0, axisWidth - horizontalPadding * 2);
return labels.map((label) => {
const text = fitText(context, label.text, maxTextWidth);
const textMetrics = context.measureText(text);
const ascent = textMetrics.actualBoundingBoxAscent || layout.fontSize * 0.75;
const descent = textMetrics.actualBoundingBoxDescent || layout.fontSize * 0.25;
const textWidth = Math.ceil(textMetrics.width);
const textHeight = ascent + descent;
return {
label,
text,
width: textWidth + horizontalPadding * 2,
height: textHeight + verticalPadding * 2,
ascent,
};
});
}
function createLabelGeometries(
context: CanvasRenderingContext2D,
labels: readonly PriceAxisLabel[],
axisWidth: number,
axisHeight: number,
side: PriceAxisSide,
layout: ChartLayoutOptions,
): LabelGeometry[] {
const measuredLabels = measureLabels(context, labels, axisWidth, layout);
const labelsWithMeasuredHeight = measuredLabels.map(({ label, height }) => ({
...label,
height,
}));
const laidOutLabels = layoutPriceAxisLabels(labelsWithMeasuredHeight, axisHeight);
const measuredLabelsById = new Map(
measuredLabels.map((measuredLabel) => [measuredLabel.label.id, measuredLabel] as const),
);
const verticalPadding = getVerticalPadding(layout);
return laidOutLabels.flatMap((label) => {
const measuredLabel = measuredLabelsById.get(label.id);
if (!measuredLabel) {
return [];
}
const width = Math.min(axisWidth, measuredLabel.width);
const height = measuredLabel.height;
const left = getLabelLeft(side, axisWidth, width);
const top = label.coordinate - height / 2;
return [
{
label,
text: measuredLabel.text,
left,
top,
width,
height,
textX: left + width / 2,
textY: top + verticalPadding + measuredLabel.ascent,
},
];
});
}
function drawLabelBackgrounds(target: CanvasRenderingTarget2D, geometries: readonly LabelGeometry[]): void {
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const { colors } = getThemeStore();
context.save();
geometries.forEach(({ label, left, top, width, height }) => {
const bitmapLeft = Math.round(left * horizontalPixelRatio);
const bitmapTop = Math.round(top * verticalPixelRatio);
const bitmapWidth = Math.round(width * horizontalPixelRatio);
const bitmapHeight = Math.round(height * verticalPixelRatio);
const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
context.fillStyle = backgroundColor;
context.fillRect(bitmapLeft, bitmapTop, bitmapWidth, bitmapHeight);
if (label.style !== 'outlined') {
return;
}
const borderWidth = Math.max(1, Math.floor(Math.min(horizontalPixelRatio, verticalPixelRatio)));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
bitmapLeft + borderWidth / 2,
bitmapTop + borderWidth / 2,
Math.max(0, bitmapWidth - borderWidth),
Math.max(0, bitmapHeight - borderWidth),
);
});
context.restore();
});
}
function drawLabelTexts(
target: CanvasRenderingTarget2D,
geometries: readonly LabelGeometry[],
layout: ChartLayoutOptions,
): void {
target.useMediaCoordinateSpace(({ context }) => {
context.save();
context.font = getFont(layout);
context.textAlign = 'center';
context.textBaseline = 'alphabetic';
geometries.forEach(({ label, text, textX, textY }) => {
context.fillStyle = getLabelTextColor(context, label);
context.fillText(text, textX, textY);
});
context.restore();
});
}
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;
}
let geometries: LabelGeometry[] = [];
target.useMediaCoordinateSpace(({ context, mediaSize }) => {
if (mediaSize.width <= 0 || mediaSize.height <= 0) {
return;
}
context.save();
context.font = getFont(layout);
geometries = createLabelGeometries(
context,
labels,
mediaSize.width,
mediaSize.height,
this.getSide(),
layout,
);
context.restore();
});
if (geometries.length === 0) {
return;
}
drawLabelBackgrounds(target, geometries);
drawLabelTexts(target, geometries, layout);
}
}
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?.();
}
}