@internetstiftelsen/charts 0.8.0 → 0.9.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.
package/gauge-chart.js CHANGED
@@ -1,5 +1,5 @@
1
- import { arc, select } from 'd3';
2
- import { BaseChart } from './base-chart.js';
1
+ import { arc, easeBounceOut, easeCubicIn, easeCubicInOut, easeCubicOut, easeElasticOut, easeLinear, interpolateNumber, select, } from 'd3';
2
+ import { BaseChart, } from './base-chart.js';
3
3
  import { DEFAULT_COLOR_PALETTE } from './theme.js';
4
4
  import { ChartValidator } from './validation.js';
5
5
  const DEFAULT_START_ANGLE = -Math.PI * 0.75;
@@ -10,6 +10,9 @@ const DEFAULT_VALUE_KEY = 'value';
10
10
  const DEFAULT_HALF_CIRCLE = false;
11
11
  const DEFAULT_MIN_VALUE = 0;
12
12
  const DEFAULT_MAX_VALUE = 100;
13
+ const DEFAULT_ANIMATE = false;
14
+ const DEFAULT_ANIMATION_DURATION_MS = 700;
15
+ const DEFAULT_ANIMATION_EASING_PRESET = 'ease-in-out';
13
16
  const DEFAULT_INNER_RADIUS_RATIO = 0.68;
14
17
  const DEFAULT_CORNER_RADIUS = 4;
15
18
  const DEFAULT_TRACK_COLOR = '#e5e7eb';
@@ -45,6 +48,14 @@ const DEFAULT_HALF_CIRCLE_VALUE_TEXT_Y = 32;
45
48
  const DEFAULT_FULL_CIRCLE_VALUE_TEXT_MIN_Y = 22;
46
49
  const TOOLTIP_OFFSET_PX = 12;
47
50
  const EDGE_MARGIN_PX = 10;
51
+ const GAUGE_ANIMATION_EASING_PRESETS = {
52
+ linear: easeLinear,
53
+ 'ease-in': easeCubicIn,
54
+ 'ease-out': easeCubicOut,
55
+ 'ease-in-out': easeCubicInOut,
56
+ 'bounce-out': easeBounceOut,
57
+ 'elastic-out': easeElasticOut,
58
+ };
48
59
  const DUMMY_ARC_DATUM = {
49
60
  innerRadius: 0,
50
61
  outerRadius: 0,
@@ -96,6 +107,12 @@ export class GaugeChart extends BaseChart {
96
107
  writable: true,
97
108
  value: void 0
98
109
  });
