@opendata-ai/openchart-engine 6.27.2 → 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 +1009 -520
- 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__/legend.test.ts +2 -2
- 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 +120 -0
- package/src/charts/bar/compute.ts +77 -45
- 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 +6 -4
- package/src/layout/axes.ts +2 -2
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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[],
|
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 })),
|
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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
|
+
}
|