Skip to content
1 change: 1 addition & 0 deletions draftlogs/7925_add.md
Original file line number Diff line number Diff line change
@@ -0,0 +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!
13 changes: 13 additions & 0 deletions src/components/legend/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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


module.exports = {
Expand Down Expand Up @@ -141,6 +142,18 @@ module.exports = {
editType: 'legend',
description: 'Sets the width (in px) of the legend item symbols (the part other than the title.text).',
},
itemheight: {
valType: 'number',
min: dfltFillHeight,
dflt: dfltFillHeight,
editType: 'legend',
description: [
'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.'
].join(' ')
},
itemclick: {
valType: 'enumerated',
values: ['toggle', 'toggleothers', false],
Expand Down
13 changes: 11 additions & 2 deletions src/components/legend/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ export const scrollBarColor = '#808BA4';
export const scrollBarMargin = 4;
export const scrollBarEnterAttrs = { rx: 20, ry: 3, width: 0, height: 0 } as const;

// Number of px between legend title and (left) side of legend (always in x direction and from inner border)
/** Number of px between legend title and (left) side of legend (always in x direction and from inner border) */
export const titlePad = 2;
// Number of px between each legend item (x and/or y direction)
/** Number of px between each legend item (x and/or y direction) */
export const itemGap = 5;
/** Height (in px) of the legend fill swatch at the default `itemheight` */
export const dfltFillHeight = 6;
/** Minimum height (in px) of a legend item, so that the largest legend marker (16px across) fits */
export const itemMinHeight = 16;
/**
* Number of px added to the height of each legend item.
* Items stack with no other gap, so this value is the vertical space between them.
*/
export const itemHeightPad = 3;
1 change: 1 addition & 0 deletions src/components/legend/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData, legendCount) {
coerce('indentation');
coerce('itemsizing');
coerce('itemwidth');
coerce('itemheight');

coerce('itemclick');
coerce('itemdoubleclick');
Expand Down
4 changes: 3 additions & 1 deletion src/components/legend/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,9 @@ function computeTextDimensions(g, gd, legendObj, aTitle) {
legendObj._titleHeight = height;
} else { // legend item
legendItem.lineHeight = lineHeight;
legendItem.height = Math.max(height, 16) + 3;
// A taller fill swatch keeps the space that the default row gives the default swatch
const fillItemHeight = constants.itemMinHeight + legendObj.itemheight - constants.dfltFillHeight;
legendItem.height = Math.max(height, constants.itemMinHeight, fillItemHeight) + constants.itemHeightPad;
legendItem.width = width;
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/components/legend/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = function style(s, gd, legend) {
if (!legend) legend = fullLayout.legend;
var constantItemSizing = legend.itemsizing === 'constant';
var itemWidth = legend.itemwidth;
var itemHeight = legend.itemheight;
var centerPos = (itemWidth + constants.itemGap * 2) / 2;
var centerTransform = strTranslate(centerPos, 0);

Expand Down Expand Up @@ -139,7 +140,14 @@ module.exports = function style(s, gd, legend) {
.data(showFill || showGradientFill ? [d] : []);
fill.enter().append('path').classed('js-fill', true);
fill.exit().remove();
fill.attr('d', pathStart + 'h' + itemWidth + 'v6h-' + itemWidth + 'z').call(fillStyle);
fill.attr('d', pathStart + 'h' + itemWidth + 'v' + itemHeight + 'h-' + itemWidth + 'z').call(fillStyle);

// Grow a tall fill swatch up and down by the same amount, so the space above and below the symbol
// stays as in the default row. The line and the markers move with the fill to stay on its top edge.
const fillShiftY = showFill || showGradientFill ? (constants.dfltFillHeight - itemHeight) / 2 : 0;
this3
.selectAll('g.legendfill, g.legendlines, g.legendsymbols')
.attr('transform', fillShiftY ? strTranslate(0, fillShiftY) : null);

if (showLine || showGradientLine) {
var lw = boundLineWidth(undefined, trace.line, MAX_LINE_WIDTH, CST_LINE_WIDTH);
Expand Down
6 changes: 6 additions & 0 deletions src/types/generated/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12758,6 +12758,12 @@ export interface Legend {
* @default 'toggleothers'
*/
itemdoubleclick?: 'toggle' | 'toggleothers' | false;
/**
* 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.
* @default 6
* Minimum: 6
*/
itemheight?: number;
/**
* Determines if the legend items symbols scale with their corresponding *trace* attributes or remain *constant* independent of the symbol size on the graph.
* @default 'trace'
Expand Down
Binary file modified test/image/baselines/scatter_fill_pattern.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion test/image/mocks/scatter_fill_pattern.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"rows": 1,
"columns": 2,
"pattern": "independent"
}
},
"legend": { "itemheight": 20 }
}
}
194 changes: 194 additions & 0 deletions test/jasmine/tests/legend_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ describe('legend defaults', function () {
expect(layoutOut.showlegend).toBe(false);
});

it('defaults itemheight to 6 and clamps values below the minimum', function () {
fullData = allShown([{ type: 'scatter' }, { type: 'scatter' }]);

supplyLayoutDefaults({}, layoutOut, fullData);
expect(layoutOut.legend.itemheight).toBe(6);

layoutOut = { font: Plots.layoutAttributes.font, bg_color: Plots.layoutAttributes.bg_color };
supplyLayoutDefaults({ showlegend: true, legend: { itemheight: 1 } }, layoutOut, fullData);
expect(layoutOut.legend.itemheight).toBe(6);

layoutOut = { font: Plots.layoutAttributes.font, bg_color: Plots.layoutAttributes.bg_color };
supplyLayoutDefaults({ showlegend: true, legend: { itemheight: 24 } }, layoutOut, fullData);
expect(layoutOut.legend.itemheight).toBe(24);
});

it('shows with one visible pie', function () {
fullData = allShown([{ type: 'pie' }]);

Expand Down Expand Up @@ -3590,3 +3605,182 @@ describe('legend title click', function() {
}).then(done, done.fail);
});
});

describe('legend itemheight:', function () {
'use strict';

var gd;

beforeEach(function () {
gd = createGraphDiv();
});

afterEach(destroyGraphDiv);

function fillPathD() {
return d3Select(gd).select('g.legendfill').select('path').attr('d');
}

function linePathD() {
return d3Select(gd).select('g.legendlines').select('path').attr('d');
}

// The toggle rect is sized to the computed row height, see setRect in draw.js
function rowHeights() {
var heights = [];
d3Select(gd)
.selectAll('rect.legendtoggle')
.each(function () {
heights.push(+this.getAttribute('height'));
});
return heights;
}

var filled = [
{ x: [1, 2], y: [1, 2], fill: 'tozeroy', name: 'a' },
{ x: [1, 2], y: [2, 3], fill: 'tozeroy', name: 'b' }
];

it('reproduces the historical 6px swatch at the default', function (done) {
Plotly.newPlot(gd, filled, { showlegend: true })
.then(function () {
expect(gd._fullLayout.legend.itemheight).toBe(6);
expect(fillPathD()).toBe('M5,0h30v6h-30z');
})
.then(done, done.fail);
});

it('grows the fill swatch to the requested height', function (done) {
Plotly.newPlot(gd, filled, { showlegend: true, legend: { itemheight: 24 } })
.then(function () {
expect(fillPathD()).toBe('M5,0h30v24h-30z');
})
.then(done, done.fail);
});

it('keeps the line on the top edge of a taller fill', function (done) {
var dfltLine;

Plotly.newPlot(gd, filled, { showlegend: true })
.then(function () {
dfltLine = linePathD();
return Plotly.relayout(gd, 'legend.itemheight', 30);
})
.then(function () {
// the line marks the top edge of the fill, exactly as in the plot itself
expect(linePathD()).toBe(dfltLine);
expect(fillPathD()).toBe('M5,0h30v30h-30z');
})
.then(done, done.fail);
});

it('does not move the swatch of a trace without fill', function (done) {
var unfilled = [
{ x: [1, 2], y: [1, 2], name: 'a' },
{ x: [1, 2], y: [2, 3], name: 'b' }
];
var dfltLine;

Plotly.newPlot(gd, unfilled, { showlegend: true })
.then(function () {
dfltLine = linePathD();
return Plotly.relayout(gd, 'legend.itemheight', 40);
})
.then(function () {
expect(linePathD()).toBe(dfltLine);
})
.then(done, done.fail);
});

it('grows each legend row so taller swatches do not overlap', function (done) {
var dflt;

Plotly.newPlot(gd, filled, { showlegend: true })
.then(function () {
dflt = rowHeights();
expect(dflt.length).toBe(2);
return Plotly.relayout(gd, 'legend.itemheight', 40);
})
.then(function () {
var grown = rowHeights();
expect(grown.length).toBe(dflt.length);
grown.forEach(function (h, i) {
expect(h).toBeGreaterThan(dflt[i]);
// itemMinHeight + itemheight - dfltFillHeight + itemHeightPad = 16 + 40 - 6 + 3
expect(h).toBe(53);
});
})
.then(done, done.fail);
});

it('leaves row heights untouched at the default', function (done) {
var dflt;

Plotly.newPlot(gd, filled, { showlegend: true })
.then(function () {
dflt = rowHeights();
return Plotly.relayout(gd, 'legend.itemheight', 6);
})
.then(function () {
expect(rowHeights()).toEqual(dflt);
})
.then(done, done.fail);
});

it('keeps the default space above and below a taller symbol', (done) => {
// Space between the row edges and the symbol (fill, line, and marker) of each legend item
const symbolGaps = () =>
[...gd.querySelectorAll('g.traces')].map((item) => {
const row = item.querySelector('rect.legendtoggle').getBoundingClientRect();
const parts = [
...item.querySelectorAll('g.legendfill path, g.legendlines path, g.legendpoints path')
].map((el) => el.getBoundingClientRect());
return {
above: Math.min(...parts.map((r) => r.top)) - row.top,
below: row.bottom - Math.max(...parts.map((r) => r.bottom))
};
});
let dfltGaps;

Plotly.newPlot(gd, filled, { showlegend: true })
.then(() => {
dfltGaps = symbolGaps();
return Plotly.relayout(gd, 'legend.itemheight', 40);
})
.then(() => {
const gaps = symbolGaps();
expect(gaps.length).toBe(dfltGaps.length);
gaps.forEach((gap, i) => {
expect(gap.above).toBeCloseTo(dfltGaps[i].above, 1);
expect(gap.below).toBeCloseTo(dfltGaps[i].below, 1);
});
})
.then(done, done.fail);
});

it('keeps the default swatch in unified hover labels', (done) => {
Plotly.newPlot(gd, filled, {
showlegend: true,
hovermode: 'x unified',
legend: { itemheight: 18 }
})
.then(() => {
Plotly.Fx.hover(gd, { xval: 1 });
Lib.clearThrottle();

// The hover legend does not inherit layout.legend, the same as for itemwidth
const hoverFills = [...gd.querySelectorAll('g.hoverlayer g.legendfill path')];
expect(hoverFills.length).toBe(2);
hoverFills.forEach((fillPath) => {
expect(fillPath.getAttribute('d')).toBe('M5,0h30v6h-30z');
});

const legendFills = [...gd.querySelectorAll('g.infolayer g.legendfill path')];
expect(legendFills.length).toBe(2);
legendFills.forEach((fillPath) => {
expect(fillPath.getAttribute('d')).toBe('M5,0h30v18h-30z');
});
})
.then(done, done.fail);
});
});
7 changes: 7 additions & 0 deletions test/plot-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3470,6 +3470,13 @@
false
]
},
"itemheight": {
"description": "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.",
"dflt": 6,
"editType": "legend",
"min": 6,
"valType": "number"
},
"itemsizing": {
"description": "Determines if the legend items symbols scale with their corresponding *trace* attributes or remain *constant* independent of the symbol size on the graph.",
"dflt": "trace",
Expand Down
Loading