@opendata-ai/openchart-engine 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +155 -19
- package/dist/index.js +1513 -164
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -3
- package/src/__tests__/axes.test.ts +168 -4
- package/src/__tests__/compile-chart.test.ts +23 -12
- package/src/__tests__/compile-layer.test.ts +386 -0
- package/src/__tests__/dimensions.test.ts +6 -3
- package/src/__tests__/legend.test.ts +6 -3
- package/src/__tests__/scales.test.ts +176 -2
- package/src/annotations/__tests__/compute.test.ts +8 -4
- package/src/charts/bar/__tests__/compute.test.ts +12 -6
- package/src/charts/bar/compute.ts +21 -5
- package/src/charts/column/__tests__/compute.test.ts +14 -7
- package/src/charts/column/compute.ts +21 -6
- package/src/charts/dot/__tests__/compute.test.ts +10 -5
- package/src/charts/dot/compute.ts +10 -4
- package/src/charts/line/__tests__/compute.test.ts +102 -11
- package/src/charts/line/__tests__/curves.test.ts +51 -0
- package/src/charts/line/__tests__/labels.test.ts +2 -1
- package/src/charts/line/__tests__/mark-options.test.ts +175 -0
- package/src/charts/line/area.ts +19 -8
- package/src/charts/line/compute.ts +64 -25
- package/src/charts/line/curves.ts +40 -0
- package/src/charts/pie/__tests__/compute.test.ts +10 -5
- package/src/charts/pie/compute.ts +2 -1
- package/src/charts/rule/index.ts +127 -0
- package/src/charts/scatter/__tests__/compute.test.ts +10 -5
- package/src/charts/scatter/compute.ts +15 -5
- package/src/charts/text/index.ts +92 -0
- package/src/charts/tick/index.ts +84 -0
- package/src/charts/utils.ts +1 -1
- package/src/compile.ts +175 -23
- package/src/compiler/__tests__/compile.test.ts +4 -4
- package/src/compiler/__tests__/normalize.test.ts +4 -4
- package/src/compiler/__tests__/validate.test.ts +25 -26
- package/src/compiler/index.ts +1 -1
- package/src/compiler/normalize.ts +77 -4
- package/src/compiler/types.ts +6 -2
- package/src/compiler/validate.ts +167 -35
- package/src/graphs/__tests__/compile-graph.test.ts +2 -2
- package/src/graphs/compile-graph.ts +2 -2
- package/src/index.ts +17 -1
- package/src/layout/axes.ts +122 -20
- package/src/layout/dimensions.ts +15 -9
- package/src/layout/scales.ts +320 -31
- package/src/legend/compute.ts +9 -6
- package/src/tables/__tests__/compile-table.test.ts +1 -1
- package/src/tooltips/__tests__/compute.test.ts +10 -5
- package/src/tooltips/compute.ts +32 -14
- package/src/transforms/__tests__/bin.test.ts +88 -0
- package/src/transforms/__tests__/calculate.test.ts +146 -0
- package/src/transforms/__tests__/conditional.test.ts +109 -0
- package/src/transforms/__tests__/filter.test.ts +59 -0
- package/src/transforms/__tests__/index.test.ts +93 -0
- package/src/transforms/__tests__/predicates.test.ts +176 -0
- package/src/transforms/__tests__/timeunit.test.ts +129 -0
- package/src/transforms/bin.ts +87 -0
- package/src/transforms/calculate.ts +60 -0
- package/src/transforms/conditional.ts +46 -0
- package/src/transforms/filter.ts +17 -0
- package/src/transforms/index.ts +48 -0
- package/src/transforms/predicates.ts +90 -0
- package/src/transforms/timeunit.ts +88 -0
|
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
20
20
|
|
|
21
21
|
function makeSimpleBarSpec(): NormalizedChartSpec {
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
markType: 'bar',
|
|
24
|
+
markDef: { type: 'bar' },
|
|
24
25
|
data: [
|
|
25
26
|
{ category: 'Apple', value: 50 },
|
|
26
27
|
{ category: 'Banana', value: 30 },
|
|
@@ -41,7 +42,8 @@ function makeSimpleBarSpec(): NormalizedChartSpec {
|
|
|
41
42
|
|
|
42
43
|
function makeGroupedBarSpec(): NormalizedChartSpec {
|
|
43
44
|
return {
|
|
44
|
-
|
|
45
|
+
markType: 'bar',
|
|
46
|
+
markDef: { type: 'bar' },
|
|
45
47
|
data: [
|
|
46
48
|
{ category: 'Q1', value: 50, region: 'East' },
|
|
47
49
|
{ category: 'Q1', value: 40, region: 'West' },
|
|
@@ -66,7 +68,8 @@ function makeGroupedBarSpec(): NormalizedChartSpec {
|
|
|
66
68
|
|
|
67
69
|
function makeNegativeBarSpec(): NormalizedChartSpec {
|
|
68
70
|
return {
|
|
69
|
-
|
|
71
|
+
markType: 'bar',
|
|
72
|
+
markDef: { type: 'bar' },
|
|
70
73
|
data: [
|
|
71
74
|
{ category: 'Growth', value: 15 },
|
|
72
75
|
{ category: 'Decline', value: -10 },
|
|
@@ -227,7 +230,8 @@ describe('computeBarMarks', () => {
|
|
|
227
230
|
describe('edge cases', () => {
|
|
228
231
|
it('returns empty array when no x encoding', () => {
|
|
229
232
|
const spec: NormalizedChartSpec = {
|
|
230
|
-
|
|
233
|
+
markType: 'bar',
|
|
234
|
+
markDef: { type: 'bar' },
|
|
231
235
|
data: [{ category: 'A', value: 10 }],
|
|
232
236
|
encoding: {
|
|
233
237
|
y: { field: 'category', type: 'nominal' },
|
|
@@ -246,7 +250,8 @@ describe('computeBarMarks', () => {
|
|
|
246
250
|
|
|
247
251
|
it('returns empty array for empty data', () => {
|
|
248
252
|
const spec: NormalizedChartSpec = {
|
|
249
|
-
|
|
253
|
+
markType: 'bar',
|
|
254
|
+
markDef: { type: 'bar' },
|
|
250
255
|
data: [],
|
|
251
256
|
encoding: {
|
|
252
257
|
x: { field: 'value', type: 'quantitative' },
|
|
@@ -306,7 +311,8 @@ describe('computeBarLabels', () => {
|
|
|
306
311
|
|
|
307
312
|
it('applies format with literal alpha suffix (e.g. "T")', () => {
|
|
308
313
|
const spec: NormalizedChartSpec = {
|
|
309
|
-
|
|
314
|
+
markType: 'bar',
|
|
315
|
+
markDef: { type: 'bar' },
|
|
310
316
|
data: [
|
|
311
317
|
{ company: 'Apple', cap: 3.75 },
|
|
312
318
|
{ company: 'Meta', cap: 1.63 },
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
|
+
ConditionalValueDef,
|
|
10
11
|
DataRow,
|
|
11
12
|
Encoding,
|
|
12
13
|
LayoutStrategy,
|
|
@@ -19,6 +20,7 @@ import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
|
19
20
|
|
|
20
21
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
21
22
|
import type { ResolvedScales } from '../../layout/scales';
|
|
23
|
+
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
22
24
|
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
23
25
|
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
@@ -68,8 +70,13 @@ export function computeBarMarks(
|
|
|
68
70
|
|
|
69
71
|
const bandwidth = yScale.bandwidth();
|
|
70
72
|
const baseline = xScale(0);
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
74
|
+
const conditionalColor =
|
|
75
|
+
encoding.color && isConditionalValueDef(encoding.color)
|
|
76
|
+
? (encoding.color as ConditionalValueDef)
|
|
77
|
+
: undefined;
|
|
78
|
+
const colorField = colorEnc?.field;
|
|
79
|
+
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
73
80
|
|
|
74
81
|
// If no color encoding, or sequential color (value-based gradient), render simple bars
|
|
75
82
|
if (!colorField || isSequentialColor) {
|
|
@@ -83,6 +90,7 @@ export function computeBarMarks(
|
|
|
83
90
|
baseline,
|
|
84
91
|
scales,
|
|
85
92
|
isSequentialColor,
|
|
93
|
+
conditionalColor,
|
|
86
94
|
);
|
|
87
95
|
}
|
|
88
96
|
|
|
@@ -167,6 +175,7 @@ function computeSimpleBars(
|
|
|
167
175
|
baseline: number,
|
|
168
176
|
scales: ResolvedScales,
|
|
169
177
|
sequentialColor = false,
|
|
178
|
+
conditionalColor?: ConditionalValueDef,
|
|
170
179
|
): RectMark[] {
|
|
171
180
|
const marks: RectMark[] = [];
|
|
172
181
|
|
|
@@ -178,9 +187,16 @@ function computeSimpleBars(
|
|
|
178
187
|
const bandY = yScale(category);
|
|
179
188
|
if (bandY === undefined) continue;
|
|
180
189
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
let color: string;
|
|
191
|
+
if (conditionalColor) {
|
|
192
|
+
color = String(
|
|
193
|
+
resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
|
|
194
|
+
);
|
|
195
|
+
} else if (sequentialColor) {
|
|
196
|
+
color = getSequentialColor(scales, value);
|
|
197
|
+
} else {
|
|
198
|
+
color = getColor(scales, '__default__');
|
|
199
|
+
}
|
|
184
200
|
const xPos = value >= 0 ? baseline : xScale(value);
|
|
185
201
|
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
186
202
|
|
|
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
20
20
|
|
|
21
21
|
function makeSimpleColumnSpec(): NormalizedChartSpec {
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
markType: 'bar',
|
|
24
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
24
25
|
data: [
|
|
25
26
|
{ month: 'Jan', sales: 120 },
|
|
26
27
|
{ month: 'Feb', sales: 80 },
|
|
@@ -42,7 +43,8 @@ function makeSimpleColumnSpec(): NormalizedChartSpec {
|
|
|
42
43
|
|
|
43
44
|
function makeGroupedColumnSpec(): NormalizedChartSpec {
|
|
44
45
|
return {
|
|
45
|
-
|
|
46
|
+
markType: 'bar',
|
|
47
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
46
48
|
data: [
|
|
47
49
|
{ month: 'Jan', sales: 120, region: 'North' },
|
|
48
50
|
{ month: 'Jan', sales: 80, region: 'South' },
|
|
@@ -67,7 +69,8 @@ function makeGroupedColumnSpec(): NormalizedChartSpec {
|
|
|
67
69
|
|
|
68
70
|
function makeNegativeColumnSpec(): NormalizedChartSpec {
|
|
69
71
|
return {
|
|
70
|
-
|
|
72
|
+
markType: 'bar',
|
|
73
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
71
74
|
data: [
|
|
72
75
|
{ quarter: 'Q1', growth: 5 },
|
|
73
76
|
{ quarter: 'Q2', growth: -3 },
|
|
@@ -211,7 +214,8 @@ describe('computeColumnMarks', () => {
|
|
|
211
214
|
describe('edge cases', () => {
|
|
212
215
|
it('returns empty array when no y encoding', () => {
|
|
213
216
|
const spec: NormalizedChartSpec = {
|
|
214
|
-
|
|
217
|
+
markType: 'bar',
|
|
218
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
215
219
|
data: [{ month: 'Jan', sales: 100 }],
|
|
216
220
|
encoding: {
|
|
217
221
|
x: { field: 'month', type: 'nominal' },
|
|
@@ -230,7 +234,8 @@ describe('computeColumnMarks', () => {
|
|
|
230
234
|
|
|
231
235
|
it('returns empty array for empty data', () => {
|
|
232
236
|
const spec: NormalizedChartSpec = {
|
|
233
|
-
|
|
237
|
+
markType: 'bar',
|
|
238
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
234
239
|
data: [],
|
|
235
240
|
encoding: {
|
|
236
241
|
x: { field: 'month', type: 'nominal' },
|
|
@@ -288,7 +293,8 @@ describe('computeColumnLabels', () => {
|
|
|
288
293
|
|
|
289
294
|
it('applies format with trailing zero trim (~)', () => {
|
|
290
295
|
const spec: NormalizedChartSpec = {
|
|
291
|
-
|
|
296
|
+
markType: 'bar',
|
|
297
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
292
298
|
data: [
|
|
293
299
|
{ company: 'A', cap: 3.1 },
|
|
294
300
|
{ company: 'B', cap: 2.85 },
|
|
@@ -315,7 +321,8 @@ describe('computeColumnLabels', () => {
|
|
|
315
321
|
|
|
316
322
|
it('applies format with literal alpha suffix (e.g. "T")', () => {
|
|
317
323
|
const spec: NormalizedChartSpec = {
|
|
318
|
-
|
|
324
|
+
markType: 'bar',
|
|
325
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
319
326
|
data: [
|
|
320
327
|
{ company: 'Apple', cap: 3.75 },
|
|
321
328
|
{ company: 'Meta', cap: 1.63 },
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
|
+
ConditionalValueDef,
|
|
13
14
|
DataRow,
|
|
14
15
|
Encoding,
|
|
15
16
|
LayoutStrategy,
|
|
@@ -19,9 +20,9 @@ import type {
|
|
|
19
20
|
} from '@opendata-ai/openchart-core';
|
|
20
21
|
import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
|
|
21
22
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
22
|
-
|
|
23
23
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
24
24
|
import type { ResolvedScales } from '../../layout/scales';
|
|
25
|
+
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
25
26
|
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
@@ -71,9 +72,14 @@ export function computeColumnMarks(
|
|
|
71
72
|
|
|
72
73
|
const bandwidth = xScale.bandwidth();
|
|
73
74
|
const baseline = yScale(0);
|
|
74
|
-
const
|
|
75
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
76
|
+
const conditionalColor =
|
|
77
|
+
encoding.color && isConditionalValueDef(encoding.color)
|
|
78
|
+
? (encoding.color as ConditionalValueDef)
|
|
79
|
+
: undefined;
|
|
80
|
+
const colorField = colorEnc?.field;
|
|
75
81
|
|
|
76
|
-
const isSequentialColor =
|
|
82
|
+
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
77
83
|
|
|
78
84
|
// Color encoding present: decide between colored simple columns vs stacked
|
|
79
85
|
if (colorField && !isSequentialColor) {
|
|
@@ -119,6 +125,7 @@ export function computeColumnMarks(
|
|
|
119
125
|
baseline,
|
|
120
126
|
scales,
|
|
121
127
|
isSequentialColor,
|
|
128
|
+
conditionalColor,
|
|
122
129
|
);
|
|
123
130
|
}
|
|
124
131
|
|
|
@@ -133,6 +140,7 @@ function computeSimpleColumns(
|
|
|
133
140
|
baseline: number,
|
|
134
141
|
scales: ResolvedScales,
|
|
135
142
|
sequentialColor = false,
|
|
143
|
+
conditionalColor?: ConditionalValueDef,
|
|
136
144
|
): RectMark[] {
|
|
137
145
|
const marks: RectMark[] = [];
|
|
138
146
|
|
|
@@ -144,9 +152,16 @@ function computeSimpleColumns(
|
|
|
144
152
|
const bandX = xScale(category);
|
|
145
153
|
if (bandX === undefined) continue;
|
|
146
154
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
155
|
+
let color: string;
|
|
156
|
+
if (conditionalColor) {
|
|
157
|
+
color = String(
|
|
158
|
+
resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
|
|
159
|
+
);
|
|
160
|
+
} else if (sequentialColor) {
|
|
161
|
+
color = getSequentialColor(scales, value);
|
|
162
|
+
} else {
|
|
163
|
+
color = getColor(scales, '__default__');
|
|
164
|
+
}
|
|
150
165
|
const yPos = yScale(value);
|
|
151
166
|
const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
|
|
152
167
|
|
|
@@ -20,7 +20,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
20
20
|
|
|
21
21
|
function makeSimpleDotSpec(): NormalizedChartSpec {
|
|
22
22
|
return {
|
|
23
|
-
|
|
23
|
+
markType: 'circle',
|
|
24
|
+
markDef: { type: 'circle' },
|
|
24
25
|
data: [
|
|
25
26
|
{ country: 'USA', score: 85 },
|
|
26
27
|
{ country: 'UK', score: 72 },
|
|
@@ -42,7 +43,8 @@ function makeSimpleDotSpec(): NormalizedChartSpec {
|
|
|
42
43
|
|
|
43
44
|
function makeColoredDotSpec(): NormalizedChartSpec {
|
|
44
45
|
return {
|
|
45
|
-
|
|
46
|
+
markType: 'circle',
|
|
47
|
+
markDef: { type: 'circle' },
|
|
46
48
|
data: [
|
|
47
49
|
{ item: 'Revenue', value: 120, status: 'good' },
|
|
48
50
|
{ item: 'Costs', value: 80, status: 'neutral' },
|
|
@@ -142,7 +144,8 @@ describe('computeDotMarks', () => {
|
|
|
142
144
|
describe('dumbbell (multi-series)', () => {
|
|
143
145
|
function makeDumbbellSpec(): NormalizedChartSpec {
|
|
144
146
|
return {
|
|
145
|
-
|
|
147
|
+
markType: 'circle',
|
|
148
|
+
markDef: { type: 'circle' },
|
|
146
149
|
data: [
|
|
147
150
|
{ country: 'USA', rate: 78, gender: 'Male' },
|
|
148
151
|
{ country: 'USA', rate: 82, gender: 'Female' },
|
|
@@ -264,7 +267,8 @@ describe('computeDotMarks', () => {
|
|
|
264
267
|
describe('edge cases', () => {
|
|
265
268
|
it('returns empty array when no x encoding', () => {
|
|
266
269
|
const spec: NormalizedChartSpec = {
|
|
267
|
-
|
|
270
|
+
markType: 'circle',
|
|
271
|
+
markDef: { type: 'circle' },
|
|
268
272
|
data: [{ country: 'USA', score: 85 }],
|
|
269
273
|
encoding: {
|
|
270
274
|
y: { field: 'country', type: 'nominal' },
|
|
@@ -283,7 +287,8 @@ describe('computeDotMarks', () => {
|
|
|
283
287
|
|
|
284
288
|
it('returns empty array for empty data', () => {
|
|
285
289
|
const spec: NormalizedChartSpec = {
|
|
286
|
-
|
|
290
|
+
markType: 'circle',
|
|
291
|
+
markDef: { type: 'circle' },
|
|
287
292
|
data: [],
|
|
288
293
|
encoding: {
|
|
289
294
|
x: { field: 'score', type: 'quantitative' },
|
|
@@ -22,7 +22,7 @@ import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
|
22
22
|
|
|
23
23
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
24
24
|
import type { ResolvedScales } from '../../layout/scales';
|
|
25
|
-
import { getColor, groupByField } from '../utils';
|
|
25
|
+
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
// Constants
|
|
@@ -68,9 +68,11 @@ export function computeDotMarks(
|
|
|
68
68
|
|
|
69
69
|
const bandwidth = yScale.bandwidth();
|
|
70
70
|
const baseline = xScale(0);
|
|
71
|
-
const
|
|
71
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
72
|
+
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
73
|
+
const colorField = isSequentialColor ? undefined : colorEnc?.field;
|
|
72
74
|
|
|
73
|
-
// Multi-series: dumbbell chart with connecting bars
|
|
75
|
+
// Multi-series (categorical): dumbbell chart with connecting bars
|
|
74
76
|
if (colorField) {
|
|
75
77
|
return computeDumbbellMarks(
|
|
76
78
|
spec.data,
|
|
@@ -94,6 +96,7 @@ export function computeDotMarks(
|
|
|
94
96
|
bandwidth,
|
|
95
97
|
baseline,
|
|
96
98
|
scales,
|
|
99
|
+
isSequentialColor,
|
|
97
100
|
);
|
|
98
101
|
}
|
|
99
102
|
|
|
@@ -198,6 +201,7 @@ function computeLollipopMarks(
|
|
|
198
201
|
bandwidth: number,
|
|
199
202
|
baseline: number,
|
|
200
203
|
scales: ResolvedScales,
|
|
204
|
+
isSequentialColor = false,
|
|
201
205
|
): (PointMark | RectMark)[] {
|
|
202
206
|
const marks: (PointMark | RectMark)[] = [];
|
|
203
207
|
|
|
@@ -212,7 +216,9 @@ function computeLollipopMarks(
|
|
|
212
216
|
const cx = xScale(value);
|
|
213
217
|
const cy = bandY + bandwidth / 2;
|
|
214
218
|
|
|
215
|
-
const color =
|
|
219
|
+
const color = isSequentialColor
|
|
220
|
+
? getSequentialColor(scales, value)
|
|
221
|
+
: getColor(scales, '__default__');
|
|
216
222
|
|
|
217
223
|
// Stem: thin rectangle from baseline to dot center
|
|
218
224
|
const stemX = Math.min(baseline, cx);
|
|
@@ -28,7 +28,8 @@ const compactStrategy: LayoutStrategy = {
|
|
|
28
28
|
|
|
29
29
|
function makeSingleSeriesSpec(): NormalizedChartSpec {
|
|
30
30
|
return {
|
|
31
|
-
|
|
31
|
+
markType: 'line',
|
|
32
|
+
markDef: { type: 'line', point: true },
|
|
32
33
|
data: [
|
|
33
34
|
{ date: '2020-01-01', value: 10 },
|
|
34
35
|
{ date: '2021-01-01', value: 40 },
|
|
@@ -49,7 +50,8 @@ function makeSingleSeriesSpec(): NormalizedChartSpec {
|
|
|
49
50
|
|
|
50
51
|
function makeMultiSeriesSpec(): NormalizedChartSpec {
|
|
51
52
|
return {
|
|
52
|
-
|
|
53
|
+
markType: 'line',
|
|
54
|
+
markDef: { type: 'line', point: true },
|
|
53
55
|
data: [
|
|
54
56
|
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
55
57
|
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
@@ -74,7 +76,8 @@ function makeMultiSeriesSpec(): NormalizedChartSpec {
|
|
|
74
76
|
|
|
75
77
|
function makeMissingDataSpec(): NormalizedChartSpec {
|
|
76
78
|
return {
|
|
77
|
-
|
|
79
|
+
markType: 'line',
|
|
80
|
+
markDef: { type: 'line', point: true },
|
|
78
81
|
data: [
|
|
79
82
|
{ date: '2020-01-01', value: 10 },
|
|
80
83
|
{ date: '2021-01-01', value: null },
|
|
@@ -144,14 +147,14 @@ describe('computeLineMarks', () => {
|
|
|
144
147
|
expect(lineMark.seriesKey).toBeUndefined();
|
|
145
148
|
});
|
|
146
149
|
|
|
147
|
-
it('point marks have
|
|
150
|
+
it('visible point marks have filled opacity when point: true', () => {
|
|
148
151
|
const spec = makeSingleSeriesSpec();
|
|
149
152
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
150
153
|
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
151
154
|
|
|
152
155
|
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
153
156
|
for (const pm of pointMarks) {
|
|
154
|
-
expect(pm.fillOpacity).toBe(
|
|
157
|
+
expect(pm.fillOpacity).toBe(1);
|
|
155
158
|
}
|
|
156
159
|
});
|
|
157
160
|
});
|
|
@@ -397,7 +400,8 @@ describe('computeLineMarks', () => {
|
|
|
397
400
|
describe('edge cases', () => {
|
|
398
401
|
it('returns empty array when no x encoding', () => {
|
|
399
402
|
const spec: NormalizedChartSpec = {
|
|
400
|
-
|
|
403
|
+
markType: 'line',
|
|
404
|
+
markDef: { type: 'line', point: true },
|
|
401
405
|
data: [{ value: 10 }],
|
|
402
406
|
encoding: {
|
|
403
407
|
y: { field: 'value', type: 'quantitative' },
|
|
@@ -416,7 +420,8 @@ describe('computeLineMarks', () => {
|
|
|
416
420
|
|
|
417
421
|
it('returns empty array for empty data', () => {
|
|
418
422
|
const spec: NormalizedChartSpec = {
|
|
419
|
-
|
|
423
|
+
markType: 'line',
|
|
424
|
+
markDef: { type: 'line', point: true },
|
|
420
425
|
data: [],
|
|
421
426
|
encoding: {
|
|
422
427
|
x: { field: 'date', type: 'temporal' },
|
|
@@ -542,7 +547,8 @@ describe('computeAreaMarks', () => {
|
|
|
542
547
|
|
|
543
548
|
it('sorts stacked area with 3+ series and shuffled dates', () => {
|
|
544
549
|
const spec: NormalizedChartSpec = {
|
|
545
|
-
|
|
550
|
+
markType: 'line',
|
|
551
|
+
markDef: { type: 'line', point: true },
|
|
546
552
|
data: [
|
|
547
553
|
{ date: '2022-01-01', value: 30, region: 'A' },
|
|
548
554
|
{ date: '2020-01-01', value: 10, region: 'A' },
|
|
@@ -618,7 +624,8 @@ describe('computeAreaMarks', () => {
|
|
|
618
624
|
// is 300, so the y-scale domain must go up to at least 300. Without the
|
|
619
625
|
// stacked domain fix, the domain only reaches 100 and the top layers clip.
|
|
620
626
|
const spec: NormalizedChartSpec = {
|
|
621
|
-
|
|
627
|
+
markType: 'area',
|
|
628
|
+
markDef: { type: 'area' },
|
|
622
629
|
data: [
|
|
623
630
|
{ date: '2020-01-01', value: 100, group: 'A' },
|
|
624
631
|
{ date: '2021-01-01', value: 100, group: 'A' },
|
|
@@ -659,7 +666,8 @@ describe('computeAreaMarks', () => {
|
|
|
659
666
|
// should handle this gracefully (empty marks) rather than crashing or
|
|
660
667
|
// producing NaN-filled paths.
|
|
661
668
|
const spec: NormalizedChartSpec = {
|
|
662
|
-
|
|
669
|
+
markType: 'area',
|
|
670
|
+
markDef: { type: 'area' },
|
|
663
671
|
data: [
|
|
664
672
|
{ quarter: '2022-Q1', revenue: 45, segment: 'Services' },
|
|
665
673
|
{ quarter: '2022-Q2', revenue: 52, segment: 'Services' },
|
|
@@ -750,7 +758,8 @@ describe('computeLineLabels', () => {
|
|
|
750
758
|
it('collision detection resolves overlapping labels', () => {
|
|
751
759
|
// Create a spec where series end at the same y position
|
|
752
760
|
const spec: NormalizedChartSpec = {
|
|
753
|
-
|
|
761
|
+
markType: 'line',
|
|
762
|
+
markDef: { type: 'line', point: true },
|
|
754
763
|
data: [
|
|
755
764
|
{ date: '2020-01-01', value: 10, country: 'A' },
|
|
756
765
|
{ date: '2021-01-01', value: 30, country: 'A' },
|
|
@@ -908,3 +917,85 @@ describe('seriesStyles', () => {
|
|
|
908
917
|
}
|
|
909
918
|
});
|
|
910
919
|
});
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// Sequential (quantitative) color
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
|
|
925
|
+
describe('sequential color encoding', () => {
|
|
926
|
+
function makeSequentialColorSpec(): NormalizedChartSpec {
|
|
927
|
+
return {
|
|
928
|
+
markType: 'line',
|
|
929
|
+
markDef: { type: 'line' },
|
|
930
|
+
data: [
|
|
931
|
+
{ date: '2020-01-01', value: 10 },
|
|
932
|
+
{ date: '2021-01-01', value: 40 },
|
|
933
|
+
{ date: '2022-01-01', value: 30 },
|
|
934
|
+
],
|
|
935
|
+
encoding: {
|
|
936
|
+
x: { field: 'date', type: 'temporal' },
|
|
937
|
+
y: { field: 'value', type: 'quantitative' },
|
|
938
|
+
color: { field: 'value', type: 'quantitative' },
|
|
939
|
+
},
|
|
940
|
+
chrome: {},
|
|
941
|
+
annotations: [],
|
|
942
|
+
responsive: true,
|
|
943
|
+
theme: {},
|
|
944
|
+
darkMode: 'off',
|
|
945
|
+
labels: { density: 'auto', format: '' },
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
it('produces a single line mark (no grouping) with sequential color', () => {
|
|
950
|
+
const spec = makeSequentialColorSpec();
|
|
951
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
952
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
953
|
+
|
|
954
|
+
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
955
|
+
expect(lineMarks).toHaveLength(1);
|
|
956
|
+
// Should not group into multiple series
|
|
957
|
+
expect(lineMarks[0].seriesKey).toBeUndefined();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('auto-shows point marks for sequential color', () => {
|
|
961
|
+
const spec = makeSequentialColorSpec();
|
|
962
|
+
// markDef.point is NOT set, but points should appear anyway for sequential color
|
|
963
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
964
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
965
|
+
|
|
966
|
+
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
967
|
+
expect(pointMarks).toHaveLength(3);
|
|
968
|
+
// Points should be visible (r > 0)
|
|
969
|
+
expect(pointMarks.every((p) => p.r > 0)).toBe(true);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it('assigns different colors to points based on data value', () => {
|
|
973
|
+
const spec = makeSequentialColorSpec();
|
|
974
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
975
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
976
|
+
|
|
977
|
+
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
978
|
+
// The three points have values 10, 40, 30 so should get distinct colors
|
|
979
|
+
const colors = pointMarks.map((p) => p.fill);
|
|
980
|
+
// Min (10) and max (40) should definitely differ
|
|
981
|
+
expect(colors[0]).not.toBe(colors[1]);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('handles NaN values gracefully in sequential color', () => {
|
|
985
|
+
const spec = makeSequentialColorSpec();
|
|
986
|
+
spec.data = [
|
|
987
|
+
{ date: '2020-01-01', value: 10 },
|
|
988
|
+
{ date: '2021-01-01', value: 'not a number' },
|
|
989
|
+
{ date: '2022-01-01', value: 30 },
|
|
990
|
+
];
|
|
991
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
992
|
+
// Should not throw
|
|
993
|
+
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
994
|
+
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
995
|
+
// All points should have a valid fill color (string)
|
|
996
|
+
for (const p of pointMarks) {
|
|
997
|
+
expect(typeof p.fill).toBe('string');
|
|
998
|
+
expect(p.fill.length).toBeGreaterThan(0);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
curveBasis,
|
|
3
|
+
curveCardinal,
|
|
4
|
+
curveLinear,
|
|
5
|
+
curveMonotoneX,
|
|
6
|
+
curveNatural,
|
|
7
|
+
curveStep,
|
|
8
|
+
curveStepAfter,
|
|
9
|
+
curveStepBefore,
|
|
10
|
+
} from 'd3-shape';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { resolveCurve } from '../curves';
|
|
13
|
+
|
|
14
|
+
describe('resolveCurve', () => {
|
|
15
|
+
it('defaults to curveMonotoneX when no interpolation is specified', () => {
|
|
16
|
+
expect(resolveCurve()).toBe(curveMonotoneX);
|
|
17
|
+
expect(resolveCurve(undefined)).toBe(curveMonotoneX);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('maps "linear" to curveLinear', () => {
|
|
21
|
+
expect(resolveCurve('linear')).toBe(curveLinear);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('maps "monotone" to curveMonotoneX', () => {
|
|
25
|
+
expect(resolveCurve('monotone')).toBe(curveMonotoneX);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('maps "step" to curveStep', () => {
|
|
29
|
+
expect(resolveCurve('step')).toBe(curveStep);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('maps "step-before" to curveStepBefore', () => {
|
|
33
|
+
expect(resolveCurve('step-before')).toBe(curveStepBefore);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('maps "step-after" to curveStepAfter', () => {
|
|
37
|
+
expect(resolveCurve('step-after')).toBe(curveStepAfter);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('maps "basis" to curveBasis', () => {
|
|
41
|
+
expect(resolveCurve('basis')).toBe(curveBasis);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('maps "cardinal" to curveCardinal', () => {
|
|
45
|
+
expect(resolveCurve('cardinal')).toBe(curveCardinal);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('maps "natural" to curveNatural', () => {
|
|
49
|
+
expect(resolveCurve('natural')).toBe(curveNatural);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -22,7 +22,8 @@ const compactStrategy: LayoutStrategy = {
|
|
|
22
22
|
|
|
23
23
|
function makeLine(series: string, color: string, yOffset: number): LineMark {
|
|
24
24
|
return {
|
|
25
|
-
|
|
25
|
+
markType: 'line',
|
|
26
|
+
markDef: { type: 'line' },
|
|
26
27
|
points: [
|
|
27
28
|
{ x: 50, y: 100 + yOffset },
|
|
28
29
|
{ x: 150, y: 80 + yOffset },
|