@internetstiftelsen/charts 0.10.0 → 0.11.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.
Files changed (57) hide show
  1. package/README.md +65 -1
  2. package/dist/area.d.ts +11 -1
  3. package/dist/area.js +199 -55
  4. package/dist/bar.d.ts +26 -1
  5. package/dist/bar.js +425 -306
  6. package/dist/base-chart.d.ts +5 -0
  7. package/dist/base-chart.js +91 -67
  8. package/dist/chart-group.d.ts +16 -0
  9. package/dist/chart-group.js +201 -143
  10. package/dist/donut-center-content.d.ts +1 -0
  11. package/dist/donut-center-content.js +21 -38
  12. package/dist/donut-chart.js +32 -32
  13. package/dist/gauge-chart.d.ts +23 -4
  14. package/dist/gauge-chart.js +235 -185
  15. package/dist/lazy-mount.d.ts +13 -0
  16. package/dist/lazy-mount.js +90 -0
  17. package/dist/legend.js +10 -9
  18. package/dist/line.d.ts +9 -1
  19. package/dist/line.js +144 -24
  20. package/dist/pie-chart.d.ts +3 -0
  21. package/dist/pie-chart.js +49 -47
  22. package/dist/radial-chart-base.d.ts +4 -3
  23. package/dist/radial-chart-base.js +27 -12
  24. package/dist/scatter.d.ts +5 -1
  25. package/dist/scatter.js +92 -9
  26. package/dist/theme.js +17 -0
  27. package/dist/tooltip.d.ts +55 -3
  28. package/dist/tooltip.js +968 -159
  29. package/dist/types.d.ts +23 -1
  30. package/dist/utils.js +11 -19
  31. package/dist/x-axis.d.ts +10 -0
  32. package/dist/x-axis.js +190 -149
  33. package/dist/xy-animation.d.ts +3 -0
  34. package/dist/xy-animation.js +2 -0
  35. package/dist/xy-chart.d.ts +35 -1
  36. package/dist/xy-chart.js +358 -153
  37. package/dist/xy-motion/config.d.ts +2 -0
  38. package/dist/xy-motion/config.js +177 -0
  39. package/dist/xy-motion/driver.d.ts +9 -0
  40. package/dist/xy-motion/driver.js +10 -0
  41. package/dist/xy-motion/helpers.d.ts +17 -0
  42. package/dist/xy-motion/helpers.js +105 -0
  43. package/dist/xy-motion/live-state.d.ts +8 -0
  44. package/dist/xy-motion/live-state.js +240 -0
  45. package/dist/xy-motion/noop-xy-motion-driver.d.ts +9 -0
  46. package/dist/xy-motion/noop-xy-motion-driver.js +15 -0
  47. package/dist/xy-motion/types.d.ts +85 -0
  48. package/dist/xy-motion/types.js +1 -0
  49. package/dist/xy-motion/xy-motion-driver.d.ts +19 -0
  50. package/dist/xy-motion/xy-motion-driver.js +130 -0
  51. package/dist/y-axis.d.ts +7 -2
  52. package/dist/y-axis.js +99 -10
  53. package/docs/components.md +50 -1
  54. package/docs/getting-started.md +35 -0
  55. package/docs/theming.md +14 -0
  56. package/docs/xy-chart.md +88 -7
  57. package/package.json +5 -4
