Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/types/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,14 @@ export interface CompileOptions {
measureText?: MeasureTextFn;
/** Extra pixels to reserve on the right margin for a secondary y-axis. Set by compileLayer when resolve.scale.y is 'independent'. */
rightAxisReserve?: number;
/**
* Use this chart drawing area instead of computing one from chrome, axes,
* and legend reservations. Set by compileLayer when compiling leaf layers so
* their marks land in the primary layout's coordinate space; leaf specs lack
* the layer-level chrome/legend/theme and would otherwise compute a
* different area than the one the axes are rendered in.
*/
frozenChartArea?: Rect;
}

/** Extended compile options for table visualizations. */
Expand Down
94 changes: 94 additions & 0 deletions packages/engine/src/__tests__/compile-layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,4 +753,98 @@ describe('compileLayer', () => {

void l0RectCount; // used for conceptual clarity above
});

it('positions leaf-layer marks in the primary layout coordinate space', () => {
// Regression: leaves were compiled without the layer-level chrome, legend,
// and theme that buildPrimarySpec merges into the primary spec, so their
// marks landed in a different chart area than the rendered axes. A scatter
// with a title + top legend drew every bubble up-and-left of where the
// axis said it should be.
const spec: LayerSpec = {
chrome: { title: 'Scatter with diagonal', subtitle: 'primary vs leaf areas' },
legend: { position: 'top' },
layer: [
{
mark: { type: 'point', filled: true },
data: [
{ x: 10, y: 10, group: 'a' },
{ x: 50, y: 50, group: 'b' },
{ x: 90, y: 90, group: 'a' },
],
encoding: {
x: {
field: 'x',
type: 'quantitative' as const,
scale: { domain: [0, 100], nice: false },
},
y: {
field: 'y',
type: 'quantitative' as const,
scale: { domain: [0, 100], nice: false },
},
color: { field: 'group', type: 'nominal' as const },
},
},
{
mark: { type: 'rule', stroke: '#888', strokeWidth: 1 },
data: [{ x: 0, y: 0, x2: 100, y2: 100 }],
encoding: {
x: {
field: 'x',
type: 'quantitative' as const,
scale: { domain: [0, 100], nice: false },
},
y: {
field: 'y',
type: 'quantitative' as const,
scale: { domain: [0, 100], nice: false },
},
x2: { field: 'x2' },
y2: { field: 'y2' },
},
},
],
};

const layout = compileLayer(spec, compileOpts);

// Axis-implied pixel position for a data value, derived from rendered ticks.
const xTicks = layout.axes.x?.ticks ?? [];
const yTicks = layout.axes.y?.ticks ?? [];
expect(xTicks.length).toBeGreaterThanOrEqual(2);
expect(yTicks.length).toBeGreaterThanOrEqual(2);
const xFirst = xTicks[0];
const xLast = xTicks[xTicks.length - 1];
const yFirst = yTicks[0];
const yLast = yTicks[yTicks.length - 1];
const axisX = (v: number) =>
xFirst.position +
((v - (xFirst.value as number)) / ((xLast.value as number) - (xFirst.value as number))) *
(xLast.position - xFirst.position);
const axisY = (v: number) =>
yFirst.position +
((v - (yFirst.value as number)) / ((yLast.value as number) - (yFirst.value as number))) *
(yLast.position - yFirst.position);

const points = layout.marks.filter((m) => m.type === 'point');
expect(points.length).toBe(3);
const expected = [10, 50, 90];
points.forEach((mark, i) => {
const pm = mark as unknown as { cx: number; cy: number };
expect(Math.abs(pm.cx - axisX(expected[i]))).toBeLessThan(1.5);
expect(Math.abs(pm.cy - axisY(expected[i]))).toBeLessThan(1.5);
});

// The diagonal rule from the second leaf must span the same coordinate space.
const rule = layout.marks.find((m) => m.type === 'rule') as unknown as
| { x1: number; y1: number; x2: number; y2: number }
| undefined;
expect(rule).toBeDefined();
if (rule) {
expect(Math.abs(rule.x1 - axisX(0))).toBeLessThan(1.5);
expect(Math.abs(rule.x2 - axisX(100))).toBeLessThan(1.5);
expect(Math.abs(rule.y1 - axisY(0))).toBeLessThan(1.5);
expect(Math.abs(rule.y2 - axisY(100))).toBeLessThan(1.5);
}
});
});
4 changes: 3 additions & 1 deletion packages/engine/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
plan.xAxisExtent,
);
const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
const chartArea = dims.chartArea;
// A frozen area (set by compileLayer for leaf layers) overrides the computed
// one so all layers share the primary layout's coordinate space.
const chartArea = options.frozenChartArea ?? dims.chartArea;

// Place legend in its final position relative to the computed chart area.
const finalLegend = placeLegend(
Expand Down
12 changes: 11 additions & 1 deletion packages/engine/src/compile/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,18 @@ export function compileLayer(
}));
indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);

// Leaf specs lack the layer-level chrome/legend, so left alone they compute
// a different chart area than the primary and their marks drift off the
// axes. Freeze the primary's area (and inherit its theme) for every leaf.
const leafOptions: CompileOptions = { ...options, frozenChartArea: primaryLayout.area };

for (const { leaf } of indexedLeaves) {
const leafLayout = compileChart(leaf as unknown, options);
const themedLeaf = {
...(leaf as ChartSpec),
theme: (leaf as ChartSpec).theme ?? spec.theme,
darkMode: (leaf as ChartSpec).darkMode ?? spec.darkMode,
};
const leafLayout = compileChart(themedLeaf as unknown, leafOptions);

allMarks.push(...leafLayout.marks);

Expand Down
Loading