@opendata-ai/openchart-engine 6.2.1 → 6.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.2.1",
3
+ "version": "6.4.1",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.2.1",
48
+ "@opendata-ai/openchart-core": "6.4.1",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -111,6 +111,108 @@ describe('computeLegend', () => {
111
111
  expect(legend.entries).toHaveLength(3);
112
112
  });
113
113
 
114
+ it('with columns: 3 and 6 series, shows all 6 entries across 2 rows', () => {
115
+ const sixSeriesSpec: NormalizedChartSpec = {
116
+ ...specWithColor,
117
+ data: [
118
+ { date: '2020', value: 10, country: 'A' },
119
+ { date: '2020', value: 10, country: 'B' },
120
+ { date: '2020', value: 10, country: 'C' },
121
+ { date: '2020', value: 10, country: 'D' },
122
+ { date: '2020', value: 10, country: 'E' },
123
+ { date: '2020', value: 10, country: 'F' },
124
+ ],
125
+ legend: { columns: 3 },
126
+ hiddenSeries: [],
127
+ seriesStyles: {},
128
+ };
129
+ const legend = computeLegend(sixSeriesSpec, compactStrategy, theme, chartArea);
130
+ // All 6 entries visible (no overflow indicator)
131
+ expect(legend.entries).toHaveLength(6);
132
+ expect(legend.entries.every((e) => !e.overflow)).toBe(true);
133
+ });
134
+
135
+ it('with symbolLimit: 3 and 6 series, truncates to 3 entries + overflow', () => {
136
+ const sixSeriesSpec: NormalizedChartSpec = {
137
+ ...specWithColor,
138
+ data: [
139
+ { date: '2020', value: 10, country: 'A' },
140
+ { date: '2020', value: 10, country: 'B' },
141
+ { date: '2020', value: 10, country: 'C' },
142
+ { date: '2020', value: 10, country: 'D' },
143
+ { date: '2020', value: 10, country: 'E' },
144
+ { date: '2020', value: 10, country: 'F' },
145
+ ],
146
+ legend: { symbolLimit: 3 },
147
+ hiddenSeries: [],
148
+ seriesStyles: {},
149
+ };
150
+ const legend = computeLegend(sixSeriesSpec, compactStrategy, theme, chartArea);
151
+ // 3 real entries + 1 overflow indicator
152
+ expect(legend.entries).toHaveLength(4);
153
+ expect(legend.entries[3].label).toBe('+3 more');
154
+ expect(legend.entries[3].overflow).toBe(true);
155
+ });
156
+
157
+ it('with symbolLimit on right-positioned legend, truncates entries', () => {
158
+ const sixSeriesSpec: NormalizedChartSpec = {
159
+ ...specWithColor,
160
+ data: [
161
+ { date: '2020', value: 10, country: 'A' },
162
+ { date: '2020', value: 10, country: 'B' },
163
+ { date: '2020', value: 10, country: 'C' },
164
+ { date: '2020', value: 10, country: 'D' },
165
+ { date: '2020', value: 10, country: 'E' },
166
+ { date: '2020', value: 10, country: 'F' },
167
+ ],
168
+ legend: { symbolLimit: 2 },
169
+ hiddenSeries: [],
170
+ seriesStyles: {},
171
+ };
172
+ const legend = computeLegend(sixSeriesSpec, fullStrategy, theme, chartArea);
173
+ // 2 real entries + 1 overflow indicator
174
+ expect(legend.entries).toHaveLength(3);
175
+ expect(legend.entries[2].label).toBe('+4 more');
176
+ expect(legend.entries[2].overflow).toBe(true);
177
+ });
178
+
179
+ it('symbolLimit: 0 is clamped to 1 (minimum 1 entry)', () => {
180
+ const sixSeriesSpec: NormalizedChartSpec = {
181
+ ...specWithColor,
182
+ data: [
183
+ { date: '2020', value: 10, country: 'A' },
184
+ { date: '2020', value: 10, country: 'B' },
185
+ { date: '2020', value: 10, country: 'C' },
186
+ ],
187
+ encoding: {
188
+ x: { field: 'date', type: 'temporal' },
189
+ y: { field: 'value', type: 'quantitative' },
190
+ color: { field: 'country', type: 'nominal' },
191
+ },
192
+ };
193
+ const compactStrategy: LayoutStrategy = {
194
+ legend: { symbolLimit: 0 },
195
+ hiddenSeries: [],
196
+ seriesStyles: {},
197
+ };
198
+ const legend = computeLegend(sixSeriesSpec, compactStrategy, theme, chartArea);
199
+ // symbolLimit: 0 gets clamped to 1, so 1 real entry + overflow
200
+ expect(legend.entries.length).toBeGreaterThanOrEqual(1);
201
+ expect(legend.entries.filter((e) => !e.overflow).length).toBeGreaterThanOrEqual(1);
202
+ });
203
+
204
+ it('symbolLimit greater than entry count shows all entries', () => {
205
+ const largeLimit: LayoutStrategy = {
206
+ legend: { symbolLimit: 100 },
207
+ hiddenSeries: [],
208
+ seriesStyles: {},
209
+ };
210
+ const legend = computeLegend(specWithColor, largeLimit, theme, chartArea);
211
+ // All 3 entries shown, no overflow
212
+ expect(legend.entries).toHaveLength(3);
213
+ expect(legend.entries.every((e) => !e.overflow)).toBe(true);
214
+ });
215
+
114
216
  it('uses correct swatch shape for chart type', () => {
115
217
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
116
218
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -946,4 +946,111 @@ describe('computeAnnotations', () => {
946
946
  expect(moved).toBe(true);
947
947
  });
948
948
  });