@@ -0,0 +1,19 @@
1
+ import type { XYMotionDriverContract } from './driver.js';
2
+ import type { NormalizedXYAnimation, XYAreaAnimationContext, XYBarAnimationContext, XYMotionRenderResult, XYMotionSeries, XYMotionUpdateContext, XYPointAnimationContext } from './types.js';
3
+ export declare class XYMotionDriver implements XYMotionDriverContract {
4
+ private readonly animation;
5
+ private hasRenderedLive;
6
+ private nextRenderAnimationMode;
7
+ private pendingAnimationSnapshot;
8
+ private pendingPathState;
9
+ private lastSeriesSnapshot;
10
+ constructor(animation: NormalizedXYAnimation);
11
+ prepareForUpdate(context: XYMotionUpdateContext): void;
12
+ getPointAnimationContext(series: XYMotionSeries, baselineY: number): XYPointAnimationContext | undefined;
13
+ getAreaAnimationContext(series: XYMotionSeries): XYAreaAnimationContext | undefined;
14
+ getBarAnimationContext(series: XYMotionSeries, baselineValuePosition: number): XYBarAnimationContext | undefined;
15
+ completeRender(result: XYMotionRenderResult): Promise<void>;
16
+ private getRenderMode;
17
+ private getPreviousSeriesSnapshot;
18
+ private getPreviousPathState;
19
+ }
@@ -0,0 +1,130 @@
1
+ import { createXYSeriesSnapshotId } from './helpers.js';
2
+ import { captureLiveAnimationState } from './live-state.js';
3
+ export class XYMotionDriver {
4
+ constructor(animation) {
5
+ Object.defineProperty(this, "animation", {
6
+ enumerable: true,
7
+ configurable: true,
8
+ writable: true,
9
+ value: void 0
10
+ });
11
+ Object.defineProperty(this, "hasRenderedLive", {
12
+ enumerable: true,
13
+ configurable: true,
14
+ writable: true,
15
+ value: false
16
+ });
17
+ Object.defineProperty(this, "nextRenderAnimationMode", {
18
+ enumerable: true,
19
+ configurable: true,
20
+ writable: true,
21
+ value: void 0
22
+ });
23
+ Object.defineProperty(this, "pendingAnimationSnapshot", {
24
+ enumerable: true,
25
+ configurable: true,
26
+ writable: true,
27
+ value: null
28
+ });
29
+ Object.defineProperty(this, "pendingPathState", {
30
+ enumerable: true,
31
+ configurable: true,
32
+ writable: true,
33
+ value: new Map()
34
+ });
35
+ Object.defineProperty(this, "lastSeriesSnapshot", {
36
+ enumerable: true,
37
+ configurable: true,
38
+ writable: true,
39
+ value: new Map()
40
+ });
41
+ this.animation = animation;
42
+ this.nextRenderAnimationMode = 'initial';
43
+ }
44
+ prepareForUpdate(context) {
45
+ if (!this.hasRenderedLive) {
46
+ this.pendingAnimationSnapshot = null;
47
+ this.pendingPathState = new Map();
48
+ this.nextRenderAnimationMode = 'none';
49
+ return;
50
+ }
51
+ const liveState = captureLiveAnimationState(context.plotGroup, context.visibleSeries, this.lastSeriesSnapshot);
52
+ this.pendingAnimationSnapshot = liveState.snapshotCollection;
53
+ this.pendingPathState = liveState.pathState;
54
+ this.nextRenderAnimationMode =
55
+ this.animation.duration > 0 ? 'update' : 'none';
56
+ }
57
+ getPointAnimationContext(series, baselineY) {
58
+ const mode = this.getRenderMode();
59
+ if (mode === 'none') {
60
+ return undefined;
61
+ }
62
+ return {
63
+ mode,
64
+ duration: this.animation.duration,
65
+ easing: this.animation.easing,
66
+ baselineY,
67
+ previousSnapshot: this.getPreviousSeriesSnapshot(series.type, series.dataKey),
68
+ previousPath: series.type === 'line'
69
+ ? this.getPreviousPathState(series.type, series.dataKey)
70
+ ?.linePath
71
+ : undefined,
72
+ previousRevealProgress: series.type === 'line'
73
+ ? this.getPreviousPathState(series.type, series.dataKey)
74
+ ?.lineRevealProgress
75
+ : undefined,
76
+ };
77
+ }
78
+ getAreaAnimationContext(series) {
79
+ const mode = this.getRenderMode();
80
+ if (mode === 'none') {
81
+ return undefined;
82
+ }
83
+ return {
84
+ mode,
85
+ duration: this.animation.duration,
86
+ easing: this.animation.easing,
87
+ previousSnapshot: this.getPreviousSeriesSnapshot(series.type, series.dataKey),
88
+ previousAreaPath: this.getPreviousPathState(series.type, series.dataKey)?.areaPath,
89
+ previousAreaRevealProgress: this.getPreviousPathState(series.type, series.dataKey)?.areaRevealProgress,
90
+ previousLinePath: this.getPreviousPathState(series.type, series.dataKey)?.areaLinePath,
91
+ previousLineRevealProgress: this.getPreviousPathState(series.type, series.dataKey)?.areaLineRevealProgress,
92
+ };
93
+ }
94
+ getBarAnimationContext(series, baselineValuePosition) {
95
+ const mode = this.getRenderMode();
96
+ if (mode === 'none') {
97
+ return undefined;
98
+ }
99
+ return {
100
+ mode,
101
+ duration: this.animation.duration,
102
+ easing: this.animation.easing,
103
+ baselineValuePosition,
104
+ previousSnapshot: this.getPreviousSeriesSnapshot(series.type, series.dataKey),
105
+ };
106
+ }
107
+ completeRender(result) {
108
+ this.lastSeriesSnapshot = result.snapshotCollection;
109
+ this.hasRenderedLive = true;
110
+ this.nextRenderAnimationMode = 'none';
111
+ this.pendingAnimationSnapshot = null;
112
+ this.pendingPathState = new Map();
113
+ if (result.transitions.length === 0) {
114
+ return Promise.resolve();
115
+ }
116
+ return Promise.allSettled(result.transitions).then(() => undefined);
117
+ }
118
+ getRenderMode() {
119
+ if (this.animation.duration === 0) {
120
+ return 'none';
121
+ }
122
+ return this.nextRenderAnimationMode;
123
+ }
124
+ getPreviousSeriesSnapshot(seriesType, dataKey) {
125
+ return this.pendingAnimationSnapshot?.get(createXYSeriesSnapshotId(seriesType, dataKey));
126
+ }
127
+ getPreviousPathState(seriesType, dataKey) {
128
+ return this.pendingPathState.get(createXYSeriesSnapshotId(seriesType, dataKey));
129
+ }
130
+ }
package/dist/y-axis.d.ts CHANGED
@@ -5,12 +5,14 @@ export declare class YAxis implements LayoutAwareComponent<YAxisConfigBase> {
5
5
  readonly type: "yAxis";
6
6
  readonly display: boolean;
7
7
  private readonly tickPadding;
8
- private readonly fontSize;
9
- private readonly maxLabelWidth;
8
+ private fontSize;
9
+ private readonly maxLabelWidth?;
10
10
  private readonly tickFormat;
11
11
  private readonly rotatedLabels;
12
12
  private readonly oversizedBehavior;
13
+ private estimatedWidth;
13
14
  readonly exportHooks?: ExportHooks<YAxisConfigBase>;
15
+ private resolveFontSizeValue;
14
16
  constructor(config?: YAxisConfig);
15
17
  getExportConfig(): YAxisConfigBase;
16
18
  createExportComponent(override?: Partial<YAxisConfigBase>): LayoutAwareComponent<YAxisConfigBase>;
@@ -18,8 +20,11 @@ export declare class YAxis implements LayoutAwareComponent<YAxisConfigBase> {
18
20
  * Returns the space required by the y-axis
19
21
  */
20
22
  getRequiredSpace(): ComponentSpace;
23
+ estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement): void;
24
+ clearEstimatedSpace(): void;
21
25
  render(svg: Selection<SVGSVGElement, undefined, null, undefined>, y: D3Scale, theme: ChartTheme, xPosition: number): void;
22
26
  private applyLabelConstraints;
27
+ private measureLabelDimensions;
23
28
  private wrapTextElement;
24
29
  private addTitleTooltip;
25
30
  }
