Загрузка данных
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 getContrastTextColor(
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 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,
]),
);
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 =
label.style === 'outlined'
? layout.textColor
: getContrastTextColor(
context,
label.color,
);
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?.();
}
}