@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +38 -6
  2. package/dist/index.js +1040 -521
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +31 -4
  6. package/src/__tests__/axes.test.ts +101 -3
  7. package/src/__tests__/legend.test.ts +2 -2
  8. package/src/annotations/__tests__/compute.test.ts +175 -0
  9. package/src/annotations/position.ts +37 -1
  10. package/src/annotations/resolve-range.ts +5 -5
  11. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  12. package/src/barlist/compile-barlist.ts +380 -0
  13. package/src/barlist/types.ts +28 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +222 -0
  15. package/src/charts/bar/compute.ts +77 -44
  16. package/src/charts/bar/index.ts +1 -0
  17. package/src/charts/bar/labels.ts +3 -2
  18. package/src/charts/column/compute.ts +60 -27
  19. package/src/charts/column/index.ts +1 -0
  20. package/src/charts/column/labels.ts +2 -1
  21. package/src/charts/line/__tests__/compute.test.ts +2 -2
  22. package/src/charts/line/area.ts +25 -4
  23. package/src/charts/line/compute.ts +15 -5
  24. package/src/compile.ts +26 -1
  25. package/src/compiler/normalize.ts +25 -1
  26. package/src/compiler/types.ts +5 -3
  27. package/src/compiler/validate.ts +120 -5
  28. package/src/index.ts +5 -0
  29. package/src/layout/axes/ticks.ts +37 -8
  30. package/src/layout/axes.ts +11 -4
  31. package/src/layout/dimensions.ts +10 -4
  32. package/src/layout/scales.ts +10 -0
  33. package/src/legend/wrap.ts +1 -1
  34. package/src/tilemap/__tests__/compile-tilemap.test.ts +5 -2
  35. package/src/tilemap/compile-tilemap.ts +41 -29
  36. 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
- return computeSimpleBars(
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
- // Color encoding present: decide between colored simple bars vs stacked
117
- const categoryGroups = groupByField(spec.data, yChannel.field);
118
- const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
119
-
120
- if (needsStacking) {
121
- const stackDisabled = xChannel.stack === null || xChannel.stack === false;
122
-
123
- if (stackDisabled) {
124
- return computeGroupedBars(
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
- // Single row per category: render like simple bars but with color from scale
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[],
@@ -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];
@@ -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
- return computeGroupedColumns(
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
- const stackMode =
105
- yChannel.stack === 'normalize'
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
- // Single row per category: render like simple columns but with color from scale
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 computeSimpleColumns(
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(2.5); // default
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(2.5);
944
+ expect(line.strokeWidth).toBe(1.5);
945
945
  }
946
946
  });
947
947
  });
@@ -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
- const fillValue = markFill != null ? markFill : color;
142
- const defaultFillOpacity = y2Channel ? 0.25 : DEFAULT_FILL_OPACITY;
143
- const fillOpacity = isGradientDef(fillValue) ? 1 : (spec.markDef.opacity ?? defaultFillOpacity);
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 : 2,
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 })),