@opendata-ai/openchart-engine 6.27.0 → 6.28.2
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.d.ts +38 -6
- package/dist/index.js +1040 -521
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
- package/src/__tests__/axes.test.ts +101 -3
- package/src/__tests__/legend.test.ts +2 -2
- package/src/annotations/__tests__/compute.test.ts +175 -0
- package/src/annotations/position.ts +37 -1
- package/src/annotations/resolve-range.ts +5 -5
- package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
- package/src/barlist/compile-barlist.ts +380 -0
- package/src/barlist/types.ts +28 -0
- package/src/charts/bar/__tests__/compute.test.ts +222 -0
- package/src/charts/bar/compute.ts +77 -44
- package/src/charts/bar/index.ts +1 -0
- package/src/charts/bar/labels.ts +3 -2
- package/src/charts/column/compute.ts +60 -27
- package/src/charts/column/index.ts +1 -0
- package/src/charts/column/labels.ts +2 -1
- package/src/charts/line/__tests__/compute.test.ts +2 -2
- package/src/charts/line/area.ts +25 -4
- package/src/charts/line/compute.ts +15 -5
- package/src/compile.ts +26 -1
- package/src/compiler/normalize.ts +25 -1
- package/src/compiler/types.ts +5 -3
- package/src/compiler/validate.ts +120 -5
- package/src/index.ts +5 -0
- package/src/layout/axes/ticks.ts +37 -8
- package/src/layout/axes.ts +11 -4
- package/src/layout/dimensions.ts +10 -4
- package/src/layout/scales.ts +10 -0
- package/src/legend/wrap.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
- package/src/tilemap/compile-tilemap.ts +41 -29
- package/src/tooltips/compute.ts +4 -2
|
@@ -386,6 +386,108 @@ describe('computeBarMarks', () => {
|
|
|
386
386
|
});
|
|
387
387
|
});
|
|
388
388
|
|
|
389
|
+
describe('stacked vs grouped (wage data reproduction)', () => {
|
|
390
|
+
// 2 years × 2 firm-size categories — the canonical grouped-bar use case
|
|
391
|
+
const wageData = [
|
|
392
|
+
{ size: '<5 employees', year: '2018', pay: 48200 },
|
|
393
|
+
{ size: '<5 employees', year: '2022', pay: 56400 },
|
|
394
|
+
{ size: '5,000+ employees', year: '2018', pay: 62300 },
|
|
395
|
+
{ size: '5,000+ employees', year: '2022', pay: 74800 },
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
function makeWageSpec(stackNull = false): NormalizedChartSpec {
|
|
399
|
+
return {
|
|
400
|
+
markType: 'bar',
|
|
401
|
+
markDef: { type: 'bar' },
|
|
402
|
+
data: wageData,
|
|
403
|
+
encoding: {
|
|
404
|
+
x: {
|
|
405
|
+
field: 'pay',
|
|
406
|
+
type: 'quantitative',
|
|
407
|
+
...(stackNull ? { stack: null } : {}),
|
|
408
|
+
},
|
|
409
|
+
y: { field: 'size', type: 'nominal' },
|
|
410
|
+
color: { field: 'year', type: 'nominal' },
|
|
411
|
+
},
|
|
412
|
+
chrome: {},
|
|
413
|
+
annotations: [],
|
|
414
|
+
responsive: true,
|
|
415
|
+
theme: {},
|
|
416
|
+
darkMode: 'off',
|
|
417
|
+
labels: { density: 'auto', format: '' },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
it('stacks by default: segments are contiguous end-to-end within each category', () => {
|
|
422
|
+
const spec = makeWageSpec(false);
|
|
423
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
424
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
425
|
+
|
|
426
|
+
// 2 firm sizes × 2 years = 4 bars
|
|
427
|
+
expect(marks).toHaveLength(4);
|
|
428
|
+
|
|
429
|
+
// For stacked bars, the second segment starts exactly where the first ends.
|
|
430
|
+
const smallFirmMarks = marks
|
|
431
|
+
.filter((m) => m.aria.label.includes('<5'))
|
|
432
|
+
.sort((a, b) => a.x - b.x);
|
|
433
|
+
expect(smallFirmMarks).toHaveLength(2);
|
|
434
|
+
expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('stacks by default: segments share the same y position (stacked on same row)', () => {
|
|
438
|
+
const spec = makeWageSpec(false);
|
|
439
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
440
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
441
|
+
|
|
442
|
+
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
443
|
+
expect(smallFirmMarks).toHaveLength(2);
|
|
444
|
+
expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('grouped with stack:null: bar widths match individual pay values (not cumulative)', () => {
|
|
448
|
+
const stackedSpec = makeWageSpec(false);
|
|
449
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
450
|
+
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
451
|
+
|
|
452
|
+
const groupedSpec = makeWageSpec(true);
|
|
453
|
+
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
454
|
+
const groupedMarks = computeBarMarks(groupedSpec, groupedScales, chartArea, fullStrategy);
|
|
455
|
+
|
|
456
|
+
expect(groupedMarks).toHaveLength(4);
|
|
457
|
+
|
|
458
|
+
// Grouped bars each start from the baseline (same x for both years within a firm size)
|
|
459
|
+
const smallFirmGrouped = groupedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
460
|
+
expect(smallFirmGrouped[0].x).toBe(smallFirmGrouped[1].x);
|
|
461
|
+
|
|
462
|
+
// Stacked bars for the same category have different x positions (end-to-end)
|
|
463
|
+
const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
464
|
+
expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
|
|
465
|
+
});
|
|
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
|
+
});
|
|
490
|
+
|
|
389
491
|
describe('edge cases', () => {
|
|
390
492
|
it('returns empty array when no x encoding', () => {
|
|
391
493
|
const spec: NormalizedChartSpec = {
|
|
@@ -507,4 +609,124 @@ describe('computeBarLabels', () => {
|
|
|
507
609
|
expect(texts).toContain('30%');
|
|
508
610
|
expect(texts).toContain('70%');
|
|
509
611
|
});
|
|
612
|
+
|
|
613
|
+
it('applies fixed label color to outside labels', () => {
|
|
614
|
+
// Narrow chart area forces bars < 40px, putting labels outside
|
|
615
|
+
const smallArea: Rect = { x: 80, y: 20, width: 30, height: 300 };
|
|
616
|
+
const spec: NormalizedChartSpec = {
|
|
617
|
+
markType: 'bar',
|
|
618
|
+
markDef: { type: 'bar', size: 6, cornerRadius: 'pill' },
|
|
619
|
+
data: [
|
|
620
|
+
{ category: 'A', value: 1 },
|
|
621
|
+
{ category: 'B', value: 2 },
|
|
622
|
+
],
|
|
623
|
+
encoding: {
|
|
624
|
+
x: { field: 'value', type: 'quantitative' },
|
|
625
|
+
y: { field: 'category', type: 'nominal' },
|
|
626
|
+
},
|
|
627
|
+
chrome: {},
|
|
628
|
+
annotations: [],
|
|
629
|
+
responsive: true,
|
|
630
|
+
theme: {},
|
|
631
|
+
darkMode: 'off',
|
|
632
|
+
labels: { density: 'auto', format: '', prefix: '' },
|
|
633
|
+
};
|
|
634
|
+
const scales = computeScales(spec, smallArea, spec.data);
|
|
635
|
+
const marks = computeBarMarks(spec, scales, smallArea, fullStrategy);
|
|
636
|
+
const labels = computeBarLabels(
|
|
637
|
+
marks,
|
|
638
|
+
smallArea,
|
|
639
|
+
'all',
|
|
640
|
+
undefined,
|
|
641
|
+
undefined,
|
|
642
|
+
undefined,
|
|
643
|
+
'#a1a1aa',
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// Outside labels should use the fixed color; inside labels use contrast-adjusted colors
|
|
647
|
+
const outsideLabels = labels.filter((l) => l.style.textAnchor === 'start');
|
|
648
|
+
expect(outsideLabels.length).toBeGreaterThan(0);
|
|
649
|
+
for (const label of outsideLabels) {
|
|
650
|
+
expect(label.style.fill).toBe('#a1a1aa');
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
describe('markDef overrides', () => {
|
|
656
|
+
it('markDef.size reduces bar height and centers within band', () => {
|
|
657
|
+
const spec = makeSimpleBarSpec();
|
|
658
|
+
spec.markDef = { type: 'bar', size: 6 };
|
|
659
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
660
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
661
|
+
|
|
662
|
+
for (const mark of marks) {
|
|
663
|
+
expect(mark.height).toBe(6);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Bars should be centered: offset = (bandwidth - 6) / 2
|
|
667
|
+
const specDefault = makeSimpleBarSpec();
|
|
668
|
+
const scalesDefault = computeScales(specDefault, chartArea, specDefault.data);
|
|
669
|
+
const marksDefault = computeBarMarks(specDefault, scalesDefault, chartArea, fullStrategy);
|
|
670
|
+
for (let i = 0; i < marks.length; i++) {
|
|
671
|
+
expect(marks[i].y).toBeGreaterThan(marksDefault[i].y);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('markDef.size is capped at bandwidth', () => {
|
|
676
|
+
const spec = makeSimpleBarSpec();
|
|
677
|
+
spec.markDef = { type: 'bar', size: 9999 };
|
|
678
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
679
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
680
|
+
|
|
681
|
+
const bandwidth = marks[0].height;
|
|
682
|
+
// Should be capped at bandwidth, not 9999
|
|
683
|
+
expect(bandwidth).toBeLessThan(9999);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('markDef.cornerRadius "pill" resolves to half the bar height', () => {
|
|
687
|
+
const spec = makeSimpleBarSpec();
|
|
688
|
+
spec.markDef = { type: 'bar', size: 6, cornerRadius: 'pill' };
|
|
689
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
690
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
691
|
+
|
|
692
|
+
for (const mark of marks) {
|
|
693
|
+
expect(mark.cornerRadius).toBe(3);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('markDef.cornerRadius as number overrides default', () => {
|
|
698
|
+
const spec = makeSimpleBarSpec();
|
|
699
|
+
spec.markDef = { type: 'bar', cornerRadius: 8 };
|
|
700
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
701
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
702
|
+
|
|
703
|
+
for (const mark of marks) {
|
|
704
|
+
expect(mark.cornerRadius).toBe(8);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('markDef.size is skipped for stacked bars', () => {
|
|
709
|
+
const spec = makeGroupedBarSpec();
|
|
710
|
+
spec.markDef = { type: 'bar', size: 6 };
|
|
711
|
+
// Enable stacking (default for grouped)
|
|
712
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
713
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
714
|
+
|
|
715
|
+
const stackedMarks = marks.filter((m) => m.stackGroup !== undefined);
|
|
716
|
+
for (const mark of stackedMarks) {
|
|
717
|
+
expect(mark.height).not.toBe(6);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it('combined size + pill uses adjusted size for radius', () => {
|
|
722
|
+
const spec = makeSimpleBarSpec();
|
|
723
|
+
spec.markDef = { type: 'bar', size: 10, cornerRadius: 'pill' };
|
|
724
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
725
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
726
|
+
|
|
727
|
+
for (const mark of marks) {
|
|
728
|
+
expect(mark.height).toBe(10);
|
|
729
|
+
expect(mark.cornerRadius).toBe(5);
|
|
730
|
+
}
|
|
731
|
+
});
|
|
510
732
|
});
|
|
@@ -97,9 +97,11 @@ export function computeBarMarks(
|
|
|
97
97
|
const colorField = colorEnc?.field;
|
|
98
98
|
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
99
99
|
|
|
100
|
+
let marks: RectMark[];
|
|
101
|
+
|
|
100
102
|
// If no color encoding, or sequential color (value-based gradient), render simple bars
|
|
101
103
|
if (!colorField || isSequentialColor) {
|
|
102
|
-
|
|
104
|
+
marks = computeSimpleBars(
|
|
103
105
|
spec.data,
|
|
104
106
|
xChannel.field,
|
|
105
107
|
yChannel.field,
|
|
@@ -111,17 +113,51 @@ export function computeBarMarks(
|
|
|
111
113
|
isSequentialColor,
|
|
112
114
|
conditionalColor,
|
|
113
115
|
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
} else {
|
|
117
|
+
// Color encoding present: decide between colored simple bars vs stacked
|
|
118
|
+
const categoryGroups = groupByField(spec.data, yChannel.field);
|
|
119
|
+
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
120
|
+
|
|
121
|
+
if (needsStacking) {
|
|
122
|
+
// stack: null or false -> grouped (side-by-side) bars
|
|
123
|
+
const stackDisabled = xChannel.stack === null || xChannel.stack === false;
|
|
124
|
+
|
|
125
|
+
if (stackDisabled) {
|
|
126
|
+
marks = computeGroupedBars(
|
|
127
|
+
spec.data,
|
|
128
|
+
xChannel.field,
|
|
129
|
+
yChannel.field,
|
|
130
|
+
colorField,
|
|
131
|
+
xScale,
|
|
132
|
+
yScale,
|
|
133
|
+
bandwidth,
|
|
134
|
+
baseline,
|
|
135
|
+
scales,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
const stackMode =
|
|
139
|
+
xChannel.stack === 'normalize'
|
|
140
|
+
? 'normalize'
|
|
141
|
+
: xChannel.stack === 'center'
|
|
142
|
+
? 'center'
|
|
143
|
+
: 'zero';
|
|
144
|
+
|
|
145
|
+
marks = computeStackedBars(
|
|
146
|
+
spec.data,
|
|
147
|
+
xChannel.field,
|
|
148
|
+
yChannel.field,
|
|
149
|
+
colorField,
|
|
150
|
+
xScale,
|
|
151
|
+
yScale,
|
|
152
|
+
bandwidth,
|
|
153
|
+
baseline,
|
|
154
|
+
scales,
|
|
155
|
+
stackMode,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
// Single row per category: render like simple bars but with color from scale
|
|
160
|
+
marks = computeColoredBars(
|
|
125
161
|
spec.data,
|
|
126
162
|
xChannel.field,
|
|
127
163
|
yChannel.field,
|
|
@@ -133,40 +169,9 @@ export function computeBarMarks(
|
|
|
133
169
|
scales,
|
|
134
170
|
);
|
|
135
171
|
}
|
|
136
|
-
|
|
137
|
-
const stackMode =
|
|
138
|
-
xChannel.stack === 'normalize'
|
|
139
|
-
? 'normalize'
|
|
140
|
-
: xChannel.stack === 'center'
|
|
141
|
-
? 'center'
|
|
142
|
-
: 'zero';
|
|
143
|
-
|
|
144
|
-
return computeStackedBars(
|
|
145
|
-
spec.data,
|
|
146
|
-
xChannel.field,
|
|
147
|
-
yChannel.field,
|
|
148
|
-
colorField,
|
|
149
|
-
xScale,
|
|
150
|
-
yScale,
|
|
151
|
-
bandwidth,
|
|
152
|
-
baseline,
|
|
153
|
-
scales,
|
|
154
|
-
stackMode,
|
|
155
|
-
);
|
|
156
172
|
}
|
|
157
173
|
|
|
158
|
-
|
|
159
|
-
return computeColoredBars(
|
|
160
|
-
spec.data,
|
|
161
|
-
xChannel.field,
|
|
162
|
-
yChannel.field,
|
|
163
|
-
colorField,
|
|
164
|
-
xScale,
|
|
165
|
-
yScale,
|
|
166
|
-
bandwidth,
|
|
167
|
-
baseline,
|
|
168
|
-
scales,
|
|
169
|
-
);
|
|
174
|
+
return applyMarkDefOverrides(marks, spec, bandwidth);
|
|
170
175
|
}
|
|
171
176
|
|
|
172
177
|
/** Compute stacked horizontal bars with support for zero/normalize/center modes. */
|
|
@@ -356,6 +361,34 @@ function computeColoredBars(
|
|
|
356
361
|
return marks;
|
|
357
362
|
}
|
|
358
363
|
|
|
364
|
+
function applyMarkDefOverrides(
|
|
365
|
+
marks: RectMark[],
|
|
366
|
+
spec: NormalizedChartSpec,
|
|
367
|
+
bandwidth: number,
|
|
368
|
+
): RectMark[] {
|
|
369
|
+
const { markDef } = spec;
|
|
370
|
+
const fixedSize = markDef.size;
|
|
371
|
+
const crSpec = markDef.cornerRadius;
|
|
372
|
+
|
|
373
|
+
if (fixedSize == null && crSpec == null) return marks;
|
|
374
|
+
|
|
375
|
+
for (const mark of marks) {
|
|
376
|
+
if (fixedSize != null && mark.stackGroup === undefined) {
|
|
377
|
+
const barHeight = Math.min(fixedSize, bandwidth);
|
|
378
|
+
const offset = (bandwidth - barHeight) / 2;
|
|
379
|
+
mark.y = mark.y + offset;
|
|
380
|
+
mark.height = barHeight;
|
|
381
|
+
}
|
|
382
|
+
const effectiveHeight = mark.height;
|
|
383
|
+
if (crSpec === 'pill') {
|
|
384
|
+
mark.cornerRadius = effectiveHeight / 2;
|
|
385
|
+
} else if (typeof crSpec === 'number') {
|
|
386
|
+
mark.cornerRadius = crSpec;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return marks;
|
|
390
|
+
}
|
|
391
|
+
|
|
359
392
|
/** Compute simple (non-grouped) horizontal bars. */
|
|
360
393
|
function computeSimpleBars(
|
|
361
394
|
data: DataRow[],
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
26
26
|
spec.labels.format,
|
|
27
27
|
spec.labels.prefix,
|
|
28
28
|
valueField,
|
|
29
|
+
spec.labels.color,
|
|
29
30
|
);
|
|
30
31
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
31
32
|
marks[i].label = labels[i];
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -94,6 +94,7 @@ export function computeBarLabels(
|
|
|
94
94
|
labelFormat?: string,
|
|
95
95
|
labelPrefix?: string,
|
|
96
96
|
valueField?: string,
|
|
97
|
+
labelColor?: string,
|
|
97
98
|
): ResolvedLabel[] {
|
|
98
99
|
const targetMarks = filterByDensity(marks, density);
|
|
99
100
|
|
|
@@ -166,12 +167,12 @@ export function computeBarLabels(
|
|
|
166
167
|
if (isNegative) {
|
|
167
168
|
// Outside negative bar: just past the bar's left edge
|
|
168
169
|
anchorX = mark.x - LABEL_PADDING;
|
|
169
|
-
fill = getRepresentativeColor(mark.fill);
|
|
170
|
+
fill = labelColor ?? getRepresentativeColor(mark.fill);
|
|
170
171
|
textAnchor = 'end';
|
|
171
172
|
} else {
|
|
172
173
|
// Outside positive bar: just past the bar's right edge
|
|
173
174
|
anchorX = mark.x + mark.width + LABEL_PADDING;
|
|
174
|
-
fill = getRepresentativeColor(mark.fill);
|
|
175
|
+
fill = labelColor ?? getRepresentativeColor(mark.fill);
|
|
175
176
|
textAnchor = 'start';
|
|
176
177
|
}
|
|
177
178
|
}
|
|
@@ -78,6 +78,8 @@ export function computeColumnMarks(
|
|
|
78
78
|
|
|
79
79
|
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
80
80
|
|
|
81
|
+
let marks: RectMark[];
|
|
82
|
+
|
|
81
83
|
// Color encoding present: decide between colored simple columns vs stacked
|
|
82
84
|
if (colorField && !isSequentialColor) {
|
|
83
85
|
// Check if any category has multiple rows (actual stacking needed)
|
|
@@ -88,7 +90,26 @@ export function computeColumnMarks(
|
|
|
88
90
|
const stackDisabled = yChannel.stack === null || yChannel.stack === false;
|
|
89
91
|
|
|
90
92
|
if (stackDisabled) {
|
|
91
|
-
|
|
93
|
+
marks = computeGroupedColumns(
|
|
94
|
+
spec.data,
|
|
95
|
+
xChannel.field,
|
|
96
|
+
yChannel.field,
|
|
97
|
+
colorField,
|
|
98
|
+
xScale,
|
|
99
|
+
yScale,
|
|
100
|
+
bandwidth,
|
|
101
|
+
baseline,
|
|
102
|
+
scales,
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
const stackMode =
|
|
106
|
+
yChannel.stack === 'normalize'
|
|
107
|
+
? 'normalize'
|
|
108
|
+
: yChannel.stack === 'center'
|
|
109
|
+
? 'center'
|
|
110
|
+
: 'zero';
|
|
111
|
+
|
|
112
|
+
marks = computeStackedColumns(
|
|
92
113
|
spec.data,
|
|
93
114
|
xChannel.field,
|
|
94
115
|
yChannel.field,
|
|
@@ -98,17 +119,12 @@ export function computeColumnMarks(
|
|
|
98
119
|
bandwidth,
|
|
99
120
|
baseline,
|
|
100
121
|
scales,
|
|
122
|
+
stackMode,
|
|
101
123
|
);
|
|
102
124
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
? 'normalize'
|
|
107
|
-
: yChannel.stack === 'center'
|
|
108
|
-
? 'center'
|
|
109
|
-
: 'zero';
|
|
110
|
-
|
|
111
|
-
return computeStackedColumns(
|
|
125
|
+
} else {
|
|
126
|
+
// Single row per category: render like simple columns but with color from scale
|
|
127
|
+
marks = computeColoredColumns(
|
|
112
128
|
spec.data,
|
|
113
129
|
xChannel.field,
|
|
114
130
|
yChannel.field,
|
|
@@ -118,36 +134,24 @@ export function computeColumnMarks(
|
|
|
118
134
|
bandwidth,
|
|
119
135
|
baseline,
|
|
120
136
|
scales,
|
|
121
|
-
stackMode,
|
|
122
137
|
);
|
|
123
138
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return computeColoredColumns(
|
|
139
|
+
} else {
|
|
140
|
+
marks = computeSimpleColumns(
|
|
127
141
|
spec.data,
|
|
128
142
|
xChannel.field,
|
|
129
143
|
yChannel.field,
|
|
130
|
-
colorField,
|
|
131
144
|
xScale,
|
|
132
145
|
yScale,
|
|
133
146
|
bandwidth,
|
|
134
147
|
baseline,
|
|
135
148
|
scales,
|
|
149
|
+
isSequentialColor,
|
|
150
|
+
conditionalColor,
|
|
136
151
|
);
|
|
137
152
|
}
|
|
138
153
|
|
|
139
|
-
return
|
|
140
|
-
spec.data,
|
|
141
|
-
xChannel.field,
|
|
142
|
-
yChannel.field,
|
|
143
|
-
xScale,
|
|
144
|
-
yScale,
|
|
145
|
-
bandwidth,
|
|
146
|
-
baseline,
|
|
147
|
-
scales,
|
|
148
|
-
isSequentialColor,
|
|
149
|
-
conditionalColor,
|
|
150
|
-
);
|
|
154
|
+
return applyMarkDefOverrides(marks, spec, bandwidth);
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
/** Compute simple (non-grouped) vertical columns. */
|
|
@@ -407,3 +411,32 @@ function computeStackedColumns(
|
|
|
407
411
|
|
|
408
412
|
return marks;
|
|
409
413
|
}
|
|
414
|
+
|
|
415
|
+
function applyMarkDefOverrides(
|
|
416
|
+
marks: RectMark[],
|
|
417
|
+
spec: NormalizedChartSpec,
|
|
418
|
+
bandwidth: number,
|
|
419
|
+
): RectMark[] {
|
|
420
|
+
const { markDef } = spec;
|
|
421
|
+
const fixedSize = markDef.size;
|
|
422
|
+
const crSpec = markDef.cornerRadius;
|
|
423
|
+
|
|
424
|
+
if (fixedSize == null && crSpec == null) return marks;
|
|
425
|
+
|
|
426
|
+
for (const mark of marks) {
|
|
427
|
+
if (fixedSize != null && mark.stackGroup === undefined) {
|
|
428
|
+
const barWidth = Math.min(fixedSize, bandwidth);
|
|
429
|
+
const offset = (bandwidth - barWidth) / 2;
|
|
430
|
+
mark.x = mark.x + offset;
|
|
431
|
+
mark.width = barWidth;
|
|
432
|
+
}
|
|
433
|
+
const effectiveWidth = mark.width;
|
|
434
|
+
if (crSpec === 'pill') {
|
|
435
|
+
mark.cornerRadius = effectiveWidth / 2;
|
|
436
|
+
} else if (typeof crSpec === 'number') {
|
|
437
|
+
mark.cornerRadius = crSpec;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return marks;
|
|
442
|
+
}
|
|
@@ -26,6 +26,7 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
|
26
26
|
spec.labels.format,
|
|
27
27
|
spec.labels.prefix,
|
|
28
28
|
valueField,
|
|
29
|
+
spec.labels.color,
|
|
29
30
|
);
|
|
30
31
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
31
32
|
marks[i].label = labels[i];
|
|
@@ -50,6 +50,7 @@ export function computeColumnLabels(
|
|
|
50
50
|
labelFormat?: string,
|
|
51
51
|
labelPrefix?: string,
|
|
52
52
|
valueField?: string,
|
|
53
|
+
labelColor?: string,
|
|
53
54
|
): ResolvedLabel[] {
|
|
54
55
|
const targetMarks = filterByDensity(marks, density);
|
|
55
56
|
|
|
@@ -107,7 +108,7 @@ export function computeColumnLabels(
|
|
|
107
108
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
108
109
|
fontSize: LABEL_FONT_SIZE,
|
|
109
110
|
fontWeight: LABEL_FONT_WEIGHT,
|
|
110
|
-
fill: getRepresentativeColor(mark.fill),
|
|
111
|
+
fill: labelColor ?? getRepresentativeColor(mark.fill),
|
|
111
112
|
lineHeight: 1.2,
|
|
112
113
|
textAnchor: 'middle',
|
|
113
114
|
dominantBaseline: isNegative ? 'hanging' : 'auto',
|
|
@@ -896,7 +896,7 @@ describe('seriesStyles', () => {
|
|
|
896
896
|
const usLine = lineMarks.find((m) => m.seriesKey === 'US');
|
|
897
897
|
|
|
898
898
|
expect(ukLine?.strokeWidth).toBe(1.5);
|
|
899
|
-
expect(usLine?.strokeWidth).toBe(
|
|
899
|
+
expect(usLine?.strokeWidth).toBe(1.5); // default
|
|
900
900
|
});
|
|
901
901
|
|
|
902
902
|
it('sets opacity on a series', () => {
|
|
@@ -941,7 +941,7 @@ describe('seriesStyles', () => {
|
|
|
941
941
|
for (const line of lineMarks) {
|
|
942
942
|
expect(line.strokeDasharray).toBeUndefined();
|
|
943
943
|
expect(line.opacity).toBeUndefined();
|
|
944
|
-
expect(line.strokeWidth).toBe(
|
|
944
|
+
expect(line.strokeWidth).toBe(1.5);
|
|
945
945
|
}
|
|
946
946
|
});
|
|
947
947
|
});
|
package/src/charts/line/area.ts
CHANGED
|
@@ -137,10 +137,31 @@ function computeSingleArea(
|
|
|
137
137
|
|
|
138
138
|
// Allow markDef.fill to override color with a gradient.
|
|
139
139
|
// When a gradient is provided, set fillOpacity=1 so gradient stop-opacity controls the fade.
|
|
140
|
+
// When no fill is provided, auto-generate a top-to-bottom fade gradient.
|
|
140
141
|
const markFill = spec.markDef.fill;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
let fillValue: string | import('@opendata-ai/openchart-core').GradientDef;
|
|
143
|
+
let fillOpacity: number;
|
|
144
|
+
|
|
145
|
+
if (markFill != null) {
|
|
146
|
+
fillValue = markFill;
|
|
147
|
+
fillOpacity = isGradientDef(markFill)
|
|
148
|
+
? 1
|
|
149
|
+
: (spec.markDef.opacity ?? (y2Channel ? 0.25 : DEFAULT_FILL_OPACITY));
|
|
150
|
+
} else {
|
|
151
|
+
const colorStr = getRepresentativeColor(color);
|
|
152
|
+
fillValue = {
|
|
153
|
+
gradient: 'linear',
|
|
154
|
+
x1: 0,
|
|
155
|
+
y1: 0,
|
|
156
|
+
x2: 0,
|
|
157
|
+
y2: 1,
|
|
158
|
+
stops: [
|
|
159
|
+
{ offset: 0, color: colorStr, opacity: 0.12 },
|
|
160
|
+
{ offset: 1, color: colorStr, opacity: 0 },
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
fillOpacity = 1;
|
|
164
|
+
}
|
|
144
165
|
|
|
145
166
|
marks.push({
|
|
146
167
|
type: 'area',
|
|
@@ -151,7 +172,7 @@ function computeSingleArea(
|
|
|
151
172
|
fill: fillValue,
|
|
152
173
|
fillOpacity: fillOpacity,
|
|
153
174
|
stroke: getRepresentativeColor(isGradientDef(fillValue) ? color : fillValue),
|
|
154
|
-
strokeWidth: spec.display === 'sparkline' ? 1.25 :
|
|
175
|
+
strokeWidth: spec.markDef.strokeWidth ?? (spec.display === 'sparkline' ? 1.25 : 1.5),
|
|
155
176
|
seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
|
|
156
177
|
data: validPoints.map((p) => p.row),
|
|
157
178
|
dataPoints: validPoints.map((p) => ({ x: p.x, y: p.yTop, datum: p.row })),
|