@opendata-ai/openchart-engine 6.27.2 → 6.28.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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Internal normalized barlist spec type used by the compilation pipeline.
3
+ */
4
+
5
+ import type {
6
+ AnimationSpec,
7
+ BarListEncoding,
8
+ DarkMode,
9
+ DataRow,
10
+ ThemeConfig,
11
+ } from '@opendata-ai/openchart-core';
12
+
13
+ import type { NormalizedChrome } from '../compiler/types';
14
+
15
+ export interface NormalizedBarListSpec {
16
+ type: 'barlist';
17
+ data: DataRow[];
18
+ encoding: BarListEncoding;
19
+ barHeight: number;
20
+ cornerRadius: number | 'pill';
21
+ maxItems: number;
22
+ chrome: NormalizedChrome;
23
+ theme: ThemeConfig;
24
+ darkMode: DarkMode;
25
+ watermark: boolean;
26
+ animation?: AnimationSpec;
27
+ valueFormat?: string;
28
+ }
@@ -609,4 +609,124 @@ describe('computeBarLabels', () => {
609
609
  expect(texts).toContain('30%');
610
610
  expect(texts).toContain('70%');
611
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
+ });
612
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,18 +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
- // stack: null or false -> grouped (side-by-side) bars
122
- const stackDisabled = xChannel.stack === null || xChannel.stack === false;
123
-
124
- if (stackDisabled) {
125
- 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(
126
161
  spec.data,
127
162
  xChannel.field,
128
163
  yChannel.field,
@@ -134,40 +169,9 @@ export function computeBarMarks(
134
169
  scales,
135
170
  );
136
171
  }
137
-
138
- const stackMode =
139
- xChannel.stack === 'normalize'
140
- ? 'normalize'
141
- : xChannel.stack === 'center'
142
- ? 'center'
143
- : 'zero';
144
-
145
- return 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
172
  }
158
173
 
159
- // Single row per category: render like simple bars but with color from scale
160
- return computeColoredBars(
161
- spec.data,
162
- xChannel.field,
163
- yChannel.field,
164
- colorField,
165
- xScale,
166
- yScale,
167
- bandwidth,
168
- baseline,
169
- scales,
170
- );
174
+ return applyMarkDefOverrides(marks, spec, bandwidth);
171
175
  }
172
176
 
173
177
  /** Compute stacked horizontal bars with support for zero/normalize/center modes. */
@@ -357,6 +361,34 @@ function computeColoredBars(
357
361
  return marks;
358
362
  }
359
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
+
360
392
  /** Compute simple (non-grouped) horizontal bars. */
361
393
  function computeSimpleBars(
362
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 })),
@@ -30,7 +30,7 @@ import { resolveCurve } from './curves';
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
32
  /** Default stroke width for line marks. */
33
- const DEFAULT_STROKE_WIDTH = 2.5;
33
+ const DEFAULT_STROKE_WIDTH = 1.5;
34
34
 
35
35
  /** Sparkline mode uses a thinner stroke since the chart area is tiny and a
36
36
  * 2.5px line reads as clunky. 1.25px keeps the trend legible without dominating. */