110
+ Object.defineProperty(this, "animation", {
111
+ enumerable: true,
112
+ configurable: true,
113
+ writable: true,
114
+ value: void 0
115
+ });
99
116
  Object.defineProperty(this, "halfCircle", {
100
117
  enumerable: true,
101
118
  configurable: true,
@@ -216,6 +233,12 @@ export class GaugeChart extends BaseChart {
216
233
  writable: true,
217
234
  value: null
218
235
  });
236
+ Object.defineProperty(this, "lastRenderedValue", {
237
+ enumerable: true,
238
+ configurable: true,
239
+ writable: true,
240
+ value: null
241
+ });
219
242
  Object.defineProperty(this, "defaultFormat", {
220
243
  enumerable: true,
221
244
  configurable: true,
@@ -235,6 +258,7 @@ export class GaugeChart extends BaseChart {
235
258
  this.targetValueKey = config.targetValueKey;
236
259
  this.minValue = gauge.min ?? DEFAULT_MIN_VALUE;
237
260
  this.maxValue = gauge.max ?? DEFAULT_MAX_VALUE;
261
+ this.animation = this.normalizeAnimationConfig(gauge.animate);
238
262
  this.halfCircle = gauge.halfCircle ?? DEFAULT_HALF_CIRCLE;
239
263
  this.startAngle =
240
264
  gauge.startAngle ??
@@ -260,7 +284,7 @@ export class GaugeChart extends BaseChart {
260
284
  this.valueLabelStyle = this.normalizeValueLabelStyle(gauge.valueLabelStyle);
261
285
  this.validateGaugeConfig();
262
286
  this.segments = this.prepareSegments();
263
- this.refreshResolvedValues();
287
+ this.initializeDataState();
264
288
  }
265
289
  normalizeNeedleConfig(config) {
266
290
  if (config === false) {
@@ -328,6 +352,147 @@ export class GaugeChart extends BaseChart {
328
352
  formatter: config?.formatter ?? this.defaultFormat,
329
353
  };
330
354
  }
355
+ normalizeAnimationConfig(config) {
356
+ if (config === undefined) {
357
+ return {
358
+ show: DEFAULT_ANIMATE,
359
+ duration: DEFAULT_ANIMATION_DURATION_MS,
360
+ easing: GAUGE_ANIMATION_EASING_PRESETS[DEFAULT_ANIMATION_EASING_PRESET],
361
+ };
362
+ }
363
+ if (typeof config === 'boolean') {
364
+ return {
365
+ show: config,
366
+ duration: DEFAULT_ANIMATION_DURATION_MS,
367
+ easing: GAUGE_ANIMATION_EASING_PRESETS[DEFAULT_ANIMATION_EASING_PRESET],
368
+ };
369
+ }
370
+ return {
371
+ show: config.show ?? true,
372
+ duration: config.duration ?? DEFAULT_ANIMATION_DURATION_MS,
373
+ easing: this.resolveAnimationEasing(config.easing),
374
+ };
375
+ }
376
+ resolveAnimationEasing(easing) {
377
+ if (!easing) {
378
+ return GAUGE_ANIMATION_EASING_PRESETS[DEFAULT_ANIMATION_EASING_PRESET];
379
+ }
380
+ if (typeof easing === 'function') {
381
+ return easing;
382
+ }
383
+ if (easing in GAUGE_ANIMATION_EASING_PRESETS) {
384
+ return GAUGE_ANIMATION_EASING_PRESETS[easing];
385
+ }
386
+ if (easing.startsWith('linear(')) {
387
+ const parsedCssLinear = this.parseCssLinearEasing(easing);
388
+ if (parsedCssLinear) {
389
+ return parsedCssLinear;
390
+ }
391
+ }
392
+ ChartValidator.warn(`GaugeChart: unsupported gauge.animate.easing '${easing}', falling back to '${DEFAULT_ANIMATION_EASING_PRESET}'`);
393
+ return GAUGE_ANIMATION_EASING_PRESETS[DEFAULT_ANIMATION_EASING_PRESET];
394
+ }
395
+ parseCssLinearEasing(cssLinearEasing) {
396
+ const normalized = cssLinearEasing.trim();
397
+ if (!normalized.startsWith('linear(') || !normalized.endsWith(')')) {
398
+ return null;
399
+ }
400
+ const body = normalized.slice('linear('.length, -1);
401
+ const tokens = body
402
+ .split(',')
403
+ .map((token) => token.trim())
404
+ .filter((token) => token.length > 0);
405
+ if (tokens.length < 2) {
406
+ return null;
407
+ }
408
+ const rawStops = tokens.map((token) => {
409
+ const parts = token.split(/\s+/).filter(Boolean);
410
+ if (parts.length === 0 || parts.length > 2) {
411
+ return null;
412
+ }
413
+ const value = Number(parts[0]);
414
+ if (!Number.isFinite(value)) {
415
+ return null;
416
+ }
417
+ if (parts.length === 1) {
418
+ return { value, position: undefined };
419
+ }
420
+ const percentageToken = parts[1];
421
+ if (!percentageToken.endsWith('%')) {
422
+ return null;
423
+ }
424
+ const percentageValue = Number(percentageToken.slice(0, -1));
425
+ if (!Number.isFinite(percentageValue)) {
426
+ return null;
427
+ }
428
+ return {
429
+ value,
430
+ position: percentageValue / 100,
431
+ };
432
+ });
433
+ if (rawStops.some((stop) => stop === null)) {
434
+ return null;
435
+ }
436
+ const stops = rawStops;
437
+ const positions = stops.map((stop) => stop.position);
438
+ if (positions[0] === undefined) {
439
+ positions[0] = 0;
440
+ }
441
+ if (positions[positions.length - 1] === undefined) {
442
+ positions[positions.length - 1] = 1;
443
+ }
444
+ let previousDefinedIndex = 0;
445
+ for (let currentIndex = 1; currentIndex < positions.length; currentIndex += 1) {
446
+ const currentPosition = positions[currentIndex];
447
+ if (currentPosition === undefined) {
448
+ continue;
449
+ }
450
+ const previousPosition = positions[previousDefinedIndex];
451
+ if (currentPosition < previousPosition) {
452
+ return null;
453
+ }
454
+ const missingCount = currentIndex - previousDefinedIndex - 1;
455
+ if (missingCount > 0) {
456
+ for (let missingIndex = 1; missingIndex <= missingCount; missingIndex += 1) {
457
+ const ratio = missingIndex / (missingCount + 1);
458
+ positions[previousDefinedIndex + missingIndex] =
459
+ previousPosition +
460
+ (currentPosition - previousPosition) * ratio;
461
+ }
462
+ }
463
+ previousDefinedIndex = currentIndex;
464
+ }
465
+ const easingPoints = stops.map((stop, index) => {
466
+ return {
467
+ value: stop.value,
468
+ position: positions[index],
469
+ };
470
+ });
471
+ return (progress) => {
472
+ const clamped = Math.max(0, Math.min(1, progress));
473
+ if (clamped <= easingPoints[0].position) {
474
+ return easingPoints[0].value;
475
+ }
476
+ const lastPoint = easingPoints[easingPoints.length - 1];
477
+ if (clamped >= lastPoint.position) {
478
+ return lastPoint.value;
479
+ }
480
+ for (let index = 0; index < easingPoints.length - 1; index += 1) {
481
+ const left = easingPoints[index];
482
+ const right = easingPoints[index + 1];
483
+ if (clamped > right.position) {
484
+ continue;
485
+ }
486
+ const span = right.position - left.position;
487
+ if (span <= 0) {
488
+ return right.value;
489
+ }
490
+ const localProgress = (clamped - left.position) / span;
491
+ return left.value + (right.value - left.value) * localProgress;
492
+ }
493
+ return lastPoint.value;
494
+ };
495
+ }
331
496
  normalizeTickLabelStyle(config) {
332
497
  return {
333
498
  fontSize: config?.fontSize ?? DEFAULT_TICK_LABEL_FONT_SIZE,
@@ -392,6 +557,10 @@ export class GaugeChart extends BaseChart {
392
557
  if (this.marker.width <= 0) {
393
558
  throw new Error(`GaugeChart: gauge.marker.width must be > 0, received '${this.marker.width}'`);
394
559
  }
560
+ if (!Number.isFinite(this.animation.duration) ||
561
+ this.animation.duration < 0) {
562
+ throw new Error(`GaugeChart: gauge.animate.duration must be >= 0, received '${this.animation.duration}'`);
563
+ }
395
564
  this.validateSegments(this.configuredSegments);
396
565
  }
397
566
  validateSegments(segments) {
@@ -502,59 +671,17 @@ export class GaugeChart extends BaseChart {
502
671
  };
503
672
  });
504
673
  }
505
- addChild(component) {
506
- const type = component.type;
507
- if (type === 'tooltip') {
508
- this.tooltip = component;
509
- }
510
- else if (type === 'legend') {
511
- this.legend = component;
512
- this.legend.setToggleCallback(() => {
513
- if (!this.container) {
514
- return;
515
- }
516
- this.update(this.data);
517
- });
518
- }
519
- else if (type === 'title') {
520
- this.title = component;
521
- }
522
- return this;
523
- }
524
674
  getExportComponents() {
525
- const components = [];
526
- if (this.title) {
527
- components.push(this.title);
528
- }
529
- if (this.tooltip) {
530
- components.push(this.tooltip);
531
- }
532
- if (this.legend?.isInlineMode()) {
533
- components.push(this.legend);
534
- }
535
- return components;
675
+ return this.getBaseExportComponents({
676
+ title: true,
677
+ tooltip: true,
678
+ legend: this.legend?.isInlineMode(),
679
+ });
536
680
  }
537
681
  update(data) {
538
- this.data = data;
539
- this.refreshResolvedValues();
682
+ this.lastRenderedValue = this.value;
540
683
  super.update(data);
541
684
  }
542
- getLayoutComponents() {
543
- const components = [];
544
- if (this.title) {
545
- components.push(this.title);
546
- }
547
- if (this.legend) {
548
- components.push(this.legend);
549
- }
550
- return components;
551
- }
552
- prepareLayout() {
553
- const svgNode = this.svg?.node();
554
- if (svgNode && this.legend?.isInlineMode()) {
555
- this.legend.estimateLayoutSpace(this.getLegendSeries(), this.theme, this.width, svgNode);
556
- }
557
- }
558
685
  createExportChart() {
559
686
  return new GaugeChart({
560
687
  data: this.data,
@@ -567,6 +694,7 @@ export class GaugeChart extends BaseChart {
567
694
  targetValue: this.configuredTargetValue,
568
695
  min: this.minValue,
569
696
  max: this.maxValue,
697
+ animate: false,
570
698
  halfCircle: this.halfCircle,
571
699
  startAngle: this.startAngle,
572
700
  endAngle: this.endAngle,
@@ -590,17 +718,14 @@ export class GaugeChart extends BaseChart {
590
718
  },
591
719
  });
592
720
  }
593
- renderChart() {
594
- if (!this.plotArea || !this.svg || !this.plotGroup) {
595
- throw new Error('Plot area not calculated');
596
- }
597
- this.svg.attr('role', 'img').attr('aria-label', this.buildAriaLabel());
598
- if (this.title) {
599
- const titlePosition = this.layoutManager.getComponentPosition(this.title);
600
- this.title.render(this.svg, this.theme, this.width, titlePosition.x, titlePosition.y);
601
- }
721
+ syncDerivedState() {
722
+ this.refreshResolvedValues();
723
+ }
724
+ renderChart({ svg, plotGroup, plotArea, }) {
725
+ svg.attr('role', 'img').attr('aria-label', this.buildAriaLabel());
726
+ this.renderTitle(svg);
602
727
  if (this.tooltip) {
603
- this.tooltip.initialize(this.theme);
728
+ this.tooltip.initialize(this.renderTheme);
604
729
  }
605
730
  const labelAllowance = this.ticks.show && this.ticks.showLabels
606
731
  ? this.ticks.size + this.ticks.labelOffset + 14
@@ -608,29 +733,30 @@ export class GaugeChart extends BaseChart {
608
733
  ? this.ticks.size + 10
609
734
  : 8;
610
735
  let outerRadius;
611
- const centerX = this.plotArea.left + this.plotArea.width / 2;
736
+ const centerX = plotArea.left + plotArea.width / 2;
612
737
  let centerY;
613
738
  if (this.halfCircle) {
614
739
  const valueSpace = this.showValue
615
740
  ? DEFAULT_HALF_CIRCLE_VALUE_SPACE_WITH_LABEL
616
741
  : DEFAULT_HALF_CIRCLE_VALUE_SPACE_WITHOUT_LABEL;
617
- const maxHorizontalRadius = this.plotArea.width / 2 - labelAllowance - 8;
618
- const maxVerticalRadius = this.plotArea.height - valueSpace - labelAllowance - 8;
742
+ const maxHorizontalRadius = plotArea.width / 2 - labelAllowance - 8;
743
+ const maxVerticalRadius = plotArea.height - valueSpace - labelAllowance - 8;
619
744
  outerRadius = Math.max(24, Math.min(maxHorizontalRadius, maxVerticalRadius));
620
- centerY = this.plotArea.top + labelAllowance + outerRadius + 4;
745
+ centerY = plotArea.top + labelAllowance + outerRadius + 4;
621
746
  }
622
747
  else {
623
- const maxRadius = Math.min(this.plotArea.width, this.plotArea.height) / 2;
748
+ const maxRadius = Math.min(plotArea.width, plotArea.height) / 2;
624
749
  outerRadius = Math.max(24, maxRadius - labelAllowance);
625
- centerY = this.plotArea.top + this.plotArea.height / 2;
750
+ centerY = plotArea.top + plotArea.height / 2;
626
751
  }
627
752
  const innerRadius = this.thickness !== null
628
753
  ? Math.max(0, outerRadius - Math.min(this.thickness, outerRadius - 1))
629
754
  : Math.max(8, Math.min(outerRadius - 4, outerRadius * this.innerRadiusRatio));
630
- const gaugeGroup = this.plotGroup
755
+ const gaugeGroup = plotGroup
631
756
  .append('g')
632
757
  .attr('class', 'gauge')
633
758
  .attr('transform', `translate(${centerX}, ${centerY})`);
759
+ const animationStartValue = this.resolveAnimationStartValue();
634
760
  this.renderTrack(gaugeGroup, innerRadius, outerRadius);
635
761
  const visibleSegments = this.getVisibleSegments();
636
762
  if (visibleSegments.length > 0) {
@@ -645,7 +771,7 @@ export class GaugeChart extends BaseChart {
645
771
  const shouldRenderProgress = visibleSegments.length === 0;
646
772
  if (shouldRenderProgress) {
647
773
  const progressRadii = this.getProgressRadii(innerRadius, outerRadius);
648
- this.renderProgress(gaugeGroup, progressColor, progressRadii.inner, progressRadii.outer);
774
+ this.renderProgress(gaugeGroup, progressColor, progressRadii.inner, progressRadii.outer, animationStartValue);
649
775
  }
650
776
  if (this.ticks.show &&
651
777
  this.ticks.count > 0 &&
@@ -653,21 +779,30 @@ export class GaugeChart extends BaseChart {
653
779
  this.renderTicks(gaugeGroup, outerRadius);
654
780
  }
655
781
  if (this.showValue) {
656
- this.renderValueText(gaugeGroup, outerRadius);
782
+ this.renderValueText(gaugeGroup, outerRadius, animationStartValue);
657
783
  }
658
784
  if (this.targetValue !== null) {
659
785
  this.renderTargetMarker(gaugeGroup, innerRadius, outerRadius);
660
786
  }
661
787
  if (this.needle.show) {
662
- this.renderNeedle(gaugeGroup, innerRadius, outerRadius);
788
+ this.renderNeedle(gaugeGroup, innerRadius, outerRadius, animationStartValue);
663
789
  }
664
790
  else if (this.marker.show) {
665
- this.renderCurrentValueMarker(gaugeGroup, innerRadius, outerRadius);
791
+ this.renderCurrentValueMarker(gaugeGroup, innerRadius, outerRadius, animationStartValue);
666
792
  }
667
793
  if (this.tooltip) {
668
794
  this.attachTooltipLayer(gaugeGroup, innerRadius, outerRadius, progressColor);
669
795
  }
670
- this.renderLegend();
796
+ this.renderInlineLegend(svg);
797
+ }
798
+ resolveAnimationStartValue() {
799
+ if (this.lastRenderedValue === null) {
800
+ return this.minValue;
801
+ }
802
+ return Math.min(this.maxValue, Math.max(this.minValue, this.lastRenderedValue));
803
+ }
804
+ shouldAnimateTransition(startValue) {
805
+ return this.animation.show && startValue !== this.value;
671
806
  }
672
807
  buildAriaLabel() {
673
808
  const statusLabel = this.findSegmentStatusLabel();
@@ -689,11 +824,8 @@ export class GaugeChart extends BaseChart {
689
824
  return null;
690
825
  }
691
826
  getVisibleSegments() {
692
- if (!this.legend) {
693
- return this.segments;
694
- }
695
- return this.segments.filter((segment) => {
696
- return this.legend.isSeriesVisible(segment.legendLabel);
827
+ return this.filterVisibleItems(this.segments, (segment) => {
828
+ return segment.legendLabel;
697
829
  });
698
830
  }
699
831
  resolveProgressColor(segments) {
@@ -811,18 +943,44 @@ export class GaugeChart extends BaseChart {
811
943
  .attr('stroke-opacity', 0.95)
812
944
  .style('pointer-events', 'none');
813
945
  }
814
- renderProgress(gaugeGroup, progressColor, innerRadius, outerRadius) {
946
+ renderProgress(gaugeGroup, progressColor, innerRadius, outerRadius, startValue) {
815
947
  const progressArc = arc()
816
948
  .innerRadius(innerRadius)
817
949
  .outerRadius(outerRadius)
818
950
  .startAngle(this.startAngle)
819
- .endAngle(this.valueToAngle(this.value))
951
+ .endAngle((datum) => datum.endAngle)
820
952
  .cornerRadius(this.cornerRadius);
821
- gaugeGroup
953
+ const startAngle = this.valueToAngle(startValue);
954
+ const endAngle = this.valueToAngle(this.value);
955
+ const startPath = progressArc({
956
+ ...DUMMY_ARC_DATUM,
957
+ endAngle: startAngle,
958
+ });
959
+ const endPath = progressArc({
960
+ ...DUMMY_ARC_DATUM,
961
+ endAngle,
962
+ });
963
+ const shouldAnimate = this.shouldAnimateTransition(startValue);
964
+ const progressPath = gaugeGroup
822
965
  .append('path')
823
966
  .attr('class', 'gauge-progress')
824
- .attr('fill', progressColor)
825
- .attr('d', progressArc(DUMMY_ARC_DATUM));
967
+ .attr('fill', progressColor);
968
+ if (shouldAnimate) {
969
+ const angleInterpolator = interpolateNumber(startAngle, endAngle);
970
+ progressPath
971
+ .attr('d', startPath)
972
+ .transition()
973
+ .duration(this.animation.duration)
974
+ .ease(this.animation.easing)
975
+ .attrTween('d', () => {
976
+ return (progress) => progressArc({
977
+ ...DUMMY_ARC_DATUM,
978
+ endAngle: angleInterpolator(progress),
979
+ }) ?? '';
980
+ });
981
+ return;
982
+ }
983
+ progressPath.attr('d', endPath);
826
984
  }
827
985
  renderTicks(gaugeGroup, outerRadius) {
828
986
  const tickGroup = gaugeGroup.append('g').attr('class', 'gauge-ticks');
@@ -889,21 +1047,43 @@ export class GaugeChart extends BaseChart {
889
1047
  .attr('stroke-width', DEFAULT_TARGET_MARKER_STROKE_WIDTH)
890
1048
  .attr('stroke-linecap', 'round');
891
1049
  }
892
- renderNeedle(gaugeGroup, innerRadius, outerRadius) {
1050
+ renderNeedle(gaugeGroup, innerRadius, outerRadius, startValue) {
893
1051
  const needleAngle = this.valueToAngle(this.value);
1052
+ const startNeedleAngle = this.valueToAngle(startValue);
894
1053
  const maxLength = Math.max(innerRadius + 2, outerRadius - 2);
895
1054
  const length = maxLength * this.needle.lengthRatio;
896
1055
  const needlePoint = this.pointAt(needleAngle, length);
897
- gaugeGroup
1056
+ const startNeedlePoint = this.pointAt(startNeedleAngle, length);
1057
+ const shouldAnimate = this.shouldAnimateTransition(startValue);
1058
+ const initialNeedlePoint = shouldAnimate
1059
+ ? startNeedlePoint
1060
+ : needlePoint;
1061
+ const needleLine = gaugeGroup
898
1062
  .append('line')
899
1063
  .attr('class', 'gauge-needle')
900
1064
  .attr('x1', 0)
901
1065
  .attr('y1', 0)
902
- .attr('x2', needlePoint.x)
903
- .attr('y2', needlePoint.y)
1066
+ .attr('x2', initialNeedlePoint.x)
1067
+ .attr('y2', initialNeedlePoint.y)
904
1068
  .attr('stroke', this.needle.color)
905
1069
  .attr('stroke-width', this.needle.width)
906
1070
  .attr('stroke-linecap', 'round');
1071
+ if (shouldAnimate) {
1072
+ const angleInterpolator = interpolateNumber(startNeedleAngle, needleAngle);
1073
+ needleLine
1074
+ .transition()
1075
+ .duration(this.animation.duration)
1076
+ .ease(this.animation.easing)
1077
+ .tween('needle-rotation', () => {
1078
+ return (progress) => {
1079
+ const interpolatedAngle = angleInterpolator(progress);
1080
+ const interpolatedPoint = this.pointAt(interpolatedAngle, length);
1081
+ needleLine
1082
+ .attr('x2', interpolatedPoint.x)
1083
+ .attr('y2', interpolatedPoint.y);
1084
+ };
1085
+ });
1086
+ }
907
1087
  gaugeGroup
908
1088
  .append('circle')
909
1089
  .attr('class', 'gauge-needle-cap')
@@ -912,28 +1092,55 @@ export class GaugeChart extends BaseChart {
912
1092
  .attr('r', this.needle.capRadius)
913
1093
  .attr('fill', this.needle.color);
914
1094
  }
915
- renderCurrentValueMarker(gaugeGroup, innerRadius, outerRadius) {
1095
+ renderCurrentValueMarker(gaugeGroup, innerRadius, outerRadius, startValue) {
916
1096
  const markerAngle = this.valueToAngle(this.value);
1097
+ const startMarkerAngle = this.valueToAngle(startValue);
917
1098
  const markerInner = innerRadius + 1;
918
1099
  const markerOuter = Math.max(markerInner + 1, outerRadius - 1);
919
1100
  const innerPoint = this.pointAt(markerAngle, markerInner);
920
1101
  const outerPoint = this.pointAt(markerAngle, markerOuter);
921
- gaugeGroup
1102
+ const startInnerPoint = this.pointAt(startMarkerAngle, markerInner);
1103
+ const startOuterPoint = this.pointAt(startMarkerAngle, markerOuter);
1104
+ const shouldAnimate = this.shouldAnimateTransition(startValue);
1105
+ const initialInnerPoint = shouldAnimate ? startInnerPoint : innerPoint;
1106
+ const initialOuterPoint = shouldAnimate ? startOuterPoint : outerPoint;
1107
+ const markerLine = gaugeGroup
922
1108
  .append('line')
923
1109
  .attr('class', 'gauge-marker')
924
- .attr('x1', innerPoint.x)
925
- .attr('y1', innerPoint.y)
926
- .attr('x2', outerPoint.x)
927
- .attr('y2', outerPoint.y)
1110
+ .attr('x1', initialInnerPoint.x)
1111
+ .attr('y1', initialInnerPoint.y)
1112
+ .attr('x2', initialOuterPoint.x)
1113
+ .attr('y2', initialOuterPoint.y)
928
1114
  .attr('stroke', this.marker.color)
929
1115
  .attr('stroke-width', this.marker.width)
930
1116
  .attr('stroke-linecap', 'round');
1117
+ if (shouldAnimate) {
1118
+ const angleInterpolator = interpolateNumber(startMarkerAngle, markerAngle);
1119
+ markerLine
1120
+ .transition()
1121
+ .duration(this.animation.duration)
1122
+ .ease(this.animation.easing)
1123
+ .tween('marker-sweep', () => {
1124
+ return (progress) => {
1125
+ const interpolatedAngle = angleInterpolator(progress);
1126
+ const interpolatedInner = this.pointAt(interpolatedAngle, markerInner);
1127
+ const interpolatedOuter = this.pointAt(interpolatedAngle, markerOuter);
1128
+ markerLine
1129
+ .attr('x1', interpolatedInner.x)
1130
+ .attr('y1', interpolatedInner.y)
1131
+ .attr('x2', interpolatedOuter.x)
1132
+ .attr('y2', interpolatedOuter.y);
1133
+ };
1134
+ });
1135
+ }
931
1136
  }
932
- renderValueText(gaugeGroup, outerRadius) {
1137
+ renderValueText(gaugeGroup, outerRadius, startValue) {
933
1138
  const mainValueY = this.halfCircle
934
1139
  ? DEFAULT_HALF_CIRCLE_VALUE_TEXT_Y
935
1140
  : Math.max(DEFAULT_FULL_CIRCLE_VALUE_TEXT_MIN_Y, outerRadius * 0.58);
936
- gaugeGroup
1141
+ const shouldAnimate = this.shouldAnimateTransition(startValue);
1142
+ const initialValue = shouldAnimate ? startValue : this.value;
1143
+ const valueText = gaugeGroup
937
1144
  .append('text')
938
1145
  .attr('class', 'gauge-value')
939
1146
  .attr('x', 0)
@@ -943,7 +1150,19 @@ export class GaugeChart extends BaseChart {
943
1150
  .attr('font-weight', this.valueLabelStyle.fontWeight)
944
1151
  .attr('font-family', this.valueLabelStyle.fontFamily)
945
1152
  .attr('fill', this.valueLabelStyle.color)
946
- .text(this.valueFormatter(this.value));
1153
+ .text(this.valueFormatter(initialValue));
1154
+ if (shouldAnimate) {
1155
+ valueText
1156
+ .transition()
1157
+ .duration(this.animation.duration)
1158
+ .ease(this.animation.easing)
1159
+ .tween('text', () => {
1160
+ return (progress) => {
1161
+ const currentValue = startValue + (this.value - startValue) * progress;
1162
+ valueText.text(this.valueFormatter(currentValue));
1163
+ };
1164
+ });
1165
+ }
947
1166
  }
948
1167
  attachTooltipLayer(gaugeGroup, innerRadius, outerRadius, progressColor) {
949
1168
  const interactionArc = arc()
@@ -1039,16 +1258,6 @@ export class GaugeChart extends BaseChart {
1039
1258
  EDGE_MARGIN_PX));
1040
1259
  tooltipDiv.style('left', `${x}px`).style('top', `${y}px`);
1041
1260
  }
1042
- renderLegend() {
1043
- if (!this.legend ||
1044
- !this.legend.isInlineMode() ||
1045
- !this.svg ||
1046
- this.segments.length === 0) {
1047
- return;
1048
- }
1049
- const legendPosition = this.layoutManager.getComponentPosition(this.legend);
1050
- this.legend.render(this.svg, this.getLegendSeries(), this.theme, this.width, legendPosition.x, legendPosition.y);
1051
- }
1052
1261
  getLegendSeries() {
1053
1262
  return this.segments.map((segment) => {
1054
1263
  return {
package/line.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { line } from 'd3';
2
2
  import { sanitizeForCSS, mergeDeep } from './utils.js';
3
+ import { getScalePosition } from './scale-utils.js';
3
4
  export class Line {
4
5
  constructor(config) {
5
6
  Object.defineProperty(this, "type", {
@@ -61,31 +62,8 @@ export class Line {
61
62
  }
62
63
  render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
63
64
  const getXPosition = (d) => {
64
- const xValue = d[xKey];
65
- // Handle different scale types appropriately
66
- let scaledValue;
67
- switch (xScaleType) {
68
- case 'band':
69
- // Band scale - use string
70
- scaledValue = String(xValue);
71
- break;
72
- case 'time':
73
- // Time scale - convert to Date
74
- scaledValue =
75
- xValue instanceof Date
76
- ? xValue
77
- : new Date(String(xValue));
78
- break;
79
- case 'linear':
80
- case 'log':
81
- // Linear/log scale - convert to number
82
- scaledValue =
83
- typeof xValue === 'number' ? xValue : Number(xValue);
84
- break;
85
- }
86
- const scaled = x(scaledValue);
87
- // Handle band scales with bandwidth
88
- return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
65
+ return (getScalePosition(x, d[xKey], xScaleType) +
66
+ (x.bandwidth ? x.bandwidth() / 2 : 0));
89
67
  };
90
68
  // Helper to check if a data point has a valid (non-null) value
91
69
  const hasValidValue = (d) => {
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.8.0",
2
+ "version": "0.9.0",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -40,6 +40,7 @@
40
40
  "class-variance-authority": "^0.7.1",
41
41
  "clsx": "^2.1.1",
42
42
  "d3": "^7.9.0",
43
+ "d3-cloud": "^1.2.9",
43
44
  "handsontable": "^16.2.0",
44
45
  "lucide-react": "^0.548.0",
45
46
  "react": "^19.2.4",
@@ -59,6 +60,7 @@
59
60
  "@testing-library/dom": "^10.4.1",
60
61
  "@testing-library/jest-dom": "^6.9.1",
61
62
  "@testing-library/react": "^16.3.2",
63
+ "@types/d3-cloud": "^1.2.9",
62
64
  "@types/node": "^24.10.13",
63
65
  "@types/react": "^19.2.14",
64
66
  "@types/react-dom": "^19.2.3",