Skip to content
Open
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
39 changes: 38 additions & 1 deletion src/compile/axis/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ import {UnitModel} from '../unit.js';
import {AxisComponent, AxisComponentIndex, AxisComponentProps, AXIS_COMPONENT_PROPERTIES} from './component.js';
import {getAxisConfig, getAxisConfigs} from './config.js';
import * as encode from './encode.js';
import {AxisRuleParams, axisRules, defaultOrient, getFieldDefTitle, getLabelAngle} from './properties.js';
import {
AxisRuleParams,
axisRules,
defaultLabelAlign,
defaultLabelBaseline,
defaultOrient,
getFieldDefTitle,
getLabelAngle,
} from './properties.js';
import {guideFormat, guideFormatType} from '../format.js';

export function parseUnitAxes(model: UnitModel): AxisComponentIndex {
Expand Down Expand Up @@ -81,6 +89,35 @@ export function parseLayerAxes(model: LayerModel) {
const oppositeOrient = OPPOSITE_ORIENT[orient];
if (axisCount[orient] > axisCount[oppositeOrient]) {
axisComponent.set('orient', oppositeOrient, false);

// Recalculate orient-dependent label properties for the new orient
// (https://github.com/vega/vega-lite/issues/3773)
let labelAngle: number | SignalRef | undefined;
if (child instanceof UnitModel) {
const fieldOrDatumDef = getFieldOrDatumDef(child.encoding[channel]) as
| PositionFieldDef<string>
| PositionDatumDef<string>;
const axis = child.axis(channel) || {};
const scaleType = child.getScaleComponent(channel)?.get('type');
if (fieldOrDatumDef && scaleType) {
const axisConfigs = getAxisConfigs(channel, scaleType, orient, child.config);
labelAngle = getLabelAngle(fieldOrDatumDef, axis, channel, child.config.style, axisConfigs);
}
}
if (labelAngle !== undefined) {
if (!axisComponent.getWithExplicit('labelAlign').explicit) {
const newAlign = defaultLabelAlign(labelAngle, oppositeOrient, channel);
if (newAlign !== undefined) {
axisComponent.set('labelAlign', newAlign, false);
}
}
if (!axisComponent.getWithExplicit('labelBaseline').explicit) {
const newBaseline = defaultLabelBaseline(labelAngle, oppositeOrient, channel);
if (newBaseline !== undefined) {
axisComponent.set('labelBaseline', newBaseline, false);
}
}
}
}
}
axisCount[orient]++;
Expand Down
247 changes: 247 additions & 0 deletions test/compile/axis/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,5 +575,252 @@ describe('Axis', () => {
expect(axisComponents.x[0].get('title')).toBe('Hello, World');
expect(axisComponents.y[0].get('title')).toBeNull();
});

it('recalculates labelAlign and labelBaseline when orient is flipped for dual y-axis (issue #3773)', () => {
const model = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'a',
type: 'quantitative',
axis: {labelAngle: 0},
},
},
},
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'b',
type: 'quantitative',
axis: {labelAngle: 0},
},
},
},
],
resolve: {
scale: {y: 'independent'},
},
});
model.parseScale();
parseLayerAxes(model);
const axisComponents = model.component.axes;

// The second y-axis should be flipped to orient='right'
expect(axisComponents.y).toHaveLength(2);
expect(axisComponents.y[0].get('orient')).toBe('left');
expect(axisComponents.y[1].get('orient')).toBe('right');

// At angle=0 for y-axis: left orient → 'right' align, right orient → 'left' align
expect(axisComponents.y[0].get('labelAlign')).toBe('right');
expect(axisComponents.y[1].get('labelAlign')).toBe('left');
});

it('recalculates labelBaseline when orient is flipped for dual y-axis at angle=60 (issue #3773)', () => {
const model = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'a',
type: 'quantitative',
axis: {labelAngle: 60},
},
},
},
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'b',
type: 'quantitative',
axis: {labelAngle: 60},
},
},
},
],
resolve: {
scale: {y: 'independent'},
},
});
model.parseScale();
parseLayerAxes(model);
const axisComponents = model.component.axes;

expect(axisComponents.y[1].get('orient')).toBe('right');

// At angle=60 for y-axis: left orient → 'top' baseline, right orient → 'bottom' baseline
expect(axisComponents.y[0].get('labelBaseline')).toBe('top');
expect(axisComponents.y[1].get('labelBaseline')).toBe('bottom');
});

