@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
package/dist/bar.js CHANGED
@@ -1,9 +1,11 @@
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;
6
7
  const LABEL_MIN_PADDING_STACKED = 16;
8
+ const LABEL_OUTSIDE_OFFSET = 4;
7
9
  function getLabelSpacing(mode) {
8
10
  const stacked = mode !== 'none';
9
11
  return {
@@ -48,34 +50,50 @@ function getBarSlotLayout(bandwidth, mode, maxBarSize, totalSeries, seriesIndex,
48
50
  function getBarValueRange(categoryKey, value, stackingContext) {
49
51
  const mode = stackingContext?.mode ?? 'normal';
50
52
  if (mode === 'none' || mode === 'layer') {
51
- return {
52
- start: 0,
53
- end: value,
54
- };
53
+ return getUnstackedBarValueRange(value);
55
54
  }
56
55
  if (mode === 'percent') {
57
- if (value >= 0) {
58
- const cumulative = stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0;
59
- const total = stackingContext?.positiveTotalData.get(categoryKey) ?? 0;
60
- if (total === 0) {
61
- return { start: 0, end: 0 };
62
- }
63
- return {
64
- start: (cumulative / total) * 100,
65
- end: ((cumulative + value) / total) * 100,
66
- };
67
- }
68
- const cumulativeMagnitude = stackingContext?.negativeCumulativeData.get(categoryKey) ?? 0;
69
- const totalMagnitude = stackingContext?.negativeTotalData.get(categoryKey) ?? 0;
70
- if (totalMagnitude === 0) {
71
- return { start: 0, end: 0 };
72
- }
56
+ return getPercentBarValueRange(categoryKey, value, stackingContext);
57
+ }
58
+ return getStackedBarValueRange(categoryKey, value, stackingContext);
59
+ }
60
+ function getUnstackedBarValueRange(value) {
61
+ return {
62
+ start: 0,
63
+ end: value,
64
+ };
65
+ }
66
+ function getPercentBarValueRange(categoryKey, value, stackingContext) {
67
+ const isPositive = value >= 0;
68
+ const { cumulative, total } = getPercentBarRangeMetrics(categoryKey, isPositive, stackingContext);
69
+ if (total === 0) {
73
70
  return {
74
- start: -((cumulativeMagnitude / totalMagnitude) * 100),
75
- end: -(((cumulativeMagnitude + Math.abs(value)) / totalMagnitude) *
76
- 100),
71
+ start: 0,
72
+ end: 0,
77
73
  };
78
74
  }
75
+ const start = (cumulative / total) * 100;
76
+ const end = ((cumulative + Math.abs(value)) / total) * 100;
77
+ return isPositive ? { start, end } : { start: -start, end: -end };
78
+ }
79
+ function getPercentBarRangeMetrics(categoryKey, isPositive, stackingContext) {
80
+ return isPositive
81
+ ? getPositivePercentBarRangeMetrics(categoryKey, stackingContext)
82
+ : getNegativePercentBarRangeMetrics(categoryKey, stackingContext);
83
+ }
84
+ function getPositivePercentBarRangeMetrics(categoryKey, stackingContext) {
85
+ return {
86
+ cumulative: stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0,
87
+ total: stackingContext?.positiveTotalData.get(categoryKey) ?? 0,
88
+ };
89
+ }
90
+ function getNegativePercentBarRangeMetrics(categoryKey, stackingContext) {
91
+ return {
92
+ cumulative: stackingContext?.negativeCumulativeData.get(categoryKey) ?? 0,
93
+ total: stackingContext?.negativeTotalData.get(categoryKey) ?? 0,
94
+ };
95
+ }
96
+ function getStackedBarValueRange(categoryKey, value, stackingContext) {
79
97
  if (value >= 0) {
80
98
  const cumulative = stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0;
81
99
  return {
@@ -181,13 +199,10 @@ export class Bar {
181
199
  }
182
200
  return value;
183
201
  }
184
- render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, orientation = 'vertical') {
185
- if (orientation === 'vertical') {
186
- this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
187
- }
188
- else {
189
- this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
190
- }
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);
191
206
  // Render value labels if enabled
192
207
  if (this.valueLabel?.show && theme) {
193
208
  if (orientation === 'vertical') {
@@ -197,8 +212,9 @@ export class Bar {
197
212
  this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
198
213
  }
199
214
  }
215
+ return result;
200
216
  }
201
- renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
217
+ renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext, animation) {
202
218
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
203
219
  const mode = stackingContext?.mode ?? 'normal';
204
220
  const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
@@ -208,29 +224,15 @@ export class Bar {
208
224
  const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
209
225
  return getScaledValueRange(y, start, end);
210
226
  };
211
- // Add bar rectangles
212
- const sanitizedKey = sanitizeForCSS(this.dataKey);
213
- plotGroup
214
- .selectAll(`.bar-${sanitizedKey}`)
215
- .data(data)
216
- .join('rect')
217
- .attr('class', `bar-${sanitizedKey}`)
218
- .attr('data-index', (_, i) => i)
219
- .attr('x', (d) => {
220
- const xPos = getScalePosition(x, d[xKey], xScaleType);
221
- return xScaleType === 'band'
222
- ? xPos + barOffset
223
- : xPos - barWidth / 2;
224
- })
225
- .attr('y', (d) => getVerticalBounds(d).min)
226
- .attr('width', barWidth)
227
- .attr('height', (d) => {
228
- const bounds = getVerticalBounds(d);
229
- return Math.abs(bounds.max - bounds.min);
230
- })
231
- .attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
232
- }
233
- 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) {
234
236
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
235
237
  const mode = stackingContext?.mode ?? 'normal';
236
238
  const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
@@ -240,278 +242,395 @@ export class Bar {
240
242
  const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
241
243
  return getScaledValueRange(x, start, end);
242
244
  };
243
- // 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) {
244
330
  const sanitizedKey = sanitizeForCSS(this.dataKey);
245
- plotGroup
331
+ const bars = plotGroup
246
332
  .selectAll(`.bar-${sanitizedKey}`)
247
- .data(data)
333
+ .data(layoutData)
248
334
  .join('rect')
249
335
  .attr('class', `bar-${sanitizedKey}`)
250
336
  .attr('data-index', (_, i) => i)
251
- .attr('x', (d) => getHorizontalBounds(d).min)
252
- .attr('y', (d) => {
253
- const yPos = getScalePosition(y, d[xKey], yScaleType);
254
- return yScaleType === 'band'
255
- ? yPos + barOffset
256
- : yPos - barHeight / 2;
257
- })
258
- .attr('width', (d) => {
259
- const bounds = getHorizontalBounds(d);
260
- return Math.abs(bounds.max - bounds.min);
261
- })
262
- .attr('height', barHeight)
263
- .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)];
364
+ }
365
+ resolveValueLabelConfig(theme) {
366
+ const config = this.valueLabel;
367
+ return {
368
+ ...this.resolveValueLabelPlacement(config),
369
+ ...this.resolveValueLabelStyle(config, theme),
370
+ formatter: config.formatter,
371
+ autoContrastInside: config.color === undefined,
372
+ };
373
+ }
374
+ resolveValueLabelPlacement(config) {
375
+ return {
376
+ position: config.position ?? 'outside',
377
+ insidePosition: config.insidePosition ?? 'top',
378
+ };
379
+ }
380
+ resolveValueLabelStyle(config, theme) {
381
+ return {
382
+ fontSize: config.fontSize ?? theme.valueLabel.fontSize,
383
+ fontFamily: config.fontFamily ?? theme.valueLabel.fontFamily,
384
+ fontWeight: config.fontWeight ?? theme.valueLabel.fontWeight,
385
+ color: config.color ?? theme.valueLabel.color,
386
+ background: config.background ?? theme.valueLabel.background,
387
+ border: config.border ?? theme.valueLabel.border,
388
+ borderRadius: config.borderRadius ?? theme.valueLabel.borderRadius,
389
+ padding: config.padding ?? theme.valueLabel.padding,
390
+ };
391
+ }
392
+ getValueLabelText(rawValue, data, config) {
393
+ return config.formatter
394
+ ? config.formatter(this.dataKey, rawValue, data)
395
+ : String(rawValue);
396
+ }
397
+ getBarColor(data, index) {
398
+ return this.colorAdapter ? this.colorAdapter(data, index) : this.fill;
399
+ }
400
+ measureLabelBox(labelGroup, valueText, config) {
401
+ const tempText = labelGroup
402
+ .append('text')
403
+ .style('font-size', `${config.fontSize}px`)
404
+ .style('font-family', config.fontFamily)
405
+ .style('font-weight', config.fontWeight)
406
+ .text(valueText);
407
+ const textBox = tempText.node().getBBox();
408
+ tempText.remove();
409
+ return {
410
+ width: textBox.width + config.padding * 2,
411
+ height: textBox.height + config.padding * 2,
412
+ };
413
+ }
414
+ getLabelColor(config, barColor) {
415
+ if (config.position === 'inside' && config.autoContrastInside) {
416
+ return getContrastTextColor(barColor);
417
+ }
418
+ return config.color;
419
+ }
420
+ appendValueLabel(labelGroup, valueText, placement, labelBox, config, labelColor) {
421
+ const group = labelGroup.append('g');
422
+ if (config.position === 'outside') {
423
+ group
424
+ .append('rect')
425
+ .attr('x', placement.x - labelBox.width / 2)
426
+ .attr('y', placement.y - labelBox.height / 2)
427
+ .attr('width', labelBox.width)
428
+ .attr('height', labelBox.height)
429
+ .attr('rx', config.borderRadius)
430
+ .attr('ry', config.borderRadius)
431
+ .attr('fill', config.background)
432
+ .attr('stroke', config.border)
433
+ .attr('stroke-width', 1);
434
+ }
435
+ group
436
+ .append('text')
437
+ .attr('x', placement.x)
438
+ .attr('y', placement.y)
439
+ .attr('text-anchor', 'middle')
440
+ .attr('dominant-baseline', 'central')
441
+ .style('font-size', `${config.fontSize}px`)
442
+ .style('font-family', config.fontFamily)
443
+ .style('font-weight', config.fontWeight)
444
+ .style('fill', labelColor)
445
+ .style('pointer-events', 'none')
446
+ .text(valueText);
447
+ }
448
+ getVerticalLabelPlacement(input) {
449
+ if (input.position === 'outside') {
450
+ return this.getVerticalOutsideLabelPlacement(input);
451
+ }
452
+ if (input.mode === 'layer' && input.insidePosition === 'bottom') {
453
+ return {
454
+ x: input.x,
455
+ y: (input.barTop + input.barBottom) / 2,
456
+ shouldRender: false,
457
+ };
458
+ }
459
+ const { inset, minPadding } = getLabelSpacing(input.mode);
460
+ const y = this.getVerticalInsideLabelY(input.barTop, input.barBottom, input.labelBox.height, input.insidePosition, inset);
461
+ return {
462
+ x: input.x,
463
+ y,
464
+ shouldRender: input.labelBox.height + minPadding <= input.barHeight,
465
+ };
466
+ }
467
+ getVerticalOutsideLabelPlacement(input) {
468
+ const y = input.isNegative
469
+ ? input.barBottom + input.labelBox.height / 2 + LABEL_OUTSIDE_OFFSET
470
+ : input.barTop - input.labelBox.height / 2 - LABEL_OUTSIDE_OFFSET;
471
+ const shouldRender = input.isNegative
472
+ ? y + input.labelBox.height / 2 <= input.plotBottom
473
+ : y - input.labelBox.height / 2 >= input.plotTop;
474
+ return {
475
+ x: input.x,
476
+ y,
477
+ shouldRender,
478
+ };
479
+ }
480
+ getVerticalInsideLabelY(barTop, barBottom, labelHeight, insidePosition, inset) {
481
+ switch (insidePosition) {
482
+ case 'top':
483
+ return barTop + labelHeight / 2 + inset;
484
+ case 'middle':
485
+ return (barTop + barBottom) / 2;
486
+ case 'bottom':
487
+ return barBottom - labelHeight / 2 - inset;
488
+ }
489
+ }
490
+ getHorizontalLabelPlacement(input) {
491
+ if (input.position === 'outside') {
492
+ return this.getHorizontalOutsideLabelPlacement(input);
493
+ }
494
+ if (input.mode === 'layer' && input.insidePosition === 'bottom') {
495
+ return {
496
+ x: (input.barLeft + input.barRight) / 2,
497
+ y: input.y,
498
+ shouldRender: false,
499
+ };
500
+ }
501
+ const { inset, minPadding } = getLabelSpacing(input.mode);
502
+ const x = this.getHorizontalInsideLabelX(input.barLeft, input.barRight, input.labelBox.width, input.isNegative, input.insidePosition, inset);
503
+ const fitsBar = input.labelBox.width + minPadding <= input.barWidth;
504
+ const withinBounds = input.insidePosition === 'middle' ||
505
+ this.isHorizontalLabelWithinBounds(x, input.labelBox.width, input.barLeft, input.barRight);
506
+ return {
507
+ x,
508
+ y: input.y,
509
+ shouldRender: fitsBar && withinBounds,
510
+ };
511
+ }
512
+ getHorizontalOutsideLabelPlacement(input) {
513
+ const x = input.isNegative
514
+ ? input.barLeft - input.labelBox.width / 2 - LABEL_OUTSIDE_OFFSET
515
+ : input.barRight + input.labelBox.width / 2 + LABEL_OUTSIDE_OFFSET;
516
+ const shouldRender = input.isNegative
517
+ ? x - input.labelBox.width / 2 >= input.plotLeft
518
+ : x + input.labelBox.width / 2 <= input.plotRight;
519
+ return {
520
+ x,
521
+ y: input.y,
522
+ shouldRender,
523
+ };
524
+ }
525
+ getHorizontalInsideLabelX(barLeft, barRight, labelWidth, isNegative, insidePosition, inset) {
526
+ switch (insidePosition) {
527
+ case 'top':
528
+ return isNegative
529
+ ? barRight - labelWidth / 2 - inset
530
+ : barLeft + labelWidth / 2 + inset;
531
+ case 'middle':
532
+ return (barLeft + barRight) / 2;
533
+ case 'bottom':
534
+ return isNegative
535
+ ? barLeft + labelWidth / 2 + inset
536
+ : barRight - labelWidth / 2 - inset;
537
+ }
538
+ }
539
+ isHorizontalLabelWithinBounds(labelX, labelWidth, barLeft, barRight) {
540
+ const labelLeft = labelX - labelWidth / 2;
541
+ const labelRight = labelX + labelWidth / 2;
542
+ return labelLeft >= barLeft + 1 && labelRight <= barRight - 1;
543
+ }
544
+ renderVerticalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, xScaleType, stackingContext, config, barWidth, barOffset, mode, plotTop, plotBottom) {
545
+ const categoryKey = String(dataItem[xKey]);
546
+ const rawValue = parseValue(dataItem[this.dataKey]);
547
+ const renderedValue = this.getRenderedValue(rawValue, 'vertical');
548
+ const valueText = this.getValueLabelText(rawValue, dataItem, config);
549
+ const xPos = getScalePosition(x, dataItem[xKey], xScaleType);
550
+ const barColor = this.getBarColor(dataItem, index);
551
+ const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
552
+ const bounds = getScaledValueRange(y, start, end);
553
+ const barTop = bounds.min;
554
+ const barBottom = bounds.max;
555
+ const barHeight = Math.abs(barBottom - barTop);
556
+ const barCenterX = xPos +
557
+ (xScaleType === 'band' ? barOffset : -barWidth / 2) +
558
+ barWidth / 2;
559
+ const labelBox = this.measureLabelBox(labelGroup, valueText, config);
560
+ const placement = this.getVerticalLabelPlacement({
561
+ x: barCenterX,
562
+ barTop,
563
+ barBottom,
564
+ barHeight,
565
+ labelBox,
566
+ isNegative: renderedValue < 0,
567
+ mode,
568
+ position: config.position,
569
+ insidePosition: config.insidePosition,
570
+ plotTop,
571
+ plotBottom,
572
+ });
573
+ if (!placement.shouldRender) {
574
+ return;
575
+ }
576
+ this.appendValueLabel(labelGroup, valueText, placement, labelBox, config, this.getLabelColor(config, barColor));
577
+ }
578
+ renderHorizontalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, yScaleType, stackingContext, config, barHeight, barOffset, mode, plotLeft, plotRight) {
579
+ const categoryKey = String(dataItem[xKey]);
580
+ const rawValue = parseValue(dataItem[this.dataKey]);
581
+ const renderedValue = this.getRenderedValue(rawValue, 'horizontal');
582
+ const valueText = this.getValueLabelText(rawValue, dataItem, config);
583
+ const yPos = getScalePosition(y, dataItem[xKey], yScaleType);
584
+ const barColor = this.getBarColor(dataItem, index);
585
+ const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
586
+ const bounds = getScaledValueRange(x, start, end);
587
+ const barLeft = bounds.min;
588
+ const barRight = bounds.max;
589
+ const barWidth = Math.abs(barRight - barLeft);
590
+ const barCenterY = yPos +
591
+ (yScaleType === 'band' ? barOffset : -barHeight / 2) +
592
+ barHeight / 2;
593
+ const labelBox = this.measureLabelBox(labelGroup, valueText, config);
594
+ const placement = this.getHorizontalLabelPlacement({
595
+ y: barCenterY,
596
+ barLeft,
597
+ barRight,
598
+ barWidth,
599
+ labelBox,
600
+ isNegative: renderedValue < 0,
601
+ mode,
602
+ position: config.position,
603
+ insidePosition: config.insidePosition,
604
+ plotLeft,
605
+ plotRight,
606
+ });
607
+ if (!placement.shouldRender) {
608
+ return;
609
+ }
610
+ this.appendValueLabel(labelGroup, valueText, placement, labelBox, config, this.getLabelColor(config, barColor));
264
611
  }
