@opendata-ai/openchart-engine 7.5.0 → 7.6.1

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": "7.5.0",
3
+ "version": "7.6.1",
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": "7.5.0",
51
+ "@opendata-ai/openchart-core": "7.6.1",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -753,4 +753,98 @@ describe('compileLayer', () => {
753
753
 
754
754
  void l0RectCount; // used for conceptual clarity above
755
755
  });
756
+
757
+ it('positions leaf-layer marks in the primary layout coordinate space', () => {
758
+ // Regression: leaves were compiled without the layer-level chrome, legend,
759
+ // and theme that buildPrimarySpec merges into the primary spec, so their
760
+ // marks landed in a different chart area than the rendered axes. A scatter
761
+ // with a title + top legend drew every bubble up-and-left of where the
762
+ // axis said it should be.
763
+ const spec: LayerSpec = {
764
+ chrome: { title: 'Scatter with diagonal', subtitle: 'primary vs leaf areas' },
765
+ legend: { position: 'top' },
766
+ layer: [
767
+ {
768
+ mark: { type: 'point', filled: true },
769
+ data: [
770
+ { x: 10, y: 10, group: 'a' },
771
+ { x: 50, y: 50, group: 'b' },
772
+ { x: 90, y: 90, group: 'a' },
773
+ ],
774
+ encoding: {
775
+ x: {
776
+ field: 'x',
777
+ type: 'quantitative' as const,
778
+ scale: { domain: [0, 100], nice: false },
779
+ },
780
+ y: {
781
+ field: 'y',
782
+ type: 'quantitative' as const,
783
+ scale: { domain: [0, 100], nice: false },
784
+ },
785
+ color: { field: 'group', type: 'nominal' as const },
786
+ },
787
+ },
788
+ {
789
+ mark: { type: 'rule', stroke: '#888', strokeWidth: 1 },
790
+ data: [{ x: 0, y: 0, x2: 100, y2: 100 }],
791
+ encoding: {
792
+ x: {
793
+ field: 'x',
794
+ type: 'quantitative' as const,
795
+ scale: { domain: [0, 100], nice: false },
796
+ },
797
+ y: {
798
+ field: 'y',
799
+ type: 'quantitative' as const,
800
+ scale: { domain: [0, 100], nice: false },
801
+ },
802
+ x2: { field: 'x2' },
803
+ y2: { field: 'y2' },
804
+ },
805
+ },
806
+ ],
807
+ };
808
+
809
+ const layout = compileLayer(spec, compileOpts);
810
+
811
+ // Axis-implied pixel position for a data value, derived from rendered ticks.
812
+ const xTicks = layout.axes.x?.ticks ?? [];
813
+ const yTicks = layout.axes.y?.ticks ?? [];
814
+ expect(xTicks.length).toBeGreaterThanOrEqual(2);
815
+ expect(yTicks.length).toBeGreaterThanOrEqual(2);
816
+ const xFirst = xTicks[0];
817
+ const xLast = xTicks[xTicks.length - 1];
818
+ const yFirst = yTicks[0];
819
+ const yLast = yTicks[yTicks.length - 1];
820
+ const axisX = (v: number) =>
821
+ xFirst.position +
822
+ ((v - (xFirst.value as number)) / ((xLast.value as number) - (xFirst.value as number))) *
823
+ (xLast.position - xFirst.position);
824
+ const axisY = (v: number) =>
825
+ yFirst.position +
826
+ ((v - (yFirst.value as number)) / ((yLast.value as number) - (yFirst.value as number))) *
827
+ (yLast.position - yFirst.position);
828
+
829
+ const points = layout.marks.filter((m) => m.type === 'point');
830
+ expect(points.length).toBe(3);
831
+ const expected = [10, 50, 90];
832
+ points.forEach((mark, i) => {
833
+ const pm = mark as unknown as { cx: number; cy: number };
834
+ expect(Math.abs(pm.cx - axisX(expected[i]))).toBeLessThan(1.5);
835
+ expect(Math.abs(pm.cy - axisY(expected[i]))).toBeLessThan(1.5);
836
+ });
837
+
838
+ // The diagonal rule from the second leaf must span the same coordinate space.
839
+ const rule = layout.marks.find((m) => m.type === 'rule') as unknown as
840
+ | { x1: number; y1: number; x2: number; y2: number }
841
+ | undefined;
842
+ expect(rule).toBeDefined();
843
+ if (rule) {
844
+ expect(Math.abs(rule.x1 - axisX(0))).toBeLessThan(1.5);
845
+ expect(Math.abs(rule.x2 - axisX(100))).toBeLessThan(1.5);
846
+ expect(Math.abs(rule.y1 - axisY(0))).toBeLessThan(1.5);
847
+ expect(Math.abs(rule.y2 - axisY(100))).toBeLessThan(1.5);
848
+ }
849
+ });
756
850
  });
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import type { NormalizedChartSpec } from '../../../compiler/types';
4
4
  import { computeScales } from '../../../layout/scales';