949
+
950
+ // -----------------------------------------------------------------
951
+ // Boundary clamping (SVG edge overflow prevention)
952
+ // -----------------------------------------------------------------
953
+
954
+ describe('boundary clamping', () => {
955
+ const svgDimensions = { width: 600, height: 400 };
956
+
957
+ it('shifts annotation left when it overflows the right SVG edge', () => {
958
+ // Place annotation at the far right of the chart area so the label
959
+ // text extends past the SVG boundary.
960
+ const spec = makeSpec([
961
+ {
962
+ type: 'text',
963
+ x: '2022-01-01',
964
+ y: 40,
965
+ text: 'This is a long annotation label',
966
+ anchor: 'right',
967
+ offset: { dx: 80, dy: 0 },
968
+ },
969
+ ]);
970
+ const scales = computeScales(spec, chartArea, spec.data);
971
+ const annotations = computeAnnotations(
972
+ spec,
973
+ scales,
974
+ chartArea,
975
+ fullStrategy,
976
+ false,
977
+ [],
978
+ svgDimensions,
979
+ );
980
+
981
+ expect(annotations).toHaveLength(1);
982
+ const label = annotations[0].label!;
983
+ const fontSize = label.style.fontSize ?? 12;
984
+ const fontWeight = label.style.fontWeight ?? 400;
985
+ // Estimate bounds to verify the right edge is within SVG
986
+ const textWidth = label.text
987
+ .split('\n')
988
+ .reduce(
989
+ (max, line) => Math.max(max, line.length * fontSize * (fontWeight >= 600 ? 0.65 : 0.55)),
990
+ 0,
991
+ );
992
+ // The label's right edge should not exceed the SVG width
993
+ expect(label.x + textWidth).toBeLessThanOrEqual(svgDimensions.width);
994
+ });
995
+
996
+ it('shifts annotation down when it overflows the top SVG edge', () => {
997
+ // Place annotation near the top of the chart and push it upward
998
+ const spec = makeSpec([
999
+ {
1000
+ type: 'text',
1001
+ x: '2020-01-01',
1002
+ y: 40,
1003
+ text: 'Top overflow',
1004
+ anchor: 'top',
1005
+ offset: { dx: 0, dy: -80 },
1006
+ },
1007
+ ]);
1008
+ const scales = computeScales(spec, chartArea, spec.data);
1009
+ const annotations = computeAnnotations(
1010
+ spec,
1011
+ scales,
1012
+ chartArea,
1013
+ fullStrategy,
1014
+ false,
1015
+ [],
1016
+ svgDimensions,
1017
+ );
1018
+
1019
+ expect(annotations).toHaveLength(1);
1020
+ const label = annotations[0].label!;
1021
+ const fontSize = label.style.fontSize ?? 12;
1022
+ // The top of the label bounds (y - fontSize) should be >= 0
1023
+ expect(label.y - fontSize).toBeGreaterThanOrEqual(0);
1024
+ });
1025
+
1026
+ it('does not modify annotation that is well within bounds', () => {
1027
+ // Place annotation in the center of the chart
1028
+ const spec = makeSpec([
1029
+ {
1030
+ type: 'text',
1031
+ x: '2020-06-01',
1032
+ y: 25,
1033
+ text: 'Centered',
1034
+ },
1035
+ ]);
1036
+ const scales = computeScales(spec, chartArea, spec.data);
1037
+
1038
+ // Compute with and without SVG dimensions to verify no change
1039
+ const withClamping = computeAnnotations(
1040
+ spec,
1041
+ scales,
1042
+ chartArea,
1043
+ fullStrategy,
1044
+ false,
1045
+ [],
1046
+ svgDimensions,
1047
+ );
1048
+ const withoutClamping = computeAnnotations(spec, scales, chartArea, fullStrategy, false, []);
1049
+
1050
+ expect(withClamping).toHaveLength(1);
1051
+ expect(withoutClamping).toHaveLength(1);
1052
+ expect(withClamping[0].label!.x).toBe(withoutClamping[0].label!.x);
1053
+ expect(withClamping[0].label!.y).toBe(withoutClamping[0].label!.y);
1054
+ });
1055
+ });
949
1056
  });
