@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/dist/index.js +200 -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 +84 -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.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.
|
|
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 +
|
|
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 +
|
|
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
|
+
});
|
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];
|