5
5
  import { computeScatterMarks } from '../compute';
6
+ import { scatterRenderer } from '../index';
6
7
  import { computeTrendLine } from '../trendline';
7
8
 
8
9
  // ---------------------------------------------------------------------------
@@ -462,3 +463,26 @@ describe('computeTrendLine', () => {
462
463
  expect(computeTrendLine([])).toBeNull();
463
464
  });
464
465
  });
466
+
467
+ describe('trendline opt-out', () => {
468
+ it('renders a trend line by default', () => {
469
+ const spec = makeBasicScatterSpec();
470
+ const scales = computeScales(spec, chartArea, spec.data);
471
+ const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
472
+
473
+ expect(marks.some((m) => m.type === 'line')).toBe(true);
474
+ });
475
+
476
+ it('suppresses the trend line when mark.trendline is false', () => {
477
+ const base = makeBasicScatterSpec();
478
+ const spec: NormalizedChartSpec = {
479
+ ...base,
480
+ markDef: { type: 'point', trendline: false },
481
+ };
482
+ const scales = computeScales(spec, chartArea, spec.data);
483
+ const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
484
+
485
+ expect(marks.some((m) => m.type === 'line')).toBe(false);
486
+ expect(marks.every((m) => m.type === 'point')).toBe(true);
487
+ });
488
+ });
@@ -23,11 +23,16 @@ export const scatterRenderer: ChartRenderer = (spec, scales, chartArea, strategy
23
23
  const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy);
24
24
  const marks: Mark[] = [...pointMarks];
25
25
 
26
- // Add trend line if there are enough points
27
- const trendLine = computeTrendLine(pointMarks);
28
- if (trendLine) {
29
- // Trend line goes behind points
30
- marks.unshift(trendLine);
26
+ // Regression trend line renders by default but is opt-out via
27
+ // `mark: { type: 'point', trendline: false }`. Rendering it unconditionally
28
+ // competed with author-drawn reference lines (e.g. a manual x=y diagonal),
29
+ // so a chart could show two diagonals.
30
+ if (spec.markDef?.trendline !== false) {
31
+ const trendLine = computeTrendLine(pointMarks);
32
+ if (trendLine) {
33
+ // Trend line goes behind points
34
+ marks.unshift(trendLine);
35
+ }
31
36
  }
32
37
 
33
38
  return marks;
@@ -70,8 +70,18 @@ export function compileLayer(
70
70
  }));
71
71
  indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
72
72
 
73
+ // Leaf specs lack the layer-level chrome/legend, so left alone they compute
74
+ // a different chart area than the primary and their marks drift off the
75
+ // axes. Freeze the primary's area (and inherit its theme) for every leaf.
76
+ const leafOptions: CompileOptions = { ...options, frozenChartArea: primaryLayout.area };
77
+
73
78
  for (const { leaf } of indexedLeaves) {
74
- const leafLayout = compileChart(leaf as unknown, options);
79
+ const themedLeaf = {
80
+ ...(leaf as ChartSpec),
81
+ theme: (leaf as ChartSpec).theme ?? spec.theme,
82
+ darkMode: (leaf as ChartSpec).darkMode ?? spec.darkMode,
83
+ };
84
+ const leafLayout = compileChart(themedLeaf as unknown, leafOptions);
75
85
 
76
86
  allMarks.push(...leafLayout.marks);
77
87
 
package/src/compile.ts CHANGED
@@ -610,7 +610,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
610
610
  plan.xAxisExtent,
611
611
  );
612
612
  const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
613
- const chartArea = dims.chartArea;
613
+ // A frozen area (set by compileLayer for leaf layers) overrides the computed
614
+ // one so all layers share the primary layout's coordinate space.
615
+ const chartArea = options.frozenChartArea ?? dims.chartArea;
614
616
 
615
617
  // Place legend in its final position relative to the computed chart area.
616
618
  const finalLegend = placeLegend(