apexify.js 5.1.1 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +240 -0
- package/README.md +248 -1105
- package/dist/cjs/Canvas/ApexPainter.d.ts +182 -204
- package/dist/cjs/Canvas/ApexPainter.d.ts.map +1 -1
- package/dist/cjs/Canvas/ApexPainter.js +482 -1286
- package/dist/cjs/Canvas/ApexPainter.js.map +1 -1
- package/dist/cjs/Canvas/extended/CanvasCreator.d.ts +33 -0
- package/dist/cjs/Canvas/extended/CanvasCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/CanvasCreator.js +223 -0
- package/dist/cjs/Canvas/extended/CanvasCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/ChartCreator.d.ts +26 -0
- package/dist/cjs/Canvas/extended/ChartCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/ChartCreator.js +50 -0
- package/dist/cjs/Canvas/extended/ChartCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/GIFCreator.d.ts +43 -0
- package/dist/cjs/Canvas/extended/GIFCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/GIFCreator.js +157 -0
- package/dist/cjs/Canvas/extended/GIFCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/ImageCreator.d.ts +83 -0
- package/dist/cjs/Canvas/extended/ImageCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/ImageCreator.js +479 -0
- package/dist/cjs/Canvas/extended/ImageCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/TextCreator.d.ts +35 -0
- package/dist/cjs/Canvas/extended/TextCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/TextCreator.js +98 -0
- package/dist/cjs/Canvas/extended/TextCreator.js.map +1 -0
- package/dist/cjs/Canvas/extended/VideoCreator.d.ts +370 -0
- package/dist/cjs/Canvas/extended/VideoCreator.d.ts.map +1 -0
- package/dist/cjs/Canvas/extended/VideoCreator.js +478 -0
- package/dist/cjs/Canvas/extended/VideoCreator.js.map +1 -0
- package/dist/cjs/Canvas/utils/Background/bg.d.ts +1 -1
- package/dist/cjs/Canvas/utils/Background/bg.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Background/bg.js +43 -7
- package/dist/cjs/Canvas/utils/Background/bg.js.map +1 -1
- package/dist/cjs/Canvas/utils/Charts/barchart.d.ts +230 -0
- package/dist/cjs/Canvas/utils/Charts/barchart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/barchart.js +1891 -0
- package/dist/cjs/Canvas/utils/Charts/barchart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.d.ts +103 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.js +368 -0
- package/dist/cjs/Canvas/utils/Charts/comparisonchart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.d.ts +178 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.js +1389 -0
- package/dist/cjs/Canvas/utils/Charts/horizontalbarchart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/index.d.ts +45 -0
- package/dist/cjs/Canvas/utils/Charts/index.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/index.js +17 -0
- package/dist/cjs/Canvas/utils/Charts/index.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.d.ts +216 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.js +1761 -0
- package/dist/cjs/Canvas/utils/Charts/linechart.js.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.d.ts +167 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.js +794 -0
- package/dist/cjs/Canvas/utils/Charts/piechart.js.map +1 -0
- package/dist/cjs/Canvas/utils/General/batchOperations.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/General/batchOperations.js +3 -4
- package/dist/cjs/Canvas/utils/General/batchOperations.js.map +1 -1
- package/dist/cjs/Canvas/utils/General/general functions.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/General/general functions.js +62 -33
- package/dist/cjs/Canvas/utils/General/general functions.js.map +1 -1
- package/dist/cjs/Canvas/utils/General/imageStitching.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/General/imageStitching.js +3 -6
- package/dist/cjs/Canvas/utils/General/imageStitching.js.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageMasking.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageMasking.js +5 -12
- package/dist/cjs/Canvas/utils/Image/imageMasking.js.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageProperties.d.ts +4 -4
- package/dist/cjs/Canvas/utils/Image/imageProperties.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Image/imageProperties.js +44 -9
- package/dist/cjs/Canvas/utils/Image/imageProperties.js.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.d.ts +5 -0
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.js +48 -5
- package/dist/cjs/Canvas/utils/Texts/enhancedTextRenderer.js.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/textProperties.d.ts +1 -1
- package/dist/cjs/Canvas/utils/Texts/textProperties.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/Texts/textProperties.js +48 -5
- package/dist/cjs/Canvas/utils/Texts/textProperties.js.map +1 -1
- package/dist/cjs/Canvas/utils/Video/videoHelpers.d.ts +489 -0
- package/dist/cjs/Canvas/utils/Video/videoHelpers.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/Video/videoHelpers.js +1835 -0
- package/dist/cjs/Canvas/utils/Video/videoHelpers.js.map +1 -0
- package/dist/cjs/Canvas/utils/errorUtils.d.ts +15 -0
- package/dist/cjs/Canvas/utils/errorUtils.d.ts.map +1 -0
- package/dist/cjs/Canvas/utils/errorUtils.js +26 -0
- package/dist/cjs/Canvas/utils/errorUtils.js.map +1 -0
- package/dist/cjs/Canvas/utils/types.d.ts +17 -178
- package/dist/cjs/Canvas/utils/types.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/types.js.map +1 -1
- package/dist/cjs/Canvas/utils/utils.d.ts +4 -3
- package/dist/cjs/Canvas/utils/utils.d.ts.map +1 -1
- package/dist/cjs/Canvas/utils/utils.js +40 -6
- package/dist/cjs/Canvas/utils/utils.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +14 -45
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/Canvas/ApexPainter.d.ts +182 -204
- package/dist/esm/Canvas/ApexPainter.d.ts.map +1 -1
- package/dist/esm/Canvas/ApexPainter.js +482 -1286
- package/dist/esm/Canvas/ApexPainter.js.map +1 -1
- package/dist/esm/Canvas/extended/CanvasCreator.d.ts +33 -0
- package/dist/esm/Canvas/extended/CanvasCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/CanvasCreator.js +223 -0
- package/dist/esm/Canvas/extended/CanvasCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/ChartCreator.d.ts +26 -0
- package/dist/esm/Canvas/extended/ChartCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/ChartCreator.js +50 -0
- package/dist/esm/Canvas/extended/ChartCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/GIFCreator.d.ts +43 -0
- package/dist/esm/Canvas/extended/GIFCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/GIFCreator.js +157 -0
- package/dist/esm/Canvas/extended/GIFCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/ImageCreator.d.ts +83 -0
- package/dist/esm/Canvas/extended/ImageCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/ImageCreator.js +479 -0
- package/dist/esm/Canvas/extended/ImageCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/TextCreator.d.ts +35 -0
- package/dist/esm/Canvas/extended/TextCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/TextCreator.js +98 -0
- package/dist/esm/Canvas/extended/TextCreator.js.map +1 -0
- package/dist/esm/Canvas/extended/VideoCreator.d.ts +370 -0
- package/dist/esm/Canvas/extended/VideoCreator.d.ts.map +1 -0
- package/dist/esm/Canvas/extended/VideoCreator.js +478 -0
- package/dist/esm/Canvas/extended/VideoCreator.js.map +1 -0
- package/dist/esm/Canvas/utils/Background/bg.d.ts +1 -1
- package/dist/esm/Canvas/utils/Background/bg.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Background/bg.js +43 -7
- package/dist/esm/Canvas/utils/Background/bg.js.map +1 -1
- package/dist/esm/Canvas/utils/Charts/barchart.d.ts +230 -0
- package/dist/esm/Canvas/utils/Charts/barchart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/barchart.js +1891 -0
- package/dist/esm/Canvas/utils/Charts/barchart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.d.ts +103 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.js +368 -0
- package/dist/esm/Canvas/utils/Charts/comparisonchart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.d.ts +178 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.js +1389 -0
- package/dist/esm/Canvas/utils/Charts/horizontalbarchart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/index.d.ts +45 -0
- package/dist/esm/Canvas/utils/Charts/index.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/index.js +17 -0
- package/dist/esm/Canvas/utils/Charts/index.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/linechart.d.ts +216 -0
- package/dist/esm/Canvas/utils/Charts/linechart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/linechart.js +1761 -0
- package/dist/esm/Canvas/utils/Charts/linechart.js.map +1 -0
- package/dist/esm/Canvas/utils/Charts/piechart.d.ts +167 -0
- package/dist/esm/Canvas/utils/Charts/piechart.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Charts/piechart.js +794 -0
- package/dist/esm/Canvas/utils/Charts/piechart.js.map +1 -0
- package/dist/esm/Canvas/utils/General/batchOperations.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/General/batchOperations.js +3 -4
- package/dist/esm/Canvas/utils/General/batchOperations.js.map +1 -1
- package/dist/esm/Canvas/utils/General/general functions.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/General/general functions.js +62 -33
- package/dist/esm/Canvas/utils/General/general functions.js.map +1 -1
- package/dist/esm/Canvas/utils/General/imageStitching.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/General/imageStitching.js +3 -6
- package/dist/esm/Canvas/utils/General/imageStitching.js.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageMasking.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageMasking.js +5 -12
- package/dist/esm/Canvas/utils/Image/imageMasking.js.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageProperties.d.ts +4 -4
- package/dist/esm/Canvas/utils/Image/imageProperties.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Image/imageProperties.js +44 -9
- package/dist/esm/Canvas/utils/Image/imageProperties.js.map +1 -1
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.d.ts +5 -0
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.js +48 -5
- package/dist/esm/Canvas/utils/Texts/enhancedTextRenderer.js.map +1 -1
- package/dist/esm/Canvas/utils/Texts/textProperties.d.ts +1 -1
- package/dist/esm/Canvas/utils/Texts/textProperties.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/Texts/textProperties.js +48 -5
- package/dist/esm/Canvas/utils/Texts/textProperties.js.map +1 -1
- package/dist/esm/Canvas/utils/Video/videoHelpers.d.ts +489 -0
- package/dist/esm/Canvas/utils/Video/videoHelpers.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/Video/videoHelpers.js +1835 -0
- package/dist/esm/Canvas/utils/Video/videoHelpers.js.map +1 -0
- package/dist/esm/Canvas/utils/errorUtils.d.ts +15 -0
- package/dist/esm/Canvas/utils/errorUtils.d.ts.map +1 -0
- package/dist/esm/Canvas/utils/errorUtils.js +26 -0
- package/dist/esm/Canvas/utils/errorUtils.js.map +1 -0
- package/dist/esm/Canvas/utils/types.d.ts +17 -178
- package/dist/esm/Canvas/utils/types.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/types.js.map +1 -1
- package/dist/esm/Canvas/utils/utils.d.ts +4 -3
- package/dist/esm/Canvas/utils/utils.d.ts.map +1 -1
- package/dist/esm/Canvas/utils/utils.js +40 -6
- package/dist/esm/Canvas/utils/utils.js.map +1 -1
- package/dist/esm/index.d.ts +1 -8
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +14 -45
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/package.json +234 -198
- package/dist/cjs/Canvas/utils/Charts/charts.d.ts +0 -13
- package/dist/cjs/Canvas/utils/Charts/charts.d.ts.map +0 -1
- package/dist/cjs/Canvas/utils/Charts/charts.js +0 -466
- package/dist/cjs/Canvas/utils/Charts/charts.js.map +0 -1
- package/dist/esm/Canvas/utils/Charts/charts.d.ts +0 -13
- package/dist/esm/Canvas/utils/Charts/charts.d.ts.map +0 -1
- package/dist/esm/Canvas/utils/Charts/charts.js +0 -466
- package/dist/esm/Canvas/utils/Charts/charts.js.map +0 -1
- package/lib/Canvas/ApexPainter.ts +0 -5414
- package/lib/Canvas/utils/Background/bg.ts +0 -285
- package/lib/Canvas/utils/Charts/charts.ts +0 -548
- package/lib/Canvas/utils/Custom/advancedLines.ts +0 -387
- package/lib/Canvas/utils/Custom/customLines.ts +0 -206
- package/lib/Canvas/utils/General/batchOperations.ts +0 -103
- package/lib/Canvas/utils/General/conversion.ts +0 -34
- package/lib/Canvas/utils/General/general functions.ts +0 -726
- package/lib/Canvas/utils/General/imageCompression.ts +0 -316
- package/lib/Canvas/utils/General/imageStitching.ts +0 -252
- package/lib/Canvas/utils/Image/imageEffects.ts +0 -175
- package/lib/Canvas/utils/Image/imageFilters.ts +0 -356
- package/lib/Canvas/utils/Image/imageMasking.ts +0 -335
- package/lib/Canvas/utils/Image/imageProperties.ts +0 -587
- package/lib/Canvas/utils/Image/professionalImageFilters.ts +0 -391
- package/lib/Canvas/utils/Image/simpleProfessionalFilters.ts +0 -229
- package/lib/Canvas/utils/Patterns/enhancedPatternRenderer.ts +0 -455
- package/lib/Canvas/utils/Shapes/shapes.ts +0 -528
- package/lib/Canvas/utils/Texts/enhancedTextRenderer.ts +0 -716
- package/lib/Canvas/utils/Texts/textPathRenderer.ts +0 -320
- package/lib/Canvas/utils/Texts/textProperties.ts +0 -231
- package/lib/Canvas/utils/types.ts +0 -983
- package/lib/Canvas/utils/utils.ts +0 -135
- package/lib/index.ts +0 -81
- package/lib/utils.ts +0 -5
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.drawAxes = drawAxes;
|
|
7
|
+
exports.createBarChart = createBarChart;
|
|
8
|
+
const canvas_1 = require("@napi-rs/canvas");
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const imageProperties_1 = require("../Image/imageProperties");
|
|
11
|
+
/**
|
|
12
|
+
* Helper function to render enhanced text with custom fonts, gradients, shadows, strokes
|
|
13
|
+
*/
|
|
14
|
+
async function renderEnhancedText(ctx, text, x, y, style, fontSize, color, textGradient) {
|
|
15
|
+
ctx.save();
|
|
16
|
+
// Preserve text alignment settings
|
|
17
|
+
const savedTextAlign = ctx.textAlign;
|
|
18
|
+
const savedTextBaseline = ctx.textBaseline;
|
|
19
|
+
const effectiveFontSize = fontSize || style?.fontSize || 16;
|
|
20
|
+
const fontFamily = style?.fontFamily || style?.fontName || 'Arial';
|
|
21
|
+
let fontString = '';
|
|
22
|
+
if (style?.bold)
|
|
23
|
+
fontString += 'bold ';
|
|
24
|
+
if (style?.italic)
|
|
25
|
+
fontString += 'italic ';
|
|
26
|
+
fontString += `${effectiveFontSize}px "${fontFamily}"`;
|
|
27
|
+
ctx.font = fontString;
|
|
28
|
+
// Restore text alignment to ensure correct positioning
|
|
29
|
+
ctx.textAlign = savedTextAlign;
|
|
30
|
+
ctx.textBaseline = savedTextBaseline;
|
|
31
|
+
// Register custom font if provided
|
|
32
|
+
if (style?.fontPath && style?.fontName) {
|
|
33
|
+
try {
|
|
34
|
+
const { GlobalFonts } = await import('@napi-rs/canvas');
|
|
35
|
+
const path = await import('path');
|
|
36
|
+
const fullPath = path.join(process.cwd(), style.fontPath);
|
|
37
|
+
GlobalFonts.registerFromPath(fullPath, style.fontName);
|
|
38
|
+
ctx.font = fontString.replace(`"${fontFamily}"`, `"${style.fontName}"`);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.warn(`Failed to register font: ${style.fontPath}`, error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Apply shadow
|
|
45
|
+
if (style?.shadow) {
|
|
46
|
+
ctx.shadowColor = style.shadow.color || 'rgba(0,0,0,0.5)';
|
|
47
|
+
ctx.shadowOffsetX = style.shadow.offsetX || 2;
|
|
48
|
+
ctx.shadowOffsetY = style.shadow.offsetY || 2;
|
|
49
|
+
ctx.shadowBlur = style.shadow.blur || 4;
|
|
50
|
+
if (style.shadow.opacity !== undefined) {
|
|
51
|
+
ctx.globalAlpha = style.shadow.opacity;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Set fill style (gradient or color)
|
|
55
|
+
if (textGradient) {
|
|
56
|
+
const metrics = ctx.measureText(text);
|
|
57
|
+
ctx.fillStyle = (0, imageProperties_1.createGradientFill)(ctx, textGradient, {
|
|
58
|
+
x, y, w: metrics.width, h: effectiveFontSize
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (color) {
|
|
62
|
+
ctx.fillStyle = color;
|
|
63
|
+
}
|
|
64
|
+
// Draw text
|
|
65
|
+
ctx.fillText(text, x, y);
|
|
66
|
+
// Apply stroke
|
|
67
|
+
if (style?.stroke) {
|
|
68
|
+
ctx.strokeStyle = style.stroke.color || '#000000';
|
|
69
|
+
ctx.lineWidth = style.stroke.width || 1;
|
|
70
|
+
if (style.stroke.gradient) {
|
|
71
|
+
const metrics = ctx.measureText(text);
|
|
72
|
+
ctx.strokeStyle = (0, imageProperties_1.createGradientFill)(ctx, style.stroke.gradient, {
|
|
73
|
+
x, y, w: metrics.width, h: effectiveFontSize
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
ctx.strokeText(text, x, y);
|
|
77
|
+
}
|
|
78
|
+
// Reset shadow and alpha
|
|
79
|
+
ctx.shadowColor = 'transparent';
|
|
80
|
+
ctx.shadowOffsetX = 0;
|
|
81
|
+
ctx.shadowOffsetY = 0;
|
|
82
|
+
ctx.shadowBlur = 0;
|
|
83
|
+
ctx.globalAlpha = 1;
|
|
84
|
+
ctx.restore();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Helper function to fill a shape with gradient or color
|
|
88
|
+
*/
|
|
89
|
+
function fillWithGradientOrColor(ctx, gradient, color, defaultColor = '#000000', rect) {
|
|
90
|
+
if (gradient && rect) {
|
|
91
|
+
ctx.fillStyle = (0, imageProperties_1.createGradientFill)(ctx, gradient, rect);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
ctx.fillStyle = color || defaultColor;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Helper function to draw a bar with opacity, shadow, and stroke support
|
|
99
|
+
*/
|
|
100
|
+
function drawBar(ctx, x, y, width, height, color, gradient, opacity, shadow, stroke, globalShadow, globalStroke) {
|
|
101
|
+
ctx.save();
|
|
102
|
+
// Apply opacity
|
|
103
|
+
const effectiveOpacity = opacity !== undefined ? opacity : 1;
|
|
104
|
+
ctx.globalAlpha = effectiveOpacity;
|
|
105
|
+
// Apply shadow (segment/item shadow takes precedence over global)
|
|
106
|
+
const effectiveShadow = shadow || globalShadow;
|
|
107
|
+
if (effectiveShadow) {
|
|
108
|
+
ctx.shadowColor = effectiveShadow.color || 'rgba(0,0,0,0.3)';
|
|
109
|
+
ctx.shadowOffsetX = effectiveShadow.offsetX ?? 2;
|
|
110
|
+
ctx.shadowOffsetY = effectiveShadow.offsetY ?? 2;
|
|
111
|
+
ctx.shadowBlur = effectiveShadow.blur ?? 4;
|
|
112
|
+
}
|
|
113
|
+
// Draw bar fill
|
|
114
|
+
ctx.beginPath();
|
|
115
|
+
ctx.rect(x, y, width, height);
|
|
116
|
+
if (gradient) {
|
|
117
|
+
fillWithGradientOrColor(ctx, gradient, color, color, { x, y, w: width, h: height });
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
ctx.fillStyle = color;
|
|
121
|
+
}
|
|
122
|
+
ctx.fill();
|
|
123
|
+
// Reset shadow before stroke
|
|
124
|
+
if (effectiveShadow) {
|
|
125
|
+
ctx.shadowColor = 'transparent';
|
|
126
|
+
ctx.shadowOffsetX = 0;
|
|
127
|
+
ctx.shadowOffsetY = 0;
|
|
128
|
+
ctx.shadowBlur = 0;
|
|
129
|
+
}
|
|
130
|
+
// Apply stroke (segment/item stroke takes precedence over global)
|
|
131
|
+
const effectiveStroke = stroke || globalStroke;
|
|
132
|
+
if (effectiveStroke && effectiveStroke.width && effectiveStroke.width > 0) {
|
|
133
|
+
ctx.beginPath();
|
|
134
|
+
ctx.rect(x, y, width, height);
|
|
135
|
+
if (effectiveStroke.gradient) {
|
|
136
|
+
ctx.strokeStyle = (0, imageProperties_1.createGradientFill)(ctx, effectiveStroke.gradient, { x, y, w: width, h: height });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
ctx.strokeStyle = effectiveStroke.color || '#000000';
|
|
140
|
+
}
|
|
141
|
+
ctx.lineWidth = effectiveStroke.width;
|
|
142
|
+
ctx.stroke();
|
|
143
|
+
}
|
|
144
|
+
ctx.restore();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Draws an arrow at the end of an axis
|
|
148
|
+
* @param ctx Canvas context
|
|
149
|
+
* @param x X position of arrow tip
|
|
150
|
+
* @param y Y position of arrow tip
|
|
151
|
+
* @param angle Angle in radians (0 = right, PI/2 = down)
|
|
152
|
+
* @param size Size of the arrow
|
|
153
|
+
*/
|
|
154
|
+
function drawArrow(ctx, x, y, angle, size) {
|
|
155
|
+
ctx.save();
|
|
156
|
+
ctx.translate(x, y);
|
|
157
|
+
ctx.rotate(angle);
|
|
158
|
+
ctx.beginPath();
|
|
159
|
+
ctx.moveTo(0, 0);
|
|
160
|
+
ctx.lineTo(-size, -size / 2);
|
|
161
|
+
ctx.lineTo(-size, size / 2);
|
|
162
|
+
ctx.closePath();
|
|
163
|
+
ctx.fill();
|
|
164
|
+
ctx.restore();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Draws Y-axis ticks and labels with custom values support
|
|
168
|
+
*/
|
|
169
|
+
function drawYAxisTicks(ctx, originX, originY, axisEndY, minValue, maxValue, step, tickFontSize, customValues, valueSpacing) {
|
|
170
|
+
ctx.save();
|
|
171
|
+
ctx.fillStyle = '#000000';
|
|
172
|
+
ctx.font = `${tickFontSize}px Arial`;
|
|
173
|
+
ctx.textAlign = 'right';
|
|
174
|
+
ctx.textBaseline = 'middle';
|
|
175
|
+
const chartHeight = originY - axisEndY;
|
|
176
|
+
if (customValues && customValues.length > 0) {
|
|
177
|
+
// Use custom Y-axis values
|
|
178
|
+
const actualMin = Math.min(...customValues);
|
|
179
|
+
const actualMax = Math.max(...customValues);
|
|
180
|
+
const range = actualMax - actualMin;
|
|
181
|
+
// Always position ticks based on their actual values
|
|
182
|
+
// valueSpacing is used only to prevent label overlap (skip labels that are too close)
|
|
183
|
+
let lastLabelY = Infinity;
|
|
184
|
+
const minLabelSpacing = valueSpacing && valueSpacing > 0 ? valueSpacing : 30; // Minimum pixels between labels
|
|
185
|
+
customValues.forEach((value) => {
|
|
186
|
+
const y = originY - ((value - actualMin) / range) * chartHeight;
|
|
187
|
+
// Check if this label would overlap with the previous one
|
|
188
|
+
if (Math.abs(y - lastLabelY) < minLabelSpacing) {
|
|
189
|
+
// Skip this label to prevent overlap, but still draw the tick mark
|
|
190
|
+
ctx.beginPath();
|
|
191
|
+
ctx.moveTo(originX - 5, y);
|
|
192
|
+
ctx.lineTo(originX, y);
|
|
193
|
+
ctx.stroke();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Draw tick mark
|
|
197
|
+
ctx.beginPath();
|
|
198
|
+
ctx.moveTo(originX - 5, y);
|
|
199
|
+
ctx.lineTo(originX, y);
|
|
200
|
+
ctx.stroke();
|
|
201
|
+
// Draw label
|
|
202
|
+
ctx.fillText(value.toFixed(1), originX - 10, y);
|
|
203
|
+
lastLabelY = y; // Update last label position
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
// Use regular step-based ticks
|
|
208
|
+
const range = maxValue - minValue;
|
|
209
|
+
// Always position ticks based on their actual values
|
|
210
|
+
// valueSpacing is used only to prevent label overlap (skip labels that are too close)
|
|
211
|
+
let lastLabelY = Infinity;
|
|
212
|
+
const minLabelSpacing = valueSpacing && valueSpacing > 0 ? valueSpacing : 30; // Minimum pixels between labels
|
|
213
|
+
for (let value = minValue; value <= maxValue; value += step) {
|
|
214
|
+
const y = originY - ((value - minValue) / range) * chartHeight;
|
|
215
|
+
// Check if this label would overlap with the previous one
|
|
216
|
+
if (Math.abs(y - lastLabelY) < minLabelSpacing && value > minValue) {
|
|
217
|
+
// Skip this label to prevent overlap, but still draw the tick mark
|
|
218
|
+
ctx.beginPath();
|
|
219
|
+
ctx.moveTo(originX - 5, y);
|
|
220
|
+
ctx.lineTo(originX, y);
|
|
221
|
+
ctx.stroke();
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Draw tick mark
|
|
225
|
+
ctx.beginPath();
|
|
226
|
+
ctx.moveTo(originX - 5, y);
|
|
227
|
+
ctx.lineTo(originX, y);
|
|
228
|
+
ctx.stroke();
|
|
229
|
+
// Draw label
|
|
230
|
+
const labelText = value.toFixed(1);
|
|
231
|
+
ctx.fillText(labelText, originX - 10, y);
|
|
232
|
+
lastLabelY = y; // Update last label position
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
ctx.restore();
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Draws X-axis ticks and labels with custom values
|
|
239
|
+
*/
|
|
240
|
+
function drawXAxisTicks(ctx, originX, originY, axisEndX, minValue, maxValue, step, tickFontSize, customValues, valueSpacing) {
|
|
241
|
+
ctx.save();
|
|
242
|
+
ctx.fillStyle = '#000000';
|
|
243
|
+
ctx.font = `${tickFontSize}px Arial`;
|
|
244
|
+
ctx.textAlign = 'center';
|
|
245
|
+
ctx.textBaseline = 'top';
|
|
246
|
+
const chartWidth = axisEndX - originX;
|
|
247
|
+
if (customValues && customValues.length > 0) {
|
|
248
|
+
// Use custom X-axis values
|
|
249
|
+
if (valueSpacing && valueSpacing > 0) {
|
|
250
|
+
// Use specified spacing - position ticks with exact pixel spacing
|
|
251
|
+
let currentX = originX;
|
|
252
|
+
customValues.forEach((value, index) => {
|
|
253
|
+
if (index === 0) {
|
|
254
|
+
currentX = originX;
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
currentX += valueSpacing; // Move right by spacing amount
|
|
258
|
+
}
|
|
259
|
+
// Clamp to chart area
|
|
260
|
+
if (currentX >= originX && currentX <= axisEndX) {
|
|
261
|
+
// Draw tick mark
|
|
262
|
+
ctx.beginPath();
|
|
263
|
+
ctx.moveTo(currentX, originY);
|
|
264
|
+
ctx.lineTo(currentX, originY + 5);
|
|
265
|
+
ctx.stroke();
|
|
266
|
+
// Draw label
|
|
267
|
+
ctx.fillText(value.toString(), currentX, originY + 10);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// Use index-based positioning (original behavior)
|
|
273
|
+
// But check for label overlap and skip labels if they're too close
|
|
274
|
+
const totalValues = customValues.length;
|
|
275
|
+
const divisor = totalValues > 1 ? totalValues - 1 : 1;
|
|
276
|
+
let lastLabelX = -Infinity;
|
|
277
|
+
const minLabelSpacing = 40; // Minimum pixels between label centers
|
|
278
|
+
customValues.forEach((value, index) => {
|
|
279
|
+
// Position based on index in the array, not the numeric value
|
|
280
|
+
const x = originX + (index / divisor) * chartWidth;
|
|
281
|
+
const labelText = value.toString();
|
|
282
|
+
const labelWidth = ctx.measureText(labelText).width;
|
|
283
|
+
// Check if this label would overlap with the previous one
|
|
284
|
+
if (x - lastLabelX < minLabelSpacing && index > 0) {
|
|
285
|
+
// Skip this label to prevent overlap
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Draw tick mark
|
|
289
|
+
ctx.beginPath();
|
|
290
|
+
ctx.moveTo(x, originY);
|
|
291
|
+
ctx.lineTo(x, originY + 5);
|
|
292
|
+
ctx.stroke();
|
|
293
|
+
// Draw label
|
|
294
|
+
ctx.fillText(labelText, x, originY + 10);
|
|
295
|
+
lastLabelX = x + labelWidth / 2; // Update last label position
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
// Use regular step-based ticks
|
|
301
|
+
const range = maxValue - minValue;
|
|
302
|
+
if (valueSpacing && valueSpacing > 0) {
|
|
303
|
+
// Use specified spacing - only show ticks that fit with spacing
|
|
304
|
+
let currentX = originX;
|
|
305
|
+
let currentValue = minValue;
|
|
306
|
+
while (currentX <= axisEndX && currentValue <= maxValue) {
|
|
307
|
+
// Draw tick mark
|
|
308
|
+
ctx.beginPath();
|
|
309
|
+
ctx.moveTo(currentX, originY);
|
|
310
|
+
ctx.lineTo(currentX, originY + 5);
|
|
311
|
+
ctx.stroke();
|
|
312
|
+
// Draw label
|
|
313
|
+
ctx.fillText(currentValue.toString(), currentX, originY + 10);
|
|
314
|
+
currentX += valueSpacing;
|
|
315
|
+
currentValue += step;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
// Original behavior - evenly distribute based on value
|
|
320
|
+
// But check for label overlap and skip labels if they're too close
|
|
321
|
+
let lastLabelX = -Infinity;
|
|
322
|
+
const minLabelSpacing = 40; // Minimum pixels between label centers
|
|
323
|
+
for (let value = minValue; value <= maxValue; value += step) {
|
|
324
|
+
const x = originX + ((value - minValue) / range) * chartWidth;
|
|
325
|
+
const labelText = value.toString();
|
|
326
|
+
const labelWidth = ctx.measureText(labelText).width;
|
|
327
|
+
// Check if this label would overlap with the previous one
|
|
328
|
+
if (x - lastLabelX < minLabelSpacing && value > minValue) {
|
|
329
|
+
// Skip this label to prevent overlap
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// Draw tick mark
|
|
333
|
+
ctx.beginPath();
|
|
334
|
+
ctx.moveTo(x, originY);
|
|
335
|
+
ctx.lineTo(x, originY + 5);
|
|
336
|
+
ctx.stroke();
|
|
337
|
+
// Draw label
|
|
338
|
+
ctx.fillText(labelText, x, originY + 10);
|
|
339
|
+
lastLabelX = x + labelWidth / 2; // Update last label position
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
ctx.restore();
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Calculates legend dimensions without needing a canvas context
|
|
347
|
+
*/
|
|
348
|
+
function calculateLegendDimensions(legend, fontSize, maxWidth, wrapTextEnabled = true, paddingBox = 8) {
|
|
349
|
+
if (!legend || legend.length === 0)
|
|
350
|
+
return { width: 0, height: 0 };
|
|
351
|
+
const boxSize = 15;
|
|
352
|
+
const spacing = 10;
|
|
353
|
+
const padding = paddingBox;
|
|
354
|
+
// Create a temporary canvas to measure text
|
|
355
|
+
const tempCanvas = (0, canvas_1.createCanvas)(1, 1);
|
|
356
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
357
|
+
tempCtx.font = `${fontSize}px Arial`;
|
|
358
|
+
const textSpacing = 10;
|
|
359
|
+
const effectiveMaxWidth = maxWidth ? maxWidth - padding * 2 - boxSize - textSpacing : undefined;
|
|
360
|
+
let maxEntryWidth = 0;
|
|
361
|
+
const entryHeights = [];
|
|
362
|
+
legend.forEach(entry => {
|
|
363
|
+
let textWidth;
|
|
364
|
+
let textHeight;
|
|
365
|
+
if (wrapTextEnabled && effectiveMaxWidth) {
|
|
366
|
+
const wrappedLines = wrapText(tempCtx, entry.label, effectiveMaxWidth);
|
|
367
|
+
textWidth = Math.max(...wrappedLines.map(line => tempCtx.measureText(line).width));
|
|
368
|
+
textHeight = wrappedLines.length * fontSize * 1.2;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
textWidth = tempCtx.measureText(entry.label).width;
|
|
372
|
+
textHeight = fontSize;
|
|
373
|
+
}
|
|
374
|
+
const entryWidth = boxSize + textSpacing + textWidth;
|
|
375
|
+
maxEntryWidth = Math.max(maxEntryWidth, entryWidth);
|
|
376
|
+
entryHeights.push(Math.max(boxSize, textHeight));
|
|
377
|
+
});
|
|
378
|
+
const legendWidth = maxWidth ? maxWidth : maxEntryWidth + padding * 2;
|
|
379
|
+
const legendHeight = entryHeights.reduce((sum, h, i) => sum + h + (i < entryHeights.length - 1 ? spacing : 0), 0) + padding * 2;
|
|
380
|
+
return { width: legendWidth, height: legendHeight };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Wraps text to fit within a maximum width
|
|
384
|
+
*/
|
|
385
|
+
function wrapText(ctx, text, maxWidth) {
|
|
386
|
+
const words = text.split(' ');
|
|
387
|
+
const lines = [];
|
|
388
|
+
let currentLine = words[0];
|
|
389
|
+
for (let i = 1; i < words.length; i++) {
|
|
390
|
+
const word = words[i];
|
|
391
|
+
const width = ctx.measureText(currentLine + ' ' + word).width;
|
|
392
|
+
if (width < maxWidth) {
|
|
393
|
+
currentLine += ' ' + word;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
lines.push(currentLine);
|
|
397
|
+
currentLine = word;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
lines.push(currentLine);
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Draws legend/key showing colors and their meanings at a specific position
|
|
405
|
+
*/
|
|
406
|
+
async function drawLegendAtPosition(ctx, legend, legendX, legendY, fontSize, backgroundColor = '#FFFFFF', textColor, borderColor, paddingBox, maxWidth, wrapTextEnabled = true, backgroundGradient, textGradient, textStyle) {
|
|
407
|
+
if (!legend || legend.length === 0)
|
|
408
|
+
return;
|
|
409
|
+
ctx.save();
|
|
410
|
+
const boxSize = 15;
|
|
411
|
+
const spacing = 10;
|
|
412
|
+
const padding = paddingBox ?? 8;
|
|
413
|
+
ctx.font = `${fontSize}px Arial`;
|
|
414
|
+
// Determine colors
|
|
415
|
+
const isDarkBackground = backgroundColor === '#000000' || backgroundColor.toLowerCase() === 'black';
|
|
416
|
+
const effectiveTextColor = textColor ?? (isDarkBackground ? '#FFFFFF' : '#000000');
|
|
417
|
+
const effectiveBgColor = isDarkBackground ? 'rgba(0, 0, 0, 0.8)' : (backgroundColor.startsWith('rgba') || backgroundColor.startsWith('rgb') ? backgroundColor : 'rgba(255, 255, 255, 0.9)');
|
|
418
|
+
const effectiveBorderColor = borderColor ?? (isDarkBackground ? '#FFFFFF' : '#000000');
|
|
419
|
+
// Calculate dimensions with text wrapping support
|
|
420
|
+
const textSpacing = 10;
|
|
421
|
+
const effectiveMaxWidth = maxWidth ? maxWidth - padding * 2 - boxSize - textSpacing : undefined;
|
|
422
|
+
let maxEntryWidth = 0;
|
|
423
|
+
const entryHeights = [];
|
|
424
|
+
legend.forEach(entry => {
|
|
425
|
+
let textWidth;
|
|
426
|
+
let textHeight;
|
|
427
|
+
if (wrapTextEnabled && effectiveMaxWidth) {
|
|
428
|
+
const wrappedLines = wrapText(ctx, entry.label, effectiveMaxWidth);
|
|
429
|
+
textWidth = Math.max(...wrappedLines.map(line => ctx.measureText(line).width));
|
|
430
|
+
textHeight = wrappedLines.length * fontSize * 1.2; // Line height multiplier
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
textWidth = ctx.measureText(entry.label).width;
|
|
434
|
+
textHeight = fontSize;
|
|
435
|
+
}
|
|
436
|
+
const entryWidth = boxSize + textSpacing + textWidth;
|
|
437
|
+
maxEntryWidth = Math.max(maxEntryWidth, entryWidth);
|
|
438
|
+
entryHeights.push(Math.max(boxSize, textHeight));
|
|
439
|
+
});
|
|
440
|
+
const legendWidth = maxWidth ? maxWidth : maxEntryWidth + padding * 2;
|
|
441
|
+
const legendHeight = entryHeights.reduce((sum, h, i) => sum + h + (i < entryHeights.length - 1 ? spacing : 0), 0) + padding * 2;
|
|
442
|
+
// Draw legend background (gradient or color)
|
|
443
|
+
ctx.beginPath();
|
|
444
|
+
ctx.rect(legendX, legendY, legendWidth, legendHeight);
|
|
445
|
+
fillWithGradientOrColor(ctx, backgroundGradient, effectiveBgColor, effectiveBgColor, { x: legendX, y: legendY, w: legendWidth, h: legendHeight });
|
|
446
|
+
ctx.fill();
|
|
447
|
+
// Draw legend border
|
|
448
|
+
ctx.strokeStyle = effectiveBorderColor;
|
|
449
|
+
ctx.lineWidth = 1;
|
|
450
|
+
ctx.strokeRect(legendX, legendY, legendWidth, legendHeight);
|
|
451
|
+
// Draw legend entries
|
|
452
|
+
ctx.textAlign = 'left';
|
|
453
|
+
ctx.textBaseline = 'middle';
|
|
454
|
+
ctx.fillStyle = effectiveTextColor;
|
|
455
|
+
let currentY = legendY + padding;
|
|
456
|
+
for (let index = 0; index < legend.length; index++) {
|
|
457
|
+
const entry = legend[index];
|
|
458
|
+
const entryHeight = entryHeights[index];
|
|
459
|
+
const centerY = currentY + entryHeight / 2;
|
|
460
|
+
// Draw color box (gradient or color)
|
|
461
|
+
ctx.beginPath();
|
|
462
|
+
ctx.rect(legendX + padding, centerY - boxSize / 2, boxSize, boxSize);
|
|
463
|
+
fillWithGradientOrColor(ctx, entry.gradient, entry.color || '#4A90E2', '#4A90E2', { x: legendX + padding, y: centerY - boxSize / 2, w: boxSize, h: boxSize });
|
|
464
|
+
ctx.fill();
|
|
465
|
+
// Draw box border
|
|
466
|
+
ctx.strokeStyle = effectiveBorderColor;
|
|
467
|
+
ctx.lineWidth = 1;
|
|
468
|
+
ctx.strokeRect(legendX + padding, centerY - boxSize / 2, boxSize, boxSize);
|
|
469
|
+
// Draw label (with wrapping if enabled) using enhanced text
|
|
470
|
+
const textX = legendX + padding + boxSize + textSpacing;
|
|
471
|
+
if (wrapTextEnabled && effectiveMaxWidth) {
|
|
472
|
+
const wrappedLines = wrapText(ctx, entry.label, effectiveMaxWidth);
|
|
473
|
+
const lineHeight = fontSize * 1.2;
|
|
474
|
+
const startY = centerY - (wrappedLines.length - 1) * lineHeight / 2;
|
|
475
|
+
for (let lineIndex = 0; lineIndex < wrappedLines.length; lineIndex++) {
|
|
476
|
+
await renderEnhancedText(ctx, wrappedLines[lineIndex], textX, startY + lineIndex * lineHeight, textStyle, fontSize, effectiveTextColor, textGradient);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
await renderEnhancedText(ctx, entry.label, textX, centerY, textStyle, fontSize, effectiveTextColor, textGradient);
|
|
481
|
+
}
|
|
482
|
+
currentY += entryHeight + spacing;
|
|
483
|
+
}
|
|
484
|
+
ctx.restore();
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Draws legend/key showing colors and their meanings (legacy function for compatibility)
|
|
488
|
+
*/
|
|
489
|
+
async function drawLegend(ctx, legend, position, width, height, padding, fontSize, backgroundColor = '#FFFFFF', legendSpacing = 20) {
|
|
490
|
+
if (!legend || legend.length === 0)
|
|
491
|
+
return;
|
|
492
|
+
ctx.save();
|
|
493
|
+
const boxSize = 15;
|
|
494
|
+
const spacing = 10;
|
|
495
|
+
const paddingBox = 8;
|
|
496
|
+
// Calculate legend dimensions
|
|
497
|
+
ctx.font = `${fontSize}px Arial`;
|
|
498
|
+
const maxLabelWidth = Math.max(...legend.map(e => ctx.measureText(e.label).width));
|
|
499
|
+
const legendWidth = boxSize + spacing + maxLabelWidth + paddingBox * 2;
|
|
500
|
+
const legendHeight = legend.length * (boxSize + spacing) + paddingBox * 2;
|
|
501
|
+
let legendX, legendY;
|
|
502
|
+
switch (position) {
|
|
503
|
+
case 'top':
|
|
504
|
+
legendX = width - padding.right - legendWidth - legendSpacing;
|
|
505
|
+
legendY = padding.top + legendSpacing;
|
|
506
|
+
break;
|
|
507
|
+
case 'bottom':
|
|
508
|
+
legendX = width - padding.right - legendWidth - legendSpacing;
|
|
509
|
+
legendY = height - padding.bottom - legendHeight - legendSpacing;
|
|
510
|
+
break;
|
|
511
|
+
case 'right':
|
|
512
|
+
legendX = width - padding.right - legendWidth - legendSpacing;
|
|
513
|
+
legendY = padding.top + legendSpacing;
|
|
514
|
+
break;
|
|
515
|
+
case 'left':
|
|
516
|
+
legendX = padding.left + legendSpacing;
|
|
517
|
+
legendY = padding.top + legendSpacing;
|
|
518
|
+
break;
|
|
519
|
+
default:
|
|
520
|
+
legendX = width - padding.right - legendWidth - legendSpacing;
|
|
521
|
+
legendY = padding.top + legendSpacing;
|
|
522
|
+
}
|
|
523
|
+
// Determine text color based on background
|
|
524
|
+
const isDarkBackground = backgroundColor === '#000000' || backgroundColor.toLowerCase() === 'black';
|
|
525
|
+
const textColor = isDarkBackground ? '#FFFFFF' : '#000000';
|
|
526
|
+
const bgColor = isDarkBackground ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.9)';
|
|
527
|
+
const borderColor = isDarkBackground ? '#FFFFFF' : '#000000';
|
|
528
|
+
// Draw legend background
|
|
529
|
+
ctx.fillStyle = bgColor;
|
|
530
|
+
ctx.fillRect(legendX, legendY, legendWidth, legendHeight);
|
|
531
|
+
// Draw legend border
|
|
532
|
+
ctx.strokeStyle = borderColor;
|
|
533
|
+
ctx.lineWidth = 1;
|
|
534
|
+
ctx.strokeRect(legendX, legendY, legendWidth, legendHeight);
|
|
535
|
+
// Draw legend entries
|
|
536
|
+
ctx.font = `${fontSize}px Arial`;
|
|
537
|
+
ctx.textAlign = 'left';
|
|
538
|
+
ctx.textBaseline = 'middle';
|
|
539
|
+
for (let index = 0; index < legend.length; index++) {
|
|
540
|
+
const entry = legend[index];
|
|
541
|
+
const y = legendY + paddingBox + index * (boxSize + spacing) + boxSize / 2;
|
|
542
|
+
const x = legendX + paddingBox;
|
|
543
|
+
// Draw color box (gradient or color)
|
|
544
|
+
ctx.beginPath();
|
|
545
|
+
ctx.rect(x, y - boxSize / 2, boxSize, boxSize);
|
|
546
|
+
fillWithGradientOrColor(ctx, entry.gradient, entry.color || '#4A90E2', '#4A90E2', { x, y: y - boxSize / 2, w: boxSize, h: boxSize });
|
|
547
|
+
ctx.fill();
|
|
548
|
+
// Draw box border
|
|
549
|
+
ctx.strokeStyle = borderColor;
|
|
550
|
+
ctx.lineWidth = 1;
|
|
551
|
+
ctx.strokeRect(x, y - boxSize / 2, boxSize, boxSize);
|
|
552
|
+
// Draw label
|
|
553
|
+
ctx.fillStyle = textColor;
|
|
554
|
+
ctx.fillText(entry.label, x + boxSize + spacing, y);
|
|
555
|
+
}
|
|
556
|
+
ctx.restore();
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Draws grid lines on the chart
|
|
560
|
+
*/
|
|
561
|
+
function drawGrid(ctx, originX, originY, axisEndX, axisEndY, xMin, xMax, xStep, yMin, yMax, yStep, xAxisCustomValues, yAxisCustomValues, gridColor = '#E0E0E0', gridWidth = 1) {
|
|
562
|
+
ctx.save();
|
|
563
|
+
ctx.strokeStyle = gridColor;
|
|
564
|
+
ctx.lineWidth = gridWidth;
|
|
565
|
+
ctx.setLineDash([2, 2]); // Dashed lines for grid
|
|
566
|
+
const chartWidth = axisEndX - originX;
|
|
567
|
+
const chartHeight = originY - axisEndY;
|
|
568
|
+
// Draw vertical grid lines (based on X-axis)
|
|
569
|
+
if (xAxisCustomValues && xAxisCustomValues.length > 0) {
|
|
570
|
+
const totalValues = xAxisCustomValues.length;
|
|
571
|
+
const divisor = totalValues > 1 ? totalValues - 1 : 1;
|
|
572
|
+
xAxisCustomValues.forEach((_, index) => {
|
|
573
|
+
const x = originX + (index / divisor) * chartWidth;
|
|
574
|
+
ctx.beginPath();
|
|
575
|
+
ctx.moveTo(x, axisEndY);
|
|
576
|
+
ctx.lineTo(x, originY);
|
|
577
|
+
ctx.stroke();
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
const xRange = xMax - xMin;
|
|
582
|
+
for (let value = xMin; value <= xMax; value += xStep) {
|
|
583
|
+
const x = originX + ((value - xMin) / xRange) * chartWidth;
|
|
584
|
+
ctx.beginPath();
|
|
585
|
+
ctx.moveTo(x, axisEndY);
|
|
586
|
+
ctx.lineTo(x, originY);
|
|
587
|
+
ctx.stroke();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Draw horizontal grid lines (based on Y-axis)
|
|
591
|
+
if (yAxisCustomValues && yAxisCustomValues.length > 0) {
|
|
592
|
+
const actualMin = Math.min(...yAxisCustomValues);
|
|
593
|
+
const actualMax = Math.max(...yAxisCustomValues);
|
|
594
|
+
const yRange = actualMax - actualMin;
|
|
595
|
+
yAxisCustomValues.forEach((value) => {
|
|
596
|
+
const y = originY - ((value - actualMin) / yRange) * chartHeight;
|
|
597
|
+
ctx.beginPath();
|
|
598
|
+
ctx.moveTo(originX, y);
|
|
599
|
+
ctx.lineTo(axisEndX, y);
|
|
600
|
+
ctx.stroke();
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const yRange = yMax - yMin;
|
|
605
|
+
for (let value = yMin; value <= yMax; value += yStep) {
|
|
606
|
+
const y = originY - ((value - yMin) / yRange) * chartHeight;
|
|
607
|
+
ctx.beginPath();
|
|
608
|
+
ctx.moveTo(originX, y);
|
|
609
|
+
ctx.lineTo(axisEndX, y);
|
|
610
|
+
ctx.stroke();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
ctx.restore();
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Calculates responsive canvas width based on X-axis range or custom values
|
|
617
|
+
*/
|
|
618
|
+
function calculateResponsiveWidth(xAxisRange, options = {}, customValues) {
|
|
619
|
+
const padding = options.dimensions?.padding || {};
|
|
620
|
+
const paddingLeft = padding.left ?? 100;
|
|
621
|
+
const paddingRight = padding.right ?? 80;
|
|
622
|
+
if (customValues && customValues.length > 0) {
|
|
623
|
+
// Calculate width based on number of custom values
|
|
624
|
+
// Use about 20-25 pixels per tick mark
|
|
625
|
+
const minChartAreaWidth = Math.max(400, customValues.length * 20);
|
|
626
|
+
return paddingLeft + minChartAreaWidth + paddingRight;
|
|
627
|
+
}
|
|
628
|
+
// Calculate width based on X-axis range
|
|
629
|
+
// Use a reasonable scale: about 10-15 pixels per unit on X-axis
|
|
630
|
+
const xRange = xAxisRange.max - xAxisRange.min;
|
|
631
|
+
const minChartAreaWidth = Math.max(400, xRange * 10); // At least 400px, or 10px per unit
|
|
632
|
+
return paddingLeft + minChartAreaWidth + paddingRight;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Draws x and y axes with arrows on a white background
|
|
636
|
+
* @param width Canvas width
|
|
637
|
+
* @param height Canvas height
|
|
638
|
+
* @param options Chart options
|
|
639
|
+
* @returns Canvas buffer and context
|
|
640
|
+
*/
|
|
641
|
+
function drawAxes(width = 800, height = 600, options = {}) {
|
|
642
|
+
const padding = options.dimensions?.padding || {};
|
|
643
|
+
const axisColor = options.appearance?.axisColor ?? options.axes?.x?.color ?? options.axes?.y?.color ?? '#000000';
|
|
644
|
+
const axisWidth = options.appearance?.axisWidth ?? options.axes?.x?.width ?? options.axes?.y?.width ?? 2;
|
|
645
|
+
const arrowSize = options.appearance?.arrowSize ?? 10;
|
|
646
|
+
const backgroundColor = options.appearance?.backgroundColor ?? '#FFFFFF';
|
|
647
|
+
const paddingTop = padding.top ?? 60;
|
|
648
|
+
const paddingRight = padding.right ?? 80;
|
|
649
|
+
const paddingBottom = padding.bottom ?? 80;
|
|
650
|
+
const paddingLeft = padding.left ?? 100;
|
|
651
|
+
// Create canvas
|
|
652
|
+
const canvas = (0, canvas_1.createCanvas)(width, height);
|
|
653
|
+
const ctx = canvas.getContext('2d');
|
|
654
|
+
// Fill white background
|
|
655
|
+
ctx.fillStyle = backgroundColor;
|
|
656
|
+
ctx.fillRect(0, 0, width, height);
|
|
657
|
+
// Calculate axis positions
|
|
658
|
+
const originX = paddingLeft;
|
|
659
|
+
const originY = height - paddingBottom;
|
|
660
|
+
const axisEndX = width - paddingRight;
|
|
661
|
+
const axisEndY = paddingTop;
|
|
662
|
+
// Set axis style
|
|
663
|
+
ctx.strokeStyle = axisColor;
|
|
664
|
+
ctx.fillStyle = axisColor;
|
|
665
|
+
ctx.lineWidth = axisWidth;
|
|
666
|
+
ctx.lineCap = 'round';
|
|
667
|
+
// Draw Y-axis (vertical line from origin to top)
|
|
668
|
+
ctx.beginPath();
|
|
669
|
+
ctx.moveTo(originX, originY);
|
|
670
|
+
ctx.lineTo(originX, axisEndY);
|
|
671
|
+
ctx.stroke();
|
|
672
|
+
// Draw X-axis (horizontal line from origin to right)
|
|
673
|
+
ctx.beginPath();
|
|
674
|
+
ctx.moveTo(originX, originY);
|
|
675
|
+
ctx.lineTo(axisEndX, originY);
|
|
676
|
+
ctx.stroke();
|
|
677
|
+
// Draw arrow on Y-axis (pointing up)
|
|
678
|
+
drawArrow(ctx, originX, axisEndY, -Math.PI / 2, arrowSize);
|
|
679
|
+
// Draw arrow on X-axis (pointing right)
|
|
680
|
+
drawArrow(ctx, axisEndX, originY, 0, arrowSize);
|
|
681
|
+
return { buffer: canvas.toBuffer('image/png'), ctx, canvas };
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Creates a single bar chart with X-axis range support
|
|
685
|
+
* @param data Array of bar chart data with X-axis ranges
|
|
686
|
+
* @param options Chart options
|
|
687
|
+
* @returns Canvas buffer
|
|
688
|
+
*/
|
|
689
|
+
async function createBarChart(data, options = {}) {
|
|
690
|
+
// Extract and map organized config to internal variables
|
|
691
|
+
// Dimensions
|
|
692
|
+
const height = options.dimensions?.height ?? 600;
|
|
693
|
+
const padding = options.dimensions?.padding || {};
|
|
694
|
+
// Appearance
|
|
695
|
+
const backgroundColor = options.appearance?.backgroundColor ?? '#FFFFFF';
|
|
696
|
+
const backgroundGradient = options.appearance?.backgroundGradient;
|
|
697
|
+
const backgroundImage = options.appearance?.backgroundImage;
|
|
698
|
+
const axisColor = options.appearance?.axisColor ?? options.axes?.x?.color ?? options.axes?.y?.color ?? '#000000';
|
|
699
|
+
const axisWidth = options.appearance?.axisWidth ?? options.axes?.x?.width ?? options.axes?.y?.width ?? 2;
|
|
700
|
+
const arrowSize = options.appearance?.arrowSize ?? 10;
|
|
701
|
+
// Labels
|
|
702
|
+
const chartTitle = options.labels?.title?.text;
|
|
703
|
+
const chartTitleFontSize = options.labels?.title?.fontSize ?? 24;
|
|
704
|
+
const showBarLabels = options.labels?.barLabelDefaults?.show ?? true;
|
|
705
|
+
const barLabelPosition = options.labels?.barLabelDefaults?.defaultPosition ?? 'bottom';
|
|
706
|
+
const axisLabelFontSize = options.labels?.barLabelDefaults?.fontSize ?? 14;
|
|
707
|
+
const showValues = options.labels?.valueLabelDefaults?.show ?? true;
|
|
708
|
+
const valueFontSize = options.labels?.valueLabelDefaults?.fontSize ?? 12;
|
|
709
|
+
const valueColor = options.labels?.valueLabelDefaults?.defaultColor ?? '#000000';
|
|
710
|
+
// Axes
|
|
711
|
+
const xAxisLabel = options.axes?.x?.label;
|
|
712
|
+
const yAxisLabel = options.axes?.y?.label;
|
|
713
|
+
const axisLabelColor = options.axes?.x?.labelColor ?? options.axes?.y?.labelColor ?? '#000000';
|
|
714
|
+
const xAxisRange = options.axes?.x?.range;
|
|
715
|
+
const xAxisValues = options.axes?.x?.values;
|
|
716
|
+
const yAxisRange = options.axes?.y?.range;
|
|
717
|
+
const yAxisValues = options.axes?.y?.values;
|
|
718
|
+
const baseline = options.axes?.y?.baseline ?? 0; // Custom baseline value (default: 0)
|
|
719
|
+
const tickFontSize = options.axes?.x?.tickFontSize ?? options.axes?.y?.tickFontSize ?? 12;
|
|
720
|
+
const xAxisValueSpacing = options.axes?.x?.valueSpacing;
|
|
721
|
+
const yAxisValueSpacing = options.axes?.y?.valueSpacing;
|
|
722
|
+
// Chart type
|
|
723
|
+
const chartType = options.type ?? 'standard';
|
|
724
|
+
// Waterfall chart options
|
|
725
|
+
const initialValue = options.waterfall?.initialValue ?? 0;
|
|
726
|
+
// Legend
|
|
727
|
+
const showLegend = options.legend?.show ?? false;
|
|
728
|
+
const legend = options.legend?.entries;
|
|
729
|
+
const legendPosition = options.legend?.position ?? 'right'; // Default: right
|
|
730
|
+
// Grid
|
|
731
|
+
const showGrid = options.grid?.show ?? false;
|
|
732
|
+
const gridColor = options.grid?.color ?? '#E0E0E0';
|
|
733
|
+
const gridWidth = options.grid?.width ?? 1;
|
|
734
|
+
// Bars
|
|
735
|
+
const minBarWidth = options.bars?.minWidth ?? 20;
|
|
736
|
+
const barSpacing = options.bars?.spacing;
|
|
737
|
+
const groupSpacing = options.bars?.groupSpacing ?? 10;
|
|
738
|
+
const segmentSpacing = options.bars?.segmentSpacing ?? 2;
|
|
739
|
+
const lollipopLineWidth = options.bars?.lineWidth ?? 2;
|
|
740
|
+
const lollipopDotSize = options.bars?.dotSize ?? 8;
|
|
741
|
+
const globalBarOpacity = options.bars?.opacity;
|
|
742
|
+
const globalBarShadow = options.bars?.shadow;
|
|
743
|
+
const globalBarStroke = options.bars?.stroke;
|
|
744
|
+
const paddingTop = padding.top ?? 60;
|
|
745
|
+
const paddingRight = padding.right ?? 80;
|
|
746
|
+
const paddingBottom = padding.bottom ?? 80;
|
|
747
|
+
const paddingLeft = padding.left ?? 100;
|
|
748
|
+
// Determine X-axis range from custom values, options, or data
|
|
749
|
+
let xMin, xMax;
|
|
750
|
+
let xAxisCustomValues = xAxisValues;
|
|
751
|
+
if (xAxisCustomValues && xAxisCustomValues.length > 0) {
|
|
752
|
+
// Use custom X-axis values
|
|
753
|
+
xMin = Math.min(...xAxisCustomValues);
|
|
754
|
+
xMax = Math.max(...xAxisCustomValues);
|
|
755
|
+
}
|
|
756
|
+
else if (xAxisRange && xAxisRange.min !== undefined && xAxisRange.max !== undefined) {
|
|
757
|
+
xMin = xAxisRange.min;
|
|
758
|
+
xMax = xAxisRange.max;
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
// Auto-calculate from data
|
|
762
|
+
if (data.length === 0) {
|
|
763
|
+
xMin = 0;
|
|
764
|
+
xMax = 100;
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
const allXStarts = data.map(d => d.xStart);
|
|
768
|
+
const allXEnds = data.map(d => d.xEnd);
|
|
769
|
+
xMin = Math.min(...allXStarts, ...allXEnds);
|
|
770
|
+
xMax = Math.max(...allXStarts, ...allXEnds);
|
|
771
|
+
// Add some padding
|
|
772
|
+
const xPadding = (xMax - xMin) * 0.1;
|
|
773
|
+
xMin = Math.max(0, xMin - xPadding);
|
|
774
|
+
xMax = xMax + xPadding;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Calculate responsive width based on X-axis range or custom values
|
|
778
|
+
let baseWidth = calculateResponsiveWidth({ min: xMin, max: xMax }, options, xAxisCustomValues);
|
|
779
|
+
// Calculate legend dimensions and adjust canvas size based on legend position
|
|
780
|
+
let legendWidth = 0;
|
|
781
|
+
let legendHeight = 0;
|
|
782
|
+
let extraWidth = 0;
|
|
783
|
+
let extraHeight = 0;
|
|
784
|
+
const minLegendSpacing = 10; // Minimum spacing from chart area
|
|
785
|
+
if (showLegend && legend && legend.length > 0) {
|
|
786
|
+
const legendMaxWidth = options.legend?.maxWidth;
|
|
787
|
+
const legendWrapText = options.legend?.wrapText !== false;
|
|
788
|
+
const legendPadding = options.legend?.padding;
|
|
789
|
+
const legendDims = calculateLegendDimensions(legend, axisLabelFontSize, legendMaxWidth, legendWrapText, legendPadding);
|
|
790
|
+
legendWidth = legendDims.width;
|
|
791
|
+
legendHeight = legendDims.height;
|
|
792
|
+
const legendSpacing = options.legend?.spacing ?? 20;
|
|
793
|
+
// Adjust canvas dimensions based on legend position
|
|
794
|
+
// For left position, add extra space for Y-axis labels
|
|
795
|
+
if (legendPosition === 'left') {
|
|
796
|
+
// Estimate Y-axis label width: measure potential large values
|
|
797
|
+
const tempCanvas = (0, canvas_1.createCanvas)(1, 1);
|
|
798
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
799
|
+
let estimatedYAxisLabelWidth = 60; // Default estimate
|
|
800
|
+
if (tempCtx) {
|
|
801
|
+
tempCtx.font = `${tickFontSize}px Arial`;
|
|
802
|
+
// Get max value from data to estimate label width
|
|
803
|
+
const allValues = [];
|
|
804
|
+
data.forEach(d => {
|
|
805
|
+
if (d.values && d.values.length > 0) {
|
|
806
|
+
if (chartType === 'stacked') {
|
|
807
|
+
allValues.push(d.values.reduce((sum, seg) => sum + seg.value, 0));
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
d.values.forEach(seg => allValues.push(seg.value));
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
else if (d.value !== undefined) {
|
|
814
|
+
allValues.push(d.value);
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
if (allValues.length > 0) {
|
|
818
|
+
const maxValue = Math.max(...allValues);
|
|
819
|
+
const minValue = Math.min(...allValues);
|
|
820
|
+
// Measure potential labels
|
|
821
|
+
const testLabels = [
|
|
822
|
+
maxValue.toFixed(1),
|
|
823
|
+
minValue.toFixed(1),
|
|
824
|
+
Math.abs(maxValue).toFixed(1),
|
|
825
|
+
Math.abs(minValue).toFixed(1)
|
|
826
|
+
];
|
|
827
|
+
testLabels.forEach(label => {
|
|
828
|
+
const width = tempCtx.measureText(label).width;
|
|
829
|
+
estimatedYAxisLabelWidth = Math.max(estimatedYAxisLabelWidth, width);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
// Add padding: 10px (label offset) + 5px (tick) + 15px (spacing) = 30px total
|
|
833
|
+
estimatedYAxisLabelWidth += 30;
|
|
834
|
+
}
|
|
835
|
+
extraWidth = legendWidth + legendSpacing + estimatedYAxisLabelWidth + minLegendSpacing;
|
|
836
|
+
}
|
|
837
|
+
else if (legendPosition === 'right') {
|
|
838
|
+
extraWidth = legendWidth + legendSpacing + minLegendSpacing;
|
|
839
|
+
}
|
|
840
|
+
else if (legendPosition === 'top' || legendPosition === 'bottom') {
|
|
841
|
+
extraHeight = legendHeight + legendSpacing + minLegendSpacing;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const width = baseWidth + extraWidth;
|
|
845
|
+
const adjustedHeight = height + extraHeight;
|
|
846
|
+
// Create canvas
|
|
847
|
+
const canvas = (0, canvas_1.createCanvas)(width, adjustedHeight);
|
|
848
|
+
const ctx = canvas.getContext('2d');
|
|
849
|
+
// Fill background (gradient, image, or color)
|
|
850
|
+
if (backgroundImage) {
|
|
851
|
+
try {
|
|
852
|
+
const bgImage = await (0, canvas_1.loadImage)(backgroundImage);
|
|
853
|
+
// Draw image to fill entire canvas
|
|
854
|
+
ctx.drawImage(bgImage, 0, 0, width, adjustedHeight);
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
console.warn(`Failed to load background image: ${backgroundImage}`, error);
|
|
858
|
+
// Fallback to gradient or color if image fails to load
|
|
859
|
+
fillWithGradientOrColor(ctx, backgroundGradient, backgroundColor, backgroundColor, {
|
|
860
|
+
x: 0, y: 0, w: width, h: adjustedHeight
|
|
861
|
+
});
|
|
862
|
+
ctx.fillRect(0, 0, width, adjustedHeight);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
fillWithGradientOrColor(ctx, backgroundGradient, backgroundColor, backgroundColor, {
|
|
867
|
+
x: 0, y: 0, w: width, h: adjustedHeight
|
|
868
|
+
});
|
|
869
|
+
ctx.fillRect(0, 0, width, adjustedHeight);
|
|
870
|
+
}
|
|
871
|
+
// Calculate axis positions
|
|
872
|
+
const titleHeight = chartTitle ? chartTitleFontSize + 30 : 0;
|
|
873
|
+
const axisLabelHeight = (xAxisLabel || yAxisLabel) ? axisLabelFontSize + 20 : 0;
|
|
874
|
+
// Adjust chart area based on legend position
|
|
875
|
+
let chartAreaLeft = paddingLeft;
|
|
876
|
+
let chartAreaRight = baseWidth - paddingRight;
|
|
877
|
+
let chartAreaTop = paddingTop + titleHeight;
|
|
878
|
+
let chartAreaBottom = height - paddingBottom;
|
|
879
|
+
if (showLegend && legend && legend.length > 0) {
|
|
880
|
+
const legendSpacing = options.legend?.spacing ?? 20;
|
|
881
|
+
if (legendPosition === 'left') {
|
|
882
|
+
// Calculate actual Y-axis label width after we have value ranges
|
|
883
|
+
let actualYAxisLabelWidth = 60; // Default estimate
|
|
884
|
+
const tempCanvas = (0, canvas_1.createCanvas)(1, 1);
|
|
885
|
+
const tempCtx = tempCanvas.getContext('2d');
|
|
886
|
+
if (tempCtx) {
|
|
887
|
+
tempCtx.font = `${tickFontSize}px Arial`;
|
|
888
|
+
// Use the calculated min/max values if available, otherwise estimate
|
|
889
|
+
const allValues = [];
|
|
890
|
+
data.forEach(d => {
|
|
891
|
+
if (d.values && d.values.length > 0) {
|
|
892
|
+
if (chartType === 'stacked') {
|
|
893
|
+
allValues.push(d.values.reduce((sum, seg) => sum + seg.value, 0));
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
d.values.forEach(seg => allValues.push(seg.value));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
else if (d.value !== undefined) {
|
|
900
|
+
allValues.push(d.value);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
if (allValues.length > 0) {
|
|
904
|
+
const maxVal = Math.max(...allValues);
|
|
905
|
+
const minVal = Math.min(...allValues);
|
|
906
|
+
const testLabels = [
|
|
907
|
+
maxVal.toFixed(1),
|
|
908
|
+
minVal.toFixed(1),
|
|
909
|
+
Math.abs(maxVal).toFixed(1),
|
|
910
|
+
Math.abs(minVal).toFixed(1)
|
|
911
|
+
];
|
|
912
|
+
testLabels.forEach(label => {
|
|
913
|
+
const width = tempCtx.measureText(label).width;
|
|
914
|
+
actualYAxisLabelWidth = Math.max(actualYAxisLabelWidth, width);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
// Add padding: 10px (label offset) + 5px (tick) + 15px (spacing)
|
|
918
|
+
actualYAxisLabelWidth += 30;
|
|
919
|
+
}
|
|
920
|
+
// Position chart area to leave room for legend + Y-axis labels
|
|
921
|
+
chartAreaLeft = paddingLeft + legendWidth + legendSpacing + actualYAxisLabelWidth;
|
|
922
|
+
chartAreaRight = baseWidth - paddingRight;
|
|
923
|
+
}
|
|
924
|
+
else if (legendPosition === 'right') {
|
|
925
|
+
chartAreaLeft = paddingLeft;
|
|
926
|
+
chartAreaRight = baseWidth - paddingRight;
|
|
927
|
+
}
|
|
928
|
+
else if (legendPosition === 'top') {
|
|
929
|
+
chartAreaTop = paddingTop + titleHeight + legendHeight + legendSpacing + minLegendSpacing;
|
|
930
|
+
chartAreaBottom = height - paddingBottom;
|
|
931
|
+
}
|
|
932
|
+
else if (legendPosition === 'bottom') {
|
|
933
|
+
chartAreaTop = paddingTop + titleHeight;
|
|
934
|
+
chartAreaBottom = height - paddingBottom;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const originX = chartAreaLeft;
|
|
938
|
+
const originY = chartAreaBottom - axisLabelHeight;
|
|
939
|
+
const axisEndX = chartAreaRight;
|
|
940
|
+
const axisEndY = chartAreaTop;
|
|
941
|
+
// Draw chart title if provided
|
|
942
|
+
if (chartTitle) {
|
|
943
|
+
ctx.save();
|
|
944
|
+
ctx.textAlign = 'center';
|
|
945
|
+
ctx.textBaseline = 'top';
|
|
946
|
+
// Title positioned with proper spacing from top
|
|
947
|
+
const titleY = paddingTop + 10;
|
|
948
|
+
const titleX = width / 2;
|
|
949
|
+
await renderEnhancedText(ctx, chartTitle, titleX, titleY, options.labels?.title?.textStyle, chartTitleFontSize, options.labels?.title?.color, options.labels?.title?.gradient);
|
|
950
|
+
ctx.restore();
|
|
951
|
+
}
|
|
952
|
+
// Set axis style
|
|
953
|
+
ctx.strokeStyle = axisColor;
|
|
954
|
+
ctx.fillStyle = axisColor;
|
|
955
|
+
ctx.lineWidth = axisWidth;
|
|
956
|
+
ctx.lineCap = 'round';
|
|
957
|
+
// Draw Y-axis
|
|
958
|
+
ctx.beginPath();
|
|
959
|
+
ctx.moveTo(originX, originY);
|
|
960
|
+
ctx.lineTo(originX, axisEndY);
|
|
961
|
+
ctx.stroke();
|
|
962
|
+
// Draw arrows (X-axis will be drawn after calculating zero line)
|
|
963
|
+
drawArrow(ctx, originX, axisEndY, -Math.PI / 2, arrowSize);
|
|
964
|
+
// Calculate Y-axis value ranges
|
|
965
|
+
// For grouped charts: find max value across all segments
|
|
966
|
+
// For stacked charts: find max sum of values per category
|
|
967
|
+
// For waterfall charts: find cumulative min/max across all bars
|
|
968
|
+
let allValues = [];
|
|
969
|
+
if (chartType === 'grouped' || chartType === 'stacked' || chartType === 'waterfall') {
|
|
970
|
+
if (chartType === 'grouped') {
|
|
971
|
+
// For grouped: find max value across all segments
|
|
972
|
+
data.forEach(d => {
|
|
973
|
+
if (d.values && d.values.length > 0) {
|
|
974
|
+
d.values.forEach(seg => allValues.push(seg.value));
|
|
975
|
+
}
|
|
976
|
+
else if (d.value !== undefined) {
|
|
977
|
+
allValues.push(d.value);
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
else if (chartType === 'waterfall') {
|
|
982
|
+
// For waterfall: calculate all cumulative values (initial + each step's cumulative total)
|
|
983
|
+
let cumulativeValue = initialValue;
|
|
984
|
+
allValues.push(initialValue); // Include initial value
|
|
985
|
+
data.forEach(d => {
|
|
986
|
+
if (d.values && d.values.length > 0) {
|
|
987
|
+
// Sum all segments for this item
|
|
988
|
+
const itemTotal = d.values.reduce((sum, seg) => sum + seg.value, 0);
|
|
989
|
+
cumulativeValue += itemTotal;
|
|
990
|
+
}
|
|
991
|
+
else if (d.value !== undefined) {
|
|
992
|
+
cumulativeValue += d.value;
|
|
993
|
+
}
|
|
994
|
+
// Add each cumulative total to allValues
|
|
995
|
+
allValues.push(cumulativeValue);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
// For stacked: find max sum per category
|
|
1000
|
+
data.forEach(d => {
|
|
1001
|
+
if (d.values && d.values.length > 0) {
|
|
1002
|
+
const sum = d.values.reduce((acc, seg) => acc + seg.value, 0);
|
|
1003
|
+
allValues.push(sum);
|
|
1004
|
+
}
|
|
1005
|
+
else if (d.value !== undefined) {
|
|
1006
|
+
allValues.push(d.value);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
// Standard chart: use value directly
|
|
1013
|
+
allValues = data.map(d => d.value ?? 0).filter(v => v !== undefined && v !== null);
|
|
1014
|
+
}
|
|
1015
|
+
let minValue, maxValue, yStep;
|
|
1016
|
+
let yAxisCustomValues = yAxisValues;
|
|
1017
|
+
const hasExplicitYRange = yAxisRange && yAxisRange.min !== undefined && yAxisRange.max !== undefined;
|
|
1018
|
+
const hasExplicitXRange = xAxisRange && xAxisRange.min !== undefined && xAxisRange.max !== undefined;
|
|
1019
|
+
if (yAxisCustomValues && yAxisCustomValues.length > 0) {
|
|
1020
|
+
// Use custom Y-axis values
|
|
1021
|
+
minValue = Math.min(...yAxisCustomValues);
|
|
1022
|
+
maxValue = Math.max(...yAxisCustomValues);
|
|
1023
|
+
yStep = 1; // Not used when custom values are provided
|
|
1024
|
+
}
|
|
1025
|
+
else if (hasExplicitYRange) {
|
|
1026
|
+
// Use Y-axis range, but for waterfall charts, ensure it includes all cumulative values
|
|
1027
|
+
// TypeScript narrowing: hasExplicitYRange ensures min and max are defined
|
|
1028
|
+
minValue = yAxisRange.min;
|
|
1029
|
+
maxValue = yAxisRange.max;
|
|
1030
|
+
// Ensure baseline is within range
|
|
1031
|
+
const effectiveBaseline = baseline !== undefined ? baseline : 0;
|
|
1032
|
+
minValue = Math.min(minValue, effectiveBaseline);
|
|
1033
|
+
maxValue = Math.max(maxValue, effectiveBaseline);
|
|
1034
|
+
// For waterfall charts, expand range if needed to include all cumulative values
|
|
1035
|
+
if (chartType === 'waterfall' && allValues.length > 0) {
|
|
1036
|
+
const dataMin = Math.min(...allValues);
|
|
1037
|
+
const dataMax = Math.max(...allValues);
|
|
1038
|
+
// Ensure the range includes all data values
|
|
1039
|
+
minValue = Math.min(minValue, dataMin);
|
|
1040
|
+
maxValue = Math.max(maxValue, dataMax);
|
|
1041
|
+
// Add padding, but ensure baseline is always included
|
|
1042
|
+
const range = maxValue - minValue;
|
|
1043
|
+
const padding = range * 0.1;
|
|
1044
|
+
minValue = Math.min(minValue - padding, effectiveBaseline);
|
|
1045
|
+
maxValue = maxValue + padding;
|
|
1046
|
+
}
|
|
1047
|
+
yStep = yAxisRange.step ?? Math.ceil((maxValue - minValue) / 10);
|
|
1048
|
+
}
|
|
1049
|
+
else {
|
|
1050
|
+
// Auto-calculate from data
|
|
1051
|
+
if (allValues.length > 0) {
|
|
1052
|
+
minValue = Math.min(...allValues);
|
|
1053
|
+
maxValue = Math.max(...allValues);
|
|
1054
|
+
// Ensure baseline is within range for waterfall charts
|
|
1055
|
+
if (chartType === 'waterfall') {
|
|
1056
|
+
minValue = Math.min(minValue, initialValue);
|
|
1057
|
+
maxValue = Math.max(maxValue, initialValue);
|
|
1058
|
+
}
|
|
1059
|
+
// Add some padding, but ensure baseline is always included in the range
|
|
1060
|
+
const range = maxValue - minValue;
|
|
1061
|
+
const padding = range * 0.1;
|
|
1062
|
+
const effectiveBaseline = baseline !== undefined ? baseline : 0;
|
|
1063
|
+
// Ensure baseline is within the range
|
|
1064
|
+
minValue = Math.min(minValue - padding, effectiveBaseline);
|
|
1065
|
+
maxValue = maxValue + padding;
|
|
1066
|
+
}
|
|
1067
|
+
else {
|
|
1068
|
+
minValue = 0;
|
|
1069
|
+
maxValue = 1;
|
|
1070
|
+
}
|
|
1071
|
+
yStep = Math.ceil((maxValue - minValue) / 10);
|
|
1072
|
+
}
|
|
1073
|
+
// Validate data values against explicit axis ranges
|
|
1074
|
+
if (hasExplicitXRange || xAxisCustomValues) {
|
|
1075
|
+
const effectiveXMin = xAxisCustomValues ? Math.min(...xAxisCustomValues) : xAxisRange.min;
|
|
1076
|
+
const effectiveXMax = xAxisCustomValues ? Math.max(...xAxisCustomValues) : xAxisRange.max;
|
|
1077
|
+
data.forEach((item, itemIndex) => {
|
|
1078
|
+
if (item.xStart < effectiveXMin || item.xStart > effectiveXMax) {
|
|
1079
|
+
throw new Error(`Bar Chart Error: Data value out of X-axis bounds.\n` +
|
|
1080
|
+
`Bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" has xStart value ${item.xStart}, ` +
|
|
1081
|
+
`which exceeds the X-axis range [${effectiveXMin}, ${effectiveXMax}].`);
|
|
1082
|
+
}
|
|
1083
|
+
if (item.xEnd < effectiveXMin || item.xEnd > effectiveXMax) {
|
|
1084
|
+
throw new Error(`Bar Chart Error: Data value out of X-axis bounds.\n` +
|
|
1085
|
+
`Bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" has xEnd value ${item.xEnd}, ` +
|
|
1086
|
+
`which exceeds the X-axis range [${effectiveXMin}, ${effectiveXMax}].`);
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
if (hasExplicitYRange || yAxisCustomValues) {
|
|
1091
|
+
const effectiveYMin = yAxisCustomValues ? Math.min(...yAxisCustomValues) : minValue;
|
|
1092
|
+
const effectiveYMax = yAxisCustomValues ? Math.max(...yAxisCustomValues) : maxValue;
|
|
1093
|
+
data.forEach((item, itemIndex) => {
|
|
1094
|
+
if (chartType === 'grouped' || chartType === 'stacked') {
|
|
1095
|
+
if (item.values && item.values.length > 0) {
|
|
1096
|
+
item.values.forEach((seg, segIndex) => {
|
|
1097
|
+
if (seg.value < effectiveYMin || seg.value > effectiveYMax) {
|
|
1098
|
+
throw new Error(`Bar Chart Error: Data value out of Y-axis bounds.\n` +
|
|
1099
|
+
`Bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" segment ${segIndex} has value ${seg.value}, ` +
|
|
1100
|
+
`which exceeds the Y-axis range [${effectiveYMin}, ${effectiveYMax}].`);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
else if (item.value !== undefined) {
|
|
1105
|
+
if (item.value < effectiveYMin || item.value > effectiveYMax) {
|
|
1106
|
+
throw new Error(`Bar Chart Error: Data value out of Y-axis bounds.\n` +
|
|
1107
|
+
`Bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" has value ${item.value}, ` +
|
|
1108
|
+
`which exceeds the Y-axis range [${effectiveYMin}, ${effectiveYMax}].`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
else if (chartType === 'waterfall') {
|
|
1113
|
+
// For waterfall, check individual segment values and cumulative totals
|
|
1114
|
+
if (item.values && item.values.length > 0) {
|
|
1115
|
+
item.values.forEach((seg, segIndex) => {
|
|
1116
|
+
if (seg.value < effectiveYMin || seg.value > effectiveYMax) {
|
|
1117
|
+
throw new Error(`Bar Chart Error: Data value out of Y-axis bounds.\n` +
|
|
1118
|
+
`Waterfall bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" segment ${segIndex} has value ${seg.value}, ` +
|
|
1119
|
+
`which exceeds the Y-axis range [${effectiveYMin}, ${effectiveYMax}].`);
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
else if (item.value !== undefined) {
|
|
1124
|
+
if (item.value < effectiveYMin || item.value > effectiveYMax) {
|
|
1125
|
+
throw new Error(`Bar Chart Error: Data value out of Y-axis bounds.\n` +
|
|
1126
|
+
`Waterfall bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" has value ${item.value}, ` +
|
|
1127
|
+
`which exceeds the Y-axis range [${effectiveYMin}, ${effectiveYMax}].`);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
// Standard chart
|
|
1133
|
+
if (item.value !== undefined && (item.value < effectiveYMin || item.value > effectiveYMax)) {
|
|
1134
|
+
throw new Error(`Bar Chart Error: Data value out of Y-axis bounds.\n` +
|
|
1135
|
+
`Bar ${itemIndex} "${item.label || `at index ${itemIndex}`}" has value ${item.value}, ` +
|
|
1136
|
+
`which exceeds the Y-axis range [${effectiveYMin}, ${effectiveYMax}].`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
// Draw Y-axis ticks and labels (with custom values if provided)
|
|
1142
|
+
drawYAxisTicks(ctx, originX, originY, axisEndY, minValue, maxValue, yStep, tickFontSize, yAxisCustomValues, yAxisValueSpacing);
|
|
1143
|
+
// Calculate chart area dimensions (needed for baseline calculation)
|
|
1144
|
+
const chartAreaHeight = originY - axisEndY;
|
|
1145
|
+
// Calculate baseline position (custom baseline value, default is 0)
|
|
1146
|
+
// Position the baseline within the chart area based on minValue, maxValue, and baseline
|
|
1147
|
+
const baselineY = originY - ((baseline - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1148
|
+
// Draw X-axis at baseline position
|
|
1149
|
+
const xAxisY = baselineY;
|
|
1150
|
+
ctx.beginPath();
|
|
1151
|
+
ctx.moveTo(originX, xAxisY);
|
|
1152
|
+
ctx.lineTo(axisEndX, xAxisY);
|
|
1153
|
+
ctx.stroke();
|
|
1154
|
+
// Draw X-axis arrow
|
|
1155
|
+
drawArrow(ctx, axisEndX, xAxisY, 0, arrowSize);
|
|
1156
|
+
// Calculate X-axis step
|
|
1157
|
+
const xStep = xAxisRange?.step ?? Math.ceil((xMax - xMin) / 10);
|
|
1158
|
+
// Draw X-axis ticks and labels at baseline position
|
|
1159
|
+
drawXAxisTicks(ctx, originX, xAxisY, axisEndX, xMin, xMax, xStep, tickFontSize, xAxisCustomValues, xAxisValueSpacing);
|
|
1160
|
+
// Draw grid lines if enabled (before calculating zero line, but will use correct Y position)
|
|
1161
|
+
if (showGrid) {
|
|
1162
|
+
drawGrid(ctx, originX, originY, axisEndX, axisEndY, xMin, xMax, xStep, minValue, maxValue, yStep, xAxisCustomValues, yAxisCustomValues, gridColor, gridWidth);
|
|
1163
|
+
}
|
|
1164
|
+
// Draw X-axis label if provided
|
|
1165
|
+
if (xAxisLabel) {
|
|
1166
|
+
ctx.save();
|
|
1167
|
+
ctx.fillStyle = axisLabelColor;
|
|
1168
|
+
ctx.font = `${axisLabelFontSize}px Arial`;
|
|
1169
|
+
ctx.textAlign = 'center';
|
|
1170
|
+
ctx.textBaseline = 'top';
|
|
1171
|
+
// Position label below X-axis ticks (ticks are at xAxisY + 10, so add more spacing)
|
|
1172
|
+
ctx.fillText(xAxisLabel, (originX + axisEndX) / 2, xAxisY + 25);
|
|
1173
|
+
ctx.restore();
|
|
1174
|
+
}
|
|
1175
|
+
if (yAxisLabel) {
|
|
1176
|
+
ctx.save();
|
|
1177
|
+
ctx.fillStyle = axisLabelColor;
|
|
1178
|
+
ctx.font = `${axisLabelFontSize}px Arial`;
|
|
1179
|
+
ctx.textAlign = 'center';
|
|
1180
|
+
ctx.textBaseline = 'bottom';
|
|
1181
|
+
// Rotate for vertical text
|
|
1182
|
+
const labelX = originX - 30;
|
|
1183
|
+
const labelY = (originY + axisEndY) / 2;
|
|
1184
|
+
ctx.translate(labelX, labelY);
|
|
1185
|
+
ctx.rotate(-Math.PI / 2);
|
|
1186
|
+
ctx.fillText(yAxisLabel, 0, 0);
|
|
1187
|
+
ctx.restore();
|
|
1188
|
+
}
|
|
1189
|
+
// Draw legend if provided - positioned based on legendPosition option
|
|
1190
|
+
if (showLegend && legend && legend.length > 0) {
|
|
1191
|
+
const legendSpacing = options.legend?.spacing ?? 20;
|
|
1192
|
+
const legendFontSize = options.legend?.fontSize ?? 16;
|
|
1193
|
+
const legendTextColor = options.legend?.textColor;
|
|
1194
|
+
const legendBorderColor = options.legend?.borderColor;
|
|
1195
|
+
const legendBgColor = options.legend?.backgroundColor;
|
|
1196
|
+
const legendPadding = options.legend?.padding;
|
|
1197
|
+
const legendMaxWidth = options.legend?.maxWidth;
|
|
1198
|
+
const legendWrapText = options.legend?.wrapText !== false;
|
|
1199
|
+
// Calculate legend position based on legendPosition option
|
|
1200
|
+
let legendX, legendY;
|
|
1201
|
+
const chartAreaHeight = originY - axisEndY;
|
|
1202
|
+
const chartAreaWidth = axisEndX - originX;
|
|
1203
|
+
switch (legendPosition) {
|
|
1204
|
+
case 'top':
|
|
1205
|
+
legendX = (width - legendWidth) / 2; // Centered horizontally
|
|
1206
|
+
legendY = paddingTop + titleHeight + minLegendSpacing;
|
|
1207
|
+
break;
|
|
1208
|
+
case 'bottom':
|
|
1209
|
+
legendX = (width - legendWidth) / 2; // Centered horizontally
|
|
1210
|
+
legendY = adjustedHeight - paddingBottom - legendHeight - minLegendSpacing;
|
|
1211
|
+
break;
|
|
1212
|
+
case 'left':
|
|
1213
|
+
// Position legend further left to make room for Y-axis labels
|
|
1214
|
+
// Position legend on the left side
|
|
1215
|
+
legendX = paddingLeft + minLegendSpacing;
|
|
1216
|
+
legendY = axisEndY + (chartAreaHeight - legendHeight) / 2; // Vertically centered in chart area
|
|
1217
|
+
break;
|
|
1218
|
+
case 'right':
|
|
1219
|
+
default:
|
|
1220
|
+
legendX = axisEndX + minLegendSpacing;
|
|
1221
|
+
legendY = axisEndY + (chartAreaHeight - legendHeight) / 2; // Vertically centered in chart area
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
await drawLegendAtPosition(ctx, legend, legendX, legendY, legendFontSize, legendBgColor || backgroundColor, legendTextColor, legendBorderColor, legendPadding, legendMaxWidth, legendWrapText, options.legend?.backgroundGradient, options.legend?.textGradient, options.legend?.textStyle);
|
|
1225
|
+
}
|
|
1226
|
+
// Calculate chart area dimensions
|
|
1227
|
+
const chartAreaWidth = axisEndX - originX;
|
|
1228
|
+
const labelsToDraw = [];
|
|
1229
|
+
// Track value label positions per bar (for adjusting bar label positions)
|
|
1230
|
+
const valueLabelPositions = new Map();
|
|
1231
|
+
// First pass: Draw all bars (no labels)
|
|
1232
|
+
data.forEach((item, itemIndex) => {
|
|
1233
|
+
// Calculate bar position and width based on X-axis range
|
|
1234
|
+
// If custom X-axis values are provided, map to those positions
|
|
1235
|
+
let barXStart, barXEnd;
|
|
1236
|
+
if (xAxisCustomValues && xAxisCustomValues.length > 0) {
|
|
1237
|
+
// Map to custom X-axis values
|
|
1238
|
+
const actualMin = Math.min(...xAxisCustomValues);
|
|
1239
|
+
const actualMax = Math.max(...xAxisCustomValues);
|
|
1240
|
+
const xRange = actualMax - actualMin;
|
|
1241
|
+
// Find the position of xStart and xEnd in the custom values array
|
|
1242
|
+
// If xStart equals xEnd, it's a single-position bar
|
|
1243
|
+
const startIndex = xAxisCustomValues.indexOf(item.xStart);
|
|
1244
|
+
const endIndex = xAxisCustomValues.indexOf(item.xEnd);
|
|
1245
|
+
if (startIndex !== -1 && endIndex !== -1) {
|
|
1246
|
+
// Both values found in custom array - use index-based positioning
|
|
1247
|
+
const totalValues = xAxisCustomValues.length;
|
|
1248
|
+
const divisor = totalValues > 1 ? totalValues - 1 : 1;
|
|
1249
|
+
barXStart = originX + (startIndex / divisor) * chartAreaWidth;
|
|
1250
|
+
barXEnd = originX + (endIndex / divisor) * chartAreaWidth;
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
// Fallback to range-based positioning
|
|
1254
|
+
barXStart = originX + ((item.xStart - actualMin) / xRange) * chartAreaWidth;
|
|
1255
|
+
barXEnd = originX + ((item.xEnd - actualMin) / xRange) * chartAreaWidth;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
// Use regular range mapping
|
|
1260
|
+
const xRange = xMax - xMin;
|
|
1261
|
+
barXStart = originX + ((item.xStart - xMin) / xRange) * chartAreaWidth;
|
|
1262
|
+
barXEnd = originX + ((item.xEnd - xMin) / xRange) * chartAreaWidth;
|
|
1263
|
+
}
|
|
1264
|
+
// If xStart equals xEnd, use a minimum bar width
|
|
1265
|
+
const groupWidth = Math.max(barXEnd - barXStart, minBarWidth);
|
|
1266
|
+
if (item.xStart === item.xEnd) {
|
|
1267
|
+
// Center the bar at the position
|
|
1268
|
+
const centerX = barXStart;
|
|
1269
|
+
barXStart = centerX - groupWidth / 2;
|
|
1270
|
+
}
|
|
1271
|
+
// Handle grouped/stacked/waterfall vs standard charts
|
|
1272
|
+
if ((chartType === 'grouped' || chartType === 'stacked' || chartType === 'waterfall') && item.values && item.values.length > 0) {
|
|
1273
|
+
// Grouped, stacked, or waterfall chart
|
|
1274
|
+
const segments = item.values;
|
|
1275
|
+
const numSegments = segments.length;
|
|
1276
|
+
if (chartType === 'grouped') {
|
|
1277
|
+
// Grouped: bars side-by-side
|
|
1278
|
+
const segmentWidth = (groupWidth - (groupSpacing * (numSegments - 1))) / numSegments;
|
|
1279
|
+
// Track the highest value label Y position for this grouped bar
|
|
1280
|
+
let highestValueLabelY = null;
|
|
1281
|
+
segments.forEach((segment, segIndex) => {
|
|
1282
|
+
const segXStart = barXStart + (segIndex * (segmentWidth + groupSpacing));
|
|
1283
|
+
// Calculate bar position relative to baseline
|
|
1284
|
+
let barY, barHeight;
|
|
1285
|
+
if (segment.value >= baseline) {
|
|
1286
|
+
// Bar extends above baseline
|
|
1287
|
+
const positiveRatio = (segment.value - baseline) / (maxValue - minValue);
|
|
1288
|
+
barHeight = positiveRatio * chartAreaHeight;
|
|
1289
|
+
barY = baselineY - barHeight;
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
// Bar extends below baseline
|
|
1293
|
+
const negativeRatio = (baseline - segment.value) / (maxValue - minValue);
|
|
1294
|
+
barHeight = negativeRatio * chartAreaHeight;
|
|
1295
|
+
barY = baselineY;
|
|
1296
|
+
}
|
|
1297
|
+
// Draw segment bar with gradient, opacity, shadow, and stroke
|
|
1298
|
+
drawBar(ctx, segXStart, barY, segmentWidth, barHeight, segment.color || item.color || '#4A90E2', segment.gradient || item.gradient, segment.opacity ?? item.opacity ?? globalBarOpacity, segment.shadow || item.shadow, segment.stroke || item.stroke, globalBarShadow, globalBarStroke);
|
|
1299
|
+
// Store value label for later drawing
|
|
1300
|
+
const shouldShowValue = segment.showValue !== undefined ? segment.showValue : showValues;
|
|
1301
|
+
if (shouldShowValue) {
|
|
1302
|
+
const valueLabelY = barY - 5;
|
|
1303
|
+
// Track the highest (smallest Y value = highest on screen) value label
|
|
1304
|
+
if (segment.value >= baseline && (highestValueLabelY === null || valueLabelY < highestValueLabelY)) {
|
|
1305
|
+
highestValueLabelY = valueLabelY;
|
|
1306
|
+
}
|
|
1307
|
+
labelsToDraw.push({
|
|
1308
|
+
type: 'value',
|
|
1309
|
+
text: segment.value.toString(),
|
|
1310
|
+
x: segXStart + segmentWidth / 2,
|
|
1311
|
+
y: valueLabelY,
|
|
1312
|
+
align: 'center',
|
|
1313
|
+
baseline: 'bottom',
|
|
1314
|
+
color: segment.valueColor || valueColor,
|
|
1315
|
+
fontSize: valueFontSize
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
// Store the highest value label position for this grouped bar (for adjusting bar label position)
|
|
1320
|
+
if (highestValueLabelY !== null) {
|
|
1321
|
+
valueLabelPositions.set(data.indexOf(item), { y: highestValueLabelY, fontSize: valueFontSize, baseline: 'bottom' });
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
else if (chartType === 'waterfall') {
|
|
1325
|
+
// Waterfall: each bar starts from cumulative total of previous bars
|
|
1326
|
+
// Calculate cumulative value up to this point
|
|
1327
|
+
let cumulativeValue = initialValue;
|
|
1328
|
+
const currentIndex = data.indexOf(item);
|
|
1329
|
+
for (let i = 0; i < currentIndex; i++) {
|
|
1330
|
+
const prevItem = data[i];
|
|
1331
|
+
if (prevItem.values && prevItem.values.length > 0) {
|
|
1332
|
+
// Sum all segments for previous item
|
|
1333
|
+
const prevTotal = prevItem.values.reduce((sum, seg) => sum + seg.value, 0);
|
|
1334
|
+
cumulativeValue += prevTotal;
|
|
1335
|
+
}
|
|
1336
|
+
else if (prevItem.value !== undefined) {
|
|
1337
|
+
cumulativeValue += prevItem.value;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
// Calculate baseline Y position for this cumulative value
|
|
1341
|
+
const cumulativeBaselineY = originY - ((cumulativeValue - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1342
|
+
// Separate positive and negative segments
|
|
1343
|
+
const positiveSegments = [];
|
|
1344
|
+
const negativeSegments = [];
|
|
1345
|
+
segments.forEach(seg => {
|
|
1346
|
+
if (seg.value >= 0) {
|
|
1347
|
+
positiveSegments.push(seg);
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
negativeSegments.push(seg);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
// Draw positive segments (stacked upward from cumulative baseline)
|
|
1354
|
+
let accumulatedPositiveHeight = 0;
|
|
1355
|
+
positiveSegments.forEach((segment) => {
|
|
1356
|
+
const positiveRatio = segment.value / (maxValue - minValue);
|
|
1357
|
+
const segmentHeight = positiveRatio * chartAreaHeight;
|
|
1358
|
+
const barY = cumulativeBaselineY - accumulatedPositiveHeight - segmentHeight;
|
|
1359
|
+
// Ensure bar stays within chart area bounds
|
|
1360
|
+
const clampedBarY = Math.max(axisEndY, barY);
|
|
1361
|
+
const clampedBarHeight = Math.min(segmentHeight, cumulativeBaselineY - accumulatedPositiveHeight - clampedBarY);
|
|
1362
|
+
if (clampedBarHeight > 0) {
|
|
1363
|
+
// Ensure bar doesn't exceed X-axis bounds
|
|
1364
|
+
const clampedBarXStart = Math.max(originX, Math.min(barXStart, axisEndX));
|
|
1365
|
+
const clampedGroupWidth = Math.min(groupWidth, axisEndX - clampedBarXStart);
|
|
1366
|
+
if (clampedGroupWidth > 0) {
|
|
1367
|
+
drawBar(ctx, clampedBarXStart, clampedBarY, clampedGroupWidth, clampedBarHeight, segment.color || item.color || '#4A90E2', segment.gradient || item.gradient, segment.opacity ?? item.opacity ?? globalBarOpacity, segment.shadow || item.shadow, segment.stroke || item.stroke, globalBarShadow, globalBarStroke);
|
|
1368
|
+
const shouldShowValue = segment.showValue !== undefined ? segment.showValue : showValues;
|
|
1369
|
+
if (shouldShowValue && clampedBarHeight > valueFontSize + 5) {
|
|
1370
|
+
labelsToDraw.push({
|
|
1371
|
+
type: 'value',
|
|
1372
|
+
text: segment.value.toString(),
|
|
1373
|
+
x: clampedBarXStart + clampedGroupWidth / 2,
|
|
1374
|
+
y: clampedBarY + clampedBarHeight / 2,
|
|
1375
|
+
align: 'center',
|
|
1376
|
+
baseline: 'middle',
|
|
1377
|
+
color: segment.valueColor || valueColor,
|
|
1378
|
+
fontSize: valueFontSize
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
accumulatedPositiveHeight += segmentHeight;
|
|
1384
|
+
});
|
|
1385
|
+
// Draw negative segments (stacked downward from cumulative baseline)
|
|
1386
|
+
let accumulatedNegativeHeight = 0;
|
|
1387
|
+
negativeSegments.forEach((segment) => {
|
|
1388
|
+
const negativeRatio = Math.abs(segment.value) / (maxValue - minValue);
|
|
1389
|
+
const segmentHeight = negativeRatio * chartAreaHeight;
|
|
1390
|
+
const barY = cumulativeBaselineY + accumulatedNegativeHeight;
|
|
1391
|
+
// Ensure bar stays within chart area bounds
|
|
1392
|
+
const clampedBarY = Math.max(barY, axisEndY);
|
|
1393
|
+
const clampedBarHeight = Math.min(segmentHeight, originY - clampedBarY);
|
|
1394
|
+
if (clampedBarHeight > 0) {
|
|
1395
|
+
// Ensure bar doesn't exceed X-axis bounds
|
|
1396
|
+
const clampedBarXStart = Math.max(originX, Math.min(barXStart, axisEndX));
|
|
1397
|
+
const clampedGroupWidth = Math.min(groupWidth, axisEndX - clampedBarXStart);
|
|
1398
|
+
if (clampedGroupWidth > 0) {
|
|
1399
|
+
drawBar(ctx, clampedBarXStart, clampedBarY, clampedGroupWidth, clampedBarHeight, segment.color || item.color || '#FF6B6B', segment.gradient || item.gradient, segment.opacity ?? item.opacity ?? globalBarOpacity, segment.shadow || item.shadow, segment.stroke || item.stroke, globalBarShadow, globalBarStroke);
|
|
1400
|
+
const shouldShowValue = segment.showValue !== undefined ? segment.showValue : showValues;
|
|
1401
|
+
if (shouldShowValue && clampedBarHeight > valueFontSize + 5) {
|
|
1402
|
+
labelsToDraw.push({
|
|
1403
|
+
type: 'value',
|
|
1404
|
+
text: segment.value.toString(),
|
|
1405
|
+
x: clampedBarXStart + clampedGroupWidth / 2,
|
|
1406
|
+
y: clampedBarY + clampedBarHeight / 2,
|
|
1407
|
+
align: 'center',
|
|
1408
|
+
baseline: 'middle',
|
|
1409
|
+
color: segment.valueColor || valueColor,
|
|
1410
|
+
fontSize: valueFontSize
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
accumulatedNegativeHeight += segmentHeight;
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
else {
|
|
1419
|
+
// Stacked: bars on top of each other
|
|
1420
|
+
// For stacked with negatives, we need to separate positive and negative segments
|
|
1421
|
+
const positiveSegments = [];
|
|
1422
|
+
const negativeSegments = [];
|
|
1423
|
+
segments.forEach(seg => {
|
|
1424
|
+
if (seg.value >= baseline) {
|
|
1425
|
+
positiveSegments.push(seg);
|
|
1426
|
+
}
|
|
1427
|
+
else {
|
|
1428
|
+
negativeSegments.push(seg);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
// Draw positive segments (stacked upward from baseline)
|
|
1432
|
+
let accumulatedPositiveHeight = 0;
|
|
1433
|
+
positiveSegments.forEach((segment) => {
|
|
1434
|
+
const positiveRatio = (segment.value - baseline) / (maxValue - minValue);
|
|
1435
|
+
const segmentHeight = positiveRatio * chartAreaHeight;
|
|
1436
|
+
const barY = baselineY - accumulatedPositiveHeight - segmentHeight;
|
|
1437
|
+
drawBar(ctx, barXStart, barY, groupWidth, segmentHeight, segment.color || item.color || '#4A90E2', segment.gradient || item.gradient, segment.opacity ?? item.opacity ?? globalBarOpacity, segment.shadow || item.shadow, segment.stroke || item.stroke, globalBarShadow, globalBarStroke);
|
|
1438
|
+
const shouldShowValue = segment.showValue !== undefined ? segment.showValue : showValues;
|
|
1439
|
+
if (shouldShowValue && segmentHeight > valueFontSize + 5) {
|
|
1440
|
+
labelsToDraw.push({
|
|
1441
|
+
type: 'value',
|
|
1442
|
+
text: segment.value.toString(),
|
|
1443
|
+
x: barXStart + groupWidth / 2,
|
|
1444
|
+
y: barY + segmentHeight / 2,
|
|
1445
|
+
align: 'center',
|
|
1446
|
+
baseline: 'middle',
|
|
1447
|
+
color: segment.valueColor || valueColor,
|
|
1448
|
+
fontSize: valueFontSize
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
accumulatedPositiveHeight += segmentHeight;
|
|
1452
|
+
});
|
|
1453
|
+
// Draw negative segments (stacked downward from baseline)
|
|
1454
|
+
let accumulatedNegativeHeight = 0;
|
|
1455
|
+
negativeSegments.forEach((segment) => {
|
|
1456
|
+
const negativeRatio = (baseline - segment.value) / (maxValue - minValue);
|
|
1457
|
+
const segmentHeight = negativeRatio * chartAreaHeight;
|
|
1458
|
+
const barY = baselineY + accumulatedNegativeHeight;
|
|
1459
|
+
drawBar(ctx, barXStart, barY, groupWidth, segmentHeight, segment.color || item.color || '#FF6B6B', segment.gradient || item.gradient, segment.opacity ?? item.opacity ?? globalBarOpacity, segment.shadow || item.shadow, segment.stroke || item.stroke, globalBarShadow, globalBarStroke);
|
|
1460
|
+
const shouldShowValue = segment.showValue !== undefined ? segment.showValue : showValues;
|
|
1461
|
+
if (shouldShowValue && segmentHeight > valueFontSize + 5) {
|
|
1462
|
+
labelsToDraw.push({
|
|
1463
|
+
type: 'value',
|
|
1464
|
+
text: segment.value.toString(),
|
|
1465
|
+
x: barXStart + groupWidth / 2,
|
|
1466
|
+
y: barY + segmentHeight / 2,
|
|
1467
|
+
align: 'center',
|
|
1468
|
+
baseline: 'middle',
|
|
1469
|
+
color: segment.valueColor || valueColor,
|
|
1470
|
+
fontSize: valueFontSize
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
accumulatedNegativeHeight += segmentHeight;
|
|
1474
|
+
});
|
|
1475
|
+
// Store total value label for later drawing
|
|
1476
|
+
const totalValue = segments.reduce((sum, seg) => sum + seg.value, 0);
|
|
1477
|
+
const shouldShowValue = item.showValue !== undefined ? item.showValue : showValues;
|
|
1478
|
+
if (shouldShowValue) {
|
|
1479
|
+
const totalValueY = totalValue >= baseline ? baselineY - accumulatedPositiveHeight - 5 : baselineY + accumulatedNegativeHeight + 5;
|
|
1480
|
+
const totalValueBaseline = totalValue >= baseline ? 'bottom' : 'top';
|
|
1481
|
+
// Store value label position for this bar (for adjusting bar label position)
|
|
1482
|
+
if (totalValue >= baseline) {
|
|
1483
|
+
valueLabelPositions.set(data.indexOf(item), { y: totalValueY, fontSize: valueFontSize, baseline: totalValueBaseline });
|
|
1484
|
+
}
|
|
1485
|
+
labelsToDraw.push({
|
|
1486
|
+
type: 'value',
|
|
1487
|
+
text: totalValue.toString(),
|
|
1488
|
+
x: barXStart + groupWidth / 2,
|
|
1489
|
+
y: totalValueY,
|
|
1490
|
+
align: 'center',
|
|
1491
|
+
baseline: totalValueBaseline,
|
|
1492
|
+
color: item.valueColor || valueColor,
|
|
1493
|
+
fontSize: valueFontSize
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
else if (chartType === 'lollipop') {
|
|
1499
|
+
// Lollipop chart: line with dot at end
|
|
1500
|
+
const barCenterX = barXStart + groupWidth / 2;
|
|
1501
|
+
const value = item.value ?? baseline;
|
|
1502
|
+
// Calculate value Y position
|
|
1503
|
+
let valueY;
|
|
1504
|
+
if (value >= baseline) {
|
|
1505
|
+
// Value above baseline
|
|
1506
|
+
const positiveRatio = (value - baseline) / (maxValue - minValue);
|
|
1507
|
+
valueY = baselineY - positiveRatio * chartAreaHeight;
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
// Value below baseline
|
|
1511
|
+
const negativeRatio = (baseline - value) / (maxValue - minValue);
|
|
1512
|
+
valueY = baselineY + negativeRatio * chartAreaHeight;
|
|
1513
|
+
}
|
|
1514
|
+
// Draw line from baseline to value position
|
|
1515
|
+
ctx.save();
|
|
1516
|
+
ctx.strokeStyle = item.color || '#4A90E2';
|
|
1517
|
+
ctx.lineWidth = lollipopLineWidth;
|
|
1518
|
+
ctx.beginPath();
|
|
1519
|
+
ctx.moveTo(barCenterX, baselineY);
|
|
1520
|
+
ctx.lineTo(barCenterX, valueY);
|
|
1521
|
+
ctx.stroke();
|
|
1522
|
+
// Draw dot/circle at value position with opacity, shadow, and stroke
|
|
1523
|
+
ctx.save();
|
|
1524
|
+
const dotOpacity = item.opacity ?? globalBarOpacity;
|
|
1525
|
+
if (dotOpacity !== undefined) {
|
|
1526
|
+
ctx.globalAlpha = dotOpacity;
|
|
1527
|
+
}
|
|
1528
|
+
// Apply shadow
|
|
1529
|
+
const dotShadow = item.shadow || globalBarShadow;
|
|
1530
|
+
if (dotShadow) {
|
|
1531
|
+
ctx.shadowColor = dotShadow.color || 'rgba(0,0,0,0.3)';
|
|
1532
|
+
ctx.shadowOffsetX = dotShadow.offsetX ?? 2;
|
|
1533
|
+
ctx.shadowOffsetY = dotShadow.offsetY ?? 2;
|
|
1534
|
+
ctx.shadowBlur = dotShadow.blur ?? 4;
|
|
1535
|
+
}
|
|
1536
|
+
ctx.beginPath();
|
|
1537
|
+
ctx.arc(barCenterX, valueY, lollipopDotSize / 2, 0, Math.PI * 2);
|
|
1538
|
+
fillWithGradientOrColor(ctx, item.gradient, item.color || '#4A90E2', '#4A90E2', { x: barCenterX - lollipopDotSize / 2, y: valueY - lollipopDotSize / 2, w: lollipopDotSize, h: lollipopDotSize });
|
|
1539
|
+
ctx.fill();
|
|
1540
|
+
// Reset shadow before stroke
|
|
1541
|
+
if (dotShadow) {
|
|
1542
|
+
ctx.shadowColor = 'transparent';
|
|
1543
|
+
ctx.shadowOffsetX = 0;
|
|
1544
|
+
ctx.shadowOffsetY = 0;
|
|
1545
|
+
ctx.shadowBlur = 0;
|
|
1546
|
+
}
|
|
1547
|
+
// Draw dot border/stroke
|
|
1548
|
+
const dotStroke = item.stroke || globalBarStroke;
|
|
1549
|
+
if (dotStroke && dotStroke.width && dotStroke.width > 0) {
|
|
1550
|
+
ctx.beginPath();
|
|
1551
|
+
ctx.arc(barCenterX, valueY, lollipopDotSize / 2, 0, Math.PI * 2);
|
|
1552
|
+
if (dotStroke.gradient) {
|
|
1553
|
+
ctx.strokeStyle = (0, imageProperties_1.createGradientFill)(ctx, dotStroke.gradient, {
|
|
1554
|
+
x: barCenterX - lollipopDotSize / 2,
|
|
1555
|
+
y: valueY - lollipopDotSize / 2,
|
|
1556
|
+
w: lollipopDotSize,
|
|
1557
|
+
h: lollipopDotSize
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
ctx.strokeStyle = dotStroke.color || item.color || '#4A90E2';
|
|
1562
|
+
}
|
|
1563
|
+
ctx.lineWidth = dotStroke.width;
|
|
1564
|
+
ctx.stroke();
|
|
1565
|
+
}
|
|
1566
|
+
else {
|
|
1567
|
+
// Default border for better visibility if no stroke specified
|
|
1568
|
+
ctx.strokeStyle = item.color || '#4A90E2';
|
|
1569
|
+
ctx.lineWidth = 1;
|
|
1570
|
+
ctx.stroke();
|
|
1571
|
+
}
|
|
1572
|
+
ctx.restore();
|
|
1573
|
+
// Store value label for later drawing
|
|
1574
|
+
const shouldShowValue = item.showValue !== undefined ? item.showValue : showValues;
|
|
1575
|
+
if (shouldShowValue) {
|
|
1576
|
+
labelsToDraw.push({
|
|
1577
|
+
type: 'value',
|
|
1578
|
+
text: value.toString(),
|
|
1579
|
+
x: barCenterX,
|
|
1580
|
+
y: value >= baseline ? valueY - lollipopDotSize / 2 - 5 : valueY + lollipopDotSize / 2 + 5,
|
|
1581
|
+
align: 'center',
|
|
1582
|
+
baseline: value >= baseline ? 'bottom' : 'top',
|
|
1583
|
+
color: item.valueColor || valueColor,
|
|
1584
|
+
fontSize: valueFontSize
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
// Standard chart: single bar
|
|
1590
|
+
const barWidth = groupWidth;
|
|
1591
|
+
const value = item.value ?? baseline;
|
|
1592
|
+
// Calculate bar height and position based on value relative to baseline
|
|
1593
|
+
let barHeight;
|
|
1594
|
+
let barY;
|
|
1595
|
+
if (value >= baseline) {
|
|
1596
|
+
// Value above baseline: bar goes up from baseline
|
|
1597
|
+
const positiveRatio = (value - baseline) / (maxValue - minValue);
|
|
1598
|
+
barHeight = positiveRatio * chartAreaHeight;
|
|
1599
|
+
barY = baselineY - barHeight;
|
|
1600
|
+
}
|
|
1601
|
+
else {
|
|
1602
|
+
// Value below baseline: bar goes down from baseline
|
|
1603
|
+
const negativeRatio = (baseline - value) / (maxValue - minValue);
|
|
1604
|
+
barHeight = negativeRatio * chartAreaHeight;
|
|
1605
|
+
barY = baselineY;
|
|
1606
|
+
}
|
|
1607
|
+
// Draw bar with gradient, opacity, shadow, and stroke
|
|
1608
|
+
drawBar(ctx, barXStart, barY, barWidth, barHeight, item.color || '#4A90E2', item.gradient, item.opacity ?? globalBarOpacity, item.shadow, item.stroke, globalBarShadow, globalBarStroke);
|
|
1609
|
+
// Store value label for later drawing
|
|
1610
|
+
const shouldShowValue = item.showValue !== undefined ? item.showValue : showValues;
|
|
1611
|
+
if (shouldShowValue) {
|
|
1612
|
+
const valueLabelY = value >= baseline ? barY - 5 : barY + barHeight + 5;
|
|
1613
|
+
const valueLabelBaseline = value >= baseline ? 'bottom' : 'top';
|
|
1614
|
+
// Store value label position for this bar (for adjusting bar label position)
|
|
1615
|
+
if (value >= baseline) {
|
|
1616
|
+
valueLabelPositions.set(data.indexOf(item), { y: valueLabelY, fontSize: valueFontSize, baseline: valueLabelBaseline });
|
|
1617
|
+
}
|
|
1618
|
+
labelsToDraw.push({
|
|
1619
|
+
type: 'value',
|
|
1620
|
+
text: value.toString(),
|
|
1621
|
+
x: barXStart + barWidth / 2,
|
|
1622
|
+
y: valueLabelY,
|
|
1623
|
+
align: 'center',
|
|
1624
|
+
baseline: valueLabelBaseline,
|
|
1625
|
+
color: item.valueColor || valueColor,
|
|
1626
|
+
fontSize: valueFontSize
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// Store bar label information for later drawing
|
|
1631
|
+
if (showBarLabels) {
|
|
1632
|
+
ctx.save();
|
|
1633
|
+
ctx.fillStyle = item.labelColor || '#000000';
|
|
1634
|
+
ctx.font = `${axisLabelFontSize}px Arial`;
|
|
1635
|
+
let labelX, labelY;
|
|
1636
|
+
let textAlign = 'center';
|
|
1637
|
+
let textBaseline = 'middle';
|
|
1638
|
+
// Calculate bar center - use groupWidth for all chart types
|
|
1639
|
+
const barCenterX = barXStart + groupWidth / 2;
|
|
1640
|
+
// For grouped/stacked, calculate appropriate center Y
|
|
1641
|
+
let barCenterY;
|
|
1642
|
+
if ((chartType === 'grouped' || chartType === 'stacked') && item.values && item.values.length > 0) {
|
|
1643
|
+
if (chartType === 'stacked') {
|
|
1644
|
+
// For stacked, use the total height
|
|
1645
|
+
const totalValue = item.values.reduce((sum, seg) => sum + seg.value, 0);
|
|
1646
|
+
const totalHeight = ((totalValue - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1647
|
+
barCenterY = originY - totalHeight / 2;
|
|
1648
|
+
}
|
|
1649
|
+
else {
|
|
1650
|
+
// For grouped, use the max value height
|
|
1651
|
+
const maxSegValue = Math.max(...item.values.map(seg => seg.value));
|
|
1652
|
+
const maxHeight = ((maxSegValue - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1653
|
+
barCenterY = originY - maxHeight / 2;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
else {
|
|
1657
|
+
// Standard chart
|
|
1658
|
+
const value = item.value ?? 0;
|
|
1659
|
+
const barHeight = ((value - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1660
|
+
barCenterY = originY - barHeight / 2;
|
|
1661
|
+
}
|
|
1662
|
+
// Use individual bar label position if provided, otherwise use global setting
|
|
1663
|
+
const currentLabelPosition = item.labelPosition ?? barLabelPosition;
|
|
1664
|
+
// Calculate top Y position for label
|
|
1665
|
+
let topBarY;
|
|
1666
|
+
if ((chartType === 'grouped' || chartType === 'stacked') && item.values && item.values.length > 0) {
|
|
1667
|
+
if (chartType === 'stacked') {
|
|
1668
|
+
const totalValue = item.values.reduce((sum, seg) => sum + seg.value, 0);
|
|
1669
|
+
const totalHeight = ((totalValue - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1670
|
+
topBarY = originY - totalHeight;
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
const maxSegValue = Math.max(...item.values.map(seg => seg.value));
|
|
1674
|
+
const maxHeight = ((maxSegValue - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1675
|
+
topBarY = originY - maxHeight;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
else {
|
|
1679
|
+
const value = item.value ?? 0;
|
|
1680
|
+
const barHeight = ((value - minValue) / (maxValue - minValue)) * chartAreaHeight;
|
|
1681
|
+
topBarY = originY - barHeight;
|
|
1682
|
+
}
|
|
1683
|
+
switch (currentLabelPosition) {
|
|
1684
|
+
case 'top':
|
|
1685
|
+
labelX = barCenterX;
|
|
1686
|
+
// Check if there's a value label at the top - if so, position bar label below it
|
|
1687
|
+
const valueLabelInfo = valueLabelPositions.get(data.indexOf(item));
|
|
1688
|
+
if (valueLabelInfo && valueLabelInfo.baseline === 'bottom') {
|
|
1689
|
+
// Value label is at top, so position bar label below it
|
|
1690
|
+
// Value label uses 'bottom' baseline, so its top is at valueLabelInfo.y
|
|
1691
|
+
// Bar label uses 'bottom' baseline, so position it below the value label
|
|
1692
|
+
const spacing = 5; // Gap between value and bar label
|
|
1693
|
+
labelY = valueLabelInfo.y - valueLabelInfo.fontSize - spacing;
|
|
1694
|
+
}
|
|
1695
|
+
else {
|
|
1696
|
+
labelY = topBarY - 5;
|
|
1697
|
+
}
|
|
1698
|
+
textAlign = 'center';
|
|
1699
|
+
textBaseline = 'bottom';
|
|
1700
|
+
break;
|
|
1701
|
+
case 'bottom':
|
|
1702
|
+
labelX = barCenterX;
|
|
1703
|
+
labelY = originY + 5;
|
|
1704
|
+
textAlign = 'center';
|
|
1705
|
+
textBaseline = 'top';
|
|
1706
|
+
break;
|
|
1707
|
+
case 'left':
|
|
1708
|
+
labelX = barXStart - 5;
|
|
1709
|
+
labelY = barCenterY;
|
|
1710
|
+
textAlign = 'right';
|
|
1711
|
+
textBaseline = 'middle';
|
|
1712
|
+
break;
|
|
1713
|
+
case 'right':
|
|
1714
|
+
labelX = barXEnd + 5;
|
|
1715
|
+
labelY = barCenterY;
|
|
1716
|
+
textAlign = 'left';
|
|
1717
|
+
textBaseline = 'middle';
|
|
1718
|
+
break;
|
|
1719
|
+
case 'inside':
|
|
1720
|
+
labelX = barCenterX;
|
|
1721
|
+
labelY = barCenterY;
|
|
1722
|
+
textAlign = 'center';
|
|
1723
|
+
textBaseline = 'middle';
|
|
1724
|
+
// Use white or black text based on bar color for better visibility
|
|
1725
|
+
const barColor = item.color || '#4A90E2';
|
|
1726
|
+
// Simple brightness check - if bar is dark, use white text
|
|
1727
|
+
const isDark = barColor === '#000000' || barColor.toLowerCase().includes('dark') ||
|
|
1728
|
+
(barColor.startsWith('#') && parseInt(barColor.slice(1, 3), 16) < 128);
|
|
1729
|
+
ctx.fillStyle = isDark ? '#FFFFFF' : (item.labelColor || '#000000');
|
|
1730
|
+
break;
|
|
1731
|
+
default:
|
|
1732
|
+
labelX = barCenterX;
|
|
1733
|
+
labelY = originY + 5;
|
|
1734
|
+
textAlign = 'center';
|
|
1735
|
+
textBaseline = 'top';
|
|
1736
|
+
}
|
|
1737
|
+
// Calculate label color (for 'inside' position, check if bar is dark)
|
|
1738
|
+
let labelColor = item.labelColor || '#000000';
|
|
1739
|
+
if (currentLabelPosition === 'inside') {
|
|
1740
|
+
const barColor = item.color || '#4A90E2';
|
|
1741
|
+
const isDark = barColor === '#000000' || barColor.toLowerCase().includes('dark') ||
|
|
1742
|
+
(barColor.startsWith('#') && parseInt(barColor.slice(1, 3), 16) < 128);
|
|
1743
|
+
labelColor = isDark ? '#FFFFFF' : (item.labelColor || '#000000');
|
|
1744
|
+
}
|
|
1745
|
+
// Store bar label for later drawing
|
|
1746
|
+
labelsToDraw.push({
|
|
1747
|
+
type: 'bar',
|
|
1748
|
+
text: item.label,
|
|
1749
|
+
x: labelX,
|
|
1750
|
+
y: labelY,
|
|
1751
|
+
align: textAlign,
|
|
1752
|
+
baseline: textBaseline,
|
|
1753
|
+
color: labelColor,
|
|
1754
|
+
fontSize: axisLabelFontSize
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
// Second pass: Draw all labels (values and bar labels) on top of everything
|
|
1759
|
+
for (const label of labelsToDraw) {
|
|
1760
|
+
ctx.save();
|
|
1761
|
+
ctx.textAlign = label.align;
|
|
1762
|
+
ctx.textBaseline = label.baseline;
|
|
1763
|
+
// Determine text style and gradient based on label type
|
|
1764
|
+
let textStyle;
|
|
1765
|
+
let textGradient;
|
|
1766
|
+
if (label.type === 'bar') {
|
|
1767
|
+
textStyle = options.labels?.barLabelDefaults?.textStyle || label.textStyle;
|
|
1768
|
+
textGradient = options.labels?.barLabelDefaults?.gradient || label.gradient;
|
|
1769
|
+
}
|
|
1770
|
+
else if (label.type === 'value') {
|
|
1771
|
+
textStyle = options.labels?.valueLabelDefaults?.textStyle || label.textStyle;
|
|
1772
|
+
textGradient = options.labels?.valueLabelDefaults?.gradient || label.gradient;
|
|
1773
|
+
}
|
|
1774
|
+
await renderEnhancedText(ctx, label.text, label.x, label.y, textStyle, label.fontSize, label.color, textGradient);
|
|
1775
|
+
ctx.restore();
|
|
1776
|
+
}
|
|
1777
|
+
return canvas.toBuffer('image/png');
|
|
1778
|
+
}
|
|
1779
|
+
// Example usage with organized, categorized configuration:
|
|
1780
|
+
(async () => {
|
|
1781
|
+
const chart = await createBarChart([
|
|
1782
|
+
{
|
|
1783
|
+
label: 'Day 25',
|
|
1784
|
+
value: 1,
|
|
1785
|
+
xStart: 25,
|
|
1786
|
+
xEnd: 29,
|
|
1787
|
+
color: '#50C878',
|
|
1788
|
+
labelColor: 'black',
|
|
1789
|
+
labelPosition: 'top',
|
|
1790
|
+
valueColor: '#000000',
|
|
1791
|
+
showValue: true
|
|
1792
|
+
},
|
|
1793
|
+
{
|
|
1794
|
+
label: 'Day 12',
|
|
1795
|
+
value: 13,
|
|
1796
|
+
xStart: 10,
|
|
1797
|
+
xEnd: 14,
|
|
1798
|
+
color: '#50C878',
|
|
1799
|
+
labelColor: 'black',
|
|
1800
|
+
labelPosition: 'inside',
|
|
1801
|
+
valueColor: '#FFFFFF',
|
|
1802
|
+
showValue: true
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
label: 'Day 13',
|
|
1806
|
+
value: 4,
|
|
1807
|
+
xStart: 17,
|
|
1808
|
+
xEnd: 22,
|
|
1809
|
+
color: '#50C878',
|
|
1810
|
+
labelColor: 'black',
|
|
1811
|
+
labelPosition: 'right',
|
|
1812
|
+
valueColor: '#000000',
|
|
1813
|
+
showValue: true
|
|
1814
|
+
}
|
|
1815
|
+
], {
|
|
1816
|
+
// Chart Type
|
|
1817
|
+
type: 'standard', // 'standard' | 'grouped' | 'stacked' | 'horizontal'
|
|
1818
|
+
// Dimensions
|
|
1819
|
+
dimensions: {
|
|
1820
|
+
height: 600,
|
|
1821
|
+
padding: {
|
|
1822
|
+
top: 60,
|
|
1823
|
+
right: 80,
|
|
1824
|
+
bottom: 80,
|
|
1825
|
+
left: 100
|
|
1826
|
+
}
|
|
1827
|
+
},
|
|
1828
|
+
// Appearance
|
|
1829
|
+
appearance: {
|
|
1830
|
+
backgroundColor: 'white',
|
|
1831
|
+
// backgroundImage: './path/to/background.png', // Optional
|
|
1832
|
+
axisColor: '#000000',
|
|
1833
|
+
axisWidth: 2,
|
|
1834
|
+
arrowSize: 10
|
|
1835
|
+
},
|
|
1836
|
+
// Axes Configuration
|
|
1837
|
+
axes: {
|
|
1838
|
+
x: {
|
|
1839
|
+
label: 'Day',
|
|
1840
|
+
labelColor: 'black',
|
|
1841
|
+
values: [24, 25, 26, 27, 28, 29, 30, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23],
|
|
1842
|
+
// OR use range: { min: 0, max: 100, step: 20 }
|
|
1843
|
+
tickFontSize: 10,
|
|
1844
|
+
valueSpacing: 5 // Pixel spacing between each tick value (e.g., 5px gap between value 1 and 2)
|
|
1845
|
+
},
|
|
1846
|
+
y: {
|
|
1847
|
+
label: 'Count',
|
|
1848
|
+
labelColor: 'black',
|
|
1849
|
+
values: [0, 2, 4, 6, 8, 10, 12, 14],
|
|
1850
|
+
// OR use range: { min: 0, max: 14, step: 2 }
|
|
1851
|
+
tickFontSize: 10,
|
|
1852
|
+
valueSpacing: 3 // Pixel spacing between each tick value (e.g., 3px gap between value 0 and 2)
|
|
1853
|
+
}
|
|
1854
|
+
},
|
|
1855
|
+
// Labels & Text
|
|
1856
|
+
labels: {
|
|
1857
|
+
title: {
|
|
1858
|
+
text: 'Joined Members',
|
|
1859
|
+
fontSize: 18,
|
|
1860
|
+
color: '#000000'
|
|
1861
|
+
},
|
|
1862
|
+
barLabelDefaults: {
|
|
1863
|
+
show: true, // Global show/hide - each bar's label is defined in data
|
|
1864
|
+
defaultPosition: 'bottom', // Default when bar doesn't specify labelPosition
|
|
1865
|
+
fontSize: 12,
|
|
1866
|
+
defaultColor: '#000000' // Default when bar doesn't specify labelColor
|
|
1867
|
+
},
|
|
1868
|
+
valueLabelDefaults: {
|
|
1869
|
+
show: true, // Global show/hide - each bar can override with showValue
|
|
1870
|
+
fontSize: 11,
|
|
1871
|
+
defaultColor: '#000000' // Default when bar doesn't specify valueColor
|
|
1872
|
+
}
|
|
1873
|
+
},
|
|
1874
|
+
// Legend (always positioned at top)
|
|
1875
|
+
legend: {
|
|
1876
|
+
show: true,
|
|
1877
|
+
entries: [
|
|
1878
|
+
{ color: '#50C878', label: 'Members' },
|
|
1879
|
+
{ color: '#4A90E2', label: 'Bots' }
|
|
1880
|
+
]
|
|
1881
|
+
},
|
|
1882
|
+
// Grid
|
|
1883
|
+
grid: {
|
|
1884
|
+
show: true,
|
|
1885
|
+
color: '#E0E0E0',
|
|
1886
|
+
width: 1
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
fs_1.default.writeFileSync('./chart.png', chart);
|
|
1890
|
+
})();
|
|
1891
|
+
//# sourceMappingURL=barchart.js.map
|