@@ -180,6 +180,7 @@ export function computeLineMarks(
180
180
  stroke: strokeColor,
181
181
  strokeWidth:
182
182
  styleOverride?.strokeWidth ??
183
+ spec.markDef.strokeWidth ??
183
184
  (spec.display === 'sparkline' ? SPARKLINE_STROKE_WIDTH : DEFAULT_STROKE_WIDTH),
184
185
  strokeDasharray,
185
186
  opacity: styleOverride?.opacity,
@@ -194,29 +195,38 @@ export function computeLineMarks(
194
195
  // Emit PointMark objects when markDef.point is truthy, or when sequential
195
196
  // color is active (points carry the gradient since SVG paths are single-color).
196
197
  const markPoint = spec.markDef.point;
197
- const showPoints = markPoint === true || markPoint === 'transparent' || isSequentialColor;
198
+ const showPoints =
199
+ markPoint === true ||
200
+ markPoint === 'transparent' ||
201
+ markPoint === 'endpoints' ||
202
+ isSequentialColor;
198
203
 
199
204
  if (showPoints) {
200
205
  const isTransparent = markPoint === 'transparent';
206
+ const isEndpoints = markPoint === 'endpoints';
201
207
  // Also respect per-series showPoints override
202
208
  const seriesShowPoints = styleOverride?.showPoints !== false;
209
+ const lastIdx = pointsWithData.length - 1;
203
210
 
204
211
  for (let i = 0; i < pointsWithData.length; i++) {
205
212
  const p = pointsWithData[i];
206
- const visible = seriesShowPoints && !isTransparent;
213
+ const isEndpoint = i === 0 || i === lastIdx;
214
+ const visible = seriesShowPoints && !isTransparent && (!isEndpoints || isEndpoint);
207
215
  // Sequential color: each point gets colored by its data value
208
216
  let pointColor = color;
209
217
  if (isSequentialColor) {
210
218
  const val = Number(p.row[sequentialColorField!]);
211
219
  pointColor = Number.isFinite(val) ? getSequentialColor(scales, val) : color;
212
220
  }
221
+ const hollow = isEndpoints && visible;
222
+ const pointColorStr = getRepresentativeColor(pointColor);
213
223
  const pointMark: PointMark = {
214
224
  type: 'point',
215
225
  cx: p.x,
216
226
  cy: p.y,
217
227
  r: visible ? DEFAULT_POINT_RADIUS : 0,
218
- fill: pointColor,
219
- stroke: visible ? '#ffffff' : 'transparent',
228
+ fill: hollow ? 'transparent' : pointColorStr,
229
+ stroke: hollow ? pointColorStr : visible ? '#ffffff' : 'transparent',
220
230
  strokeWidth: visible ? 1.5 : 0,
221
231
  fillOpacity: isTransparent ? 0 : 1,
222
232
  data: p.row,
package/src/compile.ts CHANGED
@@ -55,6 +55,7 @@ import { computeAnnotations } from './annotations/compute';
55
55
  // registry on module load. Tests that clear the registry can import
56
56
  // `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
57
57
  import './charts/builtin';
58
+ import { compileBarList as compileBarListImpl } from './barlist/compile-barlist';
58
59
  import {
59
60
  assignAnimationIndices,
60
61
  computeMarkObstacles,
@@ -816,7 +817,8 @@ function compileLayerIndependent(
816
817
  const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
817
818
  const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
818
819
  const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
819
- const hasRightAxisTitle = !!leaf1.encoding?.y?.axis?.title;
820
+ const yAxisConfig = leaf1.encoding?.y?.axis || undefined;
821
+ const hasRightAxisTitle = !!yAxisConfig?.title;
820
822
  const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
821
823
  const bodyFontSize = theme.fonts?.sizes?.body ?? 13;
822
824
  const axisTitleOffset = getAxisTitleOffset(options.width);
@@ -1316,3 +1318,26 @@ export function compileTileMap(
1316
1318
  ): import('@opendata-ai/openchart-core').TileMapLayout {
1317
1319
  return compileTileMapImpl(spec, options);
1318
1320
  }
1321
+
1322
+ // ---------------------------------------------------------------------------
1323
+ // BarList compilation
1324
+ // ---------------------------------------------------------------------------
1325
+
1326
+ /**
1327
+ * Compile a barlist spec into a BarListLayout.
1328
+ *
1329
+ * Takes a raw barlist spec, validates, normalizes, resolves theme and chrome,
1330
+ * computes row layout with proportional bars, builds tooltips, and returns
1331
+ * a BarListLayout ready for rendering.
1332
+ *
1333
+ * @param spec - Raw barlist spec (validated and normalized internally).
1334
+ * @param options - Compile options (width, height, theme, darkMode).
1335
+ * @returns BarListLayout with computed positions and visual properties.
1336
+ * @throws Error if spec is invalid or not a barlist type.
1337
+ */
1338
+ export function compileBarList(
1339
+ spec: unknown,
1340
+ options: CompileOptions,
1341
+ ): import('@opendata-ai/openchart-core').BarListLayout {
1342
+ return compileBarListImpl(spec, options);
1343
+ }