photon-grid-core 0.0.2 → 1.0.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/README.md +304 -304
- package/chart/chart-config/chart-controls.d.ts +49 -0
- package/chart/chart-config/chart-controls.d.ts.map +1 -0
- package/chart/chart-config/chart-controls.js +229 -0
- package/chart/chart-config/chart-controls.js.map +1 -0
- package/chart/chart-config/chart-tool-panel.d.ts +86 -0
- package/chart/chart-config/chart-tool-panel.d.ts.map +1 -0
- package/chart/chart-config/chart-tool-panel.js +402 -0
- package/chart/chart-config/chart-tool-panel.js.map +1 -0
- package/chart/chart-panel.d.ts +97 -0
- package/chart/chart-panel.d.ts.map +1 -1
- package/chart/chart-panel.js +211 -58
- package/chart/chart-panel.js.map +1 -1
- package/chart/chart-renderer.d.ts +97 -0
- package/chart/chart-renderer.d.ts.map +1 -1
- package/chart/chart-renderer.js +335 -72
- package/chart/chart-renderer.js.map +1 -1
- package/chart/chart-theme.d.ts +38 -0
- package/chart/chart-theme.d.ts.map +1 -0
- package/chart/chart-theme.js +47 -0
- package/chart/chart-theme.js.map +1 -0
- package/chart/model/chart-model-mapper.d.ts +38 -0
- package/chart/model/chart-model-mapper.d.ts.map +1 -0
- package/chart/model/chart-model-mapper.js +128 -0
- package/chart/model/chart-model-mapper.js.map +1 -0
- package/chart/model/chart-model.d.ts +153 -0
- package/chart/model/chart-model.d.ts.map +1 -0
- package/chart/model/chart-model.js +79 -0
- package/chart/model/chart-model.js.map +1 -0
- package/chart/range-chart-controller.d.ts +68 -0
- package/chart/range-chart-controller.d.ts.map +1 -0
- package/chart/range-chart-controller.js +209 -0
- package/chart/range-chart-controller.js.map +1 -0
- package/chart/range-chart-service.d.ts +78 -0
- package/chart/range-chart-service.d.ts.map +1 -0
- package/chart/range-chart-service.js +162 -0
- package/chart/range-chart-service.js.map +1 -0
- package/core/grid-api.d.ts +20 -0
- package/core/grid-api.d.ts.map +1 -1
- package/core/grid-api.js +41 -0
- package/core/grid-api.js.map +1 -1
- package/core/grid-context.d.ts +7 -0
- package/core/grid-context.d.ts.map +1 -1
- package/core/grid-core.d.ts.map +1 -1
- package/core/grid-core.js +21 -9
- package/core/grid-core.js.map +1 -1
- package/icons/icon-sets/core-icons.js +1 -1
- package/icons/icon-sets/core-icons.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/package.json +57 -70
- package/renderer/body-renderer.d.ts.map +1 -1
- package/renderer/body-renderer.js +21 -4
- package/renderer/body-renderer.js.map +1 -1
- package/renderer/cell-renderer.js +1 -1
- package/renderer/cell-renderer.js.map +1 -1
- package/renderer/column-menu.js +24 -25
- package/renderer/column-menu.js.map +1 -1
- package/renderer/column-style-manager.d.ts.map +1 -1
- package/renderer/column-style-manager.js +11 -2
- package/renderer/column-style-manager.js.map +1 -1
- package/renderer/grid-renderer.js +1 -1
- package/renderer/grid-renderer.js.map +1 -1
- package/renderer/header-renderer.d.ts.map +1 -1
- package/renderer/header-renderer.js +17 -3
- package/renderer/header-renderer.js.map +1 -1
- package/renderer/row-drag-renderer.d.ts +18 -0
- package/renderer/row-drag-renderer.d.ts.map +1 -1
- package/renderer/row-drag-renderer.js +53 -0
- package/renderer/row-drag-renderer.js.map +1 -1
- package/styles/base/cells.css.d.ts +9 -0
- package/styles/base/cells.css.d.ts.map +1 -0
- package/styles/base/cells.css.js +233 -0
- package/styles/base/cells.css.js.map +1 -0
- package/styles/base/chart-config.css.d.ts +10 -0
- package/styles/base/chart-config.css.d.ts.map +1 -0
- package/styles/base/chart-config.css.js +126 -0
- package/styles/base/chart-config.css.js.map +1 -0
- package/styles/base/chart-controls.css.d.ts +7 -0
- package/styles/base/chart-controls.css.d.ts.map +1 -0
- package/styles/base/chart-controls.css.js +165 -0
- package/styles/base/chart-controls.css.js.map +1 -0
- package/styles/base/chart-panel.css.d.ts +9 -0
- package/styles/base/chart-panel.css.d.ts.map +1 -0
- package/styles/base/chart-panel.css.js +148 -0
- package/styles/base/chart-panel.css.js.map +1 -0
- package/styles/base/column-context-menu.css.d.ts +9 -0
- package/styles/base/column-context-menu.css.d.ts.map +1 -0
- package/styles/base/column-context-menu.css.js +153 -0
- package/styles/base/column-context-menu.css.js.map +1 -0
- package/styles/base/column-group-header.css.d.ts +9 -0
- package/styles/base/column-group-header.css.d.ts.map +1 -0
- package/styles/base/column-group-header.css.js +270 -0
- package/styles/base/column-group-header.css.js.map +1 -0
- package/styles/base/context-menu.css.d.ts +9 -0
- package/styles/base/context-menu.css.d.ts.map +1 -0
- package/styles/base/context-menu.css.js +103 -0
- package/styles/base/context-menu.css.js.map +1 -0
- package/styles/base/editors.css.d.ts +9 -0
- package/styles/base/editors.css.d.ts.map +1 -0
- package/styles/base/editors.css.js +291 -0
- package/styles/base/editors.css.js.map +1 -0
- package/styles/base/filter.css.d.ts +9 -0
- package/styles/base/filter.css.d.ts.map +1 -0
- package/styles/base/filter.css.js +312 -0
- package/styles/base/filter.css.js.map +1 -0
- package/styles/base/footer.css.d.ts +9 -0
- package/styles/base/footer.css.d.ts.map +1 -0
- package/styles/base/footer.css.js +78 -0
- package/styles/base/footer.css.js.map +1 -0
- package/styles/base/group-bar-search.css.d.ts +9 -0
- package/styles/base/group-bar-search.css.d.ts.map +1 -0
- package/styles/base/group-bar-search.css.js +86 -0
- package/styles/base/group-bar-search.css.js.map +1 -0
- package/styles/base/group-drop-zone.css.d.ts +9 -0
- package/styles/base/group-drop-zone.css.d.ts.map +1 -0
- package/styles/base/group-drop-zone.css.js +173 -0
- package/styles/base/group-drop-zone.css.js.map +1 -0
- package/styles/base/header.css.d.ts +9 -0
- package/styles/base/header.css.d.ts.map +1 -0
- package/styles/base/header.css.js +206 -0
- package/styles/base/header.css.js.map +1 -0
- package/styles/base/master-detail.css.d.ts +9 -0
- package/styles/base/master-detail.css.d.ts.map +1 -0
- package/styles/base/master-detail.css.js +202 -0
- package/styles/base/master-detail.css.js.map +1 -0
- package/styles/base/misc.css.d.ts +9 -0
- package/styles/base/misc.css.d.ts.map +1 -0
- package/styles/base/misc.css.js +65 -0
- package/styles/base/misc.css.js.map +1 -0
- package/styles/base/panels.css.d.ts +9 -0
- package/styles/base/panels.css.d.ts.map +1 -0
- package/styles/base/panels.css.js +162 -0
- package/styles/base/panels.css.js.map +1 -0
- package/styles/base/photon-ai.css.d.ts +9 -0
- package/styles/base/photon-ai.css.d.ts.map +1 -0
- package/styles/base/photon-ai.css.js +200 -0
- package/styles/base/photon-ai.css.js.map +1 -0
- package/styles/base/root.css.d.ts +9 -0
- package/styles/base/root.css.d.ts.map +1 -0
- package/styles/base/root.css.js +56 -0
- package/styles/base/root.css.js.map +1 -0
- package/styles/base/row-drag.css.d.ts +9 -0
- package/styles/base/row-drag.css.d.ts.map +1 -0
- package/styles/base/row-drag.css.js +93 -0
- package/styles/base/row-drag.css.js.map +1 -0
- package/styles/base/row-group.css.d.ts +9 -0
- package/styles/base/row-group.css.d.ts.map +1 -0
- package/styles/base/row-group.css.js +79 -0
- package/styles/base/row-group.css.js.map +1 -0
- package/styles/base/rows.css.d.ts +9 -0
- package/styles/base/rows.css.d.ts.map +1 -0
- package/styles/base/rows.css.js +83 -0
- package/styles/base/rows.css.js.map +1 -0
- package/styles/base/scrollbars.css.d.ts +9 -0
- package/styles/base/scrollbars.css.d.ts.map +1 -0
- package/styles/base/scrollbars.css.js +56 -0
- package/styles/base/scrollbars.css.js.map +1 -0
- package/styles/base/sparkline.css.d.ts +9 -0
- package/styles/base/sparkline.css.d.ts.map +1 -0
- package/styles/base/sparkline.css.js +109 -0
- package/styles/base/sparkline.css.js.map +1 -0
- package/styles/base/tooltip.css.d.ts +9 -0
- package/styles/base/tooltip.css.d.ts.map +1 -0
- package/styles/base/tooltip.css.js +39 -0
- package/styles/base/tooltip.css.js.map +1 -0
- package/styles/base/tree.css.d.ts +9 -0
- package/styles/base/tree.css.d.ts.map +1 -0
- package/styles/base/tree.css.js +78 -0
- package/styles/base/tree.css.js.map +1 -0
- package/styles/base-styles.d.ts.map +1 -1
- package/styles/base-styles.js +60 -3026
- package/styles/base-styles.js.map +1 -1
- package/styles/themes/theme-alpine.d.ts +1 -1
- package/styles/themes/theme-alpine.d.ts.map +1 -1
- package/styles/themes/theme-alpine.js +1 -1
- package/styles/themes/theme-balham.d.ts +1 -1
- package/styles/themes/theme-balham.d.ts.map +1 -1
- package/styles/themes/theme-balham.js +0 -1
- package/styles/themes/theme-balham.js.map +1 -1
- package/styles/themes/theme-dark.d.ts +1 -1
- package/styles/themes/theme-dark.d.ts.map +1 -1
- package/styles/themes/theme-dark.js +1 -1
- package/styles/themes/theme-material.d.ts +1 -1
- package/styles/themes/theme-material.d.ts.map +1 -1
- package/styles/themes/theme-material.js +0 -1
- package/styles/themes/theme-material.js.map +1 -1
- package/styles/themes/theme-quartz.d.ts +1 -1
- package/styles/themes/theme-quartz.d.ts.map +1 -1
- package/styles/themes/theme-quartz.js +1 -1
- package/types/event.types.d.ts +29 -0
- package/types/event.types.d.ts.map +1 -1
- package/types/event.types.js +1 -0
- package/types/event.types.js.map +1 -1
- package/types/grid.types.d.ts +51 -0
- package/types/grid.types.d.ts.map +1 -1
- package/LICENSE +0 -1
- package/chart/chart-analyzer.d.ts +0 -12
- package/chart/chart-analyzer.d.ts.map +0 -1
- package/chart/chart-analyzer.js +0 -77
- package/chart/chart-analyzer.js.map +0 -1
package/chart/chart-renderer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveChartTheme } from './chart-theme';
|
|
1
2
|
const DEFAULTS = {
|
|
2
3
|
type: 'column-grouped',
|
|
3
4
|
width: 600,
|
|
@@ -9,23 +10,55 @@ const DEFAULTS = {
|
|
|
9
10
|
barWidth: 0.75,
|
|
10
11
|
lineWidth: 2,
|
|
11
12
|
smooth: false,
|
|
12
|
-
fontFamily: '
|
|
13
|
+
fontFamily: '',
|
|
13
14
|
fontSize: 12,
|
|
14
|
-
textColor: '
|
|
15
|
-
gridColor: '
|
|
15
|
+
textColor: '',
|
|
16
|
+
gridColor: '',
|
|
16
17
|
backgroundColor: 'transparent',
|
|
17
18
|
animationDuration: 400,
|
|
19
|
+
title: '',
|
|
20
|
+
titleColor: '',
|
|
21
|
+
titleFontSize: 15,
|
|
22
|
+
titleAlign: 'left',
|
|
23
|
+
subtitle: '',
|
|
24
|
+
subtitleColor: '',
|
|
25
|
+
subtitleFontSize: 12,
|
|
26
|
+
subtitleAlign: 'left',
|
|
27
|
+
legendPosition: 'bottom',
|
|
28
|
+
xAxisTitle: '',
|
|
29
|
+
yAxisTitle: '',
|
|
30
|
+
axisTitleColor: '',
|
|
31
|
+
axisLabelColor: '',
|
|
32
|
+
axisLineColor: '',
|
|
33
|
+
showXTicks: true,
|
|
34
|
+
showYTicks: true,
|
|
35
|
+
showXLabels: true,
|
|
36
|
+
showYLabels: true,
|
|
37
|
+
seriesColors: {},
|
|
38
|
+
strokeWidth: 0,
|
|
39
|
+
fillOpacity: 0,
|
|
40
|
+
compact: false,
|
|
18
41
|
};
|
|
19
42
|
const APEX_COLORS = [
|
|
20
43
|
'#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0',
|
|
21
44
|
'#3F51B5', '#03A9F4', '#4CAF50', '#F9CE1D', '#FF9800',
|
|
22
45
|
];
|
|
23
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Assigns a concrete color to every dataset. Precedence: an explicit override
|
|
48
|
+
* in `seriesColors` (keyed by the dataset label), then a color already on the
|
|
49
|
+
* dataset, then the theme `palette` cycled by index.
|
|
50
|
+
*
|
|
51
|
+
* @param data - Source chart data (datasets may lack colors).
|
|
52
|
+
* @param palette - Theme-resolved series palette.
|
|
53
|
+
* @param seriesColors - Per-label color overrides from the chart model.
|
|
54
|
+
*/
|
|
55
|
+
function assignColors(data, palette, seriesColors) {
|
|
56
|
+
const pal = palette.length > 0 ? palette : APEX_COLORS;
|
|
24
57
|
return {
|
|
25
58
|
labels: data.labels,
|
|
26
59
|
datasets: data.datasets.map((ds, i) => ({
|
|
27
60
|
...ds,
|
|
28
|
-
color: ds.color ??
|
|
61
|
+
color: seriesColors[ds.label] ?? ds.color ?? pal[i % pal.length],
|
|
29
62
|
})),
|
|
30
63
|
};
|
|
31
64
|
}
|
|
@@ -45,6 +78,12 @@ export class ChartRenderer {
|
|
|
45
78
|
this.seriesScales = [];
|
|
46
79
|
/** Active RAF IDs for per-series toggle animations, keyed by dataset index. */
|
|
47
80
|
this.seriesToggleRafs = new Map();
|
|
81
|
+
/**
|
|
82
|
+
* Theme colors/palette resolved once per {@link render} / {@link toggleSeries}
|
|
83
|
+
* call and reused across every animation and hover frame — never re-resolved
|
|
84
|
+
* per frame (that would force layout via `getComputedStyle`).
|
|
85
|
+
*/
|
|
86
|
+
this.theme = null;
|
|
48
87
|
this.canvas = canvas;
|
|
49
88
|
const ctx = canvas.getContext('2d');
|
|
50
89
|
if (!ctx)
|
|
@@ -52,11 +91,30 @@ export class ChartRenderer {
|
|
|
52
91
|
this.ctx = ctx;
|
|
53
92
|
this.attachEvents();
|
|
54
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolves theme tokens once and back-fills any color/font option the caller
|
|
96
|
+
* left blank (empty string) with the theme value. Explicit overrides win over
|
|
97
|
+
* theme defaults. Mutates `options` in place and caches the resolved theme.
|
|
98
|
+
*/
|
|
99
|
+
prepareOptions(options) {
|
|
100
|
+
const theme = resolveChartTheme(this.canvas);
|
|
101
|
+
this.theme = theme;
|
|
102
|
+
if (!options.fontFamily)
|
|
103
|
+
options.fontFamily = theme.fontFamily;
|
|
104
|
+
options.textColor = options.axisLabelColor || options.textColor || theme.textColor;
|
|
105
|
+
options.gridColor = options.axisLineColor || options.gridColor || theme.gridColor;
|
|
106
|
+
}
|
|
107
|
+
/** Theme palette color for a given series/slice index, cycling as needed. */
|
|
108
|
+
paletteColor(i) {
|
|
109
|
+
const pal = this.theme?.palette ?? APEX_COLORS;
|
|
110
|
+
return pal[i % pal.length];
|
|
111
|
+
}
|
|
55
112
|
render(data, opts = { type: 'column-grouped' }) {
|
|
56
113
|
const options = { ...DEFAULTS, ...opts };
|
|
57
114
|
this.canvas.width = options.width;
|
|
58
115
|
this.canvas.height = options.height;
|
|
59
|
-
|
|
116
|
+
this.prepareOptions(options);
|
|
117
|
+
const coloredData = assignColors(data, this.theme.palette, options.seriesColors);
|
|
60
118
|
this.lastData = coloredData;
|
|
61
119
|
this.lastOptions = options;
|
|
62
120
|
// Full re-render: reset every series to fully visible
|
|
@@ -98,7 +156,8 @@ export class ChartRenderer {
|
|
|
98
156
|
*/
|
|
99
157
|
toggleSeries(index, toVisible, data, opts = { type: 'column-grouped' }) {
|
|
100
158
|
const options = { ...DEFAULTS, ...opts };
|
|
101
|
-
|
|
159
|
+
this.prepareOptions(options);
|
|
160
|
+
const coloredData = assignColors(data, this.theme.palette, options.seriesColors);
|
|
102
161
|
this.lastData = coloredData;
|
|
103
162
|
this.lastOptions = options;
|
|
104
163
|
while (this.seriesScales.length < coloredData.datasets.length)
|
|
@@ -276,7 +335,7 @@ export class ChartRenderer {
|
|
|
276
335
|
const { ctx } = this;
|
|
277
336
|
const legendH = opts.showLegend && data.datasets.length > 1 ? 40 : 0;
|
|
278
337
|
const plotLeft = 80;
|
|
279
|
-
const plotTop = 10;
|
|
338
|
+
const plotTop = 10 + this.titleBandHeight(opts);
|
|
280
339
|
const plotRight = opts.width - 20;
|
|
281
340
|
const plotBottom = opts.height - legendH - 30;
|
|
282
341
|
const plotH = plotBottom - plotTop;
|
|
@@ -420,12 +479,60 @@ export class ChartRenderer {
|
|
|
420
479
|
};
|
|
421
480
|
this.rafId = requestAnimationFrame(tick);
|
|
422
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* Vertical space (px) reserved at the top of the canvas for the title and
|
|
484
|
+
* subtitle bands. Returns 0 when neither is set so no space is wasted.
|
|
485
|
+
*/
|
|
486
|
+
titleBandHeight(opts) {
|
|
487
|
+
let h = 0;
|
|
488
|
+
if (opts.title)
|
|
489
|
+
h += opts.titleFontSize + 8;
|
|
490
|
+
if (opts.subtitle)
|
|
491
|
+
h += opts.subtitleFontSize + 6;
|
|
492
|
+
return h > 0 ? h + 6 : 0;
|
|
493
|
+
}
|
|
494
|
+
/** Whether a shared series legend should be drawn. */
|
|
495
|
+
legendActive(opts, data) {
|
|
496
|
+
return opts.showLegend && data.datasets.length > 1;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Whether the legend may be placed on a side (left/right). Only cartesian
|
|
500
|
+
* charts (which route layout through {@link getPlotArea}) reserve side space;
|
|
501
|
+
* pie/polar/funnel/bar keep the legend on the bottom to avoid overlap.
|
|
502
|
+
*/
|
|
503
|
+
supportsSideLegend(opts) {
|
|
504
|
+
const type = opts.type;
|
|
505
|
+
return type.startsWith('column') || type === 'line' || type === 'area' || type === 'scatter';
|
|
506
|
+
}
|
|
507
|
+
/** Effective legend position, clamped to bottom for non-cartesian charts. */
|
|
508
|
+
effectiveLegendPosition(opts) {
|
|
509
|
+
if (!this.supportsSideLegend(opts))
|
|
510
|
+
return 'bottom';
|
|
511
|
+
return opts.legendPosition;
|
|
512
|
+
}
|
|
423
513
|
getPlotArea(opts, data) {
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
const
|
|
514
|
+
const legendOn = this.legendActive(opts, data);
|
|
515
|
+
const pos = this.effectiveLegendPosition(opts);
|
|
516
|
+
const band = ChartRenderer.LEGEND_BAND;
|
|
517
|
+
const side = ChartRenderer.LEGEND_SIDE;
|
|
518
|
+
const titleH = this.titleBandHeight(opts);
|
|
519
|
+
const xTitleH = opts.xAxisTitle ? opts.fontSize + 10 : 0;
|
|
520
|
+
const yTitleW = opts.yAxisTitle ? opts.fontSize + 12 : 0;
|
|
521
|
+
// Compact previews use a uniform padding gutter so the plot fills the canvas.
|
|
522
|
+
let plotTop = opts.compact ? opts.padding + titleH : 20 + titleH;
|
|
523
|
+
let plotBottom = opts.compact ? opts.height - opts.padding : opts.height - 36 - xTitleH;
|
|
524
|
+
let plotLeft = opts.compact ? opts.padding : 65 + yTitleW;
|
|
525
|
+
let plotRight = opts.width - (opts.compact ? opts.padding : 20);
|
|
526
|
+
if (legendOn) {
|
|
527
|
+
if (pos === 'top')
|
|
528
|
+
plotTop += band;
|
|
529
|
+
else if (pos === 'bottom')
|
|
530
|
+
plotBottom -= band;
|
|
531
|
+
else if (pos === 'left')
|
|
532
|
+
plotLeft += side;
|
|
533
|
+
else if (pos === 'right')
|
|
534
|
+
plotRight -= side;
|
|
535
|
+
}
|
|
429
536
|
return {
|
|
430
537
|
plotLeft,
|
|
431
538
|
plotTop,
|
|
@@ -433,7 +540,7 @@ export class ChartRenderer {
|
|
|
433
540
|
plotBottom,
|
|
434
541
|
plotW: plotRight - plotLeft,
|
|
435
542
|
plotH: plotBottom - plotTop,
|
|
436
|
-
legendH,
|
|
543
|
+
legendH: legendOn && (pos === 'top' || pos === 'bottom') ? band : 0,
|
|
437
544
|
};
|
|
438
545
|
}
|
|
439
546
|
draw(data, options, progress = 1) {
|
|
@@ -489,10 +596,81 @@ export class ChartRenderer {
|
|
|
489
596
|
this.drawColumnGrouped(data, options, progress);
|
|
490
597
|
break;
|
|
491
598
|
}
|
|
492
|
-
if (options
|
|
599
|
+
if (this.supportsSideLegend(options)) {
|
|
600
|
+
this.drawAxisTitles(options);
|
|
601
|
+
}
|
|
602
|
+
this.drawTitleBlock(options);
|
|
603
|
+
if (this.legendActive(options, data)) {
|
|
493
604
|
this.drawLegend(data, options);
|
|
494
605
|
}
|
|
495
606
|
}
|
|
607
|
+
/**
|
|
608
|
+
* Draws the chart title and subtitle in the reserved top band, honoring the
|
|
609
|
+
* configured color (falling back to theme) and alignment. No-op when both are
|
|
610
|
+
* empty.
|
|
611
|
+
*/
|
|
612
|
+
drawTitleBlock(opts) {
|
|
613
|
+
if (!opts.title && !opts.subtitle)
|
|
614
|
+
return;
|
|
615
|
+
const { ctx } = this;
|
|
616
|
+
const theme = this.theme;
|
|
617
|
+
ctx.save();
|
|
618
|
+
ctx.textBaseline = 'alphabetic';
|
|
619
|
+
let y = 20;
|
|
620
|
+
if (opts.title) {
|
|
621
|
+
ctx.font = `600 ${opts.titleFontSize}px ${opts.fontFamily}`;
|
|
622
|
+
ctx.fillStyle = opts.titleColor || theme?.textColor || opts.textColor;
|
|
623
|
+
const x = this.alignedX(opts.titleAlign, opts.width);
|
|
624
|
+
ctx.textAlign = opts.titleAlign;
|
|
625
|
+
ctx.fillText(opts.title, x, y);
|
|
626
|
+
y += opts.titleFontSize + 4;
|
|
627
|
+
}
|
|
628
|
+
if (opts.subtitle) {
|
|
629
|
+
ctx.font = `${opts.subtitleFontSize}px ${opts.fontFamily}`;
|
|
630
|
+
ctx.fillStyle = opts.subtitleColor || theme?.mutedColor || opts.textColor;
|
|
631
|
+
const x = this.alignedX(opts.subtitleAlign, opts.width);
|
|
632
|
+
ctx.textAlign = opts.subtitleAlign;
|
|
633
|
+
ctx.fillText(opts.subtitle, x, y + opts.subtitleFontSize);
|
|
634
|
+
}
|
|
635
|
+
ctx.restore();
|
|
636
|
+
}
|
|
637
|
+
/** X coordinate for a given horizontal alignment across the canvas width. */
|
|
638
|
+
alignedX(align, width) {
|
|
639
|
+
if (align === 'center')
|
|
640
|
+
return width / 2;
|
|
641
|
+
if (align === 'right')
|
|
642
|
+
return width - 20;
|
|
643
|
+
return 20;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Draws the x- and y-axis titles (cartesian charts only). The y-axis title is
|
|
647
|
+
* rotated −90° and centered along the left margin; the x-axis title sits below
|
|
648
|
+
* the category labels.
|
|
649
|
+
*/
|
|
650
|
+
drawAxisTitles(opts) {
|
|
651
|
+
if (!opts.xAxisTitle && !opts.yAxisTitle)
|
|
652
|
+
return;
|
|
653
|
+
const { ctx } = this;
|
|
654
|
+
const color = opts.axisTitleColor || this.theme?.textColor || opts.textColor;
|
|
655
|
+
ctx.save();
|
|
656
|
+
ctx.fillStyle = color;
|
|
657
|
+
ctx.font = `600 ${opts.fontSize}px ${opts.fontFamily}`;
|
|
658
|
+
if (opts.xAxisTitle) {
|
|
659
|
+
ctx.textAlign = 'center';
|
|
660
|
+
ctx.textBaseline = 'bottom';
|
|
661
|
+
ctx.fillText(opts.xAxisTitle, opts.width / 2, opts.height - 4);
|
|
662
|
+
}
|
|
663
|
+
if (opts.yAxisTitle) {
|
|
664
|
+
ctx.save();
|
|
665
|
+
ctx.translate(14, opts.height / 2);
|
|
666
|
+
ctx.rotate(-Math.PI / 2);
|
|
667
|
+
ctx.textAlign = 'center';
|
|
668
|
+
ctx.textBaseline = 'top';
|
|
669
|
+
ctx.fillText(opts.yAxisTitle, 0, 0);
|
|
670
|
+
ctx.restore();
|
|
671
|
+
}
|
|
672
|
+
ctx.restore();
|
|
673
|
+
}
|
|
496
674
|
// ── Column Grouped ──────────────────────────────────────────────────────────
|
|
497
675
|
drawColumnGrouped(data, opts, progress) {
|
|
498
676
|
const { ctx } = this;
|
|
@@ -559,28 +737,43 @@ export class ChartRenderer {
|
|
|
559
737
|
drawColumnStacked(data, opts, progress) {
|
|
560
738
|
const { ctx } = this;
|
|
561
739
|
const { plotLeft, plotTop, plotBottom, plotRight, plotW, plotH } = this.getPlotArea(opts, data);
|
|
740
|
+
// Match the grouped chart: with multiple series, scale each series to its own
|
|
741
|
+
// maximum so a small-scale series (e.g. Age next to Salary) stays visible
|
|
742
|
+
// instead of collapsing to a sub-pixel sliver. Each series then occupies an
|
|
743
|
+
// equal band of the plot height. Falls back to true absolute stacking for a
|
|
744
|
+
// single series.
|
|
745
|
+
const nD = data.datasets.length;
|
|
746
|
+
const usePerSeries = nD > 1;
|
|
747
|
+
const seriesMaxes = data.datasets.map((ds) => this.niceMax(Math.max(...ds.data, 0)));
|
|
562
748
|
const stackTotals = data.labels.map((_, gi) => data.datasets.reduce((sum, ds) => sum + (ds.data[gi] ?? 0), 0));
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
749
|
+
const maxVal = this.niceMax(Math.max(...stackTotals, 0));
|
|
750
|
+
if (opts.showGrid) {
|
|
751
|
+
if (usePerSeries)
|
|
752
|
+
this.drawGridLinesRelative(plotLeft, plotTop, plotW, plotH, opts);
|
|
753
|
+
else
|
|
754
|
+
this.drawGridLines(plotLeft, plotTop, plotW, plotH, maxVal, opts);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
568
757
|
this.drawAxes(plotLeft, plotTop, plotRight, plotBottom, opts);
|
|
758
|
+
}
|
|
569
759
|
const nGroups = data.labels.length;
|
|
570
760
|
const groupWidth = plotW / Math.max(nGroups, 1);
|
|
571
761
|
const barW = groupWidth * opts.barWidth;
|
|
762
|
+
const band = plotH / Math.max(nD, 1);
|
|
572
763
|
const labelStep = this.getLabelStep(nGroups, plotW, opts.fontSize);
|
|
573
764
|
const labelAlpha = Math.min(1, progress * 2.5);
|
|
574
765
|
for (let gi = 0; gi < nGroups; gi++) {
|
|
575
766
|
const barX = plotLeft + gi * groupWidth + (groupWidth - barW) / 2;
|
|
576
767
|
let currentY = plotBottom;
|
|
577
|
-
for (let di = 0; di <
|
|
768
|
+
for (let di = 0; di < nD; di++) {
|
|
578
769
|
const value = data.datasets[di].data[gi] ?? 0;
|
|
579
|
-
const segH = (
|
|
770
|
+
const segH = (usePerSeries
|
|
771
|
+
? (value / (seriesMaxes[di] || 1)) * band
|
|
772
|
+
: (value / maxVal) * plotH) * progress;
|
|
580
773
|
const segY = currentY - segH;
|
|
581
774
|
ctx.fillStyle = data.datasets[di].color ?? APEX_COLORS[di % APEX_COLORS.length];
|
|
582
775
|
ctx.beginPath();
|
|
583
|
-
if (di ===
|
|
776
|
+
if (di === nD - 1) {
|
|
584
777
|
ctx.roundRect(barX, segY, Math.max(barW, 1), Math.max(segH, 0), [3, 3, 0, 0]);
|
|
585
778
|
}
|
|
586
779
|
else {
|
|
@@ -607,22 +800,30 @@ export class ChartRenderer {
|
|
|
607
800
|
this.drawGridLines(plotLeft, plotTop, plotW, plotH, 100, opts);
|
|
608
801
|
else
|
|
609
802
|
this.drawAxes(plotLeft, plotTop, plotRight, plotBottom, opts);
|
|
803
|
+
// Weight each series by its own maximum so a small-scale series keeps a
|
|
804
|
+
// visible share of the 100% bar instead of rounding to ~0%.
|
|
805
|
+
const nD = data.datasets.length;
|
|
806
|
+
const usePerSeries = nD > 1;
|
|
807
|
+
const seriesMaxes = data.datasets.map((ds) => this.niceMax(Math.max(...ds.data, 0)));
|
|
610
808
|
const nGroups = data.labels.length;
|
|
611
809
|
const groupWidth = plotW / Math.max(nGroups, 1);
|
|
612
810
|
const barW = groupWidth * opts.barWidth;
|
|
613
811
|
const labelStep = this.getLabelStep(nGroups, plotW, opts.fontSize);
|
|
614
812
|
const labelAlpha = Math.min(1, progress * 2.5);
|
|
615
813
|
for (let gi = 0; gi < nGroups; gi++) {
|
|
616
|
-
const
|
|
814
|
+
const shares = data.datasets.map((ds, di) => {
|
|
815
|
+
const value = ds.data[gi] ?? 0;
|
|
816
|
+
return usePerSeries ? value / (seriesMaxes[di] || 1) : value;
|
|
817
|
+
});
|
|
818
|
+
const total = shares.reduce((sum, s) => sum + s, 0) || 1;
|
|
617
819
|
const barX = plotLeft + gi * groupWidth + (groupWidth - barW) / 2;
|
|
618
820
|
let currentY = plotBottom;
|
|
619
|
-
for (let di = 0; di <
|
|
620
|
-
const
|
|
621
|
-
const segH = (value / total) * plotH * progress;
|
|
821
|
+
for (let di = 0; di < nD; di++) {
|
|
822
|
+
const segH = (shares[di] / total) * plotH * progress;
|
|
622
823
|
const segY = currentY - segH;
|
|
623
824
|
ctx.fillStyle = data.datasets[di].color ?? APEX_COLORS[di % APEX_COLORS.length];
|
|
624
825
|
ctx.beginPath();
|
|
625
|
-
if (di ===
|
|
826
|
+
if (di === nD - 1) {
|
|
626
827
|
ctx.roundRect(barX, segY, Math.max(barW, 1), Math.max(segH, 0), [3, 3, 0, 0]);
|
|
627
828
|
}
|
|
628
829
|
else {
|
|
@@ -645,11 +846,11 @@ export class ChartRenderer {
|
|
|
645
846
|
drawBarGrouped(data, opts, progress) {
|
|
646
847
|
const { ctx } = this;
|
|
647
848
|
const legendH = opts.showLegend && data.datasets.length > 1 ? 40 : 0;
|
|
648
|
-
const labelAreaW = 80;
|
|
849
|
+
const labelAreaW = opts.compact ? opts.padding : 80;
|
|
649
850
|
const plotLeft = labelAreaW;
|
|
650
|
-
const plotTop = 10;
|
|
651
|
-
const plotRight = opts.width - 20;
|
|
652
|
-
const plotBottom = opts.height - legendH - 30;
|
|
851
|
+
const plotTop = (opts.compact ? opts.padding : 10) + this.titleBandHeight(opts);
|
|
852
|
+
const plotRight = opts.width - (opts.compact ? opts.padding : 20);
|
|
853
|
+
const plotBottom = opts.height - legendH - (opts.compact ? opts.padding : 30);
|
|
653
854
|
const plotW = plotRight - plotLeft;
|
|
654
855
|
const plotH = plotBottom - plotTop;
|
|
655
856
|
const nDatasets = data.datasets.length;
|
|
@@ -723,13 +924,16 @@ export class ChartRenderer {
|
|
|
723
924
|
drawBarStacked(data, opts, progress) {
|
|
724
925
|
const { ctx } = this;
|
|
725
926
|
const legendH = opts.showLegend && data.datasets.length > 1 ? 40 : 0;
|
|
726
|
-
const labelAreaW = 80;
|
|
927
|
+
const labelAreaW = opts.compact ? opts.padding : 80;
|
|
727
928
|
const plotLeft = labelAreaW;
|
|
728
|
-
const plotTop = 10;
|
|
729
|
-
const plotRight = opts.width - 20;
|
|
730
|
-
const plotBottom = opts.height - legendH - 30;
|
|
929
|
+
const plotTop = (opts.compact ? opts.padding : 10) + this.titleBandHeight(opts);
|
|
930
|
+
const plotRight = opts.width - (opts.compact ? opts.padding : 20);
|
|
931
|
+
const plotBottom = opts.height - legendH - (opts.compact ? opts.padding : 30);
|
|
731
932
|
const plotW = plotRight - plotLeft;
|
|
732
933
|
const plotH = plotBottom - plotTop;
|
|
934
|
+
const nD = data.datasets.length;
|
|
935
|
+
const usePerSeries = nD > 1;
|
|
936
|
+
const seriesMaxes = data.datasets.map((ds) => this.niceMax(Math.max(...ds.data, 0)));
|
|
733
937
|
const stackTotals = data.labels.map((_, gi) => data.datasets.reduce((sum, ds) => sum + (ds.data[gi] ?? 0), 0));
|
|
734
938
|
const rawMax = Math.max(...stackTotals, 0);
|
|
735
939
|
const maxVal = this.niceMax(rawMax);
|
|
@@ -748,7 +952,9 @@ export class ChartRenderer {
|
|
|
748
952
|
ctx.fillStyle = opts.textColor;
|
|
749
953
|
ctx.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
|
750
954
|
ctx.textAlign = 'center';
|
|
751
|
-
|
|
955
|
+
// Per-series normalization uses a relative (0–100%) axis, like grouped.
|
|
956
|
+
const xLabel = usePerSeries ? `${Math.round((100 * i) / steps)}%` : this.formatNum((maxVal * i) / steps);
|
|
957
|
+
ctx.fillText(xLabel, x, plotBottom + 14);
|
|
752
958
|
}
|
|
753
959
|
ctx.setLineDash([]);
|
|
754
960
|
ctx.strokeStyle = opts.gridColor;
|
|
@@ -768,16 +974,19 @@ export class ChartRenderer {
|
|
|
768
974
|
const nGroups = data.labels.length;
|
|
769
975
|
const groupH = plotH / Math.max(nGroups, 1);
|
|
770
976
|
const barH = groupH * opts.barWidth;
|
|
977
|
+
const band = plotW / Math.max(nD, 1);
|
|
771
978
|
const labelStep = this.getLabelStep(nGroups, plotH, opts.fontSize);
|
|
772
979
|
for (let gi = 0; gi < nGroups; gi++) {
|
|
773
980
|
const barY = plotTop + gi * groupH + (groupH - barH) / 2;
|
|
774
981
|
let currentX = plotLeft;
|
|
775
|
-
for (let di = 0; di <
|
|
982
|
+
for (let di = 0; di < nD; di++) {
|
|
776
983
|
const value = data.datasets[di].data[gi] ?? 0;
|
|
777
|
-
const segW = (
|
|
984
|
+
const segW = (usePerSeries
|
|
985
|
+
? (value / (seriesMaxes[di] || 1)) * band
|
|
986
|
+
: (value / maxVal) * plotW) * progress;
|
|
778
987
|
ctx.fillStyle = data.datasets[di].color ?? APEX_COLORS[di % APEX_COLORS.length];
|
|
779
988
|
ctx.beginPath();
|
|
780
|
-
if (di ===
|
|
989
|
+
if (di === nD - 1) {
|
|
781
990
|
ctx.roundRect(currentX, barY, Math.max(segW, 0), Math.max(barH, 1), [0, 3, 3, 0]);
|
|
782
991
|
}
|
|
783
992
|
else {
|
|
@@ -800,11 +1009,11 @@ export class ChartRenderer {
|
|
|
800
1009
|
drawBar100Stacked(data, opts, progress) {
|
|
801
1010
|
const { ctx } = this;
|
|
802
1011
|
const legendH = opts.showLegend && data.datasets.length > 1 ? 40 : 0;
|
|
803
|
-
const labelAreaW = 80;
|
|
1012
|
+
const labelAreaW = opts.compact ? opts.padding : 80;
|
|
804
1013
|
const plotLeft = labelAreaW;
|
|
805
|
-
const plotTop = 10;
|
|
806
|
-
const plotRight = opts.width - 20;
|
|
807
|
-
const plotBottom = opts.height - legendH - 30;
|
|
1014
|
+
const plotTop = (opts.compact ? opts.padding : 10) + this.titleBandHeight(opts);
|
|
1015
|
+
const plotRight = opts.width - (opts.compact ? opts.padding : 20);
|
|
1016
|
+
const plotBottom = opts.height - legendH - (opts.compact ? opts.padding : 30);
|
|
808
1017
|
const plotW = plotRight - plotLeft;
|
|
809
1018
|
const plotH = plotBottom - plotTop;
|
|
810
1019
|
const labelAlpha = Math.min(1, progress * 2.5);
|
|
@@ -843,13 +1052,21 @@ export class ChartRenderer {
|
|
|
843
1052
|
const groupH = plotH / Math.max(nGroups, 1);
|
|
844
1053
|
const barH = groupH * opts.barWidth;
|
|
845
1054
|
const labelStep = this.getLabelStep(nGroups, plotH, opts.fontSize);
|
|
1055
|
+
// Weight each series by its own maximum so a small-scale series keeps a
|
|
1056
|
+
// visible share of the 100% bar instead of rounding to ~0%.
|
|
1057
|
+
const nD = data.datasets.length;
|
|
1058
|
+
const usePerSeries = nD > 1;
|
|
1059
|
+
const seriesMaxes = data.datasets.map((ds) => this.niceMax(Math.max(...ds.data, 0)));
|
|
846
1060
|
for (let gi = 0; gi < nGroups; gi++) {
|
|
847
|
-
const
|
|
1061
|
+
const shares = data.datasets.map((ds, di) => {
|
|
1062
|
+
const value = ds.data[gi] ?? 0;
|
|
1063
|
+
return usePerSeries ? value / (seriesMaxes[di] || 1) : value;
|
|
1064
|
+
});
|
|
1065
|
+
const total = shares.reduce((sum, s) => sum + s, 0) || 1;
|
|
848
1066
|
const barY = plotTop + gi * groupH + (groupH - barH) / 2;
|
|
849
1067
|
let currentX = plotLeft;
|
|
850
|
-
for (let di = 0; di <
|
|
851
|
-
const
|
|
852
|
-
const segW = (value / total) * plotW * progress;
|
|
1068
|
+
for (let di = 0; di < nD; di++) {
|
|
1069
|
+
const segW = (shares[di] / total) * plotW * progress;
|
|
853
1070
|
ctx.fillStyle = data.datasets[di].color ?? APEX_COLORS[di % APEX_COLORS.length];
|
|
854
1071
|
ctx.beginPath();
|
|
855
1072
|
if (di === data.datasets.length - 1) {
|
|
@@ -892,7 +1109,7 @@ export class ChartRenderer {
|
|
|
892
1109
|
ctx.rect(plotLeft, plotTop, plotW, plotH + 1);
|
|
893
1110
|
ctx.clip();
|
|
894
1111
|
ctx.strokeStyle = ds.color ?? APEX_COLORS[0];
|
|
895
|
-
ctx.lineWidth = opts.lineWidth;
|
|
1112
|
+
ctx.lineWidth = opts.strokeWidth > 0 ? opts.strokeWidth : opts.lineWidth;
|
|
896
1113
|
ctx.lineCap = 'round';
|
|
897
1114
|
ctx.lineJoin = 'round';
|
|
898
1115
|
ctx.beginPath();
|
|
@@ -960,10 +1177,10 @@ export class ChartRenderer {
|
|
|
960
1177
|
ctx.lineTo(getX(Math.min(visibleCount, nPoints) - 1), plotBottom);
|
|
961
1178
|
ctx.lineTo(getX(0), plotBottom);
|
|
962
1179
|
ctx.closePath();
|
|
963
|
-
ctx.fillStyle = color +
|
|
1180
|
+
ctx.fillStyle = color + this.alphaHex(opts.fillOpacity > 0 ? opts.fillOpacity : 0.15);
|
|
964
1181
|
ctx.fill();
|
|
965
1182
|
ctx.strokeStyle = color;
|
|
966
|
-
ctx.lineWidth = opts.lineWidth;
|
|
1183
|
+
ctx.lineWidth = opts.strokeWidth > 0 ? opts.strokeWidth : opts.lineWidth;
|
|
967
1184
|
ctx.lineCap = 'round';
|
|
968
1185
|
ctx.lineJoin = 'round';
|
|
969
1186
|
ctx.beginPath();
|
|
@@ -994,15 +1211,15 @@ export class ChartRenderer {
|
|
|
994
1211
|
const { ctx } = this;
|
|
995
1212
|
const legendH = opts.showLegend ? 40 : 0;
|
|
996
1213
|
const cx = opts.width / 2;
|
|
997
|
-
const cy = (opts.height - legendH) / 2;
|
|
998
|
-
const radius = Math.min(opts.width, opts.height - legendH) / 2 - 20;
|
|
1214
|
+
const cy = this.titleBandHeight(opts) + (opts.height - legendH - this.titleBandHeight(opts)) / 2;
|
|
1215
|
+
const radius = Math.min(opts.width, opts.height - legendH - this.titleBandHeight(opts)) / 2 - (opts.compact ? opts.padding : 20);
|
|
999
1216
|
const innerRadius = isDoughnut ? radius * 0.5 : 0;
|
|
1000
1217
|
const values = data.datasets[0]?.data ?? [];
|
|
1001
1218
|
const total = values.reduce((a, b) => a + b, 0) || 1;
|
|
1002
1219
|
let startAngle = -Math.PI / 2;
|
|
1003
1220
|
for (let i = 0; i < values.length; i++) {
|
|
1004
1221
|
const sliceAngle = (values[i] / total) * Math.PI * 2 * progress;
|
|
1005
|
-
const color =
|
|
1222
|
+
const color = this.paletteColor(i);
|
|
1006
1223
|
ctx.beginPath();
|
|
1007
1224
|
ctx.moveTo(cx, cy);
|
|
1008
1225
|
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
|
|
@@ -1090,8 +1307,8 @@ export class ChartRenderer {
|
|
|
1090
1307
|
const { ctx } = this;
|
|
1091
1308
|
const legendH = opts.showLegend && data.datasets.length > 1 ? 40 : 0;
|
|
1092
1309
|
const cx = opts.width / 2;
|
|
1093
|
-
const cy = (opts.height - legendH) / 2;
|
|
1094
|
-
const radius = Math.min(opts.width, opts.height - legendH) / 2 - 30;
|
|
1310
|
+
const cy = this.titleBandHeight(opts) + (opts.height - legendH - this.titleBandHeight(opts)) / 2;
|
|
1311
|
+
const radius = Math.min(opts.width, opts.height - legendH - this.titleBandHeight(opts)) / 2 - (opts.compact ? opts.padding : 30);
|
|
1095
1312
|
const labels = data.labels;
|
|
1096
1313
|
const nSpokes = labels.length;
|
|
1097
1314
|
if (nSpokes < 3)
|
|
@@ -1164,12 +1381,12 @@ export class ChartRenderer {
|
|
|
1164
1381
|
const maxVal = Math.max(...values, 0) || 1;
|
|
1165
1382
|
const n = labels.length;
|
|
1166
1383
|
const legendH = opts.showLegend && data.datasets.length > 1 ? 40 : 0;
|
|
1167
|
-
const labelAreaW = 90;
|
|
1384
|
+
const labelAreaW = opts.compact ? opts.padding : 90;
|
|
1168
1385
|
const plotLeft = labelAreaW;
|
|
1169
|
-
const plotRight = opts.width - 20;
|
|
1386
|
+
const plotRight = opts.width - (opts.compact ? opts.padding : 20);
|
|
1170
1387
|
const plotWidth = plotRight - plotLeft;
|
|
1171
|
-
const plotTop = 10;
|
|
1172
|
-
const plotBottom = opts.height - legendH - 10;
|
|
1388
|
+
const plotTop = (opts.compact ? opts.padding : 10) + this.titleBandHeight(opts);
|
|
1389
|
+
const plotBottom = opts.height - legendH - (opts.compact ? opts.padding : 10);
|
|
1173
1390
|
const plotH = plotBottom - plotTop;
|
|
1174
1391
|
const itemH = Math.max((plotH / Math.max(n, 1)) - 2, 1);
|
|
1175
1392
|
const cx = plotLeft + plotWidth / 2;
|
|
@@ -1179,7 +1396,7 @@ export class ChartRenderer {
|
|
|
1179
1396
|
const barW = pct * plotWidth * progress;
|
|
1180
1397
|
const barX = cx - barW / 2;
|
|
1181
1398
|
const barY = plotTop + i * (itemH + 2);
|
|
1182
|
-
const color =
|
|
1399
|
+
const color = this.paletteColor(i);
|
|
1183
1400
|
ctx.fillStyle = color;
|
|
1184
1401
|
ctx.beginPath();
|
|
1185
1402
|
ctx.roundRect(barX, barY, Math.max(barW, 0), itemH, 3);
|
|
@@ -1210,10 +1427,12 @@ export class ChartRenderer {
|
|
|
1210
1427
|
ctx.moveTo(left, y);
|
|
1211
1428
|
ctx.lineTo(left + width, y);
|
|
1212
1429
|
ctx.stroke();
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1430
|
+
if (opts.showYLabels) {
|
|
1431
|
+
ctx.fillStyle = opts.textColor;
|
|
1432
|
+
ctx.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
|
1433
|
+
ctx.textAlign = 'right';
|
|
1434
|
+
ctx.fillText(this.formatNum((maxVal * i) / steps), left - 6, y + 4);
|
|
1435
|
+
}
|
|
1217
1436
|
}
|
|
1218
1437
|
ctx.setLineDash([]);
|
|
1219
1438
|
ctx.strokeStyle = opts.gridColor;
|
|
@@ -1243,10 +1462,12 @@ export class ChartRenderer {
|
|
|
1243
1462
|
ctx.moveTo(left, y);
|
|
1244
1463
|
ctx.lineTo(left + width, y);
|
|
1245
1464
|
ctx.stroke();
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1465
|
+
if (opts.showYLabels) {
|
|
1466
|
+
ctx.fillStyle = opts.textColor;
|
|
1467
|
+
ctx.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
|
1468
|
+
ctx.textAlign = 'right';
|
|
1469
|
+
ctx.fillText(`${Math.round((100 * i) / steps)}%`, left - 6, y + 4);
|
|
1470
|
+
}
|
|
1250
1471
|
}
|
|
1251
1472
|
ctx.setLineDash([]);
|
|
1252
1473
|
ctx.strokeStyle = opts.gridColor;
|
|
@@ -1262,8 +1483,18 @@ export class ChartRenderer {
|
|
|
1262
1483
|
}
|
|
1263
1484
|
// ── Legend ──────────────────────────────────────────────────────────────────
|
|
1264
1485
|
drawLegend(data, opts) {
|
|
1486
|
+
const position = this.effectiveLegendPosition(opts);
|
|
1487
|
+
if (position === 'left' || position === 'right') {
|
|
1488
|
+
this.drawLegendVertical(data, opts, position);
|
|
1489
|
+
}
|
|
1490
|
+
else {
|
|
1491
|
+
this.drawLegendHorizontal(data, opts, position);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
/** Horizontal legend row along the top or bottom edge. */
|
|
1495
|
+
drawLegendHorizontal(data, opts, position) {
|
|
1265
1496
|
const { ctx } = this;
|
|
1266
|
-
const y = opts.height -
|
|
1497
|
+
const y = position === 'top' ? this.titleBandHeight(opts) + 22 : opts.height - 16;
|
|
1267
1498
|
const totalItems = data.datasets.length;
|
|
1268
1499
|
const itemWidth = opts.width / totalItems;
|
|
1269
1500
|
ctx.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
|
@@ -1271,8 +1502,29 @@ export class ChartRenderer {
|
|
|
1271
1502
|
ctx.textBaseline = 'middle';
|
|
1272
1503
|
for (let i = 0; i < totalItems; i++) {
|
|
1273
1504
|
const x = i * itemWidth + (itemWidth - 80) / 2;
|
|
1274
|
-
|
|
1275
|
-
ctx.
|
|
1505
|
+
ctx.fillStyle = data.datasets[i].color ?? this.paletteColor(i);
|
|
1506
|
+
ctx.beginPath();
|
|
1507
|
+
ctx.roundRect(x, y - 5, 12, 10, 2);
|
|
1508
|
+
ctx.fill();
|
|
1509
|
+
ctx.fillStyle = opts.textColor;
|
|
1510
|
+
ctx.fillText(this.truncate(data.datasets[i].label, 14), x + 16, y);
|
|
1511
|
+
}
|
|
1512
|
+
ctx.textBaseline = 'alphabetic';
|
|
1513
|
+
}
|
|
1514
|
+
/** Vertical legend stack along the left or right edge (cartesian charts only). */
|
|
1515
|
+
drawLegendVertical(data, opts, position) {
|
|
1516
|
+
const { ctx } = this;
|
|
1517
|
+
const rowH = opts.fontSize + 10;
|
|
1518
|
+
const totalItems = data.datasets.length;
|
|
1519
|
+
const blockH = totalItems * rowH;
|
|
1520
|
+
const startY = Math.max(this.titleBandHeight(opts) + 24, (opts.height - blockH) / 2);
|
|
1521
|
+
const x = position === 'left' ? 12 : opts.width - ChartRenderer.LEGEND_SIDE + 12;
|
|
1522
|
+
ctx.font = `${opts.fontSize}px ${opts.fontFamily}`;
|
|
1523
|
+
ctx.textAlign = 'left';
|
|
1524
|
+
ctx.textBaseline = 'middle';
|
|
1525
|
+
for (let i = 0; i < totalItems; i++) {
|
|
1526
|
+
const y = startY + i * rowH + rowH / 2;
|
|
1527
|
+
ctx.fillStyle = data.datasets[i].color ?? this.paletteColor(i);
|
|
1276
1528
|
ctx.beginPath();
|
|
1277
1529
|
ctx.roundRect(x, y - 5, 12, 10, 2);
|
|
1278
1530
|
ctx.fill();
|
|
@@ -1317,6 +1569,15 @@ export class ChartRenderer {
|
|
|
1317
1569
|
truncate(str, max) {
|
|
1318
1570
|
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
|
1319
1571
|
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Converts a 0–1 opacity into a two-digit hex alpha suffix for `#rrggbb` +
|
|
1574
|
+
* `aa` color strings. Clamped to the valid range so out-of-bounds model values
|
|
1575
|
+
* never produce a malformed color.
|
|
1576
|
+
*/
|
|
1577
|
+
alphaHex(opacity) {
|
|
1578
|
+
const clamped = Math.max(0, Math.min(1, opacity));
|
|
1579
|
+
return Math.round(clamped * 255).toString(16).padStart(2, '0');
|
|
1580
|
+
}
|
|
1320
1581
|
formatNum(n) {
|
|
1321
1582
|
if (n >= 1e6)
|
|
1322
1583
|
return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
|
|
@@ -1327,4 +1588,6 @@ export class ChartRenderer {
|
|
|
1327
1588
|
return String(Math.round(n));
|
|
1328
1589
|
}
|
|
1329
1590
|
}
|
|
1591
|
+
ChartRenderer.LEGEND_BAND = 40;
|
|
1592
|
+
ChartRenderer.LEGEND_SIDE = 120;
|
|
1330
1593
|
//# sourceMappingURL=chart-renderer.js.map
|