@internetstiftelsen/charts 0.7.1 → 0.9.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/base-chart.js CHANGED
@@ -139,12 +139,24 @@ export class BaseChart {
139
139
  writable: true,
140
140
  value: null
141
141
  });
142
+ Object.defineProperty(this, "readyPromise", {
143
+ enumerable: true,
144
+ configurable: true,
145
+ writable: true,
146
+ value: Promise.resolve()
147
+ });
142
148
  Object.defineProperty(this, "disconnectedLegendContainer", {
143
149
  enumerable: true,
144
150
  configurable: true,
145
151
  writable: true,
146
152
  value: null
147
153
  });
154
+ Object.defineProperty(this, "renderThemeOverride", {
155
+ enumerable: true,
156
+ configurable: true,
157
+ writable: true,
158
+ value: null
159
+ });
148
160
  const normalized = normalizeChartData(config.data);
149
161
  ChartValidator.validateData(normalized.data);
150
162
  this.sourceData = config.data;
@@ -156,6 +168,13 @@ export class BaseChart {
156
168
  this.responsiveConfig = config.responsive;
157
169
  this.layoutManager = new LayoutManager(this.theme);
158
170
  }
171
+ /**
172
+ * Adds a component (axis, grid, tooltip, etc.) to the chart
173
+ */
174
+ addChild(component) {
175
+ this.registerBaseComponent(component);
176
+ return this;
177
+ }
159
178
  /**
160
179
  * Renders the chart to the specified target element
161
180
  */