@@ -363,6 +363,7 @@ function resolveTextAnnotation(
363
363
 
364
364
  return {
365
365
  type: 'text',
366
+ id: annotation.id,
366
367
  label,
367
368
  stroke: annotation.stroke,
368
369
  fill: annotation.fill,
@@ -447,6 +448,7 @@ function resolveRangeAnnotation(
447
448
 
448
449
  return {
449
450
  type: 'range',
451
+ id: annotation.id,
450
452
  rect,
451
453
  label,
452
454
  fill: annotation.fill ?? DEFAULT_RANGE_FILL,
@@ -528,6 +530,7 @@ function resolveRefLineAnnotation(
528
530
 
529
531
  return {
530
532
  type: 'refline',
533
+ id: annotation.id,
531
534
  line: { start, end },
532
535
  label,
533
536
  stroke: annotation.stroke ?? defaultStroke,
@@ -667,7 +670,7 @@ function nudgeAnnotationFromObstacles(
667
670
  // need to shift into that space to avoid marks or the brand watermark.
668
671
  const inBounds =
669
672
  labelCenterX >= chartArea.x &&
670
- labelCenterX <= chartArea.x + chartArea.width + 100 &&
673
+ labelCenterX <= chartArea.x + chartArea.width + 10 &&
671
674
  labelCenterY >= chartArea.y - fontSize &&
672
675
  labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
673
676
 
@@ -755,7 +758,7 @@ function resolveAnnotationCollisions(
755
758
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
756
759
  const inBounds =
757
760
  labelCenterX >= chartArea.x &&
758
- labelCenterX <= chartArea.x + chartArea.width + 100 &&
761
+ labelCenterX <= chartArea.x + chartArea.width + 10 &&
759
762
  labelCenterY >= chartArea.y - fontSize &&
760
763
  labelCenterY <= chartArea.y + chartArea.height + fontSize;
761
764
 
@@ -799,6 +802,81 @@ function resolveAnnotationCollisions(
799
802
  }
800
803
  }
801
804
 
805
+ // ---------------------------------------------------------------------------
806
+ // Boundary clamping
807
+ // ---------------------------------------------------------------------------
808
+
809
+ /** Small inset margin so labels don't touch the SVG edge. */
810
+ const CLAMP_MARGIN = 4;
811
+
812
+ /**
813
+ * Shift text annotation labels so they stay within the total SVG bounds.
814
+ * If a label overflows the right, left, top, or bottom edge, its position
815
+ * is adjusted inward by the overflow amount. Connector geometry is updated
816
+ * to match.
817
+ */
818
+ function clampAnnotationsToBounds(
819
+ annotations: ResolvedAnnotation[],
820
+ svgWidth: number,
821
+ svgHeight: number,
822
+ ): void {
823
+ for (const annotation of annotations) {
824
+ if (annotation.type !== 'text' || !annotation.label) continue;
825
+
826
+ const bounds = estimateLabelBounds(annotation.label);
827
+ let dx = 0;
828
+ let dy = 0;
829
+
830
+ // Right overflow
831
+ if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
832
+ dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
833
+ }
834
+ // Left overflow
835
+ if (bounds.x + dx < CLAMP_MARGIN) {
836
+ dx = CLAMP_MARGIN - bounds.x;
837
+ }
838
+ // Top overflow
839
+ if (bounds.y < CLAMP_MARGIN) {
840
+ dy = CLAMP_MARGIN - bounds.y;
841
+ }
842
+ // Bottom overflow
843
+ if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
844
+ dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
845
+ }
846
+
847
+ if (dx === 0 && dy === 0) continue;
848
+
849
+ const newX = annotation.label.x + dx;
850
+ const newY = annotation.label.y + dy;
851
+
852
+ // Update connector origin if present
853
+ let newConnector = annotation.label.connector;
854
+ if (newConnector) {
855
+ const fontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
856
+ const fontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
857
+ const connStyle = newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
858
+ const newFrom = computeConnectorOrigin(
859
+ newX,
860
+ newY,
861
+ annotation.label.text,
862
+ fontSize,
863
+ fontWeight,
864
+ newConnector.to.x,
865
+ newConnector.to.y,
866
+ connStyle,
867
+ );
868
+ newConnector = { ...newConnector, from: newFrom };
869
+ }
870
+
871
+ annotation.label = {
872
+ ...annotation.label,
873
+ x: newX,
874
+ y: newY,
875
+ connector: newConnector,
876
+ };
877
+ }
878
+ }
879
+
802
880
  // ---------------------------------------------------------------------------
803
881
  // Public API
804
882
  // ---------------------------------------------------------------------------
@@ -814,6 +892,7 @@ function resolveAnnotationCollisions(
814
892
  * that overlap with them are automatically repositioned using alternate
815
893
  * anchor directions. After individual obstacle avoidance, annotation-to-
816
894
  * annotation collisions are resolved using a greedy placement algorithm.
895
+ * Finally, labels are clamped to stay within the total SVG bounds.
817
896
  */
818
897
  export function computeAnnotations(
819
898
  spec: NormalizedChartSpec,
@@ -822,6 +901,7 @@ export function computeAnnotations(
822
901
  strategy: LayoutStrategy,
823
902
  isDark = false,
824
903
  obstacles: Rect[] = [],
904
+ svgDimensions?: { width: number; height: number },
825
905
  ): ResolvedAnnotation[] {
826
906
  // At compact breakpoints, skip all annotations
827
907
  if (strategy.annotationPosition === 'tooltip-only') {
@@ -857,6 +937,11 @@ export function computeAnnotations(
857
937
  // Resolve annotation-to-annotation collisions (greedy, order-preserving)
858
938
  resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
859
939
 
940
+ // Clamp labels that overflow the SVG boundary back inside
941
+ if (svgDimensions) {
942
+ clampAnnotationsToBounds(annotations, svgDimensions.width, svgDimensions.height);
943
+ }
944
+
860
945
  // Sort by zIndex (lower first, undefined treated as 0)
861
946
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
862
947
 
@@ -73,3 +73,115 @@ describe('computeBarLabels density modes', () => {
73
73
  expect(withDefault.length).toBe(withAuto.length);
74
74
  });
75
75
  });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Stacked bar segment-fit tests (BUG-4)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function makeStackedMark(index: number, value: number, width: number): RectMark {
82
+ return {
83
+ type: 'rect',
84
+ x: index * 50,
85
+ y: 0,
86
+ width,
87
+ height: 25,
88
+ fill: '#4e79a7',
89
+ cornerRadius: 0, // cornerRadius 0 = stacked segment
90
+ data: { category: `Cat${index}`, value },
91
+ aria: { label: `Cat${index}: ${value}` },
92
+ };
93
+ }
94
+
95
+ describe('stacked bar label segment-fit', () => {
96
+ it('density "all" hides labels that do not fit in narrow stacked segments', () => {
97
+ const stackedMarks: RectMark[] = [
98
+ // Narrow segment: 30px wide, label for "8003" will be wider than 30 - 12 = 18px
99
+ makeStackedMark(0, 8003, 30),
100
+ // Wide segment: 200px wide, label should fit easily
101
+ makeStackedMark(1, 5000, 200),
102
+ ];
103
+ const labels = computeBarLabels(stackedMarks, chartArea, 'all');
104
+ expect(labels).toHaveLength(2);
105
+ // Narrow segment label should be hidden
106
+ expect(labels[0].visible).toBe(false);
107
+ // Wide segment label should still be visible
108
+ expect(labels[1].visible).toBe(true);
109
+ });
110
+
111
+ it('density "auto" hides labels that do not fit in narrow stacked segments', () => {
112
+ const stackedMarks: RectMark[] = [makeStackedMark(0, 8003, 30), makeStackedMark(1, 5000, 200)];
113
+ const labels = computeBarLabels(stackedMarks, chartArea, 'auto');
114
+ expect(labels).toHaveLength(2);
115
+ // Narrow segment label should be hidden
116
+ expect(labels[0].visible).toBe(false);
117
+ // Wide segment label should still be visible
118
+ expect(labels[1].visible).toBe(true);
119
+ });
120
+
121
+ it('non-stacked bars still show labels regardless of width', () => {
122
+ // Non-stacked marks (no cornerRadius = undefined, not 0)
123
+ const nonStackedMarks: RectMark[] = [
124
+ makeMark(0, 10), // width = 50
125
+ makeMark(1, 20), // width = 100
126
+ ];
127
+ const labels = computeBarLabels(nonStackedMarks, chartArea, 'all');
128
+ expect(labels).toHaveLength(2);
129
+ expect(labels.every((l) => l.visible === true)).toBe(true);
130
+ });
131
+
132
+ it('wide stacked segments still show labels', () => {
133
+ const wideStacked: RectMark[] = [makeStackedMark(0, 42, 200), makeStackedMark(1, 99, 200)];
134
+ const labels = computeBarLabels(wideStacked, chartArea, 'all');
135
+ expect(labels).toHaveLength(2);
136
+ expect(labels.every((l) => l.visible === true)).toBe(true);
137
+ });
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Format with abbreviated aria values (BUG-3)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ describe('computeBarLabels with $~s format and abbreviated aria values', () => {
145
+ function makeAbbreviatedMark(index: number, value: number, abbr: string): RectMark {
146
+ return {
147
+ type: 'rect',
148
+ x: 0,
149
+ y: index * 30,
150
+ width: 200,
151
+ height: 25,
152
+ fill: '#4e79a7',
153
+ data: { category: `Cat${index}`, value },
154
+ aria: { label: `Cat${index}: ${abbr}` },
155
+ };
156
+ }
157
+
158
+ it('$~s format preserves SI suffix for low thousands', () => {
159
+ const thousandMarks: RectMark[] = [
160
+ makeAbbreviatedMark(0, 6000, '6K'),
161
+ makeAbbreviatedMark(1, 7000, '7K'),
162
+ makeAbbreviatedMark(2, 14000, '14K'),
163
+ ];
164
+
165
+ const labels = computeBarLabels(thousandMarks, chartArea, 'all', '$~s');
166
+ expect(labels).toHaveLength(3);
167
+ expect(labels[0].text).toBe('$6k');
168
+ expect(labels[1].text).toBe('$7k');
169
+ expect(labels[2].text).toBe('$14k');
170
+ });
171
+
172
+ it('$~s format works for millions', () => {
173
+ const millionMarks: RectMark[] = [makeAbbreviatedMark(0, 1500000, '1.5M')];
174
+
175
+ const labels = computeBarLabels(millionMarks, chartArea, 'all', '$~s');
176
+ expect(labels).toHaveLength(1);
177
+ expect(labels[0].text).toBe('$1.5M');
178
+ });
179
+
180
+ it('handles comma-formatted aria values', () => {
181
+ const commaMarks: RectMark[] = [makeAbbreviatedMark(0, 500, '500')];
182
+
183
+ const labels = computeBarLabels(commaMarks, chartArea, 'all', '$,.0f');
184
+ expect(labels).toHaveLength(1);
185
+ expect(labels[0].text).toBe('$500');
186
+ });
187
+ });
@@ -23,6 +23,40 @@ import {
23
23
  resolveCollisions,
24
24
  } from '@opendata-ai/openchart-core';
25
25
 
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
31
+ const SUFFIX_MULTIPLIERS: Record<string, number> = {
32
+ K: 1_000,
33
+ M: 1_000_000,
34
+ B: 1_000_000_000,
35
+ T: 1_000_000_000_000,
36
+ };
37
+
38
+ /**
39
+ * Parse a numeric string that may include comma separators and/or a
40
+ * K/M/B/T abbreviation suffix (as produced by `abbreviateNumber`).
41
+ * Returns NaN when the string cannot be parsed.
42
+ */
43
+ function parseDisplayNumber(raw: string): number {
44
+ const trimmed = raw.trim();
45
+ if (!trimmed) return NaN;
46
+
47
+ // Check for trailing abbreviation suffix (case-insensitive)
48
+ const last = trimmed[trimmed.length - 1].toUpperCase();
49
+ const multiplier = SUFFIX_MULTIPLIERS[last];
50
+ if (multiplier) {
51
+ const numPart = trimmed.slice(0, -1).replace(/,/g, '');
52
+ const n = Number(numPart);
53
+ return Number.isNaN(n) ? NaN : n * multiplier;
54
+ }
55
+
56
+ // No suffix — strip commas and parse
57
+ return Number(trimmed.replace(/,/g, ''));
58
+ }
59
+
26
60
  // ---------------------------------------------------------------------------
27
61
  // Constants
28
62
  // ---------------------------------------------------------------------------
@@ -57,6 +91,9 @@ export function computeBarLabels(
57
91
  density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
58
92
 
59
93
  const candidates: LabelCandidate[] = [];
94
+ // Track whether each candidate fits within its stacked segment.
95
+ // Non-stacked bars are always considered fitting (undefined = fits).
96
+ const fitsInSegment: boolean[] = [];
60
97
 
61
98
  const formatter = buildD3Formatter(labelFormat);
62
99
 
@@ -72,7 +109,7 @@ export function computeBarLabels(
72
109
  // Apply label format if provided (re-parse the number from the aria string)
73
110
  let valuePart = rawValue;
74
111
  if (formatter) {
75
- const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
112
+ const num = parseDisplayNumber(rawValue);
76
113
  if (!Number.isNaN(num)) valuePart = formatter(num);
77
114
  }
78
115
 
@@ -110,6 +147,10 @@ export function computeBarLabels(
110
147
  // SVG places the text center at this y coordinate.
111
148
  const anchorY = mark.y + mark.height / 2;
112
149
 
150
+ // Check if label text fits within the stacked segment
151
+ const fits = !(isStacked && textWidth > mark.width - 2 * LABEL_PADDING);
152
+
153
+ fitsInSegment.push(fits);
113
154
  candidates.push({
114
155
  text: valuePart,
115
156
  anchorX,
@@ -132,15 +173,47 @@ export function computeBarLabels(
132
173
  if (candidates.length === 0) return [];
133
174
 
134
175
  // 'all': skip collision detection, mark everything visible
176
+ // (but hide labels that don't fit in their stacked segment)
135
177
  if (density === 'all') {
136
- return candidates.map((c) => ({
178
+ return candidates.map((c, i) => ({
137
179
  text: c.text,
138
180
  x: c.anchorX,
139
181
  y: c.anchorY,
140
182
  style: c.style,
141
- visible: true,
183
+ visible: fitsInSegment[i] !== false,
142
184
  }));
143
185
  }
144
186
 
145
- return resolveCollisions(candidates);
187
+ // For 'auto' and 'endpoints': pre-mark candidates that don't fit their
188
+ // stacked segment as hidden before running collision detection.
189
+ const fittingCandidates: LabelCandidate[] = [];
190
+ const unfittingIndices = new Set<number>();
191
+ for (let i = 0; i < candidates.length; i++) {
192
+ if (fitsInSegment[i] === false) {
193
+ unfittingIndices.add(i);
194
+ } else {
195
+ fittingCandidates.push(candidates[i]);
196
+ }
197
+ }
198
+
199
+ const resolved = resolveCollisions(fittingCandidates);
200
+
201
+ // Re-insert hidden labels for candidates that didn't fit, preserving order
202
+ const results: ResolvedLabel[] = [];
203
+ let resolvedIdx = 0;
204
+ for (let i = 0; i < candidates.length; i++) {
205
+ if (unfittingIndices.has(i)) {
206
+ results.push({
207
+ text: candidates[i].text,
208
+ x: candidates[i].anchorX,
209
+ y: candidates[i].anchorY,
210
+ style: candidates[i].style,
211
+ visible: false,
212
+ });
213
+ } else {
214
+ results.push(resolved[resolvedIdx++]);
215
+ }
216
+ }
217
+
218
+ return results;
146
219
  }
@@ -19,7 +19,11 @@ import type {
19
19
  LineMark,
20
20
  ResolvedLabel,
21
21
  } from '@opendata-ai/openchart-core';
22
- import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
22
+ import {
23
+ EXTENDED_OFFSET_STRATEGIES,
24
+ estimateTextWidth,
25
+ resolveCollisions,
26
+ } from '@opendata-ai/openchart-core';
23
27
 
24
28
  // ---------------------------------------------------------------------------
25
29
  // Constants
@@ -127,7 +131,7 @@ export function computeLineLabels(
127
131
  // label (which is what we already compute). This is the same as 'auto' for lines
128
132
  // since we only compute the endpoint label per series.
129
133
 
130
- const resolved = resolveCollisions(candidates);
134
+ const resolved = resolveCollisions(candidates, EXTENDED_OFFSET_STRATEGIES);
131
135
  for (let i = 0; i < resolved.length; i++) {
132
136
  const seriesKey = seriesOrder[i];
133
137
  const label = resolved[i];