@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/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compile-layer.test.ts +94 -0
- package/src/charts/scatter/__tests__/compute.test.ts +24 -0
- package/src/charts/scatter/index.ts +10 -5
- package/src/compile/layer.ts +11 -1
- package/src/compile.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
package/src/compile/layer.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|