@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.
@@ -1,5 +1,5 @@
1
1
  import { type Selection } from 'd3';
2
- import type { ChartData, DataItem, ChartTheme, ResolvedChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale, ExportHookContext, ExportRenderContext, LegendItem, LegendSeries, ResponsiveConfig, ResponsiveRenderContext, DeepPartial } from './types.js';
2
+ import type { ChartData, DataItem, ChartTheme, ResolvedChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale, ExportHookContext, ExportRenderContext, LegendItem, LegendMode, LegendSeries, ResponsiveConfig, ResponsiveRenderContext, DeepPartial } from './types.js';
3
3
  import type { ChartComponentBase, LayoutAwareComponentBase } from './chart-interface.js';
4
4
  import type { XAxis } from './x-axis.js';
5
5
  import type { YAxis } from './y-axis.js';
@@ -8,6 +8,7 @@ import type { Tooltip } from './tooltip.js';
8
8
  import type { Legend } from './legend.js';
9
9
  import type { Title } from './title.js';
10
10
  import { LayoutManager, type PlotAreaBounds } from './layout-manager.js';
11
+ import { LegendStateController } from './legend-state.js';
11
12
  type VisualExportFormat = 'svg' | 'png' | 'jpg' | 'pdf';
12
13
  type RenderDimensions = {
13
14
  width: number;
@@ -82,10 +83,13 @@ export declare abstract class BaseChart {
82
83
  protected resizeObserver: ResizeObserver | null;
83
84
  protected layoutManager: LayoutManager;
84
85
  protected plotArea: PlotAreaBounds | null;
86
+ protected readonly legendState: LegendStateController;
85
87
  private readyPromise;
86
88
  private disconnectedLegendContainer;
87
89
  private renderThemeOverride;
88
90
  private renderSizeOverride;
91
+ private readonly renderCallbacks;
92
+ private legendModeOverride;
89
93
  protected constructor(config: BaseChartConfig);
90
94
  /**
91
95
  * Adds a component (axis, grid, tooltip, etc.) to the chart
@@ -112,6 +116,17 @@ export declare abstract class BaseChart {
112
116
  private applyRenderTheme;
113
117
  protected get renderTheme(): ChartTheme;
114
118
  protected get resolvedRenderTheme(): ResolvedChartTheme;
119
+ protected getLegendMode(): LegendMode | null;
120
+ protected hasInlineLegend(): boolean;
121
+ protected hasDisconnectedLegend(): boolean;
122
+ protected shouldIncludeLegendInExport(): boolean;
123
+ getConfiguredSize(): {
124
+ width?: number;
125
+ height?: number;
126
+ };
127
+ setLegendModeOverride(mode: LegendMode | null, rerender?: boolean): this;
128
+ onRender(callback: () => void): () => void;
129
+ private notifyRendered;
115
130
  /**
116
131
  * Get layout-aware components in order
117
132
  * Override in subclasses to provide chart-specific components
@@ -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 { LegendStateController } from './legend-state.js';
10
11
  import { mergeDeep } from './utils.js';
11
12
  function normalizeChartDimension(value, name) {
12
13
  if (value === undefined) {
@@ -182,6 +183,12 @@ export class BaseChart {
182
183
  writable: true,
183
184
  value: null
184
185
  });
186
+ Object.defineProperty(this, "legendState", {
187
+ enumerable: true,
188
+ configurable: true,
189
+ writable: true,
190
+ value: void 0
191
+ });
185
192
  Object.defineProperty(this, "readyPromise", {
186
193
  enumerable: true,
187
194
  configurable: true,
@@ -206,6 +213,18 @@ export class BaseChart {
206
213
  writable: true,
207
214
  value: null
208
215
  });
216
+ Object.defineProperty(this, "renderCallbacks", {
217
+ enumerable: true,
218
+ configurable: true,
219
+ writable: true,
220
+ value: new Set()
221
+ });
222
+ Object.defineProperty(this, "legendModeOverride", {
223
+ enumerable: true,
224
+ configurable: true,
225
+ writable: true,
226
+ value: null
227
+ });
209
228
  const normalized = normalizeChartData(config.data);
210
229
  ChartValidator.validateData(normalized.data);
211
230
  this.sourceData = config.data;
@@ -217,6 +236,10 @@ export class BaseChart {
217
236
  this.height = this.configuredHeight ?? DEFAULT_CHART_HEIGHT;
218
237
  this.scaleConfig = config.scales || {};
219
238
  this.responsiveConfig = config.responsive;
239
+ this.legendState = new LegendStateController();
240
+ this.legendState.subscribe(() => {
241
+ this.rerender();
242
+ });
220
243
  this.layoutManager = new LayoutManager(this.resolvedRenderTheme);
221
244
  }
222
245
  /**
@@ -309,6 +332,7 @@ export class BaseChart {
309
332
  plotArea,
310
333
  });
311
334
  this.renderDisconnectedLegend();
335
+ this.notifyRendered();
312
336
  }
313
337
  finally {
314
338
  restoreComponents();
@@ -401,6 +425,49 @@ export class BaseChart {
401
425
  height: this.height,
402
426
  };
403
427
  }
428
+ getLegendMode() {
429
+ if (!this.legend) {
430
+ return null;
431
+ }
432
+ return this.legendModeOverride ?? this.legend.mode;
433
+ }
434
+ hasInlineLegend() {
435
+ return this.getLegendMode() === 'inline';
436
+ }
437
+ hasDisconnectedLegend() {
438
+ return this.getLegendMode() === 'disconnected';
439
+ }
440
+ shouldIncludeLegendInExport() {
441
+ const legendMode = this.getLegendMode();
442
+ return !!this.legend && legendMode !== null && legendMode !== 'hidden';
443
+ }
444
+ getConfiguredSize() {
445
+ return {
446
+ width: this.configuredWidth,
447
+ height: this.configuredHeight,
448
+ };
449
+ }
450
+ setLegendModeOverride(mode, rerender = true) {
451
+ if (this.legendModeOverride === mode) {
452
+ return this;
453
+ }
454
+ this.legendModeOverride = mode;
455
+ if (rerender) {
456
+ this.rerender();
457
+ }
458
+ return this;
459
+ }
460
+ onRender(callback) {
461
+ this.renderCallbacks.add(callback);
462
+ return () => {
463
+ this.renderCallbacks.delete(callback);
464
+ };
465
+ }
466
+ notifyRendered() {
467
+ this.renderCallbacks.forEach((callback) => {
468
+ callback();
469
+ });
470
+ }
404
471
  /**
405
472
  * Get layout-aware components in order
406
473
  * Override in subclasses to provide chart-specific components
@@ -424,7 +491,7 @@ export class BaseChart {
424
491
  if (options.yAxis && this.yAxis) {
425
492
  components.push(this.yAxis);
426
493
  }
427
- if (options.inlineLegend && this.legend?.isInlineMode()) {
494
+ if (options.inlineLegend && this.legend && this.hasInlineLegend()) {
428
495
  components.push(this.legend);
429
496
  }
430
497
  return components;
@@ -459,7 +526,9 @@ export class BaseChart {
459
526
  if (options.tooltip && this.tooltip) {
460
527
  components.push(this.tooltip);
461
528
  }
462
- if (options.legend && this.legend) {
529
+ if (options.legend &&
530
+ this.legend &&
531
+ this.shouldIncludeLegendInExport()) {
463
532
  components.push(this.legend);
464
533
  }
465
534
  return components;
@@ -630,25 +699,25 @@ export class BaseChart {
630
699
  this.title.render(svg, this.renderTheme, this.width, position.x, position.y);
631
700
  }
632
701
  renderInlineLegend(svg) {
633
- if (!this.legend?.isInlineMode()) {
702
+ if (!this.legend || !this.hasInlineLegend()) {
634
703
  return;
635
704
  }
705
+ const series = this.getLegendSeries();
706
+ this.legendState.ensureSeries(series.map((entry) => entry.dataKey), { silent: true });
636
707
  const position = this.layoutManager.getComponentPosition(this.legend);
637
- this.legend.render(svg, this.getLegendSeries(), this.renderTheme, this.width, position.x, position.y);
708
+ this.legend.render(svg, series, this.renderTheme, this.width, position.x, position.y);
638
709
  }
639
710
  measureInlineLegend(svgNode) {
640
- if (!this.legend?.isInlineMode()) {
711
+ if (!this.legend || !this.hasInlineLegend()) {
641
712
  return;
642
713
  }
643
- this.legend.estimateLayoutSpace(this.getLegendSeries(), this.renderTheme, this.width, svgNode);
714
+ const series = this.getLegendSeries();
715
+ this.legendState.ensureSeries(series.map((entry) => entry.dataKey), { silent: true });
716
+ this.legend.estimateLayoutSpace(series, this.renderTheme, this.width, svgNode);
644
717
  }
645
718
  filterVisibleItems(items, getDataKey) {
646
- const { legend } = this;
647
- if (!legend) {
648
- return items;
649
- }
650
719
  return items.filter((item) => {
651
- return legend.isSeriesVisible(getDataKey(item));
720
+ return this.legendState.isSeriesVisible(getDataKey(item));
652
721
  });
653
722
  }
654
723
  validateSourceData(_data) { }
@@ -683,40 +752,31 @@ export class BaseChart {
683
752
  return [];
684
753
  }
685
754
  getLegendItems() {
686
- if (!this.legend) {
687
- return [];
688
- }
689
755
  return this.getLegendSeries().map((series) => {
690
756
  return {
691
757
  dataKey: series.dataKey,
692
758
  color: series.stroke || series.fill || '#8884d8',
693
- visible: this.legend.isSeriesVisible(series.dataKey),
759
+ visible: this.legendState.isSeriesVisible(series.dataKey),
694
760
  };
695
761
  });
696
762
  }
697
763
  isLegendSeriesVisible(dataKey) {
698
- if (!this.legend) {
699
- return true;
700
- }
701
- return this.legend.isSeriesVisible(dataKey);
764
+ return this.legendState.isSeriesVisible(dataKey);
702
765
  }
703
766
  setLegendSeriesVisible(dataKey, visible) {
704
- this.legend?.setSeriesVisible(dataKey, visible);
767
+ this.legendState.setSeriesVisible(dataKey, visible);
705
768
  return this;
706
769
  }
707
770
  toggleLegendSeries(dataKey) {
708
- this.legend?.toggleSeries(dataKey);
771
+ this.legendState.toggleSeries(dataKey);
709
772
  return this;
710
773
  }
711
774
  setLegendVisibility(visibility) {
712
- this.legend?.setVisibilityMap(visibility);
775
+ this.legendState.setVisibilityMap(visibility);
713
776
  return this;
714
777
  }
715
778
  onLegendChange(callback) {
716
- if (!this.legend) {
717
- return () => { };
718
- }
719
- return this.legend.subscribe(callback);
779
+ return this.legendState.subscribe(callback);
720
780
  }
721
781
  /**
722
782
  * Updates the chart with new data
@@ -758,7 +818,7 @@ export class BaseChart {
758
818
  this.cleanupDisconnectedLegendContainer();
759
819
  return;
760
820
  }
761
- if (!this.legend.isDisconnectedMode()) {
821
+ if (!this.hasDisconnectedLegend()) {
762
822
  this.cleanupDisconnectedLegendContainer();
763
823
  return;
764
824
  }
@@ -767,6 +827,7 @@ export class BaseChart {
767
827
  this.cleanupDisconnectedLegendContainer();
768
828
  return;
769
829
  }
830
+ this.legendState.ensureSeries(series.map((entry) => entry.dataKey), { silent: true });
770
831
  const legendHost = this.resolveDisconnectedLegendHost();
771
832
  if (!legendHost) {
772
833
  this.cleanupDisconnectedLegendContainer();
@@ -927,9 +988,7 @@ export class BaseChart {
927
988
  this.legend = component;
928
989
  },
929
990
  onRegister: (component) => {
930
- component.setToggleCallback(() => {
931
- this.rerender();
932
- });
991
+ component.setStateController(this.legendState);
933
992
  },
934
993
  },
935
994
  ];
@@ -0,0 +1,121 @@
1
+ import type { BaseChart } from './base-chart.js';
2
+ import type { ChartTheme, DeepPartial, ExportOptions, LegendItem } from './types.js';
3
+ type BreakpointRange = {
4
+ minWidth?: number;
5
+ maxWidth?: number;
6
+ };
7
+ export type ChartGroupResponsiveBreakpointConfig = BreakpointRange & {
8
+ cols?: number;
9
+ gap?: number;
10
+ };
11
+ export type ChartGroupResponsiveBreakpointDefinition = number | ChartGroupResponsiveBreakpointConfig;
12
+ export type ChartGroupResponsiveBreakpoints = Record<string, ChartGroupResponsiveBreakpointDefinition>;
13
+ export type ChartGroupResponsiveConfig = {
14
+ breakpoints?: ChartGroupResponsiveBreakpoints;
15
+ };
16
+ export type ChartGroupItemResponsiveBreakpointConfig = BreakpointRange & {
17
+ span?: number;
18
+ height?: number;
19
+ hidden?: boolean;
20
+ order?: number;
21
+ };
22
+ export type ChartGroupItemResponsiveBreakpointDefinition = number | ChartGroupItemResponsiveBreakpointConfig;
23
+ export type ChartGroupItemResponsiveBreakpoints = Record<string, ChartGroupItemResponsiveBreakpointDefinition>;
24
+ export type ChartGroupItemResponsiveConfig = {
25
+ breakpoints?: ChartGroupItemResponsiveBreakpoints;
26
+ };
27
+ export type ChartGroupConfig = {
28
+ cols: number;
29
+ gap?: number;
30
+ syncY?: boolean;
31
+ height?: number;
32
+ chartHeight?: number;
33
+ theme?: DeepPartial<ChartTheme>;
34
+ responsive?: ChartGroupResponsiveConfig;
35
+ };
36
+ export type ChartGroupChartOptions = {
37
+ span?: number;
38
+ height?: number;
39
+ hidden?: boolean;
40
+ order?: number;
41
+ responsive?: ChartGroupItemResponsiveConfig;
42
+ };
43
+ export type ChartGroupExportFormat = 'svg' | 'png' | 'jpg' | 'pdf';
44
+ export declare class ChartGroup {
45
+ private readonly cols;
46
+ private readonly gap;
47
+ private readonly syncY;
48
+ private readonly configuredHeight?;
49
+ private readonly chartHeight;
50
+ private readonly theme;
51
+ private readonly responsiveConfig?;
52
+ private readonly charts;
53
+ private readonly legendState;
54
+ private readonly renderCallbacks;
55
+ private readonly warnedWidthCharts;
56
+ private readonly warnedColorConflicts;
57
+ private container;
58
+ private legend;
59
+ private title;
60
+ private resizeObserver;
61
+ private readyPromise;
62
+ private childLegendSnapshot;
63
+ private childYDomainSnapshot;
64
+ private isRendering;
65
+ private isSyncingLegend;
66
+ private hasWarnedIncompatibleYScaleTypes;
67
+ constructor(config: ChartGroupConfig);
68
+ addChart(chart: BaseChart, options?: ChartGroupChartOptions): this;
69
+ addChild(component: {
70
+ type: string;
71
+ }): this;
72
+ render(target: string | HTMLElement): HTMLElement | null;
73
+ refresh(): HTMLElement | null;
74
+ whenReady(): Promise<void>;
75
+ getLegendItems(): LegendItem[];
76
+ private getLegendItemsForWidth;
77
+ isLegendSeriesVisible(dataKey: string): boolean;
78
+ setLegendSeriesVisible(dataKey: string, visible: boolean): this;
79
+ toggleLegendSeries(dataKey: string): this;
80
+ setLegendVisibility(visibility: Record<string, boolean>): this;
81
+ onLegendChange(callback: () => void): () => void;
82
+ export(format: ChartGroupExportFormat, options?: ExportOptions): Promise<string | Blob | void>;
83
+ destroy(): void;
84
+ private bindChartRenderCallback;
85
+ private resolveContainer;
86
+ private resolveContainerWidth;
87
+ private resolveCurrentWidth;
88
+ private resolveTotalHeightConstraint;
89
+ private resolveChartAreaHeightConstraint;
90
+ private resolveSpan;
91
+ private warnOnExplicitChildWidth;
92
+ private getAllVerticalXYCharts;
93
+ private getVerticalXYCharts;
94
+ private resolveSharedYDomain;
95
+ private warnIncompatibleYScaleTypes;
96
+ private applyScaleSyncOverrides;
97
+ private buildRows;
98
+ private resolveDefaultChartHeight;
99
+ private resolveDefaultChartHeightForWidth;
100
+ private calculateLayout;
101
+ private collectMergedLegendEntries;
102
+ private resolveLegendVisibility;
103
+ private syncLegendStateFromChildren;
104
+ private applyLegendStateToChildren;
105
+ private serializeChildLegendState;
106
+ private serializeChildYDomains;
107
+ private renderLegendIntoContainer;
108
+ private renderTitleSvg;
109
+ private renderLegendSvg;
110
+ private setupResizeObserver;
111
+ private exportSVG;
112
+ private validateResponsiveConfig;
113
+ private validateItemResponsiveConfig;
114
+ private resolveResponsiveConfigForWidth;
115
+ private resolveMatchedGroupBreakpoints;
116
+ private resolveVisibleChartEntries;
117
+ private resolveResponsiveChartEntry;
118
+ private downloadContent;
119
+ private getMimeType;
120
+ }
121
+ export {};