@opendata-ai/openchart-engine 8.0.0-rc.6 → 8.0.0-rc.8
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 +122 -34
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +84 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +162 -0
- package/src/compiler/validate.ts +155 -27
- package/src/geo/compile-map.ts +12 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "8.0.0-rc.
|
|
3
|
+
"version": "8.0.0-rc.8",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@opendata-ai/openchart-core": "8.0.0-rc.
|
|
51
|
+
"@opendata-ai/openchart-core": "8.0.0-rc.8",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -283,6 +283,64 @@ describe('a callout lands where the anchor says it should', () => {
|
|
|
283
283
|
});
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
// --- auto-placement commits to the side it chose ---------------------------
|
|
287
|
+
|
|
288
|
+
describe('an auto-placed callout on the right edge anchors its text to the left', () => {
|
|
289
|
+
// A point at the far right edge forces the scored search to place the block on
|
|
290
|
+
// its LEFT, which it signals by returning `textAnchor: 'end'`. The anchor must
|
|
291
|
+
// actually be STAMPED on the label (and its subtitle): with a `start` anchor the
|
|
292
|
+
// same origin makes the text extend right, over the data point. This is the
|
|
293
|
+
// mutation the suite used to miss -- deleting the anchor assignment in
|
|
294
|
+
// `moveAnnotationBy` kept every test green, because the only test that looked at
|
|
295
|
+
// the anchor compared it against bounds derived from the same (wrong) anchor.
|
|
296
|
+
test('the search picks the left side and the anchor stamp reaches the whole block', () => {
|
|
297
|
+
const data = Array.from({ length: 21 }, (_, i) => ({
|
|
298
|
+
x: `2024-01-${String(i + 1).padStart(2, '0')}`,
|
|
299
|
+
y: 40 + 2 * i,
|
|
300
|
+
}));
|
|
301
|
+
const layout: ChartLayout = compileChart(
|
|
302
|
+
{
|
|
303
|
+
mark: 'line',
|
|
304
|
+
data,
|
|
305
|
+
encoding: {
|
|
306
|
+
x: { field: 'x', type: 'temporal' },
|
|
307
|
+
y: { field: 'y', type: 'quantitative' },
|
|
308
|
+
},
|
|
309
|
+
annotations: [
|
|
310
|
+
{
|
|
311
|
+
type: 'text',
|
|
312
|
+
x: data[20].x,
|
|
313
|
+
y: data[20].y,
|
|
314
|
+
text: 'Peak of the series',
|
|
315
|
+
subtitle: 'highest reading of the month',
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
} as ChartSpec,
|
|
319
|
+
{ width: 800, height: 460 },
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const a = layout.annotations![0];
|
|
323
|
+
const label = a.label!;
|
|
324
|
+
const bounds = label.bounds!;
|
|
325
|
+
const dot = a.dot!;
|
|
326
|
+
|
|
327
|
+
// The search really chose the left side. If a future engine change makes it
|
|
328
|
+
// pick another candidate, fail loudly here rather than silently stop guarding.
|
|
329
|
+
expect(label.style.textAnchor).toBe('end');
|
|
330
|
+
|
|
331
|
+
// An end-anchored box extends LEFT of its origin.
|
|
332
|
+
expect(Math.abs(bounds.x + bounds.width - label.x)).toBeLessThanOrEqual(1);
|
|
333
|
+
|
|
334
|
+
// The whole block sits clear of the point, on its left.
|
|
335
|
+
expect(bounds.x + bounds.width).toBeLessThan(dot.x);
|
|
336
|
+
|
|
337
|
+
// The anchor stamp reaches the subtitle too -- `moveAnnotationBy` applies it
|
|
338
|
+
// to both lines, and an unstamped subtitle would jut right from a shared origin.
|
|
339
|
+
expect(a.subtitle!.style.textAnchor).toBe('end');
|
|
340
|
+
expect(a.subtitle!.x).toBeCloseTo(label.x, 0);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
286
344
|
// --- the leader connects the block to the point --------------------------
|
|
287
345
|
|
|
288
346
|
describe('the leader actually connects the words to the data', () => {
|
|
@@ -362,6 +420,29 @@ describe('the leader actually connects the words to the data', () => {
|
|
|
362
420
|
expect(asserted).toBeGreaterThan(30);
|
|
363
421
|
});
|
|
364
422
|
|
|
423
|
+
test("the author's connectorOffset.from survives auto-placement", () => {
|
|
424
|
+
// `connectorOffset.to` is baked into `connector.endpoint`, so it survives every
|
|
425
|
+
// later geometry rebuild for free. `from` used to be applied once at resolve and
|
|
426
|
+
// then thrown away by `refreshConnector` -- so on an auto-placed annotation
|
|
427
|
+
// (which ALWAYS moves in Pass 2) the author's nudge did exactly nothing, and an
|
|
428
|
+
// edit-mode drag of the from handle was a silent no-op after re-render.
|
|
429
|
+
const control = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak' }]);
|
|
430
|
+
const nudged = first([
|
|
431
|
+
{
|
|
432
|
+
type: 'text',
|
|
433
|
+
x: 'Mar',
|
|
434
|
+
y: 30,
|
|
435
|
+
text: 'Peak',
|
|
436
|
+
connectorOffset: { from: { dx: 15, dy: 9 } },
|
|
437
|
+
},
|
|
438
|
+
]);
|
|
439
|
+
|
|
440
|
+
const from = nudged.label!.connector!.from;
|
|
441
|
+
const controlFrom = control.label!.connector!.from;
|
|
442
|
+
expect(from.x - controlFrom.x).toBeCloseTo(15, 5);
|
|
443
|
+
expect(from.y - controlFrom.y).toBeCloseTo(9, 5);
|
|
444
|
+
});
|
|
445
|
+
|
|
365
446
|
test('a drop-line hangs straight down from the words to the point', () => {
|
|
366
447
|
const a = first([{ type: 'text', x: 'Mar', y: 30, text: 'Peak', connector: 'drop-line' }]);
|
|
367
448
|
const c = a.label!.connector!;
|
|
@@ -718,4 +799,54 @@ describe('callouts are anchored in the data, not in pixels', () => {
|
|
|
718
799
|
expect(all).toHaveLength(1);
|
|
719
800
|
expect(all[0].label!.text).toBe('Real');
|
|
720
801
|
});
|
|
802
|
+
|
|
803
|
+
test('a dropped annotation does not shift which of its neighbours counts as pinned', () => {
|
|
804
|
+
// The resolved array is a FILTERED view of the spec array: dropping spec 0
|
|
805
|
+
// shifts every later annotation down one slot. Anything that reaches back to
|
|
806
|
+
// the authored spec (collision resolution's "does it carry an offset?" check)
|
|
807
|
+
// must key on the stamped `specIndex`, not on resolved position -- indexed by
|
|
808
|
+
// position, the pinned annotation reads the dropped spec's (offset-less) slot,
|
|
809
|
+
// loses its pin, and gets displaced off its authored spot.
|
|
810
|
+
const bogus: Ann = { type: 'text', x: 'NotAMonth', y: 30, text: 'Bogus' };
|
|
811
|
+
const pinned: Ann = {
|
|
812
|
+
type: 'text',
|
|
813
|
+
x: 'Mar',
|
|
814
|
+
y: 30,
|
|
815
|
+
text: 'Pinned block here',
|
|
816
|
+
anchor: 'top',
|
|
817
|
+
offset: { dy: -25 },
|
|
818
|
+
};
|
|
819
|
+
const movable: Ann = {
|
|
820
|
+
type: 'text',
|
|
821
|
+
x: 'Mar',
|
|
822
|
+
y: 30,
|
|
823
|
+
text: 'Other block here',
|
|
824
|
+
anchor: 'top',
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const resolved = annotationsFor([bogus, pinned, movable]);
|
|
828
|
+
|
|
829
|
+
// The bogus one is dropped; the survivors keep their spec identities.
|
|
830
|
+
expect(resolved).toHaveLength(2);
|
|
831
|
+
expect(resolved.map((a) => a.specIndex)).toEqual([1, 2]);
|
|
832
|
+
|
|
833
|
+
const [pinnedResolved, movableResolved] = resolved;
|
|
834
|
+
|
|
835
|
+
// Pinning held despite the collision: same spot as when compiled alone.
|
|
836
|
+
const pinnedSolo = first([pinned]);
|
|
837
|
+
expect(pinnedResolved.label!.y - pinnedResolved.dot!.y).toBeCloseTo(
|
|
838
|
+
pinnedSolo.label!.y - pinnedSolo.dot!.y,
|
|
839
|
+
0,
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
// And the movable one actually moved -- without this half, the test passes
|
|
843
|
+
// on a pair that never collided in the first place.
|
|
844
|
+
const movableSolo = first([movable]);
|
|
845
|
+
expect(movableResolved.label!.y - movableResolved.dot!.y).not.toBeCloseTo(
|
|
846
|
+
movableSolo.label!.y - movableSolo.dot!.y,
|
|
847
|
+
1,
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
expect(overlaps(pinnedResolved.bounds!, movableResolved.bounds!)).toBe(false);
|
|
851
|
+
});
|
|
721
852
|
});
|
|
@@ -349,7 +349,13 @@ export function refreshConnector(
|
|
|
349
349
|
const exit = connectorExit(bounds, endpoint.x, endpoint.y);
|
|
350
350
|
if (!exit) return undefined;
|
|
351
351
|
|
|
352
|
-
|
|
352
|
+
// Re-apply the author's from-end nudge BEFORE the pullback, so the pullback
|
|
353
|
+
// direction is measured from the real origin -- exactly as
|
|
354
|
+
// `resolveTextAnnotation` does on first resolve.
|
|
355
|
+
const from = {
|
|
356
|
+
x: exit.x + (connector.fromOffset?.dx ?? 0),
|
|
357
|
+
y: exit.y + (connector.fromOffset?.dy ?? 0),
|
|
358
|
+
};
|
|
353
359
|
|
|
354
360
|
const gap = connectorPullbackGap(markerRadius, connector.arrow);
|
|
355
361
|
const cdx = endpoint.x - from.x;
|
|
@@ -351,6 +351,12 @@ export function resolveTextAnnotation(
|
|
|
351
351
|
from,
|
|
352
352
|
to,
|
|
353
353
|
endpoint,
|
|
354
|
+
fromOffset: annotation.connectorOffset?.from
|
|
355
|
+
? {
|
|
356
|
+
dx: annotation.connectorOffset.from.dx ?? 0,
|
|
357
|
+
dy: annotation.connectorOffset.from.dy ?? 0,
|
|
358
|
+
}
|
|
359
|
+
: undefined,
|
|
354
360
|
stroke: connectorStroke,
|
|
355
361
|
style: connectorStyle,
|
|
356
362
|
arrow: connectorArrow,
|
|
@@ -66,6 +66,90 @@ describe('did-you-mean field suggestions', () => {
|
|
|
66
66
|
expect(suggestion).toContain('Did you mean "quarter"?');
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it('suggests the nearest column for a misspelled sankey encoding field', () => {
|
|
70
|
+
const result = validateSpec({
|
|
71
|
+
type: 'sankey',
|
|
72
|
+
data: [{ source: 'A', target: 'B', value: 10 }],
|
|
73
|
+
encoding: {
|
|
74
|
+
source: { field: 'sorce', type: 'nominal' }, // typo of "source"
|
|
75
|
+
target: { field: 'target', type: 'nominal' },
|
|
76
|
+
value: { field: 'value', type: 'quantitative' },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(result.valid).toBe(false);
|
|
80
|
+
const suggestion = suggestionFor(result, 'encoding.source.field');
|
|
81
|
+
expect(suggestion).toContain('Did you mean "source"?');
|
|
82
|
+
expect(suggestion).toContain('target');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('suggests the nearest column for a misspelled tilemap encoding field', () => {
|
|
86
|
+
const result = validateSpec({
|
|
87
|
+
type: 'tilemap',
|
|
88
|
+
data: [{ state: 'CA', value: 12000 }],
|
|
89
|
+
encoding: {
|
|
90
|
+
state: { field: 'state', type: 'nominal' },
|
|
91
|
+
value: { field: 'vlaue', type: 'quantitative' }, // typo of "value"
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
expect(result.valid).toBe(false);
|
|
95
|
+
const suggestion = suggestionFor(result, 'encoding.value.field');
|
|
96
|
+
expect(suggestion).toContain('Did you mean "value"?');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('suggests the nearest column for a misspelled barlist encoding field', () => {
|
|
100
|
+
const result = validateSpec({
|
|
101
|
+
type: 'barlist',
|
|
102
|
+
data: [{ label: 'A', value: 42 }],
|
|
103
|
+
encoding: {
|
|
104
|
+
label: { field: 'lable', type: 'nominal' }, // typo of "label"
|
|
105
|
+
value: { field: 'value', type: 'quantitative' },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(result.valid).toBe(false);
|
|
109
|
+
const suggestion = suggestionFor(result, 'encoding.label.field');
|
|
110
|
+
expect(suggestion).toContain('Did you mean "label"?');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('suggests the nearest column for a misspelled optional barlist channel field', () => {
|
|
114
|
+
const result = validateSpec({
|
|
115
|
+
type: 'barlist',
|
|
116
|
+
data: [{ label: 'A', value: 42, group: 'x' }],
|
|
117
|
+
encoding: {
|
|
118
|
+
label: { field: 'label', type: 'nominal' },
|
|
119
|
+
value: { field: 'value', type: 'quantitative' },
|
|
120
|
+
color: { field: 'gruop', type: 'nominal' }, // typo of "group"
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(result.valid).toBe(false);
|
|
124
|
+
const suggestion = suggestionFor(result, 'encoding.color.field');
|
|
125
|
+
expect(suggestion).toContain('Did you mean "group"?');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('suggests the nearest node field for a misspelled graph encoding field', () => {
|
|
129
|
+
const result = validateSpec({
|
|
130
|
+
type: 'graph',
|
|
131
|
+
nodes: [{ id: 'a', category: 'x' }],
|
|
132
|
+
edges: [],
|
|
133
|
+
encoding: {
|
|
134
|
+
nodeColor: { field: 'catgory', type: 'nominal' }, // typo of "category"
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
expect(result.valid).toBe(false);
|
|
138
|
+
const suggestion = suggestionFor(result, 'encoding.nodeColor.field');
|
|
139
|
+
expect(suggestion).toContain('Did you mean "category"?');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('suggests the nearest node id for a misspelled graph edge endpoint', () => {
|
|
143
|
+
const result = validateSpec({
|
|
144
|
+
type: 'graph',
|
|
145
|
+
nodes: [{ id: 'alpha' }, { id: 'beta' }],
|
|
146
|
+
edges: [{ source: 'alpha', target: 'betta' }], // typo of "beta"
|
|
147
|
+
});
|
|
148
|
+
expect(result.valid).toBe(false);
|
|
149
|
+
const suggestion = suggestionFor(result, 'edges[0].target');
|
|
150
|
+
expect(suggestion).toContain('Did you mean "beta"?');
|
|
151
|
+
});
|
|
152
|
+
|
|
69
153
|
it('does not match on a short unrelated field where the edit distance exceeds the threshold', () => {
|
|
70
154
|
const shortData = [
|
|
71
155
|
{ a: 1, b: 2 },
|
|
@@ -87,3 +87,165 @@ describe('near-miss validation messages', () => {
|
|
|
87
87
|
expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
describe('scale.scheme on non-chart specs', () => {
|
|
92
|
+
it('rejects any scheme on a sankey color channel as dead config', () => {
|
|
93
|
+
// Sankey never reads scale.scheme (node colors cycle theme.colors.categorical),
|
|
94
|
+
// so even a known name like "blues" must not be silently accepted.
|
|
95
|
+
for (const scheme of ['viridis', 'blues']) {
|
|
96
|
+
const result = validateSpec({
|
|
97
|
+
type: 'sankey',
|
|
98
|
+
data: [{ source: 'A', target: 'B', value: 10 }],
|
|
99
|
+
encoding: {
|
|
100
|
+
source: { field: 'source', type: 'nominal' },
|
|
101
|
+
target: { field: 'target', type: 'nominal' },
|
|
102
|
+
value: { field: 'value', type: 'quantitative' },
|
|
103
|
+
color: { field: 'source', type: 'nominal', scale: { scheme } },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
108
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
109
|
+
expect(error?.message).toContain('has no effect');
|
|
110
|
+
expect(error?.suggestion).toContain('theme.colors.categorical');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects any scheme on a tilemap value channel, pointing at the palette prop', () => {
|
|
115
|
+
const result = validateSpec({
|
|
116
|
+
type: 'tilemap',
|
|
117
|
+
data: [{ state: 'CA', value: 12000 }],
|
|
118
|
+
encoding: {
|
|
119
|
+
state: { field: 'state', type: 'nominal' },
|
|
120
|
+
value: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(result.valid).toBe(false);
|
|
124
|
+
const error = result.errors.find((e) => e.path === 'encoding.value.scale.scheme');
|
|
125
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
126
|
+
expect(error?.message).toContain('has no effect');
|
|
127
|
+
expect(error?.suggestion).toContain('palette: "green"');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects any scheme on a barlist color channel as dead config', () => {
|
|
131
|
+
const result = validateSpec({
|
|
132
|
+
type: 'barlist',
|
|
133
|
+
data: [{ label: 'A', value: 42, group: 'x' }],
|
|
134
|
+
encoding: {
|
|
135
|
+
label: { field: 'label', type: 'nominal' },
|
|
136
|
+
value: { field: 'value', type: 'quantitative' },
|
|
137
|
+
color: { field: 'group', type: 'nominal', scale: { scheme: 'viridis' } },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
expect(result.valid).toBe(false);
|
|
141
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
142
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
143
|
+
expect(error?.message).toContain('has no effect');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects any scheme on a graph nodeColor channel as dead config', () => {
|
|
147
|
+
// Graph never reads scale.scheme (encoding.ts consumes only
|
|
148
|
+
// scale.range/scale.domain), so even a known name must not pass.
|
|
149
|
+
for (const scheme of ['viridis', 'blues']) {
|
|
150
|
+
const result = validateSpec({
|
|
151
|
+
type: 'graph',
|
|
152
|
+
nodes: [{ id: 'a', category: 'x' }],
|
|
153
|
+
edges: [],
|
|
154
|
+
encoding: {
|
|
155
|
+
nodeColor: { field: 'category', type: 'nominal', scale: { scheme } },
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
expect(result.valid).toBe(false);
|
|
159
|
+
const error = result.errors.find((e) => e.path === 'encoding.nodeColor.scale.scheme');
|
|
160
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
161
|
+
expect(error?.message).toContain('has no effect');
|
|
162
|
+
expect(error?.suggestion).toContain('scale.range');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('scale.scheme on map specs', () => {
|
|
168
|
+
// Minimal valid TopoJSON topology with one feature
|
|
169
|
+
const topo = {
|
|
170
|
+
type: 'Topology',
|
|
171
|
+
objects: {
|
|
172
|
+
things: {
|
|
173
|
+
type: 'GeometryCollection',
|
|
174
|
+
geometries: [{ type: 'Polygon', arcs: [[0]], id: '01' }],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
arcs: [
|
|
178
|
+
[
|
|
179
|
+
[0, 0],
|
|
180
|
+
[0, 1],
|
|
181
|
+
[1, 0],
|
|
182
|
+
[-1, -1],
|
|
183
|
+
],
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
it('rejects a scheme the map palette set does not support', () => {
|
|
188
|
+
const result = validateSpec({
|
|
189
|
+
type: 'map',
|
|
190
|
+
geo: { features: topo },
|
|
191
|
+
data: [{ id: '01', value: 5 }],
|
|
192
|
+
encoding: {
|
|
193
|
+
key: { field: 'id' },
|
|
194
|
+
color: { field: 'value', type: 'quantitative', scale: { scheme: 'viridis' } },
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
expect(result.valid).toBe(false);
|
|
198
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
199
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
200
|
+
expect(error?.message).toContain('"viridis" is not a supported map palette');
|
|
201
|
+
expect(error?.suggestion).toContain('green');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('accepts a supported sequential palette on a map color channel', () => {
|
|
205
|
+
const result = validateSpec({
|
|
206
|
+
type: 'map',
|
|
207
|
+
geo: { features: topo },
|
|
208
|
+
data: [{ id: '01', value: 5 }],
|
|
209
|
+
encoding: {
|
|
210
|
+
key: { field: 'id' },
|
|
211
|
+
color: { field: 'value', type: 'quantitative', scale: { scheme: 'green' } },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(result.errors.filter((e) => e.path === 'encoding.color.scale.scheme')).toHaveLength(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rejects any scheme on a categorical map color channel as dead config', () => {
|
|
218
|
+
const result = validateSpec({
|
|
219
|
+
type: 'map',
|
|
220
|
+
geo: { features: topo },
|
|
221
|
+
data: [{ id: '01', region: 'south' }],
|
|
222
|
+
encoding: {
|
|
223
|
+
key: { field: 'id' },
|
|
224
|
+
color: { field: 'region', type: 'nominal', scale: { scheme: 'green' } },
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
expect(result.valid).toBe(false);
|
|
228
|
+
const error = result.errors.find((e) => e.path === 'encoding.color.scale.scheme');
|
|
229
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
230
|
+
expect(error?.message).toContain('has no effect');
|
|
231
|
+
expect(error?.suggestion).toContain('scale.range');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects an unsupported scheme on the points layer color channel', () => {
|
|
235
|
+
const result = validateSpec({
|
|
236
|
+
type: 'map',
|
|
237
|
+
geo: { features: topo },
|
|
238
|
+
data: [],
|
|
239
|
+
encoding: { key: { field: 'id' } },
|
|
240
|
+
points: {
|
|
241
|
+
data: [{ lat: 30, lon: -97, score: 1 }],
|
|
242
|
+
latitude: { field: 'lat' },
|
|
243
|
+
longitude: { field: 'lon' },
|
|
244
|
+
color: { field: 'score', type: 'quantitative', scale: { scheme: 'magma' } },
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const error = result.errors.find((e) => e.path === 'points.color.scale.scheme');
|
|
248
|
+
expect(error?.code).toBe('INVALID_VALUE');
|
|
249
|
+
expect(error?.message).toContain('"magma" is not a supported map palette');
|
|
250
|
+
});
|
|
251
|
+
});
|