@internetstiftelsen/charts 0.9.2 → 0.10.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 +136 -2
- package/dist/bar.d.ts +3 -1
- package/dist/bar.js +167 -327
- package/dist/base-chart.d.ts +16 -1
- package/dist/base-chart.js +89 -30
- package/dist/chart-group.d.ts +121 -0
- package/dist/chart-group.js +1097 -0
- package/dist/chart-interface.d.ts +1 -1
- package/dist/donut-chart.js +1 -1
- package/dist/gauge-chart.js +1 -1
- package/dist/legend-state.d.ts +19 -0
- package/dist/legend-state.js +81 -0
- package/dist/legend.d.ts +5 -2
- package/dist/legend.js +35 -29
- package/dist/pie-chart.js +1 -1
- package/dist/scatter.d.ts +16 -0
- package/dist/scatter.js +163 -0
- package/dist/tooltip.d.ts +2 -1
- package/dist/tooltip.js +3 -3
- package/dist/types.d.ts +16 -0
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +19 -0
- package/dist/xy-chart.d.ts +16 -1
- package/dist/xy-chart.js +317 -102
- package/docs/chart-group.md +213 -0
- package/docs/components.md +308 -0
- package/docs/donut-chart.md +193 -0
- package/docs/gauge-chart.md +175 -0
- package/docs/getting-started.md +311 -0
- package/docs/pie-chart.md +123 -0
- package/docs/theming.md +162 -0
- package/docs/word-cloud-chart.md +98 -0
- package/docs/xy-chart.md +502 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
import { create } from 'd3';
|
|
2
|
+
import { exportRasterBlob } from './export-image.js';
|
|
3
|
+
import { exportPDFBlob } from './export-pdf.js';
|
|
4
|
+
import { LegendStateController } from './legend-state.js';
|
|
5
|
+
import { DEFAULT_CHART_HEIGHT, DEFAULT_CHART_WIDTH, defaultTheme, } from './theme.js';
|
|
6
|
+
import { ChartValidator } from './validation.js';
|
|
7
|
+
import { mergeDeep } from './utils.js';
|
|
8
|
+
import { XYChart } from './xy-chart.js';
|
|
9
|
+
function normalizePositiveInteger(value, name) {
|
|
10
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
11
|
+
throw new Error(`${name} must be a positive integer`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function normalizeFiniteNumber(value, name) {
|
|
16
|
+
if (!Number.isFinite(value)) {
|
|
17
|
+
throw new Error(`${name} must be a finite number`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function normalizeNonNegativeNumber(value, name) {
|
|
22
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
23
|
+
throw new Error(`${name} must be a non-negative finite number`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function normalizeOptionalHeight(value, name) {
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
32
|
+
throw new Error(`${name} must be a positive finite number`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function normalizeOptionalItemHeight(value) {
|
|
37
|
+
return normalizeOptionalHeight(value, 'ChartGroup item height');
|
|
38
|
+
}
|
|
39
|
+
function normalizeBreakpointRange(definition) {
|
|
40
|
+
if (typeof definition === 'number') {
|
|
41
|
+
return Number.isFinite(definition) ? { minWidth: definition } : null;
|
|
42
|
+
}
|
|
43
|
+
if (!definition || typeof definition !== 'object') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const minWidth = Number.isFinite(definition.minWidth)
|
|
47
|
+
? definition.minWidth
|
|
48
|
+
: undefined;
|
|
49
|
+
const maxWidth = Number.isFinite(definition.maxWidth)
|
|
50
|
+
? definition.maxWidth
|
|
51
|
+
: undefined;
|
|
52
|
+
if (minWidth === undefined && maxWidth === undefined) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
minWidth,
|
|
57
|
+
maxWidth,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function normalizeChartGroupBreakpoint(definition) {
|
|
61
|
+
const range = normalizeBreakpointRange(definition);
|
|
62
|
+
if (!range) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (typeof definition === 'number') {
|
|
66
|
+
return range;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...definition,
|
|
70
|
+
...range,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function normalizeChartGroupItemBreakpoint(definition) {
|
|
74
|
+
const range = normalizeBreakpointRange(definition);
|
|
75
|
+
if (!range) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (typeof definition === 'number') {
|
|
79
|
+
return range;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
...definition,
|
|
83
|
+
...range,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function matchesBreakpoint(width, config) {
|
|
87
|
+
const matchesMinWidth = config.minWidth === undefined || width >= config.minWidth;
|
|
88
|
+
const matchesMaxWidth = config.maxWidth === undefined || width <= config.maxWidth;
|
|
89
|
+
return matchesMinWidth && matchesMaxWidth;
|
|
90
|
+
}
|
|
91
|
+
export class ChartGroup {
|
|
92
|
+
constructor(config) {
|
|
93
|
+
Object.defineProperty(this, "cols", {
|
|
94
|
+
enumerable: true,
|
|
95
|
+
configurable: true,
|
|
96
|
+
writable: true,
|
|
97
|
+
value: void 0
|
|
98
|
+
});
|
|
99
|
+
Object.defineProperty(this, "gap", {
|
|
100
|
+
enumerable: true,
|
|
101
|
+
configurable: true,
|
|
102
|
+
writable: true,
|
|
103
|
+
value: void 0
|
|
104
|
+
});
|
|
105
|
+
Object.defineProperty(this, "syncY", {
|
|
106
|
+
enumerable: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
writable: true,
|
|
109
|
+
value: void 0
|
|
110
|
+
});
|
|
111
|
+
Object.defineProperty(this, "configuredHeight", {
|
|
112
|
+
enumerable: true,
|
|
113
|
+
configurable: true,
|
|
114
|
+
writable: true,
|
|
115
|
+
value: void 0
|
|
116
|
+
});
|
|
117
|
+
Object.defineProperty(this, "chartHeight", {
|
|
118
|
+
enumerable: true,
|
|
119
|
+
configurable: true,
|
|
120
|
+
writable: true,
|
|
121
|
+
value: void 0
|
|
122
|
+
});
|
|
123
|
+
Object.defineProperty(this, "theme", {
|
|
124
|
+
enumerable: true,
|
|
125
|
+
configurable: true,
|
|
126
|
+
writable: true,
|
|
127
|
+
value: void 0
|
|
128
|
+
});
|
|
129
|
+
Object.defineProperty(this, "responsiveConfig", {
|
|
130
|
+
enumerable: true,
|
|
131
|
+
configurable: true,
|
|
132
|
+
writable: true,
|
|
133
|
+
value: void 0
|
|
134
|
+
});
|
|
135
|
+
Object.defineProperty(this, "charts", {
|
|
136
|
+
enumerable: true,
|
|
137
|
+
configurable: true,
|
|
138
|
+
writable: true,
|
|
139
|
+
value: []
|
|
140
|
+
});
|
|
141
|
+
Object.defineProperty(this, "legendState", {
|
|
142
|
+
enumerable: true,
|
|
143
|
+
configurable: true,
|
|
144
|
+
writable: true,
|
|
145
|
+
value: new LegendStateController()
|
|
146
|
+
});
|
|
147
|
+
Object.defineProperty(this, "renderCallbacks", {
|
|
148
|
+
enumerable: true,
|
|
149
|
+
configurable: true,
|
|
150
|
+
writable: true,
|
|
151
|
+
value: new Map()
|
|
152
|
+
});
|
|
153
|
+
Object.defineProperty(this, "warnedWidthCharts", {
|
|
154
|
+
enumerable: true,
|
|
155
|
+
configurable: true,
|
|
156
|
+
writable: true,
|
|
157
|
+
value: new WeakSet()
|
|
158
|
+
});
|
|
159
|
+
Object.defineProperty(this, "warnedColorConflicts", {
|
|
160
|
+
enumerable: true,
|
|
161
|
+
configurable: true,
|
|
162
|
+
writable: true,
|
|
163
|
+
value: new Set()
|
|
164
|
+
});
|
|
165
|
+
Object.defineProperty(this, "container", {
|
|
166
|
+
enumerable: true,
|
|
167
|
+
configurable: true,
|
|
168
|
+
writable: true,
|
|
169
|
+
value: null
|
|
170
|
+
});
|
|
171
|
+
Object.defineProperty(this, "legend", {
|
|
172
|
+
enumerable: true,
|
|
173
|
+
configurable: true,
|
|
174
|
+
writable: true,
|
|
175
|
+
value: null
|
|
176
|
+
});
|
|
177
|
+
Object.defineProperty(this, "title", {
|
|
178
|
+
enumerable: true,
|
|
179
|
+
configurable: true,
|
|
180
|
+
writable: true,
|
|
181
|
+
value: null
|
|
182
|
+
});
|
|
183
|
+
Object.defineProperty(this, "resizeObserver", {
|
|
184
|
+
enumerable: true,
|
|
185
|
+
configurable: true,
|
|
186
|
+
writable: true,
|
|
187
|
+
value: null
|
|
188
|
+
});
|
|
189
|
+
Object.defineProperty(this, "readyPromise", {
|
|
190
|
+
enumerable: true,
|
|
191
|
+
configurable: true,
|
|
192
|
+
writable: true,
|
|
193
|
+
value: Promise.resolve()
|
|
194
|
+
});
|
|
195
|
+
Object.defineProperty(this, "childLegendSnapshot", {
|
|
196
|
+
enumerable: true,
|
|
197
|
+
configurable: true,
|
|
198
|
+
writable: true,
|
|
199
|
+
value: ''
|
|
200
|
+
});
|
|
201
|
+
Object.defineProperty(this, "childYDomainSnapshot", {
|
|
202
|
+
enumerable: true,
|
|
203
|
+
configurable: true,
|
|
204
|
+
writable: true,
|
|
205
|
+
value: ''
|
|
206
|
+
});
|
|
207
|
+
Object.defineProperty(this, "isRendering", {
|
|
208
|
+
enumerable: true,
|
|
209
|
+
configurable: true,
|
|
210
|
+
writable: true,
|
|
211
|
+
value: false
|
|
212
|
+
});
|
|
213
|
+
Object.defineProperty(this, "isSyncingLegend", {
|
|
214
|
+
enumerable: true,
|
|
215
|
+
configurable: true,
|
|
216
|
+
writable: true,
|
|
217
|
+
value: false
|
|
218
|
+
});
|
|
219
|
+
Object.defineProperty(this, "hasWarnedIncompatibleYScaleTypes", {
|
|
220
|
+
enumerable: true,
|
|
221
|
+
configurable: true,
|
|
222
|
+
writable: true,
|
|
223
|
+
value: false
|
|
224
|
+
});
|
|
225
|
+
this.cols = normalizePositiveInteger(config.cols, 'ChartGroup cols');
|
|
226
|
+
this.gap = normalizeNonNegativeNumber(config.gap ?? 20, 'ChartGroup gap');
|
|
227
|
+
this.syncY = config.syncY ?? false;
|
|
228
|
+
this.configuredHeight = normalizeOptionalHeight(config.height, 'ChartGroup height');
|
|
229
|
+
this.chartHeight = normalizeNonNegativeNumber(config.chartHeight ?? DEFAULT_CHART_HEIGHT, 'ChartGroup chartHeight');
|
|
230
|
+
this.theme = mergeDeep(defaultTheme, config.theme);
|
|
231
|
+
this.responsiveConfig = config.responsive;
|
|
232
|
+
this.validateResponsiveConfig(this.responsiveConfig);
|
|
233
|
+
this.legendState.subscribe(() => {
|
|
234
|
+
if (this.isSyncingLegend) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.applyLegendStateToChildren();
|
|
238
|
+
this.renderLegendIntoContainer();
|
|
239
|
+
this.childLegendSnapshot = this.serializeChildLegendState(this.resolveCurrentWidth());
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
addChart(chart, options) {
|
|
243
|
+
const span = normalizePositiveInteger(options?.span ?? 1, 'ChartGroup span');
|
|
244
|
+
const height = normalizeOptionalItemHeight(options?.height);
|
|
245
|
+
const hidden = options?.hidden ?? false;
|
|
246
|
+
const order = normalizeFiniteNumber(options?.order ?? this.charts.length, 'ChartGroup order');
|
|
247
|
+
this.warnOnExplicitChildWidth(chart);
|
|
248
|
+
chart.setLegendModeOverride('hidden', false);
|
|
249
|
+
this.validateItemResponsiveConfig(options?.responsive);
|
|
250
|
+
this.charts.push({
|
|
251
|
+
chart,
|
|
252
|
+
span,
|
|
253
|
+
height,
|
|
254
|
+
hidden,
|
|
255
|
+
order,
|
|
256
|
+
responsive: options?.responsive,
|
|
257
|
+
});
|
|
258
|
+
this.bindChartRenderCallback(chart);
|
|
259
|
+
if (this.container) {
|
|
260
|
+
this.refresh();
|
|
261
|
+
}
|
|
262
|
+
return this;
|
|
263
|
+
}
|
|
264
|
+
addChild(component) {
|
|
265
|
+
if (component.type === 'title') {
|
|
266
|
+
this.title = component;
|
|
267
|
+
if (this.container) {
|
|
268
|
+
this.refresh();
|
|
269
|
+
}
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
if (component.type === 'legend') {
|
|
273
|
+
const legend = component;
|
|
274
|
+
if (legend.mode === 'disconnected') {
|
|
275
|
+
throw new Error('ChartGroup does not support disconnected legends in v1');
|
|
276
|
+
}
|
|
277
|
+
legend.setStateController(this.legendState);
|
|
278
|
+
this.legend = legend;
|
|
279
|
+
if (this.container) {
|
|
280
|
+
this.refresh();
|
|
281
|
+
}
|
|
282
|
+
return this;
|
|
283
|
+
}
|
|
284
|
+
throw new Error('ChartGroup only supports Title and Legend via addChild()');
|
|
285
|
+
}
|
|
286
|
+
render(target) {
|
|
287
|
+
this.container = this.resolveContainer(target);
|
|
288
|
+
this.refresh();
|
|
289
|
+
this.setupResizeObserver();
|
|
290
|
+
return this.container;
|
|
291
|
+
}
|
|
292
|
+
refresh() {
|
|
293
|
+
if (!this.container) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const container = this.container;
|
|
297
|
+
const containerRect = container.getBoundingClientRect();
|
|
298
|
+
const width = this.resolveContainerWidth(container);
|
|
299
|
+
const renderedTitle = this.renderTitleSvg(width);
|
|
300
|
+
const renderedLegend = this.renderLegendSvg(width);
|
|
301
|
+
const totalHeightConstraint = this.resolveTotalHeightConstraint(containerRect);
|
|
302
|
+
const chartAreaHeight = totalHeightConstraint === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: Math.max(1, totalHeightConstraint -
|
|
305
|
+
(renderedTitle?.height ?? 0) -
|
|
306
|
+
(renderedLegend?.height ?? 0));
|
|
307
|
+
const layout = this.calculateLayout(width, chartAreaHeight);
|
|
308
|
+
const totalHeight = (renderedTitle?.height ?? 0) +
|
|
309
|
+
layout.chartHeight +
|
|
310
|
+
(renderedLegend?.height ?? 0);
|
|
311
|
+
this.isRendering = true;
|
|
312
|
+
try {
|
|
313
|
+
this.applyScaleSyncOverrides(width);
|
|
314
|
+
container.innerHTML = '';
|
|
315
|
+
const root = document.createElement('div');
|
|
316
|
+
root.className = 'chart-group';
|
|
317
|
+
root.style.width = '100%';
|
|
318
|
+
root.style.height = `${totalHeight}px`;
|
|
319
|
+
if (renderedTitle) {
|
|
320
|
+
const titleHost = document.createElement('div');
|
|
321
|
+
titleHost.className = 'chart-group__title';
|
|
322
|
+
titleHost.style.width = '100%';
|
|
323
|
+
titleHost.appendChild(renderedTitle.svg);
|
|
324
|
+
root.appendChild(titleHost);
|
|
325
|
+
}
|
|
326
|
+
const chartLayer = document.createElement('div');
|
|
327
|
+
chartLayer.className = 'chart-group__charts';
|
|
328
|
+
chartLayer.style.position = 'relative';
|
|
329
|
+
chartLayer.style.width = '100%';
|
|
330
|
+
chartLayer.style.height = `${layout.chartHeight}px`;
|
|
331
|
+
root.appendChild(chartLayer);
|
|
332
|
+
container.appendChild(root);
|
|
333
|
+
layout.items.forEach((item) => {
|
|
334
|
+
const chartHost = document.createElement('div');
|
|
335
|
+
chartHost.className = 'chart-group__item';
|
|
336
|
+
chartHost.style.position = 'absolute';
|
|
337
|
+
chartHost.style.left = `${item.x}px`;
|
|
338
|
+
chartHost.style.top = `${item.y}px`;
|
|
339
|
+
chartHost.style.width = `${item.width}px`;
|
|
340
|
+
chartHost.style.height = `${item.height}px`;
|
|
341
|
+
Object.defineProperty(chartHost, 'getBoundingClientRect', {
|
|
342
|
+
configurable: true,
|
|
343
|
+
value: () => {
|
|
344
|
+
return {
|
|
345
|
+
x: item.x,
|
|
346
|
+
y: item.y,
|
|
347
|
+
top: item.y,
|
|
348
|
+
left: item.x,
|
|
349
|
+
right: item.x + item.width,
|
|
350
|
+
bottom: item.y + item.height,
|
|
351
|
+
width: item.width,
|
|
352
|
+
height: item.height,
|
|
353
|
+
toJSON: () => ({}),
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
chartLayer.appendChild(chartHost);
|
|
358
|
+
item.chart.setLegendModeOverride('hidden', false);
|
|
359
|
+
item.chart.render(chartHost);
|
|
360
|
+
});
|
|
361
|
+
if (renderedLegend) {
|
|
362
|
+
const legendHost = document.createElement('div');
|
|
363
|
+
legendHost.className = 'chart-group__legend';
|
|
364
|
+
legendHost.style.width = '100%';
|
|
365
|
+
legendHost.appendChild(renderedLegend.svg);
|
|
366
|
+
root.appendChild(legendHost);
|
|
367
|
+
}
|
|
368
|
+
this.readyPromise = Promise.all(this.charts.map(({ chart }) => chart.whenReady())).then(() => undefined);
|
|
369
|
+
this.syncLegendStateFromChildren();
|
|
370
|
+
this.childYDomainSnapshot = this.serializeChildYDomains(width);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
this.isRendering = false;
|
|
374
|
+
}
|
|
375
|
+
return container;
|
|
376
|
+
}
|
|
377
|
+
whenReady() {
|
|
378
|
+
return this.readyPromise;
|
|
379
|
+
}
|
|
380
|
+
getLegendItems() {
|
|
381
|
+
return this.getLegendItemsForWidth(this.resolveCurrentWidth());
|
|
382
|
+
}
|
|
383
|
+
getLegendItemsForWidth(width) {
|
|
384
|
+
return this.collectMergedLegendEntries(width).map((entry) => {
|
|
385
|
+
return {
|
|
386
|
+
dataKey: entry.dataKey,
|
|
387
|
+
color: entry.color,
|
|
388
|
+
visible: this.resolveLegendVisibility(entry),
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
isLegendSeriesVisible(dataKey) {
|
|
393
|
+
return this.legendState.isSeriesVisible(dataKey);
|
|
394
|
+
}
|
|
395
|
+
setLegendSeriesVisible(dataKey, visible) {
|
|
396
|
+
this.legendState.setSeriesVisible(dataKey, visible);
|
|
397
|
+
return this;
|
|
398
|
+
}
|
|
399
|
+
toggleLegendSeries(dataKey) {
|
|
400
|
+
this.legendState.toggleSeries(dataKey);
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
setLegendVisibility(visibility) {
|
|
404
|
+
this.legendState.setVisibilityMap(visibility);
|
|
405
|
+
return this;
|
|
406
|
+
}
|
|
407
|
+
onLegendChange(callback) {
|
|
408
|
+
return this.legendState.subscribe(callback);
|
|
409
|
+
}
|
|
410
|
+
async export(format, options) {
|
|
411
|
+
if (!this.container) {
|
|
412
|
+
throw new Error('ChartGroup must be rendered before export()');
|
|
413
|
+
}
|
|
414
|
+
if (options?.height !== undefined) {
|
|
415
|
+
throw new Error('ChartGroup export height is layout-derived and cannot be overridden in v1');
|
|
416
|
+
}
|
|
417
|
+
await this.whenReady();
|
|
418
|
+
const width = options?.width ?? this.resolveContainerWidth(this.container);
|
|
419
|
+
const { svg, height } = await this.exportSVG(width, options);
|
|
420
|
+
let content = svg;
|
|
421
|
+
if (format === 'png' || format === 'jpg') {
|
|
422
|
+
content = await exportRasterBlob({
|
|
423
|
+
format,
|
|
424
|
+
svg,
|
|
425
|
+
width,
|
|
426
|
+
height,
|
|
427
|
+
pixelRatio: options?.pixelRatio ?? 1,
|
|
428
|
+
backgroundColor: options?.backgroundColor,
|
|
429
|
+
jpegQuality: options?.jpegQuality ?? 0.92,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
if (format === 'pdf') {
|
|
433
|
+
const pngBlob = await exportRasterBlob({
|
|
434
|
+
format: 'png',
|
|
435
|
+
svg,
|
|
436
|
+
width,
|
|
437
|
+
height,
|
|
438
|
+
pixelRatio: options?.pixelRatio ?? 1,
|
|
439
|
+
backgroundColor: options?.backgroundColor,
|
|
440
|
+
jpegQuality: options?.jpegQuality ?? 0.92,
|
|
441
|
+
});
|
|
442
|
+
content = await exportPDFBlob(pngBlob, {
|
|
443
|
+
width,
|
|
444
|
+
height,
|
|
445
|
+
margin: options?.pdfMargin ?? 0,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (options?.download) {
|
|
449
|
+
this.downloadContent(content, format, options);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
return content;
|
|
453
|
+
}
|
|
454
|
+
destroy() {
|
|
455
|
+
if (this.resizeObserver) {
|
|
456
|
+
this.resizeObserver.disconnect();
|
|
457
|
+
this.resizeObserver = null;
|
|
458
|
+
}
|
|
459
|
+
this.renderCallbacks.forEach((cleanup) => {
|
|
460
|
+
cleanup();
|
|
461
|
+
});
|
|
462
|
+
this.renderCallbacks.clear();
|
|
463
|
+
this.charts.forEach(({ chart }) => {
|
|
464
|
+
chart.setLegendModeOverride(null, false);
|
|
465
|
+
if (chart instanceof XYChart) {
|
|
466
|
+
chart.setScaleConfigOverride(null, false);
|
|
467
|
+
}
|
|
468
|
+
chart.destroy();
|
|
469
|
+
});
|
|
470
|
+
if (this.container) {
|
|
471
|
+
this.container.innerHTML = '';
|
|
472
|
+
}
|
|
473
|
+
this.container = null;
|
|
474
|
+
this.childLegendSnapshot = '';
|
|
475
|
+
this.childYDomainSnapshot = '';
|
|
476
|
+
}
|
|
477
|
+
bindChartRenderCallback(chart) {
|
|
478
|
+
if (this.renderCallbacks.has(chart)) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const cleanup = chart.onRender(() => {
|
|
482
|
+
if (this.isRendering || this.isSyncingLegend || !this.container) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (this.syncY) {
|
|
486
|
+
const nextYDomainSnapshot = this.serializeChildYDomains(this.resolveCurrentWidth());
|
|
487
|
+
if (nextYDomainSnapshot !== this.childYDomainSnapshot) {
|
|
488
|
+
this.refresh();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const nextSnapshot = this.serializeChildLegendState(this.resolveCurrentWidth());
|
|
493
|
+
if (nextSnapshot === this.childLegendSnapshot) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
this.refresh();
|
|
497
|
+
});
|
|
498
|
+
this.renderCallbacks.set(chart, cleanup);
|
|
499
|
+
}
|
|
500
|
+
resolveContainer(target) {
|
|
501
|
+
if (target instanceof HTMLElement) {
|
|
502
|
+
return target;
|
|
503
|
+
}
|
|
504
|
+
const container = document.querySelector(target);
|
|
505
|
+
if (!container) {
|
|
506
|
+
throw new Error(`Container "${target}" not found`);
|
|
507
|
+
}
|
|
508
|
+
if (!(container instanceof HTMLElement)) {
|
|
509
|
+
throw new Error(`Container "${target}" is not an HTMLElement`);
|
|
510
|
+
}
|
|
511
|
+
return container;
|
|
512
|
+
}
|
|
513
|
+
resolveContainerWidth(container) {
|
|
514
|
+
return container.getBoundingClientRect().width || DEFAULT_CHART_WIDTH;
|
|
515
|
+
}
|
|
516
|
+
resolveCurrentWidth() {
|
|
517
|
+
if (this.container) {
|
|
518
|
+
return this.resolveContainerWidth(this.container);
|
|
519
|
+
}
|
|
520
|
+
return DEFAULT_CHART_WIDTH;
|
|
521
|
+
}
|
|
522
|
+
resolveTotalHeightConstraint(containerRect) {
|
|
523
|
+
if (this.configuredHeight !== undefined) {
|
|
524
|
+
return this.configuredHeight;
|
|
525
|
+
}
|
|
526
|
+
return containerRect.height > 0 ? containerRect.height : undefined;
|
|
527
|
+
}
|
|
528
|
+
resolveChartAreaHeightConstraint(totalHeightConstraint, titleHeight, legendHeight) {
|
|
529
|
+
if (totalHeightConstraint === undefined) {
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
return Math.max(1, totalHeightConstraint - titleHeight - legendHeight);
|
|
533
|
+
}
|
|
534
|
+
resolveSpan(span, cols) {
|
|
535
|
+
return Math.min(span, cols);
|
|
536
|
+
}
|
|
537
|
+
warnOnExplicitChildWidth(chart) {
|
|
538
|
+
const configuredWidth = chart.getConfiguredSize().width;
|
|
539
|
+
if (configuredWidth === undefined ||
|
|
540
|
+
this.warnedWidthCharts.has(chart)) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
this.warnedWidthCharts.add(chart);
|
|
544
|
+
ChartValidator.warn('ChartGroup: explicit child chart widths are ignored inside grouped layouts');
|
|
545
|
+
}
|
|
546
|
+
getAllVerticalXYCharts() {
|
|
547
|
+
return this.charts
|
|
548
|
+
.map(({ chart }) => chart)
|
|
549
|
+
.filter((chart) => chart instanceof XYChart)
|
|
550
|
+
.filter((chart) => chart.getOrientation() === 'vertical');
|
|
551
|
+
}
|
|
552
|
+
getVerticalXYCharts(width) {
|
|
553
|
+
return this.resolveVisibleChartEntries(width)
|
|
554
|
+
.map(({ chart }) => chart)
|
|
555
|
+
.filter((chart) => chart instanceof XYChart)
|
|
556
|
+
.filter((chart) => chart.getOrientation() === 'vertical');
|
|
557
|
+
}
|
|
558
|
+
resolveSharedYDomain(width) {
|
|
559
|
+
if (!this.syncY) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const charts = this.getVerticalXYCharts(width);
|
|
563
|
+
const domains = [];
|
|
564
|
+
let sharedScaleType = null;
|
|
565
|
+
for (const chart of charts) {
|
|
566
|
+
const scaleType = chart.getValueAxisScaleType();
|
|
567
|
+
if (scaleType !== 'linear' && scaleType !== 'log') {
|
|
568
|
+
this.warnIncompatibleYScaleTypes();
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
if (sharedScaleType === null) {
|
|
572
|
+
sharedScaleType = scaleType;
|
|
573
|
+
}
|
|
574
|
+
else if (sharedScaleType !== scaleType) {
|
|
575
|
+
this.warnIncompatibleYScaleTypes();
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
const domain = chart.getBaseValueAxisDomain();
|
|
579
|
+
if (!domain) {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
domains.push(domain);
|
|
583
|
+
}
|
|
584
|
+
if (domains.length === 0) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
return [
|
|
588
|
+
Math.min(...domains.map((domain) => domain[0])),
|
|
589
|
+
Math.max(...domains.map((domain) => domain[1])),
|
|
590
|
+
];
|
|
591
|
+
}
|
|
592
|
+
warnIncompatibleYScaleTypes() {
|
|
593
|
+
if (this.hasWarnedIncompatibleYScaleTypes) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.hasWarnedIncompatibleYScaleTypes = true;
|
|
597
|
+
ChartValidator.warn('ChartGroup: syncY requires all synced XY child charts to use the same vertical numeric scale type');
|
|
598
|
+
}
|
|
599
|
+
applyScaleSyncOverrides(width) {
|
|
600
|
+
const syncedCharts = new Set(this.getVerticalXYCharts(width));
|
|
601
|
+
const sharedDomain = this.resolveSharedYDomain(width);
|
|
602
|
+
this.getAllVerticalXYCharts().forEach((chart) => {
|
|
603
|
+
if (!sharedDomain || !syncedCharts.has(chart)) {
|
|
604
|
+
chart.setScaleConfigOverride(null, false);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
chart.setScaleConfigOverride({
|
|
608
|
+
y: {
|
|
609
|
+
domain: sharedDomain,
|
|
610
|
+
nice: false,
|
|
611
|
+
},
|
|
612
|
+
}, false);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
buildRows(entries, width, cols, gap) {
|
|
616
|
+
const availableWidth = Math.max(1, width - gap * Math.max(0, cols - 1));
|
|
617
|
+
const columnWidth = availableWidth / cols;
|
|
618
|
+
const rows = [];
|
|
619
|
+
let currentRow = [];
|
|
620
|
+
let currentCol = 0;
|
|
621
|
+
let currentX = 0;
|
|
622
|
+
entries.forEach((entry) => {
|
|
623
|
+
const span = this.resolveSpan(entry.span, cols);
|
|
624
|
+
if (currentRow.length > 0 && currentCol + span > cols) {
|
|
625
|
+
rows.push(currentRow);
|
|
626
|
+
currentRow = [];
|
|
627
|
+
currentCol = 0;
|
|
628
|
+
currentX = 0;
|
|
629
|
+
}
|
|
630
|
+
const itemWidth = columnWidth * span + gap * (span - 1);
|
|
631
|
+
currentRow.push({
|
|
632
|
+
...entry,
|
|
633
|
+
span,
|
|
634
|
+
width: itemWidth,
|
|
635
|
+
x: currentX,
|
|
636
|
+
});
|
|
637
|
+
currentCol += span;
|
|
638
|
+
currentX += itemWidth + gap;
|
|
639
|
+
if (currentCol >= cols) {
|
|
640
|
+
rows.push(currentRow);
|
|
641
|
+
currentRow = [];
|
|
642
|
+
currentCol = 0;
|
|
643
|
+
currentX = 0;
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
if (currentRow.length > 0) {
|
|
647
|
+
rows.push(currentRow);
|
|
648
|
+
}
|
|
649
|
+
return rows;
|
|
650
|
+
}
|
|
651
|
+
resolveDefaultChartHeight(rowCount, gap, availableChartHeight) {
|
|
652
|
+
if (availableChartHeight === undefined || rowCount === 0) {
|
|
653
|
+
return this.chartHeight;
|
|
654
|
+
}
|
|
655
|
+
return Math.max(1, (availableChartHeight - gap * Math.max(0, rowCount - 1)) / rowCount);
|
|
656
|
+
}
|
|
657
|
+
resolveDefaultChartHeightForWidth(width, availableChartHeight) {
|
|
658
|
+
const resolvedConfig = this.resolveResponsiveConfigForWidth(width);
|
|
659
|
+
const entries = this.resolveVisibleChartEntries(width, resolvedConfig);
|
|
660
|
+
const rows = this.buildRows(entries, width, resolvedConfig.cols, resolvedConfig.gap);
|
|
661
|
+
return this.resolveDefaultChartHeight(rows.length, resolvedConfig.gap, availableChartHeight);
|
|
662
|
+
}
|
|
663
|
+
calculateLayout(width, availableChartHeight, defaultChartHeightOverride) {
|
|
664
|
+
const resolvedConfig = this.resolveResponsiveConfigForWidth(width);
|
|
665
|
+
const entries = this.resolveVisibleChartEntries(width, resolvedConfig);
|
|
666
|
+
const rows = this.buildRows(entries, width, resolvedConfig.cols, resolvedConfig.gap);
|
|
667
|
+
const defaultChartHeight = defaultChartHeightOverride ??
|
|
668
|
+
this.resolveDefaultChartHeight(rows.length, resolvedConfig.gap, availableChartHeight);
|
|
669
|
+
const items = [];
|
|
670
|
+
let currentY = 0;
|
|
671
|
+
rows.forEach((row, rowIndex) => {
|
|
672
|
+
const rowHeight = Math.max(...row.map((item) => {
|
|
673
|
+
return (item.height ??
|
|
674
|
+
item.chart.getConfiguredSize().height ??
|
|
675
|
+
defaultChartHeight);
|
|
676
|
+
}), 0);
|
|
677
|
+
row.forEach((item) => {
|
|
678
|
+
items.push({
|
|
679
|
+
...item,
|
|
680
|
+
height: item.height ??
|
|
681
|
+
item.chart.getConfiguredSize().height ??
|
|
682
|
+
defaultChartHeight,
|
|
683
|
+
y: currentY,
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
currentY += rowHeight;
|
|
687
|
+
if (rowIndex < rows.length - 1) {
|
|
688
|
+
currentY += resolvedConfig.gap;
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
return {
|
|
692
|
+
items,
|
|
693
|
+
chartHeight: currentY,
|
|
694
|
+
totalHeight: currentY,
|
|
695
|
+
width,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
collectMergedLegendEntries(width) {
|
|
699
|
+
const entries = [];
|
|
700
|
+
const seen = new Map();
|
|
701
|
+
this.resolveVisibleChartEntries(width).forEach(({ chart }) => {
|
|
702
|
+
chart.getLegendItems().forEach((item) => {
|
|
703
|
+
const existing = seen.get(item.dataKey);
|
|
704
|
+
if (!existing) {
|
|
705
|
+
const entry = {
|
|
706
|
+
dataKey: item.dataKey,
|
|
707
|
+
color: item.color,
|
|
708
|
+
fallbackVisible: item.visible,
|
|
709
|
+
};
|
|
710
|
+
entries.push(entry);
|
|
711
|
+
seen.set(item.dataKey, entry);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (existing.color !== item.color &&
|
|
715
|
+
!this.warnedColorConflicts.has(item.dataKey)) {
|
|
716
|
+
this.warnedColorConflicts.add(item.dataKey);
|
|
717
|
+
ChartValidator.warn(`ChartGroup: legend key "${item.dataKey}" has conflicting colors across child charts; using the first color`);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
return entries;
|
|
722
|
+
}
|
|
723
|
+
resolveLegendVisibility(entry) {
|
|
724
|
+
if (this.legendState.hasSeries(entry.dataKey)) {
|
|
725
|
+
return this.legendState.isSeriesVisible(entry.dataKey);
|
|
726
|
+
}
|
|
727
|
+
return entry.fallbackVisible;
|
|
728
|
+
}
|
|
729
|
+
syncLegendStateFromChildren() {
|
|
730
|
+
this.isSyncingLegend = true;
|
|
731
|
+
try {
|
|
732
|
+
const width = this.resolveCurrentWidth();
|
|
733
|
+
const entries = this.collectMergedLegendEntries(width);
|
|
734
|
+
const visibility = {};
|
|
735
|
+
entries.forEach((entry) => {
|
|
736
|
+
visibility[entry.dataKey] = this.resolveLegendVisibility(entry);
|
|
737
|
+
});
|
|
738
|
+
this.legendState.setVisibilityMap(visibility, { silent: true });
|
|
739
|
+
this.applyLegendStateToChildren();
|
|
740
|
+
this.renderLegendIntoContainer();
|
|
741
|
+
this.childLegendSnapshot = this.serializeChildLegendState(width);
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
this.isSyncingLegend = false;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
applyLegendStateToChildren() {
|
|
748
|
+
this.isSyncingLegend = true;
|
|
749
|
+
try {
|
|
750
|
+
const visibility = this.legendState.toVisibilityMap();
|
|
751
|
+
this.charts.forEach(({ chart }) => {
|
|
752
|
+
chart.getLegendItems().forEach((item) => {
|
|
753
|
+
const nextVisible = visibility[item.dataKey];
|
|
754
|
+
if (nextVisible === undefined ||
|
|
755
|
+
item.visible === nextVisible) {
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
chart.setLegendSeriesVisible(item.dataKey, nextVisible);
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
finally {
|
|
763
|
+
this.isSyncingLegend = false;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
serializeChildLegendState(width) {
|
|
767
|
+
return this.resolveVisibleChartEntries(width)
|
|
768
|
+
.map(({ chart }, chartIndex) => {
|
|
769
|
+
const items = chart.getLegendItems().map((item) => {
|
|
770
|
+
return `${item.dataKey}:${item.color}:${item.visible}`;
|
|
771
|
+
});
|
|
772
|
+
return `${chartIndex}[${items.join(',')}]`;
|
|
773
|
+
})
|
|
774
|
+
.join('|');
|
|
775
|
+
}
|
|
776
|
+
serializeChildYDomains(width) {
|
|
777
|
+
return this.getVerticalXYCharts(width)
|
|
778
|
+
.map((chart, index) => {
|
|
779
|
+
const scaleType = chart.getValueAxisScaleType() ?? 'none';
|
|
780
|
+
const domain = chart.getBaseValueAxisDomain();
|
|
781
|
+
return `${index}:${scaleType}:${domain?.join(',') ?? ''}`;
|
|
782
|
+
})
|
|
783
|
+
.join('|');
|
|
784
|
+
}
|
|
785
|
+
renderLegendIntoContainer() {
|
|
786
|
+
if (!this.container) {
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const root = this.container.querySelector('.chart-group');
|
|
790
|
+
if (!root) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const existingLegendHost = root.querySelector('.chart-group__legend');
|
|
794
|
+
existingLegendHost?.remove();
|
|
795
|
+
const renderedLegend = this.renderLegendSvg(this.resolveContainerWidth(this.container));
|
|
796
|
+
if (!renderedLegend) {
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const legendHost = document.createElement('div');
|
|
800
|
+
legendHost.className = 'chart-group__legend';
|
|
801
|
+
legendHost.style.width = '100%';
|
|
802
|
+
legendHost.appendChild(renderedLegend.svg);
|
|
803
|
+
root.appendChild(legendHost);
|
|
804
|
+
}
|
|
805
|
+
renderTitleSvg(width) {
|
|
806
|
+
if (!this.title || !this.title.display) {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
const height = Math.max(1, this.title.getRequiredSpace().height);
|
|
810
|
+
const svg = create('svg')
|
|
811
|
+
.attr('width', width)
|
|
812
|
+
.attr('height', height)
|
|
813
|
+
.style('display', 'block');
|
|
814
|
+
const svgNode = svg.node();
|
|
815
|
+
if (!svgNode) {
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
this.title.render(svg, this.theme, width);
|
|
819
|
+
return {
|
|
820
|
+
height,
|
|
821
|
+
svg: svgNode,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
renderLegendSvg(width) {
|
|
825
|
+
if (!this.legend || this.legend.mode === 'hidden') {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
const items = this.getLegendItemsForWidth(width);
|
|
829
|
+
if (items.length === 0) {
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
const tempHost = document.createElement('div');
|
|
833
|
+
tempHost.style.position = 'absolute';
|
|
834
|
+
tempHost.style.left = '-99999px';
|
|
835
|
+
tempHost.style.top = '0';
|
|
836
|
+
tempHost.style.visibility = 'hidden';
|
|
837
|
+
document.body.appendChild(tempHost);
|
|
838
|
+
const svg = create('svg')
|
|
839
|
+
.attr('width', width)
|
|
840
|
+
.style('display', 'block');
|
|
841
|
+
const svgNode = svg.node();
|
|
842
|
+
if (!svgNode) {
|
|
843
|
+
document.body.removeChild(tempHost);
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
tempHost.appendChild(svgNode);
|
|
847
|
+
try {
|
|
848
|
+
const series = items.map((item) => {
|
|
849
|
+
return {
|
|
850
|
+
dataKey: item.dataKey,
|
|
851
|
+
fill: item.color,
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
this.legend.estimateLayoutSpace(series, this.theme, width, svgNode);
|
|
855
|
+
const height = Math.max(1, this.legend.getMeasuredHeight());
|
|
856
|
+
svg.attr('height', height);
|
|
857
|
+
this.legend.render(svg, series, this.theme, width);
|
|
858
|
+
return {
|
|
859
|
+
height,
|
|
860
|
+
svg: svgNode,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
finally {
|
|
864
|
+
document.body.removeChild(tempHost);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
setupResizeObserver() {
|
|
868
|
+
if (!this.container) {
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (this.resizeObserver) {
|
|
872
|
+
this.resizeObserver.disconnect();
|
|
873
|
+
}
|
|
874
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
875
|
+
this.refresh();
|
|
876
|
+
});
|
|
877
|
+
this.resizeObserver.observe(this.container);
|
|
878
|
+
}
|
|
879
|
+
async exportSVG(width, options) {
|
|
880
|
+
this.applyScaleSyncOverrides(width);
|
|
881
|
+
const renderedTitle = this.renderTitleSvg(width);
|
|
882
|
+
const renderedLegend = this.renderLegendSvg(width);
|
|
883
|
+
const titleHeight = renderedTitle?.height ?? 0;
|
|
884
|
+
const legendHeight = renderedLegend?.height ?? 0;
|
|
885
|
+
let chartAreaHeight;
|
|
886
|
+
let defaultChartHeightOverride;
|
|
887
|
+
if (this.configuredHeight !== undefined) {
|
|
888
|
+
chartAreaHeight = this.resolveChartAreaHeightConstraint(this.configuredHeight, titleHeight, legendHeight);
|
|
889
|
+
}
|
|
890
|
+
else if (this.container) {
|
|
891
|
+
const liveWidth = this.resolveContainerWidth(this.container);
|
|
892
|
+
const liveTitle = this.renderTitleSvg(liveWidth);
|
|
893
|
+
const liveLegend = this.renderLegendSvg(liveWidth);
|
|
894
|
+
const liveChartAreaHeight = this.resolveChartAreaHeightConstraint(this.resolveTotalHeightConstraint(this.container.getBoundingClientRect()), liveTitle?.height ?? 0, liveLegend?.height ?? 0);
|
|
895
|
+
if (liveChartAreaHeight !== undefined) {
|
|
896
|
+
defaultChartHeightOverride =
|
|
897
|
+
this.resolveDefaultChartHeightForWidth(liveWidth, liveChartAreaHeight);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const layout = this.calculateLayout(width, chartAreaHeight, defaultChartHeightOverride);
|
|
901
|
+
const childSvgs = await Promise.all(layout.items.map(async (item) => {
|
|
902
|
+
const svg = await item.chart.export('svg', {
|
|
903
|
+
...options,
|
|
904
|
+
width: item.width,
|
|
905
|
+
height: item.height,
|
|
906
|
+
download: false,
|
|
907
|
+
});
|
|
908
|
+
if (typeof svg !== 'string') {
|
|
909
|
+
throw new Error('ChartGroup child export did not return SVG');
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
...item,
|
|
913
|
+
svg,
|
|
914
|
+
};
|
|
915
|
+
}));
|
|
916
|
+
const totalHeight = titleHeight + layout.chartHeight + legendHeight;
|
|
917
|
+
const parser = new DOMParser();
|
|
918
|
+
const exportSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
919
|
+
exportSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
920
|
+
exportSvg.setAttribute('width', String(width));
|
|
921
|
+
exportSvg.setAttribute('height', String(totalHeight));
|
|
922
|
+
exportSvg.setAttribute('role', 'img');
|
|
923
|
+
exportSvg.setAttribute('aria-label', this.title?.text.trim() || 'Chart group');
|
|
924
|
+
if (renderedTitle) {
|
|
925
|
+
renderedTitle.svg.setAttribute('x', '0');
|
|
926
|
+
renderedTitle.svg.setAttribute('y', '0');
|
|
927
|
+
exportSvg.appendChild(document.importNode(renderedTitle.svg, true));
|
|
928
|
+
}
|
|
929
|
+
childSvgs.forEach((item) => {
|
|
930
|
+
const parsed = parser.parseFromString(item.svg, 'image/svg+xml');
|
|
931
|
+
const childSvg = parsed.documentElement;
|
|
932
|
+
const imported = document.importNode(childSvg, true);
|
|
933
|
+
imported.setAttribute('x', String(item.x));
|
|
934
|
+
imported.setAttribute('y', String(titleHeight + item.y));
|
|
935
|
+
imported.setAttribute('width', String(item.width));
|
|
936
|
+
imported.setAttribute('height', String(item.height));
|
|
937
|
+
exportSvg.appendChild(imported);
|
|
938
|
+
});
|
|
939
|
+
if (renderedLegend) {
|
|
940
|
+
renderedLegend.svg.setAttribute('x', '0');
|
|
941
|
+
renderedLegend.svg.setAttribute('y', String(titleHeight + layout.chartHeight));
|
|
942
|
+
exportSvg.appendChild(document.importNode(renderedLegend.svg, true));
|
|
943
|
+
}
|
|
944
|
+
return {
|
|
945
|
+
svg: exportSvg.outerHTML,
|
|
946
|
+
height: totalHeight,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
validateResponsiveConfig(responsive) {
|
|
950
|
+
const breakpoints = responsive?.breakpoints;
|
|
951
|
+
if (!breakpoints) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
Object.entries(breakpoints).forEach(([name, definition]) => {
|
|
955
|
+
const config = normalizeChartGroupBreakpoint(definition);
|
|
956
|
+
if (!config) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (config.cols !== undefined) {
|
|
960
|
+
normalizePositiveInteger(config.cols, `ChartGroup breakpoint "${name}" cols`);
|
|
961
|
+
}
|
|
962
|
+
if (config.gap !== undefined) {
|
|
963
|
+
normalizeNonNegativeNumber(config.gap, `ChartGroup breakpoint "${name}" gap`);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
validateItemResponsiveConfig(responsive) {
|
|
968
|
+
const breakpoints = responsive?.breakpoints;
|
|
969
|
+
if (!breakpoints) {
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
Object.entries(breakpoints).forEach(([name, definition]) => {
|
|
973
|
+
const config = normalizeChartGroupItemBreakpoint(definition);
|
|
974
|
+
if (!config) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (config.span !== undefined) {
|
|
978
|
+
normalizePositiveInteger(config.span, `ChartGroup item breakpoint "${name}" span`);
|
|
979
|
+
}
|
|
980
|
+
if (config.height !== undefined) {
|
|
981
|
+
normalizeOptionalHeight(config.height, `ChartGroup item breakpoint "${name}" height`);
|
|
982
|
+
}
|
|
983
|
+
if (config.order !== undefined) {
|
|
984
|
+
normalizeFiniteNumber(config.order, `ChartGroup item breakpoint "${name}" order`);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
resolveResponsiveConfigForWidth(width) {
|
|
989
|
+
let cols = this.cols;
|
|
990
|
+
let gap = this.gap;
|
|
991
|
+
const matchedBreakpoints = this.resolveMatchedGroupBreakpoints(width);
|
|
992
|
+
matchedBreakpoints.forEach(({ config, name }) => {
|
|
993
|
+
if (config.cols !== undefined) {
|
|
994
|
+
cols = normalizePositiveInteger(config.cols, `ChartGroup breakpoint "${name}" cols`);
|
|
995
|
+
}
|
|
996
|
+
if (config.gap !== undefined) {
|
|
997
|
+
gap = normalizeNonNegativeNumber(config.gap, `ChartGroup breakpoint "${name}" gap`);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
return {
|
|
1001
|
+
cols,
|
|
1002
|
+
gap,
|
|
1003
|
+
activeBreakpoints: matchedBreakpoints.map(({ name }) => name),
|
|
1004
|
+
breakpoint: matchedBreakpoints.length > 0
|
|
1005
|
+
? matchedBreakpoints[matchedBreakpoints.length - 1].name
|
|
1006
|
+
: null,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
resolveMatchedGroupBreakpoints(width) {
|
|
1010
|
+
const breakpoints = this.responsiveConfig?.breakpoints;
|
|
1011
|
+
if (!breakpoints) {
|
|
1012
|
+
return [];
|
|
1013
|
+
}
|
|
1014
|
+
return Object.entries(breakpoints).flatMap(([name, definition]) => {
|
|
1015
|
+
const config = normalizeChartGroupBreakpoint(definition);
|
|
1016
|
+
if (!config || !matchesBreakpoint(width, config)) {
|
|
1017
|
+
return [];
|
|
1018
|
+
}
|
|
1019
|
+
return [{ name, config }];
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
resolveVisibleChartEntries(width, responsiveConfig = this.resolveResponsiveConfigForWidth(width)) {
|
|
1023
|
+
const visibleEntries = this.charts.flatMap((entry, sourceIndex) => {
|
|
1024
|
+
const resolvedEntry = this.resolveResponsiveChartEntry(entry, sourceIndex, width, responsiveConfig.cols);
|
|
1025
|
+
return resolvedEntry ? [resolvedEntry] : [];
|
|
1026
|
+
});
|
|
1027
|
+
visibleEntries.sort((left, right) => {
|
|
1028
|
+
if (left.order !== right.order) {
|
|
1029
|
+
return left.order - right.order;
|
|
1030
|
+
}
|
|
1031
|
+
return left.sourceIndex - right.sourceIndex;
|
|
1032
|
+
});
|
|
1033
|
+
return visibleEntries;
|
|
1034
|
+
}
|
|
1035
|
+
resolveResponsiveChartEntry(entry, sourceIndex, width, cols) {
|
|
1036
|
+
let span = entry.span;
|
|
1037
|
+
let height = entry.height;
|
|
1038
|
+
let hidden = entry.hidden;
|
|
1039
|
+
let order = entry.order;
|
|
1040
|
+
Object.entries(entry.responsive?.breakpoints ?? {}).forEach(([name, definition]) => {
|
|
1041
|
+
const config = normalizeChartGroupItemBreakpoint(definition);
|
|
1042
|
+
if (!config || !matchesBreakpoint(width, config)) {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (config.span !== undefined) {
|
|
1046
|
+
span = normalizePositiveInteger(config.span, `ChartGroup item breakpoint "${name}" span`);
|
|
1047
|
+
}
|
|
1048
|
+
if (config.height !== undefined) {
|
|
1049
|
+
height = normalizeOptionalHeight(config.height, `ChartGroup item breakpoint "${name}" height`);
|
|
1050
|
+
}
|
|
1051
|
+
if (config.hidden !== undefined) {
|
|
1052
|
+
hidden = config.hidden;
|
|
1053
|
+
}
|
|
1054
|
+
if (config.order !== undefined) {
|
|
1055
|
+
order = normalizeFiniteNumber(config.order, `ChartGroup item breakpoint "${name}" order`);
|
|
1056
|
+
}
|
|
1057
|
+
});
|
|
1058
|
+
if (hidden) {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
return {
|
|
1062
|
+
...entry,
|
|
1063
|
+
span: this.resolveSpan(span, cols),
|
|
1064
|
+
height,
|
|
1065
|
+
hidden,
|
|
1066
|
+
order,
|
|
1067
|
+
sourceIndex,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
downloadContent(content, format, options) {
|
|
1071
|
+
const mimeType = this.getMimeType(format);
|
|
1072
|
+
const blob = content instanceof Blob
|
|
1073
|
+
? content
|
|
1074
|
+
: new Blob([content], { type: mimeType });
|
|
1075
|
+
const filename = options.filename || `chart-group.${format}`;
|
|
1076
|
+
const url = URL.createObjectURL(blob);
|
|
1077
|
+
const link = document.createElement('a');
|
|
1078
|
+
link.href = url;
|
|
1079
|
+
link.download = filename;
|
|
1080
|
+
document.body.appendChild(link);
|
|
1081
|
+
link.click();
|
|
1082
|
+
document.body.removeChild(link);
|
|
1083
|
+
URL.revokeObjectURL(url);
|
|
1084
|
+
}
|
|
1085
|
+
getMimeType(format) {
|
|
1086
|
+
if (format === 'svg') {
|
|
1087
|
+
return 'image/svg+xml';
|
|
1088
|
+
}
|
|
1089
|
+
if (format === 'png') {
|
|
1090
|
+
return 'image/png';
|
|
1091
|
+
}
|
|
1092
|
+
if (format === 'jpg') {
|
|
1093
|
+
return 'image/jpeg';
|
|
1094
|
+
}
|
|
1095
|
+
return 'application/pdf';
|
|
1096
|
+
}
|
|
1097
|
+
}
|