Загрузка данных
import type {
LaidOutPriceAxisLabel,
MeasuredPriceAxisLabel,
PriceAxisLabel,
} from './types';
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';
type PriceAxisSide = 'left' | 'right';
type ChartLayoutOptions = Readonly<ChartOptions['layout']>;
interface MeasuredLabel extends MeasuredPriceAxisLabel {
width: number;
}
interface LabelGeometry {
label: LaidOutPriceAxisLabel;
left: number;
top: number;
width: number;
textX: number;
textY: number;
}
const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = 1 / 12;
const LABEL_EDGE_GAP = 1;
const CONTRAST_THRESHOLD = 160;
function getFont(layout: ChartLayoutOptions): string {
return `${layout.fontSize}px ${layout.fontFamily}`;
}
function getLabelTextColor(label: PriceAxisLabel): string {
if (label.style === 'outlined') {
return label.color;
}
const hex = label.color.replace('#', '').slice(0, 6);
if (hex.length !== 6) {
return '#FFFFFF';
}
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 '#FFFFFF';
}
const brightness = 0.199 * red + 0.687 * green + 0.114 * blue;
return brightness > CONTRAST_THRESHOLD ? '#000000' : '#FFFFFF';
}
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.desiredCoordinate === nextLabel.desiredCoordinate &&
currentLabel.text === nextLabel.text &&
currentLabel.color === nextLabel.color &&
currentLabel.style === nextLabel.style &&
currentLabel.priority === nextLabel.priority
);
});
}
function measureLabels(
context: CanvasRenderingContext2D,
labels: readonly PriceAxisLabel[],
layout: ChartLayoutOptions,
): MeasuredLabel[] {
const horizontalPadding = layout.fontSize * HORIZONTAL_PADDING_RATIO;
const verticalPadding = layout.fontSize * VERTICAL_PADDING_RATIO;
return labels.map((label) => {
const textMetrics = context.measureText(label.text);
const measuredTextHeight =
textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent;
return {
...label,
width: Math.ceil(textMetrics.width) + horizontalPadding * 2,
height: (measuredTextHeight || layout.fontSize) + verticalPadding * 2,
};
});
}
function createLabelGeometries(
labels: readonly MeasuredLabel[],
axisWidth: number,
axisHeight: number,
side: PriceAxisSide,
layout: ChartLayoutOptions,
reservedCoordinate: number | null,
): LabelGeometry[] {
const labelsById = new Map<string, MeasuredLabel>();
labels.forEach((label) => {
labelsById.set(label.id, label);
});
const reservedHeight = labels.reduce(
(maximumHeight, label) => Math.max(maximumHeight, label.height),
layout.fontSize + layout.fontSize * VERTICAL_PADDING_RATIO * 2,
);
const laidOutLabels = layoutPriceAxisLabels(labels, axisHeight, {
reservedCoordinate: reservedCoordinate ?? undefined,
reservedHeight,
});
return laidOutLabels.flatMap((label) => {
const measuredLabel = labelsById.get(label.id);
if (!measuredLabel) {
return [];
}
const left =
side === 'right'
? LABEL_EDGE_GAP
: Math.max(LABEL_EDGE_GAP, axisWidth - measuredLabel.width - LABEL_EDGE_GAP);
return [
{
label,
left,
top: label.coordinate - measuredLabel.height / 2,
width: measuredLabel.width,
textX: left + measuredLabel.width / 2,
textY: label.coordinate + layout.fontSize * TEXT_VERTICAL_OFFSET_RATIO,
},
];
});
}
function drawLabelBackgrounds(
target: CanvasRenderingTarget2D,
geometries: readonly LabelGeometry[],
): void {
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const { colors } = getThemeStore();
context.save();
geometries.forEach(({ label, left, top, width }) => {
const bitmapLeft = Math.round(left * horizontalPixelRatio);
const bitmapTop = Math.round(top * verticalPixelRatio);
const bitmapWidth = Math.round(width * horizontalPixelRatio);
const bitmapHeight = Math.round(label.height * verticalPixelRatio);
context.fillStyle = label.style === 'outlined' ? colors.chartBackground : label.color;
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 = 'middle';
geometries.forEach(({ label, textX, textY }) => {
context.fillStyle = getLabelTextColor(label);
context.fillText(label.text, textX, textY);
});
context.restore();
});
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(
private readonly getLabels: () => readonly PriceAxisLabel[],
private readonly getLayout: () => ChartLayoutOptions | null,
private readonly getSide: () => PriceAxisSide,
private readonly getReservedCoordinate: () => number | null,
private readonly setMinimumWidth: (width: number) => void,
) {}
public draw(target: CanvasRenderingTarget2D): void {
const labels = this.getLabels();
const layout = this.getLayout();
if (!layout || labels.length === 0) {
return;
}
let geometries: LabelGeometry[] = [];
target.useMediaCoordinateSpace(({ context, mediaSize }) => {
if (mediaSize.width <= 0 || mediaSize.height <= 0) {
return;
}
context.save();
context.font = getFont(layout);
const measuredLabels = measureLabels(context, labels, layout);
this.setMinimumWidth(
measuredLabels.reduce(
(maximumWidth, label) => Math.max(maximumWidth, label.width + LABEL_EDGE_GAP * 2),
0,
),
);
geometries = createLabelGeometries(
measuredLabels,
mediaSize.width,
mediaSize.height,
this.getSide(),
layout,
this.getReservedCoordinate(),
);
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,
getReservedCoordinate: () => number | null,
setMinimumWidth: (width: number) => void,
) {
this.rendererInstance = new PriceAxisLabelsRenderer(
getLabels,
getLayout,
getSide,
getReservedCoordinate,
setMinimumWidth,
);
}
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 reservedCoordinate: number | null = null;
private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
private series: SeriesAttachedParameter<Time>['series'] | null = null;
private requestUpdate: (() => void) | null = null;
private initialMinimumWidth = 0;
private minimumWidth = 0;
private pendingMinimumWidth: number | null = null;
private minimumWidthFrame: number | null = null;
constructor() {
this.priceAxisPaneView = new PriceAxisLabelsPaneView(
() => this.labels,
() => this.chart?.options().layout ?? null,
() => (this.series?.options().priceScaleId === 'left' ? 'left' : 'right'),
() => this.reservedCoordinate,
(width) => {
this.scheduleMinimumWidth(width);
},
);
this.priceAxisPaneViewList = [this.priceAxisPaneView];
}
public attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
this.chart = chart;
this.series = series;
this.requestUpdate = requestUpdate;
this.initialMinimumWidth = series.priceScale().options().minimumWidth ?? 0;
this.minimumWidth = this.initialMinimumWidth;
this.requestUpdate();
}
public detached(): void {
this.cancelMinimumWidthUpdate();
this.applyMinimumWidth(this.initialMinimumWidth);
this.chart = null;
this.series = null;
this.requestUpdate = null;
this.labels = [];
this.reservedCoordinate = null;
this.initialMinimumWidth = 0;
this.minimumWidth = 0;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewList;
}
public updateAllViews(): void {}
public setLabels(
labels: readonly PriceAxisLabel[],
reservedCoordinate: number | null = null,
): void {
if (areLabelsEqual(this.labels, labels) && this.reservedCoordinate === reservedCoordinate) {
return;
}
this.labels = labels;
this.reservedCoordinate = reservedCoordinate;
if (labels.length === 0) {
this.scheduleMinimumWidth(0);
}
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0 && this.reservedCoordinate === null) {
return;
}
this.labels = [];
this.reservedCoordinate = null;
this.scheduleMinimumWidth(0);
this.requestUpdate?.();
}
private scheduleMinimumWidth(width: number): void {
const minimumWidth = Math.max(this.initialMinimumWidth, Math.ceil(width));
if (minimumWidth === this.minimumWidth || minimumWidth === this.pendingMinimumWidth) {
return;
}
this.pendingMinimumWidth = minimumWidth;
if (this.minimumWidthFrame !== null) {
return;
}
this.minimumWidthFrame = requestAnimationFrame(() => {
this.minimumWidthFrame = null;
if (this.pendingMinimumWidth !== null) {
this.applyMinimumWidth(this.pendingMinimumWidth);
}
this.pendingMinimumWidth = null;
});
}
private applyMinimumWidth(minimumWidth: number): void {
if (!this.series || minimumWidth === this.minimumWidth) {
return;
}
this.series.priceScale().applyOptions({
minimumWidth,
});
this.minimumWidth = minimumWidth;
}
private cancelMinimumWidthUpdate(): void {
if (this.minimumWidthFrame === null) {
return;
}
cancelAnimationFrame(this.minimumWidthFrame);
this.minimumWidthFrame = null;
this.pendingMinimumWidth = null;
}
}