Загрузка данных
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
export type PriceAxisLabelStyle = 'filled' | 'outlined';
export interface PriceAxisLabel {
id: string;
coordinate: number | null;
value: string;
color: string;
style: PriceAxisLabelStyle;
symbol?: string;
priority?: number;
}
interface ResolvedPriceAxisLabel extends PriceAxisLabel {
coordinate: number;
}
const LABEL_HEIGHT = 20;
const LABEL_GAP = 2;
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;
function getContrastTextColor(color: string): string {
const normalizedColor = color.replace('#', '').slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
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 '#FFFFFF';
}
const luminance =
(red * 0.299 + green * 0.587 + blue * 0.114) /
255;
return luminance > 0.6 ? '#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 resolveLabelPositions(
labels: PriceAxisLabel[],
height: number,
): ResolvedPriceAxisLabel[] {
const halfLabelHeight = LABEL_HEIGHT / 2;
const minCoordinate = halfLabelHeight;
const maxCoordinate = Math.max(
halfLabelHeight,
height - halfLabelHeight,
);
const resolvedLabels = labels
.filter(
(
label,
): label is PriceAxisLabel & {
coordinate: number;
} =>
label.coordinate !== null &&
Number.isFinite(label.coordinate),
)
.sort((left, right) => {
if (left.coordinate === right.coordinate) {
return (
(right.priority ?? 0) -
(left.priority ?? 0)
);
}
return left.coordinate - right.coordinate;
})
.map((label) => ({
...label,
coordinate: Math.min(
Math.max(label.coordinate, minCoordinate),
maxCoordinate,
),
}));
for (
let index = 1;
index < resolvedLabels.length;
index += 1
) {
const previousLabel = resolvedLabels[index - 1];
const currentLabel = resolvedLabels[index];
const minCurrentCoordinate =
previousLabel.coordinate +
LABEL_HEIGHT +
LABEL_GAP;
if (
currentLabel.coordinate < minCurrentCoordinate
) {
currentLabel.coordinate = minCurrentCoordinate;
}
}
const lastLabel =
resolvedLabels[resolvedLabels.length - 1];
if (
lastLabel &&
lastLabel.coordinate > maxCoordinate
) {
const offset =
lastLabel.coordinate - maxCoordinate;
resolvedLabels.forEach((label) => {
label.coordinate -= offset;
});
}
for (
let index = resolvedLabels.length - 2;
index >= 0;
index -= 1
) {
const currentLabel = resolvedLabels[index];
const nextLabel = resolvedLabels[index + 1];
const maxCurrentCoordinate =
nextLabel.coordinate -
LABEL_HEIGHT -
LABEL_GAP;
if (
currentLabel.coordinate > maxCurrentCoordinate
) {
currentLabel.coordinate = maxCurrentCoordinate;
}
}
const firstLabel = resolvedLabels[0];
if (
firstLabel &&
firstLabel.coordinate < minCoordinate
) {
const offset =
minCoordinate - firstLabel.coordinate;
resolvedLabels.forEach((label) => {
label.coordinate += offset;
});
}
return resolvedLabels;
}
class PriceAxisLabelsRenderer
implements IPrimitivePaneRenderer
{
constructor(
private readonly getLabels: () => readonly PriceAxisLabel[],
) {}
public draw(
target: Parameters<
IPrimitivePaneRenderer['draw']
>[0],
): void {
const labels = this.getLabels();
if (labels.length === 0) {
return;
}
target.useBitmapCoordinateSpace(
({
context,
bitmapSize,
horizontalPixelRatio,
verticalPixelRatio,
}) => {
const { width, height } = bitmapSize;
if (width <= 0 || height <= 0) {
return;
}
const resolvedLabels =
resolveLabelPositions(
[...labels],
height / verticalPixelRatio,
);
if (resolvedLabels.length === 0) {
return;
}
const { colors } = getThemeStore();
context.save();
context.font = `${FONT_WEIGHT} ${Math.round(
FONT_SIZE * verticalPixelRatio,
)}px Inter, sans-serif`;
context.textBaseline = 'middle';
resolvedLabels.forEach((label) => {
const coordinate = Math.round(
label.coordinate * verticalPixelRatio,
);
const labelHeight = Math.round(
LABEL_HEIGHT * verticalPixelRatio,
);
const top = Math.round(
coordinate - labelHeight / 2,
);
const horizontalPadding = Math.round(
HORIZONTAL_PADDING *
horizontalPixelRatio,
);
const backgroundColor =
label.style === 'outlined'
? colors.chartBackground
: label.color;
const textColor =
label.style === 'outlined'
? label.color
: getContrastTextColor(label.color);
context.fillStyle = backgroundColor;
context.fillRect(
0,
top,
width,
labelHeight,
);
if (label.style === 'outlined') {
const borderWidth = Math.max(
1,
Math.round(horizontalPixelRatio),
);
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
borderWidth / 2,
top + borderWidth / 2,
Math.max(0, width - borderWidth),
Math.max(
0,
labelHeight - borderWidth,
),
);
}
context.fillStyle = textColor;
if (!label.symbol) {
const maxValueWidth =
width - horizontalPadding * 2;
const value = fitText(
context,
label.value,
maxValueWidth,
);
context.textAlign = 'center';
context.fillText(
value,
width / 2,
coordinate,
);
return;
}
const availableWidth =
width - horizontalPadding * 2;
const measuredSymbolWidth =
context.measureText(label.symbol).width +
horizontalPadding * 2;
const symbolWidth = Math.min(
Math.max(
measuredSymbolWidth,
availableWidth *
SYMBOL_MIN_WIDTH_RATIO,
),
availableWidth *
SYMBOL_MAX_WIDTH_RATIO,
);
const separatorX = Math.round(
horizontalPadding + symbolWidth,
);
const separatorWidth = Math.max(
1,
Math.round(horizontalPixelRatio),
);
const symbolMaxWidth =
symbolWidth - horizontalPadding * 2;
const valueAreaWidth =
width - separatorX;
const valueMaxWidth =
valueAreaWidth -
horizontalPadding * 2;
const symbol = fitText(
context,
label.symbol,
symbolMaxWidth,
);
const value = fitText(
context,
label.value,
valueMaxWidth,
);
context.textAlign = 'center';
context.fillText(
symbol,
horizontalPadding +
symbolWidth / 2,
coordinate,
);
context.globalAlpha = 0.5;
context.fillRect(
separatorX,
top +
Math.round(
3 * verticalPixelRatio,
),
separatorWidth,
labelHeight -
Math.round(
6 * verticalPixelRatio,
),
);
context.globalAlpha = 1;
context.fillText(
value,
separatorX +
valueAreaWidth / 2,
coordinate,
);
});
context.restore();
},
);
}
}
class PriceAxisLabelsPaneView
implements IPrimitivePaneView
{
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(
private readonly getLabels: () => readonly PriceAxisLabel[],
) {
this.rendererInstance =
new PriceAxisLabelsRenderer(getLabels);
}
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 requestUpdate:
| (() => void)
| null = null;
constructor() {
this.priceAxisPaneView =
new PriceAxisLabelsPaneView(
() => this.labels,
);
this.priceAxisPaneViewList = [
this.priceAxisPaneView,
];
}
public attached({
requestUpdate,
}: SeriesAttachedParameter<Time>): void {
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewList;
}
public updateAllViews(): void {}
public setLabels(
labels: readonly PriceAxisLabel[],
): void {
this.labels = labels;
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.requestUpdate?.();
}
}