@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,90 @@
1
+ const DEFAULT_ROOT_MARGIN = '200px 0px';
2
+ const DEFAULT_THRESHOLD = 0.01;
3
+ function resolveTarget(target) {
4
+ if (target instanceof HTMLElement) {
5
+ return target;
6
+ }
7
+ const container = document.querySelector(target);
8
+ if (!container) {
9
+ throw new Error(`Container "${target}" not found`);
10
+ }
11
+ if (!(container instanceof HTMLElement)) {
12
+ throw new Error(`Container "${target}" is not an HTMLElement`);
13
+ }
14
+ return container;
15
+ }
16
+ export function mountChartWhenVisible(target, factory, options = {}) {
17
+ const container = resolveTarget(target);
18
+ let mountedChart = null;
19
+ let pendingLoad = null;
20
+ let observer = null;
21
+ let destroyed = false;
22
+ const disconnectObserver = () => {
23
+ observer?.disconnect();
24
+ observer = null;
25
+ };
26
+ const handleError = (error) => {
27
+ if (options.onError) {
28
+ options.onError(error);
29
+ return;
30
+ }
31
+ console.error(error);
32
+ };
33
+ const load = async () => {
34
+ if (destroyed) {
35
+ return null;
36
+ }
37
+ if (mountedChart) {
38
+ return mountedChart;
39
+ }
40
+ if (pendingLoad) {
41
+ return pendingLoad;
42
+ }
43
+ disconnectObserver();
44
+ const currentLoad = Promise.resolve(factory(container))
45
+ .then((chart) => {
46
+ if (destroyed) {
47
+ chart.destroy();
48
+ return null;
49
+ }
50
+ mountedChart = chart;
51
+ return chart;
52
+ })
53
+ .catch((error) => {
54
+ handleError(error);
55
+ throw error;
56
+ })
57
+ .finally(() => {
58
+ if (pendingLoad === currentLoad && !mountedChart) {
59
+ pendingLoad = null;
60
+ }
61
+ });
62
+ pendingLoad = currentLoad;
63
+ return currentLoad;
64
+ };
65
+ const destroy = () => {
66
+ destroyed = true;
67
+ disconnectObserver();
68
+ mountedChart?.destroy();
69
+ mountedChart = null;
70
+ };
71
+ observer = new IntersectionObserver((entries) => {
72
+ const isVisible = entries.some((entry) => {
73
+ return entry.isIntersecting || entry.intersectionRatio > 0;
74
+ });
75
+ if (!isVisible) {
76
+ return;
77
+ }
78
+ void load().catch(() => { });
79
+ }, {
80
+ root: options.root ?? null,
81
+ rootMargin: options.rootMargin ?? DEFAULT_ROOT_MARGIN,
82
+ threshold: options.threshold ?? DEFAULT_THRESHOLD,
83
+ });
84
+ observer.observe(container);
85
+ return {
86
+ load,
87
+ destroy,
88
+ getChart: () => mountedChart,
89
+ };
90
+ }
package/dist/legend.js CHANGED
@@ -106,15 +106,16 @@ export class Legend {
106
106
  writable: true,
107
107
  value: null
108
108
  });
109
- this.mode = config?.mode ?? 'inline';
110
- this.position = config?.position || 'bottom';
111
- this.disconnectedTarget = config?.disconnectedTarget;
112
- this.marginTop = config?.marginTop ?? 20;
113
- this.marginBottom = config?.marginBottom ?? 10;
114
- this.paddingX = config?.paddingX;
115
- this.itemSpacingX = config?.itemSpacingX;
116
- this.itemSpacingY = config?.itemSpacingY;
117
- this.exportHooks = config?.exportHooks;
109
+ const { mode = 'inline', position = 'bottom', disconnectedTarget, marginTop = 20, marginBottom = 10, paddingX, itemSpacingX, itemSpacingY, exportHooks, } = config ?? {};
110
+ this.mode = mode;
111
+ this.position = position;
112
+ this.disconnectedTarget = disconnectedTarget;
113
+ this.marginTop = marginTop;
114
+ this.marginBottom = marginBottom;
115
+ this.paddingX = paddingX;
116
+ this.itemSpacingX = itemSpacingX;
117
+ this.itemSpacingY = itemSpacingY;
118
+ this.exportHooks = exportHooks;
118
119
  this.stateController = new LegendStateController();
