@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "8.0.0-rc.1",
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.1",
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
- 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,
@@ -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 = ['x', 'y', 'color', 'detail', 'strokeDash', 'facet'] as const;
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: when encoding.facet is present, take the faceted path
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 grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
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 for determining if we use 'independent' or 'shared'
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 yResolve = resolveConfig?.scale?.y ?? 'shared';
1001
- const xResolve = resolveConfig?.scale?.x ?? 'shared';
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
  }