@opendata-ai/openchart-engine 7.0.3 → 7.1.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 +69 -26
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +112 -108
- package/src/__tests__/compile-chart.test.ts +4 -1
- package/src/__tests__/dimensions.test.ts +6 -1
- 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/charts/column/labels.ts +7 -4
- package/src/charts/line/__tests__/compute.test.ts +20 -1
- package/src/charts/line/area.ts +12 -2
- package/src/charts/line/index.ts +2 -2
- package/src/endpoint-labels/__tests__/compute.test.ts +5 -1
- package/src/endpoint-labels/compute.ts +4 -2
- package/src/layout/axes/thinning.ts +4 -2
- package/src/layout/dimensions.ts +58 -4
- package/src/layout/scales.ts +8 -6
|
@@ -230,7 +230,10 @@ describe('compileChart', () => {
|
|
|
230
230
|
|
|
231
231
|
expect(light.theme.isDark).toBe(false);
|
|
232
232
|
expect(dark.theme.isDark).toBe(true);
|
|
233
|
-
|
|
233
|
+
// Both modes preserve transparent background — dark mode swaps text/axis/gridline
|
|
234
|
+
// colors but keeps transparency so the host surface shows through.
|
|
235
|
+
expect(dark.theme.colors.background).toBe('transparent');
|
|
236
|
+
expect(light.theme.colors.background).toBe('transparent');
|
|
234
237
|
// Dark mode text should be light, light mode text should be dark
|
|
235
238
|
expect(dark.theme.colors.text).not.toBe(light.theme.colors.text);
|
|
236
239
|
});
|
|
@@ -142,7 +142,12 @@ describe('computeDimensions', () => {
|
|
|
142
142
|
|
|
143
143
|
expect(lightDims.theme.isDark).toBe(false);
|
|
144
144
|
expect(darkDims.theme.isDark).toBe(true);
|
|
145
|
-
|
|
145
|
+
// Both modes use transparent background — the dark adaptation changes text/axis
|
|
146
|
+
// colors while keeping transparency so the host surface shows through.
|
|
147
|
+
expect(darkDims.theme.colors.background).toBe('transparent');
|
|
148
|
+
expect(lightDims.theme.colors.background).toBe('transparent');
|
|
149
|
+
// Dark mode uses a different text color
|
|
150
|
+
expect(darkDims.theme.colors.text).not.toBe(lightDims.theme.colors.text);
|
|
146
151
|
});
|
|
147
152
|
|
|
148
153
|
it('prevents negative chart area dimensions', () => {
|
|
@@ -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,
|
|
@@ -32,7 +32,7 @@ import { formatLabelValue } from '../_shared/format-label-value';
|
|
|
32
32
|
|
|
33
33
|
const LABEL_FONT_SIZE = 10;
|
|
34
34
|
const LABEL_FONT_WEIGHT = 600;
|
|
35
|
-
const LABEL_OFFSET_Y =
|
|
35
|
+
const LABEL_OFFSET_Y = 8;
|
|
36
36
|
|
|
37
37
|
// ---------------------------------------------------------------------------
|
|
38
38
|
// Public API
|
|
@@ -91,8 +91,11 @@ export function computeColumnLabels(
|
|
|
91
91
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
92
92
|
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
93
93
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
94
|
+
// anchorY is the TOP of the label bounding box so the collision system's
|
|
95
|
+
// AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
|
|
96
|
+
// correct. dominantBaseline 'hanging' anchors the glyph top at anchorY.
|
|
97
|
+
// Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
|
|
98
|
+
// Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
|
|
96
99
|
const anchorX = mark.x + mark.width / 2;
|
|
97
100
|
const anchorY = isNegative
|
|
98
101
|
? mark.y + mark.height + LABEL_OFFSET_Y
|
|
@@ -112,7 +115,7 @@ export function computeColumnLabels(
|
|
|
112
115
|
fill: labelColor ?? getRepresentativeColor(mark.fill),
|
|
113
116
|
lineHeight: 1.2,
|
|
114
117
|
textAnchor: 'middle',
|
|
115
|
-
dominantBaseline:
|
|
118
|
+
dominantBaseline: 'hanging',
|
|
116
119
|
},
|
|
117
120
|
});
|
|
118
121
|
}
|
|
@@ -620,12 +620,31 @@ describe('computeAreaMarks', () => {
|
|
|
620
620
|
expect(fill.gradient).toBe('linear');
|
|
621
621
|
expect(fill.stops).toHaveLength(2);
|
|
622
622
|
expect(fill.stops[0].opacity).toBe(0.65);
|
|
623
|
-
|
|
623
|
+
// Light mode: bottom fades to 0 so the colored wash at the base is avoided
|
|
624
|
+
expect(fill.stops[1].opacity).toBe(0);
|
|
624
625
|
// fillOpacity should be 1 so gradient stop-opacity controls the fade
|
|
625
626
|
expect(mark.fillOpacity).toBe(1);
|
|
626
627
|
}
|
|
627
628
|
});
|
|
628
629
|
|
|
630
|
+
it('stacked areas use higher bottom opacity in dark mode', () => {
|
|
631
|
+
const spec = makeMultiSeriesSpec();
|
|
632
|
+
spec.encoding.y!.stack = 'zero';
|
|
633
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
634
|
+
const marks = computeAreaMarks(spec, scales, chartArea, true /* darkMode */);
|
|
635
|
+
|
|
636
|
+
expect(marks.length).toBeGreaterThan(0);
|
|
637
|
+
for (const mark of marks) {
|
|
638
|
+
const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
|
|
639
|
+
expect(fill.gradient).toBe('linear');
|
|
640
|
+
expect(fill.stops).toHaveLength(2);
|
|
641
|
+
expect(fill.stops[0].opacity).toBe(0.65);
|
|
642
|
+
// Dark mode: bottom stop is 0.35 so bands remain visible on dark surfaces
|
|
643
|
+
expect(fill.stops[1].opacity).toBe(0.35);
|
|
644
|
+
expect(mark.fillOpacity).toBe(1);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
629
648
|
it('stacked: markDef.fill string still overrides per-layer gradient', () => {
|
|
630
649
|
const spec = makeMultiSeriesSpec();
|
|
631
650
|
spec.encoding.y!.stack = 'zero';
|
package/src/charts/line/area.ts
CHANGED
|
@@ -80,6 +80,13 @@ const STACKED_GRADIENT_STOPS = [
|
|
|
80
80
|
{ offset: 1, opacity: 0.35 },
|
|
81
81
|
];
|
|
82
82
|
|
|
83
|
+
// Light-mode stacked areas: bottom out at opacity 0 rather than 0.35 to avoid
|
|
84
|
+
// the muddy color wash at the base of each band on white/light backgrounds.
|
|
85
|
+
const STACKED_GRADIENT_STOPS_LIGHT = [
|
|
86
|
+
{ offset: 0, opacity: 0.65 },
|
|
87
|
+
{ offset: 1, opacity: 0 },
|
|
88
|
+
];
|
|
89
|
+
|
|
83
90
|
function buildGradientFill(
|
|
84
91
|
colorStr: string,
|
|
85
92
|
stops: ReadonlyArray<{ offset: number; opacity: number }>,
|
|
@@ -257,6 +264,7 @@ function computeStackedArea(
|
|
|
257
264
|
spec: NormalizedChartSpec,
|
|
258
265
|
scales: ResolvedScales,
|
|
259
266
|
chartArea: Rect,
|
|
267
|
+
darkMode?: boolean,
|
|
260
268
|
): AreaMark[] {
|
|
261
269
|
const encoding = spec.encoding as Encoding;
|
|
262
270
|
const xChannel = encoding.x;
|
|
@@ -389,7 +397,8 @@ function computeStackedArea(
|
|
|
389
397
|
fillOpacity = isGradientDef(markFill) ? 1 : (spec.markDef.opacity ?? 0.7);
|
|
390
398
|
} else {
|
|
391
399
|
const colorStr = getRepresentativeColor(color);
|
|
392
|
-
|
|
400
|
+
const stackedStops = darkMode ? STACKED_GRADIENT_STOPS : STACKED_GRADIENT_STOPS_LIGHT;
|
|
401
|
+
fillValue = buildGradientFill(colorStr, stackedStops);
|
|
393
402
|
fillOpacity = 1;
|
|
394
403
|
}
|
|
395
404
|
|
|
@@ -443,12 +452,13 @@ export function computeAreaMarks(
|
|
|
443
452
|
spec: NormalizedChartSpec,
|
|
444
453
|
scales: ResolvedScales,
|
|
445
454
|
chartArea: Rect,
|
|
455
|
+
darkMode?: boolean,
|
|
446
456
|
): AreaMark[] {
|
|
447
457
|
const encoding = spec.encoding as Encoding;
|
|
448
458
|
const yChannel = encoding.y;
|
|
449
459
|
|
|
450
460
|
if (yChannel && isStacked(yChannel.stack)) {
|
|
451
|
-
return computeStackedArea(spec, scales, chartArea);
|
|
461
|
+
return computeStackedArea(spec, scales, chartArea, darkMode);
|
|
452
462
|
}
|
|
453
463
|
|
|
454
464
|
return computeSingleArea(spec, scales, chartArea);
|
package/src/charts/line/index.ts
CHANGED
|
@@ -71,8 +71,8 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
|
|
|
71
71
|
* of whether the layout is stacked (cumulative tops) or overlap (per-series
|
|
72
72
|
* raw values).
|
|
73
73
|
*/
|
|
74
|
-
export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
75
|
-
const areas = computeAreaMarks(spec, scales, chartArea);
|
|
74
|
+
export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
|
|
75
|
+
const areas = computeAreaMarks(spec, scales, chartArea, theme.isDark);
|
|
76
76
|
|
|
77
77
|
const encoding = spec.encoding;
|
|
78
78
|
const hasColor = !!(encoding.color && 'field' in encoding.color);
|
|
@@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
19
19
|
|
|
20
20
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
21
21
|
import { bidirectionalSweep, computeEndpointLabels } from '../compute';
|
|
22
|
+
import { ENDPOINT_MARKER_RADIUS } from '../constants';
|
|
22
23
|
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Fixtures
|
|
@@ -281,7 +282,10 @@ describe('computeEndpointLabels', () => {
|
|
|
281
282
|
|
|
282
283
|
for (const entry of layout.entries) {
|
|
283
284
|
expect(entry.marker).toBeDefined();
|
|
284
|
-
|
|
285
|
+
// dataX is the original line endpoint; x is offset right by radius so the
|
|
286
|
+
// line terminates at the circle edge rather than its center.
|
|
287
|
+
expect(entry.marker!.dataX).toBe(lastX);
|
|
288
|
+
expect(entry.marker!.x).toBe(lastX + ENDPOINT_MARKER_RADIUS);
|
|
285
289
|
// Marker y is at the actual data point (not displaced labelY).
|
|
286
290
|
expect(entry.marker!.y).toBe(entry.dataY);
|
|
287
291
|
// Open-circle convention: fill = background, stroke = series color.
|
|
@@ -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`.
|
|
@@ -387,9 +386,12 @@ export function computeEndpointLabels(
|
|
|
387
386
|
showLeader: showLeader && displaced,
|
|
388
387
|
};
|
|
389
388
|
if (showMarker) {
|
|
389
|
+
// Offset cx right by markerRadius so the line terminates at the circle's
|
|
390
|
+
// left edge rather than its center — prevents the line from piercing the ring.
|
|
390
391
|
entry.marker = {
|
|
391
|
-
x:
|
|
392
|
+
x: p.dataX + markerRadius,
|
|
392
393
|
y: p.dataY,
|
|
394
|
+
dataX: p.dataX,
|
|
393
395
|
fill: markerFill,
|
|
394
396
|
stroke: config?.markerStyle?.stroke ?? p.color,
|
|
395
397
|
strokeWidth: markerStrokeWidth,
|
|
@@ -12,9 +12,11 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
15
|
-
* At the default
|
|
15
|
+
* At the default 11px axis font, this yields ~5-6px of breathing room.
|
|
16
|
+
* Reduced from 1.0 to 0.5 to prevent over-aggressive thinning on charts
|
|
17
|
+
* with a small number of categories that clearly have room for all labels.
|
|
16
18
|
*/
|
|
17
|
-
const MIN_TICK_GAP_FACTOR =
|
|
19
|
+
const MIN_TICK_GAP_FACTOR = 0.5;
|
|
18
20
|
|
|
19
21
|
/** Always show at least this many ticks, even if they overlap. */
|
|
20
22
|
const MIN_TICK_COUNT = 2;
|