119
120
  this.bindStateController(this.stateController);
120
121
  }
package/dist/line.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Selection } from 'd3';
2
2
  import type { LineConfig, DataItem, D3Scale, ScaleType, ChartTheme, LineValueLabelConfig, ExportHooks, LineConfigBase } from './types.js';
3
3
  import type { ChartComponent } from './chart-interface.js';
4
+ import type { XYPointAnimationContext, XYPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
4
5
  export declare class Line implements ChartComponent<LineConfigBase> {
5
6
  readonly type: "line";
6
7
  readonly dataKey: string;
@@ -11,6 +12,13 @@ export declare class Line implements ChartComponent<LineConfigBase> {
11
12
  constructor(config: LineConfig);
12
13
  getExportConfig(): LineConfigBase;
13
14
  createExportComponent(override?: Partial<LineConfigBase>): ChartComponent<LineConfigBase>;
14
- render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
15
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme, animation?: XYPointAnimationContext): XYSeriesRenderResult<XYPointSnapshot>;
16
+ private buildLineData;
17
+ private buildAnimatedLineData;
18
+ private createSnapshot;
19
+ private renderLinePath;
20
+ private getInitialPathValue;
21
+ private createRevealTransitions;
22
+ private renderLinePoints;
15
23
  private renderValueLabels;
16
24
  }
package/dist/line.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { line } from 'd3';
2
2
  import { sanitizeForCSS, mergeDeep } from './utils.js';
3
3
  import { getScalePosition } from './scale-utils.js';
4
+ import { buildXYDatumSnapshotKeys, createTransitionCompletionPromise, createLeftToRightRevealTransition, getEnterStaggerTiming, } from './xy-motion/helpers.js';
4
5
  export class Line {
5
6
  constructor(config) {
6
7
  Object.defineProperty(this, "type", {
@@ -60,7 +61,7 @@ export class Line {
60
61
  exportHooks: this.exportHooks,
61
62
  });
62
63
  }
63
- render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
64
+ render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, animation) {
64
65
  const getXPosition = (d) => {
65
66
  return (getScalePosition(x, d[xKey], xScaleType) +
66
67
  (x.bandwidth ? x.bandwidth() / 2 : 0));
@@ -70,41 +71,158 @@ export class Line {
70
71
  const value = d[this.dataKey];
71
72
  return value !== null && value !== undefined;
72
73
  };
73
- const lineGenerator = line()
74
- .defined(hasValidValue)
75
- .x(getXPosition)
76
- .y((d) => y(parseValue(d[this.dataKey])) || 0);
74
+ const snapshotKeys = buildXYDatumSnapshotKeys(data, xKey);
75
+ const lineData = this.buildLineData(data, snapshotKeys, y, parseValue, getXPosition, hasValidValue);
76
+ const animatedLineData = this.buildAnimatedLineData(lineData, animation);
77
+ const validLineData = lineData.filter((entry) => entry.valid);
78
+ const validAnimatedLineData = animatedLineData.filter((entry) => {
79
+ return entry.valid;
80
+ });
81
+ const transitions = [
82
+ ...this.renderLinePath(plotGroup, lineData, animatedLineData, theme, sanitizeForCSS(this.dataKey), animation),
83
+ ...this.renderLinePoints(plotGroup, validLineData, validAnimatedLineData, theme, animation),
84
+ ];
85
+ const snapshot = this.createSnapshot(validLineData);
86
+ // Render value labels if enabled (only for valid values)
87
+ if (this.valueLabel?.show) {
88
+ this.renderValueLabels(plotGroup, validLineData.map((entry) => entry.data), y, parseValue, theme, getXPosition);
89
+ }
90
+ return {
91
+ snapshot,
92
+ transitions,
93
+ };
94
+ }
95
+ buildLineData(data, snapshotKeys, y, parseValue, getXPosition, hasValidValue) {
96
+ return data.map((entry, index) => {
97
+ const valid = hasValidValue(entry);
98
+ return {
99
+ data: entry,
100
+ valid,
101
+ snapshotKey: snapshotKeys[index] ?? String(index),
102
+ x: getXPosition(entry),
103
+ y: valid ? (y(parseValue(entry[this.dataKey])) ?? 0) : 0,
104
+ };
105
+ });
106
+ }
107
+ buildAnimatedLineData(lineData, animation) {
108
+ return lineData.map((entry) => {
109
+ if (!animation || !entry.valid) {
110
+ return entry;
111
+ }
112
+ const previousSnapshot = animation.previousSnapshot?.get(entry.snapshotKey);
113
+ return {
114
+ ...entry,
115
+ x: previousSnapshot?.x ?? entry.x,
116
+ y: previousSnapshot?.y ?? animation.baselineY,
117
+ };
118
+ });
119
+ }
120
+ createSnapshot(lineData) {
121
+ const snapshot = new Map();
122
+ lineData.forEach((entry) => {
123
+ snapshot.set(entry.snapshotKey, {
124
+ x: entry.x,
125
+ y: entry.y,
126
+ });
127
+ });
128
+ return snapshot;
129
+ }
130
+ renderLinePath(plotGroup, lineData, animatedLineData, theme, sanitizedKey, animation) {
77
131
  const lineStrokeWidth = this.strokeWidth ?? theme.line.strokeWidth;
78
- const pointSize = theme.line.point.size;
79
- const pointStrokeWidth = theme.line.point.strokeWidth;
80
- const pointStrokeColor = theme.line.point.strokeColor || this.stroke;
81
- const pointColor = theme.line.point.color || this.stroke;
82
- // Add line path
83
- plotGroup
132
+ const lineGenerator = line()
133
+ .defined((entry) => entry.valid)
134
+ .x((entry) => entry.x)
135
+ .y((entry) => entry.y);
136
+ const finalPath = lineGenerator(lineData);
137
+ const linePath = plotGroup
84
138
  .append('path')
85
- .datum(data)
139
+ .datum(lineData)
140
+ .attr('class', `line-${sanitizedKey}`)
86
141
  .attr('fill', 'none')
87
142
  .attr('stroke', this.stroke)
88
143
  .attr('stroke-width', lineStrokeWidth)
89
- .attr('d', lineGenerator);
90
- // Add data point circles (only for valid values)
91
- const validData = data.filter(hasValidValue);
144
+ .attr('d', this.getInitialPathValue(finalPath, animatedLineData, lineGenerator, animation));
145
+ if (!animation || !finalPath) {
146
+ return [];
147
+ }
148
+ const revealTransitions = this.createRevealTransitions(linePath.node(), sanitizedKey, lineStrokeWidth, animation);
149
+ const transition = linePath
150
+ .transition()
151
+ .duration(animation.duration)
152
+ .ease(animation.easing)
153
+ .attr('d', finalPath);
154
+ return [
155
+ ...revealTransitions,
156
+ createTransitionCompletionPromise(transition),
157
+ ];
158
+ }
159
+ getInitialPathValue(finalPath, animatedLineData, lineGenerator, animation) {
160
+ if (animation?.mode === 'initial') {
161
+ return finalPath;
162
+ }
163
+ if (!animation) {
164
+ return finalPath;
165
+ }
166
+ return animation.previousPath ?? lineGenerator(animatedLineData);
167
+ }
168
+ createRevealTransitions(linePath, sanitizedKey, lineStrokeWidth, animation) {
169
+ if (animation.mode === 'initial') {
170
+ return createLeftToRightRevealTransition(linePath, animation.duration, animation.easing, `line-${sanitizedKey}-reveal`, lineStrokeWidth);
171
+ }
172
+ if (animation.previousRevealProgress == null) {
173
+ return [];
174
+ }
175
+ return createLeftToRightRevealTransition(linePath, animation.duration, animation.easing, `line-${sanitizedKey}-reveal`, lineStrokeWidth, animation.previousRevealProgress);
176
+ }
177
+ renderLinePoints(plotGroup, validLineData, validAnimatedLineData, theme, animation) {
178
+ const pointSize = theme.line.point.size;
179
+ const pointStrokeWidth = theme.line.point.strokeWidth;
180
+ const pointStrokeColor = theme.line.point.strokeColor || this.stroke;
181
+ const pointColor = theme.line.point.color || this.stroke;
92
182
  const sanitizedKey = sanitizeForCSS(this.dataKey);
93
- plotGroup
183
+ const isInitialAnimation = animation?.mode === 'initial';
184
+ const circles = plotGroup
94
185
  .selectAll(`.circle-${sanitizedKey}`)
95
- .data(validData)
186
+ .data(validLineData)
96
187
  .join('circle')
97
188
  .attr('class', `circle-${sanitizedKey}`)
98
- .attr('cx', getXPosition)
99
- .attr('cy', (d) => y(parseValue(d[this.dataKey])) || 0)
100
- .attr('r', pointSize)
189
+ .attr('cx', (_, index) => (isInitialAnimation
190
+ ? validLineData[index]?.x
191
+ : animation
192
+ ? validAnimatedLineData[index]?.x
193
+ : validLineData[index]?.x) ?? 0)
194
+ .attr('cy', (_, index) => (isInitialAnimation
195
+ ? validLineData[index]?.y
196
+ : animation
197
+ ? validAnimatedLineData[index]?.y
198
+ : validLineData[index]?.y) ?? 0)
199
+ .attr('r', isInitialAnimation ? 0 : pointSize)
101
200
  .attr('fill', pointColor)
102
201
  .attr('stroke', pointStrokeColor)
103
202
  .attr('stroke-width', pointStrokeWidth);
104
- // Render value labels if enabled (only for valid values)
105
- if (this.valueLabel?.show) {
106
- this.renderValueLabels(plotGroup, validData, y, parseValue, theme, getXPosition);
203
+ if (!animation) {
204
+ return [];
205
+ }
206
+ if (isInitialAnimation) {
207
+ const transition = circles
208
+ .transition()
209
+ .delay((_, index) => {
210
+ return getEnterStaggerTiming(index, validLineData.length, animation.duration).delay;
211
+ })
212
+ .duration((_, index) => {
213
+ return getEnterStaggerTiming(index, validLineData.length, animation.duration).duration;
214
+ })
215
+ .ease(animation.easing)
216
+ .attr('r', pointSize);
217
+ return [createTransitionCompletionPromise(transition)];
107
218
  }
219
+ const transition = circles
220
+ .transition()
221
+ .duration(animation.duration)
222
+ .ease(animation.easing)
223
+ .attr('cx', (_, index) => validLineData[index]?.x ?? 0)
224
+ .attr('cy', (_, index) => validLineData[index]?.y ?? 0);
225
+ return [createTransitionCompletionPromise(transition)];
108
226
  }
109
227
  renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
110
228
  const config = this.valueLabel;
@@ -123,7 +241,9 @@ export class Line {
123
241
  const plotBottom = y.range()[0];
124
242
  data.forEach((d) => {
125
243
  const value = parseValue(d[this.dataKey]);
126
- const valueText = String(value);
244
+ const valueText = config.formatter
245
+ ? config.formatter(this.dataKey, value, d)
246
+ : String(value);
127
247
  const xPos = getXPosition(d);
128
248
  const yPos = y(value) || 0;
129
249
  // Create temporary text to measure dimensions
@@ -46,6 +46,9 @@ export declare class PieChart extends RadialChartBase {
46
46
  private segments;
47
47
  constructor(config: PieChartConfig);
48
48
  private validatePieData;
49
+ private validatePieConfig;
50
+ private validateValueLabelConfig;
51
+ private validateDataItems;
49
52
  private prepareSegments;
50
53
  private warnOnTinySlices;
51
54
  protected getExportComponents(): ChartComponentBase[];
package/dist/pie-chart.js CHANGED
@@ -8,6 +8,17 @@ const FULL_CIRCLE_RADIANS = Math.PI * 2;
8
8
  const OUTSIDE_LABEL_TEXT_OFFSET_PX = 10;
9
9
  const OUTSIDE_LABEL_LINE_INSET_PX = 4;
10
10
  const TINY_SLICE_THRESHOLD_RATIO = 0.03;
11
+ const DEFAULT_PIE_VALUE_LABEL = {
12
+ show: false,
13
+ position: 'auto',
14
+ minInsidePercentage: 8,
15
+ outsideOffset: 16,
16
+ insideMargin: 8,
17
+ minVerticalSpacing: 14,
18
+ formatter: (label, value, _data, _percentage) => {
19
+ return `${label}: ${value}`;
20
+ },
21
+ };
11
22
  export class PieChart extends RadialChartBase {
12
23
  constructor(config) {
13
24
  super(config);
@@ -71,25 +82,31 @@ export class PieChart extends RadialChartBase {
71
82
  writable: true,
72
83
  value: []
73
84
  });
74
- const pieConfig = config.pie ?? {};
75
- this.innerRadiusRatio = pieConfig.innerRadius ?? 0;
76
- this.startAngle = pieConfig.startAngle ?? 0;
77
- this.endAngle = pieConfig.endAngle ?? FULL_CIRCLE_RADIANS;
78
- this.padAngle = pieConfig.padAngle ?? this.theme.donut.padAngle;
79
- this.cornerRadius =
80
- pieConfig.cornerRadius ?? this.theme.donut.cornerRadius;
81
- this.sort = pieConfig.sort ?? 'none';
82
- this.valueKey = config.valueKey ?? 'value';
83
- this.labelKey = config.labelKey ?? 'name';
85
+ const pieConfig = {
86
+ innerRadius: 0,
87
+ startAngle: 0,
88
+ endAngle: FULL_CIRCLE_RADIANS,
89
+ padAngle: this.theme.donut.padAngle,
90
+ cornerRadius: this.theme.donut.cornerRadius,
91
+ sort: 'none',
92
+ ...config.pie,
93
+ };
94
+ const resolvedConfig = {
95
+ valueKey: 'value',
96
+ labelKey: 'name',
97
+ ...config,
98
+ };
99
+ this.innerRadiusRatio = pieConfig.innerRadius;
100
+ this.startAngle = pieConfig.startAngle;
101
+ this.endAngle = pieConfig.endAngle;
102
+ this.padAngle = pieConfig.padAngle;
103
+ this.cornerRadius = pieConfig.cornerRadius;
104
+ this.sort = pieConfig.sort;
105
+ this.valueKey = resolvedConfig.valueKey;
106
+ this.labelKey = resolvedConfig.labelKey;
84
107
  this.valueLabel = {
85
- show: config.valueLabel?.show ?? false,
86
- position: config.valueLabel?.position ?? 'auto',
87
- minInsidePercentage: config.valueLabel?.minInsidePercentage ?? 8,
88
- outsideOffset: config.valueLabel?.outsideOffset ?? 16,
89
- insideMargin: config.valueLabel?.insideMargin ?? 8,
90
- minVerticalSpacing: config.valueLabel?.minVerticalSpacing ?? 14,
91
- formatter: config.valueLabel?.formatter ??
92
- ((label, value, _data, _percentage) => `${label}: ${value}`),
108
+ ...DEFAULT_PIE_VALUE_LABEL,
109
+ ...config.valueLabel,
93
110
  };
94
111
  this.initializeDataState();
95
112
  }
@@ -97,6 +114,11 @@ export class PieChart extends RadialChartBase {
97
114
  ChartValidator.validateDataKey(this.data, this.labelKey, 'PieChart');
98
115
  ChartValidator.validateDataKey(this.data, this.valueKey, 'PieChart');
99
116
  ChartValidator.validateNumericData(this.data, this.valueKey, 'PieChart');
117
+ this.validatePieConfig();
118
+ this.validateValueLabelConfig();
119
+ this.validateDataItems();
120
+ }
121
+ validatePieConfig() {
100
122
  if (this.innerRadiusRatio < 0 || this.innerRadiusRatio > 1) {
101
123
  throw new Error(`PieChart: pie.innerRadius must be between 0 and 1, received '${this.innerRadiusRatio}'`);
102
124
  }
@@ -106,6 +128,8 @@ export class PieChart extends RadialChartBase {
106
128
  if (this.padAngle < 0) {
107
129
  throw new Error(`PieChart: pie.padAngle must be >= 0, received '${this.padAngle}'`);
108
130
  }
131
+ }
132
+ validateValueLabelConfig() {
109
133
  if (this.valueLabel.minInsidePercentage < 0 ||
110
134
  this.valueLabel.minInsidePercentage > 100) {
111
135
  throw new Error(`PieChart: valueLabel.minInsidePercentage must be between 0 and 100, received '${this.valueLabel.minInsidePercentage}'`);
@@ -119,6 +143,8 @@ export class PieChart extends RadialChartBase {
119
143
  if (this.valueLabel.minVerticalSpacing < 0) {
120
144
  throw new Error(`PieChart: valueLabel.minVerticalSpacing must be >= 0, received '${this.valueLabel.minVerticalSpacing}'`);
121
145
  }
146
+ }
147
+ validateDataItems() {
122
148
  for (const [index, item] of this.data.entries()) {
123
149
  const label = String(item[this.labelKey] ?? '').trim();
124
150
  if (!label) {
@@ -263,9 +289,6 @@ export class PieChart extends RadialChartBase {
263
289
  .attr('class', 'pie-segments')
264
290
  .attr('transform', `translate(${cx}, ${cy})`);
265
291
  const total = segments.reduce((sum, segment) => sum + segment.value, 0);
266
- const resolveTooltipDiv = () => this.tooltip
267
- ? select(`#${this.tooltip.id}`)
268
- : null;
269
292
  const segmentSelection = segmentGroup
270
293
  .selectAll('.pie-segment')
271
294
  .data(pieData)
@@ -287,19 +310,10 @@ export class PieChart extends RadialChartBase {
287
310
  segmentSelection
288
311
  .filter((_, i, nodes) => nodes[i] !== target)
289
312
  .style('opacity', 0.5);
290
- const tooltipDiv = resolveTooltipDiv();
291
- if (tooltipDiv && !tooltipDiv.empty()) {
292
- tooltipDiv
293
- .style('visibility', 'visible')
294
- .html(this.buildTooltipContent(d, segments));
295
- this.positionTooltipFromPointer(event, tooltipDiv);
296
- }
313
+ this.showTooltipFromPointer(event, this.buildTooltipContent(d, segments));
297
314
  })
298
315
  .on('mousemove', (event) => {
299
- const tooltipDiv = resolveTooltipDiv();
300
- if (tooltipDiv && !tooltipDiv.empty()) {
301
- this.positionTooltipFromPointer(event, tooltipDiv);
302
- }
316
+ this.positionTooltipFromPointer(event);
303
317
  })
304
318
  .on('mouseleave', (event, d) => {
305
319
  const target = event.currentTarget;
@@ -308,10 +322,7 @@ export class PieChart extends RadialChartBase {
308
322
  .duration(ANIMATION_DURATION_MS)
309
323
  .attr('d', arcGenerator(d));
310
324
  segmentSelection.style('opacity', 1);
311
- const tooltipDiv = resolveTooltipDiv();
312
- if (tooltipDiv && !tooltipDiv.empty()) {
313
- tooltipDiv.style('visibility', 'hidden');
314
- }
325
+ this.hideTooltip();
315
326
  })
316
327
  .on('focus', (event, d) => {
317
328
  const target = event.currentTarget;
@@ -322,13 +333,7 @@ export class PieChart extends RadialChartBase {
322
333
  segmentSelection
323
334
  .filter((_, i, nodes) => nodes[i] !== target)
324
335
  .style('opacity', 0.5);
325
- const tooltipDiv = resolveTooltipDiv();
326
- if (tooltipDiv && !tooltipDiv.empty()) {
327
- tooltipDiv
328
- .style('visibility', 'visible')
329
- .html(this.buildTooltipContent(d, segments));
330
- this.positionTooltipAtElement(target, tooltipDiv);
331
- }
336
+ this.showTooltipAtElement(target, this.buildTooltipContent(d, segments));
332
337
  })
333
338
  .on('blur', (event, d) => {
334
339
  const target = event.currentTarget;
@@ -337,10 +342,7 @@ export class PieChart extends RadialChartBase {
337
342
  .duration(ANIMATION_DURATION_MS)
338
343
  .attr('d', arcGenerator(d));
339
344
  segmentSelection.style('opacity', 1);
340
- const tooltipDiv = resolveTooltipDiv();
341
- if (tooltipDiv && !tooltipDiv.empty()) {
342
- tooltipDiv.style('visibility', 'hidden');
343
- }
345
+ this.hideTooltip();
344
346
  })
345
347
  .on('keydown', (event) => {
346
348
  this.handleSegmentKeyNavigation(event);
@@ -1,4 +1,3 @@
1
- import type { Selection } from 'd3';
2
1
  import { BaseChart } from './base-chart.js';
3
2
  import type { PlotAreaBounds } from './layout-manager.js';
4
3
  import type { LegendSeries } from './types.js';
@@ -17,8 +16,10 @@ export declare abstract class RadialChartBase extends BaseChart {
17
16
  fontScale: number;
18
17
  };
19
18
  protected getRadialLegendSeries<T extends RadialLegendItem>(items: T[]): LegendSeries[];
20
- protected positionTooltipFromPointer(event: MouseEvent, tooltipDiv: Selection<HTMLDivElement, unknown, HTMLElement, undefined>): void;
21
- protected positionTooltipAtElement(target: Element, tooltipDiv: Selection<HTMLDivElement, unknown, HTMLElement, undefined>): void;
19
+ protected showTooltipFromPointer(event: MouseEvent, content: string): void;
20
+ protected positionTooltipFromPointer(event: MouseEvent): void;
21
+ protected showTooltipAtElement(target: Element, content: string): void;
22
+ protected hideTooltip(): void;
22
23
  private applyTooltipPosition;
23
24
  private resolveRadialFontScale;
24
25
  }
@@ -27,26 +27,38 @@ export class RadialChartBase extends BaseChart {
27
27
  fill: item.color,
28
28
  }));
29
29
  }
30
- positionTooltipFromPointer(event, tooltipDiv) {
31
- const node = tooltipDiv.node();
32
- if (!node) {
30
+ showTooltipFromPointer(event, content) {
31
+ if (!this.tooltip) {
32
+ return;
33
+ }
34
+ this.tooltip.setContent(content);
35
+ this.positionTooltipFromPointer(event);
36
+ }
37
+ positionTooltipFromPointer(event) {
38
+ if (!this.tooltip) {
39
+ return;
40
+ }
41
+ const rect = this.tooltip.getBounds();
42
+ if (!rect) {
33
43
  return;
34
44
  }
35
- const rect = node.getBoundingClientRect();
36
45
  let x = event.pageX + TOOLTIP_OFFSET_PX;
37
46
  const y = event.pageY - rect.height / 2;
38
47
  if (x + rect.width > window.innerWidth - EDGE_MARGIN_PX) {
39
48
  x = event.pageX - rect.width - TOOLTIP_OFFSET_PX;
40
49
  }
41
- this.applyTooltipPosition(tooltipDiv, x, y, rect.height, rect.width);
50
+ this.applyTooltipPosition(x, y, rect.height, rect.width);
42
51
  }
43
- positionTooltipAtElement(target, tooltipDiv) {
44
- const node = tooltipDiv.node();
45
- if (!node) {
52
+ showTooltipAtElement(target, content) {
53
+ if (!this.tooltip) {
46
54
  return;
47
55
  }
56
+ this.tooltip.setContent(content);
48
57
  const targetRect = target.getBoundingClientRect();
49
- const tooltipRect = node.getBoundingClientRect();
58
+ const tooltipRect = this.tooltip.getBounds();
59
+ if (!tooltipRect) {
60
+ return;
61
+ }
50
62
  let x = targetRect.left +
51
63
  window.scrollX +
52
64
  targetRect.width / 2 +
@@ -62,12 +74,15 @@ export class RadialChartBase extends BaseChart {
62
74
  tooltipRect.width -
63
75
  TOOLTIP_OFFSET_PX;
64
76
  }
65
- this.applyTooltipPosition(tooltipDiv, x, y, tooltipRect.height, tooltipRect.width);
77
+ this.applyTooltipPosition(x, y, tooltipRect.height, tooltipRect.width);
78
+ }
79
+ hideTooltip() {
80
+ this.tooltip?.hide();
66
81
  }
67
- applyTooltipPosition(tooltipDiv, rawX, rawY, height, width) {
82
+ applyTooltipPosition(rawX, rawY, height, width) {
68
83
  const x = Math.max(EDGE_MARGIN_PX, Math.min(rawX, window.innerWidth + window.scrollX - width - EDGE_MARGIN_PX));
69
84
  const y = Math.max(EDGE_MARGIN_PX, Math.min(rawY, window.innerHeight + window.scrollY - height - EDGE_MARGIN_PX));
70
- tooltipDiv.style('left', `${x}px`).style('top', `${y}px`);
85
+ this.tooltip?.showAt(x, y);
71
86
  }
72
87
  resolveRadialFontScale(outerRadius, theme) {
73
88
  const referenceHeight = this.configuredHeight ?? DEFAULT_CHART_HEIGHT;
package/dist/scatter.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ChartTheme, D3Scale, DataItem, ExportHooks, LineValueLabelConfig, ScaleType, ScatterConfig, ScatterConfigBase } from './types.js';
2
2
  import type { ChartComponent } from './chart-interface.js';
3
3
  import type { Selection } from 'd3';
4
+ import type { XYPointAnimationContext, XYPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
4
5
  export declare class Scatter implements ChartComponent<ScatterConfigBase> {
5
6
  readonly type: "scatter";
6
7
  readonly dataKey: string;
@@ -11,6 +12,9 @@ export declare class Scatter implements ChartComponent<ScatterConfigBase> {
11
12
  constructor(config: ScatterConfig);
12
13
  getExportConfig(): ScatterConfigBase;
13
14
  createExportComponent(override?: Partial<ScatterConfigBase>): ChartComponent<ScatterConfigBase>;
14
- render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
15
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme, animation?: XYPointAnimationContext): XYSeriesRenderResult<XYPointSnapshot>;
16
+ private buildAnimatedData;
17
+ private createSnapshot;
18
+ private renderPoints;
15
19
  private renderValueLabels;
16
20
  }