@internetstiftelsen/charts 0.13.3 → 0.14.1

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
@@ -10,8 +10,9 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
10
10
  - **Combined Chart Layouts** - `ChartGroup` composes existing charts into shared dashboards with one coordinated legend
11
11
  - **Divergent Bar Support** - Bar charts automatically render from zero and diverge around `0` for mixed positive/negative values
12
12
  - **Mirrored Bar Sides** - Horizontal bars can mirror a series to the left for population-pyramid style charts without changing source data
13
- - **Custom Value Labels** - XY, pie, and donut charts support optional on-chart labels with custom formatters
13
+ - **Custom Value Labels** - XY, pie, donut, and gauge charts support configurable labels with formatters, max-width overflow behavior, and forced rendering when labels would otherwise be hidden
14
14
  - **Optional XY Animation** - Animate XY series on first render and `chart.update(...)` with `animate`
15
+ - **Optional Radial Animation** - Animate pie and donut segments on first render and `chart.update(...)` with `animate`
15
16
  - **Optional Gauge Animation** - Animate gauge value transitions with `gauge.animate`
16
17
  - **Stacking Control** - Bar and area stacking modes with optional reversed visual series order
17
18
  - **Configurable Tooltips** - Shared or split tooltips with connectors, transitions, and default max-width wrapping
@@ -139,6 +140,8 @@ await chart.whenReady();
139
140
 
140
141
  Animation is off by default, applies to XY series marks only, and visual
141
142
  exports always render the final static state.
143
+ Preset easing values include `linear`, `ease-in`, `ease-out`, `ease-in-out`,
144
+ `bounce-out`, `elastic-out`, and `spring-out`.
142
145
 
143
146
  ## Lifecycle Events
144
147
 
@@ -359,6 +362,7 @@ const data = [
359
362
 
360
363
  const chart = new WordCloudChart({
361
364
  data,
365
+ animate: true,
362
366
  wordCloud: {
363
367
  minValue: 5,
364
368
  minWordLength: 3,
@@ -376,6 +380,8 @@ chart.render('#word-cloud');
376
380
  dimension and define the relative size range passed into `d3-cloud`. The chart
377
381
  expects flat `{ word, count }` rows, aggregates duplicate words after trimming,
378
382
  and maps theme typography and colors directly into the layout and rendered SVG.
383
+ Set `animate: true` or pass an animation config to fade and scale words from
384
+ their own centers on initial render and `chart.update(...)`.
379
385
 
380
386
  ## Export
381
387
 
package/dist/area.js CHANGED
@@ -388,6 +388,7 @@ export class Area {
388
388
  const border = config.border ?? theme.valueLabel.border;
389
389
  const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
390
390
  const padding = config.padding ?? theme.valueLabel.padding;
391
+ const forceVisible = config.forceVisible === true;
391
392
  const labelGroup = plotGroup
392
393
  .append('g')
393
394
  .attr('class', `area-value-labels-${sanitizeForCSS(this.dataKey)}`);
@@ -422,7 +423,7 @@ export class Area {
422
423
  if (labelY - boxHeight / 2 < plotTop + 4) {
423
424
  labelY = yPos + boxHeight / 2 + theme.line.point.size + 4;
424
425
  if (labelY + boxHeight / 2 > plotBottom - 4) {
425
- shouldRender = false;
426
+ shouldRender = forceVisible;
426
427
  }
427
428
  }
428
429
  tempText.remove();
package/dist/bar.js CHANGED
@@ -369,6 +369,7 @@ export class Bar {
369
369
  ...this.resolveValueLabelStyle(config, theme),
370
370
  formatter: config.formatter,
371
371
  autoContrastInside: config.color === undefined,
372
+ forceVisible: config.forceVisible ?? false,
372
373
  };
373
374
  }
374
375
  resolveValueLabelPlacement(config) {
@@ -461,7 +462,8 @@ export class Bar {
461
462
  return {
462
463
  x: input.x,
463
464
  y,
464
- shouldRender: input.labelBox.height + minPadding <= input.barHeight,
465
+ shouldRender: input.forceVisible ||
466
+ input.labelBox.height + minPadding <= input.barHeight,
465
467
  };
466
468
  }
467
469
  getVerticalOutsideLabelPlacement(input) {
@@ -474,7 +476,7 @@ export class Bar {
474
476
  return {
475
477
  x: input.x,
476
478
  y,
477
- shouldRender,
479
+ shouldRender: input.forceVisible || shouldRender,
478
480
  };
479
481
  }
480
482
  getVerticalInsideLabelY(barTop, barBottom, labelHeight, insidePosition, inset) {
@@ -506,7 +508,7 @@ export class Bar {
506
508
  return {
507
509
  x,
508
510
  y: input.y,
509
- shouldRender: fitsBar && withinBounds,
511
+ shouldRender: input.forceVisible || (fitsBar && withinBounds),
510
512
  };
511
513
  }
512
514
  getHorizontalOutsideLabelPlacement(input) {
@@ -519,7 +521,7 @@ export class Bar {
519
521
  return {
520
522
  x,
521
523
  y: input.y,
522
- shouldRender,
524
+ shouldRender: input.forceVisible || shouldRender,
523
525
  };
524
526
  }
525
527
  getHorizontalInsideLabelX(barLeft, barRight, labelWidth, isNegative, insidePosition, inset) {
@@ -567,6 +569,7 @@ export class Bar {
567
569
  mode,
568
570
  position: config.position,
569
571
  insidePosition: config.insidePosition,
572
+ forceVisible: config.forceVisible,
570
573
  plotTop,
571
574
  plotBottom,
572
575
  });
@@ -601,6 +604,7 @@ export class Bar {
601
604
  mode,
602
605
  position: config.position,
603
606
  insidePosition: config.insidePosition,
607
+ forceVisible: config.forceVisible,
604
608
  plotLeft,
605
609
  plotRight,
606
610
  });
@@ -16,6 +16,7 @@ type RenderDimensions = {
16
16
  svgWidthAttr: number | string;
17
17
  svgHeightAttr: number | string;
18
18
  };
19
+ type PlotAreaOverride = Partial<Pick<PlotAreaBounds, 'top' | 'right' | 'bottom' | 'left'>>;
19
20
  type ResponsiveOverrides = {
20
21
  theme?: DeepPartial<ChartTheme>;
21
22
  components: Map<ChartComponentBase, Record<string, unknown>>;
@@ -123,6 +124,7 @@ export declare abstract class BaseChart {
123
124
  private disconnectedLegendContainer;
124
125
  private renderThemeOverride;
125
126
  private renderSizeOverride;
127
+ private plotAreaOverride;
126
128
  private legendModeOverride;
127
129
  private readonly eventListeners;
128
130
  private renderId;
@@ -165,6 +167,10 @@ export declare abstract class BaseChart {
165
167
  width?: number;
166
168
  height?: number;
167
169
  };
170
+ /** @internal */
171
+ measurePlotArea(width: number, height: number): PlotAreaBounds;
172
+ /** @internal */
173
+ setPlotAreaOverride(override: PlotAreaOverride | null, rerender?: boolean): this;
168
174
  setLegendModeOverride(mode: LegendMode | null, rerender?: boolean): this;
169
175
  on<TEventName extends ChartEventName>(eventName: TEventName, listener: ChartEventListener<TEventName>): this;
170
176
  off<TEventName extends ChartEventName>(eventName: TEventName, listener: ChartEventListener<TEventName>): this;
@@ -199,8 +205,10 @@ export declare abstract class BaseChart {
199
205
  protected filterVisibleItems<T>(items: T[], getDataKey: (item: T) => string): T[];
200
206
  protected validateSourceData(_data: ChartData): void;
201
207
  protected syncDerivedState(_previousData?: DataItem[]): void;
208
+ protected prepareForLegendChange(): void;
202
209
  protected initializeDataState(): void;
203
210
  protected prepareLayout(context: BaseLayoutContext): void;
211
+ private applyPlotAreaOverride;
204
212
  /**
205
213
  * Setup ResizeObserver for automatic resize handling
206
214
  */
@@ -219,6 +219,12 @@ export class BaseChart {
219
219
  writable: true,
220
220
  value: null
221
221
  });
222
+ Object.defineProperty(this, "plotAreaOverride", {
223
+ enumerable: true,
224
+ configurable: true,
225
+ writable: true,
226
+ value: null
227
+ });
222
228
  Object.defineProperty(this, "legendModeOverride", {
223
229
  enumerable: true,
224
230
  configurable: true,
@@ -268,6 +274,7 @@ export class BaseChart {
268
274
  this.responsiveConfig = config.responsive;
269
275
  this.legendState = new LegendStateController();
270
276
  this.legendState.subscribe(() => {
277
+ this.prepareForLegendChange();
271
278
  this.notifyLegendChanged();
272
279
  this.rerender('legend');
273
280
  });
@@ -360,7 +367,7 @@ export class BaseChart {
360
367
  // Calculate layout
361
368
  this.layoutManager = new LayoutManager(this.resolvedRenderTheme);
362
369
  const components = this.getLayoutComponents();
363
- const plotArea = this.layoutManager.calculateLayout(components);
370
+ const plotArea = this.applyPlotAreaOverride(this.layoutManager.calculateLayout(components));
364
371
  this.plotArea = plotArea;
365
372
  // Create plot group
366
373
  const plotGroup = this.svg.append('g').attr('class', 'chart-plot');
@@ -516,6 +523,59 @@ export class BaseChart {
516
523
  height: this.configuredHeight,
517
524
  };
518
525
  }
526
+ /** @internal */
527
+ measurePlotArea(width, height) {
528
+ const previousWidth = this.width;
529
+ const previousHeight = this.height;
530
+ const measurementSvg = create('svg')
531
+ .attr('width', width)
532
+ .attr('height', height)
533
+ .style('position', 'absolute')
534
+ .style('left', '-9999px')
535
+ .style('top', '-9999px')
536
+ .style('overflow', 'hidden')
537
+ .style('pointer-events', 'none');
538
+ const svgNode = measurementSvg.node();
539
+ if (!svgNode) {
540
+ throw new Error('Failed to initialize chart measurement SVG');
541
+ }
542
+ document.body.appendChild(svgNode);
543
+ this.width = width;
544
+ this.height = height;
545
+ const responsiveContext = this.resolveResponsiveContext({
546
+ width,
547
+ height,
548
+ });
549
+ const responsiveOverrides = this.collectResponsiveOverrides(responsiveContext);
550
+ const mergedComponentOverrides = this.mergeComponentOverrideMaps(responsiveOverrides.components);
551
+ const renderTheme = this.resolveRenderTheme(responsiveOverrides);
552
+ const overrideComponents = this.createOverrideComponents(mergedComponentOverrides);
553
+ const restoreComponents = this.applyComponentOverrides(overrideComponents);
554
+ const restoreTheme = this.applyRenderTheme(renderTheme);
555
+ try {
556
+ this.prepareLayout({
557
+ svg: measurementSvg,
558
+ svgNode,
559
+ });
560
+ const layoutManager = new LayoutManager(this.resolvedRenderTheme);
561
+ return this.applyPlotAreaOverride(layoutManager.calculateLayout(this.getLayoutComponents()));
562
+ }
563
+ finally {
564
+ restoreComponents();
565
+ restoreTheme();
566
+ this.width = previousWidth;
567
+ this.height = previousHeight;
568
+ svgNode.remove();
569
+ }
570
+ }
571
+ /** @internal */
572
+ setPlotAreaOverride(override, rerender = true) {
573
+ this.plotAreaOverride = override;
574
+ if (rerender) {
575
+ this.rerender('component');
576
+ }
577
+ return this;
578
+ }
519
579
  setLegendModeOverride(mode, rerender = true) {
520
580
  if (this.legendModeOverride === mode) {
521
581
  return this;
@@ -844,6 +904,7 @@ export class BaseChart {
844
904
  }
845
905
  validateSourceData(_data) { }
846
906
  syncDerivedState(_previousData) { }
907
+ prepareForLegendChange() { }
847
908
  initializeDataState() {
848
909
  this.validateSourceData(this.sourceData);
849
910
  this.syncDerivedState();
@@ -852,6 +913,23 @@ export class BaseChart {
852
913
  prepareLayout(context) {
853
914
  this.measureInlineLegend(context.svgNode);
854
915
  }
916
+ applyPlotAreaOverride(plotArea) {
917
+ if (!this.plotAreaOverride) {
918
+ return plotArea;
919
+ }
920
+ const left = this.plotAreaOverride.left ?? plotArea.left;
921
+ const right = this.plotAreaOverride.right ?? plotArea.right;
922
+ const top = this.plotAreaOverride.top ?? plotArea.top;
923
+ const bottom = this.plotAreaOverride.bottom ?? plotArea.bottom;
924
+ return {
925
+ left,
926
+ right,
927
+ top,
928
+ bottom,
929
+ width: Math.max(0, right - left),
930
+ height: Math.max(0, bottom - top),
931
+ };
932
+ }
855
933
  /**
856
934
  * Setup ResizeObserver for automatic resize handling
857
935
  */
@@ -96,6 +96,7 @@ export declare class ChartGroup {
96
96
  private resolveSharedYDomain;
97
97
  private warnIncompatibleYScaleTypes;
98
98
  private applyScaleSyncOverrides;
99
+ private applyPlotAreaSyncOverrides;
99
100
  private buildRows;
100
101
  private resolveDefaultChartHeight;
101
102
  private resolveDefaultChartHeightForWidth;
@@ -291,7 +291,9 @@ export class ChartGroup {
291
291
  const { width, renderedTopText, renderedBottomText, renderedLegend, layout, totalHeight, } = this.prepareRenderState(container);
292
292
  this.isRendering = true;
293
293
  try {
294
- this.applyScaleSyncOverrides(width);
294
+ const sharedYDomain = this.resolveSharedYDomain(width);
295
+ this.applyScaleSyncOverrides(width, sharedYDomain);
296
+ this.applyPlotAreaSyncOverrides(layout.items, sharedYDomain);
295
297
  container.innerHTML = '';
296
298
  const { root, chartLayer } = this.createRenderHosts(totalHeight, layout.chartHeight);
297
299
  this.appendRenderedTextSections(root, renderedTopText);
@@ -376,6 +378,7 @@ export class ChartGroup {
376
378
  chart.setLegendModeOverride(null, false);
377
379
  if (chart instanceof XYChart) {
378
380
  chart.setScaleConfigOverride(null, false);
381
+ chart.setPlotAreaOverride(null, false);
379
382
  }
380
383
  chart.destroy();
381
384
  });
@@ -508,9 +511,8 @@ export class ChartGroup {
508
511
  this.hasWarnedIncompatibleYScaleTypes = true;
509
512
  ChartValidator.warn('ChartGroup: syncY requires all synced XY child charts to use the same vertical numeric scale type');
510
513
  }
511
- applyScaleSyncOverrides(width) {
514
+ applyScaleSyncOverrides(width, sharedDomain) {
512
515
  const syncedCharts = new Set(this.getVerticalXYCharts(width));
513
- const sharedDomain = this.resolveSharedYDomain(width);
514
516
  this.getAllVerticalXYCharts().forEach((chart) => {
515
517
  if (!sharedDomain || !syncedCharts.has(chart)) {
516
518
  chart.setScaleConfigOverride(null, false);
@@ -524,6 +526,43 @@ export class ChartGroup {
524
526
  }, false);
525
527
  });
526
528
  }
529
+ applyPlotAreaSyncOverrides(items, sharedDomain) {
530
+ this.getAllVerticalXYCharts().forEach((chart) => {
531
+ chart.setPlotAreaOverride(null, false);
532
+ });
533
+ if (!sharedDomain) {
534
+ return;
535
+ }
536
+ const rowItems = new Map();
537
+ items.forEach((item) => {
538
+ if (!(item.chart instanceof XYChart) ||
539
+ item.chart.getOrientation() !== 'vertical') {
540
+ return;
541
+ }
542
+ const row = rowItems.get(item.y) ?? [];
543
+ row.push(item);
544
+ rowItems.set(item.y, row);
545
+ });
546
+ rowItems.forEach((row) => {
547
+ if (row.length < 2) {
548
+ return;
549
+ }
550
+ const measuredPlotAreas = row.map((item) => {
551
+ return {
552
+ chart: item.chart,
553
+ plotArea: item.chart.measurePlotArea(item.width, item.height),
554
+ };
555
+ });
556
+ const top = Math.max(...measuredPlotAreas.map(({ plotArea }) => plotArea.top));
557
+ const bottom = Math.min(...measuredPlotAreas.map(({ plotArea }) => plotArea.bottom));
558
+ if (bottom <= top) {
559
+ return;
560
+ }
561
+ measuredPlotAreas.forEach(({ chart }) => {
562
+ chart.setPlotAreaOverride({ top, bottom }, false);
563
+ });
564
+ });
565
+ }
527
566
  buildRows(entries, width, cols, gap) {
528
567
  const availableWidth = Math.max(1, width - gap * Math.max(0, cols - 1));
529
568
  const columnWidth = availableWidth / cols;
@@ -802,10 +841,12 @@ export class ChartGroup {
802
841
  this.resizeObserver.observe(this.container);
803
842
  }
804
843
  async exportSVG(format, width, options) {
805
- this.applyScaleSyncOverrides(width);
844
+ const sharedYDomain = this.resolveSharedYDomain(width);
845
+ this.applyScaleSyncOverrides(width, sharedYDomain);
806
846
  const baseContext = this.createExportRenderContext(format, width, options);
807
847
  const exportLayoutState = this.resolveExportLayoutState(width, baseContext);
808
848
  const layout = this.calculateLayout(width, exportLayoutState.chartAreaHeight, exportLayoutState.defaultChartHeightOverride);
849
+ this.applyPlotAreaSyncOverrides(layout.items, sharedYDomain);
809
850
  const childSvgs = await this.exportLayoutItems(layout.items, options);
810
851
  const totalHeight = exportLayoutState.topTextHeight +
811
852
  layout.chartHeight +
@@ -1,23 +1,34 @@
1
- import type { DataItem, LegendSeries } from './types.js';
1
+ import type { DataItem, LabelOversizedBehavior, LegendSeries } from './types.js';
2
2
  import { type BaseChartConfig, type BaseRenderContext } from './base-chart.js';
3
3
  import type { ChartComponentBase } from './chart-interface.js';
4
4
  import { RadialChartBase } from './radial-chart-base.js';
5
+ import { type RadialAnimationConfig, type RadialAnimationEasingPreset } from './radial-animation.js';
6
+ export type DonutAnimationConfig = RadialAnimationConfig;
7
+ export type DonutAnimationEasingPreset = RadialAnimationEasingPreset;
5
8
  export type DonutConfig = {
6
9
  innerRadius?: number;
7
10
  padAngle?: number;
8
11
  cornerRadius?: number;
9
12
  };
10
13
  export type DonutValueLabelPosition = 'outside' | 'auto';
14
+ export type DonutValueLabelFormatter = (label: string, value: number, data: DataItem, percentage: number) => string;
11
15
  export type DonutValueLabelConfig = {
12
16
  show?: boolean;
13
17
  position?: DonutValueLabelPosition;
14
18
  outsideOffset?: number;
15
19
  minVerticalSpacing?: number;
16
- formatter?: (label: string, value: number, data: DataItem, percentage: number) => string;
20
+ maxLabelWidth?: number;
21
+ oversizedBehavior?: LabelOversizedBehavior;
22
+ forceVisible?: boolean;
23
+ labelFormatter?: DonutValueLabelFormatter;
24
+ valueFormatter?: DonutValueLabelFormatter;
25
+ separator?: string;
26
+ formatter?: DonutValueLabelFormatter;
17
27
  };
18
28
  export type DonutChartConfig = BaseChartConfig & {
19
29
  donut?: DonutConfig;
20
30
  valueLabel?: DonutValueLabelConfig;
31
+ animate?: boolean | DonutAnimationConfig;
21
32
  valueKey?: string;
22
33
  labelKey?: string;
23
34
  };
@@ -28,6 +39,7 @@ export declare class DonutChart extends RadialChartBase {
28
39
  private readonly valueKey;
29
40
  private readonly labelKey;
30
41
  private readonly valueLabel;
42
+ private readonly motionController;
31
43
  private segments;
32
44
  private centerContent;
33
45
  constructor(config: DonutChartConfig);
@@ -36,16 +48,20 @@ export declare class DonutChart extends RadialChartBase {
36
48
  addChild(component: ChartComponentBase): this;
37
49
  protected getExportComponents(): ChartComponentBase[];
38
50
  update(data: DataItem[]): void;
51
+ protected prepareForLegendChange(): void;
39
52
  protected createExportChart(): RadialChartBase;
40
53
  protected applyComponentOverrides(overrides: Map<ChartComponentBase, ChartComponentBase>): () => void;
41
54
  protected syncDerivedState(): void;
42
55
  protected renderChart({ svg, plotGroup, plotArea, }: BaseRenderContext): void;
43
56
  protected getLegendSeries(): LegendSeries[];
44
57
  private buildTooltipContent;
45
- private formatValueLabelText;
58
+ private resolveValueLabelText;
59
+ private getValueLabelPercentage;
46
60
  private renderSegments;
47
61
  private renderLabels;
48
62
  private getArcPoint;
49
63
  private resolveOutsideLabel;
64
+ private measureValueLabelDimensions;
50
65
  private adjustOutsideLabelPositions;
66
+ private getOutsideLabelSpacing;
51
67
  }