@opendata-ai/openchart-engine 6.2.1 → 6.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.2.1",
3
+ "version": "6.3.0",
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.3.0",
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
  });
@@ -667,7 +667,7 @@ function nudgeAnnotationFromObstacles(
667
667
  // need to shift into that space to avoid marks or the brand watermark.
668
668
  const inBounds =
669
669
  labelCenterX >= chartArea.x &&
670
- labelCenterX <= chartArea.x + chartArea.width + 100 &&
670
+ labelCenterX <= chartArea.x + chartArea.width + 10 &&
671
671
  labelCenterY >= chartArea.y - fontSize &&
672
672
  labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
673
673
 
@@ -755,7 +755,7 @@ function resolveAnnotationCollisions(
755
755
  const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
756
756
  const inBounds =
757
757
  labelCenterX >= chartArea.x &&
758
- labelCenterX <= chartArea.x + chartArea.width + 100 &&
758
+ labelCenterX <= chartArea.x + chartArea.width + 10 &&
759
759
  labelCenterY >= chartArea.y - fontSize &&
760
760
  labelCenterY <= chartArea.y + chartArea.height + fontSize;
761
761
 
@@ -799,6 +799,81 @@ function resolveAnnotationCollisions(
799
799
  }
800
800
  }
801
801
 
802
+ // ---------------------------------------------------------------------------
803
+ // Boundary clamping
804
+ // ---------------------------------------------------------------------------
805
+
806
+ /** Small inset margin so labels don't touch the SVG edge. */
807
+ const CLAMP_MARGIN = 4;
808
+
809
+ /**
810
+ * Shift text annotation labels so they stay within the total SVG bounds.
811
+ * If a label overflows the right, left, top, or bottom edge, its position
812
+ * is adjusted inward by the overflow amount. Connector geometry is updated
813
+ * to match.
814
+ */
815
+ function clampAnnotationsToBounds(
816
+ annotations: ResolvedAnnotation[],
817
+ svgWidth: number,
818
+ svgHeight: number,
819
+ ): void {
820
+ for (const annotation of annotations) {
821
+ if (annotation.type !== 'text' || !annotation.label) continue;
822
+
823
+ const bounds = estimateLabelBounds(annotation.label);
824
+ let dx = 0;
825
+ let dy = 0;
826
+
827
+ // Right overflow
828
+ if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
829
+ dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
830
+ }
831
+ // Left overflow
832
+ if (bounds.x + dx < CLAMP_MARGIN) {
833
+ dx = CLAMP_MARGIN - bounds.x;
834
+ }
835
+ // Top overflow
836
+ if (bounds.y < CLAMP_MARGIN) {
837
+ dy = CLAMP_MARGIN - bounds.y;
838
+ }
839
+ // Bottom overflow
840
+ if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
841
+ dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
842
+ }
843
+
844
+ if (dx === 0 && dy === 0) continue;
845
+
846
+ const newX = annotation.label.x + dx;
847
+ const newY = annotation.label.y + dy;
848
+
849
+ // Update connector origin if present
850
+ let newConnector = annotation.label.connector;
851
+ if (newConnector) {
852
+ const fontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
853
+ const fontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
854
+ const connStyle = newConnector.style === 'curve' ? ('curve' as const) : ('straight' as const);
855
+ const newFrom = computeConnectorOrigin(
856
+ newX,
857
+ newY,
858
+ annotation.label.text,
859
+ fontSize,
860
+ fontWeight,
861
+ newConnector.to.x,
862
+ newConnector.to.y,
863
+ connStyle,
864
+ );
865
+ newConnector = { ...newConnector, from: newFrom };
866
+ }
867
+
868
+ annotation.label = {
869
+ ...annotation.label,
870
+ x: newX,
871
+ y: newY,
872
+ connector: newConnector,
873
+ };
874
+ }
875
+ }
876
+
802
877
  // ---------------------------------------------------------------------------
803
878
  // Public API
804
879
  // ---------------------------------------------------------------------------
@@ -814,6 +889,7 @@ function resolveAnnotationCollisions(
814
889
  * that overlap with them are automatically repositioned using alternate
815
890
  * anchor directions. After individual obstacle avoidance, annotation-to-
816
891
  * annotation collisions are resolved using a greedy placement algorithm.
892
+ * Finally, labels are clamped to stay within the total SVG bounds.
817
893
  */
818
894
  export function computeAnnotations(
819
895
  spec: NormalizedChartSpec,
@@ -822,6 +898,7 @@ export function computeAnnotations(
822
898
  strategy: LayoutStrategy,
823
899
  isDark = false,
824
900
  obstacles: Rect[] = [],
901
+ svgDimensions?: { width: number; height: number },
825
902
  ): ResolvedAnnotation[] {
826
903
  // At compact breakpoints, skip all annotations
827
904
  if (strategy.annotationPosition === 'tooltip-only') {
@@ -857,6 +934,11 @@ export function computeAnnotations(
857
934
  // Resolve annotation-to-annotation collisions (greedy, order-preserving)
858
935
  resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
859
936
 
937
+ // Clamp labels that overflow the SVG boundary back inside
938
+ if (svgDimensions) {
939
+ clampAnnotationsToBounds(annotations, svgDimensions.width, svgDimensions.height);
940
+ }
941
+
860
942
  // Sort by zIndex (lower first, undefined treated as 0)
861
943
  annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
862
944
 
@@ -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];