@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "8.0.0-rc.6",
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.6",
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
- const from = { x: exit.x, y: exit.y };
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
+ });