Skip to content

Commit 1f53454

Browse files
authored
Merge pull request #7925 from MannXo/feat/7924-legend-itemheight
Add legend.itemheight to set the height of the legend fill swatch
2 parents 5885cb7 + 36c97ee commit 1f53454

11 files changed

Lines changed: 247 additions & 5 deletions

File tree

‎draftlogs/7925_add.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Add `layout.legend.itemheight` to set the height of the legend fill swatch, so more of a trace `fillpattern` is visible in the legend [[#7925](https://github.com/plotly/plotly.js/pull/7925)], with thanks to @MannXo for the contribution!

‎src/components/legend/attributes.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
var fontAttrs = require('../../plots/font_attributes');
44
var colorAttrs = require('../color/attributes');
5+
const { dfltFillHeight } = require('./constants');
56

67

78
module.exports = {
@@ -141,6 +142,18 @@ module.exports = {
141142
editType: 'legend',
142143
description: 'Sets the width (in px) of the legend item symbols (the part other than the title.text).',
143144
},
145+
itemheight: {
146+
valType: 'number',
147+
min: dfltFillHeight,
148+
dflt: dfltFillHeight,
149+
editType: 'legend',
150+
description: [
151+
'Sets the height (in px) of the legend item fill swatch.',
152+
'A taller swatch shows more of a trace *fill* or *fillpattern*.',
153+
'Only traces with a *fill* use this value.',
154+
'Every legend item grows to fit, so a larger value makes every row taller.'
155+
].join(' ')
156+
},
144157
itemclick: {
145158
valType: 'enumerated',
146159
values: ['toggle', 'toggleothers', false],

‎src/components/legend/constants.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@ export const scrollBarColor = '#808BA4';
44
export const scrollBarMargin = 4;
55
export const scrollBarEnterAttrs = { rx: 20, ry: 3, width: 0, height: 0 } as const;
66

7-
// Number of px between legend title and (left) side of legend (always in x direction and from inner border)
7+
/** Number of px between legend title and (left) side of legend (always in x direction and from inner border) */
88
export const titlePad = 2;
9-
// Number of px between each legend item (x and/or y direction)
9+
/** Number of px between each legend item (x and/or y direction) */
1010
export const itemGap = 5;
11+
/** Height (in px) of the legend fill swatch at the default `itemheight` */
12+
export const dfltFillHeight = 6;
13+
/** Minimum height (in px) of a legend item, so that the largest legend marker (16px across) fits */
14+
export const itemMinHeight = 16;
15+
/**
16+
* Number of px added to the height of each legend item.
17+
* Items stack with no other gap, so this value is the vertical space between them.
18+
*/
19+
export const itemHeightPad = 3;

‎src/components/legend/defaults.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData, legendCount) {
219219
coerce('indentation');
220220
coerce('itemsizing');
221221
coerce('itemwidth');
222+
coerce('itemheight');
222223

223224
coerce('itemclick');
224225
coerce('itemdoubleclick');

‎src/components/legend/draw.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -824,7 +824,9 @@ function computeTextDimensions(g, gd, legendObj, aTitle) {
824824
legendObj._titleHeight = height;
825825
} else { // legend item
826826
legendItem.lineHeight = lineHeight;
827-
legendItem.height = Math.max(height, 16) + 3;
827+
// A taller fill swatch keeps the space that the default row gives the default swatch
828+
const fillItemHeight = constants.itemMinHeight + legendObj.itemheight - constants.dfltFillHeight;
829+
legendItem.height = Math.max(height, constants.itemMinHeight, fillItemHeight) + constants.itemHeightPad;
828830
legendItem.width = width;
829831
}
830832
}

‎src/components/legend/style.js‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ module.exports = function style(s, gd, legend) {
2626
if (!legend) legend = fullLayout.legend;
2727
var constantItemSizing = legend.itemsizing === 'constant';
2828
var itemWidth = legend.itemwidth;
29+
var itemHeight = legend.itemheight;
2930
var centerPos = (itemWidth + constants.itemGap * 2) / 2;
3031
var centerTransform = strTranslate(centerPos, 0);
3132

@@ -139,7 +140,14 @@ module.exports = function style(s, gd, legend) {
139140
.data(showFill || showGradientFill ? [d] : []);
140141
fill.enter().append('path').classed('js-fill', true);
141142
fill.exit().remove();
142-
fill.attr('d', pathStart + 'h' + itemWidth + 'v6h-' + itemWidth + 'z').call(fillStyle);
143+
fill.attr('d', pathStart + 'h' + itemWidth + 'v' + itemHeight + 'h-' + itemWidth + 'z').call(fillStyle);
144+
145+
// Grow a tall fill swatch up and down by the same amount, so the space above and below the symbol
146+
// stays as in the default row. The line and the markers move with the fill to stay on its top edge.
147+
const fillShiftY = showFill || showGradientFill ? (constants.dfltFillHeight - itemHeight) / 2 : 0;
148+
this3
149+
.selectAll('g.legendfill, g.legendlines, g.legendsymbols')
150+
.attr('transform', fillShiftY ? strTranslate(0, fillShiftY) : null);
143151

144152
if (showLine || showGradientLine) {
145153
var lw = boundLineWidth(undefined, trace.line, MAX_LINE_WIDTH, CST_LINE_WIDTH);

‎src/types/generated/schema.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12758,6 +12758,12 @@ export interface Legend {
1275812758
* @default 'toggleothers'
1275912759
*/
1276012760
itemdoubleclick?: 'toggle' | 'toggleothers' | false;
12761+
/**
12762+
* Sets the height (in px) of the legend item fill swatch. A taller swatch shows more of a trace *fill* or *fillpattern*. Only traces with a *fill* use this value. Every legend item grows to fit, so a larger value makes every row taller.
12763+
* @default 6
12764+
* Minimum: 6
12765+
*/
12766+
itemheight?: number;
1276112767
/**
1276212768
* Determines if the legend items symbols scale with their corresponding *trace* attributes or remain *constant* independent of the symbol size on the graph.
1276312769
* @default 'trace'
2.07 KB
Loading

‎test/image/mocks/scatter_fill_pattern.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"rows": 1,
6565
"columns": 2,
6666
"pattern": "independent"
67-
}
67+
},
68+
"legend": { "itemheight": 20 }
6869
}
6970
}

‎test/jasmine/tests/legend_test.js‎

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ describe('legend defaults', function () {
8383
expect(layoutOut.showlegend).toBe(false);
8484
});
8585

86+
it('defaults itemheight to 6 and clamps values below the minimum', function () {
87+
fullData = allShown([{ type: 'scatter' }, { type: 'scatter' }]);
88+
89+
supplyLayoutDefaults({}, layoutOut, fullData);
90+
expect(layoutOut.legend.itemheight).toBe(6);
91+
92+
layoutOut = { font: Plots.layoutAttributes.font, bg_color: Plots.layoutAttributes.bg_color };
93+
supplyLayoutDefaults({ showlegend: true, legend: { itemheight: 1 } }, layoutOut, fullData);
94+
expect(layoutOut.legend.itemheight).toBe(6);
95+
96+
layoutOut = { font: Plots.layoutAttributes.font, bg_color: Plots.layoutAttributes.bg_color };
97+
supplyLayoutDefaults({ showlegend: true, legend: { itemheight: 24 } }, layoutOut, fullData);
98+
expect(layoutOut.legend.itemheight).toBe(24);
99+
});
100+
86101
it('shows with one visible pie', function () {
87102
fullData = allShown([{ type: 'pie' }]);
88103

@@ -3590,3 +3605,182 @@ describe('legend title click', function() {
35903605
}).then(done, done.fail);
35913606
});
35923607
});
3608+
3609+
describe('legend itemheight:', function () {
3610+
'use strict';
3611+
3612+
var gd;
3613+
3614+
beforeEach(function () {
3615+
gd = createGraphDiv();
3616+
});
3617+
3618+
afterEach(destroyGraphDiv);
3619+
3620+
function fillPathD() {
3621+
return d3Select(gd).select('g.legendfill').select('path').attr('d');
3622+
}
3623+
3624+
function linePathD() {
3625+
return d3Select(gd).select('g.legendlines').select('path').attr('d');
3626+
}
3627+
3628+
// The toggle rect is sized to the computed row height, see setRect in draw.js
3629+
function rowHeights() {
3630+
var heights = [];
3631+
d3Select(gd)
3632+
.selectAll('rect.legendtoggle')
3633+
.each(function () {
3634+
heights.push(+this.getAttribute('height'));
3635+
});
3636+
return heights;
3637+
}
3638+
3639+
var filled = [
3640+
{ x: [1, 2], y: [1, 2], fill: 'tozeroy', name: 'a' },
3641+
{ x: [1, 2], y: [2, 3], fill: 'tozeroy', name: 'b' }
3642+
];
3643+
3644+
it('reproduces the historical 6px swatch at the default', function (done) {
3645+
Plotly.newPlot(gd, filled, { showlegend: true })
3646+
.then(function () {
3647+
expect(gd._fullLayout.legend.itemheight).toBe(6);
3648+
expect(fillPathD()).toBe('M5,0h30v6h-30z');
3649+
})
3650+
.then(done, done.fail);
3651+
});
3652+
3653+
it('grows the fill swatch to the requested height', function (done) {
3654+
Plotly.newPlot(gd, filled, { showlegend: true, legend: { itemheight: 24 } })
3655+
.then(function () {
3656+
expect(fillPathD()).toBe('M5,0h30v24h-30z');
3657+
})
3658+
.then(done, done.fail);
3659+
});
3660+
3661+
it('keeps the line on the top edge of a taller fill', function (done) {
3662+
var dfltLine;
3663+
3664+
Plotly.newPlot(gd, filled, { showlegend: true })
3665+
.then(function () {
3666+
dfltLine = linePathD();
3667+
return Plotly.relayout(gd, 'legend.itemheight', 30);
3668+
})
3669+
.then(function () {
3670+
// the line marks the top edge of the fill, exactly as in the plot itself
3671+
expect(linePathD()).toBe(dfltLine);
3672+
expect(fillPathD()).toBe('M5,0h30v30h-30z');
3673+
})
3674+
.then(done, done.fail);
3675+
});
3676+
3677+
it('does not move the swatch of a trace without fill', function (done) {
3678+
var unfilled = [
3679+
{ x: [1, 2], y: [1, 2], name: 'a' },
3680+
{ x: [1, 2], y: [2, 3], name: 'b' }
3681+
];
3682+
var dfltLine;
3683+
3684+
Plotly.newPlot(gd, unfilled, { showlegend: true })
3685+
.then(function () {
3686+
dfltLine = linePathD();
3687+
return Plotly.relayout(gd, 'legend.itemheight', 40);
3688+
})
3689+
.then(function () {
3690+
expect(linePathD()).toBe(dfltLine);
3691+
})
3692+
.then(done, done.fail);
3693+
});
3694+
3695+
it('grows each legend row so taller swatches do not overlap', function (done) {
3696+
var dflt;
3697+
3698+
Plotly.newPlot(gd, filled, { showlegend: true })
3699+
.then(function () {
3700+
dflt = rowHeights();
3701+
expect(dflt.length).toBe(2);
3702+
return Plotly.relayout(gd, 'legend.itemheight', 40);
3703+
})
3704+
.then(function () {
3705+
var grown = rowHeights();
3706+
expect(grown.length).toBe(dflt.length);
3707+
grown.forEach(function (h, i) {
3708+
expect(h).toBeGreaterThan(dflt[i]);
3709+
// itemMinHeight + itemheight - dfltFillHeight + itemHeightPad = 16 + 40 - 6 + 3
3710+
expect(h).toBe(53);
3711+
});
3712+
})
3713+
.then(done, done.fail);
3714+
});
3715+
3716+
it('leaves row heights untouched at the default', function (done) {
3717+
var dflt;
3718+
3719+
Plotly.newPlot(gd, filled, { showlegend: true })
3720+
.then(function () {
3721+
dflt = rowHeights();
3722+
return Plotly.relayout(gd, 'legend.itemheight', 6);
3723+
})
3724+
.then(function () {
3725+
expect(rowHeights()).toEqual(dflt);
3726+
})
3727+
.then(done, done.fail);
3728+
});
3729+
3730+
it('keeps the default space above and below a taller symbol', (done) => {
3731+
// Space between the row edges and the symbol (fill, line, and marker) of each legend item
3732+
const symbolGaps = () =>
3733+
[...gd.querySelectorAll('g.traces')].map((item) => {
3734+
const row = item.querySelector('rect.legendtoggle').getBoundingClientRect();
3735+
const parts = [
3736+
...item.querySelectorAll('g.legendfill path, g.legendlines path, g.legendpoints path')
3737+
].map((el) => el.getBoundingClientRect());
3738+
return {
3739+
above: Math.min(...parts.map((r) => r.top)) - row.top,
3740+
below: row.bottom - Math.max(...parts.map((r) => r.bottom))
3741+
};
3742+
});
3743+
let dfltGaps;
3744+
3745+
Plotly.newPlot(gd, filled, { showlegend: true })
3746+
.then(() => {
3747+
dfltGaps = symbolGaps();
3748+
return Plotly.relayout(gd, 'legend.itemheight', 40);
3749+
})
3750+
.then(() => {
3751+
const gaps = symbolGaps();
3752+
expect(gaps.length).toBe(dfltGaps.length);
3753+
gaps.forEach((gap, i) => {
3754+
expect(gap.above).toBeCloseTo(dfltGaps[i].above, 1);
3755+
expect(gap.below).toBeCloseTo(dfltGaps[i].below, 1);
3756+
});
3757+
})
3758+
.then(done, done.fail);
3759+
});
3760+
3761+
it('keeps the default swatch in unified hover labels', (done) => {
3762+
Plotly.newPlot(gd, filled, {
3763+
showlegend: true,
3764+
hovermode: 'x unified',
3765+
legend: { itemheight: 18 }
3766+
})
3767+
.then(() => {
3768+
Plotly.Fx.hover(gd, { xval: 1 });
3769+
Lib.clearThrottle();
3770+
3771+
// The hover legend does not inherit layout.legend, the same as for itemwidth
3772+
const hoverFills = [...gd.querySelectorAll('g.hoverlayer g.legendfill path')];
3773+
expect(hoverFills.length).toBe(2);
3774+
hoverFills.forEach((fillPath) => {
3775+
expect(fillPath.getAttribute('d')).toBe('M5,0h30v6h-30z');
3776+
});
3777+
3778+
const legendFills = [...gd.querySelectorAll('g.infolayer g.legendfill path')];
3779+
expect(legendFills.length).toBe(2);
3780+
legendFills.forEach((fillPath) => {
3781+
expect(fillPath.getAttribute('d')).toBe('M5,0h30v18h-30z');
3782+
});
3783+
})
3784+
.then(done, done.fail);
3785+
});
3786+
});

0 commit comments

Comments
 (0)