@internetstiftelsen/charts 0.6.0 → 0.7.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 +24 -0
- package/base-chart.d.ts +29 -1
- package/base-chart.js +315 -21
- package/donut-center-content.js +2 -1
- package/donut-chart.d.ts +4 -1
- package/donut-chart.js +44 -9
- package/export-tabular.js +6 -2
- package/gauge-chart.d.ts +3 -1
- package/gauge-chart.js +30 -11
- package/grouped-data.js +3 -1
- package/grouped-tabular.d.ts +22 -1
- package/grouped-tabular.js +158 -0
- package/legend.d.ts +30 -2
- package/legend.js +285 -51
- package/package.json +2 -1
- package/pie-chart.d.ts +3 -1
- package/pie-chart.js +34 -13
- package/theme.d.ts +2 -1
- package/theme.js +33 -0
- package/types.d.ts +50 -0
- package/utils.d.ts +4 -1
- package/utils.js +1 -0
- package/xy-chart.d.ts +3 -1
- package/xy-chart.js +58 -32
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
|
|
|
9
9
|
- **Multiple Chart Types** - XYChart (lines, areas, bars), DonutChart, PieChart, and GaugeChart
|
|
10
10
|
- **Flexible Scales** - Band, linear, time, and logarithmic scales
|
|
11
11
|
- **Auto Resize** - Built-in ResizeObserver handles responsive behavior
|
|
12
|
+
- **Responsive Policy** - Chart-level container-query overrides for theme and components
|
|
12
13
|
- **Type Safe** - Written in TypeScript with full type definitions
|
|
13
14
|
- **Data Validation** - Built-in validation with helpful error messages
|
|
14
15
|
- **Auto Colors** - Smart color palette with sensible defaults
|
|
@@ -57,6 +58,29 @@ await chart.export('pdf', { download: true, pdfMargin: 16 });
|
|
|
57
58
|
`xlsx` and `pdf` are lazy-loaded and require optional dependencies (`xlsx` and
|
|
58
59
|
`jspdf`) only when those formats are used.
|
|
59
60
|
|
|
61
|
+
## Import
|
|
62
|
+
|
|
63
|
+
`toChartData()` converts tab-delimited string input into chart JSON data.
|
|
64
|
+
It auto-detects grouped and normal (flat) table layouts.
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { toChartData } from '@internetstiftelsen/charts/utils';
|
|
68
|
+
import { XYChart } from '@internetstiftelsen/charts/xy-chart';
|
|
69
|
+
|
|
70
|
+
const data = toChartData(
|
|
71
|
+
'\t\tDaily\tWeekly\nAll users\tSegment A\t85%\t92%\n\tSegment B\t84%\t91%',
|
|
72
|
+
{
|
|
73
|
+
categoryKey: 'Category',
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const chart = new XYChart({ data });
|
|
78
|
+
chart.render('#chart-container');
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The parser supports JSON-escaped string payloads and grouped carry-forward row
|
|
82
|
+
structure (blank first column on continuation rows).
|
|
83
|
+
|
|
60
84
|
## Documentation
|
|
61
85
|
|
|
62
86
|
- [Getting Started](./docs/getting-started.md) - Installation, Vanilla JS, React integration
|
package/base-chart.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { ChartData, DataItem, ChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale, ExportHookContext, ExportRenderContext } from './types.js';
|
|
2
|
+
import type { ChartData, DataItem, ChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale, ExportHookContext, ExportRenderContext, LegendItem, LegendSeries, ResponsiveConfig, ResponsiveRenderContext, DeepPartial } from './types.js';
|
|
3
3
|
import type { ChartComponent, LayoutAwareComponent } from './chart-interface.js';
|
|
4
4
|
import type { XAxis } from './x-axis.js';
|
|
5
5
|
import type { YAxis } from './y-axis.js';
|
|
@@ -14,10 +14,15 @@ type RenderDimensions = {
|
|
|
14
14
|
height: number;
|
|
15
15
|
svgHeightAttr: number | string;
|
|
16
16
|
};
|
|
17
|
+
type ResponsiveOverrides = {
|
|
18
|
+
theme?: DeepPartial<ChartTheme>;
|
|
19
|
+
components: Map<ChartComponent, Record<string, unknown>>;
|
|
20
|
+
};
|
|
17
21
|
export type BaseChartConfig = {
|
|
18
22
|
data: ChartData;
|
|
19
23
|
theme?: Partial<ChartTheme>;
|
|
20
24
|
scales?: AxisScaleConfig;
|
|
25
|
+
responsive?: ResponsiveConfig;
|
|
21
26
|
};
|
|
22
27
|
/**
|
|
23
28
|
* Base chart class that provides common functionality for all chart types
|
|
@@ -27,6 +32,7 @@ export declare abstract class BaseChart {
|
|
|
27
32
|
protected sourceData: ChartData;
|
|
28
33
|
protected readonly theme: ChartTheme;
|
|
29
34
|
protected readonly scaleConfig: AxisScaleConfig;
|
|
35
|
+
protected readonly responsiveConfig?: ResponsiveConfig;
|
|
30
36
|
protected width: number;
|
|
31
37
|
protected height: number;
|
|
32
38
|
protected xAxis: XAxis | null;
|
|
@@ -43,6 +49,7 @@ export declare abstract class BaseChart {
|
|
|
43
49
|
protected resizeObserver: ResizeObserver | null;
|
|
44
50
|
protected layoutManager: LayoutManager;
|
|
45
51
|
protected plotArea: PlotAreaBounds | null;
|
|
52
|
+
private disconnectedLegendContainer;
|
|
46
53
|
protected constructor(config: BaseChartConfig);
|
|
47
54
|
/**
|
|
48
55
|
* Adds a component (axis, grid, tooltip, etc.) to the chart
|
|
@@ -58,6 +65,13 @@ export declare abstract class BaseChart {
|
|
|
58
65
|
*/
|
|
59
66
|
private performRender;
|
|
60
67
|
protected resolveRenderDimensions(containerRect: DOMRect): RenderDimensions;
|
|
68
|
+
protected resolveResponsiveContext(context: {
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
}): ResponsiveRenderContext;
|
|
72
|
+
private resolveBreakpointName;
|
|
73
|
+
private resolveRenderTheme;
|
|
74
|
+
private applyThemeOverride;
|
|
61
75
|
/**
|
|
62
76
|
* Get layout-aware components in order
|
|
63
77
|
* Override in subclasses to provide chart-specific components
|
|
@@ -65,7 +79,11 @@ export declare abstract class BaseChart {
|
|
|
65
79
|
protected getLayoutComponents(): LayoutAwareComponent[];
|
|
66
80
|
protected getExportComponents(): ChartComponent[];
|
|
67
81
|
protected collectExportOverrides(context: ExportRenderContext): Map<ChartComponent, Record<string, unknown>>;
|
|
82
|
+
protected collectResponsiveOverrides(context: ResponsiveRenderContext): ResponsiveOverrides;
|
|
68
83
|
protected runExportHooks(context: ExportHookContext): void;
|
|
84
|
+
private mergeComponentOverrideMaps;
|
|
85
|
+
private createOverrideComponents;
|
|
86
|
+
protected applyComponentOverrides(overrides: Map<ChartComponent, ChartComponent>): () => void;
|
|
69
87
|
private renderExportChart;
|
|
70
88
|
protected prepareLayout(): void;
|
|
71
89
|
/**
|
|
@@ -77,6 +95,13 @@ export declare abstract class BaseChart {
|
|
|
77
95
|
*/
|
|
78
96
|
protected abstract renderChart(): void;
|
|
79
97
|
protected abstract createExportChart(): BaseChart;
|
|
98
|
+
protected getLegendSeries(): LegendSeries[];
|
|
99
|
+
getLegendItems(): LegendItem[];
|
|
100
|
+
isLegendSeriesVisible(dataKey: string): boolean;
|
|
101
|
+
setLegendSeriesVisible(dataKey: string, visible: boolean): this;
|
|
102
|
+
toggleLegendSeries(dataKey: string): this;
|
|
103
|
+
setLegendVisibility(visibility: Record<string, boolean>): this;
|
|
104
|
+
onLegendChange(callback: () => void): () => void;
|
|
80
105
|
/**
|
|
81
106
|
* Updates the chart with new data
|
|
82
107
|
*/
|
|
@@ -85,6 +110,9 @@ export declare abstract class BaseChart {
|
|
|
85
110
|
* Destroys the chart and cleans up resources
|
|
86
111
|
*/
|
|
87
112
|
destroy(): void;
|
|
113
|
+
private renderDisconnectedLegend;
|
|
114
|
+
private resolveDisconnectedLegendHost;
|
|
115
|
+
private cleanupDisconnectedLegendContainer;
|
|
88
116
|
protected parseValue(value: unknown): number;
|
|
89
117
|
/**
|
|
90
118
|
* Exports the chart in the specified format
|
package/base-chart.js
CHANGED
|
@@ -7,6 +7,7 @@ import { exportRasterBlob } from './export-image.js';
|
|
|
7
7
|
import { exportXLSXBlob } from './export-xlsx.js';
|
|
8
8
|
import { exportPDFBlob } from './export-pdf.js';
|
|
9
9
|
import { normalizeChartData } from './grouped-data.js';
|
|
10
|
+
import { mergeDeep } from './utils.js';
|
|
10
11
|
/**
|
|
11
12
|
* Base chart class that provides common functionality for all chart types
|
|
12
13
|
*/
|
|
@@ -36,6 +37,12 @@ export class BaseChart {
|
|
|
36
37
|
writable: true,
|
|
37
38
|
value: void 0
|
|
38
39
|
});
|
|
40
|
+
Object.defineProperty(this, "responsiveConfig", {
|
|
41
|
+
enumerable: true,
|
|
42
|
+
configurable: true,
|
|
43
|
+
writable: true,
|
|
44
|
+
value: void 0
|
|
45
|
+
});
|
|
39
46
|
Object.defineProperty(this, "width", {
|
|
40
47
|
enumerable: true,
|
|
41
48
|
configurable: true,
|
|
@@ -132,6 +139,12 @@ export class BaseChart {
|
|
|
132
139
|
writable: true,
|
|
133
140
|
value: null
|
|
134
141
|
});
|
|
142
|
+
Object.defineProperty(this, "disconnectedLegendContainer", {
|
|
143
|
+
enumerable: true,
|
|
144
|
+
configurable: true,
|
|
145
|
+
writable: true,
|
|
146
|
+
value: null
|
|
147
|
+
});
|
|
135
148
|
const normalized = normalizeChartData(config.data);
|
|
136
149
|
ChartValidator.validateData(normalized.data);
|
|
137
150
|
this.sourceData = config.data;
|
|
@@ -140,6 +153,7 @@ export class BaseChart {
|
|
|
140
153
|
this.width = this.theme.width;
|
|
141
154
|
this.height = this.theme.height;
|
|
142
155
|
this.scaleConfig = config.scales || {};
|
|
156
|
+
this.responsiveConfig = config.responsive;
|
|
143
157
|
this.layoutManager = new LayoutManager(this.theme);
|
|
144
158
|
}
|
|
145
159
|
/**
|
|
@@ -147,6 +161,7 @@ export class BaseChart {
|
|
|
147
161
|
*/
|
|
148
162
|
render(target) {
|
|
149
163
|
const container = this.resolveContainer(target);
|
|
164
|
+
this.cleanupDisconnectedLegendContainer();
|
|
150
165
|
this.container = container;
|
|
151
166
|
container.innerHTML = '';
|
|
152
167
|
// Perform initial render
|
|
@@ -178,27 +193,45 @@ export class BaseChart {
|
|
|
178
193
|
const dimensions = this.resolveRenderDimensions(this.container.getBoundingClientRect());
|
|
179
194
|
this.width = dimensions.width;
|
|
180
195
|
this.height = dimensions.height;
|
|
181
|
-
|
|
182
|
-
this.container.innerHTML = '';
|
|
183
|
-
this.svg = create('svg')
|
|
184
|
-
.attr('width', '100%')
|
|
185
|
-
.attr('height', dimensions.svgHeightAttr)
|
|
186
|
-
.style('display', 'block');
|
|
187
|
-
this.container.appendChild(this.svg.node());
|
|
188
|
-
this.prepareLayout();
|
|
189
|
-
// Calculate layout
|
|
190
|
-
const layoutTheme = {
|
|
191
|
-
...this.theme,
|
|
196
|
+
const sizeContext = {
|
|
192
197
|
width: this.width,
|
|
193
198
|
height: this.height,
|
|
194
199
|
};
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
this.
|
|
200
|
+
const responsiveContext = this.resolveResponsiveContext(sizeContext);
|
|
201
|
+
const responsiveOverrides = this.collectResponsiveOverrides(responsiveContext);
|
|
202
|
+
const mergedComponentOverrides = this.mergeComponentOverrideMaps(responsiveOverrides.components);
|
|
203
|
+
const renderTheme = this.resolveRenderTheme(responsiveOverrides);
|
|
204
|
+
const overrideComponents = this.createOverrideComponents(mergedComponentOverrides);
|
|
205
|
+
const restoreComponents = this.applyComponentOverrides(overrideComponents);
|
|
206
|
+
const restoreTheme = this.applyThemeOverride(renderTheme);
|
|
207
|
+
try {
|
|
208
|
+
// Clear and setup SVG
|
|
209
|
+
this.container.innerHTML = '';
|
|
210
|
+
this.svg = create('svg')
|
|
211
|
+
.attr('width', '100%')
|
|
212
|
+
.attr('height', dimensions.svgHeightAttr)
|
|
213
|
+
.style('display', 'block');
|
|
214
|
+
this.container.appendChild(this.svg.node());
|
|
215
|
+
this.prepareLayout();
|
|
216
|
+
// Calculate layout
|
|
217
|
+
const layoutTheme = {
|
|
218
|
+
...this.theme,
|
|
219
|
+
width: this.width,
|
|
220
|
+
height: this.height,
|
|
221
|
+
};
|
|
222
|
+
this.layoutManager = new LayoutManager(layoutTheme);
|
|
223
|
+
const components = this.getLayoutComponents();
|
|
224
|
+
this.plotArea = this.layoutManager.calculateLayout(components);
|
|
225
|
+
// Create plot group
|
|
226
|
+
this.plotGroup = this.svg.append('g').attr('class', 'chart-plot');
|
|
227
|
+
// Render chart content
|
|
228
|
+
this.renderChart();
|
|
229
|
+
this.renderDisconnectedLegend();
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
restoreComponents();
|
|
233
|
+
restoreTheme();
|
|
234
|
+
}
|
|
202
235
|
}
|
|
203
236
|
resolveRenderDimensions(containerRect) {
|
|
204
237
|
const width = containerRect.width || this.theme.width;
|
|
@@ -209,6 +242,44 @@ export class BaseChart {
|
|
|
209
242
|
svgHeightAttr: '100%',
|
|
210
243
|
};
|
|
211
244
|
}
|
|
245
|
+
resolveResponsiveContext(context) {
|
|
246
|
+
return {
|
|
247
|
+
...context,
|
|
248
|
+
breakpoint: this.resolveBreakpointName(context.width),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
resolveBreakpointName(width) {
|
|
252
|
+
const breakpoints = this.responsiveConfig?.breakpoints;
|
|
253
|
+
if (!breakpoints) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const sorted = Object.entries(breakpoints)
|
|
257
|
+
.filter(([, minWidth]) => Number.isFinite(minWidth))
|
|
258
|
+
.sort((a, b) => a[1] - b[1]);
|
|
259
|
+
let active = null;
|
|
260
|
+
sorted.forEach(([name, minWidth]) => {
|
|
261
|
+
if (width >= minWidth) {
|
|
262
|
+
active = name;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
return active;
|
|
266
|
+
}
|
|
267
|
+
resolveRenderTheme(responsiveOverrides) {
|
|
268
|
+
if (!responsiveOverrides.theme) {
|
|
269
|
+
return this.theme;
|
|
270
|
+
}
|
|
271
|
+
return mergeDeep(this.theme, responsiveOverrides.theme);
|
|
272
|
+
}
|
|
273
|
+
applyThemeOverride(theme) {
|
|
274
|
+
if (theme === this.theme) {
|
|
275
|
+
return () => { };
|
|
276
|
+
}
|
|
277
|
+
const originalTheme = this.theme;
|
|
278
|
+
this.theme = theme;
|
|
279
|
+
return () => {
|
|
280
|
+
this.theme = originalTheme;
|
|
281
|
+
};
|
|
282
|
+
}
|
|
212
283
|
/**
|
|
213
284
|
* Get layout-aware components in order
|
|
214
285
|
* Override in subclasses to provide chart-specific components
|
|
@@ -224,7 +295,7 @@ export class BaseChart {
|
|
|
224
295
|
if (this.yAxis) {
|
|
225
296
|
components.push(this.yAxis);
|
|
226
297
|
}
|
|
227
|
-
if (this.legend) {
|
|
298
|
+
if (this.legend?.isInlineMode()) {
|
|
228
299
|
components.push(this.legend);
|
|
229
300
|
}
|
|
230
301
|
return components;
|
|
@@ -266,12 +337,123 @@ export class BaseChart {
|
|
|
266
337
|
});
|
|
267
338
|
return overrides;
|
|
268
339
|
}
|
|
340
|
+
collectResponsiveOverrides(context) {
|
|
341
|
+
const beforeRender = this.responsiveConfig?.beforeRender;
|
|
342
|
+
const components = this.getExportComponents();
|
|
343
|
+
const componentOverrides = new Map();
|
|
344
|
+
if (!beforeRender) {
|
|
345
|
+
return {
|
|
346
|
+
components: componentOverrides,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const snapshots = components.map((component, index) => {
|
|
350
|
+
const exportable = component;
|
|
351
|
+
const currentConfig = exportable.getExportConfig?.() ?? {};
|
|
352
|
+
const dataKey = component.dataKey;
|
|
353
|
+
return {
|
|
354
|
+
index,
|
|
355
|
+
type: component.type,
|
|
356
|
+
dataKey: typeof dataKey === 'string' ? dataKey : undefined,
|
|
357
|
+
currentConfig,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
const result = beforeRender(context, {
|
|
361
|
+
theme: this.theme,
|
|
362
|
+
components: snapshots,
|
|
363
|
+
});
|
|
364
|
+
if (!result || typeof result !== 'object') {
|
|
365
|
+
return {
|
|
366
|
+
components: componentOverrides,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
result.components?.forEach((entry) => {
|
|
370
|
+
const match = entry.match ?? {};
|
|
371
|
+
const override = entry.override;
|
|
372
|
+
if (!override || typeof override !== 'object') {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
components.forEach((component, index) => {
|
|
376
|
+
const dataKey = component.dataKey;
|
|
377
|
+
const matchesIndex = match.index === undefined || match.index === index;
|
|
378
|
+
const matchesType = match.type === undefined || match.type === component.type;
|
|
379
|
+
const matchesDataKey = match.dataKey === undefined ||
|
|
380
|
+
(typeof dataKey === 'string' &&
|
|
381
|
+
dataKey === match.dataKey);
|
|
382
|
+
if (!matchesIndex || !matchesType || !matchesDataKey) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const existing = componentOverrides.get(component);
|
|
386
|
+
componentOverrides.set(component, existing
|
|
387
|
+
? mergeDeep(existing, override)
|
|
388
|
+
: { ...override });
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
return {
|
|
392
|
+
theme: result.theme,
|
|
393
|
+
components: componentOverrides,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
269
396
|
runExportHooks(context) {
|
|
270
397
|
const components = this.getExportComponents();
|
|
271
398
|
components.forEach((component) => {
|
|
272
399
|
component.exportHooks?.before?.call(component, context);
|
|
273
400
|
});
|
|
274
401
|
}
|
|
402
|
+
mergeComponentOverrideMaps(...maps) {
|
|
403
|
+
const merged = new Map();
|
|
404
|
+
maps.forEach((map) => {
|
|
405
|
+
map.forEach((override, component) => {
|
|
406
|
+
const existing = merged.get(component);
|
|
407
|
+
merged.set(component, existing ? mergeDeep(existing, override) : { ...override });
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
return merged;
|
|
411
|
+
}
|
|
412
|
+
createOverrideComponents(overrides) {
|
|
413
|
+
const overrideComponents = new Map();
|
|
414
|
+
overrides.forEach((override, component) => {
|
|
415
|
+
const exportable = component;
|
|
416
|
+
if (!exportable.createExportComponent) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
overrideComponents.set(component, exportable.createExportComponent(override));
|
|
420
|
+
});
|
|
421
|
+
return overrideComponents;
|
|
422
|
+
}
|
|
423
|
+
applyComponentOverrides(overrides) {
|
|
424
|
+
if (overrides.size === 0) {
|
|
425
|
+
return () => { };
|
|
426
|
+
}
|
|
427
|
+
const previousState = {
|
|
428
|
+
title: this.title,
|
|
429
|
+
grid: this.grid,
|
|
430
|
+
xAxis: this.xAxis,
|
|
431
|
+
yAxis: this.yAxis,
|
|
432
|
+
tooltip: this.tooltip,
|
|
433
|
+
legend: this.legend,
|
|
434
|
+
};
|
|
435
|
+
const resolve = (component) => {
|
|
436
|
+
if (!component) {
|
|
437
|
+
return component;
|
|
438
|
+
}
|
|
439
|
+
const override = overrides.get(component);
|
|
440
|
+
return override ?? component;
|
|
441
|
+
};
|
|
442
|
+
this.title = resolve(this.title);
|
|
443
|
+
this.grid = resolve(this.grid);
|
|
444
|
+
this.xAxis = resolve(this.xAxis);
|
|
445
|
+
this.yAxis = resolve(this.yAxis);
|
|
446
|
+
this.tooltip = resolve(this.tooltip);
|
|
447
|
+
this.legend = resolve(this.legend);
|
|
448
|
+
return () => {
|
|
449
|
+
this.title = previousState.title;
|
|
450
|
+
this.grid = previousState.grid;
|
|
451
|
+
this.xAxis = previousState.xAxis;
|
|
452
|
+
this.yAxis = previousState.yAxis;
|
|
453
|
+
this.tooltip = previousState.tooltip;
|
|
454
|
+
this.legend = previousState.legend;
|
|
455
|
+
};
|
|
456
|
+
}
|
|
275
457
|
renderExportChart(chart, width, height) {
|
|
276
458
|
const container = document.createElement('div');
|
|
277
459
|
const containerId = `chart-export-${Math.random()
|
|
@@ -310,6 +492,45 @@ export class BaseChart {
|
|
|
310
492
|
this.resizeObserver = new ResizeObserver(() => this.performRender());
|
|
311
493
|
this.resizeObserver.observe(this.container);
|
|
312
494
|
}
|
|
495
|
+
getLegendSeries() {
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
getLegendItems() {
|
|
499
|
+
if (!this.legend) {
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
return this.getLegendSeries().map((series) => {
|
|
503
|
+
return {
|
|
504
|
+
dataKey: series.dataKey,
|
|
505
|
+
color: series.stroke || series.fill || '#8884d8',
|
|
506
|
+
visible: this.legend.isSeriesVisible(series.dataKey),
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
isLegendSeriesVisible(dataKey) {
|
|
511
|
+
if (!this.legend) {
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
return this.legend.isSeriesVisible(dataKey);
|
|
515
|
+
}
|
|
516
|
+
setLegendSeriesVisible(dataKey, visible) {
|
|
517
|
+
this.legend?.setSeriesVisible(dataKey, visible);
|
|
518
|
+
return this;
|
|
519
|
+
}
|
|
520
|
+
toggleLegendSeries(dataKey) {
|
|
521
|
+
this.legend?.toggleSeries(dataKey);
|
|
522
|
+
return this;
|
|
523
|
+
}
|
|
524
|
+
setLegendVisibility(visibility) {
|
|
525
|
+
this.legend?.setVisibilityMap(visibility);
|
|
526
|
+
return this;
|
|
527
|
+
}
|
|
528
|
+
onLegendChange(callback) {
|
|
529
|
+
if (!this.legend) {
|
|
530
|
+
return () => { };
|
|
531
|
+
}
|
|
532
|
+
return this.legend.subscribe(callback);
|
|
533
|
+
}
|
|
313
534
|
/**
|
|
314
535
|
* Updates the chart with new data
|
|
315
536
|
*/
|
|
@@ -335,12 +556,82 @@ export class BaseChart {
|
|
|
335
556
|
if (this.container) {
|
|
336
557
|
this.container.innerHTML = '';
|
|
337
558
|
}
|
|
559
|
+
this.cleanupDisconnectedLegendContainer();
|
|
338
560
|
this.svg = null;
|
|
339
561
|
this.plotGroup = null;
|
|
340
562
|
this.plotArea = null;
|
|
341
563
|
this.x = null;
|
|
342
564
|
this.y = null;
|
|
343
565
|
}
|
|
566
|
+
renderDisconnectedLegend() {
|
|
567
|
+
if (!this.legend || !this.svg || !this.container) {
|
|
568
|
+
this.cleanupDisconnectedLegendContainer();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (!this.legend.isDisconnectedMode()) {
|
|
572
|
+
this.cleanupDisconnectedLegendContainer();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const series = this.getLegendSeries();
|
|
576
|
+
if (!series.length) {
|
|
577
|
+
this.cleanupDisconnectedLegendContainer();
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const legendHost = this.resolveDisconnectedLegendHost();
|
|
581
|
+
if (!legendHost) {
|
|
582
|
+
this.cleanupDisconnectedLegendContainer();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
legendHost.innerHTML = '';
|
|
586
|
+
const legendSvg = create('svg')
|
|
587
|
+
.attr('width', '100%')
|
|
588
|
+
.style('display', 'block');
|
|
589
|
+
legendHost.appendChild(legendSvg.node());
|
|
590
|
+
const svgNode = legendSvg.node();
|
|
591
|
+
if (!svgNode) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
this.legend.estimateLayoutSpace(series, this.theme, this.width, svgNode);
|
|
595
|
+
const legendHeight = Math.max(1, this.legend.getMeasuredHeight());
|
|
596
|
+
legendSvg.attr('height', legendHeight);
|
|
597
|
+
this.legend.render(legendSvg, series, this.theme, this.width);
|
|
598
|
+
}
|
|
599
|
+
resolveDisconnectedLegendHost() {
|
|
600
|
+
if (!this.legend || !this.container) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
const customTarget = this.legend.getDisconnectedTarget();
|
|
604
|
+
if (customTarget instanceof HTMLElement) {
|
|
605
|
+
this.disconnectedLegendContainer = customTarget;
|
|
606
|
+
return customTarget;
|
|
607
|
+
}
|
|
608
|
+
if (typeof customTarget === 'string') {
|
|
609
|
+
const target = document.querySelector(customTarget);
|
|
610
|
+
if (target instanceof HTMLElement) {
|
|
611
|
+
this.disconnectedLegendContainer = target;
|
|
612
|
+
return target;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (!this.disconnectedLegendContainer) {
|
|
616
|
+
const autoContainer = document.createElement('div');
|
|
617
|
+
autoContainer.className = 'chart-disconnected-legend';
|
|
618
|
+
this.container.insertAdjacentElement('afterend', autoContainer);
|
|
619
|
+
this.disconnectedLegendContainer = autoContainer;
|
|
620
|
+
}
|
|
621
|
+
return this.disconnectedLegendContainer;
|
|
622
|
+
}
|
|
623
|
+
cleanupDisconnectedLegendContainer() {
|
|
624
|
+
if (!this.disconnectedLegendContainer) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (this.disconnectedLegendContainer.classList.contains('chart-disconnected-legend')) {
|
|
628
|
+
this.disconnectedLegendContainer.remove();
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
this.disconnectedLegendContainer.innerHTML = '';
|
|
632
|
+
}
|
|
633
|
+
this.disconnectedLegendContainer = null;
|
|
634
|
+
}
|
|
344
635
|
parseValue(value) {
|
|
345
636
|
if (typeof value === 'string') {
|
|
346
637
|
return parseFloat(value);
|
|
@@ -387,7 +678,9 @@ export class BaseChart {
|
|
|
387
678
|
*/
|
|
388
679
|
downloadContent(content, format, options) {
|
|
389
680
|
const mimeType = this.getMimeType(format);
|
|
390
|
-
const blob = content instanceof Blob
|
|
681
|
+
const blob = content instanceof Blob
|
|
682
|
+
? content
|
|
683
|
+
: new Blob([content], { type: mimeType });
|
|
391
684
|
const filename = options.filename || `chart.${format}`;
|
|
392
685
|
const url = URL.createObjectURL(blob);
|
|
393
686
|
const link = document.createElement('a');
|
|
@@ -434,7 +727,8 @@ export class BaseChart {
|
|
|
434
727
|
async exportImage(format, options) {
|
|
435
728
|
const { width, height } = this.exportSize(options);
|
|
436
729
|
const svg = this.exportSVG(options, format);
|
|
437
|
-
const backgroundColor = options?.backgroundColor ??
|
|
730
|
+
const backgroundColor = options?.backgroundColor ??
|
|
731
|
+
(format === 'jpg' ? '#ffffff' : undefined);
|
|
438
732
|
return exportRasterBlob({
|
|
439
733
|
format,
|
|
440
734
|
svg,
|
package/donut-center-content.js
CHANGED
|
@@ -67,7 +67,8 @@ export class DonutCenterContent {
|
|
|
67
67
|
const style = this.config.mainValueStyle;
|
|
68
68
|
elements.push({
|
|
69
69
|
text: this.mainValue,
|
|
70
|
-
fontSize: (style?.fontSize ?? defaults.mainValue.fontSize) *
|
|
70
|
+
fontSize: (style?.fontSize ?? defaults.mainValue.fontSize) *
|
|
71
|
+
fontScale,
|
|
71
72
|
fontWeight: style?.fontWeight ?? defaults.mainValue.fontWeight,
|
|
72
73
|
fontFamily: style?.fontFamily ??
|
|
73
74
|
defaults.mainValue.fontFamily ??
|
package/donut-chart.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataItem } from './types.js';
|
|
1
|
+
import type { DataItem, LegendSeries } from './types.js';
|
|
2
2
|
import { BaseChart, type BaseChartConfig } from './base-chart.js';
|
|
3
3
|
import type { ChartComponent, LayoutAwareComponent } from './chart-interface.js';
|
|
4
4
|
export type DonutConfig = {
|
|
@@ -26,9 +26,12 @@ export declare class DonutChart extends BaseChart {
|
|
|
26
26
|
protected getExportComponents(): ChartComponent[];
|
|
27
27
|
update(data: DataItem[]): void;
|
|
28
28
|
protected getLayoutComponents(): LayoutAwareComponent[];
|
|
29
|
+
protected prepareLayout(): void;
|
|
29
30
|
protected createExportChart(): BaseChart;
|
|
31
|
+
protected applyComponentOverrides(overrides: Map<ChartComponent, ChartComponent>): () => void;
|
|
30
32
|
protected renderChart(): void;
|
|
31
33
|
private resolveFontScale;
|
|
34
|
+
protected getLegendSeries(): LegendSeries[];
|
|
32
35
|
private positionTooltip;
|
|
33
36
|
private buildTooltipContent;
|
|
34
37
|
private renderSegments;
|
package/donut-chart.js
CHANGED
|
@@ -87,7 +87,12 @@ export class DonutChart extends BaseChart {
|
|
|
87
87
|
}
|
|
88
88
|
else if (type === 'legend') {
|
|
89
89
|
this.legend = component;
|
|
90
|
-
this.legend.setToggleCallback(() =>
|
|
90
|
+
this.legend.setToggleCallback(() => {
|
|
91
|
+
if (!this.container) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.update(this.data);
|
|
95
|
+
});
|
|
91
96
|
}
|
|
92
97
|
else if (type === 'title') {
|
|
93
98
|
this.title = component;
|
|
@@ -108,7 +113,7 @@ export class DonutChart extends BaseChart {
|
|
|
108
113
|
if (this.tooltip) {
|
|
109
114
|
components.push(this.tooltip);
|
|
110
115
|
}
|
|
111
|
-
if (this.legend) {
|
|
116
|
+
if (this.legend?.isInlineMode()) {
|
|
112
117
|
components.push(this.legend);
|
|
113
118
|
}
|
|
114
119
|
return components;
|
|
@@ -129,10 +134,17 @@ export class DonutChart extends BaseChart {
|
|
|
129
134
|
}
|
|
130
135
|
return components;
|
|
131
136
|
}
|
|
137
|
+
prepareLayout() {
|
|
138
|
+
const svgNode = this.svg?.node();
|
|
139
|
+
if (svgNode && this.legend?.isInlineMode()) {
|
|
140
|
+
this.legend.estimateLayoutSpace(this.getLegendSeries(), this.theme, this.width, svgNode);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
132
143
|
createExportChart() {
|
|
133
144
|
return new DonutChart({
|
|
134
145
|
data: this.data,
|
|
135
146
|
theme: this.theme,
|
|
147
|
+
responsive: this.responsiveConfig,
|
|
136
148
|
donut: {
|
|
137
149
|
innerRadius: this.innerRadiusRatio,
|
|
138
150
|
padAngle: this.padAngle,
|
|
@@ -142,6 +154,23 @@ export class DonutChart extends BaseChart {
|
|
|
142
154
|
labelKey: this.labelKey,
|
|
143
155
|
});
|
|
144
156
|
}
|
|
157
|
+
applyComponentOverrides(overrides) {
|
|
158
|
+
const restoreBase = super.applyComponentOverrides(overrides);
|
|
159
|
+
if (overrides.size === 0) {
|
|
160
|
+
return restoreBase;
|
|
161
|
+
}
|
|
162
|
+
const previousCenterContent = this.centerContent;
|
|
163
|
+
if (this.centerContent) {
|
|
164
|
+
const override = overrides.get(this.centerContent);
|
|
165
|
+
if (override && override.type === 'donutCenterContent') {
|
|
166
|
+
this.centerContent = override;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return () => {
|
|
170
|
+
this.centerContent = previousCenterContent;
|
|
171
|
+
restoreBase();
|
|
172
|
+
};
|
|
173
|
+
}
|
|
145
174
|
renderChart() {
|
|
146
175
|
if (!this.plotArea || !this.svg || !this.plotGroup) {
|
|
147
176
|
throw new Error('Plot area not calculated');
|
|
@@ -165,21 +194,27 @@ export class DonutChart extends BaseChart {
|
|
|
165
194
|
if (this.centerContent) {
|
|
166
195
|
this.centerContent.render(this.svg, cx, cy, this.theme, fontScale);
|
|
167
196
|
}
|
|
168
|
-
if (this.legend) {
|
|
197
|
+
if (this.legend?.isInlineMode()) {
|
|
169
198
|
const pos = this.layoutManager.getComponentPosition(this.legend);
|
|
170
|
-
|
|
171
|
-
dataKey: seg.label,
|
|
172
|
-
fill: seg.color,
|
|
173
|
-
}));
|
|
174
|
-
this.legend.render(this.svg, legendSeries, this.theme, this.width, pos.x, pos.y);
|
|
199
|
+
this.legend.render(this.svg, this.getLegendSeries(), this.theme, this.width, pos.x, pos.y);
|
|
175
200
|
}
|
|
176
201
|
}
|
|
177
202
|
resolveFontScale(outerRadius) {
|
|
178
|
-
const plotHeight = Math.max(1, this.theme.height -
|
|
203
|
+
const plotHeight = Math.max(1, this.theme.height -
|
|
204
|
+
this.theme.margins.top -
|
|
205
|
+
this.theme.margins.bottom);
|
|
179
206
|
const referenceRadius = Math.max(1, plotHeight / 2);
|
|
180
207
|
const rawScale = outerRadius / referenceRadius;
|
|
181
208
|
return Math.max(0.5, Math.min(1, rawScale));
|
|
182
209
|
}
|
|
210
|
+
getLegendSeries() {
|
|
211
|
+
return this.segments.map((segment) => {
|
|
212
|
+
return {
|
|
213
|
+
dataKey: segment.label,
|
|
214
|
+
fill: segment.color,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
183
218
|
positionTooltip(event, tooltipDiv) {
|
|
184
219
|
const node = tooltipDiv.node();
|
|
185
220
|
if (!node)
|