Files
2026-07-13 13:08:41 +08:00

1678 lines
44 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Chart from "chart.js/auto";
import type {
ChartConfiguration,
ChartDataset,
ChartOptions,
Plugin,
} from "chart.js";
import { Group, Image as KonvaImage, Rect } from "react-konva";
import {
ellipsizeChartText,
markdownToPlainChartText,
normalizeChartTypeName,
} from "@/components/slide-editor/charts/chart-data";
import type { DataLabelPosition } from "@/components/slide-editor/types";
import {
asRecord,
clamp,
isRecord,
readArray,
readBoolean,
readNumber,
readString,
withHash,
type RawElement,
} from "@/components/slide-editor/model/core";
type RawChartPoint = {
x: number;
y: number;
r?: number;
};
type RawChartDataset = {
categoryColors?: string[];
color: string;
name: string;
points: RawChartPoint[];
values: number[];
};
type ChartJsKind = {
area: boolean;
chartJsType:
| "bar"
| "bubble"
| "doughnut"
| "line"
| "pie"
| "polarArea"
| "radar"
| "scatter";
horizontal: boolean;
pieLike: boolean;
stacked: boolean;
};
type LabelBounds = {
bottom: number;
left: number;
right: number;
top: number;
};
type LineSegment = {
end: { x: number; y: number };
start: { x: number; y: number };
};
const DEFAULT_CHART_COLORS = [
"#7F22FE",
"#155DFC",
"#F59E0B",
"#12B76A",
"#EF4444",
"#06B6D4",
"#8B5CF6",
"#64748B",
];
const CHART_FONT_FAMILY = "Inter, Arial, sans-serif";
const DATA_LABEL_POSITIONS = new Set(["base", "mid", "top", "outside"]);
const CHART_TITLE_DISPLAY_MAX_LENGTH = 44;
const CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH = 36;
const CHART_AXIS_TICK_DISPLAY_MAX_LENGTH = 22;
const CHART_LEGEND_DISPLAY_MAX_LENGTH = 28;
export function TemplateV2ChartJsElement({
element,
height,
interactive,
width,
}: {
element: RawElement;
height: number;
interactive: boolean;
width: number;
}) {
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
const logicalWidth = Math.max(1, Math.round(width));
const logicalHeight = Math.max(1, Math.round(height));
const pixelRatio =
typeof window === "undefined"
? 1
: clamp(window.devicePixelRatio || 1, 1, interactive ? 2 : 3);
const renderSignature = useMemo(
() =>
chartRenderSignature(
element,
logicalWidth,
logicalHeight,
pixelRatio,
),
[element, logicalHeight, logicalWidth, pixelRatio],
);
const renderInput = useMemo(
() => ({
element,
logicalHeight,
logicalWidth,
pixelRatio,
renderSignature,
}),
[element, logicalHeight, logicalWidth, pixelRatio, renderSignature],
);
useEffect(() => {
if (typeof document === "undefined") return;
const nextCanvas = document.createElement("canvas");
nextCanvas.width = renderInput.logicalWidth;
nextCanvas.height = renderInput.logicalHeight;
nextCanvas.style.width = `${renderInput.logicalWidth}px`;
nextCanvas.style.height = `${renderInput.logicalHeight}px`;
const config = createChartJsConfig(
renderInput.element,
renderInput.logicalHeight,
renderInput.pixelRatio,
);
let chart: Chart | null = null;
try {
chart = new Chart(nextCanvas, config);
chart.update("none");
setCanvas(nextCanvas);
} catch (error) {
console.error("Failed to render template v2 Chart.js chart:", error);
setCanvas(null);
}
return () => {
chart?.destroy();
};
}, [renderInput]);
return (
<Group listening={interactive}>
<Rect width={width} height={height} fill="rgba(0,0,0,0)" />
{canvas ? (
<KonvaImage
image={canvas}
listening={false}
perfectDrawEnabled={false}
shadowForStrokeEnabled={false}
width={width}
height={height}
/>
) : null}
</Group>
);
}
function chartRenderSignature(
element: RawElement,
width: number,
height: number,
pixelRatio: number,
) {
return stableChartStringify({
axis_color: element.axis_color,
axisColor: element.axisColor,
categories: element.categories,
chart_type: element.chart_type,
chartType: element.chartType,
color: element.color,
colors: element.colors,
data: element.data,
data_labels: element.data_labels,
dataLabels: element.dataLabels,
grid_color: element.grid_color,
gridColor: element.gridColor,
height,
legend: element.legend,
pixelRatio,
series: element.series,
showLegend: element.showLegend,
text_color: element.text_color,
textColor: element.textColor,
title: element.title,
title_color: element.title_color,
titleColor: element.titleColor,
width,
x_axis: element.x_axis,
x_axis_grid: element.x_axis_grid,
x_axis_title: element.x_axis_title,
xAxis: element.xAxis,
xAxisGrid: element.xAxisGrid,
xAxisTitle: element.xAxisTitle,
y_axis: element.y_axis,
y_axis_grid: element.y_axis_grid,
y_axis_title: element.y_axis_title,
yAxis: element.yAxis,
yAxisGrid: element.yAxisGrid,
yAxisTitle: element.yAxisTitle,
});
}
function stableChartStringify(value: unknown) {
return JSON.stringify(value, (_key, currentValue) => {
if (!isRecord(currentValue)) return currentValue;
return Object.keys(currentValue)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = currentValue[key];
return result;
}, {});
});
}
function createChartJsConfig(
element: RawElement,
height: number,
pixelRatio: number,
): ChartConfiguration {
const kind = rawChartJsKind(element.chart_type ?? element.chartType);
const primaryColor = safeChartColor(
readString(element.color),
DEFAULT_CHART_COLORS[0],
);
const sourceColors = readArray(element.colors).map((value) =>
safeChartColor(String(value)),
);
const rawColors = sourceColors.length > 0 ? sourceColors : [primaryColor];
const categories = rawChartCategories(element, kind.pieLike);
const datasets = rawChartDatasets(
element,
categories,
rawColors,
kind.pieLike,
);
const axisColor = safeChartColor(
readString(element.axis_color ?? element.axisColor),
"#98A2B3",
);
const gridColor = safeChartColor(
readString(element.grid_color ?? element.gridColor),
axisColor,
);
const textColor = safeChartColor(
readString(element.text_color ?? element.textColor),
"#475467",
);
const titleColor = safeChartColor(
readString(element.title_color ?? element.titleColor),
"#344054",
);
const title = markdownToPlainChartText(readString(element.title) ?? "");
const dataLabelPosition = readDataLabelPosition(
hasOwn(element, "data_labels") ? element.data_labels : element.dataLabels,
);
const showValues = dataLabelPosition != null;
const showXAxisGrid =
readBoolean(element.x_axis_grid ?? element.xAxisGrid) ?? true;
const showYAxisGrid =
readBoolean(element.y_axis_grid ?? element.yAxisGrid) ?? true;
const showXAxis = readBoolean(element.x_axis ?? element.xAxis) ?? true;
const showYAxis = readBoolean(element.y_axis ?? element.yAxis) ?? true;
const xAxisTitle =
markdownToPlainChartText(
readString(
"x_axis_title" in element
? element.x_axis_title
: element.xAxisTitle,
) ?? "",
);
const yAxisTitle =
markdownToPlainChartText(
readString(
"y_axis_title" in element
? element.y_axis_title
: element.yAxisTitle,
) ?? "",
);
const fontSize = clamp(height * 0.033, 9, 18);
const titleFontSize = clamp(height * 0.044, 11, 26);
const valueFontSize = clamp(height * 0.029, 8, 15);
const chartDatasets = createChartJsDatasets(kind, datasets);
const autoShowLegend =
kind.pieLike ||
datasets.length > 1 ||
Boolean(datasets[0]?.name && datasets[0].name !== "Series 1");
const showLegend =
readBoolean(
"legend" in element ? element.legend : element.showLegend,
) ?? autoShowLegend;
return {
type: kind.chartJsType,
data: {
labels: categories.map((category) => displayChartCategoryLabel(category)),
datasets: chartDatasets,
},
options: {
animation: false,
color: textColor,
devicePixelRatio: pixelRatio,
font: {
family: CHART_FONT_FAMILY,
},
indexAxis: kind.horizontal ? "y" : "x",
layout: {
padding: kind.pieLike
? { top: 16, right: 20, bottom: 12, left: 20 }
: { top: 12, right: 22, bottom: 8, left: 12 },
},
maintainAspectRatio: false,
normalized: true,
plugins: {
legend: showLegend ? {
display: showLegend,
labels: {
boxHeight: Math.max(8, fontSize * 0.8),
boxWidth: Math.max(8, fontSize * 0.8),
color: textColor,
font: {
family: CHART_FONT_FAMILY,
size: fontSize,
weight: 600,
},
padding: Math.max(8, fontSize),
usePointStyle: true,
},
position: "bottom",
} : {
display: false,
},
title: {
color: titleColor,
display: Boolean(title),
font: {
family: CHART_FONT_FAMILY,
size: titleFontSize,
weight: 700,
},
padding: {
bottom: Math.max(16, titleFontSize * 0.8),
top: 0,
},
text: title
.split(/\r?\n/)
.filter(Boolean)
.map((line) =>
ellipsizeChartText(line, CHART_TITLE_DISPLAY_MAX_LENGTH),
),
},
tooltip: {
enabled: false,
},
},
responsive: false,
scales: chartScales({
axisColor,
fontSize,
gridColor,
kind,
showXAxis,
showXAxisGrid,
showYAxis,
showYAxisGrid,
xAxisTitle,
yAxisTitle,
}),
} as ChartOptions,
plugins: [
chartValueLabelsPlugin({
enabled: showValues,
fontSize: valueFontSize,
horizontal: kind.horizontal,
outsideColor: textColor,
position: dataLabelPosition ?? "top",
}),
],
};
}
function createChartJsDatasets(
kind: ChartJsKind,
datasets: RawChartDataset[],
): ChartDataset[] {
if (kind.chartJsType === "pie" || kind.chartJsType === "doughnut") {
const dataset = datasets[0] ?? emptyDataset();
return [
{
backgroundColor: categoryColors(dataset),
borderColor: "#FFFFFF",
borderWidth: 1,
data: dataset.values,
hoverOffset: 0,
label: displayChartLegendLabel(dataset.name),
},
];
}
if (kind.chartJsType === "polarArea") {
const sourceDatasets = datasets.length > 0 ? datasets : [emptyDataset()];
return sourceDatasets.map((dataset) => {
const colors =
sourceDatasets.length === 1
? categoryColors(dataset)
: dataset.values.map(() => dataset.color);
return {
backgroundColor: colors.map((color) => withAlpha(color, 0.78)),
borderColor: colors,
borderWidth: 1,
data: dataset.values,
label: displayChartLegendLabel(dataset.name),
};
});
}
if (kind.chartJsType === "scatter" || kind.chartJsType === "bubble") {
return datasets.map((dataset) => {
const colors =
datasets.length === 1
? categoryColors(dataset)
: [withHash(dataset.color) ?? "#7F22FE"];
return {
backgroundColor: colors.map((color) => withAlpha(color, 0.78)),
borderColor: colors,
borderWidth: 2,
data:
kind.chartJsType === "bubble"
? dataset.points.map((point) => ({ ...point, r: point.r ?? 6 }))
: dataset.points.map(({ x, y }) => ({ x, y })),
label: displayChartLegendLabel(dataset.name),
pointRadius: kind.chartJsType === "scatter" ? 4 : undefined,
pointHoverRadius: 4,
};
});
}
return datasets.map((dataset, index) => {
const color =
withHash(dataset.color) ??
DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length];
const isLineLike = kind.chartJsType === "line";
const datasetCategoryColors =
datasets.length === 1 && dataset.categoryColors?.length
? categoryColors(dataset)
: null;
return {
backgroundColor: kind.area
? withAlpha(color, 0.24)
: isLineLike
? color
: datasetCategoryColors
? datasetCategoryColors
: color,
borderColor: color,
borderRadius:
kind.chartJsType === "bar"
? kind.stacked
? 7
: (context: { raw: unknown }) =>
barBorderRadius(context.raw, kind.horizontal)
: undefined,
borderSkipped:
kind.chartJsType === "bar" && kind.stacked ? "start" : false,
borderWidth: isLineLike ? 3 : 0,
data: dataset.values,
fill: kind.area,
label: displayChartLegendLabel(dataset.name),
maxBarThickness: 62,
pointBackgroundColor: datasetCategoryColors ?? color,
pointBorderColor: "#FFFFFF",
pointBorderWidth: isLineLike ? 1.5 : 0,
pointRadius: isLineLike ? 3.5 : 0,
tension: isLineLike ? 0.35 : 0,
};
});
}
function barBorderRadius(rawValue: unknown, horizontal: boolean) {
const radius = 7;
const value = chartValue(rawValue);
if (horizontal) {
return value < 0
? {
bottomLeft: radius,
bottomRight: 0,
topLeft: radius,
topRight: 0,
}
: {
bottomLeft: 0,
bottomRight: radius,
topLeft: 0,
topRight: radius,
};
}
return value < 0
? {
bottomLeft: radius,
bottomRight: radius,
topLeft: 0,
topRight: 0,
}
: {
bottomLeft: 0,
bottomRight: 0,
topLeft: radius,
topRight: radius,
};
}
function chartScales({
axisColor,
fontSize,
gridColor,
kind,
showXAxis,
showXAxisGrid,
showYAxis,
showYAxisGrid,
xAxisTitle,
yAxisTitle,
}: {
axisColor: string;
fontSize: number;
gridColor: string;
kind: ChartJsKind;
showXAxis: boolean;
showXAxisGrid: boolean;
showYAxis: boolean;
showYAxisGrid: boolean;
xAxisTitle: string;
yAxisTitle: string;
}) {
if (kind.pieLike || kind.chartJsType === "polarArea") return undefined;
if (kind.chartJsType === "radar") {
return {
r: {
angleLines: {
color: withAlpha(gridColor, showXAxisGrid ? 0.35 : 0),
display: showXAxisGrid,
},
beginAtZero: true,
grid: {
color: withAlpha(gridColor, showYAxisGrid ? 0.35 : 0),
display: showYAxisGrid,
},
pointLabels: {
color: axisColor,
display: showXAxis,
font: {
family: CHART_FONT_FAMILY,
size: fontSize,
weight: 600,
},
},
ticks: {
backdropColor: "transparent",
callback: (value: string | number) => formatAxisTick(value),
color: axisColor,
display: showYAxis,
font: {
family: CHART_FONT_FAMILY,
size: Math.max(8, fontSize - 1),
},
},
},
};
}
const showCategoryGrid = kind.horizontal
? showXAxisGrid
: showYAxisGrid;
const showLinearGrid = kind.horizontal ? showYAxisGrid : showXAxisGrid;
const showCategoryAxis = kind.horizontal ? showYAxis : showXAxis;
const showLinearAxis = kind.horizontal ? showXAxis : showYAxis;
const categoryAxis = {
display: showCategoryAxis || showCategoryGrid,
border: {
color: axisColor,
display: showCategoryAxis,
},
grid: {
color: withAlpha(gridColor, showCategoryGrid ? 0.25 : 0),
display: showCategoryGrid,
drawTicks: showCategoryAxis,
},
stacked: kind.stacked,
ticks: {
color: axisColor,
display: showCategoryAxis,
font: {
family: CHART_FONT_FAMILY,
size: fontSize,
weight: 600,
},
},
title: {
color: axisColor,
display:
showCategoryAxis &&
Boolean(kind.horizontal ? yAxisTitle : xAxisTitle),
font: {
family: CHART_FONT_FAMILY,
size: fontSize,
weight: 700,
},
text: ellipsizeChartText(
kind.horizontal ? yAxisTitle : xAxisTitle,
CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH,
),
},
type: "category",
};
const linearAxis = {
beginAtZero: true,
display: showLinearAxis || showLinearGrid,
border: {
color: axisColor,
display: showLinearAxis,
},
grace: "8%",
grid: {
color: withAlpha(gridColor, showLinearGrid ? 0.35 : 0),
display: showLinearGrid,
drawTicks: showLinearAxis,
},
stacked: kind.stacked,
ticks: {
callback: (value: string | number) => formatAxisTick(value),
color: axisColor,
display: showLinearAxis,
font: {
family: CHART_FONT_FAMILY,
size: Math.max(8, fontSize - 2),
weight: 600,
},
},
title: {
color: axisColor,
display:
showLinearAxis &&
Boolean(kind.horizontal ? xAxisTitle : yAxisTitle),
font: {
family: CHART_FONT_FAMILY,
size: fontSize,
weight: 700,
},
text: ellipsizeChartText(
kind.horizontal ? xAxisTitle : yAxisTitle,
CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH,
),
},
type: "linear",
};
if (kind.chartJsType === "scatter" || kind.chartJsType === "bubble") {
return {
x: {
...linearAxis,
display: showXAxis || showYAxisGrid,
border: {
...linearAxis.border,
display: showXAxis,
},
grid: {
color: withAlpha(gridColor, showYAxisGrid ? 0.35 : 0),
display: showYAxisGrid,
drawTicks: showXAxis,
},
ticks: {
...linearAxis.ticks,
display: showXAxis,
},
title: {
...linearAxis.title,
display: showXAxis && Boolean(xAxisTitle),
text: ellipsizeChartText(
xAxisTitle,
CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH,
),
},
},
y: {
...linearAxis,
display: showYAxis || showXAxisGrid,
border: {
...linearAxis.border,
display: showYAxis,
},
grid: {
color: withAlpha(gridColor, showXAxisGrid ? 0.35 : 0),
display: showXAxisGrid,
drawTicks: showYAxis,
},
ticks: {
...linearAxis.ticks,
display: showYAxis,
},
title: {
...linearAxis.title,
display: showYAxis && Boolean(yAxisTitle),
text: ellipsizeChartText(
yAxisTitle,
CHART_AXIS_TITLE_DISPLAY_MAX_LENGTH,
),
},
},
};
}
return kind.horizontal
? { x: linearAxis, y: categoryAxis }
: { x: categoryAxis, y: linearAxis };
}
function rawChartJsKind(value: unknown): ChartJsKind {
const normalized = normalizeChartTypeName(value);
switch (normalized) {
case "area":
return baseKind("line", { area: true });
case "bubble":
return baseKind("bubble");
case "donut":
case "doughnut":
return baseKind("doughnut", { pieLike: true });
case "horizontal_bar":
case "bar_horizontal":
return baseKind("bar", { horizontal: true });
case "line":
return baseKind("line");
case "pie":
return baseKind("pie", { pieLike: true });
case "polar":
case "polar_area":
return baseKind("polarArea");
case "radar":
return baseKind("radar");
case "scatter":
return baseKind("scatter");
case "stackedbar":
case "stacked_bar":
case "bar_stacked":
case "stacked":
return baseKind("bar", { stacked: true });
case "horizontalstackbar":
case "horizontalstackedbar":
case "horizontal_stack_bar":
case "horizontal_stacked_bar":
return baseKind("bar", { horizontal: true, stacked: true });
case "bar":
default:
return baseKind("bar");
}
}
function baseKind(
chartJsType: ChartJsKind["chartJsType"],
overrides: Partial<ChartJsKind> = {},
): ChartJsKind {
return {
area: false,
chartJsType,
horizontal: false,
pieLike: false,
stacked: false,
...overrides,
};
}
function rawChartCategories(
element: RawElement,
singleSeriesOnly = false,
): string[] {
const categories = readArray(element.categories).map(String);
const dataLabels = readArray(element.data)
.map((item) => readString(asRecord(item)?.label))
.filter((label): label is string => Boolean(label));
const rawSeries = readArray(element.series).filter(isRecord);
const series = singleSeriesOnly ? rawSeries.slice(0, 1) : rawSeries;
const length = Math.min(
24,
Math.max(
1,
categories.length,
dataLabels.length,
...series.map((item) => readArray(item.values ?? item.data).length),
),
);
if (categories.length > 0) {
return Array.from(
{ length },
(_, index) => categories[index] ?? `Item ${index + 1}`,
);
}
if (dataLabels.length > 0) {
return Array.from(
{ length },
(_, index) => dataLabels[index] ?? `Item ${index + 1}`,
);
}
return Array.from({ length }, (_, index) => `Item ${index + 1}`);
}
function rawChartDatasets(
element: RawElement,
categories: string[],
colors: string[],
singleSeriesOnly = false,
): RawChartDataset[] {
const rawSeries = readArray(element.series).filter(isRecord);
const effectiveSeries = singleSeriesOnly ? rawSeries.slice(0, 1) : rawSeries;
const categoryLength = Math.max(1, categories.length);
if (effectiveSeries.length > 0) {
return effectiveSeries.slice(0, 12).map((item, index) => {
const rawValues = readArray(item.values ?? item.data);
const values = normalizeValues(
rawValues.map((value) => chartValue(value)),
categoryLength,
);
const points = normalizePoints(
rawValues.map((value, valueIndex) => chartPoint(value, valueIndex)),
categoryLength,
values,
);
return {
color:
colors[index % colors.length] ??
DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length],
name: readString(item.name) ?? `Series ${index + 1}`,
categoryColors: effectiveSeries.length === 1 ? colors : undefined,
points,
values,
};
});
}
const rawData = readArray(element.data).filter(isRecord);
const values = normalizeValues(
rawData.map((item) => chartValue(item)),
categoryLength,
);
return [
{
color:
colors[0] ??
safeChartColor(readString(element.color), DEFAULT_CHART_COLORS[0]),
name: readString(element.title) ?? "Series 1",
categoryColors: colors,
points: normalizePoints(
rawData.map((value, valueIndex) => chartPoint(value, valueIndex)),
categoryLength,
values,
),
values,
},
];
}
function chartValue(value: unknown) {
const directNumber = readNumber(value);
if (directNumber != null) return directNumber;
const record = asRecord(value);
return (
readNumber(record?.value) ??
readNumber(record?.y) ??
readNumber(record?.data) ??
0
);
}
function chartPoint(value: unknown, index: number): RawChartPoint {
const record = asRecord(value);
if (!record) {
return { x: index + 1, y: chartValue(value) };
}
return {
x: readNumber(record.x) ?? index + 1,
y: chartValue(record),
...(readNumber(record.r ?? record.radius) != null
? { r: readNumber(record.r ?? record.radius) ?? undefined }
: {}),
};
}
function normalizeValues(values: number[], length: number) {
const normalized = values.slice(0, length);
while (normalized.length < length) normalized.push(0);
return normalized;
}
function normalizePoints(
points: RawChartPoint[],
length: number,
fallbackValues: number[],
) {
const normalized = points.slice(0, length);
while (normalized.length < length) {
const index = normalized.length;
normalized.push({ x: index + 1, y: fallbackValues[index] ?? 0 });
}
return normalized;
}
function emptyDataset(): RawChartDataset {
return {
color: DEFAULT_CHART_COLORS[0],
name: "Series 1",
points: [{ x: 1, y: 0 }],
values: [0],
};
}
function categoryColors(dataset: RawChartDataset) {
const colors = dataset.categoryColors ?? [];
return dataset.values.map(
(_, index) =>
withHash(colors[index % colors.length]) ??
withHash(dataset.color) ??
DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length],
);
}
function readDataLabelPosition(value: unknown): DataLabelPosition | null {
if (value === true) return "top";
if (value === false || value == null) return null;
const text = readString(value);
return text && DATA_LABEL_POSITIONS.has(text)
? (text as DataLabelPosition)
: null;
}
function hasOwn(record: RawElement, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function chartValueLabelsPlugin({
enabled,
fontSize,
horizontal,
outsideColor,
position,
}: {
enabled: boolean;
fontSize: number;
horizontal: boolean;
outsideColor: string;
position: DataLabelPosition;
}): Plugin {
return {
id: "templateV2ChartValueLabels",
afterDatasetsDraw(chart) {
if (!enabled) return;
const ctx = chart.ctx;
ctx.save();
ctx.font = `600 ${fontSize}px ${CHART_FONT_FAMILY}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const occupiedLabels: LabelBounds[] = [];
const lineObstacles: LineSegment[] = [];
const pointObstacles: LabelBounds[] = [];
chart.data.datasets.forEach((_, datasetIndex) => {
const meta = chart.getDatasetMeta(datasetIndex);
const metaType = readString((meta as { type?: unknown }).type);
if (meta.hidden || !isPointChartType(metaType)) return;
meta.data.forEach((element) => {
const bounds = pointElementBounds(element);
if (bounds) pointObstacles.push(bounds);
});
if (metaType === "line" || metaType === "radar") {
for (let index = 1; index < meta.data.length; index += 1) {
const start = chartElementPoint(meta.data[index - 1]);
const end = chartElementPoint(meta.data[index]);
if (start && end) lineObstacles.push({ start, end });
}
}
});
chart.data.datasets.forEach((dataset, datasetIndex) => {
const meta = chart.getDatasetMeta(datasetIndex);
if (meta.hidden) return;
meta.data.forEach((element, index) => {
const rawValue = readArray(dataset.data)[index];
const numeric = chartValue(rawValue);
const metaType = readString((meta as { type?: unknown }).type);
const label = formatChartValue(numeric);
if (metaType === "bar") {
drawBarValueLabel({
color: datasetBackgroundColor(dataset, index),
ctx,
element,
fontSize,
horizontal,
label,
outsideColor,
position,
value: numeric,
});
return;
}
if (isPointChartType(metaType)) {
drawPointValueLabel({
chartArea: chart.chartArea,
ctx,
datasetIndex,
element,
fontSize,
index,
label,
lineLike: metaType === "line" || metaType === "radar",
lineObstacles,
metaElements: meta.data,
occupiedLabels,
outsideColor,
position,
pointObstacles,
});
return;
}
if (
metaType === "pie" ||
metaType === "doughnut" ||
metaType === "polarArea"
) {
drawArcValueLabel({
color: datasetBackgroundColor(dataset, index),
ctx,
element,
fontSize,
label,
outsideColor,
position,
});
return;
}
const fallbackPosition =
typeof element.tooltipPosition === "function"
? element.tooltipPosition(true)
: null;
if (!fallbackPosition) return;
ctx.fillStyle =
metaType === "pie" ||
metaType === "doughnut" ||
metaType === "polarArea"
? contrastTextColor(
datasetBackgroundColor(dataset, index),
outsideColor,
)
: outsideColor;
ctx.fillText(label, (fallbackPosition.x ?? 0), (fallbackPosition.y ?? 0));
});
});
ctx.restore();
},
};
}
function drawBarValueLabel({
color,
ctx,
element,
fontSize,
horizontal,
label,
outsideColor,
position,
value,
}: {
color: string | null;
ctx: CanvasRenderingContext2D;
element: unknown;
fontSize: number;
horizontal: boolean;
label: string;
outsideColor: string;
position: DataLabelPosition;
value: number;
}) {
const bar = asRecord(element);
const x = readNumber(bar?.x);
const y = readNumber(bar?.y);
const base = readNumber(bar?.base);
const width = Math.abs(readNumber(bar?.width) ?? 0);
const height = Math.abs(readNumber(bar?.height) ?? 0);
if (x == null || y == null || base == null) return;
const textWidth = ctx.measureText(label).width;
const padding = 5;
const fitsInside = horizontal
? width >= textWidth + padding * 2 && height >= fontSize * 1.35
: height >= fontSize * 1.65 && width >= textWidth + padding * 2;
const resolvedPosition =
position === "outside" || !fitsInside ? "outside" : position;
if (resolvedPosition !== "outside") {
ctx.fillStyle = contrastTextColor(color, outsideColor);
if (horizontal) {
const direction = value < 0 ? -1 : 1;
const labelX =
resolvedPosition === "base"
? base + direction * (textWidth / 2 + padding)
: resolvedPosition === "top"
? x - direction * (textWidth / 2 + padding)
: (x + base) / 2;
ctx.fillText(label, labelX, y);
return;
}
const direction = value < 0 ? 1 : -1;
const labelY =
resolvedPosition === "base"
? base + direction * (fontSize / 2 + padding)
: resolvedPosition === "top"
? y - direction * (fontSize / 2 + padding)
: (y + base) / 2;
ctx.fillText(
label,
x,
labelY,
);
return;
}
ctx.fillStyle = outsideColor;
if (horizontal) {
const direction = value < 0 ? -1 : 1;
ctx.fillText(
label,
x + direction * (textWidth / 2 + padding),
y,
);
return;
}
const direction = value < 0 ? 1 : -1;
ctx.fillText(label, x, y + direction * (fontSize / 2 + padding));
}
function drawPointValueLabel({
chartArea,
ctx,
datasetIndex,
element,
fontSize,
index,
label,
lineLike,
lineObstacles,
metaElements,
occupiedLabels,
outsideColor,
position,
pointObstacles,
}: {
chartArea: LabelBounds;
ctx: CanvasRenderingContext2D;
datasetIndex: number;
element: unknown;
fontSize: number;
index: number;
label: string;
lineLike: boolean;
lineObstacles: LineSegment[];
metaElements: unknown[];
occupiedLabels: LabelBounds[];
outsideColor: string;
position: DataLabelPosition;
pointObstacles: LabelBounds[];
}) {
const point = asRecord(element);
const x = readNumber(point?.x);
const y = readNumber(point?.y);
if (!point || x == null || y == null) return;
const radius = pointElementRadius(point);
const textWidth = ctx.measureText(label).width;
const textHeight = fontSize * 1.15;
const verticalDirection = lineLike
? lineLabelDirection(metaElements, index, datasetIndex)
: (index + datasetIndex) % 2 === 0
? -1
: 1;
const verticalOffset = radius + textHeight / 2 + 5;
const horizontalOffset = radius + textWidth / 2 + 5;
if (position !== "outside") {
const candidate =
position === "base"
? { x, y: y + verticalOffset }
: position === "top"
? { x, y: y - verticalOffset }
: { x, y };
const bounds = valueLabelBounds(
candidate.x,
candidate.y,
textWidth,
textHeight,
);
const intersectsPoint =
position !== "mid" &&
pointObstacles.some((obstacle) => labelBoundsOverlap(bounds, obstacle));
if (
labelFitsChartArea(bounds, chartArea) &&
occupiedLabels.every(
(occupied) => !labelBoundsOverlap(bounds, occupied),
) &&
!intersectsPoint &&
lineObstacles.every(
(segment) => !lineSegmentIntersectsBounds(segment, bounds),
)
) {
occupiedLabels.push(bounds);
ctx.fillStyle = outsideColor;
ctx.fillText(label, candidate.x, candidate.y);
return;
}
}
const candidates = [
{ x, y: y + verticalDirection * verticalOffset },
{ x, y: y - verticalDirection * verticalOffset },
{ x: x + horizontalOffset, y },
{ x: x - horizontalOffset, y },
{
x: x + horizontalOffset,
y: y + verticalDirection * verticalOffset,
},
{
x: x - horizontalOffset,
y: y + verticalDirection * verticalOffset,
},
{
x: x + horizontalOffset,
y: y - verticalDirection * verticalOffset,
},
{
x: x - horizontalOffset,
y: y - verticalDirection * verticalOffset,
},
{ x, y: y + verticalDirection * verticalOffset * 1.7 },
{ x, y: y - verticalDirection * verticalOffset * 1.7 },
];
const availableCandidate = candidates
.map((candidate) => ({
...candidate,
bounds: valueLabelBounds(
candidate.x,
candidate.y,
textWidth,
textHeight,
),
}))
.find(
(candidate) =>
labelFitsChartArea(candidate.bounds, chartArea) &&
occupiedLabels.every(
(occupied) => !labelBoundsOverlap(candidate.bounds, occupied),
) &&
pointObstacles.every(
(obstacle) => !labelBoundsOverlap(candidate.bounds, obstacle),
) &&
lineObstacles.every(
(segment) => !lineSegmentIntersectsBounds(segment, candidate.bounds),
),
);
if (!availableCandidate) return;
const resolved = availableCandidate;
const bounds = valueLabelBounds(
resolved.x,
resolved.y,
textWidth,
textHeight,
);
occupiedLabels.push(bounds);
ctx.fillStyle = outsideColor;
ctx.fillText(label, resolved.x, resolved.y);
}
function drawArcValueLabel({
color,
ctx,
element,
fontSize,
label,
outsideColor,
position,
}: {
color: string | null;
ctx: CanvasRenderingContext2D;
element: unknown;
fontSize: number;
label: string;
outsideColor: string;
position: DataLabelPosition;
}) {
const arc = asRecord(element);
const centerX = readNumber(arc?.x);
const centerY = readNumber(arc?.y);
const startAngle = readNumber(arc?.startAngle);
const endAngle = readNumber(arc?.endAngle);
const innerRadius = Math.max(0, readNumber(arc?.innerRadius) ?? 0);
const outerRadius = Math.max(innerRadius, readNumber(arc?.outerRadius) ?? 0);
let point: { x: number; y: number } | null = null;
if (
centerX != null &&
centerY != null &&
startAngle != null &&
endAngle != null &&
outerRadius > 0
) {
const angle = (startAngle + endAngle) / 2;
const ringWidth = Math.max(1, outerRadius - innerRadius);
const textHeight = fontSize * 1.15;
const radius =
position === "outside"
? outerRadius + textHeight / 2 + 7
: position === "top"
? Math.max(innerRadius + textHeight / 2, outerRadius - textHeight / 2 - 5)
: position === "base"
? innerRadius > 0
? innerRadius + Math.min(ringWidth * 0.25, textHeight + 5)
: outerRadius * 0.35
: innerRadius + ringWidth / 2;
point = {
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
};
} else if (typeof arc?.tooltipPosition === "function") {
point = arc.tooltipPosition(true);
}
if (!point) return;
ctx.fillStyle =
position === "outside"
? outsideColor
: contrastTextColor(color, outsideColor);
ctx.fillText(label, point.x, point.y);
}
function isPointChartType(metaType: string | null) {
return (
metaType === "line" ||
metaType === "scatter" ||
metaType === "bubble" ||
metaType === "radar"
);
}
function pointElementRadius(point: RawElement) {
const options = asRecord(point.options);
return Math.max(
0,
readNumber(options?.radius) ?? readNumber(point.radius) ?? 3,
);
}
function pointElementBounds(element: unknown): LabelBounds | null {
const point = asRecord(element);
const x = readNumber(point?.x);
const y = readNumber(point?.y);
if (!point || x == null || y == null) return null;
const radius = pointElementRadius(point) + 2;
return {
bottom: y + radius,
left: x - radius,
right: x + radius,
top: y - radius,
};
}
function lineLabelDirection(
elements: unknown[],
index: number,
datasetIndex: number,
) {
const currentY = readNumber(asRecord(elements[index])?.y);
const previousY = readNumber(asRecord(elements[index - 1])?.y);
const nextY = readNumber(asRecord(elements[index + 1])?.y);
if (currentY == null) return datasetIndex % 2 === 0 ? -1 : 1;
if (previousY != null && nextY != null) {
if (currentY <= previousY && currentY <= nextY) return -1;
if (currentY >= previousY && currentY >= nextY) return 1;
}
if (nextY != null && previousY == null) return nextY < currentY ? 1 : -1;
if (previousY != null && nextY == null) {
return previousY < currentY ? 1 : -1;
}
return datasetIndex % 2 === 0 ? -1 : 1;
}
function chartElementPoint(element: unknown) {
const point = asRecord(element);
const x = readNumber(point?.x);
const y = readNumber(point?.y);
return x == null || y == null ? null : { x, y };
}
function lineSegmentIntersectsBounds(
segment: LineSegment,
bounds: LabelBounds,
) {
if (
pointInsideBounds(segment.start, bounds) ||
pointInsideBounds(segment.end, bounds)
) {
return true;
}
const topLeft = { x: bounds.left, y: bounds.top };
const topRight = { x: bounds.right, y: bounds.top };
const bottomLeft = { x: bounds.left, y: bounds.bottom };
const bottomRight = { x: bounds.right, y: bounds.bottom };
return (
lineSegmentsIntersect(segment.start, segment.end, topLeft, topRight) ||
lineSegmentsIntersect(segment.start, segment.end, topRight, bottomRight) ||
lineSegmentsIntersect(segment.start, segment.end, bottomRight, bottomLeft) ||
lineSegmentsIntersect(segment.start, segment.end, bottomLeft, topLeft)
);
}
function pointInsideBounds(
point: { x: number; y: number },
bounds: LabelBounds,
) {
return (
point.x >= bounds.left &&
point.x <= bounds.right &&
point.y >= bounds.top &&
point.y <= bounds.bottom
);
}
function lineSegmentsIntersect(
firstStart: { x: number; y: number },
firstEnd: { x: number; y: number },
secondStart: { x: number; y: number },
secondEnd: { x: number; y: number },
) {
if (
Math.max(firstStart.x, firstEnd.x) < Math.min(secondStart.x, secondEnd.x) ||
Math.min(firstStart.x, firstEnd.x) > Math.max(secondStart.x, secondEnd.x) ||
Math.max(firstStart.y, firstEnd.y) < Math.min(secondStart.y, secondEnd.y) ||
Math.min(firstStart.y, firstEnd.y) > Math.max(secondStart.y, secondEnd.y)
) {
return false;
}
const firstA = lineSide(firstStart, firstEnd, secondStart);
const firstB = lineSide(firstStart, firstEnd, secondEnd);
const secondA = lineSide(secondStart, secondEnd, firstStart);
const secondB = lineSide(secondStart, secondEnd, firstEnd);
return firstA * firstB <= 0 && secondA * secondB <= 0;
}
function lineSide(
start: { x: number; y: number },
end: { x: number; y: number },
point: { x: number; y: number },
) {
return (
(end.x - start.x) * (point.y - start.y) -
(end.y - start.y) * (point.x - start.x)
);
}
function valueLabelBounds(
x: number,
y: number,
width: number,
height: number,
): LabelBounds {
const padding = 2;
return {
bottom: y + height / 2 + padding,
left: x - width / 2 - padding,
right: x + width / 2 + padding,
top: y - height / 2 - padding,
};
}
function labelFitsChartArea(bounds: LabelBounds, chartArea: LabelBounds) {
return (
bounds.left >= chartArea.left &&
bounds.right <= chartArea.right &&
bounds.top >= chartArea.top &&
bounds.bottom <= chartArea.bottom
);
}
function labelBoundsOverlap(first: LabelBounds, second: LabelBounds) {
return !(
first.right < second.left ||
first.left > second.right ||
first.bottom < second.top ||
first.top > second.bottom
);
}
function datasetBackgroundColor(dataset: ChartDataset, index: number) {
const backgroundColor = (dataset as { backgroundColor?: unknown })
.backgroundColor;
const color = Array.isArray(backgroundColor)
? backgroundColor[index]
: backgroundColor;
return typeof color === "string" ? color : null;
}
function contrastTextColor(backgroundColor: string | null, fallback: string) {
const background = parseChartColor(backgroundColor);
if (!background) return fallback;
const [red, green, blue, alpha] = background;
const composite = [red, green, blue].map(
(channel) => channel * alpha + 255 * (1 - alpha),
);
const backgroundLuminance = relativeLuminance(composite);
const dark = [16, 24, 40];
const light = [255, 255, 255];
const darkContrast = contrastRatio(
backgroundLuminance,
relativeLuminance(dark),
);
const lightContrast = contrastRatio(
backgroundLuminance,
relativeLuminance(light),
);
return lightContrast >= darkContrast ? "#FFFFFF" : "#101828";
}
function parseChartColor(
color: string | null,
): [number, number, number, number] | null {
if (!color) return null;
const hex = color.match(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/);
if (hex) {
const raw =
hex[1].length === 3
? hex[1]
.split("")
.map((character) => character + character)
.join("")
: hex[1];
const value = Number.parseInt(raw, 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255, 1];
}
const rgb = color.match(/^rgba?\(([^)]+)\)$/i);
if (!rgb) return null;
const channels = rgb[1].split(",").map((part) => Number(part.trim()));
if (channels.length < 3 || channels.slice(0, 3).some(Number.isNaN)) {
return null;
}
return [
clamp(channels[0], 0, 255),
clamp(channels[1], 0, 255),
clamp(channels[2], 0, 255),
clamp(Number.isFinite(channels[3]) ? channels[3] : 1, 0, 1),
];
}
function relativeLuminance(channels: number[]) {
const [red, green, blue] = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
}
function contrastRatio(first: number, second: number) {
const lighter = Math.max(first, second);
const darker = Math.min(first, second);
return (lighter + 0.05) / (darker + 0.05);
}
function formatChartValue(value: number) {
if (!Number.isFinite(value)) return "0";
if (Math.abs(value) >= 1000) {
return Intl.NumberFormat("en", { notation: "compact" }).format(value);
}
return Number.isInteger(value) ? `${value}` : value.toFixed(1).replace(/\.0$/, "");
}
function formatAxisTick(value: string | number) {
const numeric = Number(value);
return Number.isFinite(numeric) ? formatChartValue(numeric) : String(value);
}
function displayChartCategoryLabel(value: string) {
return ellipsizeChartText(value, CHART_AXIS_TICK_DISPLAY_MAX_LENGTH);
}
function displayChartLegendLabel(value: string) {
return ellipsizeChartText(value, CHART_LEGEND_DISPLAY_MAX_LENGTH);
}
function safeChartColor(
value: string | null | undefined,
fallback = DEFAULT_CHART_COLORS[0],
) {
const color = withHash(value) ?? fallback;
if (
/^#[0-9A-Fa-f]{3}$/.test(color) ||
/^#[0-9A-Fa-f]{6}$/.test(color) ||
/^rgba?\(/i.test(color)
) {
return color;
}
return fallback;
}
function withAlpha(color: string, alpha: number) {
const normalized = safeChartColor(color);
const hex = normalized.match(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/);
if (!hex) {
const rgb = normalized.match(/^rgba?\(([^)]+)\)$/i);
if (rgb) {
const channels = rgb[1]
.split(",")
.slice(0, 3)
.map((part) => part.trim());
return `rgba(${channels.join(", ")}, ${alpha})`;
}
return normalized;
}
const raw =
hex[1].length === 3
? hex[1].split("").map((char) => char + char).join("")
: hex[1];
const int = Number.parseInt(raw, 16);
return `rgba(${(int >> 16) & 255}, ${(int >> 8) & 255}, ${int & 255}, ${alpha})`;
}