Загрузка данных
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,
MeasuredPriceAxisLabel,
PriceAxisLabel,
} from './types';
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 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);
const color = Number.parseInt(hex, 16);
if (Number.isNaN(color)) {
return '#FFFFFF';
}
const red = (color >> 16) & 255;
const green = (color >> 8) & 255;
const blue = color & 255;
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.collisionGroup ===
nextLabel.collisionGroup &&
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 textHeight =
textMetrics.actualBoundingBoxAscent +
textMetrics.actualBoundingBoxDescent ||
layout.fontSize;
return {
...label,
width:
Math.ceil(textMetrics.width) +
horizontalPadding * 2,
height:
textHeight +
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'
? 0
: Math.max(
0,
axisWidth - measuredLabel.width,
);
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(
(width, label) =>
Math.max(width, label.width),
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;
}
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;
}
}