@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/dist/index.js +203 -20
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +102 -0
- package/src/annotations/__tests__/compute.test.ts +107 -0
- package/src/annotations/compute.ts +87 -2
- package/src/charts/bar/__tests__/labels.test.ts +112 -0
- package/src/charts/bar/labels.ts +77 -4
- package/src/charts/line/labels.ts +6 -2
- package/src/charts/scatter/__tests__/compute.test.ts +121 -0
- package/src/charts/scatter/compute.ts +63 -12
- package/src/compile.ts +2 -0
- package/src/compiler/__tests__/validate.test.ts +34 -0
- package/src/compiler/validate.ts +34 -0
- package/src/layout/dimensions.ts +1 -0
- package/src/layout/scales.ts +4 -1
- package/src/legend/compute.ts +22 -2
- package/src/tooltips/__tests__/compute.test.ts +61 -0
- package/src/tooltips/compute.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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 +
|
|
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 +
|
|
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
|
+
});
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
183
|
+
visible: fitsInSegment[i] !== false,
|
|
142
184
|
}));
|
|
143
185
|
}
|
|
144
186
|
|
|
145
|
-
|
|
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 {
|
|
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];
|