@internetstiftelsen/charts 0.10.1 → 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 (45) hide show
  1. package/README.md +64 -0
  2. package/dist/area.d.ts +9 -1
  3. package/dist/area.js +174 -38
  4. package/dist/bar.d.ts +9 -1
  5. package/dist/bar.js +130 -47
  6. package/dist/base-chart.js +11 -1
  7. package/dist/donut-chart.js +3 -18
  8. package/dist/gauge-chart.d.ts +3 -4
  9. package/dist/gauge-chart.js +7 -53
  10. package/dist/lazy-mount.d.ts +13 -0
  11. package/dist/lazy-mount.js +90 -0
  12. package/dist/line.d.ts +9 -1
  13. package/dist/line.js +141 -23
  14. package/dist/pie-chart.js +5 -29
  15. package/dist/radial-chart-base.d.ts +4 -3
  16. package/dist/radial-chart-base.js +27 -12
  17. package/dist/scatter.d.ts +5 -1
  18. package/dist/scatter.js +89 -8
  19. package/dist/theme.js +17 -0
  20. package/dist/tooltip.d.ts +55 -3
  21. package/dist/tooltip.js +950 -137
  22. package/dist/types.d.ts +20 -0
  23. package/dist/xy-animation.d.ts +3 -0
  24. package/dist/xy-animation.js +2 -0
  25. package/dist/xy-chart.d.ts +11 -1
  26. package/dist/xy-chart.js +107 -10
  27. package/dist/xy-motion/config.d.ts +2 -0
  28. package/dist/xy-motion/config.js +177 -0
  29. package/dist/xy-motion/driver.d.ts +9 -0
  30. package/dist/xy-motion/driver.js +10 -0
  31. package/dist/xy-motion/helpers.d.ts +17 -0
  32. package/dist/xy-motion/helpers.js +105 -0
  33. package/dist/xy-motion/live-state.d.ts +8 -0
  34. package/dist/xy-motion/live-state.js +240 -0
  35. package/dist/xy-motion/noop-xy-motion-driver.d.ts +9 -0
  36. package/dist/xy-motion/noop-xy-motion-driver.js +15 -0
  37. package/dist/xy-motion/types.d.ts +85 -0
  38. package/dist/xy-motion/types.js +1 -0
  39. package/dist/xy-motion/xy-motion-driver.d.ts +19 -0
  40. package/dist/xy-motion/xy-motion-driver.js +130 -0
  41. package/docs/components.md +36 -0
  42. package/docs/getting-started.md +35 -0
  43. package/docs/theming.md +14 -0
  44. package/docs/xy-chart.md +67 -1
  45. package/package.json +1 -1
package/README.md CHANGED
@@ -11,6 +11,7 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
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
13
  - **Custom Value Labels** - XY, pie, and donut charts support optional on-chart labels with custom formatters
14
+ - **Optional XY Animation** - Animate XY series on first render and `chart.update(...)` with `animate`
14
15
  - **Optional Gauge Animation** - Animate gauge value transitions with `gauge.animate`
15
16
  - **Stacking Control** - Bar stacking modes with optional reversed visual series order
16
17
  - **Axis Direction Control** - Use `scales.x.reverse` / `scales.y.reverse` to flip an axis when needed
@@ -18,6 +19,7 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
18
19
  - **Explicit or Responsive Sizing** - Set top-level `width`/`height` or let the container drive size
19
20
  - **Auto Resize** - Built-in ResizeObserver handles responsive behavior
20
21
  - **Responsive Policy** - Chart-level container-query overrides for theme and components
22
+ - **Lazy Mount Utility** - Observe a container and defer chart imports until it approaches the viewport
21
23
  - **Type Safe** - Written in TypeScript with full type definitions
22
24
  - **Data Validation** - Built-in validation with helpful error messages
23
25
  - **Auto Colors** - Smart color palette with sensible defaults
@@ -110,6 +112,68 @@ from the render container.
110
112
  Theme overrides are deep-partial, so nested overrides like
111
113
  `theme.axis.fontSize` preserve the rest of the theme defaults.
112
114
 