package/dist/y-axis.js CHANGED
@@ -1,6 +1,18 @@
1
1
  import { axisLeft } from 'd3';
2
2
  import { measureTextWidth, truncateText, wrapText, mergeDeep } from './utils.js';
3
3
  export class YAxis {
4
+ resolveFontSizeValue(fontSize, fallback) {
5
+ if (typeof fontSize === 'number' && Number.isFinite(fontSize)) {
6
+ return fontSize;
7
+ }
8
+ if (typeof fontSize === 'string') {
9
+ const parsedFontSize = parseFloat(fontSize);
10
+ if (Number.isFinite(parsedFontSize)) {
11
+ return parsedFontSize;
12
+ }
13
+ }
14
+ return fallback;
15
+ }
4
16
  constructor(config) {
5
17
  Object.defineProperty(this, "type", {
6
18
  enumerable: true,
@@ -50,18 +62,25 @@ export class YAxis {
50
62
  writable: true,
51
63
  value: void 0
52
64
  });
65
+ Object.defineProperty(this, "estimatedWidth", {
66
+ enumerable: true,
67
+ configurable: true,
68
+ writable: true,
69
+ value: null
70
+ });
53
71
  Object.defineProperty(this, "exportHooks", {
54
72
  enumerable: true,
55
73
  configurable: true,
56
74
  writable: true,
57
75
  value: void 0
58
76
  });
59
- this.display = config?.display ?? true;
60
- this.tickFormat = config?.tickFormat ?? null;
61
- this.rotatedLabels = config?.rotatedLabels ?? false;
62
- this.maxLabelWidth = config?.maxLabelWidth ?? 40; // Default 40 for backward compatibility
63
- this.oversizedBehavior = config?.oversizedBehavior ?? 'truncate';
64
- this.exportHooks = config?.exportHooks;
77
+ const { display = true, tickFormat = null, rotatedLabels = false, maxLabelWidth, oversizedBehavior = 'truncate', exportHooks, } = config ?? {};
78
+ this.display = display;
79
+ this.tickFormat = tickFormat;
80
+ this.rotatedLabels = rotatedLabels;
81
+ this.maxLabelWidth = maxLabelWidth;
82
+ this.oversizedBehavior = oversizedBehavior;
83
+ this.exportHooks = exportHooks;
65
84
  }
66
85
  getExportConfig() {
67
86
  return {
@@ -90,20 +109,54 @@ export class YAxis {
90
109
  position: 'left',
91
110
  };
92
111
  }
93
- // Width = max label width + tick padding
94
- // Rotated labels need less width (cos(45°) ≈ 0.7 of horizontal width)
95
- const baseWidth = this.maxLabelWidth + this.tickPadding;
96
- const width = this.rotatedLabels ? baseWidth * 0.7 : baseWidth;
112
+ if (this.estimatedWidth !== null) {
113
+ return {
114
+ width: this.estimatedWidth,
115
+ height: 0,
116
+ position: 'left',
117
+ };
118
+ }
119
+ const fallbackLabelWidth = this.maxLabelWidth ?? this.fontSize;
120
+ const width = fallbackLabelWidth + this.tickPadding;
97
121
  return {
98
122
  width,
99
123
  height: 0, // Y-axis spans full height
100
124
  position: 'left',
101
125
  };
102
126
  }
127
+ estimateLayoutSpace(labels, theme, svg) {
128
+ if (!labels.length) {
129
+ this.estimatedWidth = 0;
130
+ return;
131
+ }
132
+ this.fontSize = this.resolveFontSizeValue(theme.axis.fontSize, this.fontSize);
133
+ const fontSize = this.fontSize;
134
+ const fontFamily = theme.axis.fontFamily;
135
+ const fontWeight = theme.axis.fontWeight || 'normal';
136
+ let maxWidth = 0;
137
+ let maxHeight = 0;
138
+ for (const label of labels) {
139
+ const text = String(label ?? '');
140
+ if (!text)
141
+ continue;
142
+ const { width, height } = this.measureLabelDimensions(text, fontSize, fontFamily, fontWeight, svg);
143
+ maxWidth = Math.max(maxWidth, width);
144
+ maxHeight = Math.max(maxHeight, height);
145
+ }
146
+ const radians = Math.PI / 4;
147
+ const labelFootprint = this.rotatedLabels
148
+ ? Math.cos(radians) * maxWidth + Math.sin(radians) * maxHeight
149
+ : maxWidth;
150
+ this.estimatedWidth = this.tickPadding + labelFootprint;
151
+ }
152
+ clearEstimatedSpace() {
153
+ this.estimatedWidth = null;
154
+ }
103
155
  render(svg, y, theme, xPosition) {
104
156
  if (!this.display) {
105
157
  return;
106
158
  }
159
+ this.fontSize = this.resolveFontSizeValue(theme.axis.fontSize, this.fontSize);
107
160
  const axis = axisLeft(y).tickSize(0).tickPadding(this.tickPadding);
108
161
  // Apply tick formatting if specified
109
162
  if (this.tickFormat) {
@@ -138,6 +191,9 @@ export class YAxis {
138
191
  axisGroup.selectAll('.domain').remove();
139
192
  }
140
193
  applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
194
+ if (this.maxLabelWidth === undefined) {
195
+ return;
196
+ }
141
197
  const maxWidth = this.maxLabelWidth;
142
198
  const behavior = this.oversizedBehavior;
143
199
  axisGroup
@@ -170,6 +226,39 @@ export class YAxis {
170
226
  }
171
227
  });
172
228
  }
229
+ measureLabelDimensions(text, fontSize, fontFamily, fontWeight, svg) {
230
+ const textWidth = measureTextWidth(text, fontSize, fontFamily, fontWeight, svg);
231
+ const lineHeight = fontSize * 1.2;
232
+ if (this.maxLabelWidth === undefined ||
233
+ textWidth <= this.maxLabelWidth) {
234
+ return {
235
+ width: textWidth,
236
+ height: fontSize,
237
+ };
238
+ }
239
+ switch (this.oversizedBehavior) {
240
+ case 'truncate':
241
+ return {
242
+ width: this.maxLabelWidth,
243
+ height: fontSize,
244
+ };
245
+ case 'wrap': {
246
+ const lines = wrapText(text, this.maxLabelWidth, fontSize, fontFamily, fontWeight, svg);
247
+ const widestLine = lines.reduce((widest, line) => {
248
+ return Math.max(widest, measureTextWidth(line, fontSize, fontFamily, fontWeight, svg));
249
+ }, 0);
250
+ return {
251
+ width: widestLine,
252
+ height: Math.max(lines.length, 1) * lineHeight,
253
+ };
254
+ }
255
+ case 'hide':
256
+ return {
257
+ width: 0,
258
+ height: 0,
259
+ };
260
+ }
261
+ }
173
262
  wrapTextElement(textEl, lines, originalText) {
174
263
  // Clear existing content
175
264
  textEl.textContent = '';
@@ -8,12 +8,18 @@ Renders the X axis.
8
8
 
9
9
  ```typescript
10
10
  new XAxis({
11
- display?: boolean, // Render axis and reserve layout space (default: true)
11
+ display?: boolean, // Render axis and reserve layout space (default: true)
12
12
  dataKey?: string, // Key in data objects for X values (auto-detected if omitted)
13
13
  labelKey?: string, // Optional display label key (uses dataKey values if omitted)
14
14
  groupLabelKey?: string, // Optional key used for second-row grouped labels
15
15
  showGroupLabels?: boolean, // Show second-row grouped labels (default: false)
16
16
  groupLabelGap?: number, // Vertical gap between tick row and grouped row
17
+ rotatedLabels?: boolean, // Rotate tick labels -45deg
18
+ maxLabelWidth?: number, // Optional cap for tick-label width
19
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Only applies when maxLabelWidth is set
20
+ autoHideOverlapping?: boolean, // Automatically hide overlapping labels
21
+ minLabelGap?: number, // Minimum gap between visible labels when auto-hide is enabled
22
+ preserveEndLabels?: boolean, // Keep first/last labels visible when auto-hide is enabled
17
23
  tickFormat?: string | ((value: string | number | Date) => string) | null
18
24
  })
19
25
  ```
@@ -54,10 +60,17 @@ Renders the Y axis.
54
60
  ```typescript
55
61
  new YAxis({
56
62
  display?: boolean, // Render axis and reserve layout space (default: true)
63
+ rotatedLabels?: boolean, // Rotate tick labels -45deg
64
+ maxLabelWidth?: number, // Optional cap for tick-label width
65
+ oversizedBehavior?: 'truncate' | 'wrap' | 'hide', // Only applies when maxLabelWidth is set
57
66
  tickFormat?: string | ((value: string | number | Date) => string) | null
58
67
  })
59
68
  ```
60
69
 
70
+ When `maxLabelWidth` is omitted, `YAxis` reserves the measured width of its
71
+ rendered tick labels. Set `maxLabelWidth` to cap the reserved width and use
72
+ `oversizedBehavior` to control truncation, wrapping, or hiding.
73
+
61
74
  ### Format Examples
62
75
 
63
76
  ```javascript
@@ -106,6 +119,14 @@ Renders interactive tooltips on hover and keyboard focus.
106
119
 
107
120
  ```typescript
108
121
  new Tooltip({
122
+ mode?: 'shared' | 'split',
123
+ position?: 'side' | 'vertical',
124
+ barAnchorPosition?: 'top' | 'middle',
125
+ transition?: {
126
+ show?: boolean,
127
+ duration?: number,
128
+ easing?: string
129
+ },
109
130
  formatter?: (dataKey: string, value: DataValue, data: DataItem) => string
110
131
  })
111
132
  ```
@@ -116,6 +137,34 @@ The formatter receives:
116
137
  - `value` - The data value at this point
117
138
  - `data` - The full data item object
118
139
 
140
+ Tooltip modes:
141
+
142
+ - `shared` - One tooltip per hovered category/value group
143
+ - `split` - Default. One tooltip per visible series at the hovered category/value group
144
+
145
+ Split tooltip positions:
146
+
147
+ - `side` - Default. Place split tooltips to the side of each data point
148
+ - `vertical` - Place split tooltips above or below each data point
149
+
150
+ When `split` mode is active, `customFormatter` receives the current series only for
151
+ that tooltip box instead of the full visible series list. Split tooltips include
152
+ directional arrows and avoid overlapping on the same side of the chart when
153
+ possible.
154
+
155
+ For bars, `barAnchorPosition` controls whether split tooltips point to the `top`
156
+ or `middle` of each bar.
157
+
158
+ Set `transition.show: true` to fade tooltips in and out. Tooltip position and
159
+ connector geometry update immediately; only opacity and the small entrance
160
+ offset transition.
161
+
162
+ Tooltip box, text, connector, and arrow colors use `theme.tooltip`.
163
+
164
+ Formatter, label formatter, and custom formatter output is inserted as HTML.
165
+ Only return trusted content or sanitize user-provided strings before returning
166
+ them from formatter callbacks.
167
+
119
168
  ### Example
120
169
 
121
170
  ```javascript
@@ -76,6 +76,41 @@ const chart = new XYChart({
76
76
  });
77
77
  ```
78
78
 
79
+ ## Lazy Loading
80
+
81
+ When a page contains charts further down the document, you can defer the chart
82
+ imports until the container is near the viewport.
83
+
84
+ ```typescript
85
+ import { mountChartWhenVisible } from '@internetstiftelsen/charts/lazy-mount';
86
+
87
+ const lazyChart = mountChartWhenVisible(
88
+ '#chart-container',
89
+ async (container) => {
90
+ const [{ XYChart }, { Line }, { XAxis }, { YAxis }] = await Promise.all([import('@internetstiftelsen/charts/xy-chart'), import('@internetstiftelsen/charts/line'), import('@internetstiftelsen/charts/x-axis'), import('@internetstiftelsen/charts/y-axis')]);
91
+
92
+ const chart = new XYChart({ data });
93
+ chart
94
+ .addChild(new XAxis({ dataKey: 'date' }))
95
+ .addChild(new YAxis())
96
+ .addChild(new Line({ dataKey: 'revenue' }));
97
+
98
+ chart.render(container);
99
+ return chart;
100
+ },
101
+ {
102
+ rootMargin: '240px 0px',
103
+ },
104
+ );
105
+
106
+ // Optional if you want to trigger the load early
107
+ await lazyChart.load();
108
+ ```
109
+
110
+ `mountChartWhenVisible` keeps the loading strategy separate from chart
111
+ construction, which makes it a good building block for custom wrappers or
112
+ attribute-driven mounting later on.
113
+
79
114
  ## Grouping Charts
80
115
 
81
116
  ```javascript
package/docs/theming.md CHANGED
@@ -27,6 +27,14 @@ const chart = new XYChart({
27
27
  fontFamily: 'Inter, sans-serif',
28
28
  fontSize: 12,
29
29
  },
30
+ tooltip: {
31
+ background: '#102030',
32
+ border: '#405060',
33
+ color: '#f7fafc',
34
+ fontFamily: 'Inter, sans-serif',
35
+ fontSize: 13,
36
+ fontWeight: '600',
37
+ },
30
38
  },
31
39
  });