265
612
  renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
266
613
  const bandwidth = x.bandwidth ? x.bandwidth() : 20;
267
614
  const mode = stackingContext?.mode ?? 'normal';
268
615
  const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
269
- const config = this.valueLabel;
270
- const position = config.position || 'outside';
271
- const insidePosition = config.insidePosition || 'top';
272
- const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
273
- const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
274
- const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
275
- const defaultLabelColor = config.color ?? theme.valueLabel.color;
276
- const background = config.background ?? theme.valueLabel.background;
277
- const border = config.border ?? theme.valueLabel.border;
278
- const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
279
- const padding = config.padding ?? theme.valueLabel.padding;
616
+ const config = this.resolveValueLabelConfig(theme);
617
+ const plotTop = Math.min(...y.range());
618
+ const plotBottom = Math.max(...y.range());
280
619
  const labelGroup = plotGroup
281
620
  .append('g')
282
621
  .attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
283
- data.forEach((d, i) => {
284
- const categoryKey = String(d[xKey]);
285
- const rawValue = parseValue(d[this.dataKey]);
286
- const renderedValue = this.getRenderedValue(rawValue, 'vertical');
287
- const valueText = String(rawValue);
288
- const xPos = getScalePosition(x, d[xKey], xScaleType);
289
- const barColor = this.colorAdapter
290
- ? this.colorAdapter(d, i)
291
- : this.fill;
292
- const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
293
- const bounds = getScaledValueRange(y, start, end);
294
- const barTop = bounds.min;
295
- const barBottom = bounds.max;
296
- const isNegative = renderedValue < 0;
297
- const barHeight = Math.abs(barBottom - barTop);
298
- const barCenterX = xPos +
299
- (xScaleType === 'band' ? barOffset : -barWidth / 2) +
300
- barWidth / 2;
301
- // Create temporary text to measure dimensions
302
- const tempText = labelGroup
303
- .append('text')
304
- .style('font-size', `${fontSize}px`)
305
- .style('font-family', fontFamily)
306
- .style('font-weight', fontWeight)
307
- .text(valueText);
308
- const textBBox = tempText.node().getBBox();
309
- const boxWidth = textBBox.width + padding * 2;
310
- const boxHeight = textBBox.height + padding * 2;
311
- const labelX = barCenterX;
312
- let labelY = (barTop + barBottom) / 2; // Default to middle
313
- let shouldRender = true;
314
- if (position === 'outside') {
315
- const plotTop = y.range()[1];
316
- const plotBottom = y.range()[0];
317
- labelY = isNegative
318
- ? barBottom + boxHeight / 2 + 4
319
- : barTop - boxHeight / 2 - 4;
320
- if ((!isNegative && labelY - boxHeight / 2 < plotTop) ||
321
- (isNegative && labelY + boxHeight / 2 > plotBottom)) {
322
- shouldRender = false;
323
- }
324
- }
325
- else {
326
- if (mode === 'layer' && insidePosition === 'bottom') {
327
- // Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
328
- shouldRender = false;
329
- }
330
- else {
331
- const { inset, minPadding } = getLabelSpacing(mode);
332
- switch (insidePosition) {
333
- case 'top':
334
- labelY = barTop + boxHeight / 2 + inset;
335
- break;
336
- case 'middle':
337
- labelY = (barTop + barBottom) / 2;
338
- break;
339
- case 'bottom':
340
- labelY = barBottom - boxHeight / 2 - inset;
341
- break;
342
- }
343
- if (boxHeight + minPadding > barHeight) {
344
- shouldRender = false;
345
- }
346
- }
347
- }
348
- tempText.remove();
349
- if (shouldRender) {
350
- const labelColor = position === 'inside' && config.color === undefined
351
- ? getContrastTextColor(barColor)
352
- : defaultLabelColor;
353
- const group = labelGroup.append('g');
354
- if (position === 'outside') {
355
- // Draw rounded rectangle background
356
- group
357
- .append('rect')
358
- .attr('x', labelX - boxWidth / 2)
359
- .attr('y', labelY - boxHeight / 2)
360
- .attr('width', boxWidth)
361
- .attr('height', boxHeight)
362
- .attr('rx', borderRadius)
363
- .attr('ry', borderRadius)
364
- .attr('fill', background)
365
- .attr('stroke', border)
366
- .attr('stroke-width', 1);
367
- }
368
- // Draw text
369
- group
370
- .append('text')
371
- .attr('x', labelX)
372
- .attr('y', labelY)
373
- .attr('text-anchor', 'middle')
374
- .attr('dominant-baseline', 'central')
375
- .style('font-size', `${fontSize}px`)
376
- .style('font-family', fontFamily)
377
- .style('font-weight', fontWeight)
378
- .style('fill', labelColor)
379
- .style('pointer-events', 'none')
380
- .text(valueText);
381
- }
382
- });
622
+ data.forEach((dataItem, index) => this.renderVerticalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, xScaleType, stackingContext, config, barWidth, barOffset, mode, plotTop, plotBottom));
383
623
  }