115
+ ## XY Animation
116
+
117
+ Enable `animate` on `XYChart` when you want series marks to animate on the
118
+ first render and on later `chart.update(...)` calls.
119
+
120
+ ```typescript
121
+ const chart = new XYChart({
122
+ data,
123
+ animate: {
124
+ duration: 700,
125
+ easing: 'ease-in-out',
126
+ },
127
+ });
128
+
129
+ chart
130
+ .addChild(new XAxis({ dataKey: 'month' }))
131
+ .addChild(new YAxis())
132
+ .addChild(new Line({ dataKey: 'value' }));
133
+
134
+ chart.render('#chart-container');
135
+
136
+ await chart.whenReady();
137
+ ```
138
+
139
+ Animation is off by default, applies to XY series marks only, and visual
140
+ exports always render the final static state.
141
+
142
+ ## Lazy Loading
143
+
144
+ Use `mountChartWhenVisible` when you want the page to wait until a chart is
145
+ near the viewport before importing chart code and rendering it.
146
+
147
+ ```typescript
148
+ import { mountChartWhenVisible } from '@internetstiftelsen/charts/lazy-mount';
149
+
150
+ const lazyChart = mountChartWhenVisible(
151
+ '#chart-container',
152
+ async (container) => {
153
+ 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')]);
154
+
155
+ const chart = new XYChart({ data });
156
+ chart
157
+ .addChild(new XAxis({ dataKey: 'month' }))
158
+ .addChild(new YAxis())
159
+ .addChild(new Line({ dataKey: 'value' }));
160
+
161
+ chart.render(container);
162
+ return chart;
163
+ },
164
+ {
165
+ rootMargin: '240px 0px',
166
+ },
167
+ );
168
+
169
+ // Optional: preload manually before it scrolls into view
170
+ await lazyChart.load();
171
+ ```
172
+
173
+ The utility is intentionally small: it observes the container, calls your async
174
+ factory once, and gives you `load()` plus `destroy()` so higher-level DOM
175
+ conventions such as `data-chart` scanners can build on top of it.
176
+
113
177
  ## Chart Groups
114
178
 
115
179
  Use `ChartGroup` when you want to combine existing charts into one layout while
package/dist/area.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Selection } from 'd3';
2
2
  import type { AreaConfig, AreaCurveType, AreaConfigBase, AreaStackingContext, ChartTheme, D3Scale, DataItem, ExportHooks, LineValueLabelConfig, ScaleType } from './types.js';
3
3
  import type { ChartComponent } from './chart-interface.js';
4
+ import type { XYAreaAnimationContext, XYAreaPointSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
4
5
  export declare class Area implements ChartComponent<AreaConfigBase> {
5
6
  readonly type: "area";
6
7
  readonly dataKey: string;
@@ -20,8 +21,15 @@ export declare class Area implements ChartComponent<AreaConfigBase> {
20
21
  getExportConfig(): AreaConfigBase;
21
22
  createExportComponent(override?: Partial<AreaConfigBase>): ChartComponent<AreaConfigBase>;
22
23
  private getStackValues;
23
- render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme, stackingContext?: AreaStackingContext, valueLabelLayer?: Selection<SVGGElement, undefined, null, undefined>): void;
24
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme, stackingContext?: AreaStackingContext, valueLabelLayer?: Selection<SVGGElement, undefined, null, undefined>, animation?: XYAreaAnimationContext): XYSeriesRenderResult<XYAreaPointSnapshot>;
25
+ private buildAreaData;
26
+ private buildAnimatedAreaData;
27
+ private createSnapshot;
28
+ private renderAreaFill;
24
29
  private renderLinePath;
30
+ private getInitialAreaPathValue;
31
+ private getInitialLinePathValue;
32
+ private createRevealTransitions;
25
33
  private renderPoints;
26
34
  private renderValueLabels;
27
35
  }
package/dist/area.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { area, curveBasis, curveCardinal, curveLinear, curveMonotoneX, curveNatural, curveStep, line, } from 'd3';
2
2
  import { mergeDeep, sanitizeForCSS } from './utils.js';
3
3
  import { getScalePosition } from './scale-utils.js';
4
+ import { buildXYDatumSnapshotKeys, createTransitionCompletionPromise, createLeftToRightRevealTransition, getEnterStaggerTiming, } from './xy-motion/helpers.js';
4
5
  const AREA_CURVE_FACTORIES = {
5
6
  linear: curveLinear,
6
7
  monotone: curveMonotoneX,
@@ -157,9 +158,9 @@ export class Area {
157
158
  y1: cumulative + value,
158
159
  };
159
160
  }
