diff --git a/draftlogs/7925_add.md b/draftlogs/7925_add.md new file mode 100644 index 00000000000..a7c037cc2e2 --- /dev/null +++ b/draftlogs/7925_add.md @@ -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! diff --git a/src/components/legend/attributes.js b/src/components/legend/attributes.js index f8164a550a6..f6629622692 100644 --- a/src/components/legend/attributes.js +++ b/src/components/legend/attributes.js @@ -2,6 +2,7 @@ var fontAttrs = require('../../plots/font_attributes'); var colorAttrs = require('../color/attributes'); +const { dfltFillHeight } = require('./constants'); module.exports = { @@ -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], diff --git a/src/components/legend/constants.ts b/src/components/legend/constants.ts index 6b950ac03d3..b28cddedecb 100644 --- a/src/components/legend/constants.ts +++ b/src/components/legend/constants.ts @@ -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; diff --git a/src/components/legend/defaults.js b/src/components/legend/defaults.js index 2a65034b3bc..5b904749a60 100644 --- a/src/components/legend/defaults.js +++ b/src/components/legend/defaults.js @@ -219,6 +219,7 @@ function groupDefaults(legendId, layoutIn, layoutOut, fullData, legendCount) { coerce('indentation'); coerce('itemsizing'); coerce('itemwidth'); + coerce('itemheight'); coerce('itemclick'); coerce('itemdoubleclick'); diff --git a/src/components/legend/draw.js b/src/components/legend/draw.js index 7ed481e2393..d0e89e7fbd0 100644 --- a/src/components/legend/draw.js +++ b/src/components/legend/draw.js @@ -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; } } diff --git a/src/components/legend/style.js b/src/components/legend/style.js index a149a535f84..5e348846837 100644 --- a/src/components/legend/style.js +++ b/src/components/legend/style.js @@ -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); @@ -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); diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 5283911ff43..3271bfc07ef 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -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' diff --git a/test/image/mocks/scatter_fill_pattern.json b/test/image/mocks/scatter_fill_pattern.json index 4a4e6f12b90..1a83c888cbe 100644 --- a/test/image/mocks/scatter_fill_pattern.json +++ b/test/image/mocks/scatter_fill_pattern.json @@ -64,6 +64,7 @@ "rows": 1, "columns": 2, "pattern": "independent" - } + }, + "legend": { "itemheight": 20 } } } diff --git a/test/jasmine/tests/legend_test.js b/test/jasmine/tests/legend_test.js index 023c22e2aab..18c2878d875 100644 --- a/test/jasmine/tests/legend_test.js +++ b/test/jasmine/tests/legend_test.js @@ -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' }]); @@ -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); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 3f8728d1b01..3f5dde569b4 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -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",