384
624
  renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
385
625
  const bandwidth = y.bandwidth ? y.bandwidth() : 20;
386
626
  const mode = stackingContext?.mode ?? 'normal';
387
627
  const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
388
- const config = this.valueLabel;
389
- const position = config.position || 'outside';
390
- const insidePosition = config.insidePosition || 'top';
391
- const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
392
- const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
393
- const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
394
- const defaultLabelColor = config.color ?? theme.valueLabel.color;
395
- const background = config.background ?? theme.valueLabel.background;
396
- const border = config.border ?? theme.valueLabel.border;
397
- const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
398
- const padding = config.padding ?? theme.valueLabel.padding;
628
+ const config = this.resolveValueLabelConfig(theme);
629
+ const plotLeft = Math.min(...x.range());
630
+ const plotRight = Math.max(...x.range());
399
631
  const labelGroup = plotGroup
400
632
  .append('g')
401
633
  .attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
402
- data.forEach((d, i) => {
403
- const categoryKey = String(d[xKey]);
404
- const rawValue = parseValue(d[this.dataKey]);
405
- const renderedValue = this.getRenderedValue(rawValue, 'horizontal');
406
- const valueText = String(rawValue);
407
- const yPos = getScalePosition(y, d[xKey], yScaleType);
408
- const barColor = this.colorAdapter
409
- ? this.colorAdapter(d, i)
410
- : this.fill;
411
- const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
412
- const bounds = getScaledValueRange(x, start, end);
413
- const barLeft = bounds.min;
414
- const barRight = bounds.max;
415
- const isNegative = renderedValue < 0;
416
- const barWidth = Math.abs(barRight - barLeft);
417
- const barCenterY = yPos +
418
- (yScaleType === 'band' ? barOffset : -barHeight / 2) +
419
- barHeight / 2;
420
- // Create temporary text to measure dimensions
421
- const tempText = labelGroup
422
- .append('text')
423
- .style('font-size', `${fontSize}px`)
424
- .style('font-family', fontFamily)
425
- .style('font-weight', fontWeight)
426
- .text(valueText);
427
- const textBBox = tempText.node().getBBox();
428
- const boxWidth = textBBox.width + padding * 2;
429
- const boxHeight = textBBox.height + padding * 2;
430
- let labelX = (barLeft + barRight) / 2; // Default to middle
431
- const labelY = barCenterY;
432
- let shouldRender = true;
433
- if (position === 'outside') {
434
- const plotLeft = Math.min(...x.range());
435
- const plotRight = Math.max(...x.range());
436
- labelX = isNegative
437
- ? barLeft - boxWidth / 2 - 4
438
- : barRight + boxWidth / 2 + 4;
439
- if ((!isNegative && labelX + boxWidth / 2 > plotRight) ||
440
- (isNegative && labelX - boxWidth / 2 < plotLeft)) {
441
- shouldRender = false;
442
- }
443
- }
444
- else {
445
- if (mode === 'layer' && insidePosition === 'bottom') {
446
- // Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
447
- shouldRender = false;
448
- }
449
- else {
450
- const { inset, minPadding } = getLabelSpacing(mode);
451
- switch (insidePosition) {
452
- case 'top':
453
- labelX = isNegative
454
- ? barRight - boxWidth / 2 - inset
455
- : barLeft + boxWidth / 2 + inset;
456
- break;
457
- case 'middle':
458
- labelX = (barLeft + barRight) / 2;
459
- break;
460
- case 'bottom':
461
- labelX = isNegative
462
- ? barLeft + boxWidth / 2 + inset
463
- : barRight - boxWidth / 2 - inset;
464
- break;
465
- }
466
- if (boxWidth + minPadding > barWidth) {
467
- shouldRender = false;
468
- }
469
- if (shouldRender &&
470
- position === 'inside' &&
471
- insidePosition !== 'middle') {
472
- const labelLeft = labelX - boxWidth / 2;
473
- const labelRight = labelX + boxWidth / 2;
474
- if (labelLeft < barLeft + 1 ||
475
- labelRight > barRight - 1) {
476
- shouldRender = false;
477
- }
478
- }
479
- }
480
- }
481
- tempText.remove();
482
- if (shouldRender) {
483
- const labelColor = position === 'inside' && config.color === undefined
484
- ? getContrastTextColor(barColor)
485
- : defaultLabelColor;
486
- const group = labelGroup.append('g');
487
- if (position === 'outside') {
488
- // Draw rounded rectangle background
489
- group
490
- .append('rect')
491
- .attr('x', labelX - boxWidth / 2)
492
- .attr('y', labelY - boxHeight / 2)
493
- .attr('width', boxWidth)
494
- .attr('height', boxHeight)
495
- .attr('rx', borderRadius)
496
- .attr('ry', borderRadius)
497
- .attr('fill', background)
498
- .attr('stroke', border)
499
- .attr('stroke-width', 1);
500
- }
501
- // Draw text
502
- group
503
- .append('text')
504
- .attr('x', labelX)
505
- .attr('y', labelY)
506
- .attr('text-anchor', 'middle')
507
- .attr('dominant-baseline', 'central')
508
- .style('font-size', `${fontSize}px`)
509
- .style('font-family', fontFamily)
510
- .style('font-weight', fontWeight)
511
- .style('fill', labelColor)
512
- .style('pointer-events', 'none')
513
- .text(valueText);
514
- }
515
- });
634
+ data.forEach((dataItem, index) => this.renderHorizontalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, yScaleType, stackingContext, config, barHeight, barOffset, mode, plotLeft, plotRight));
516
635
  }
517
636
  }