160
- render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, valueLabelLayer) {
161
- const getXPosition = (d) => {
162
- const scaled = getScalePosition(x, d.data[xKey], xScaleType);
161
+ render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, valueLabelLayer, animation) {
162
+ const getXPosition = (dataPoint) => {
163
+ const scaled = getScalePosition(x, dataPoint[xKey], xScaleType);
163
164
  return scaled + (x.bandwidth ? x.bandwidth() / 2 : 0);
164
165
  };
165
166
  const hasValidValue = (d) => {
@@ -169,80 +170,215 @@ export class Area {
169
170
  }
170
171
  return Number.isFinite(parseValue(value));
171
172
  };
172
- const areaData = data.map((d) => {
173
- const valid = hasValidValue(d);
173
+ const snapshotKeys = buildXYDatumSnapshotKeys(data, xKey);
174
+ const areaData = this.buildAreaData(data, xKey, snapshotKeys, y, parseValue, stackingContext, getXPosition, hasValidValue);
175
+ const animatedAreaData = this.buildAnimatedAreaData(areaData, animation);
176
+ const curveFactory = AREA_CURVE_FACTORIES[this.curve] || curveLinear;
177
+ const sanitizedKey = sanitizeForCSS(this.dataKey);
178
+ const transitions = this.renderAreaFill(plotGroup, areaData, animatedAreaData, curveFactory, sanitizedKey, animation);
179
+ const snapshot = this.createSnapshot(areaData);
180
+ if (this.showLine) {
181
+ const lineTransitions = this.renderLinePath(plotGroup, areaData, animatedAreaData, curveFactory, theme, sanitizedKey, animation);
182
+ transitions.push(...lineTransitions);
183
+ }
184
+ if (this.showPoints) {
185
+ const pointTransitions = this.renderPoints(plotGroup, areaData, animatedAreaData, theme, sanitizedKey, animation);
186
+ transitions.push(...pointTransitions);
187
+ }
188
+ if (this.valueLabel?.show) {
189
+ this.renderValueLabels(valueLabelLayer ?? plotGroup, areaData.filter((d) => d.valid), y, parseValue, theme);
190
+ }
191
+ return {
192
+ snapshot,
193
+ transitions,
194
+ };
195
+ }
196
+ buildAreaData(data, xKey, snapshotKeys, y, parseValue, stackingContext, getXPosition, hasValidValue) {
197
+ return data.map((entry, index) => {
198
+ const valid = hasValidValue(entry);
174
199
  const stackValues = valid
175
- ? this.getStackValues(d, xKey, parseValue, stackingContext)
200
+ ? this.getStackValues(entry, xKey, parseValue, stackingContext)
176
201
  : { y0: this.baseline, y1: this.baseline };
177
202
  return {
178
- data: d,
203
+ data: entry,
179
204
  valid,
180
- y0: stackValues.y0,
181
- y1: stackValues.y1,
205
+ snapshotKey: snapshotKeys[index] ?? String(index),
206
+ x: getXPosition(entry),
207
+ y0: y(stackValues.y0) ?? 0,
208
+ y1: y(stackValues.y1) ?? 0,
182
209
  };
183
210
  });
184
- const curveFactory = AREA_CURVE_FACTORIES[this.curve] || curveLinear;
211
+ }
212
+ buildAnimatedAreaData(areaData, animation) {
213
+ return areaData.map((entry) => {
214
+ if (!animation || !entry.valid) {
215
+ return entry;
216
+ }
217
+ const previousSnapshot = animation.previousSnapshot?.get(entry.snapshotKey);
218
+ return {
219
+ ...entry,
220
+ x: previousSnapshot?.x ?? entry.x,
221
+ y0: previousSnapshot?.y0 ?? entry.y0,
222
+ y1: previousSnapshot?.y1 ?? entry.y0,
223
+ };
224
+ });
225
+ }
226
+ createSnapshot(areaData) {
227
+ const snapshot = new Map();
228
+ areaData.forEach((entry) => {
229
+ if (!entry.valid) {
230
+ return;
231
+ }
232
+ snapshot.set(entry.snapshotKey, {
233
+ x: entry.x,
234
+ y0: entry.y0,
235
+ y1: entry.y1,
236
+ });
237
+ });
238
+ return snapshot;
239
+ }
240
+ renderAreaFill(plotGroup, areaData, animatedAreaData, curveFactory, sanitizedKey, animation) {
241
+ const areaOpacity = Math.max(0, Math.min(1, this.opacity));
185
242
  const areaGenerator = area()
186
243
  .defined((d) => d.valid)
187
244
  .curve(curveFactory)
188
- .x(getXPosition)
189
- .y0((d) => y(d.y0) || 0)
190
- .y1((d) => y(d.y1) || 0);
191
- const areaOpacity = Math.max(0, Math.min(1, this.opacity));
192
- const sanitizedKey = sanitizeForCSS(this.dataKey);
193
- plotGroup
245
+ .x((entry) => entry.x)
246
+ .y0((entry) => entry.y0)
247
+ .y1((entry) => entry.y1);
248
+ const finalAreaPath = areaGenerator(areaData);
249
+ const areaPath = plotGroup
194
250
  .append('path')
195
251
  .datum(areaData)
196
252
  .attr('class', `area-${sanitizedKey}`)
197
253
  .attr('fill', this.fill)
198
254
  .attr('fill-opacity', areaOpacity)
199
255
  .attr('stroke', 'none')
200
- .attr('d', areaGenerator);
201
- if (this.showLine) {
202
- this.renderLinePath(plotGroup, areaData, curveFactory, getXPosition, y, theme, sanitizedKey);
203
- }
204
- if (this.showPoints) {
205
- this.renderPoints(plotGroup, areaData, getXPosition, y, theme, sanitizedKey);
206
- }
207
- if (this.valueLabel?.show) {
208
- this.renderValueLabels(valueLabelLayer ?? plotGroup, areaData.filter((d) => d.valid), y, parseValue, theme, getXPosition);
256
+ .attr('d', this.getInitialAreaPathValue(finalAreaPath, areaData, animatedAreaData, areaGenerator, animation));
257
+ if (!animation || !finalAreaPath) {
258
+ return [];
209
259
  }
260
+ const revealTransitions = this.createRevealTransitions(areaPath.node(), `area-${sanitizedKey}-reveal`, animation, animation.previousAreaRevealProgress);
261
+ const transition = areaPath
262
+ .transition()
263
+ .duration(animation.duration)
264
+ .ease(animation.easing)
265
+ .attr('d', finalAreaPath);
266
+ return [
267
+ ...revealTransitions,
268
+ createTransitionCompletionPromise(transition),
269
+ ];
210
270
  }
211
- renderLinePath(plotGroup, areaData, curveFactory, getXPosition, y, theme, sanitizedKey) {
271
+ renderLinePath(plotGroup, areaData, animatedAreaData, curveFactory, theme, sanitizedKey, animation) {
212
272
  const lineGenerator = line()
213
273
  .defined((d) => d.valid)
214
274
  .curve(curveFactory)
215
- .x(getXPosition)
216
- .y((d) => y(d.y1) || 0);
275
+ .x((entry) => entry.x)
276
+ .y((entry) => entry.y1);
217
277
  const lineStrokeWidth = this.strokeWidth ?? theme.line.strokeWidth;
218
- plotGroup
278
+ const finalPath = lineGenerator(areaData);
279
+ const linePath = plotGroup
219
280
  .append('path')
220
281
  .datum(areaData)
221
282
  .attr('class', `area-line-${sanitizedKey}`)
222
283
  .attr('fill', 'none')
223
284
  .attr('stroke', this.stroke)
224
285
  .attr('stroke-width', lineStrokeWidth)
225
- .attr('d', lineGenerator);
286
+ .attr('d', this.getInitialLinePathValue(finalPath, areaData, animatedAreaData, lineGenerator, animation));
287
+ if (!animation || !finalPath) {
288
+ return [];
289
+ }
290
+ const revealTransitions = this.createRevealTransitions(linePath.node(), `area-line-${sanitizedKey}-reveal`, animation, animation.previousLineRevealProgress, lineStrokeWidth);
291
+ const transition = linePath
292
+ .transition()
293
+ .duration(animation.duration)
294
+ .ease(animation.easing)
295
+ .attr('d', finalPath);
296
+ return [
297
+ ...revealTransitions,
298
+ createTransitionCompletionPromise(transition),
299
+ ];
226
300
  }
227
- renderPoints(plotGroup, areaData, getXPosition, y, theme, sanitizedKey) {
301
+ getInitialAreaPathValue(finalAreaPath, areaData, animatedAreaData, areaGenerator, animation) {
302
+ if (animation?.mode === 'initial') {
303
+ return finalAreaPath;
304
+ }
305
+ if (!animation) {
306
+ return finalAreaPath;
307
+ }
308
+ return (animation.previousAreaPath ??
309
+ areaGenerator(animation ? animatedAreaData : areaData));
310
+ }
311
+ getInitialLinePathValue(finalPath, areaData, animatedAreaData, lineGenerator, animation) {
312
+ if (animation?.mode === 'initial') {
313
+ return finalPath;
314
+ }
315
+ if (!animation) {
316
+ return finalPath;
317
+ }
318
+ return (animation.previousLinePath ??
319
+ lineGenerator(animation ? animatedAreaData : areaData));
320
+ }
321
+ createRevealTransitions(path, revealId, animation, previousRevealProgress, padding = 0) {
322
+ if (animation.mode === 'initial') {
323
+ return createLeftToRightRevealTransition(path, animation.duration, animation.easing, revealId, padding);
324
+ }
325
+ if (previousRevealProgress == null) {
326
+ return [];
327
+ }
328
+ return createLeftToRightRevealTransition(path, animation.duration, animation.easing, revealId, padding, previousRevealProgress);
329
+ }
330
+ renderPoints(plotGroup, areaData, animatedAreaData, theme, sanitizedKey, animation) {
228
331
  const validData = areaData.filter((d) => d.valid);
332
+ const validAnimatedData = animatedAreaData.filter((d) => d.valid);
229
333
  const pointSize = this.pointSize ?? theme.line.point.size;
230
334
  const pointStrokeWidth = theme.line.point.strokeWidth;
231
335
  const pointStrokeColor = theme.line.point.strokeColor || this.stroke;
232
336
  const pointColor = theme.line.point.color || this.stroke;
233
- plotGroup
337
+ const isInitialAnimation = animation?.mode === 'initial';
338
+ const circles = plotGroup
234
339
  .selectAll(`.area-point-${sanitizedKey}`)
235
340
  .data(validData)
236
341
  .join('circle')
237
342
  .attr('class', `area-point-${sanitizedKey}`)
238
- .attr('cx', getXPosition)
239
- .attr('cy', (d) => y(d.y1) || 0)
240
- .attr('r', pointSize)
343
+ .attr('cx', (_, index) => (isInitialAnimation
344
+ ? validData[index]?.x
345
+ : animation
346
+ ? validAnimatedData[index]?.x
347
+ : validData[index]?.x) ?? 0)
348
+ .attr('cy', (_, index) => (isInitialAnimation
349
+ ? validData[index]?.y1
350
+ : animation
351
+ ? validAnimatedData[index]?.y1
352
+ : validData[index]?.y1) ?? 0)
353
+ .attr('r', isInitialAnimation ? 0 : pointSize)
241
354
  .attr('fill', pointColor)
242
355
  .attr('stroke', pointStrokeColor)
243
356
  .attr('stroke-width', pointStrokeWidth);
357
+ if (!animation) {
358
+ return [];
359
+ }
360
+ if (isInitialAnimation) {
361
+ const transition = circles
362
+ .transition()
363
+ .delay((_, index) => {
364
+ return getEnterStaggerTiming(index, validData.length, animation.duration).delay;
365
+ })
366
+ .duration((_, index) => {
367
+ return getEnterStaggerTiming(index, validData.length, animation.duration).duration;
368
+ })
369
+ .ease(animation.easing)
370
+ .attr('r', pointSize);
371
+ return [createTransitionCompletionPromise(transition)];
372
+ }
373
+ const transition = circles
374
+ .transition()
375
+ .duration(animation.duration)
376
+ .ease(animation.easing)
377
+ .attr('cx', (_, index) => validData[index]?.x ?? 0)
378
+ .attr('cy', (_, index) => validData[index]?.y1 ?? 0);
379
+ return [createTransitionCompletionPromise(transition)];
244
380
  }
245
- renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
381
+ renderValueLabels(plotGroup, data, y, parseValue, theme) {
246
382
  const config = this.valueLabel;
247
383
  const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
248
384
  const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
@@ -269,8 +405,8 @@ export class Area {
269
405
  const valueText = config.formatter
270
406
  ? config.formatter(this.dataKey, parsedValue, d.data)
271
407
  : String(parsedValue);
272
- const xPos = getXPosition(d);
273
- const yPos = y(d.y1) || 0;
408
+ const xPos = d.x;
409
+ const yPos = d.y1;
274
410
  const tempText = labelGroup
275
411
  .append('text')
276
412
  .style('font-size', `${fontSize}px`)
package/dist/bar.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Selection } from 'd3';
2
2
  import type { BarConfig, BarStackingContext, BarSide, BarValueLabelConfig, ChartTheme, D3Scale, DataItem, Orientation, ScaleType, ExportHooks, BarConfigBase } from './types.js';
3
3
  import type { ChartComponent } from './chart-interface.js';
4
+ import type { XYBarAnimationContext, XYBarSnapshot, XYSeriesRenderResult } from './xy-motion/types.js';
4
5
  export declare class Bar implements ChartComponent<BarConfigBase> {
5
6
  readonly type: "bar";
6
7
  readonly dataKey: string;
@@ -14,9 +15,16 @@ export declare class Bar implements ChartComponent<BarConfigBase> {
14
15
  getExportConfig(): BarConfigBase;
15
16
  createExportComponent(override?: Partial<BarConfigBase>): ChartComponent<BarConfigBase>;
16
17
  getRenderedValue(value: number, orientation?: Orientation): number;
17
- render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType?: ScaleType, theme?: ChartTheme, stackingContext?: BarStackingContext, orientation?: 'vertical' | 'horizontal'): void;
18
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType?: ScaleType, theme?: ChartTheme, stackingContext?: BarStackingContext, orientation?: 'vertical' | 'horizontal', animation?: XYBarAnimationContext): XYSeriesRenderResult<XYBarSnapshot>;
18
19
  private renderVertical;
19
20
  private renderHorizontal;
21
+ private createVerticalLayoutData;
22
+ private createHorizontalLayoutData;
23
+ private createAnimatedVerticalLayoutData;
24
+ private createAnimatedHorizontalLayoutData;
25
+ private resolveAnimatedLayoutDatum;
26
+ private createSnapshot;
27
+ private renderBars;
20
28
  private resolveValueLabelConfig;
21
29
  private resolveValueLabelPlacement;
22
30
  private resolveValueLabelStyle;
package/dist/bar.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getContrastTextColor, sanitizeForCSS, mergeDeep } from './utils.js';
2
2
  import { getScalePosition } from './scale-utils.js';
3
+ import { buildXYDatumSnapshotKeys, createTransitionCompletionPromise, } from './xy-motion/helpers.js';
3
4
  const LABEL_INSET_DEFAULT = 4;
4
5
  const LABEL_INSET_STACKED = 6;
5
6
  const LABEL_MIN_PADDING_DEFAULT = 8;
@@ -198,13 +199,10 @@ export class Bar {
198
199
  }
199
200
  return value;
200
201
  }
201
- render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, orientation = 'vertical') {
202
- if (orientation === 'vertical') {
203
- this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
204
- }
205
- else {
206
- this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
207
- }
202
+ render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, orientation = 'vertical', animation) {
203
+ const result = orientation === 'vertical'
204
+ ? this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext, animation)
205
+ : this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext, animation);
208
206
  // Render value labels if enabled
209
207
  if (this.valueLabel?.show && theme) {
210
208
  if (orientation === 'vertical') {
@@ -214,8 +212,9 @@ export class Bar {
214
212
  this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
215
213
  }
216
214
  }
215
+ return result;
217
216
  }
218
- renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
217
+ renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext, animation) {
219
218
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
220
219
  const mode = stackingContext?.mode ?? 'normal';
221
220
  const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
@@ -225,29 +224,15 @@ export class Bar {
225
224
  const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
226
225
  return getScaledValueRange(y, start, end);
227
226
  };
228
- // Add bar rectangles
229
- const sanitizedKey = sanitizeForCSS(this.dataKey);
230
- plotGroup
231
- .selectAll(`.bar-${sanitizedKey}`)
232
- .data(data)
233
- .join('rect')
234
- .attr('class', `bar-${sanitizedKey}`)
235
- .attr('data-index', (_, i) => i)
236
- .attr('x', (d) => {
237
- const xPos = getScalePosition(x, d[xKey], xScaleType);
238
- return xScaleType === 'band'
239
- ? xPos + barOffset
240
- : xPos - barWidth / 2;
241
- })
242
- .attr('y', (d) => getVerticalBounds(d).min)
243
- .attr('width', barWidth)
244
- .attr('height', (d) => {
245
- const bounds = getVerticalBounds(d);
246
- return Math.abs(bounds.max - bounds.min);
247
- })
248
- .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
249
- }
250
- renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
227
+ const snapshotKeys = buildXYDatumSnapshotKeys(data, xKey);
228
+ const layoutData = this.createVerticalLayoutData(data, xKey, snapshotKeys, x, xScaleType, barWidth, barOffset, getVerticalBounds);
229
+ const animatedLayoutData = this.createAnimatedVerticalLayoutData(layoutData, animation);
230
+ return {
231
+ snapshot: this.createSnapshot(layoutData),
232
+ transitions: this.renderBars(plotGroup, layoutData, animatedLayoutData, animation),
233
+ };
234
+ }
235
+ renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext, animation) {
251
236
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
252
237
  const mode = stackingContext?.mode ?? 'normal';
253
238
  const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
@@ -257,27 +242,125 @@ export class Bar {
257
242
  const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
258
243
  return getScaledValueRange(x, start, end);
259
244
  };
260
- // Add bar rectangles (horizontal)
245
+ const snapshotKeys = buildXYDatumSnapshotKeys(data, xKey);
246
+ const layoutData = this.createHorizontalLayoutData(data, xKey, snapshotKeys, y, yScaleType, barHeight, barOffset, getHorizontalBounds);
247
+ const animatedLayoutData = this.createAnimatedHorizontalLayoutData(layoutData, animation);
248
+ return {
249
+ snapshot: this.createSnapshot(layoutData),
250
+ transitions: this.renderBars(plotGroup, layoutData, animatedLayoutData, animation),
251
+ };
252
+ }
253
+ createVerticalLayoutData(data, xKey, snapshotKeys, x, xScaleType, barWidth, barOffset, getVerticalBounds) {
254
+ return data.map((entry, index) => {
255
+ const xPos = getScalePosition(x, entry[xKey], xScaleType);
256
+ const bounds = getVerticalBounds(entry);
257
+ return {
258
+ data: entry,
259
+ snapshotKey: snapshotKeys[index] ?? String(index),
260
+ x: xScaleType === 'band'
261
+ ? xPos + barOffset
262
+ : xPos - barWidth / 2,
263
+ y: bounds.min,
264
+ width: barWidth,
265
+ height: Math.abs(bounds.max - bounds.min),
266
+ };
267
+ });
268
+ }
269
+ createHorizontalLayoutData(data, xKey, snapshotKeys, y, yScaleType, barHeight, barOffset, getHorizontalBounds) {
270
+ return data.map((entry, index) => {
271
+ const yPos = getScalePosition(y, entry[xKey], yScaleType);
272
+ const bounds = getHorizontalBounds(entry);
273
+ return {
274
+ data: entry,
275
+ snapshotKey: snapshotKeys[index] ?? String(index),
276
+ x: bounds.min,
277
+ y: yScaleType === 'band'
278
+ ? yPos + barOffset
279
+ : yPos - barHeight / 2,
280
+ width: Math.abs(bounds.max - bounds.min),
281
+ height: barHeight,
282
+ };
283
+ });
284
+ }
285
+ createAnimatedVerticalLayoutData(layoutData, animation) {
286
+ return layoutData.map((entry) => {
287
+ return this.resolveAnimatedLayoutDatum(entry, animation, {
288
+ y: animation?.baselineValuePosition,
289
+ height: 0,
290
+ });
291
+ });
292
+ }
293
+ createAnimatedHorizontalLayoutData(layoutData, animation) {
294
+ return layoutData.map((entry) => {
295
+ return this.resolveAnimatedLayoutDatum(entry, animation, {
296
+ x: animation?.baselineValuePosition,
297
+ width: 0,
298
+ });
299
+ });
300
+ }
301
+ resolveAnimatedLayoutDatum(entry, animation, baselineOverride) {
302
+ if (!animation) {
303
+ return entry;
304
+ }
305
+ const previousSnapshot = animation.previousSnapshot?.get(entry.snapshotKey);
306
+ if (previousSnapshot) {
307
+ return {
308
+ ...entry,
309
+ ...previousSnapshot,
310
+ };
311
+ }
312
+ return {
313
+ ...entry,
314
+ ...baselineOverride,
315
+ };
316
+ }
317
+ createSnapshot(layoutData) {
318
+ const snapshot = new Map();
319
+ layoutData.forEach((entry) => {
320
+ snapshot.set(entry.snapshotKey, {
321
+ x: entry.x,
322
+ y: entry.y,
323
+ width: entry.width,
324
+ height: entry.height,
325
+ });
326
+ });
327
+ return snapshot;
328
+ }
329
+ renderBars(plotGroup, layoutData, animatedLayoutData, animation) {
261
330
  const sanitizedKey = sanitizeForCSS(this.dataKey);
262
- plotGroup
331
+ const bars = plotGroup
263
332
  .selectAll(`.bar-${sanitizedKey}`)
264
- .data(data)
333
+ .data(layoutData)
265
334
  .join('rect')
266
335
  .attr('class', `bar-${sanitizedKey}`)
267
336
  .attr('data-index', (_, i) => i)
268
- .attr('x', (d) => getHorizontalBounds(d).min)
269
- .attr('y', (d) => {
270
- const yPos = getScalePosition(y, d[xKey], yScaleType);
271
- return yScaleType === 'band'
272
- ? yPos + barOffset
273
- : yPos - barHeight / 2;
274
- })
275
- .attr('width', (d) => {
276
- const bounds = getHorizontalBounds(d);
277
- return Math.abs(bounds.max - bounds.min);
278
- })
279
- .attr('height', barHeight)
280
- .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
337
+ .attr('x', (_, index) => (animation
338
+ ? animatedLayoutData[index]?.x
339
+ : layoutData[index]?.x) ?? 0)
340
+ .attr('y', (_, index) => (animation
341
+ ? animatedLayoutData[index]?.y
342
+ : layoutData[index]?.y) ?? 0)
343
+ .attr('width', (_, index) => (animation
344
+ ? animatedLayoutData[index]?.width
345
+ : layoutData[index]?.width) ?? 0)
346
+ .attr('height', (_, index) => (animation
347
+ ? animatedLayoutData[index]?.height
348
+ : layoutData[index]?.height) ?? 0)
349
+ .attr('fill', (entry, i) => this.colorAdapter
350
+ ? this.colorAdapter(entry.data, i)
351
+ : this.fill);
352
+ if (!animation) {
353
+ return [];
354
+ }
355
+ const transition = bars
356
+ .transition()
357
+ .duration(animation.duration)
358
+ .ease(animation.easing)
359
+ .attr('x', (_, index) => layoutData[index]?.x ?? 0)
360
+ .attr('y', (_, index) => layoutData[index]?.y ?? 0)
361
+ .attr('width', (_, index) => layoutData[index]?.width ?? 0)
362
+ .attr('height', (_, index) => layoutData[index]?.height ?? 0);
363
+ return [createTransitionCompletionPromise(transition)];
281
364
  }
282
365
  resolveValueLabelConfig(theme) {
283
366
  const config = this.valueLabel;
@@ -737,7 +737,17 @@ export class BaseChart {
737
737
  if (this.resizeObserver) {
738
738
  this.resizeObserver.disconnect();
739
739
  }
740
- this.resizeObserver = new ResizeObserver(() => this.rerender());
740
+ this.resizeObserver = new ResizeObserver(() => {
741
+ if (!this.container) {
742
+ return;
743
+ }
744
+ const nextDimensions = this.resolveRenderDimensions(this.container.getBoundingClientRect());
745
+ if (nextDimensions.width === this.width &&
746
+ nextDimensions.height === this.height) {
747
+ return;
748
+ }
749
+ this.rerender();
750
+ });
741
751
  this.resizeObserver.observe(this.container);
742
752
  }
743
753
  setReadyPromise(promise) {