Загрузка данных
import { getThemeStore } from '@src/theme';
import { layoutPriceAxisLabels } from './layout';
import type {
LaidOutPriceAxisLabel,
PriceAxisLabel,
} from './types';
import type {
CanvasRenderingTarget2D,
} from 'fancy-canvas';
import type {
ChartOptions,
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
type PriceAxisSide = 'left' | 'right';
type ChartLayoutOptions =
Readonly<ChartOptions['layout']>;
interface MeasuredLabel {
label: PriceAxisLabel;
text: string;
width: number;
height: number;
ascent: number;
descent: number;
}
interface LabelGeometry {
label: LaidOutPriceAxisLabel;
text: string;
left: number;
top: number;
width: number;
height: number;
textX: number;
textY: number;
}
const HORIZONTAL_PADDING_RATIO = 7.25 / 12;
const VERTICAL_PADDING_RATIO = 3 / 12;
const TEXT_VERTICAL_OFFSET_RATIO = -0.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 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 getTextVerticalOffset(
layout: ChartLayoutOptions,
): number {
return (
layout.fontSize *
TEXT_VERTICAL_OFFSET_RATIO
);
}
function getLabelLeft(
side: PriceAxisSide,
axisWidth: number,
labelWidth: number,
): number {
return side === 'right'
? 0
: axisWidth - labelWidth;
}
function measureLabels(
context: CanvasRenderingContext2D,
labels:
readonly PriceAxisLabel[],
layout: ChartLayoutOptions,
): MeasuredLabel[] {
const horizontalPadding =
getHorizontalPadding(layout);
const verticalPadding =
getVerticalPadding(layout);
return labels.map((label) => {
const textMetrics =
context.measureText(label.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: label.text,
width:
textWidth +
horizontalPadding * 2,
height:
textHeight +
verticalPadding * 2,
ascent,
descent,
};
});
}
function createLabelGeometries(
measuredLabels:
readonly MeasuredLabel[],
axisWidth: number,
axisHeight: number,
side: PriceAxisSide,
layout: ChartLayoutOptions,
): LabelGeometry[] {
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 verticalOffset =
getTextVerticalOffset(layout);
return laidOutLabels.flatMap(
(label) => {
const measuredLabel =
measuredLabelsById.get(
label.id,
);
if (!measuredLabel) {
return [];
}
const {
width,
height,
ascent,
descent,
} = measuredLabel;
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:
label.coordinate +
(ascent - descent) / 2 +
verticalOffset,
},
];
},
);
}
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,
private readonly requestMinimumWidth:
(width: number) => void,
) {}
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);
const measuredLabels =
measureLabels(
context,
labels,
layout,
);
const requiredWidth =
measuredLabels.reduce(
(maximumWidth, label) =>
Math.max(
maximumWidth,
label.width,
),
0,
);
this.requestMinimumWidth(
requiredWidth,
);
geometries =
createLabelGeometries(
measuredLabels,
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,
requestMinimumWidth:
(width: number) => void,
) {
this.rendererInstance =
new PriceAxisLabelsRenderer(
getLabels,
getLayout,
getSide,
requestMinimumWidth,
);
}
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;
private initialMinimumWidth = 0;
private appliedMinimumWidth = 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',
(width) => {
this.scheduleMinimumWidthUpdate(
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.appliedMinimumWidth =
this.initialMinimumWidth;
this.requestUpdate();
}
public detached(): void {
this.cancelMinimumWidthUpdate();
this.applyMinimumWidth(
this.initialMinimumWidth,
);
this.chart = null;
this.series = null;
this.requestUpdate = null;
this.pendingMinimumWidth = null;
this.initialMinimumWidth = 0;
this.appliedMinimumWidth = 0;
}
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;
if (labels.length === 0) {
this.scheduleMinimumWidthUpdate(0);
}
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.scheduleMinimumWidthUpdate(0);
this.requestUpdate?.();
}
private scheduleMinimumWidthUpdate(
requiredWidth: number,
): void {
const nextMinimumWidth =
Math.max(
this.initialMinimumWidth,
Math.ceil(requiredWidth),
);
if (
nextMinimumWidth ===
this.appliedMinimumWidth ||
nextMinimumWidth ===
this.pendingMinimumWidth
) {
return;
}
this.pendingMinimumWidth =
nextMinimumWidth;
if (
this.minimumWidthFrame !== null
) {
return;
}
if (
typeof requestAnimationFrame !==
'function'
) {
this.flushMinimumWidthUpdate();
return;
}
this.minimumWidthFrame =
requestAnimationFrame(() => {
this.minimumWidthFrame = null;
this.flushMinimumWidthUpdate();
});
}
private flushMinimumWidthUpdate(): void {
const nextMinimumWidth =
this.pendingMinimumWidth;
this.pendingMinimumWidth = null;
if (nextMinimumWidth === null) {
return;
}
this.applyMinimumWidth(
nextMinimumWidth,
);
}
private applyMinimumWidth(
minimumWidth: number,
): void {
if (
!this.series ||
minimumWidth ===
this.appliedMinimumWidth
) {
return;
}
this.series
.priceScale()
.applyOptions({
minimumWidth,
});
this.appliedMinimumWidth =
minimumWidth;
}
private cancelMinimumWidthUpdate(): void {
if (
this.minimumWidthFrame === null
) {
return;
}
if (
typeof cancelAnimationFrame ===
'function'
) {
cancelAnimationFrame(
this.minimumWidthFrame,
);
}
this.minimumWidthFrame = null;
this.pendingMinimumWidth = null;
}
}