32
40
  ```
@@ -51,6 +59,12 @@ import { defaultTheme, newspaperTheme, themes } from '@internetstiftelsen/charts
51
59
  | `legend.paddingX` | `number` | `0` | Horizontal legend layout padding |
52
60
  | `legend.itemSpacingX` | `number` | `20` | Horizontal spacing between legend items |
53
61
  | `legend.itemSpacingY` | `number` | `8` | Vertical spacing between wrapped legend rows |
62
+ | `tooltip.background` | `string` | `'#ffffff'` | Tooltip box and arrow fill color |
63
+ | `tooltip.border` | `string` | `'#dddddd'` | Tooltip box, connector, and arrow border |
64
+ | `tooltip.color` | `string` | `'#1f2a36'` | Tooltip text color |
65
+ | `tooltip.fontFamily` | `string` | - | Tooltip text font |
66
+ | `tooltip.fontSize` | `number` | `12` | Tooltip text size in pixels |
67
+ | `tooltip.fontWeight` | `string` | `'normal'` | Tooltip text weight |
54
68
 
55
69
  ---
56
70
 
package/docs/xy-chart.md CHANGED
@@ -21,6 +21,7 @@ new XYChart(config: XYChartConfig)
21
21
  | `responsive` | `ResponsiveConfig` | - | Container-query responsive overrides (theme + components) |
22
22
  | `barStack` | `BarStackConfig` | `{ mode: 'normal', gap: 0.1, reverseSeries: false }` | Bar stacking configuration |
23
23
  | `areaStack` | `AreaStackConfig` | `{ mode: 'none' }` | Area stacking configuration |
24
+ | `animate` | `boolean \| XYAnimationConfig` | `false` | Opt-in XY series animation for initial render and `update()` |
24
25
 
25
26
  ### Theme Options
26
27
 
@@ -223,6 +224,45 @@ is measured after declarative breakpoint overrides are merged. It receives
223
224
  `context.activeBreakpoints` with every match and `context.breakpoint` as the
224
225
  last matching breakpoint name.
225
226
 
227
+ ## Animation
228
+
229
+ Use `animate` when you want XY series marks to animate on the first render and
230
+ when calling `chart.update(...)`.
231
+
232
+ ```typescript
233
+ const chart = new XYChart({
234
+ data,
235
+ animate: {
236
+ duration: 700,
237
+ easing: 'ease-in-out',
238
+ },
239
+ });
240
+ ```
241
+
242
+ `XYAnimationConfig` supports:
243
+
244
+ ```typescript
245
+ animate: boolean | {
246
+ show?: boolean, // default: true when object form is used
247
+ duration?: number, // ms, default: 700
248
+ easing?:
249
+ | 'linear'
250
+ | 'ease-in'
251
+ | 'ease-out'
252
+ | 'ease-in-out'
253
+ | 'bounce-out'
254
+ | 'elastic-out'
255
+ | `linear(...)`
256
+ | ((t: number) => number),
257
+ }
258
+ ```
259
+
260
+ Notes:
261
+
262
+ - Animation is off by default.
263
+ - Animates XY series marks only. Axes, legends, tooltips, and value labels remain static.
264
+ - Visual exports always render the final static state, even when the live chart is animated.
265
+
226
266
  ## Validation
227
267
 
228
268
  `XYChart` validates configuration and series data early:
@@ -256,6 +296,9 @@ chart.render('#chart-container');
256
296
  chart.render(document.getElementById('chart-container'));
257
297
  ```
