@opendata-ai/openchart-engine 7.0.3 → 7.0.4
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 +13 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -6
- package/src/charts/bar/__tests__/compute.test.ts +58 -56
- package/src/charts/bar/__tests__/labels.test.ts +3 -2
- package/src/charts/bar/compute.ts +8 -4
- package/src/charts/bar/labels.ts +5 -5
- package/src/charts/column/__tests__/compute.test.ts +22 -21
- package/src/charts/column/compute.ts +8 -3
- package/src/endpoint-labels/compute.ts +1 -2
- package/src/layout/scales.ts +8 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.4",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@opendata-ai/openchart-core": "7.0.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.0.4",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -227,7 +227,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
227
227
|
"connector": undefined,
|
|
228
228
|
"style": {
|
|
229
229
|
"dominantBaseline": "central",
|
|
230
|
-
"fill": "#
|
|
230
|
+
"fill": "#111111",
|
|
231
231
|
"fontFamily": "system-ui, -apple-system, sans-serif",
|
|
232
232
|
"fontSize": 11,
|
|
233
233
|
"fontWeight": 600,
|
|
@@ -260,7 +260,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
260
260
|
"connector": undefined,
|
|
261
261
|
"style": {
|
|
262
262
|
"dominantBaseline": "central",
|
|
263
|
-
"fill": "#
|
|
263
|
+
"fill": "#111111",
|
|
264
264
|
"fontFamily": "system-ui, -apple-system, sans-serif",
|
|
265
265
|
"fontSize": 11,
|
|
266
266
|
"fontWeight": 600,
|
|
@@ -293,7 +293,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
293
293
|
"connector": undefined,
|
|
294
294
|
"style": {
|
|
295
295
|
"dominantBaseline": "central",
|
|
296
|
-
"fill": "#
|
|
296
|
+
"fill": "#111111",
|
|
297
297
|
"fontFamily": "system-ui, -apple-system, sans-serif",
|
|
298
298
|
"fontSize": 11,
|
|
299
299
|
"fontWeight": 600,
|
|
@@ -389,7 +389,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
389
389
|
"#2166ac",
|
|
390
390
|
],
|
|
391
391
|
},
|
|
392
|
-
"gridline": "rgba(0,0,0,0.
|
|
392
|
+
"gridline": "rgba(0,0,0,0.1)",
|
|
393
393
|
"negative": "#dc2626",
|
|
394
394
|
"positive": "#16a34a",
|
|
395
395
|
"sequential": {
|
|
@@ -1359,7 +1359,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
1359
1359
|
"#2166ac",
|
|
1360
1360
|
],
|
|
1361
1361
|
},
|
|
1362
|
-
"gridline": "rgba(0,0,0,0.
|
|
1362
|
+
"gridline": "rgba(0,0,0,0.1)",
|
|
1363
1363
|
"negative": "#dc2626",
|
|
1364
1364
|
"positive": "#16a34a",
|
|
1365
1365
|
"sequential": {
|
|
@@ -1948,7 +1948,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
|
|
|
1948
1948
|
"#2166ac",
|
|
1949
1949
|
],
|
|
1950
1950
|
},
|
|
1951
|
-
"gridline": "rgba(0,0,0,0.
|
|
1951
|
+
"gridline": "rgba(0,0,0,0.1)",
|
|
1952
1952
|
"negative": "#dc2626",
|
|
1953
1953
|
"positive": "#16a34a",
|
|
1954
1954
|
"sequential": {
|
|
@@ -143,9 +143,15 @@ describe('computeBarMarks', () => {
|
|
|
143
143
|
});
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
describe('stacked bars', () => {
|
|
147
|
-
|
|
146
|
+
describe('stacked bars (stack: zero)', () => {
|
|
147
|
+
function makeStackedBarSpec(): NormalizedChartSpec {
|
|
148
148
|
const spec = makeGroupedBarSpec();
|
|
149
|
+
(spec.encoding.x as { stack?: string }).stack = 'zero';
|
|
150
|
+
return spec;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
it('produces marks for all data rows', () => {
|
|
154
|
+
const spec = makeStackedBarSpec();
|
|
149
155
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
150
156
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
151
157
|
|
|
@@ -154,7 +160,7 @@ describe('computeBarMarks', () => {
|
|
|
154
160
|
});
|
|
155
161
|
|
|
156
162
|
it('segments within a category have different colors', () => {
|
|
157
|
-
const spec =
|
|
163
|
+
const spec = makeStackedBarSpec();
|
|
158
164
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
159
165
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
160
166
|
|
|
@@ -165,7 +171,7 @@ describe('computeBarMarks', () => {
|
|
|
165
171
|
});
|
|
166
172
|
|
|
167
173
|
it('stacked segments share the same y position within a category', () => {
|
|
168
|
-
const spec =
|
|
174
|
+
const spec = makeStackedBarSpec();
|
|
169
175
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
170
176
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
171
177
|
|
|
@@ -181,7 +187,7 @@ describe('computeBarMarks', () => {
|
|
|
181
187
|
});
|
|
182
188
|
|
|
183
189
|
it('stacked segments are placed end-to-end horizontally', () => {
|
|
184
|
-
const spec =
|
|
190
|
+
const spec = makeStackedBarSpec();
|
|
185
191
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
186
192
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
187
193
|
|
|
@@ -193,7 +199,7 @@ describe('computeBarMarks', () => {
|
|
|
193
199
|
});
|
|
194
200
|
|
|
195
201
|
it('stacked bars have zero corner radius', () => {
|
|
196
|
-
const spec =
|
|
202
|
+
const spec = makeStackedBarSpec();
|
|
197
203
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
198
204
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
199
205
|
|
|
@@ -203,15 +209,9 @@ describe('computeBarMarks', () => {
|
|
|
203
209
|
});
|
|
204
210
|
});
|
|
205
211
|
|
|
206
|
-
describe('grouped bars (
|
|
207
|
-
function makeDodgedBarSpec(): NormalizedChartSpec {
|
|
208
|
-
const spec = makeGroupedBarSpec();
|
|
209
|
-
(spec.encoding.x as { stack?: boolean | null }).stack = null;
|
|
210
|
-
return spec;
|
|
211
|
-
}
|
|
212
|
-
|
|
212
|
+
describe('grouped bars (default)', () => {
|
|
213
213
|
it('produces marks for all data rows', () => {
|
|
214
|
-
const spec =
|
|
214
|
+
const spec = makeGroupedBarSpec();
|
|
215
215
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
216
216
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
217
217
|
|
|
@@ -219,7 +219,7 @@ describe('computeBarMarks', () => {
|
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
it('grouped bars within a category have different y positions', () => {
|
|
222
|
-
const spec =
|
|
222
|
+
const spec = makeGroupedBarSpec();
|
|
223
223
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
224
224
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
225
225
|
|
|
@@ -234,7 +234,7 @@ describe('computeBarMarks', () => {
|
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
it('grouped bars all start from baseline (not cumulative)', () => {
|
|
237
|
-
const spec =
|
|
237
|
+
const spec = makeGroupedBarSpec();
|
|
238
238
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
239
239
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
240
240
|
|
|
@@ -250,7 +250,7 @@ describe('computeBarMarks', () => {
|
|
|
250
250
|
});
|
|
251
251
|
|
|
252
252
|
it('grouped bars have cornerRadius 2', () => {
|
|
253
|
-
const spec =
|
|
253
|
+
const spec = makeGroupedBarSpec();
|
|
254
254
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
255
255
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
256
256
|
|
|
@@ -260,7 +260,7 @@ describe('computeBarMarks', () => {
|
|
|
260
260
|
});
|
|
261
261
|
|
|
262
262
|
it('grouped bars do not set stackGroup', () => {
|
|
263
|
-
const spec =
|
|
263
|
+
const spec = makeGroupedBarSpec();
|
|
264
264
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
265
265
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
266
266
|
|
|
@@ -269,22 +269,23 @@ describe('computeBarMarks', () => {
|
|
|
269
269
|
}
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it('sub-band heights are smaller than full bandwidth', () => {
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const
|
|
272
|
+
it('sub-band heights are smaller than full bandwidth (stacked bars use full height)', () => {
|
|
273
|
+
const groupedSpec = makeGroupedBarSpec();
|
|
274
|
+
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
275
|
+
const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
|
|
276
276
|
|
|
277
277
|
// With 2 groups, each sub-bar should be less than the full bandwidth
|
|
278
278
|
const stackedSpec = makeGroupedBarSpec();
|
|
279
|
+
(stackedSpec.encoding.x as { stack?: string }).stack = 'zero';
|
|
279
280
|
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
280
281
|
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
281
282
|
|
|
282
|
-
expect(
|
|
283
|
-
expect(
|
|
283
|
+
expect(groupedMarks[0].height).toBeLessThan(stackedMarks[0].height);
|
|
284
|
+
expect(groupedMarks[0].height).toBeGreaterThan(0);
|
|
284
285
|
});
|
|
285
286
|
|
|
286
287
|
it('scale domain covers max individual value, not stacked sum', () => {
|
|
287
|
-
const spec =
|
|
288
|
+
const spec = makeGroupedBarSpec();
|
|
288
289
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
289
290
|
|
|
290
291
|
// Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
|
|
@@ -395,7 +396,7 @@ describe('computeBarMarks', () => {
|
|
|
395
396
|
{ size: '5,000+ employees', year: '2022', pay: 74800 },
|
|
396
397
|
];
|
|
397
398
|
|
|
398
|
-
function makeWageSpec(
|
|
399
|
+
function makeWageSpec(stackZero = false): NormalizedChartSpec {
|
|
399
400
|
return {
|
|
400
401
|
markType: 'bar',
|
|
401
402
|
markDef: { type: 'bar' },
|
|
@@ -404,7 +405,7 @@ describe('computeBarMarks', () => {
|
|
|
404
405
|
x: {
|
|
405
406
|
field: 'pay',
|
|
406
407
|
type: 'quantitative',
|
|
407
|
-
...(
|
|
408
|
+
...(stackZero ? { stack: 'zero' } : {}),
|
|
408
409
|
},
|
|
409
410
|
y: { field: 'size', type: 'nominal' },
|
|
410
411
|
color: { field: 'year', type: 'nominal' },
|
|
@@ -418,7 +419,7 @@ describe('computeBarMarks', () => {
|
|
|
418
419
|
};
|
|
419
420
|
}
|
|
420
421
|
|
|
421
|
-
it('
|
|
422
|
+
it('groups by default: bars sit at different y positions within each category', () => {
|
|
422
423
|
const spec = makeWageSpec(false);
|
|
423
424
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
424
425
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
@@ -426,6 +427,30 @@ describe('computeBarMarks', () => {
|
|
|
426
427
|
// 2 firm sizes × 2 years = 4 bars
|
|
427
428
|
expect(marks).toHaveLength(4);
|
|
428
429
|
|
|
430
|
+
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
431
|
+
expect(smallFirmMarks).toHaveLength(2);
|
|
432
|
+
expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('groups by default: scale domain covers max individual value, not stacked sum', () => {
|
|
436
|
+
const spec = makeWageSpec(false);
|
|
437
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
438
|
+
|
|
439
|
+
// Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
|
|
440
|
+
// Default grouped bars should NOT extend domain to 137100.
|
|
441
|
+
const xScale = scales.x!.scale;
|
|
442
|
+
const domain = xScale.domain() as number[];
|
|
443
|
+
expect(domain[1]).toBeLessThan(137100);
|
|
444
|
+
expect(domain[1]).toBeGreaterThanOrEqual(74800);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('stacked with stack:zero: segments are contiguous end-to-end within each category', () => {
|
|
448
|
+
const spec = makeWageSpec(true);
|
|
449
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
450
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
451
|
+
|
|
452
|
+
expect(marks).toHaveLength(4);
|
|
453
|
+
|
|
429
454
|
// For stacked bars, the second segment starts exactly where the first ends.
|
|
430
455
|
const smallFirmMarks = marks
|
|
431
456
|
.filter((m) => m.aria.label.includes('<5'))
|
|
@@ -434,8 +459,8 @@ describe('computeBarMarks', () => {
|
|
|
434
459
|
expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
|
|
435
460
|
});
|
|
436
461
|
|
|
437
|
-
it('
|
|
438
|
-
const spec = makeWageSpec(
|
|
462
|
+
it('stacked with stack:zero: segments share the same y position (stacked on same row)', () => {
|
|
463
|
+
const spec = makeWageSpec(true);
|
|
439
464
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
440
465
|
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
441
466
|
|
|
@@ -444,12 +469,12 @@ describe('computeBarMarks', () => {
|
|
|
444
469
|
expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
|
|
445
470
|
});
|
|
446
471
|
|
|
447
|
-
it('grouped
|
|
448
|
-
const stackedSpec = makeWageSpec(
|
|
472
|
+
it('grouped vs stacked: grouped bars each start from baseline', () => {
|
|
473
|
+
const stackedSpec = makeWageSpec(true);
|
|
449
474
|
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
450
475
|
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
451
476
|
|
|
452
|
-
const groupedSpec = makeWageSpec(
|
|
477
|
+
const groupedSpec = makeWageSpec(false);
|
|
453
478
|
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
454
479
|
const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
|
|
455
480
|
|
|
@@ -463,29 +488,6 @@ describe('computeBarMarks', () => {
|
|
|
463
488
|
const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
464
489
|
expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
|
|
465
490
|
});
|
|
466
|
-
|
|
467
|
-
it('grouped with stack:null: bars sit at different y positions within each category', () => {
|
|
468
|
-
const spec = makeWageSpec(true);
|
|
469
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
470
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
471
|
-
|
|
472
|
-
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
473
|
-
expect(smallFirmMarks).toHaveLength(2);
|
|
474
|
-
expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
it('grouped with stack:null: scale domain covers max individual value, not stacked sum', () => {
|
|
478
|
-
const spec = makeWageSpec(true);
|
|
479
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
480
|
-
|
|
481
|
-
// Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
|
|
482
|
-
// With stack:null the domain should NOT reach 137100.
|
|
483
|
-
const xScale = scales.x!.scale;
|
|
484
|
-
const domain = xScale.domain() as number[];
|
|
485
|
-
expect(domain[1]).toBeLessThan(137100);
|
|
486
|
-
// But it should cover the max individual value
|
|
487
|
-
expect(domain[1]).toBeGreaterThanOrEqual(74800);
|
|
488
|
-
});
|
|
489
491
|
});
|
|
490
492
|
|
|
491
493
|
describe('edge cases', () => {
|
|
@@ -86,7 +86,8 @@ function makeStackedMark(index: number, value: number, width: number): RectMark
|
|
|
86
86
|
width,
|
|
87
87
|
height: 25,
|
|
88
88
|
fill: '#4e79a7',
|
|
89
|
-
cornerRadius: 0,
|
|
89
|
+
cornerRadius: 0,
|
|
90
|
+
stackGroup: `Cat${index}`,
|
|
90
91
|
data: { category: `Cat${index}`, value },
|
|
91
92
|
aria: { label: `Cat${index}: ${value}` },
|
|
92
93
|
};
|
|
@@ -119,7 +120,7 @@ describe('stacked bar label segment-fit', () => {
|
|
|
119
120
|
});
|
|
120
121
|
|
|
121
122
|
it('non-stacked bars still show labels regardless of width', () => {
|
|
122
|
-
// Non-stacked marks
|
|
123
|
+
// Non-stacked marks have no stackGroup
|
|
123
124
|
const nonStackedMarks: RectMark[] = [
|
|
124
125
|
makeMark(0, 10), // width = 50
|
|
125
126
|
makeMark(1, 20), // width = 100
|
|
@@ -119,10 +119,14 @@ export function computeBarMarks(
|
|
|
119
119
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
120
120
|
|
|
121
121
|
if (needsStacking) {
|
|
122
|
-
// stack:
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
// stack: true/'zero'/'normalize'/'center' -> stacked; default (undefined/null/false) -> grouped
|
|
123
|
+
const stackEnabled =
|
|
124
|
+
xChannel.stack === true ||
|
|
125
|
+
xChannel.stack === 'zero' ||
|
|
126
|
+
xChannel.stack === 'normalize' ||
|
|
127
|
+
xChannel.stack === 'center';
|
|
128
|
+
|
|
129
|
+
if (!stackEnabled) {
|
|
126
130
|
marks = computeGroupedBars(
|
|
127
131
|
spec.data,
|
|
128
132
|
xChannel.field,
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -20,8 +20,8 @@ import type {
|
|
|
20
20
|
import {
|
|
21
21
|
buildD3Formatter,
|
|
22
22
|
estimateTextWidth,
|
|
23
|
-
findAccessibleColor,
|
|
24
23
|
getRepresentativeColor,
|
|
24
|
+
pickLabelColor,
|
|
25
25
|
resolveCollisions,
|
|
26
26
|
} from '@opendata-ai/openchart-core';
|
|
27
27
|
import { filterByDensity } from '../_shared/density-filter';
|
|
@@ -136,7 +136,7 @@ export function computeBarLabels(
|
|
|
136
136
|
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
137
137
|
|
|
138
138
|
// Detect stacked bars: cornerRadius 0 indicates stacked segment
|
|
139
|
-
const isStacked = mark.
|
|
139
|
+
const isStacked = mark.stackGroup !== undefined;
|
|
140
140
|
|
|
141
141
|
// Determine if label goes inside or outside the bar
|
|
142
142
|
const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
|
|
@@ -150,18 +150,18 @@ export function computeBarLabels(
|
|
|
150
150
|
if (isStacked && isInside) {
|
|
151
151
|
// Stacked: centered within segment
|
|
152
152
|
anchorX = mark.x + mark.width / 2;
|
|
153
|
-
fill =
|
|
153
|
+
fill = pickLabelColor(bgColor);
|
|
154
154
|
textAnchor = 'middle';
|
|
155
155
|
} else if (isInside) {
|
|
156
156
|
if (isNegative) {
|
|
157
157
|
// Negative bar: left-aligned within bar (bar extends leftward)
|
|
158
158
|
anchorX = mark.x + LABEL_PADDING;
|
|
159
|
-
fill =
|
|
159
|
+
fill = pickLabelColor(bgColor);
|
|
160
160
|
textAnchor = 'start';
|
|
161
161
|
} else {
|
|
162
162
|
// Positive bar: right-aligned within bar
|
|
163
163
|
anchorX = mark.x + mark.width - LABEL_PADDING;
|
|
164
|
-
fill =
|
|
164
|
+
fill = pickLabelColor(bgColor);
|
|
165
165
|
textAnchor = 'end';
|
|
166
166
|
}
|
|
167
167
|
} else {
|
|
@@ -148,9 +148,15 @@ describe('computeColumnMarks', () => {
|
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
-
describe('stacked columns', () => {
|
|
152
|
-
|
|
151
|
+
describe('stacked columns (stack: zero)', () => {
|
|
152
|
+
function makeStackedColumnSpec(): NormalizedChartSpec {
|
|
153
153
|
const spec = makeGroupedColumnSpec();
|
|
154
|
+
(spec.encoding.y as { stack?: string }).stack = 'zero';
|
|
155
|
+
return spec;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
it('produces marks for all data rows', () => {
|
|
159
|
+
const spec = makeStackedColumnSpec();
|
|
154
160
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
155
161
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
156
162
|
|
|
@@ -159,7 +165,7 @@ describe('computeColumnMarks', () => {
|
|
|
159
165
|
});
|
|
160
166
|
|
|
161
167
|
it('stacked segments within a category have different colors', () => {
|
|
162
|
-
const spec =
|
|
168
|
+
const spec = makeStackedColumnSpec();
|
|
163
169
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
164
170
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
165
171
|
|
|
@@ -169,7 +175,7 @@ describe('computeColumnMarks', () => {
|
|
|
169
175
|
});
|
|
170
176
|
|
|
171
177
|
it('stacked columns within a category share the same x position', () => {
|
|
172
|
-
const spec =
|
|
178
|
+
const spec = makeStackedColumnSpec();
|
|
173
179
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
174
180
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
175
181
|
|
|
@@ -187,15 +193,9 @@ describe('computeColumnMarks', () => {
|
|
|
187
193
|
});
|
|
188
194
|
});
|
|
189
195
|
|
|
190
|
-
describe('grouped columns (
|
|
191
|
-
function makeDodgedColumnSpec(): NormalizedChartSpec {
|
|
192
|
-
const spec = makeGroupedColumnSpec();
|
|
193
|
-
(spec.encoding.y as { stack?: boolean | null }).stack = null;
|
|
194
|
-
return spec;
|
|
195
|
-
}
|
|
196
|
-
|
|
196
|
+
describe('grouped columns (default)', () => {
|
|
197
197
|
it('produces marks for all data rows', () => {
|
|
198
|
-
const spec =
|
|
198
|
+
const spec = makeGroupedColumnSpec();
|
|
199
199
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
200
200
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
201
201
|
|
|
@@ -203,7 +203,7 @@ describe('computeColumnMarks', () => {
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
it('grouped columns within a category have different x positions', () => {
|
|
206
|
-
const spec =
|
|
206
|
+
const spec = makeGroupedColumnSpec();
|
|
207
207
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
208
208
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
209
209
|
|
|
@@ -217,22 +217,23 @@ describe('computeColumnMarks', () => {
|
|
|
217
217
|
expect(janNorth.x).not.toBe(janSouth.x);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
-
it('grouped columns have subdivided widths', () => {
|
|
221
|
-
const
|
|
222
|
-
const
|
|
223
|
-
const
|
|
220
|
+
it('grouped columns have subdivided widths (vs stacked full bandwidth)', () => {
|
|
221
|
+
const groupedSpec = makeGroupedColumnSpec();
|
|
222
|
+
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
223
|
+
const groupedMarks = computeColumnMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
|
|
224
224
|
|
|
225
225
|
// With 2 groups, each sub-column should be narrower than full bandwidth
|
|
226
226
|
const stackedSpec = makeGroupedColumnSpec();
|
|
227
|
+
(stackedSpec.encoding.y as { stack?: string }).stack = 'zero';
|
|
227
228
|
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
228
229
|
const stackedMarks = computeColumnMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
229
230
|
|
|
230
|
-
expect(
|
|
231
|
-
expect(
|
|
231
|
+
expect(groupedMarks[0].width).toBeLessThan(stackedMarks[0].width);
|
|
232
|
+
expect(groupedMarks[0].width).toBeGreaterThan(0);
|
|
232
233
|
});
|
|
233
234
|
|
|
234
235
|
it('grouped columns have cornerRadius 2 and no stackGroup', () => {
|
|
235
|
-
const spec =
|
|
236
|
+
const spec = makeGroupedColumnSpec();
|
|
236
237
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
237
238
|
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
238
239
|
|
|
@@ -243,7 +244,7 @@ describe('computeColumnMarks', () => {
|
|
|
243
244
|
});
|
|
244
245
|
|
|
245
246
|
it('scale domain covers max individual value, not stacked sum', () => {
|
|
246
|
-
const spec =
|
|
247
|
+
const spec = makeGroupedColumnSpec();
|
|
247
248
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
248
249
|
|
|
249
250
|
// Max individual value is 150 (Mar North), not 280 (Mar stacked sum)
|
|
@@ -94,9 +94,14 @@ export function computeColumnMarks(
|
|
|
94
94
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
95
95
|
|
|
96
96
|
if (needsStacking) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
// stack: true/'zero'/'normalize'/'center' -> stacked; default (undefined/null/false) -> grouped
|
|
98
|
+
const stackEnabled =
|
|
99
|
+
yChannel.stack === true ||
|
|
100
|
+
yChannel.stack === 'zero' ||
|
|
101
|
+
yChannel.stack === 'normalize' ||
|
|
102
|
+
yChannel.stack === 'center';
|
|
103
|
+
|
|
104
|
+
if (!stackEnabled) {
|
|
100
105
|
marks = computeGroupedColumns(
|
|
101
106
|
spec.data,
|
|
102
107
|
xChannel.field,
|
|
@@ -360,7 +360,6 @@ export function computeEndpointLabels(
|
|
|
360
360
|
32,
|
|
361
361
|
);
|
|
362
362
|
const columnX = chartArea.x + chartArea.width + ENDPOINT_COLUMN_GAP;
|
|
363
|
-
const markerX = chartArea.x + chartArea.width;
|
|
364
363
|
|
|
365
364
|
// The marker is the line's visual terminator — always at (chartRightX, dataY).
|
|
366
365
|
// The swatch + label first-line baseline-center sit at `labelY + fontSize/2`.
|
|
@@ -388,7 +387,7 @@ export function computeEndpointLabels(
|
|
|
388
387
|
};
|
|
389
388
|
if (showMarker) {
|
|
390
389
|
entry.marker = {
|
|
391
|
-
x:
|
|
390
|
+
x: p.dataX,
|
|
392
391
|
y: p.dataY,
|
|
393
392
|
fill: markerFill,
|
|
394
393
|
stroke: config?.markerStyle?.stroke ?? p.color,
|
package/src/layout/scales.ts
CHANGED
|
@@ -667,12 +667,16 @@ export function computeScales(
|
|
|
667
667
|
// Without this, stacked bars would clip past the chart area.
|
|
668
668
|
let xData = data;
|
|
669
669
|
let xChannel = encoding.x;
|
|
670
|
-
const
|
|
670
|
+
const xStackEnabled =
|
|
671
|
+
encoding.x.stack === true ||
|
|
672
|
+
encoding.x.stack === 'zero' ||
|
|
673
|
+
encoding.x.stack === 'normalize' ||
|
|
674
|
+
encoding.x.stack === 'center';
|
|
671
675
|
if (
|
|
672
676
|
spec.markType === 'bar' &&
|
|
673
677
|
encoding.color &&
|
|
674
678
|
encoding.x.type === 'quantitative' &&
|
|
675
|
-
|
|
679
|
+
xStackEnabled
|
|
676
680
|
) {
|
|
677
681
|
if (encoding.x.stack === 'normalize') {
|
|
678
682
|
// Normalize: domain is [0, 1]
|
|
@@ -738,9 +742,7 @@ export function computeScales(
|
|
|
738
742
|
spec.markType === 'bar' &&
|
|
739
743
|
(encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
|
|
740
744
|
encoding.y.type === 'quantitative';
|
|
741
|
-
//
|
|
742
|
-
// overlap (v6), so the stacked-domain expansion only applies when the user
|
|
743
|
-
// explicitly opts into stacking.
|
|
745
|
+
// Both bar and area require explicit opt-in for stacked domain expansion.
|
|
744
746
|
const stackProp = encoding.y.stack;
|
|
745
747
|
const isExplicitlyStacked =
|
|
746
748
|
stackProp === true ||
|
|
@@ -748,7 +750,7 @@ export function computeScales(
|
|
|
748
750
|
stackProp === 'normalize' ||
|
|
749
751
|
stackProp === 'center';
|
|
750
752
|
const isAreaStacked = spec.markType === 'area' && isExplicitlyStacked;
|
|
751
|
-
const isBarStacked = isVerticalBar &&
|
|
753
|
+
const isBarStacked = isVerticalBar && isExplicitlyStacked;
|
|
752
754
|
|
|
753
755
|
// Sparkline tightening: drop the default `zero: true` baseline so the
|
|
754
756
|
// y-domain hugs the actual data range. Without this, a series with
|