@opendata-ai/openchart-engine 8.0.0-rc.1 → 8.0.0-rc.10
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 +2 -8
- package/dist/index.js +793 -91
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +62 -0
- package/src/__tests__/annotation-behavior.test.ts +131 -0
- package/src/__tests__/facet.test.ts +144 -0
- package/src/annotations/geometry.ts +7 -1
- package/src/annotations/resolve-text.ts +6 -0
- package/src/compile/layer.ts +4 -0
- package/src/compile/size-scale.ts +2 -0
- package/src/compile/spec-sugar.ts +10 -1
- package/src/compile.ts +106 -8
- package/src/compiler/__tests__/keys.test.ts +3 -9
- 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/__tests__/validate.test.ts +102 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +1 -0
- package/src/compiler/validate.ts +326 -43
- package/src/geo/__tests__/compile-map.test.ts +507 -0
- package/src/geo/compile-map.ts +555 -35
- package/src/geo/projections.ts +21 -1
- package/src/geo/types.ts +2 -0
- package/src/layout/axes.ts +5 -4
- package/src/legend/compute.ts +4 -3
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.10",
|
|
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.10",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -554,6 +554,29 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
554
554
|
],
|
|
555
555
|
],
|
|
556
556
|
"watermark": false,
|
|
557
|
+
"xInvert": {
|
|
558
|
+
"bottomData": 50,
|
|
559
|
+
"bottomPixel": 577,
|
|
560
|
+
"scaleType": "linear",
|
|
561
|
+
"topData": 0,
|
|
562
|
+
"topPixel": 36.942,
|
|
563
|
+
},
|
|
564
|
+
"yInvert": {
|
|
565
|
+
"bottomData": 0,
|
|
566
|
+
"bottomPixel": 60.9,
|
|
567
|
+
"domain": [
|
|
568
|
+
"A",
|
|
569
|
+
"B",
|
|
570
|
+
"C",
|
|
571
|
+
],
|
|
572
|
+
"positions": [
|
|
573
|
+
297.33805970149257,
|
|
574
|
+
208.95000000000002,
|
|
575
|
+
120.56194029850745,
|
|
576
|
+
],
|
|
577
|
+
"topData": 0,
|
|
578
|
+
"topPixel": 357,
|
|
579
|
+
},
|
|
557
580
|
}
|
|
558
581
|
`;
|
|
559
582
|
|
|
@@ -1608,6 +1631,20 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
1608
1631
|
},
|
|
1609
1632
|
"tooltipDescriptors": [],
|
|
1610
1633
|
"watermark": false,
|
|
1634
|
+
"xInvert": {
|
|
1635
|
+
"bottomData": 1609459200000,
|
|
1636
|
+
"bottomPixel": 651.76115,
|
|
1637
|
+
"scaleType": "time",
|
|
1638
|
+
"topData": 1577836800000,
|
|
1639
|
+
"topPixel": 47,
|
|
1640
|
+
},
|
|
1641
|
+
"yInvert": {
|
|
1642
|
+
"bottomData": 40,
|
|
1643
|
+
"bottomPixel": 105.2,
|
|
1644
|
+
"scaleType": "linear",
|
|
1645
|
+
"topData": 8,
|
|
1646
|
+
"topPixel": 410.59999999999997,
|
|
1647
|
+
},
|
|
1611
1648
|
}
|
|
1612
1649
|
`;
|
|
1613
1650
|
|
|
@@ -2335,5 +2372,30 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
|
|
|
2335
2372
|
],
|
|
2336
2373
|
],
|
|
2337
2374
|
"watermark": true,
|
|
2375
|
+
"xInvert": {
|
|
2376
|
+
"bottomData": 0,
|
|
2377
|
+
"bottomPixel": 577,
|
|
2378
|
+
"domain": [
|
|
2379
|
+
"A",
|
|
2380
|
+
"B",
|
|
2381
|
+
"C",
|
|
2382
|
+
"D",
|
|
2383
|
+
],
|
|
2384
|
+
"positions": [
|
|
2385
|
+
122.67875862068962,
|
|
2386
|
+
246.30358620689654,
|
|
2387
|
+
369.92841379310346,
|
|
2388
|
+
493.55324137931035,
|
|
2389
|
+
],
|
|
2390
|
+
"topData": 0,
|
|
2391
|
+
"topPixel": 39.232,
|
|
2392
|
+
},
|
|
2393
|
+
"yInvert": {
|
|
2394
|
+
"bottomData": 70,
|
|
2395
|
+
"bottomPixel": 60.9,
|
|
2396
|
+
"scaleType": "linear",
|
|
2397
|
+
"topData": 0,
|
|
2398
|
+
"topPixel": 297.2,
|
|
2399
|
+
},
|
|
2338
2400
|
}
|
|
2339
2401
|
`;
|
|
@@ -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
|
});
|
|
@@ -167,6 +167,7 @@ describe('compileChart with facet', () => {
|
|
|
167
167
|
it('uses shared scales by default', () => {
|
|
168
168
|
const layout = compileChart(facetSpec, { width: 600, height: 400 });
|
|
169
169
|
expect(layout.facet!.sharedScales).toBe(true);
|
|
170
|
+
expect(layout.facet!.direction).toBe('wrap');
|
|
170
171
|
});
|
|
171
172
|
|
|
172
173
|
it('supports independent y scales via resolve', () => {
|
|
@@ -213,3 +214,146 @@ describe('compileChart with facet', () => {
|
|
|
213
214
|
expect(layout.facet).toBeUndefined();
|
|
214
215
|
});
|
|
215
216
|
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Row faceting
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
const rowSpec = {
|
|
223
|
+
mark: 'bar' as const,
|
|
224
|
+
data: facetData,
|
|
225
|
+
encoding: {
|
|
226
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
227
|
+
y: { field: 'country', type: 'nominal' as const },
|
|
228
|
+
row: { field: 'year', type: 'nominal' as const },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
describe('compileChart with row faceting', () => {
|
|
233
|
+
it('produces facet layout with direction row', () => {
|
|
234
|
+
const layout = compileChart(rowSpec, { width: 600, height: 400 });
|
|
235
|
+
expect(layout.facet).toBeDefined();
|
|
236
|
+
expect(layout.facet!.direction).toBe('row');
|
|
237
|
+
expect(layout.facet!.columns).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('creates one panel per unique row value', () => {
|
|
241
|
+
const layout = compileChart(rowSpec, { width: 600, height: 400 });
|
|
242
|
+
expect(layout.facet!.panels).toHaveLength(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('defaults x to shared, y to independent', () => {
|
|
246
|
+
const layout = compileChart(rowSpec, { width: 600, height: 400 });
|
|
247
|
+
expect(layout.facet!.sharedScales).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('strips x-axis from non-bottom panels', () => {
|
|
251
|
+
const layout = compileChart(rowSpec, { width: 600, height: 600 });
|
|
252
|
+
const panels = layout.facet!.panels;
|
|
253
|
+
expect(panels[0].axes.x?.ticks ?? []).toHaveLength(0);
|
|
254
|
+
const bottomPanel = panels[panels.length - 1];
|
|
255
|
+
expect(bottomPanel.axes.x?.ticks.length).toBeGreaterThan(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('keeps y-axis on all panels', () => {
|
|
259
|
+
const layout = compileChart(rowSpec, { width: 600, height: 600 });
|
|
260
|
+
for (const panel of layout.facet!.panels) {
|
|
261
|
+
expect(panel.axes.y).toBeDefined();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('left-aligns headers', () => {
|
|
266
|
+
const layout = compileChart(rowSpec, { width: 600, height: 400 });
|
|
267
|
+
for (const panel of layout.facet!.panels) {
|
|
268
|
+
expect(panel.header.textAnchor).toBe('start');
|
|
269
|
+
expect(panel.header.x).toBe(panel.area.x);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('allows overriding y to shared via resolve', () => {
|
|
274
|
+
const spec = {
|
|
275
|
+
...rowSpec,
|
|
276
|
+
resolve: { scale: { y: 'shared' as const } },
|
|
277
|
+
};
|
|
278
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
279
|
+
expect(layout.facet!.sharedScales).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('each panel has marks', () => {
|
|
283
|
+
const layout = compileChart(rowSpec, { width: 600, height: 600 });
|
|
284
|
+
for (const panel of layout.facet!.panels) {
|
|
285
|
+
expect(panel.marks.length).toBeGreaterThan(0);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Column faceting
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
const colSpec = {
|
|
295
|
+
mark: 'bar' as const,
|
|
296
|
+
data: facetData,
|
|
297
|
+
encoding: {
|
|
298
|
+
x: { field: 'country', type: 'nominal' as const },
|
|
299
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
300
|
+
column: { field: 'year', type: 'nominal' as const },
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
describe('compileChart with column faceting', () => {
|
|
305
|
+
it('produces facet layout with direction column', () => {
|
|
306
|
+
const layout = compileChart(colSpec, { width: 800, height: 400 });
|
|
307
|
+
expect(layout.facet).toBeDefined();
|
|
308
|
+
expect(layout.facet!.direction).toBe('column');
|
|
309
|
+
expect(layout.facet!.columns).toBe(2);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('defaults y to shared, x to independent', () => {
|
|
313
|
+
const layout = compileChart(colSpec, { width: 800, height: 400 });
|
|
314
|
+
expect(layout.facet!.sharedScales).toBe(false);
|
|
315
|
+
// Verify the directional resolve: y-axis ticks should be stripped from
|
|
316
|
+
// non-leftmost panels (shared y), x-axis ticks should appear on all panels
|
|
317
|
+
// (independent x).
|
|
318
|
+
const panels = layout.facet!.panels;
|
|
319
|
+
if (panels.length > 1) {
|
|
320
|
+
const rightPanel = panels[1];
|
|
321
|
+
expect(rightPanel.axes.y?.ticks ?? []).toHaveLength(0);
|
|
322
|
+
}
|
|
323
|
+
const leftPanel = panels[0];
|
|
324
|
+
expect(leftPanel.axes.y?.ticks?.length).toBeGreaterThan(0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('strips y-axis from non-leftmost panels', () => {
|
|
328
|
+
const layout = compileChart(colSpec, { width: 800, height: 400 });
|
|
329
|
+
const panels = layout.facet!.panels;
|
|
330
|
+
expect(panels[0].axes.y?.ticks?.length).toBeGreaterThan(0);
|
|
331
|
+
if (panels.length > 1) {
|
|
332
|
+
expect(panels[1].axes.y?.ticks ?? []).toHaveLength(0);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('allows overriding x to shared via resolve', () => {
|
|
337
|
+
const spec = {
|
|
338
|
+
...colSpec,
|
|
339
|
+
resolve: { scale: { x: 'shared' as const } },
|
|
340
|
+
};
|
|
341
|
+
const layout = compileChart(spec, { width: 800, height: 400 });
|
|
342
|
+
expect(layout.facet!.sharedScales).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('centers headers (not left-aligned)', () => {
|
|
346
|
+
const layout = compileChart(colSpec, { width: 800, height: 400 });
|
|
347
|
+
for (const panel of layout.facet!.panels) {
|
|
348
|
+
expect(panel.header.textAnchor).toBeUndefined();
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('creates panels in a single row', () => {
|
|
353
|
+
const layout = compileChart(colSpec, { width: 800, height: 400 });
|
|
354
|
+
for (const panel of layout.facet!.panels) {
|
|
355
|
+
expect(panel.header.text).toBeTruthy();
|
|
356
|
+
}
|
|
357
|
+
expect(layout.facet!.panels).toHaveLength(2);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -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,
|
package/src/compile/layer.ts
CHANGED
|
@@ -611,6 +611,10 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
|
|
|
611
611
|
...leaves[0],
|
|
612
612
|
data: allData,
|
|
613
613
|
chrome: layerSpec.chrome ?? leaves[0].chrome,
|
|
614
|
+
// Annotations come from the parent LayerSpec when present, otherwise
|
|
615
|
+
// the first flattened leaf's. Later leaves' annotations are silently
|
|
616
|
+
// dropped. The dual-axis path (compileLayerIndependent) explicitly
|
|
617
|
+
// clears the secondary axis spec's annotations.
|
|
614
618
|
annotations: layerSpec.annotations ?? leaves[0].annotations,
|
|
615
619
|
labels: layerSpec.labels ?? leaves[0].labels,
|
|
616
620
|
legend: layerSpec.legend ?? leaves[0].legend,
|
|
@@ -45,6 +45,8 @@ export const SIZE_SCALE_DEFAULTS = {
|
|
|
45
45
|
* linear in font size, where a disc reads by its area.
|
|
46
46
|
*/
|
|
47
47
|
text: { curve: 'linear', range: [11, 32] },
|
|
48
|
+
/** Map point dots. sqrt for area-proportional, tighter range than scatter. */
|
|
49
|
+
mapPoint: { curve: 'sqrt', range: [3, 20] },
|
|
48
50
|
} as const satisfies Record<string, { curve: SizeCurve; range: readonly [number, number] }>;
|
|
49
51
|
|
|
50
52
|
/** Size-scale options for a mark, or null when the mark has no size channel. */
|
|
@@ -449,7 +449,16 @@ function expandChannelSugar(
|
|
|
449
449
|
// ---------------------------------------------------------------------------
|
|
450
450
|
|
|
451
451
|
/** Channels whose fields group the count aggregate (mirrors VL's implicit groupby). */
|
|
452
|
-
const COUNT_GROUP_CHANNELS = [
|
|
452
|
+
const COUNT_GROUP_CHANNELS = [
|
|
453
|
+
'x',
|
|
454
|
+
'y',
|
|
455
|
+
'color',
|
|
456
|
+
'detail',
|
|
457
|
+
'strokeDash',
|
|
458
|
+
'facet',
|
|
459
|
+
'row',
|
|
460
|
+
'column',
|
|
461
|
+
] as const;
|
|
453
462
|
|
|
454
463
|
/**
|
|
455
464
|
* Desugar `{ aggregate: 'count' }` (no field) on x/y into an explicit
|
package/src/compile.ts
CHANGED
|
@@ -28,6 +28,8 @@ import type {
|
|
|
28
28
|
ResolvedAnimation,
|
|
29
29
|
ResolvedAnnotation,
|
|
30
30
|
ResolvedTheme,
|
|
31
|
+
ResolveMode,
|
|
32
|
+
ScaleInvert,
|
|
31
33
|
TableLayout,
|
|
32
34
|
} from '@opendata-ai/openchart-core';
|
|
33
35
|
import {
|
|
@@ -84,7 +86,7 @@ import { computeDimensions } from './layout/dimensions';
|
|
|
84
86
|
import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
|
|
85
87
|
import { computeGridlines } from './layout/gridlines';
|
|
86
88
|
import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
|
|
87
|
-
import { computeScales } from './layout/scales';
|
|
89
|
+
import { computeScales, type ResolvedScale } from './layout/scales';
|
|
88
90
|
import { placeLegend } from './legend/compute';
|
|
89
91
|
import { placeSizeLegend } from './legend/size';
|
|
90
92
|
import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
|
|
@@ -187,6 +189,52 @@ function applySparklineDefaults(
|
|
|
187
189
|
return { ...spec, markDef: newMarkDef };
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Extract scale inversion anchors from a resolved scale so the vanilla layer
|
|
194
|
+
* can convert pixel positions back to data-space values without holding the
|
|
195
|
+
* d3 scale object. Generalizes the YouDrawItYInvert precedent to any axis.
|
|
196
|
+
*/
|
|
197
|
+
function resolveScaleInvert(resolved: ResolvedScale | undefined): ScaleInvert | undefined {
|
|
198
|
+
if (!resolved) return undefined;
|
|
199
|
+
const { scale, type } = resolved;
|
|
200
|
+
|
|
201
|
+
// Band / point scales: snap to nearest domain value
|
|
202
|
+
if (type === 'band' || type === 'point') {
|
|
203
|
+
const domain = (scale as { domain(): string[] }).domain() as (string | number)[];
|
|
204
|
+
const positions = domain.map((v) => {
|
|
205
|
+
const pos = (scale as (v: string | number) => number | undefined)(v) ?? 0;
|
|
206
|
+
return type === 'band' ? pos + (scale as { bandwidth(): number }).bandwidth() / 2 : pos;
|
|
207
|
+
});
|
|
208
|
+
const range = (scale as { range(): number[] }).range();
|
|
209
|
+
return {
|
|
210
|
+
topPixel: range[0],
|
|
211
|
+
bottomPixel: range[range.length - 1],
|
|
212
|
+
topData: 0,
|
|
213
|
+
bottomData: 0,
|
|
214
|
+
domain,
|
|
215
|
+
positions,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Continuous scales (linear, time, log, pow, sqrt, symlog)
|
|
220
|
+
const invert = (scale as { invert?: (px: number) => unknown }).invert;
|
|
221
|
+
if (typeof invert !== 'function') return undefined;
|
|
222
|
+
|
|
223
|
+
const range = (scale as { range(): number[] }).range();
|
|
224
|
+
const topPx = range[0];
|
|
225
|
+
const bottomPx = range[range.length - 1];
|
|
226
|
+
const topVal = invert.call(scale, topPx);
|
|
227
|
+
const bottomVal = invert.call(scale, bottomPx);
|
|
228
|
+
|
|
229
|
+
// For time scales, convert Date to epoch ms
|
|
230
|
+
const topData = topVal instanceof Date ? +topVal : Number(topVal);
|
|
231
|
+
const bottomData = bottomVal instanceof Date ? +bottomVal : Number(bottomVal);
|
|
232
|
+
if (!Number.isFinite(topData) || !Number.isFinite(bottomData)) return undefined;
|
|
233
|
+
|
|
234
|
+
const scaleType = type as ScaleInvert['scaleType'];
|
|
235
|
+
return { topPixel: topPx, bottomPixel: bottomPx, topData, bottomData, scaleType };
|
|
236
|
+
}
|
|
237
|
+
|
|
190
238
|
/**
|
|
191
239
|
* Compile a chart spec into a ChartLayout.
|
|
192
240
|
*
|
|
@@ -570,14 +618,38 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart
|
|
|
570
618
|
}
|
|
571
619
|
|
|
572
620
|
// ---------------------------------------------------------------------------
|
|
573
|
-
// Faceted compilation:
|
|
621
|
+
// Faceted compilation: desugar row/column into facet, then take faceted path
|
|
574
622
|
// ---------------------------------------------------------------------------
|
|
623
|
+
let facetDirection: 'row' | 'column' | 'wrap' = 'wrap';
|
|
624
|
+
const rowChannel = renderSpec.encoding.row;
|
|
625
|
+
const colChannel = renderSpec.encoding.column;
|
|
626
|
+
if (rowChannel?.field || colChannel?.field) {
|
|
627
|
+
const channel = (rowChannel?.field ? rowChannel : colChannel)!;
|
|
628
|
+
facetDirection = rowChannel?.field ? 'row' : 'column';
|
|
629
|
+
const desugared = { ...renderSpec.encoding } as Record<string, unknown>;
|
|
630
|
+
delete desugared.row;
|
|
631
|
+
delete desugared.column;
|
|
632
|
+
desugared.facet = {
|
|
633
|
+
field: channel.field,
|
|
634
|
+
type: channel.type,
|
|
635
|
+
sort: channel.sort,
|
|
636
|
+
...(facetDirection === 'row' ? { columns: 1 } : {}),
|
|
637
|
+
};
|
|
638
|
+
renderSpec = { ...renderSpec, encoding: desugared as typeof renderSpec.encoding };
|
|
639
|
+
const chartDesugared = { ...chartSpec.encoding } as Record<string, unknown>;
|
|
640
|
+
delete chartDesugared.row;
|
|
641
|
+
delete chartDesugared.column;
|
|
642
|
+
chartDesugared.facet = desugared.facet;
|
|
643
|
+
chartSpec = { ...chartSpec, encoding: chartDesugared as typeof chartSpec.encoding };
|
|
644
|
+
}
|
|
645
|
+
|
|
575
646
|
const facetChannel = renderSpec.encoding.facet;
|
|
576
647
|
if (facetChannel?.field) {
|
|
577
648
|
return compileFaceted(
|
|
578
649
|
chartSpec,
|
|
579
650
|
renderSpec,
|
|
580
651
|
facetChannel,
|
|
652
|
+
facetDirection,
|
|
581
653
|
options,
|
|
582
654
|
theme,
|
|
583
655
|
strategy,
|
|
@@ -857,6 +929,10 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart
|
|
|
857
929
|
|
|
858
930
|
const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
|
|
859
931
|
|
|
932
|
+
// Scale inversion anchors for edit-mode anchor drag (pixel -> data-space)
|
|
933
|
+
const xInvert = resolveScaleInvert(scales.x);
|
|
934
|
+
const yInvert = resolveScaleInvert(scales.y);
|
|
935
|
+
|
|
860
936
|
return {
|
|
861
937
|
area: chartArea,
|
|
862
938
|
chrome,
|
|
@@ -890,6 +966,8 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart
|
|
|
890
966
|
display: chartSpec.display,
|
|
891
967
|
crosshair,
|
|
892
968
|
measureText: options.measureText,
|
|
969
|
+
...(xInvert ? { xInvert } : {}),
|
|
970
|
+
...(yInvert ? { yInvert } : {}),
|
|
893
971
|
};
|
|
894
972
|
}
|
|
895
973
|
|
|
@@ -901,6 +979,7 @@ function compileFaceted(
|
|
|
901
979
|
chartSpec: NormalizedChartSpec,
|
|
902
980
|
renderSpec: NormalizedChartSpec,
|
|
903
981
|
facetChannel: FacetChannel,
|
|
982
|
+
facetDirection: 'row' | 'column' | 'wrap',
|
|
904
983
|
options: CompileOptions,
|
|
905
984
|
theme: ResolvedTheme,
|
|
906
985
|
strategy: LayoutStrategy,
|
|
@@ -973,7 +1052,8 @@ function compileFaceted(
|
|
|
973
1052
|
const bottomReservation = plan.xAxisExtent;
|
|
974
1053
|
|
|
975
1054
|
// Compute facet grid geometry
|
|
976
|
-
const
|
|
1055
|
+
const effectiveColumns = facetDirection === 'column' ? facetValues.length : facetChannel.columns;
|
|
1056
|
+
const grid = computeFacetGrid(facetValues, effectiveColumns, chartArea, {
|
|
977
1057
|
left: leftReservation,
|
|
978
1058
|
bottom: bottomReservation,
|
|
979
1059
|
});
|
|
@@ -995,10 +1075,15 @@ function compileFaceted(
|
|
|
995
1075
|
}
|
|
996
1076
|
}
|
|
997
1077
|
|
|
998
|
-
// Resolve scale
|
|
1078
|
+
// Resolve scale: defaults depend on facet direction.
|
|
1079
|
+
// row: x shared (comparable values), y independent (different categories)
|
|
1080
|
+
// column: y shared (comparable values), x independent (different categories)
|
|
1081
|
+
// wrap: both shared (original facet behavior)
|
|
999
1082
|
const resolveConfig = chartSpec.resolve;
|
|
1000
|
-
const
|
|
1001
|
-
const
|
|
1083
|
+
const xDefault: ResolveMode = facetDirection === 'column' ? 'independent' : 'shared';
|
|
1084
|
+
const yDefault: ResolveMode = facetDirection === 'row' ? 'independent' : 'shared';
|
|
1085
|
+
const yResolve = resolveConfig?.scale?.y ?? yDefault;
|
|
1086
|
+
const xResolve = resolveConfig?.scale?.x ?? xDefault;
|
|
1002
1087
|
|
|
1003
1088
|
// Compute shared scale domains from the full dataset (when shared)
|
|
1004
1089
|
const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
|
|
@@ -1079,7 +1164,6 @@ function compileFaceted(
|
|
|
1079
1164
|
}
|
|
1080
1165
|
|
|
1081
1166
|
// Strip y-axis tick labels from non-leftmost panels (gridlines stay).
|
|
1082
|
-
// X-axis labels show on every panel for readability.
|
|
1083
1167
|
if (yResolve === 'shared' && !isLeftCol && panelAxes.y) {
|
|
1084
1168
|
panelAxes.y = {
|
|
1085
1169
|
...panelAxes.y,
|
|
@@ -1090,6 +1174,18 @@ function compileFaceted(
|
|
|
1090
1174
|
};
|
|
1091
1175
|
}
|
|
1092
1176
|
|
|
1177
|
+
// Strip x-axis tick labels from non-bottom panels for row faceting.
|
|
1178
|
+
const isBottomRow = gridPanel.row === grid.rows - 1;
|
|
1179
|
+
if (xResolve === 'shared' && facetDirection === 'row' && !isBottomRow && panelAxes.x) {
|
|
1180
|
+
panelAxes.x = {
|
|
1181
|
+
...panelAxes.x,
|
|
1182
|
+
ticks: [],
|
|
1183
|
+
label: undefined,
|
|
1184
|
+
domainLine: false,
|
|
1185
|
+
tickMarks: false,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1093
1189
|
// Compute marks for this panel
|
|
1094
1190
|
const panelMarks: Mark[] = renderer
|
|
1095
1191
|
? renderer(
|
|
@@ -1173,10 +1269,11 @@ function compileFaceted(
|
|
|
1173
1269
|
annotations: panelAnnotations,
|
|
1174
1270
|
header: {
|
|
1175
1271
|
text: gridPanel.key,
|
|
1176
|
-
x: gridPanel.headerPos.x,
|
|
1272
|
+
x: facetDirection === 'row' ? gridPanel.area.x : gridPanel.headerPos.x,
|
|
1177
1273
|
y: gridPanel.headerPos.y,
|
|
1178
1274
|
fontSize: theme.fonts.sizes.body,
|
|
1179
1275
|
fontWeight: theme.fonts.weights.medium,
|
|
1276
|
+
...(facetDirection === 'row' ? { textAnchor: 'start' as const } : {}),
|
|
1180
1277
|
},
|
|
1181
1278
|
});
|
|
1182
1279
|
}
|
|
@@ -1272,6 +1369,7 @@ function compileFaceted(
|
|
|
1272
1369
|
facetField: facetChannel.field,
|
|
1273
1370
|
columns: grid.columns,
|
|
1274
1371
|
sharedScales: yResolve === 'shared' && xResolve === 'shared',
|
|
1372
|
+
direction: facetDirection,
|
|
1275
1373
|
},
|
|
1276
1374
|
};
|
|
1277
1375
|
}
|