@@ -203,8 +222,9 @@ export class BaseChart {
203
222
  const renderTheme = this.resolveRenderTheme(responsiveOverrides);
204
223
  const overrideComponents = this.createOverrideComponents(mergedComponentOverrides);
205
224
  const restoreComponents = this.applyComponentOverrides(overrideComponents);
206
- const restoreTheme = this.applyThemeOverride(renderTheme);
225
+ const restoreTheme = this.applyRenderTheme(renderTheme);
207
226
  try {
227
+ this.setReadyPromise(Promise.resolve());
208
228
  // Clear and setup SVG
209
229
  this.container.innerHTML = '';
210
230
  this.svg = create('svg')
@@ -212,20 +232,34 @@ export class BaseChart {
212
232
  .attr('height', dimensions.svgHeightAttr)
213
233
  .style('display', 'block');
214
234
  this.container.appendChild(this.svg.node());
215
- this.prepareLayout();
235
+ const svgNode = this.svg.node();
236
+ if (!svgNode) {
237
+ throw new Error('Failed to initialize chart SVG');
238
+ }
239
+ this.prepareLayout({
240
+ svg: this.svg,
241
+ svgNode,
242
+ });
216
243
  // Calculate layout
217
244
  const layoutTheme = {
218
- ...this.theme,
245
+ ...this.renderTheme,
219
246
  width: this.width,
220
247
  height: this.height,
221
248
  };
222
249
  this.layoutManager = new LayoutManager(layoutTheme);
223
250
  const components = this.getLayoutComponents();
224
- this.plotArea = this.layoutManager.calculateLayout(components);
251
+ const plotArea = this.layoutManager.calculateLayout(components);
252
+ this.plotArea = plotArea;
225
253
  // Create plot group
226
- this.plotGroup = this.svg.append('g').attr('class', 'chart-plot');
254
+ const plotGroup = this.svg.append('g').attr('class', 'chart-plot');
255
+ this.plotGroup = plotGroup;
227
256
  // Render chart content
228
- this.renderChart();
257
+ this.renderChart({
258
+ svg: this.svg,
259
+ svgNode,
260
+ plotGroup,
261
+ plotArea,
262
+ });
229
263
  this.renderDisconnectedLegend();
230
264
  }
231
265
  finally {
@@ -270,58 +304,84 @@ export class BaseChart {
270
304
  }
271
305
  return mergeDeep(this.theme, responsiveOverrides.theme);
272
306
  }
273
- applyThemeOverride(theme) {
307
+ applyRenderTheme(theme) {
274
308
  if (theme === this.theme) {
275
309
  return () => { };
276
310
  }
277
- const originalTheme = this.theme;
278
- this.theme = theme;
311
+ this.renderThemeOverride = theme;
279
312
  return () => {
280
- this.theme = originalTheme;
313
+ this.renderThemeOverride = null;
281
314
  };
282
315
  }
316
+ get renderTheme() {
317
+ return this.renderThemeOverride ?? this.theme;
318
+ }
283
319
  /**
284
320
  * Get layout-aware components in order
285
321
  * Override in subclasses to provide chart-specific components
286
322
  */
287
323
  getLayoutComponents() {
324
+ return this.getBaseLayoutComponents({
325
+ title: true,
326
+ xAxis: true,
327
+ yAxis: true,
328
+ inlineLegend: true,
329
+ });
330
+ }
331
+ getBaseLayoutComponents(options) {
288
332
  const components = [];
289
- if (this.title) {
333
+ if (options.title && this.title) {
290
334
  components.push(this.title);
291
335
  }
292
- if (this.xAxis) {
336
+ if (options.xAxis && this.xAxis) {
293
337
  components.push(this.xAxis);
294
338
  }
295
- if (this.yAxis) {
339
+ if (options.yAxis && this.yAxis) {
296
340
  components.push(this.yAxis);
297
341
  }
298
- if (this.legend?.isInlineMode()) {
342
+ if (options.inlineLegend && this.legend?.isInlineMode()) {
299
343
  components.push(this.legend);
300
344
  }
301
345
  return components;
302
346
  }
303
347
  getExportComponents() {
348
+ return this.getBaseExportComponents({
349
+ title: true,
350
+ grid: true,
351
+ xAxis: true,
352
+ yAxis: true,
353
+ tooltip: true,
354
+ legend: true,
355
+ });
356
+ }
357
+ getOverrideableComponents() {
358
+ return this.getExportComponents();
359
+ }
360
+ getBaseExportComponents(options) {
304
361
  const components = [];
305
- if (this.title) {
362
+ if (options.title && this.title) {
306
363
  components.push(this.title);
307
364
  }
308
- if (this.grid) {
365
+ if (options.grid && this.grid) {
309
366
  components.push(this.grid);
310
367
  }
311
- if (this.xAxis) {
368
+ if (options.xAxis && this.xAxis) {
312
369
  components.push(this.xAxis);
313
370
  }
314
- if (this.yAxis) {
371
+ if (options.yAxis && this.yAxis) {
315
372
  components.push(this.yAxis);
316
373
  }
317
- if (this.tooltip) {
374
+ if (options.tooltip && this.tooltip) {
318
375
  components.push(this.tooltip);
319
376
  }
320
- if (this.legend) {
377
+ if (options.legend && this.legend) {
321
378
  components.push(this.legend);
322
379
  }
323
380
  return components;
324
381
  }
382
+ registerBaseComponent(component) {
383
+ return this.tryRegisterComponent(component, this.getBaseComponentSlots());
384
+ }
325
385
  collectExportOverrides(context) {
326
386
  const overrides = new Map();
327
387
  const components = this.getExportComponents();
@@ -339,7 +399,7 @@ export class BaseChart {
339
399
  }
340
400
  collectResponsiveOverrides(context) {
341
401
  const beforeRender = this.responsiveConfig?.beforeRender;
342
- const components = this.getExportComponents();
402
+ const components = this.getOverrideableComponents();
343
403
  const componentOverrides = new Map();
344
404
  if (!beforeRender) {
345
405
  return {
@@ -418,38 +478,7 @@ export class BaseChart {
418
478
  return overrideComponents;
419
479
  }
420
480
  applyComponentOverrides(overrides) {
421
- if (overrides.size === 0) {
422
- return () => { };
423
- }
424
- const previousState = {
425
- title: this.title,
426
- grid: this.grid,
427
- xAxis: this.xAxis,
428
- yAxis: this.yAxis,
429
- tooltip: this.tooltip,
430
- legend: this.legend,
431
- };
432
- const resolve = (component) => {
433
- if (!component) {
434
- return component;
435
- }
436
- const override = overrides.get(component);
437
- return override ?? component;
438
- };
439
- this.title = resolve(this.title);
440
- this.grid = resolve(this.grid);
441
- this.xAxis = resolve(this.xAxis);
442
- this.yAxis = resolve(this.yAxis);
443
- this.tooltip = resolve(this.tooltip);
444
- this.legend = resolve(this.legend);
445
- return () => {
446
- this.title = previousState.title;
447
- this.grid = previousState.grid;
448
- this.xAxis = previousState.xAxis;
449
- this.yAxis = previousState.yAxis;
450
- this.tooltip = previousState.tooltip;
451
- this.legend = previousState.legend;
452
- };
481
+ return this.applySlotOverrides(overrides, this.getBaseComponentSlots());
453
482
  }
454
483
  renderExportChart(chart, width, height) {
455
484
  const container = document.createElement('div');
@@ -465,18 +494,59 @@ export class BaseChart {
465
494
  container.style.visibility = 'hidden';
466
495
  document.body.appendChild(container);
467
496
  chart.render(`#${containerId}`);
468
- const svg = chart.svg?.node();
469
- if (!svg) {
497
+ return chart
498
+ .whenReady()
499
+ .then(() => {
500
+ const svg = chart.svg?.node();
501
+ if (!svg) {
502
+ throw new Error('Failed to render export SVG');
503
+ }
504
+ return svg;
505
+ })
506
+ .finally(() => {
470
507
  chart.destroy();
471
508
  document.body.removeChild(container);
472
- throw new Error('Failed to render export SVG');
509
+ });
510
+ }
511
+ renderTitle(svg) {
512
+ if (!this.title) {
513
+ return;
514
+ }
515
+ const position = this.layoutManager.getComponentPosition(this.title);
516
+ this.title.render(svg, this.renderTheme, this.width, position.x, position.y);
517
+ }
518
+ renderInlineLegend(svg) {
519
+ if (!this.legend?.isInlineMode()) {
520
+ return;
521
+ }
522
+ const position = this.layoutManager.getComponentPosition(this.legend);
523
+ this.legend.render(svg, this.getLegendSeries(), this.renderTheme, this.width, position.x, position.y);
524
+ }
525
+ measureInlineLegend(svgNode) {
526
+ if (!this.legend?.isInlineMode()) {
527
+ return;
473
528
  }
474
- chart.destroy();
475
- document.body.removeChild(container);
476
- return svg;
529
+ this.legend.estimateLayoutSpace(this.getLegendSeries(), this.renderTheme, this.width, svgNode);
530
+ }
531
+ filterVisibleItems(items, getDataKey) {
532
+ const { legend } = this;
533
+ if (!legend) {
534
+ return items;
535
+ }
536
+ return items.filter((item) => {
537
+ return legend.isSeriesVisible(getDataKey(item));
538
+ });
539
+ }
540
+ validateSourceData(_data) { }
541
+ syncDerivedState(_previousData) { }
542
+ initializeDataState() {
543
+ this.validateSourceData(this.sourceData);
544
+ this.syncDerivedState();
477
545
  }
478
546
  // Hook for subclasses to update component layout estimates before layout calc
479
- prepareLayout() { }
547
+ prepareLayout(context) {
548
+ this.measureInlineLegend(context.svgNode);
549
+ }
480
550
  /**
481
551
  * Setup ResizeObserver for automatic resize handling
482
552
  */
@@ -486,9 +556,15 @@ export class BaseChart {
486
556
  if (this.resizeObserver) {
487
557
  this.resizeObserver.disconnect();
488
558
  }
489
- this.resizeObserver = new ResizeObserver(() => this.performRender());
559
+ this.resizeObserver = new ResizeObserver(() => this.rerender());
490
560
  this.resizeObserver.observe(this.container);
491
561
  }
562
+ setReadyPromise(promise) {
563
+ this.readyPromise = promise;
564
+ }
565
+ whenReady() {
566
+ return this.readyPromise;
567
+ }
492
568
  getLegendSeries() {
493
569
  return [];
494
570
  }
@@ -532,14 +608,17 @@ export class BaseChart {
532
608
  * Updates the chart with new data
533
609
  */
534
610
  update(data) {
611
+ if (!this.container) {
612
+ throw new Error('Chart must be rendered before update()');
613
+ }
614
+ this.validateSourceData(data);
535
615
  const normalized = normalizeChartData(data);
536
616
  ChartValidator.validateData(normalized.data);
617
+ const previousData = this.data;
537
618
  this.sourceData = data;
538
619
  this.data = normalized.data;
539
- if (!this.container) {
540
- throw new Error('Chart must be rendered before update()');
541
- }
542
- this.performRender();
620
+ this.syncDerivedState(previousData);
621
+ this.rerender();
543
622
  }
544
623
  /**
545
624
  * Destroys the chart and cleans up resources
@@ -588,10 +667,10 @@ export class BaseChart {
588
667
  if (!svgNode) {
589
668
  return;
590
669
  }
591
- this.legend.estimateLayoutSpace(series, this.theme, this.width, svgNode);
670
+ this.legend.estimateLayoutSpace(series, this.renderTheme, this.width, svgNode);
592
671
  const legendHeight = Math.max(1, this.legend.getMeasuredHeight());
593
672
  legendSvg.attr('height', legendHeight);
594
- this.legend.render(legendSvg, series, this.theme, this.width);
673
+ this.legend.render(legendSvg, series, this.renderTheme, this.width);
595
674
  }
596
675
  resolveDisconnectedLegendHost() {
597
676
  if (!this.legend || !this.container) {
@@ -638,6 +717,109 @@ export class BaseChart {
638
717
  }
639
718
  return 0;
640
719
  }
720
+ rerender() {
721
+ if (!this.container) {
722
+ return;
723
+ }
724
+ this.performRender();
725
+ }
726
+ tryRegisterComponent(component, slots) {
727
+ const slot = slots.find((entry) => entry.type === component.type);
728
+ if (!slot) {
729
+ return false;
730
+ }
731
+ slot.set(component);
732
+ slot.onRegister?.(component);
733
+ return true;
734
+ }
735
+ applySlotOverrides(overrides, slots) {
736
+ if (overrides.size === 0) {
737
+ return () => { };
738
+ }
739
+ const previousState = new Map();
740
+ slots.forEach((slot) => {
741
+ previousState.set(slot, slot.get());
742
+ const current = slot.get();
743
+ if (!current) {
744
+ return;
745
+ }
746
+ const override = overrides.get(current);
747
+ if (override) {
748
+ slot.set(override);
749
+ }
750
+ });
751
+ return () => {
752
+ slots.forEach((slot) => {
753
+ slot.set(previousState.get(slot) ?? null);
754
+ });
755
+ };
756
+ }
757
+ applyArrayComponentOverrides(components, overrides, isComponent) {
758
+ if (overrides.size === 0) {
759
+ return () => { };
760
+ }
761
+ const previousState = [...components];
762
+ components.forEach((component, index) => {
763
+ const override = overrides.get(component);
764
+ if (override && isComponent(override)) {
765
+ components[index] = override;
766
+ }
767
+ });
768
+ return () => {
769
+ components.splice(0, components.length, ...previousState);
770
+ };
771
+ }
772
+ getBaseComponentSlots() {
773
+ return [
774
+ {
775
+ type: 'title',
776
+ get: () => this.title,
777
+ set: (component) => {
778
+ this.title = component;
779
+ },
780
+ },
781
+ {
782
+ type: 'grid',
783
+ get: () => this.grid,
784
+ set: (component) => {
785
+ this.grid = component;
786
+ },
787
+ },
788
+ {
789
+ type: 'xAxis',
790
+ get: () => this.xAxis,
791
+ set: (component) => {
792
+ this.xAxis = component;
793
+ },
794
+ },
795
+ {
796
+ type: 'yAxis',
797
+ get: () => this.yAxis,
798
+ set: (component) => {
799
+ this.yAxis = component;
800
+ },
801
+ },
802
+ {
803
+ type: 'tooltip',
804
+ get: () => this.tooltip,
805
+ set: (component) => {
806
+ this.tooltip = component;
807
+ },
808
+ },
809
+ {
810
+ type: 'legend',
811
+ get: () => this.legend,
812
+ set: (component) => {
813
+ this.legend = component;
814
+ },
815
+ onRegister: (component) => {
816
+ component.setToggleCallback(() => {
817
+ this.rerender();
818
+ });
819
+ },
820
+ },
821
+ ];
822
+ }
641
823
  /**
642
824
  * Exports the chart in the specified format
643
825
  * @param format - The export format
@@ -647,7 +829,7 @@ export class BaseChart {
647
829
  async export(format, options) {
648
830
  let content;
649
831
  if (format === 'svg') {
650
- content = this.exportSVG(options, 'svg');
832
+ content = await this.exportSVG(options, 'svg');
651
833
  }
652
834
  else if (format === 'json') {
653
835
  content = this.exportJSON();
@@ -723,7 +905,7 @@ export class BaseChart {
723
905
  }
724
906
  async exportImage(format, options) {
725
907
  const { width, height } = this.exportSize(options);
726
- const svg = this.exportSVG(options, format);
908
+ const svg = await this.exportSVG(options, format);
727
909
  const backgroundColor = options?.backgroundColor ??
728
910
  (format === 'jpg' ? '#ffffff' : undefined);
729
911
  return exportRasterBlob({
@@ -738,7 +920,7 @@ export class BaseChart {
738
920
  }
739
921
  async exportPDF(options) {
740
922
  const { width, height } = this.exportSize(options);
741
- const svg = this.exportSVG(options, 'pdf');
923
+ const svg = await this.exportSVG(options, 'pdf');
742
924
  const pngBlob = await exportRasterBlob({
743
925
  format: 'png',
744
926
  svg,
@@ -754,14 +936,19 @@ export class BaseChart {
754
936
  margin: options?.pdfMargin ?? 0,
755
937
  });
756
938
  }
757
- exportSVG(options, formatForHooks = 'svg') {
939
+ async exportSVG(options, formatForHooks = 'svg') {
758
940
  if (!this.svg) {
759
941
  throw new Error('Chart must be rendered before export');
760
942
  }
943
+ await this.whenReady();
944
+ const liveSvg = this.svg?.node();
945
+ if (!liveSvg) {
946
+ throw new Error('Chart must remain mounted until export completes');
947
+ }
761
948
  const exportWidth = options?.width ?? this.width;
762
949
  const exportHeight = options?.height ?? this.height;
763
950
  const requiresExportRender = exportWidth !== this.width || exportHeight !== this.height;
764
- const clone = this.svg.node().cloneNode(true);
951
+ const clone = liveSvg.cloneNode(true);
765
952
  clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
766
953
  clone.setAttribute('width', String(exportWidth));
767
954
  clone.setAttribute('height', String(exportHeight));
@@ -791,7 +978,7 @@ export class BaseChart {
791
978
  exportChart.addChild(component);
792
979
  }
793
980
  });
794
- const exportSvg = this.renderExportChart(exportChart, exportWidth, exportHeight);
981
+ const exportSvg = await this.renderExportChart(exportChart, exportWidth, exportHeight);
795
982
  exportSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
796
983
  exportSvg.setAttribute('width', String(exportWidth));
797
984
  exportSvg.setAttribute('height', String(exportHeight));
package/donut-chart.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { DataItem, LegendSeries } from './types.js';
2
- import { BaseChart, type BaseChartConfig } from './base-chart.js';
3
- import type { ChartComponent, LayoutAwareComponent } from './chart-interface.js';
2
+ import { type BaseChartConfig, type BaseRenderContext } from './base-chart.js';
3
+ import type { ChartComponent } from './chart-interface.js';
4
+ import { RadialChartBase } from './radial-chart-base.js';
4
5
  export type DonutConfig = {
5
6
  innerRadius?: number;
6
7
  padAngle?: number;
@@ -11,7 +12,7 @@ export type DonutChartConfig = BaseChartConfig & {
11
12
  valueKey?: string;
12
13
  labelKey?: string;
13
14
  };
14
- export declare class DonutChart extends BaseChart {
15
+ export declare class DonutChart extends RadialChartBase {
15
16
  private readonly innerRadiusRatio;
16
17
  private readonly padAngle;
17
18
  private readonly cornerRadius;
@@ -25,14 +26,11 @@ export declare class DonutChart extends BaseChart {
25
26
  addChild(component: ChartComponent): this;
26
27
  protected getExportComponents(): ChartComponent[];
27
28
  update(data: DataItem[]): void;
28
- protected getLayoutComponents(): LayoutAwareComponent[];
29
- protected prepareLayout(): void;
30
- protected createExportChart(): BaseChart;
29
+ protected createExportChart(): RadialChartBase;
31
30
  protected applyComponentOverrides(overrides: Map<ChartComponent, ChartComponent>): () => void;
32
- protected renderChart(): void;
33
- private resolveFontScale;
31
+ protected syncDerivedState(): void;
32
+ protected renderChart({ svg, plotGroup, plotArea, }: BaseRenderContext): void;
34
33
  protected getLegendSeries(): LegendSeries[];
35
- private positionTooltip;
36
34
  private buildTooltipContent;
37
35
  private renderSegments;
38
36
  }