@opendata-ai/openchart-engine 6.15.0 → 6.16.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.js +57 -27
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +32 -0
- package/src/annotations/resolve-text.ts +1 -0
- package/src/charts/bar/compute.ts +28 -5
- package/src/compile.ts +18 -9
- package/src/compiler/validate.ts +1 -1
- package/src/layout/dimensions.ts +1 -1
- package/src/layout/scales.ts +10 -2
- package/src/legend/compute.ts +18 -7
- package/src/tables/__tests__/category-colors.test.ts +50 -8
- package/src/tables/category-colors.ts +9 -3
- package/src/transforms/__tests__/predicates.test.ts +17 -0
- package/src/transforms/predicates.ts +7 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.16.0",
|
|
4
4
|
"description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "6.
|
|
48
|
+
"@opendata-ai/openchart-core": "6.16.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -243,6 +243,38 @@ describe('computeLegend', () => {
|
|
|
243
243
|
expect(maxRowsVisible).toBeGreaterThan(defaultVisible);
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
it('uses explicit domain+range colors in legend entries', () => {
|
|
247
|
+
const specExplicit: NormalizedChartSpec = {
|
|
248
|
+
...specWithColor,
|
|
249
|
+
data: [
|
|
250
|
+
{ date: '2020', value: 10, country: 'UK' },
|
|
251
|
+
{ date: '2021', value: 20, country: 'US' },
|
|
252
|
+
{ date: '2022', value: 30, country: 'Germany' },
|
|
253
|
+
],
|
|
254
|
+
encoding: {
|
|
255
|
+
x: { field: 'date', type: 'temporal' },
|
|
256
|
+
y: { field: 'value', type: 'quantitative' },
|
|
257
|
+
color: {
|
|
258
|
+
field: 'country',
|
|
259
|
+
type: 'nominal',
|
|
260
|
+
scale: {
|
|
261
|
+
domain: ['US', 'UK', 'Germany'],
|
|
262
|
+
range: ['#ff0000', '#0000ff', '#00ff00'],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
const legend = computeLegend(specExplicit, compactStrategy, theme, chartArea);
|
|
268
|
+
// Data order is UK, US, Germany but domain order is US, UK, Germany
|
|
269
|
+
// Legend should match colors to domain indices, not data order
|
|
270
|
+
const ukEntry = legend.entries.find((e) => e.label === 'UK')!;
|
|
271
|
+
const usEntry = legend.entries.find((e) => e.label === 'US')!;
|
|
272
|
+
const deEntry = legend.entries.find((e) => e.label === 'Germany')!;
|
|
273
|
+
expect(usEntry.color).toBe('#ff0000');
|
|
274
|
+
expect(ukEntry.color).toBe('#0000ff');
|
|
275
|
+
expect(deEntry.color).toBe('#00ff00');
|
|
276
|
+
});
|
|
277
|
+
|
|
246
278
|
it('uses correct swatch shape for chart type', () => {
|
|
247
279
|
const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
|
|
248
280
|
expect(lineLegend.entries[0].shape).toBe('line');
|
|
@@ -12,18 +12,41 @@ import type {
|
|
|
12
12
|
Encoding,
|
|
13
13
|
GradientDef,
|
|
14
14
|
LayoutStrategy,
|
|
15
|
+
LinearGradient,
|
|
15
16
|
MarkAria,
|
|
16
17
|
Rect,
|
|
17
18
|
RectMark,
|
|
18
19
|
} from '@opendata-ai/openchart-core';
|
|
19
20
|
import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
|
|
20
21
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
21
|
-
|
|
22
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
23
23
|
import type { ResolvedScales } from '../../layout/scales';
|
|
24
24
|
import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
|
|
25
25
|
import { getColor, getSequentialColor, groupByField } from '../utils';
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Auto-orient a gradient for horizontal bars.
|
|
29
|
+
*
|
|
30
|
+
* If the gradient uses the default top-to-bottom direction (no explicit
|
|
31
|
+
* x1/y1/x2/y2, or the defaults x1:0, y1:0, x2:0, y2:1), rotate it to
|
|
32
|
+
* left-to-right so the gradient follows the bar's data direction.
|
|
33
|
+
*
|
|
34
|
+
* Gradients with explicit non-default coordinates are left unchanged.
|
|
35
|
+
*/
|
|
36
|
+
function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
|
|
37
|
+
if (grad.gradient !== 'linear') return grad;
|
|
38
|
+
const lg = grad as LinearGradient;
|
|
39
|
+
// Only auto-orient if using the default vertical direction.
|
|
40
|
+
// Default is x1:0, y1:0, x2:0, y2:1 (top-to-bottom).
|
|
41
|
+
const isDefaultVertical =
|
|
42
|
+
(lg.x1 === undefined || lg.x1 === 0) &&
|
|
43
|
+
(lg.y1 === undefined || lg.y1 === 0) &&
|
|
44
|
+
(lg.x2 === undefined || lg.x2 === 0) &&
|
|
45
|
+
(lg.y2 === undefined || lg.y2 === 1);
|
|
46
|
+
if (!isDefaultVertical) return grad;
|
|
47
|
+
return { ...lg, x1: 0, y1: 0, x2: 1, y2: 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
// ---------------------------------------------------------------------------
|
|
28
51
|
// Constants
|
|
29
52
|
// ---------------------------------------------------------------------------
|
|
@@ -207,7 +230,7 @@ function computeStackedBars(
|
|
|
207
230
|
y: bandY,
|
|
208
231
|
width: barWidth,
|
|
209
232
|
height: bandwidth,
|
|
210
|
-
fill: color,
|
|
233
|
+
fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
|
|
211
234
|
cornerRadius: 0,
|
|
212
235
|
data: row as Record<string, unknown>,
|
|
213
236
|
aria,
|
|
@@ -277,7 +300,7 @@ function computeGroupedBars(
|
|
|
277
300
|
y: subY,
|
|
278
301
|
width: barWidth,
|
|
279
302
|
height: subBandHeight,
|
|
280
|
-
fill: color,
|
|
303
|
+
fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
|
|
281
304
|
cornerRadius: 2,
|
|
282
305
|
data: row as Record<string, unknown>,
|
|
283
306
|
aria,
|
|
@@ -327,7 +350,7 @@ function computeColoredBars(
|
|
|
327
350
|
y: bandY,
|
|
328
351
|
width: barWidth,
|
|
329
352
|
height: bandwidth,
|
|
330
|
-
fill: color,
|
|
353
|
+
fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
|
|
331
354
|
cornerRadius: 2,
|
|
332
355
|
data: row as Record<string, unknown>,
|
|
333
356
|
aria,
|
|
@@ -387,7 +410,7 @@ function computeSimpleBars(
|
|
|
387
410
|
y: bandY,
|
|
388
411
|
width: barWidth,
|
|
389
412
|
height: bandwidth,
|
|
390
|
-
fill: color,
|
|
413
|
+
fill: isGradientDef(color) ? orientGradientForHorizontalBar(color) : color,
|
|
391
414
|
cornerRadius: 2,
|
|
392
415
|
data: row as Record<string, unknown>,
|
|
393
416
|
aria,
|
package/src/compile.ts
CHANGED
|
@@ -375,19 +375,28 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
375
375
|
// Compute scales
|
|
376
376
|
const scales = computeScales(renderSpec, chartArea, renderSpec.data);
|
|
377
377
|
|
|
378
|
-
// Update color scale to use theme palette
|
|
378
|
+
// Update color scale to use theme palette (only when user hasn't provided an explicit range)
|
|
379
379
|
if (scales.color) {
|
|
380
|
+
const hasExplicitRange = !!(
|
|
381
|
+
renderSpec.encoding.color &&
|
|
382
|
+
'field' in renderSpec.encoding.color &&
|
|
383
|
+
(renderSpec.encoding.color.scale?.range as string[] | undefined)?.length
|
|
384
|
+
);
|
|
380
385
|
if (scales.color.type === 'sequential') {
|
|
381
386
|
// Sequential: use first sequential palette (or fall back to categorical endpoints)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
+
if (!hasExplicitRange) {
|
|
388
|
+
const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
389
|
+
(scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
|
|
390
|
+
seqStops[0],
|
|
391
|
+
seqStops[seqStops.length - 1],
|
|
392
|
+
]);
|
|
393
|
+
}
|
|
387
394
|
} else {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
395
|
+
if (!hasExplicitRange) {
|
|
396
|
+
(scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
|
|
397
|
+
theme.colors.categorical,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
391
400
|
}
|
|
392
401
|
}
|
|
393
402
|
|
package/src/compiler/validate.ts
CHANGED
|
@@ -186,7 +186,7 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
186
186
|
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
187
187
|
path: `encoding.${channel}.field`,
|
|
188
188
|
code: 'MISSING_FIELD',
|
|
189
|
-
suggestion: `
|
|
189
|
+
suggestion: `For constant colors, use mark.fill (e.g., mark: { type: "bar", fill: "#1b7fa3" }) instead of encoding.${channel}. Encoding channels require a data field: ${availableColumns}`,
|
|
190
190
|
});
|
|
191
191
|
continue;
|
|
192
192
|
}
|
package/src/layout/dimensions.ts
CHANGED
package/src/layout/scales.ts
CHANGED
|
@@ -512,7 +512,11 @@ function buildOrdinalColorScale(
|
|
|
512
512
|
? explicitDomain.map(String)
|
|
513
513
|
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
514
514
|
|
|
515
|
-
|
|
515
|
+
// Use explicit range if provided, otherwise fall back to theme palette
|
|
516
|
+
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
517
|
+
const colors = explicitRange ?? palette;
|
|
518
|
+
|
|
519
|
+
const scale = scaleOrdinal<string>().domain(values).range(colors);
|
|
516
520
|
|
|
517
521
|
return { scale, type: 'ordinal', channel };
|
|
518
522
|
}
|
|
@@ -526,9 +530,13 @@ function buildSequentialColorScale(
|
|
|
526
530
|
const domainMin = min(values) ?? 0;
|
|
527
531
|
const domainMax = max(values) ?? 1;
|
|
528
532
|
|
|
533
|
+
// Use explicit range if provided, otherwise fall back to theme palette endpoints
|
|
534
|
+
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
535
|
+
const colors = explicitRange ?? palette;
|
|
536
|
+
|
|
529
537
|
const scale = scaleLinear<string>()
|
|
530
538
|
.domain([domainMin, domainMax])
|
|
531
|
-
.range([
|
|
539
|
+
.range([colors[0], colors[colors.length - 1]])
|
|
532
540
|
.clamp(true);
|
|
533
541
|
|
|
534
542
|
// Cast: sequential color scale (number -> string) is structurally incompatible
|
package/src/legend/compute.ts
CHANGED
|
@@ -70,15 +70,26 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
70
70
|
if (colorEnc.type === 'quantitative') return [];
|
|
71
71
|
|
|
72
72
|
const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
|
|
73
|
-
const
|
|
73
|
+
const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
|
|
74
|
+
const explicitRange = colorEnc.scale?.range as string[] | undefined;
|
|
75
|
+
const palette = explicitRange ?? theme.colors.categorical;
|
|
74
76
|
const shape = swatchShapeForType(spec.markType);
|
|
75
77
|
|
|
76
|
-
return uniqueValues.map((value, i) =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
return uniqueValues.map((value, i) => {
|
|
79
|
+
// When explicit domain+range are provided, look up the color by domain index
|
|
80
|
+
// so legend colors match the mark colors exactly.
|
|
81
|
+
let colorIndex = i;
|
|
82
|
+
if (explicitDomain && explicitRange) {
|
|
83
|
+
const domainIdx = explicitDomain.indexOf(value);
|
|
84
|
+
if (domainIdx >= 0) colorIndex = domainIdx;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
label: value,
|
|
88
|
+
color: palette[colorIndex % palette.length],
|
|
89
|
+
shape,
|
|
90
|
+
active: true,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
/**
|
|
@@ -89,7 +89,7 @@ describe('computeCategoryColors', () => {
|
|
|
89
89
|
}
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it('dark mode
|
|
92
|
+
it('dark mode preserves explicit user-provided colors', () => {
|
|
93
93
|
const col: ColumnConfig = {
|
|
94
94
|
key: 'status',
|
|
95
95
|
categoryColors: {
|
|
@@ -97,18 +97,32 @@ describe('computeCategoryColors', () => {
|
|
|
97
97
|
inactive: '#ff0000',
|
|
98
98
|
},
|
|
99
99
|
};
|
|
100
|
-
const lightTheme = getTheme(false);
|
|
101
100
|
const darkTheme = getTheme(true);
|
|
101
|
+
const colors = computeCategoryColors(data, col, darkTheme, true);
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
// Explicit colors should NOT be adapted for dark mode
|
|
104
|
+
expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
|
|
105
|
+
expect(colors.get(1)!.backgroundColor).toBe('#ff0000');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('dark mode adapts auto-assigned palette colors but not explicit ones', () => {
|
|
109
|
+
// Use a bright yellow that will definitely get adapted in dark mode
|
|
110
|
+
const col: ColumnConfig = {
|
|
111
|
+
key: 'status',
|
|
112
|
+
categoryColors: {
|
|
113
|
+
active: '#ffff00',
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const darkTheme = getTheme(true);
|
|
104
117
|
const darkColors = computeCategoryColors(data, col, darkTheme, true);
|
|
105
118
|
|
|
106
|
-
|
|
119
|
+
// Explicit color should be preserved as-is (not adapted)
|
|
120
|
+
expect(darkColors.get(0)!.backgroundColor).toBe('#ffff00');
|
|
107
121
|
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
expect(
|
|
122
|
+
// Auto-assigned palette colors should still be present (adaptation may or
|
|
123
|
+
// may not visually change them, but the code path runs adaptColorForDarkMode)
|
|
124
|
+
expect(darkColors.has(1)).toBe(true); // inactive
|
|
125
|
+
expect(darkColors.has(3)).toBe(true); // pending
|
|
112
126
|
});
|
|
113
127
|
|
|
114
128
|
it('dark mode text contrast still meets AA', () => {
|
|
@@ -137,6 +151,34 @@ describe('computeCategoryColors', () => {
|
|
|
137
151
|
expect(colors.size).toBe(0);
|
|
138
152
|
});
|
|
139
153
|
|
|
154
|
+
it('skips transparent and none category colors', () => {
|
|
155
|
+
const dataWithSpecial = [
|
|
156
|
+
{ status: 'active' },
|
|
157
|
+
{ status: 'inactive' },
|
|
158
|
+
{ status: 'pending' },
|
|
159
|
+
{ status: 'disabled' },
|
|
160
|
+
];
|
|
161
|
+
const col: ColumnConfig = {
|
|
162
|
+
key: 'status',
|
|
163
|
+
categoryColors: {
|
|
164
|
+
active: '#00ff00',
|
|
165
|
+
inactive: 'transparent',
|
|
166
|
+
pending: 'none',
|
|
167
|
+
disabled: '#cccccc',
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
const theme = getTheme();
|
|
171
|
+
const colors = computeCategoryColors(dataWithSpecial, col, theme, false);
|
|
172
|
+
|
|
173
|
+
// transparent and none rows should be skipped
|
|
174
|
+
expect(colors.has(1)).toBe(false); // inactive = transparent
|
|
175
|
+
expect(colors.has(2)).toBe(false); // pending = none
|
|
176
|
+
// explicit colors should still be present
|
|
177
|
+
expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
|
|
178
|
+
expect(colors.get(3)!.backgroundColor).toBe('#cccccc');
|
|
179
|
+
expect(colors.size).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
140
182
|
it('skips null values', () => {
|
|
141
183
|
const dataWithNull = [{ status: 'active' }, { status: null }, { status: 'inactive' }];
|
|
142
184
|
const col: ColumnConfig = {
|
|
@@ -39,9 +39,15 @@ export function computeCategoryColors(
|
|
|
39
39
|
|
|
40
40
|
const key = String(raw);
|
|
41
41
|
let bg: string;
|
|
42
|
+
let isExplicit = false;
|
|
42
43
|
|
|
43
|
-
if (explicitMap[key]) {
|
|
44
|
+
if (explicitMap[key] != null) {
|
|
45
|
+
if (explicitMap[key] === 'transparent' || explicitMap[key] === 'none') {
|
|
46
|
+
// Skip transparent/none — let the cell inherit default table styling
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
44
49
|
bg = explicitMap[key];
|
|
50
|
+
isExplicit = true;
|
|
45
51
|
} else if (autoAssigned.has(key)) {
|
|
46
52
|
bg = autoAssigned.get(key)!;
|
|
47
53
|
} else {
|
|
@@ -51,8 +57,8 @@ export function computeCategoryColors(
|
|
|
51
57
|
autoAssigned.set(key, bg);
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
// Dark mode adaptation
|
|
55
|
-
if (darkMode) {
|
|
60
|
+
// Dark mode adaptation (skip for explicit user-provided colors)
|
|
61
|
+
if (darkMode && !isExplicit) {
|
|
56
62
|
bg = adaptColorForDarkMode(bg, lightBg, darkBg);
|
|
57
63
|
}
|
|
58
64
|
|
|
@@ -14,6 +14,15 @@ describe('evaluatePredicate', () => {
|
|
|
14
14
|
it('matches equal numeric value', () => {
|
|
15
15
|
expect(evaluatePredicate({ age: 30 }, { field: 'age', equal: 30 })).toBe(true);
|
|
16
16
|
});
|
|
17
|
+
|
|
18
|
+
it('matches string value against numeric predicate via loose equality', () => {
|
|
19
|
+
// CSV/JSON parsing often produces string values for numeric fields
|
|
20
|
+
expect(evaluatePredicate({ id: '181' }, { field: 'id', equal: 181 })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('matches numeric value against string predicate via loose equality', () => {
|
|
24
|
+
expect(evaluatePredicate({ id: 181 }, { field: 'id', equal: '181' })).toBe(true);
|
|
25
|
+
});
|
|
17
26
|
});
|
|
18
27
|
|
|
19
28
|
describe('FieldPredicate: lt/lte/gt/gte', () => {
|
|
@@ -62,6 +71,14 @@ describe('evaluatePredicate', () => {
|
|
|
62
71
|
it('rejects values not in the set', () => {
|
|
63
72
|
expect(evaluatePredicate({ c: 'green' }, { field: 'c', oneOf: ['red', 'blue'] })).toBe(false);
|
|
64
73
|
});
|
|
74
|
+
|
|
75
|
+
it('matches string value against numeric oneOf via loose equality', () => {
|
|
76
|
+
expect(evaluatePredicate({ id: '181' }, { field: 'id', oneOf: [181, 200] })).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects value not in numeric oneOf set', () => {
|
|
80
|
+
expect(evaluatePredicate({ id: '999' }, { field: 'id', oneOf: [181, 200] })).toBe(false);
|
|
81
|
+
});
|
|
65
82
|
});
|
|
66
83
|
|
|
67
84
|
describe('FieldPredicate: valid', () => {
|
|
@@ -26,9 +26,11 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
|
|
|
26
26
|
return pred.valid ? isValid : !isValid;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// equal
|
|
29
|
+
// equal: use loose equality so "181" == 181 matches across type boundaries
|
|
30
|
+
// (data values may arrive as strings from CSV/JSON parsing)
|
|
30
31
|
if (pred.equal !== undefined) {
|
|
31
|
-
|
|
32
|
+
// biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for CSV/JSON type coercion
|
|
33
|
+
return value == pred.equal;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
// Numeric comparisons
|
|
@@ -53,9 +55,10 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
|
|
|
53
55
|
return numValue >= min && numValue <= max;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
// oneOf:
|
|
58
|
+
// oneOf: use loose equality (same rationale as equal above)
|
|
57
59
|
if (pred.oneOf !== undefined) {
|
|
58
|
-
|
|
60
|
+
// biome-ignore lint/suspicious/noDoubleEquals: intentional loose equality for CSV/JSON type coercion
|
|
61
|
+
return pred.oneOf.some((v) => v == value);
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
// No condition specified, default to true
|