it('does not override explicit labelAlign when orient is flipped (issue #3773)', () => {
const model = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'a',
type: 'quantitative',
axis: {labelAngle: 0},
},
},
},
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'b',
type: 'quantitative',
axis: {labelAngle: 0, labelAlign: 'center'},
},
},
},
],
resolve: {
scale: {y: 'independent'},
},
});
model.parseScale();
parseLayerAxes(model);
const axisComponents = model.component.axes;

// Explicit labelAlign should be preserved even after orient flip
expect(axisComponents.y[1].get('orient')).toBe('right');
expect(axisComponents.y[1].get('labelAlign')).toBe('center');
});

it('does not override explicit labelBaseline when orient is flipped (issue #3773)', () => {
const model = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'a',
type: 'quantitative',
axis: {labelAngle: 60},
},
},
},
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'b',
type: 'quantitative',
axis: {labelAngle: 60, labelBaseline: 'middle'},
},
},
},
],
resolve: {
scale: {y: 'independent'},
},
});
model.parseScale();
parseLayerAxes(model);
const axisComponents = model.component.axes;

// Explicit labelBaseline should be preserved even after orient flip
expect(axisComponents.y[1].get('orient')).toBe('right');
expect(axisComponents.y[1].get('labelBaseline')).toBe('middle');
});

it('recalculates labelAlign when labelAngle comes from config (issue #3773)', () => {
const model = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {field: 'a', type: 'quantitative'},
},
},
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {field: 'b', type: 'quantitative'},
},
},
],
resolve: {
scale: {y: 'independent'},
},
config: {
axisY: {labelAngle: 45},
},
});
model.parseScale();
parseLayerAxes(model);
const axisComponents = model.component.axes;

expect(axisComponents.y[0].get('orient')).toBe('left');
expect(axisComponents.y[1].get('orient')).toBe('right');

// labelAngle from config should still trigger recalculation
expect(axisComponents.y[0].get('labelAlign')).toBe('right');
expect(axisComponents.y[1].get('labelAlign')).toBe('left');
});

it('recalculates labelAlign and labelBaseline when orient is flipped for dual x-axis (issue #3773)', () => {
const model = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {
field: 'a',
type: 'quantitative',
axis: {labelAngle: 90},
},
y: {field: 'date', type: 'temporal'},
},
},
{
mark: 'line',
encoding: {
x: {
field: 'b',
type: 'quantitative',
axis: {labelAngle: 90},
},
y: {field: 'date', type: 'temporal'},
},
},
],
resolve: {
scale: {x: 'independent'},
},
});
model.parseScale();
parseLayerAxes(model);
const axisComponents = model.component.axes;

expect(axisComponents.x).toHaveLength(2);
expect(axisComponents.x[0].get('orient')).toBe('bottom');
expect(axisComponents.x[1].get('orient')).toBe('top');

// At angle=90 for x-axis: bottom orient → 'left' align, top orient → 'right' align
expect(axisComponents.x[0].get('labelAlign')).toBe('left');
expect(axisComponents.x[1].get('labelAlign')).toBe('right');

// At angle=90 for x-axis: bottom orient → 'middle' baseline, top orient → 'middle' baseline
expect(axisComponents.x[0].get('labelBaseline')).toBe('middle');
expect(axisComponents.x[1].get('labelBaseline')).toBe('middle');
});
});
});
46 changes: 46 additions & 0 deletions test/compile/layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,50 @@ describe('Layer', () => {
expect(model.component.axes['x'][1].implicit.orient).toBe('top');
});
});

describe('dual y-axis chart with label angle', () => {
it('should recalculate labelAlign for the flipped axis (issue #3773)', () => {
const dualYModel = parseLayerModel({
layer: [
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'temp',
type: 'quantitative',
axis: {labelAngle: 0},
},
},
},
{
mark: 'line',
encoding: {
x: {field: 'date', type: 'temporal'},
y: {
field: 'precip',
type: 'quantitative',
axis: {labelAngle: 0},
},
},
},
],
resolve: {
scale: {y: 'independent'},
},
});
dualYModel.parseScale();
dualYModel.parseAxesAndHeaders();

const yAxes = dualYModel.component.axes['y'];
expect(yAxes).toHaveLength(2);
expect(yAxes[0].get('orient')).toBe('left');
expect(yAxes[1].get('orient')).toBe('right');

// At angle=0, left axis labels should align right (toward chart),
// right axis labels should align left (toward chart)
expect(yAxes[0].get('labelAlign')).toBe('right');
expect(yAxes[1].get('labelAlign')).toBe('left');
});
});
});