258
298
 
299
+ When `animate` is enabled, the first render returns immediately while the series
300
+ transition continues in the background.
301
+
259
302
  ### update(data)
260
303
 
261
304
  Updates the chart with new data and re-renders.
@@ -264,6 +307,18 @@ Updates the chart with new data and re-renders.
264
307
  chart.update(newData);
265
308
  ```
266
309
 
310
+ When `animate` is enabled, `update()` animates XY series marks from the previous
311
+ rendered geometry to the new one.
312
+
313
+ ### whenReady()
314
+
315
+ Returns a promise that resolves after any pending async render work finishes.
316
+ For animated XY charts, this waits until the current series transitions finish.
317
+
318
+ ```javascript
319
+ await chart.whenReady();
320
+ ```
321
+
267
322
  ### destroy()
268
323
 
269
324
  Cleans up all resources, removes resize observer, and clears the chart from the DOM.
@@ -280,9 +335,13 @@ Renders a line series on the chart.
280
335
 
281
336
  ```typescript
282
337
  new Line({
283
- dataKey: string, // Key in data objects for Y values (required)
284
- stroke?: string, // Line color (auto-assigned if omitted)
285
- strokeWidth?: number, // Line width in pixels (default: 2)
338
+ dataKey: string, // Key in data objects for Y values (required)
339
+ stroke?: string, // Line color (auto-assigned if omitted)
340
+ strokeWidth?: number, // Line width in pixels (default: 2)
341
+ valueLabel?: {
342
+ show?: boolean,
343
+ formatter?: (dataKey, value, data) => string
344
+ } // Point value badges
286
345
  })
