@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 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
- // Clear and setup SVG
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
- this.layoutManager = new LayoutManager(layoutTheme);
196
- const components = this.getLayoutComponents();
197
- this.plotArea = this.layoutManager.calculateLayout(components);
198
- // Create plot group
199
- this.plotGroup = this.svg.append('g').attr('class', 'chart-plot');
200
- // Render chart content
201
- this.renderChart();
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 ? content : new Blob([content], { type: mimeType });
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 ?? (format === 'jpg' ? '#ffffff' : undefined);
730
+ const backgroundColor = options?.backgroundColor ??
731
+ (format === 'jpg' ? '#ffffff' : undefined);
438
732
  return exportRasterBlob({
439
733
  format,
440
734
  svg,
@@ -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) * fontScale,
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(() => this.update(this.data));
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
- const legendSeries = this.segments.map((seg) => ({
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 - this.theme.margins.top - this.theme.margins.bottom);
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)