287
346
  ```
288
347
 
@@ -309,7 +368,10 @@ new Scatter({
309
368
  dataKey: string, // Key in data objects for Y values (required)
310
369
  stroke?: string, // Point color (auto-assigned if omitted)
311
370
  pointSize?: number, // Point radius in pixels (default: theme.line.point.size)
312
- valueLabel?: { show?: boolean } // Point value badges
371
+ valueLabel?: {
372
+ show?: boolean,
373
+ formatter?: (dataKey, value, data) => string
374
+ } // Point value badges
313
375
  })
314
376
  ```
315
377
 
@@ -335,7 +397,8 @@ new Bar({
335
397
  valueLabel?: {
336
398
  show?: boolean,
337
399
  position?: 'inside' | 'outside',
338
- insidePosition?: 'top' | 'middle' | 'bottom'
400
+ insidePosition?: 'top' | 'middle' | 'bottom',
401
+ formatter?: (dataKey, value, data) => string
339
402
  }
340
403
  })
341
404
  ```
@@ -430,7 +493,18 @@ Bar charts support different stacking modes:
430
493
  - `percent` - 100% stacked bars
431
494
  - `layer` - Overlapping bars
432
495
 
433
- Use `barStack.reverseSeries: true` to reverse bar series display order for rendering, legend entries, and tooltip rows without changing data exports.
496
+ XY charts use split tooltips by default, rendering one tooltip per visible
497
+ series at the hovered category. Use
498
+ `new Tooltip({ mode: 'shared' | 'split', position: 'side' | 'vertical' })`
499
+ to override the grouping and split-tooltip placement.
500
+ For bars, set `barAnchorPosition: 'top' | 'middle'` to choose whether split
501
+ tooltips point to the top or middle of each bar.
502
+ Use `transition: { show: true, duration: 120, easing: 'ease-out' }` to opt in
503
+ to softer tooltip opacity transitions without delaying position updates.
504
+
505
+ Use `barStack.reverseSeries: true` to reverse bar series display order for
506
+ rendering, legend entries, and split tooltip ordering without changing data
507
+ exports.
434
508
 
435
509
  ---
436
510
 
@@ -449,7 +523,10 @@ new Area({
449
523
  baseline?: number, // Baseline for non-stacked area (default: 0)
450
524
  showLine?: boolean, // Show top stroke line (default: true)
451
525
  showPoints?: boolean, // Show points on top line (default: false)
452
- valueLabel?: { show?: boolean } // Point value badges
526
+ valueLabel?: {
527
+ show?: boolean,
528
+ formatter?: (dataKey, value, data) => string
529
+ } // Point value badges
453
530
  })
454
531
  ```
455
532
 
@@ -468,6 +545,10 @@ chart.addChild(
468
545
  );
469
546
  ```
470
547
 
548
+ For `Line`, `Scatter`, `Bar`, and `Area`, `valueLabel.formatter` receives
549
+ `(dataKey, value, data)`, where `value` is the parsed numeric series value used
550
+ for rendering that label.
551
+
471
552
  ### Area Stacking
472
553
 
473
554
  Area charts support stacking when series share the same `stackId`: