diff --git a/draftlogs/7656_add.md b/draftlogs/7656_add.md new file mode 100644 index 00000000000..97170821904 --- /dev/null +++ b/draftlogs/7656_add.md @@ -0,0 +1 @@ +- Add `colorlegend`, `sizelegend`, and `symbollegend` components for attribute-based legends [[#7656](https://github.com/plotly/plotly.js/pull/7656)] diff --git a/src/components/colorlegend/attributes.js b/src/components/colorlegend/attributes.js new file mode 100644 index 00000000000..2a242be4cef --- /dev/null +++ b/src/components/colorlegend/attributes.js @@ -0,0 +1,171 @@ +'use strict'; + +var fontAttrs = require('../../plots/font_attributes'); +var colorAttrs = require('../color/attributes'); + + +module.exports = { + // not really a 'subplot' attribute container, + // but this is the flag we use to denote attributes that + // support colorlegend, colorlegend2, colorlegend3, ... counters + _isSubplotObj: true, + + visible: { + valType: 'boolean', + dflt: true, + editType: 'legend', + description: 'Determines whether this color legend is visible.' + }, + + title: { + text: { + valType: 'string', + dflt: '', + editType: 'legend', + description: 'Sets the title of the color legend.' + }, + font: fontAttrs({ + editType: 'legend', + description: 'Sets the font for the color legend title.' + }), + side: { + valType: 'enumerated', + values: ['top', 'left', 'right'], + dflt: 'top', + editType: 'legend', + description: 'Determines the location of the legend title.' + }, + editType: 'legend' + }, + + // Positioning (same pattern as legend) + x: { + valType: 'number', + dflt: 1.02, + editType: 'legend', + description: 'Sets the x position with respect to `xref`.' + }, + xref: { + valType: 'enumerated', + values: ['container', 'paper'], + dflt: 'paper', + editType: 'legend', + description: 'Sets the container `x` refers to.' + }, + xanchor: { + valType: 'enumerated', + values: ['auto', 'left', 'center', 'right'], + dflt: 'left', + editType: 'legend', + description: 'Sets the horizontal anchor.' + }, + y: { + valType: 'number', + dflt: 1, + editType: 'legend', + description: 'Sets the y position with respect to `yref`.' + }, + yref: { + valType: 'enumerated', + values: ['container', 'paper'], + dflt: 'paper', + editType: 'legend', + description: 'Sets the container `y` refers to.' + }, + yanchor: { + valType: 'enumerated', + values: ['auto', 'top', 'middle', 'bottom'], + dflt: 'auto', + editType: 'legend', + description: 'Sets the vertical anchor.' + }, + + // Styling + bgcolor: { + valType: 'color', + editType: 'legend', + description: 'Sets the background color.' + }, + bordercolor: { + valType: 'color', + dflt: colorAttrs.defaultLine, + editType: 'legend', + description: 'Sets the border color.' + }, + borderwidth: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'legend', + description: 'Sets the border width.' + }, + font: fontAttrs({ + editType: 'legend', + description: 'Sets the font for legend item text.' + }), + + // Orientation + orientation: { + valType: 'enumerated', + values: ['v', 'h'], + dflt: 'v', + editType: 'legend', + description: 'Sets the orientation of the legend items.' + }, + + // Item sizing + itemsizing: { + valType: 'enumerated', + values: ['trace', 'constant'], + dflt: 'constant', + editType: 'legend', + description: [ + 'Determines if legend items symbols scale with their corresponding data values', + 'or remain constant.' + ].join(' ') + }, + itemwidth: { + valType: 'number', + min: 30, + dflt: 30, + editType: 'legend', + description: 'Sets the width of the legend item symbols.' + }, + + // Behavior + itemclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + dflt: 'toggle', + editType: 'legend', + description: 'Determines the behavior on legend item click.' + }, + itemdoubleclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + dflt: 'toggleothers', + editType: 'legend', + description: 'Determines the behavior on legend item double-click.' + }, + + // Continuous data binning + binning: { + valType: 'enumerated', + values: ['auto', 'discrete'], + dflt: 'auto', + editType: 'calc', + description: [ + 'For numeric color data, *auto* creates bins while', + '*discrete* treats each unique value as a category.' + ].join(' ') + }, + nbins: { + valType: 'integer', + min: 1, + dflt: 5, + editType: 'calc', + description: 'Sets the number of bins for continuous data when binning is *auto*.' + }, + + editType: 'legend' +}; diff --git a/src/components/colorlegend/constants.js b/src/components/colorlegend/constants.js new file mode 100644 index 00000000000..ac14cd54910 --- /dev/null +++ b/src/components/colorlegend/constants.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + itemGap: 5, + textGap: 8, + padding: 10 +}; diff --git a/src/components/colorlegend/defaults.js b/src/components/colorlegend/defaults.js new file mode 100644 index 00000000000..ef4778ddf0e --- /dev/null +++ b/src/components/colorlegend/defaults.js @@ -0,0 +1,94 @@ +'use strict'; + +var Lib = require('../../lib'); +var Template = require('../../plot_api/plot_template'); +var attributes = require('./attributes'); + +module.exports = function colorlegendDefaults(layoutIn, layoutOut, fullData) { + var colorlegendIds = findColorlegendIds(fullData); + + layoutOut._colorlegends = []; + + for(var i = 0; i < colorlegendIds.length; i++) { + var id = colorlegendIds[i]; + var containerIn = layoutIn[id] || {}; + var containerOut = Template.newContainer(layoutOut, id); + + handleColorlegendDefaults(containerIn, containerOut, layoutOut, id); + + if(containerOut.visible) { + containerOut._id = id; + layoutOut._colorlegends.push(id); + } + } +}; + +function findColorlegendIds(fullData) { + var ids = []; + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + if(!trace.visible) continue; + + var marker = trace.marker; + if(marker && marker.colorlegend) { + var id = marker.colorlegend; + if(ids.indexOf(id) === -1) { + ids.push(id); + } + } + } + + return ids; +} + +function handleColorlegendDefaults(containerIn, containerOut, layoutOut, id) { + function coerce(attr, dflt) { + return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); + } + + var visible = coerce('visible'); + if(!visible) return; + + // Title + coerce('title.text'); + Lib.coerceFont(coerce, 'title.font', layoutOut.font); + coerce('title.side'); + + // Orientation first - affects positioning defaults + var orientation = coerce('orientation'); + + // Positioning - defaults depend on orientation + var isHorizontal = orientation === 'h'; + if(isHorizontal) { + // Horizontal: top-right, outside the chart + coerce('x', 1.02); + coerce('xanchor', 'left'); + coerce('y', 1); + coerce('yanchor', 'top'); + } else { + // Vertical: right side of chart (default from attributes) + coerce('x'); + coerce('xanchor'); + coerce('y'); + coerce('yanchor'); + } + coerce('xref'); + coerce('yref'); + + // Styling + coerce('bgcolor', layoutOut.paper_bgcolor); + coerce('bordercolor'); + coerce('borderwidth'); + Lib.coerceFont(coerce, 'font', layoutOut.font); + coerce('itemsizing'); + coerce('itemwidth'); + + // Behavior + coerce('itemclick'); + coerce('itemdoubleclick'); + + // Binning + coerce('binning'); + coerce('nbins'); +} diff --git a/src/components/colorlegend/draw.js b/src/components/colorlegend/draw.js new file mode 100644 index 00000000000..f8496727786 --- /dev/null +++ b/src/components/colorlegend/draw.js @@ -0,0 +1,367 @@ +'use strict'; + +var d3 = require('@plotly/d3'); + +var Lib = require('../../lib'); +var Registry = require('../../registry'); +var Plots = require('../../plots/plots'); +var Drawing = require('../drawing'); +var Color = require('../color'); + +var getColorlegendData = require('./get_data'); +var constants = require('./constants'); + +var alignmentConstants = require('../../constants/alignment'); +var FROM_TL = alignmentConstants.FROM_TL; +var FROM_BR = alignmentConstants.FROM_BR; + +var COLORLEGEND_PATTERN = /^colorlegend([2-9]|[1-9][0-9]+)?$/; + +module.exports = function draw(gd) { + var fullLayout = gd._fullLayout; + var colorlegendIds = fullLayout._colorlegends || []; + + // Remove old colorlegends that won't stay on the graph + var oldColorlegends = fullLayout._infolayer.selectAll('[class^="colorlegend"]'); + oldColorlegends.each(function() { + var el = d3.select(this); + var classes = el.attr('class'); + var cls = classes.split(' ')[0]; + if(cls.match(COLORLEGEND_PATTERN) && colorlegendIds.indexOf(cls) === -1) { + el.remove(); + } + }); + + // Draw each colorlegend + for(var i = 0; i < colorlegendIds.length; i++) { + var id = colorlegendIds[i]; + var opts = fullLayout[id]; + if(opts && opts.visible) { + drawOne(gd, opts); + } + } +}; + +function drawOne(gd, opts) { + var fullLayout = gd._fullLayout; + var id = opts._id; + + // Get data + var legendData = getColorlegendData(gd, id); + if(!legendData || !legendData.length) { + fullLayout._infolayer.selectAll('.' + id).remove(); + return; + } + + // Create/update main group + var legend = Lib.ensureSingle(fullLayout._infolayer, 'g', id); + legend.attr('class', id + ' colorlegend') + .style('overflow', 'visible'); + + // Background rect + var bg = Lib.ensureSingle(legend, 'rect', 'bg'); + bg.attr('shape-rendering', 'crispEdges'); + + // Items container + var itemsGroup = Lib.ensureSingle(legend, 'g', 'items'); + + // Bind data and create items + var items = itemsGroup.selectAll('g.colorlegend-item') + .data(legendData, function(d) { return String(d.value); }); + + // Exit old items + items.exit().remove(); + + // Enter new items + items.enter().append('g') + .attr('class', 'colorlegend-item') + .style('cursor', opts.itemclick !== false ? 'pointer' : 'default'); + + // Re-select all items (enter + update) + items = itemsGroup.selectAll('g.colorlegend-item'); + + // Layout configuration + var isVertical = opts.orientation === 'v'; + var itemWidth = opts.itemwidth; + var itemHeight = 20; + var itemGap = constants.itemGap; + var textGap = constants.textGap; + var padding = constants.padding; + var borderWidth = opts.borderwidth; + + var maxTextWidth = 0; + var totalHeight = 0; + var totalWidth = 0; + + // First pass: create items and measure text + items.each(function(d, i) { + var item = d3.select(this); + + // Color swatch + var swatch = Lib.ensureSingle(item, 'rect', 'swatch'); + swatch + .attr('width', itemWidth) + .attr('height', itemHeight - 4) + .attr('x', 0) + .attr('y', 2) + .attr('rx', 2) + .attr('ry', 2); + + Color.fill(swatch, d.color); + + // Label text + var labelText = d.displayValue !== undefined ? d.displayValue : String(d.value); + var label = Lib.ensureSingle(item, 'text', 'label'); + label + .attr('x', itemWidth + textGap) + .attr('y', itemHeight / 2) + .attr('dy', '0.35em') + .text(labelText); + + Drawing.font(label, opts.font); + + // Measure text width + var textBBox = label.node().getBBox(); + maxTextWidth = Math.max(maxTextWidth, textBBox.width); + + // Position item + if(isVertical) { + Drawing.setTranslate(item, 0, i * (itemHeight + itemGap)); + } else { + // Horizontal layout - will be repositioned after measuring + item.attr('data-index', i); + } + + // Store data for click handling + item.datum(d); + }); + + // Second pass: add click target rects now that we know maxTextWidth + var clickTargetWidth = itemWidth + textGap + Math.max(maxTextWidth, 40); + items.each(function(d) { + var item = d3.select(this); + + // Transparent click target rect - must cover entire item area + var clickTarget = Lib.ensureSingle(item, 'rect', 'clicktarget'); + clickTarget + .attr('width', clickTargetWidth) + .attr('height', itemHeight) + .attr('x', 0) + .attr('y', 0) + .style('fill', 'transparent') + .style('cursor', 'pointer') + .attr('pointer-events', 'all'); + }); + + // Calculate dimensions - ensure minimum text width for readability + maxTextWidth = Math.max(maxTextWidth, 40); + var totalItemWidth = itemWidth + textGap + maxTextWidth; + + if(isVertical) { + totalWidth = totalItemWidth + padding * 2; + totalHeight = legendData.length * (itemHeight + itemGap) - itemGap + padding * 2; + } else { + // Horizontal: reposition items + var offsetX = 0; + items.each(function() { + var item = d3.select(this); + Drawing.setTranslate(item, offsetX, 0); + offsetX += totalItemWidth + itemGap * 2; + }); + totalWidth = offsetX - itemGap * 2 + padding * 2; + totalHeight = itemHeight + padding * 2; + } + + // Handle title + var titleHeight = 0; + if(opts.title && opts.title.text) { + var title = Lib.ensureSingle(legend, 'text', 'legendtitle'); + title + .attr('class', 'legendtitle') + .attr('x', padding) + .attr('y', padding) + .attr('dy', '1em') + .text(opts.title.text); + + var titleFont = opts.title.font || opts.font; + Drawing.font(title, titleFont); + + var titleBBox = title.node().getBBox(); + titleHeight = titleBBox.height + itemGap; + totalHeight += titleHeight; + + // Ensure legend is wide enough for title + totalWidth = Math.max(totalWidth, titleBBox.width + padding * 2); + + Drawing.setTranslate(itemsGroup, padding, padding + titleHeight); + } else { + legend.selectAll('.legendtitle').remove(); + Drawing.setTranslate(itemsGroup, padding, padding); + } + + // Set background size and style + bg.attr('width', totalWidth) + .attr('height', totalHeight); + + Color.fill(bg, opts.bgcolor); + Color.stroke(bg, opts.bordercolor); + bg.style('stroke-width', borderWidth + 'px'); + + // Store dimensions for positioning + opts._width = totalWidth; + opts._height = totalHeight; + + // Position legend and reserve margin space + positionLegend(gd, legend, opts, totalWidth, totalHeight); + computeAutoMargin(gd, opts); + + // Setup click handlers + setupClickHandlers(gd, items, opts); +} + +function computeAutoMargin(gd, opts) { + var id = opts._id; + var xanchor = opts.xanchor === 'auto' ? + (opts.x < 0.5 ? 'left' : 'right') : opts.xanchor; + var yanchor = opts.yanchor === 'auto' ? + (opts.y < 0.5 ? 'bottom' : 'top') : opts.yanchor; + + // Only auto-margin when using paper reference + if(opts.xref === 'paper' && opts.yref === 'paper') { + Plots.autoMargin(gd, id, { + x: opts.x, + y: opts.y, + l: opts._width * FROM_TL[xanchor], + r: opts._width * FROM_BR[xanchor], + b: opts._height * FROM_BR[yanchor], + t: opts._height * FROM_TL[yanchor] + }); + } +} + +function positionLegend(gd, legend, opts, width, height) { + var fullLayout = gd._fullLayout; + var gs = fullLayout._size; + + var isPaperX = opts.xref === 'paper'; + var isPaperY = opts.yref === 'paper'; + + // Calculate anchor multipliers + var anchorX = getXAnchorFraction(opts); + var anchorY = getYAnchorFraction(opts); + + var lx, ly; + + if(isPaperX) { + lx = gs.l + gs.w * opts.x - anchorX * width; + } else { + lx = fullLayout.width * opts.x - anchorX * width; + } + + if(isPaperY) { + ly = gs.t + gs.h * (1 - opts.y) - anchorY * height; + } else { + ly = fullLayout.height * (1 - opts.y) - anchorY * height; + } + + Drawing.setTranslate(legend, Math.round(lx), Math.round(ly)); +} + +function getXAnchorFraction(opts) { + var xanchor = opts.xanchor; + if(xanchor === 'auto') { + // Auto anchor based on x position + if(opts.x <= 0.33) return 0; // left + if(opts.x >= 0.67) return 1; // right + return 0.5; // center + } + return {left: 0, center: 0.5, right: 1}[xanchor] || 0; +} + +function getYAnchorFraction(opts) { + var yanchor = opts.yanchor; + if(yanchor === 'auto') { + // Auto anchor based on y position + if(opts.y <= 0.33) return 1; // bottom + if(opts.y >= 0.67) return 0; // top + return 0.5; // middle + } + return {top: 0, middle: 0.5, bottom: 1}[yanchor] || 0; +} + +function setupClickHandlers(gd, items, opts) { + if(opts.itemclick === false && opts.itemdoubleclick === false) { + items.style('cursor', 'default'); + items.on('mousedown', null); + items.on('mouseup', null); + items.on('click', null); + return; + } + + var doubleClickDelay = gd._context.doubleClickDelay || 300; + var lastClickTime = 0; + var clickCount = 0; + + items.on('mousedown', function() { + var now = Date.now(); + if(now - lastClickTime < doubleClickDelay) { + clickCount++; + } else { + clickCount = 1; + } + lastClickTime = now; + }); + + items.on('mouseup', function() { + var d = d3.select(this).datum(); + var now = Date.now(); + if(now - lastClickTime > doubleClickDelay) { + clickCount = Math.max(clickCount - 1, 1); + } + + handleClick(gd, d, clickCount, opts); + }); +} + +function handleClick(gd, itemData, numClicks, opts) { + var action = numClicks === 2 ? opts.itemdoubleclick : opts.itemclick; + if(!action) return; + + var fullLayout = gd._fullLayout; + var hiddenColors = fullLayout._hiddenColorlegendValues || {}; + var id = opts._id; + + if(!hiddenColors[id]) { + hiddenColors[id] = []; + } + + var valueKey = String(itemData.value); + var isHidden = hiddenColors[id].indexOf(valueKey) !== -1; + + if(action === 'toggle') { + if(isHidden) { + // Show this value + hiddenColors[id] = hiddenColors[id].filter(function(v) { return v !== valueKey; }); + } else { + // Hide this value + hiddenColors[id].push(valueKey); + } + } else if(action === 'toggleothers') { + // Get all values for this legend + var legendData = getColorlegendData(gd, id); + var allValues = legendData.map(function(d) { return String(d.value); }); + + if(isHidden && hiddenColors[id].length === allValues.length - 1) { + // Only this one showing, show all + hiddenColors[id] = []; + } else { + // Hide all except this one + hiddenColors[id] = allValues.filter(function(v) { return v !== valueKey; }); + } + } + + fullLayout._hiddenColorlegendValues = hiddenColors; + + // Trigger redraw via Registry + Registry.call('_guiRelayout', gd, {}); +} diff --git a/src/components/colorlegend/get_data.js b/src/components/colorlegend/get_data.js new file mode 100644 index 00000000000..4da7cc03d20 --- /dev/null +++ b/src/components/colorlegend/get_data.js @@ -0,0 +1,206 @@ +'use strict'; + +var tinycolor = require('tinycolor2'); +var isNumeric = require('fast-isnumeric'); + +var Lib = require('../../lib'); +var Colorscale = require('../colorscale'); + +/** + * Collect unique color values from all traces referencing this colorlegend + * + * @param {object} gd - graph div + * @param {string} colorlegendId - e.g., 'colorlegend', 'colorlegend2' + * @returns {Array} - array of {value, color, traces: [traceIndices], points: [{trace, i}]} + */ +module.exports = function getColorlegendData(gd, colorlegendId) { + var fullData = gd._fullData; + var fullLayout = gd._fullLayout; + var colorlegendOpts = fullLayout[colorlegendId]; + + var valueMap = {}; // value -> {color, traces, points} + var values = []; // ordered list of unique values + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + if(!trace.visible || trace.visible === 'legendonly') continue; + + var marker = trace.marker; + if(!marker || marker.colorlegend !== colorlegendId) continue; + + var colors = marker.color; + if(!Array.isArray(colors)) { + // Single color - treat as one category + colors = [colors]; + } + + for(var j = 0; j < colors.length; j++) { + var colorVal = colors[j]; + var key = String(colorVal); + + if(!valueMap[key]) { + valueMap[key] = { + value: colorVal, + color: resolveColor(colorVal, trace, j), + traces: [], + points: [] + }; + values.push(key); + } + + if(valueMap[key].traces.indexOf(i) === -1) { + valueMap[key].traces.push(i); + } + valueMap[key].points.push({trace: i, i: j}); + } + } + + // Handle binning for numeric data + if(colorlegendOpts.binning === 'auto' && values.length > 0) { + var isNumericData = values.every(function(v) { + return isNumeric(valueMap[v].value); + }); + + if(isNumericData && values.length > colorlegendOpts.nbins) { + return binNumericValues(valueMap, values, colorlegendOpts); + } + } + + // Return discrete values + return values.map(function(key) { + return valueMap[key]; + }); +}; + +function resolveColor(colorVal, trace, pointIndex) { + var marker = trace.marker; + + // If colorVal is already a valid color string, return it + if(typeof colorVal === 'string') { + var tc = tinycolor(colorVal); + if(tc.isValid()) { + return tc.toRgbString(); + } + } + + // If using colorscale, compute color from scale + if(marker.colorscale && isNumeric(colorVal)) { + var scaleFunc = Colorscale.makeColorScaleFuncFromTrace({ + marker: marker, + _module: trace._module + }); + + if(scaleFunc) { + return scaleFunc(colorVal); + } + } + + // Fallback: use a default color palette based on the value + // This handles cases like categorical strings that aren't valid colors + var defaultColors = [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf' + ]; + + // For non-color strings, assign from palette based on hash + if(typeof colorVal === 'string') { + var hash = 0; + for(var i = 0; i < colorVal.length; i++) { + hash = ((hash << 5) - hash) + colorVal.charCodeAt(i); + hash = hash & hash; // Convert to 32-bit integer + } + return defaultColors[Math.abs(hash) % defaultColors.length]; + } + + // Final fallback + return marker.color || (trace.line && trace.line.color) || '#1f77b4'; +} + +function binNumericValues(valueMap, values, opts) { + var numericValues = values.map(function(v) { + return parseFloat(valueMap[v].value); + }); + + var min = Math.min.apply(null, numericValues); + var max = Math.max.apply(null, numericValues); + + // Handle edge case where all values are the same + if(min === max) { + return [{ + value: min, + displayValue: String(min), + color: valueMap[values[0]].color, + traces: valueMap[values[0]].traces.slice(), + points: valueMap[values[0]].points.slice() + }]; + } + + var binSize = (max - min) / opts.nbins; + + var bins = []; + for(var b = 0; b < opts.nbins; b++) { + var binMin = min + b * binSize; + var binMax = min + (b + 1) * binSize; + + bins.push({ + value: (binMin + binMax) / 2, + displayValue: formatRange(binMin, binMax, b === opts.nbins - 1), + binMin: binMin, + binMax: binMax, + color: null, // Will be computed from first encountered value + traces: [], + points: [] + }); + } + + // Assign points to bins + values.forEach(function(key) { + var item = valueMap[key]; + var val = parseFloat(item.value); + var binIndex = Math.min(Math.floor((val - min) / binSize), opts.nbins - 1); + + bins[binIndex].points = bins[binIndex].points.concat(item.points); + + // Add traces + for(var t = 0; t < item.traces.length; t++) { + if(bins[binIndex].traces.indexOf(item.traces[t]) === -1) { + bins[binIndex].traces.push(item.traces[t]); + } + } + + // Use first encountered color for bin + if(!bins[binIndex].color) { + bins[binIndex].color = item.color; + } + }); + + // Remove empty bins + return bins.filter(function(bin) { + return bin.points.length > 0; + }); +} + +function formatRange(min, max, isLast) { + // Format numbers nicely + var precision = Math.max( + countDecimals(min), + countDecimals(max), + 0 + ); + + // Limit precision to something reasonable + precision = Math.min(precision, 2); + + var minStr = min.toFixed(precision); + var maxStr = max.toFixed(precision); + + // Use different bracket for last bin (inclusive upper bound) + return '[' + minStr + ', ' + maxStr + (isLast ? ']' : ')'); +} + +function countDecimals(num) { + if(Math.floor(num) === num) return 0; + var str = String(num); + var parts = str.split('.'); + return parts[1] ? parts[1].length : 0; +} diff --git a/src/components/colorlegend/index.js b/src/components/colorlegend/index.js new file mode 100644 index 00000000000..1d742d6274f --- /dev/null +++ b/src/components/colorlegend/index.js @@ -0,0 +1,12 @@ +'use strict'; + + +module.exports = { + moduleType: 'component', + name: 'colorlegend', + + layoutAttributes: require('./attributes'), + supplyLayoutDefaults: require('./defaults'), + + draw: require('./draw') +}; diff --git a/src/components/sizelegend/attributes.js b/src/components/sizelegend/attributes.js new file mode 100644 index 00000000000..1ac49791629 --- /dev/null +++ b/src/components/sizelegend/attributes.js @@ -0,0 +1,164 @@ +'use strict'; + +var fontAttrs = require('../../plots/font_attributes'); +var colorAttrs = require('../color/attributes'); + + +module.exports = { + // not really a 'subplot' attribute container, + // but this is the flag we use to denote attributes that + // support sizelegend, sizelegend2, sizelegend3, ... counters + _isSubplotObj: true, + + visible: { + valType: 'boolean', + dflt: true, + editType: 'legend', + description: 'Determines whether this size legend is visible.' + }, + + title: { + text: { + valType: 'string', + dflt: '', + editType: 'legend', + description: 'Sets the title of the size legend.' + }, + font: fontAttrs({ + editType: 'legend', + description: 'Sets the font for the size legend title.' + }), + side: { + valType: 'enumerated', + values: ['top', 'left', 'right'], + dflt: 'top', + editType: 'legend', + description: 'Determines the location of the legend title.' + }, + editType: 'legend' + }, + + // Positioning (same pattern as legend) + x: { + valType: 'number', + dflt: 1.02, + editType: 'legend', + description: 'Sets the x position with respect to `xref`.' + }, + xref: { + valType: 'enumerated', + values: ['container', 'paper'], + dflt: 'paper', + editType: 'legend', + description: 'Sets the container `x` refers to.' + }, + xanchor: { + valType: 'enumerated', + values: ['auto', 'left', 'center', 'right'], + dflt: 'left', + editType: 'legend', + description: 'Sets the horizontal anchor.' + }, + y: { + valType: 'number', + dflt: 0.5, + editType: 'legend', + description: 'Sets the y position with respect to `yref`.' + }, + yref: { + valType: 'enumerated', + values: ['container', 'paper'], + dflt: 'paper', + editType: 'legend', + description: 'Sets the container `y` refers to.' + }, + yanchor: { + valType: 'enumerated', + values: ['auto', 'top', 'middle', 'bottom'], + dflt: 'middle', + editType: 'legend', + description: 'Sets the vertical anchor.' + }, + + // Styling + bgcolor: { + valType: 'color', + editType: 'legend', + description: 'Sets the background color.' + }, + bordercolor: { + valType: 'color', + dflt: colorAttrs.defaultLine, + editType: 'legend', + description: 'Sets the border color.' + }, + borderwidth: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'legend', + description: 'Sets the border width.' + }, + font: fontAttrs({ + editType: 'legend', + description: 'Sets the font for legend item text.' + }), + + // Orientation + orientation: { + valType: 'enumerated', + values: ['v', 'h'], + dflt: 'v', + editType: 'legend', + description: 'Sets the orientation of the legend items.' + }, + + // Number of size samples to show + nsamples: { + valType: 'integer', + min: 2, + max: 10, + dflt: 4, + editType: 'legend', + description: 'Number of size samples to display in the legend.' + }, + + // Symbol styling + symbolcolor: { + valType: 'color', + dflt: '#444', + editType: 'legend', + description: 'Fill color for size symbols.' + }, + symboloutlinecolor: { + valType: 'color', + dflt: '#444', + editType: 'legend', + description: 'Outline color for size symbols.' + }, + symboloutlinewidth: { + valType: 'number', + min: 0, + dflt: 1, + editType: 'legend', + description: 'Outline width for size symbols.' + }, + + // Click behavior + itemclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + dflt: 'toggle', + editType: 'legend', + description: 'Determines the behavior on legend item click.' + }, + itemdoubleclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + dflt: 'toggleothers', + editType: 'legend', + description: 'Determines the behavior on legend item double-click.' + }, + + editType: 'legend' +}; diff --git a/src/components/sizelegend/constants.js b/src/components/sizelegend/constants.js new file mode 100644 index 00000000000..ac14cd54910 --- /dev/null +++ b/src/components/sizelegend/constants.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + itemGap: 5, + textGap: 8, + padding: 10 +}; diff --git a/src/components/sizelegend/defaults.js b/src/components/sizelegend/defaults.js new file mode 100644 index 00000000000..d9e378f1e52 --- /dev/null +++ b/src/components/sizelegend/defaults.js @@ -0,0 +1,94 @@ +'use strict'; + +var Lib = require('../../lib'); +var Template = require('../../plot_api/plot_template'); +var attributes = require('./attributes'); + +module.exports = function sizelegendDefaults(layoutIn, layoutOut, fullData) { + var sizelegendIds = findSizelegendIds(fullData); + + layoutOut._sizelegends = []; + + for(var i = 0; i < sizelegendIds.length; i++) { + var id = sizelegendIds[i]; + var containerIn = layoutIn[id] || {}; + var containerOut = Template.newContainer(layoutOut, id); + + handleSizelegendDefaults(containerIn, containerOut, layoutOut, id); + + if(containerOut.visible) { + containerOut._id = id; + layoutOut._sizelegends.push(id); + } + } +}; + +function findSizelegendIds(fullData) { + var ids = []; + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + if(!trace.visible) continue; + + var marker = trace.marker; + if(marker && marker.sizelegend) { + var id = marker.sizelegend; + if(ids.indexOf(id) === -1) { + ids.push(id); + } + } + } + + return ids; +} + +function handleSizelegendDefaults(containerIn, containerOut, layoutOut, id) { + function coerce(attr, dflt) { + return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); + } + + var visible = coerce('visible'); + if(!visible) return; + + // Title + coerce('title.text'); + Lib.coerceFont(coerce, 'title.font', layoutOut.font); + coerce('title.side'); + + // Orientation first - affects positioning defaults + var orientation = coerce('orientation'); + + // Positioning - defaults depend on orientation + var isHorizontal = orientation === 'h'; + if(isHorizontal) { + // Horizontal: bottom-right + coerce('x', 1.02); + coerce('xanchor', 'left'); + coerce('y', 0); + coerce('yanchor', 'bottom'); + } else { + // Vertical: right side of chart, centered (default from attributes) + coerce('x'); + coerce('xanchor'); + coerce('y'); + coerce('yanchor'); + } + coerce('xref'); + coerce('yref'); + + // Styling + coerce('bgcolor', layoutOut.paper_bgcolor); + coerce('bordercolor'); + coerce('borderwidth'); + Lib.coerceFont(coerce, 'font', layoutOut.font); + + // Size-specific + coerce('nsamples'); + coerce('symbolcolor'); + coerce('symboloutlinecolor'); + coerce('symboloutlinewidth'); + + // Behavior + coerce('itemclick'); + coerce('itemdoubleclick'); +} diff --git a/src/components/sizelegend/draw.js b/src/components/sizelegend/draw.js new file mode 100644 index 00000000000..8a082426633 --- /dev/null +++ b/src/components/sizelegend/draw.js @@ -0,0 +1,370 @@ +'use strict'; + +var d3 = require('@plotly/d3'); + +var Lib = require('../../lib'); +var Registry = require('../../registry'); +var Plots = require('../../plots/plots'); +var Drawing = require('../drawing'); +var Color = require('../color'); + +var getSizelegendData = require('./get_data'); +var constants = require('./constants'); + +var alignmentConstants = require('../../constants/alignment'); +var FROM_TL = alignmentConstants.FROM_TL; +var FROM_BR = alignmentConstants.FROM_BR; + +var SIZELEGEND_PATTERN = /^sizelegend([2-9]|[1-9][0-9]+)?$/; + +module.exports = function draw(gd) { + var fullLayout = gd._fullLayout; + var sizelegendIds = fullLayout._sizelegends || []; + + // Remove old sizelegends that won't stay on the graph + var oldSizelegends = fullLayout._infolayer.selectAll('[class^="sizelegend"]'); + oldSizelegends.each(function() { + var el = d3.select(this); + var classes = el.attr('class'); + var cls = classes.split(' ')[0]; + if(cls.match(SIZELEGEND_PATTERN) && sizelegendIds.indexOf(cls) === -1) { + el.remove(); + } + }); + + // Draw each sizelegend + for(var i = 0; i < sizelegendIds.length; i++) { + var id = sizelegendIds[i]; + var opts = fullLayout[id]; + if(opts && opts.visible) { + drawOne(gd, opts); + } + } +}; + +function drawOne(gd, opts) { + var fullLayout = gd._fullLayout; + var id = opts._id; + + // Get data + var legendData = getSizelegendData(gd, id); + if(!legendData || !legendData.length) { + fullLayout._infolayer.selectAll('.' + id).remove(); + return; + } + + // Create/update main group + var legend = Lib.ensureSingle(fullLayout._infolayer, 'g', id); + legend.attr('class', id + ' sizelegend') + .style('overflow', 'visible'); + + // Background rect + var bg = Lib.ensureSingle(legend, 'rect', 'bg'); + bg.attr('shape-rendering', 'crispEdges'); + + // Items container + var itemsGroup = Lib.ensureSingle(legend, 'g', 'items'); + + // Bind data and create items + var items = itemsGroup.selectAll('g.sizelegend-item') + .data(legendData, function(d) { return String(d.value); }); + + // Exit old items + items.exit().remove(); + + // Enter new items + items.enter().append('g') + .attr('class', 'sizelegend-item') + .style('cursor', opts.itemclick !== false ? 'pointer' : 'default'); + + // Re-select all items (enter + update) + items = itemsGroup.selectAll('g.sizelegend-item'); + + // Layout configuration + var isVertical = opts.orientation === 'v'; + var itemGap = constants.itemGap; + var textGap = constants.textGap; + var padding = constants.padding; + var borderWidth = opts.borderwidth; + + // Find max display size for layout calculations + var maxDisplaySize = 0; + legendData.forEach(function(d) { + maxDisplaySize = Math.max(maxDisplaySize, d.displaySize); + }); + + var maxTextWidth = 0; + var totalHeight = 0; + var totalWidth = 0; + + // Update items + items.each(function(d, i) { + var item = d3.select(this); + var radius = d.displaySize / 2; + + // Circle symbol - centered in the allocated space + var circle = Lib.ensureSingle(item, 'circle', 'symbol'); + circle + .attr('cx', maxDisplaySize / 2) + .attr('cy', maxDisplaySize / 2) + .attr('r', radius); + + Color.fill(circle, opts.symbolcolor); + Color.stroke(circle, opts.symboloutlinecolor); + circle.style('stroke-width', opts.symboloutlinewidth + 'px'); + + // Label text + var label = Lib.ensureSingle(item, 'text', 'label'); + label + .attr('x', maxDisplaySize + textGap) + .attr('y', maxDisplaySize / 2) + .attr('dy', '0.35em') + .text(d.displayValue); + + Drawing.font(label, opts.font); + + // Measure text width + var textBBox = label.node().getBBox(); + maxTextWidth = Math.max(maxTextWidth, textBBox.width); + + // Position item + if(isVertical) { + Drawing.setTranslate(item, 0, i * (maxDisplaySize + itemGap)); + } else { + // Horizontal layout - will be repositioned after measuring + item.attr('data-index', i); + } + + // Store data for click handling + item.datum(d); + }); + + // Calculate dimensions + maxTextWidth = Math.max(maxTextWidth, 20); + var totalItemWidth = maxDisplaySize + textGap + maxTextWidth; + + // Second pass: add click target rects now that we know dimensions + var clickTargetWidth = totalItemWidth; + items.each(function(d) { + var item = d3.select(this); + + // Transparent click target rect - must cover entire item area + var clickTarget = Lib.ensureSingle(item, 'rect', 'clicktarget'); + clickTarget + .attr('width', clickTargetWidth) + .attr('height', maxDisplaySize) + .attr('x', 0) + .attr('y', 0) + .style('fill', 'transparent') + .style('cursor', 'pointer') + .attr('pointer-events', 'all'); + }); + + if(isVertical) { + totalWidth = totalItemWidth + padding * 2; + totalHeight = legendData.length * (maxDisplaySize + itemGap) - itemGap + padding * 2; + } else { + // Horizontal: reposition items + var offsetX = 0; + items.each(function() { + var item = d3.select(this); + Drawing.setTranslate(item, offsetX, 0); + offsetX += totalItemWidth + itemGap * 2; + }); + totalWidth = offsetX - itemGap * 2 + padding * 2; + totalHeight = maxDisplaySize + padding * 2; + } + + // Handle title + var titleHeight = 0; + if(opts.title && opts.title.text) { + var title = Lib.ensureSingle(legend, 'text', 'legendtitle'); + title + .attr('class', 'legendtitle') + .attr('x', padding) + .attr('y', padding) + .attr('dy', '1em') + .text(opts.title.text); + + var titleFont = opts.title.font || opts.font; + Drawing.font(title, titleFont); + + var titleBBox = title.node().getBBox(); + titleHeight = titleBBox.height + itemGap; + totalHeight += titleHeight; + + // Ensure legend is wide enough for title + totalWidth = Math.max(totalWidth, titleBBox.width + padding * 2); + + Drawing.setTranslate(itemsGroup, padding, padding + titleHeight); + } else { + legend.selectAll('.legendtitle').remove(); + Drawing.setTranslate(itemsGroup, padding, padding); + } + + // Set background size and style + bg.attr('width', totalWidth) + .attr('height', totalHeight); + + Color.fill(bg, opts.bgcolor); + Color.stroke(bg, opts.bordercolor); + bg.style('stroke-width', borderWidth + 'px'); + + // Store dimensions for positioning + opts._width = totalWidth; + opts._height = totalHeight; + + // Position legend and reserve margin space + positionLegend(gd, legend, opts, totalWidth, totalHeight); + computeAutoMargin(gd, opts); + + // Setup click handlers + setupClickHandlers(gd, items, opts); +} + +function computeAutoMargin(gd, opts) { + var id = opts._id; + var xanchor = opts.xanchor === 'auto' ? + (opts.x < 0.5 ? 'left' : 'right') : opts.xanchor; + var yanchor = opts.yanchor === 'auto' ? + (opts.y < 0.5 ? 'bottom' : 'top') : opts.yanchor; + + // Only auto-margin when using paper reference + if(opts.xref === 'paper' && opts.yref === 'paper') { + Plots.autoMargin(gd, id, { + x: opts.x, + y: opts.y, + l: opts._width * FROM_TL[xanchor], + r: opts._width * FROM_BR[xanchor], + b: opts._height * FROM_BR[yanchor], + t: opts._height * FROM_TL[yanchor] + }); + } +} + +function positionLegend(gd, legend, opts, width, height) { + var fullLayout = gd._fullLayout; + var gs = fullLayout._size; + + var isPaperX = opts.xref === 'paper'; + var isPaperY = opts.yref === 'paper'; + + // Calculate anchor multipliers + var anchorX = getXAnchorFraction(opts); + var anchorY = getYAnchorFraction(opts); + + var lx, ly; + + if(isPaperX) { + lx = gs.l + gs.w * opts.x - anchorX * width; + } else { + lx = fullLayout.width * opts.x - anchorX * width; + } + + if(isPaperY) { + ly = gs.t + gs.h * (1 - opts.y) - anchorY * height; + } else { + ly = fullLayout.height * (1 - opts.y) - anchorY * height; + } + + Drawing.setTranslate(legend, Math.round(lx), Math.round(ly)); +} + +function getXAnchorFraction(opts) { + var xanchor = opts.xanchor; + if(xanchor === 'auto') { + // Auto anchor based on x position + if(opts.x <= 0.33) return 0; // left + if(opts.x >= 0.67) return 1; // right + return 0.5; // center + } + return {left: 0, center: 0.5, right: 1}[xanchor] || 0; +} + +function getYAnchorFraction(opts) { + var yanchor = opts.yanchor; + if(yanchor === 'auto') { + // Auto anchor based on y position + if(opts.y <= 0.33) return 1; // bottom + if(opts.y >= 0.67) return 0; // top + return 0.5; // middle + } + return {top: 0, middle: 0.5, bottom: 1}[yanchor] || 0; +} + +function setupClickHandlers(gd, items, opts) { + if(opts.itemclick === false && opts.itemdoubleclick === false) { + items.style('cursor', 'default'); + items.on('mousedown', null); + items.on('mouseup', null); + items.on('click', null); + return; + } + + var doubleClickDelay = gd._context.doubleClickDelay || 300; + var lastClickTime = 0; + var clickCount = 0; + + items.on('mousedown', function() { + var now = Date.now(); + if(now - lastClickTime < doubleClickDelay) { + clickCount++; + } else { + clickCount = 1; + } + lastClickTime = now; + }); + + items.on('mouseup', function() { + var d = d3.select(this).datum(); + var now = Date.now(); + if(now - lastClickTime > doubleClickDelay) { + clickCount = Math.max(clickCount - 1, 1); + } + + handleClick(gd, d, clickCount, opts); + }); +} + +function handleClick(gd, itemData, numClicks, opts) { + var action = numClicks === 2 ? opts.itemdoubleclick : opts.itemclick; + if(!action) return; + + var fullLayout = gd._fullLayout; + var hiddenSizes = fullLayout._hiddenSizelegendValues || {}; + var id = opts._id; + + if(!hiddenSizes[id]) { + hiddenSizes[id] = []; + } + + var valueKey = String(itemData.value); + var isHidden = hiddenSizes[id].indexOf(valueKey) !== -1; + + if(action === 'toggle') { + if(isHidden) { + // Show this value + hiddenSizes[id] = hiddenSizes[id].filter(function(v) { return v !== valueKey; }); + } else { + // Hide this value + hiddenSizes[id].push(valueKey); + } + } else if(action === 'toggleothers') { + // Get all values for this legend + var legendData = getSizelegendData(gd, id); + var allValues = legendData.map(function(d) { return String(d.value); }); + + if(isHidden && hiddenSizes[id].length === allValues.length - 1) { + // Only this one showing, show all + hiddenSizes[id] = []; + } else { + // Hide all except this one + hiddenSizes[id] = allValues.filter(function(v) { return v !== valueKey; }); + } + } + + fullLayout._hiddenSizelegendValues = hiddenSizes; + + // Trigger redraw via Registry + Registry.call('_guiRelayout', gd, {}); +} diff --git a/src/components/sizelegend/get_data.js b/src/components/sizelegend/get_data.js new file mode 100644 index 00000000000..cee889f1392 --- /dev/null +++ b/src/components/sizelegend/get_data.js @@ -0,0 +1,156 @@ +'use strict'; + +var isNumeric = require('fast-isnumeric'); + +/** + * Collect size data from all traces referencing this sizelegend + * and return representative size samples for the legend + * + * @param {object} gd - graph div + * @param {string} sizelegendId - e.g., 'sizelegend', 'sizelegend2' + * @returns {Array} - array of {value, displaySize, traces, points} + */ +module.exports = function getSizelegendData(gd, sizelegendId) { + var fullData = gd._fullData; + var fullLayout = gd._fullLayout; + var sizelegendOpts = fullLayout[sizelegendId]; + + var allSizes = []; + var traceInfo = []; + + // Collect all sizes from traces referencing this sizelegend + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + if(!trace.visible || trace.visible === 'legendonly') continue; + + var marker = trace.marker; + if(!marker || marker.sizelegend !== sizelegendId) continue; + + var sizes = marker.size; + if(!Array.isArray(sizes)) { + // Single size value + sizes = [sizes]; + } + + for(var j = 0; j < sizes.length; j++) { + var sizeVal = sizes[j]; + if(isNumeric(sizeVal)) { + allSizes.push(parseFloat(sizeVal)); + traceInfo.push({trace: i, i: j, size: parseFloat(sizeVal)}); + } + } + } + + if(allSizes.length === 0) { + return []; + } + + // Calculate min and max sizes + var minSize = Math.min.apply(null, allSizes); + var maxSize = Math.max.apply(null, allSizes); + + // Handle case where all sizes are the same + if(minSize === maxSize) { + return [{ + value: minSize, + displayValue: formatSize(minSize), + displaySize: computeDisplaySize(minSize, minSize, maxSize), + traces: getTracesWithSize(traceInfo, minSize, minSize), + points: getPointsWithSize(traceInfo, minSize, minSize) + }]; + } + + // Generate size samples + var nsamples = sizelegendOpts.nsamples; + var samples = []; + + for(var s = 0; s < nsamples; s++) { + // Sample from min to max, inclusive + var fraction = s / (nsamples - 1); + var sampleSize = minSize + fraction * (maxSize - minSize); + + // Determine the range of sizes this sample represents + var rangeMin, rangeMax; + if(s === 0) { + rangeMin = minSize; + rangeMax = minSize + (maxSize - minSize) / (nsamples * 2); + } else if(s === nsamples - 1) { + rangeMin = maxSize - (maxSize - minSize) / (nsamples * 2); + rangeMax = maxSize; + } else { + var halfStep = (maxSize - minSize) / (nsamples - 1) / 2; + rangeMin = sampleSize - halfStep; + rangeMax = sampleSize + halfStep; + } + + samples.push({ + value: sampleSize, + displayValue: formatSize(sampleSize), + displaySize: computeDisplaySize(sampleSize, minSize, maxSize), + rangeMin: rangeMin, + rangeMax: rangeMax, + traces: getTracesWithSize(traceInfo, rangeMin, rangeMax), + points: getPointsWithSize(traceInfo, rangeMin, rangeMax) + }); + } + + return samples; +}; + +/** + * Compute display size for legend (pixels) + * Map the value proportionally between min/max legend display sizes + */ +function computeDisplaySize(value, minVal, maxVal) { + var minDisplay = 6; // minimum circle diameter in legend + var maxDisplay = 30; // maximum circle diameter in legend + + if(minVal === maxVal) { + return (minDisplay + maxDisplay) / 2; + } + + var fraction = (value - minVal) / (maxVal - minVal); + return minDisplay + fraction * (maxDisplay - minDisplay); +} + +/** + * Get trace indices that have sizes in the given range + */ +function getTracesWithSize(traceInfo, rangeMin, rangeMax) { + var traces = []; + for(var i = 0; i < traceInfo.length; i++) { + var info = traceInfo[i]; + if(info.size >= rangeMin && info.size <= rangeMax) { + if(traces.indexOf(info.trace) === -1) { + traces.push(info.trace); + } + } + } + return traces; +} + +/** + * Get points that have sizes in the given range + */ +function getPointsWithSize(traceInfo, rangeMin, rangeMax) { + var points = []; + for(var i = 0; i < traceInfo.length; i++) { + var info = traceInfo[i]; + if(info.size >= rangeMin && info.size <= rangeMax) { + points.push({trace: info.trace, i: info.i}); + } + } + return points; +} + +/** + * Format size value for display + */ +function formatSize(value) { + // Format nicely - remove unnecessary decimals + if(Math.floor(value) === value) { + return String(value); + } + // Limit to 1 decimal place + return value.toFixed(1); +} diff --git a/src/components/sizelegend/index.js b/src/components/sizelegend/index.js new file mode 100644 index 00000000000..576b9c38054 --- /dev/null +++ b/src/components/sizelegend/index.js @@ -0,0 +1,12 @@ +'use strict'; + + +module.exports = { + moduleType: 'component', + name: 'sizelegend', + + layoutAttributes: require('./attributes'), + supplyLayoutDefaults: require('./defaults'), + + draw: require('./draw') +}; diff --git a/src/components/symbollegend/attributes.js b/src/components/symbollegend/attributes.js new file mode 100644 index 00000000000..21fbbfe90a2 --- /dev/null +++ b/src/components/symbollegend/attributes.js @@ -0,0 +1,148 @@ +'use strict'; + +var fontAttrs = require('../../plots/font_attributes'); +var colorAttrs = require('../color/attributes'); + + +module.exports = { + // not really a 'subplot' attribute container, + // but this is the flag we use to denote attributes that + // support symbollegend, symbollegend2, symbollegend3, ... counters + _isSubplotObj: true, + + visible: { + valType: 'boolean', + dflt: true, + editType: 'legend', + description: 'Determines whether this symbol legend is visible.' + }, + + title: { + text: { + valType: 'string', + dflt: '', + editType: 'legend', + description: 'Sets the title of the symbol legend.' + }, + font: fontAttrs({ + editType: 'legend', + description: 'Sets the font for the symbol legend title.' + }), + side: { + valType: 'enumerated', + values: ['top', 'left', 'right'], + dflt: 'top', + editType: 'legend', + description: 'Determines the location of the legend title.' + }, + editType: 'legend' + }, + + // Positioning (same pattern as legend) + x: { + valType: 'number', + dflt: 1.02, + editType: 'legend', + description: 'Sets the x position with respect to `xref`.' + }, + xref: { + valType: 'enumerated', + values: ['container', 'paper'], + dflt: 'paper', + editType: 'legend', + description: 'Sets the container `x` refers to.' + }, + xanchor: { + valType: 'enumerated', + values: ['auto', 'left', 'center', 'right'], + dflt: 'left', + editType: 'legend', + description: 'Sets the horizontal anchor.' + }, + y: { + valType: 'number', + dflt: 0, + editType: 'legend', + description: 'Sets the y position with respect to `yref`.' + }, + yref: { + valType: 'enumerated', + values: ['container', 'paper'], + dflt: 'paper', + editType: 'legend', + description: 'Sets the container `y` refers to.' + }, + yanchor: { + valType: 'enumerated', + values: ['auto', 'top', 'middle', 'bottom'], + dflt: 'bottom', + editType: 'legend', + description: 'Sets the vertical anchor.' + }, + + // Styling + bgcolor: { + valType: 'color', + editType: 'legend', + description: 'Sets the background color.' + }, + bordercolor: { + valType: 'color', + dflt: colorAttrs.defaultLine, + editType: 'legend', + description: 'Sets the border color.' + }, + borderwidth: { + valType: 'number', + min: 0, + dflt: 0, + editType: 'legend', + description: 'Sets the border width.' + }, + font: fontAttrs({ + editType: 'legend', + description: 'Sets the font for legend item text.' + }), + + // Orientation + orientation: { + valType: 'enumerated', + values: ['v', 'h'], + dflt: 'v', + editType: 'legend', + description: 'Sets the orientation of the legend items.' + }, + + // Symbol styling + symbolsize: { + valType: 'number', + min: 1, + dflt: 12, + editType: 'legend', + description: 'Size of symbols in the legend.' + }, + symbolcolor: { + valType: 'color', + dflt: '#444', + editType: 'legend', + description: 'Color to use for symbol display.' + }, + + // Click behavior + itemclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + dflt: 'toggle', + editType: 'legend', + description: 'Determines the behavior on legend item click.' + }, + itemdoubleclick: { + valType: 'enumerated', + values: ['toggle', 'toggleothers', false], + dflt: 'toggleothers', + editType: 'legend', + description: 'Determines the behavior on legend item double-click.' + }, + + editType: 'legend' +}; diff --git a/src/components/symbollegend/constants.js b/src/components/symbollegend/constants.js new file mode 100644 index 00000000000..ac14cd54910 --- /dev/null +++ b/src/components/symbollegend/constants.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + itemGap: 5, + textGap: 8, + padding: 10 +}; diff --git a/src/components/symbollegend/defaults.js b/src/components/symbollegend/defaults.js new file mode 100644 index 00000000000..aedcc02da71 --- /dev/null +++ b/src/components/symbollegend/defaults.js @@ -0,0 +1,92 @@ +'use strict'; + +var Lib = require('../../lib'); +var Template = require('../../plot_api/plot_template'); +var attributes = require('./attributes'); + +module.exports = function symbollegendDefaults(layoutIn, layoutOut, fullData) { + var symbollegendIds = findSymbollegendIds(fullData); + + layoutOut._symbollegends = []; + + for(var i = 0; i < symbollegendIds.length; i++) { + var id = symbollegendIds[i]; + var containerIn = layoutIn[id] || {}; + var containerOut = Template.newContainer(layoutOut, id); + + handleSymbollegendDefaults(containerIn, containerOut, layoutOut, id); + + if(containerOut.visible) { + containerOut._id = id; + layoutOut._symbollegends.push(id); + } + } +}; + +function findSymbollegendIds(fullData) { + var ids = []; + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + if(!trace.visible) continue; + + var marker = trace.marker; + if(marker && marker.symbollegend) { + var id = marker.symbollegend; + if(ids.indexOf(id) === -1) { + ids.push(id); + } + } + } + + return ids; +} + +function handleSymbollegendDefaults(containerIn, containerOut, layoutOut, id) { + function coerce(attr, dflt) { + return Lib.coerce(containerIn, containerOut, attributes, attr, dflt); + } + + var visible = coerce('visible'); + if(!visible) return; + + // Title + coerce('title.text'); + Lib.coerceFont(coerce, 'title.font', layoutOut.font); + coerce('title.side'); + + // Orientation first - affects positioning defaults + var orientation = coerce('orientation'); + + // Positioning - defaults depend on orientation + var isHorizontal = orientation === 'h'; + if(isHorizontal) { + // Horizontal: bottom-right + coerce('x', 1.02); + coerce('xanchor', 'left'); + coerce('y', 0); + coerce('yanchor', 'bottom'); + } else { + // Vertical: right side of chart, bottom (default from attributes) + coerce('x'); + coerce('xanchor'); + coerce('y'); + coerce('yanchor'); + } + coerce('xref'); + coerce('yref'); + + // Styling + coerce('bgcolor', layoutOut.paper_bgcolor); + coerce('bordercolor'); + coerce('borderwidth'); + Lib.coerceFont(coerce, 'font', layoutOut.font); + + // Symbol-specific + coerce('symbolsize'); + coerce('symbolcolor'); + + // Behavior + coerce('itemclick'); + coerce('itemdoubleclick'); +} diff --git a/src/components/symbollegend/draw.js b/src/components/symbollegend/draw.js new file mode 100644 index 00000000000..1cd4d50f5c3 --- /dev/null +++ b/src/components/symbollegend/draw.js @@ -0,0 +1,398 @@ +'use strict'; + +var d3 = require('@plotly/d3'); + +var Lib = require('../../lib'); +var Registry = require('../../registry'); +var Plots = require('../../plots/plots'); +var Drawing = require('../drawing'); +var Color = require('../color'); + +var getSymbollegendData = require('./get_data'); +var constants = require('./constants'); + +var alignmentConstants = require('../../constants/alignment'); +var FROM_TL = alignmentConstants.FROM_TL; +var FROM_BR = alignmentConstants.FROM_BR; + +var SYMBOLLEGEND_PATTERN = /^symbollegend([2-9]|[1-9][0-9]+)?$/; + +// Add a dot in the middle of the symbol (for -dot variants) +var DOTPATH = 'M0,0.5L0.5,0L0,-0.5L-0.5,0Z'; + +module.exports = function draw(gd) { + var fullLayout = gd._fullLayout; + var symbollegendIds = fullLayout._symbollegends || []; + + // Remove old symbollegends that won't stay on the graph + var oldSymbollegends = fullLayout._infolayer.selectAll('[class^="symbollegend"]'); + oldSymbollegends.each(function() { + var el = d3.select(this); + var classes = el.attr('class'); + var cls = classes.split(' ')[0]; + if(cls.match(SYMBOLLEGEND_PATTERN) && symbollegendIds.indexOf(cls) === -1) { + el.remove(); + } + }); + + // Draw each symbollegend + for(var i = 0; i < symbollegendIds.length; i++) { + var id = symbollegendIds[i]; + var opts = fullLayout[id]; + if(opts && opts.visible) { + drawOne(gd, opts); + } + } +}; + +function drawOne(gd, opts) { + var fullLayout = gd._fullLayout; + var id = opts._id; + + // Get data + var legendData = getSymbollegendData(gd, id); + if(!legendData || !legendData.length) { + fullLayout._infolayer.selectAll('.' + id).remove(); + return; + } + + // Create/update main group + var legend = Lib.ensureSingle(fullLayout._infolayer, 'g', id); + legend.attr('class', id + ' symbollegend') + .style('overflow', 'visible'); + + // Background rect + var bg = Lib.ensureSingle(legend, 'rect', 'bg'); + bg.attr('shape-rendering', 'crispEdges'); + + // Items container + var itemsGroup = Lib.ensureSingle(legend, 'g', 'items'); + + // Bind data and create items + var items = itemsGroup.selectAll('g.symbollegend-item') + .data(legendData, function(d) { return String(d.value); }); + + // Exit old items + items.exit().remove(); + + // Enter new items + items.enter().append('g') + .attr('class', 'symbollegend-item') + .style('cursor', opts.itemclick !== false ? 'pointer' : 'default'); + + // Re-select all items (enter + update) + items = itemsGroup.selectAll('g.symbollegend-item'); + + // Layout configuration + var isVertical = opts.orientation === 'v'; + var symbolSize = opts.symbolsize; + var itemGap = constants.itemGap; + var textGap = constants.textGap; + var padding = constants.padding; + var borderWidth = opts.borderwidth; + + var maxTextWidth = 0; + var totalHeight = 0; + var totalWidth = 0; + + // Calculate item height based on symbol size + var itemHeight = symbolSize + 4; + + // Update items + items.each(function(d, i) { + var item = d3.select(this); + var symbolNumber = d.symbolNumber; + var base = symbolNumber % 100; + + // Symbol path - centered in the allocated space + var symbolPath = Lib.ensureSingle(item, 'path', 'symbol'); + var pathD = makeSymbolPath(base, symbolSize / 2); + + // Add dot if needed (symbolNumber >= 200) + if(symbolNumber >= 200 && symbolNumber < 400) { + pathD += DOTPATH; + } + + symbolPath + .attr('d', pathD) + .attr('transform', 'translate(' + (symbolSize / 2) + ',' + (itemHeight / 2) + ')'); + + // Determine fill and stroke based on symbol type + var isOpen = symbolNumber >= 100 && symbolNumber < 200; + var isOpenDot = symbolNumber >= 300; + + if(isOpen || isOpenDot || Drawing.symbolNoFill[base]) { + Color.fill(symbolPath, 'none'); + } else { + Color.fill(symbolPath, opts.symbolcolor); + } + + Color.stroke(symbolPath, opts.symbolcolor); + symbolPath.style('stroke-width', '1px'); + + // Label text + var label = Lib.ensureSingle(item, 'text', 'label'); + label + .attr('x', symbolSize + textGap) + .attr('y', itemHeight / 2) + .attr('dy', '0.35em') + .text(d.label); + + Drawing.font(label, opts.font); + + // Measure text width + var textBBox = label.node().getBBox(); + maxTextWidth = Math.max(maxTextWidth, textBBox.width); + + // Position item + if(isVertical) { + Drawing.setTranslate(item, 0, i * (itemHeight + itemGap)); + } else { + // Horizontal layout - will be repositioned after measuring + item.attr('data-index', i); + } + + // Store data for click handling + item.datum(d); + }); + + // Calculate dimensions + maxTextWidth = Math.max(maxTextWidth, 20); + var totalItemWidth = symbolSize + textGap + maxTextWidth; + + // Second pass: add click target rects now that we know dimensions + var clickTargetWidth = totalItemWidth; + items.each(function(d) { + var item = d3.select(this); + + // Transparent click target rect - must cover entire item area + var clickTarget = Lib.ensureSingle(item, 'rect', 'clicktarget'); + clickTarget + .attr('width', clickTargetWidth) + .attr('height', itemHeight) + .attr('x', 0) + .attr('y', 0) + .style('fill', 'transparent') + .style('cursor', 'pointer') + .attr('pointer-events', 'all'); + }); + + if(isVertical) { + totalWidth = totalItemWidth + padding * 2; + totalHeight = legendData.length * (itemHeight + itemGap) - itemGap + padding * 2; + } else { + // Horizontal: reposition items + var offsetX = 0; + items.each(function() { + var item = d3.select(this); + Drawing.setTranslate(item, offsetX, 0); + offsetX += totalItemWidth + itemGap * 2; + }); + totalWidth = offsetX - itemGap * 2 + padding * 2; + totalHeight = itemHeight + padding * 2; + } + + // Handle title + var titleHeight = 0; + if(opts.title && opts.title.text) { + var title = Lib.ensureSingle(legend, 'text', 'legendtitle'); + title + .attr('class', 'legendtitle') + .attr('x', padding) + .attr('y', padding) + .attr('dy', '1em') + .text(opts.title.text); + + var titleFont = opts.title.font || opts.font; + Drawing.font(title, titleFont); + + var titleBBox = title.node().getBBox(); + titleHeight = titleBBox.height + itemGap; + totalHeight += titleHeight; + + // Ensure legend is wide enough for title + totalWidth = Math.max(totalWidth, titleBBox.width + padding * 2); + + Drawing.setTranslate(itemsGroup, padding, padding + titleHeight); + } else { + legend.selectAll('.legendtitle').remove(); + Drawing.setTranslate(itemsGroup, padding, padding); + } + + // Set background size and style + bg.attr('width', totalWidth) + .attr('height', totalHeight); + + Color.fill(bg, opts.bgcolor); + Color.stroke(bg, opts.bordercolor); + bg.style('stroke-width', borderWidth + 'px'); + + // Store dimensions for positioning + opts._width = totalWidth; + opts._height = totalHeight; + + // Position legend and reserve margin space + positionLegend(gd, legend, opts, totalWidth, totalHeight); + computeAutoMargin(gd, opts); + + // Setup click handlers + setupClickHandlers(gd, items, opts); +} + +/** + * Create symbol path using Drawing.symbolFuncs + */ +function makeSymbolPath(base, radius) { + if(Drawing.symbolFuncs[base]) { + return Drawing.symbolFuncs[base](radius); + } + // Default to circle + return Drawing.symbolFuncs[0](radius); +} + +function computeAutoMargin(gd, opts) { + var id = opts._id; + var xanchor = opts.xanchor === 'auto' ? + (opts.x < 0.5 ? 'left' : 'right') : opts.xanchor; + var yanchor = opts.yanchor === 'auto' ? + (opts.y < 0.5 ? 'bottom' : 'top') : opts.yanchor; + + // Only auto-margin when using paper reference + if(opts.xref === 'paper' && opts.yref === 'paper') { + Plots.autoMargin(gd, id, { + x: opts.x, + y: opts.y, + l: opts._width * FROM_TL[xanchor], + r: opts._width * FROM_BR[xanchor], + b: opts._height * FROM_BR[yanchor], + t: opts._height * FROM_TL[yanchor] + }); + } +} + +function positionLegend(gd, legend, opts, width, height) { + var fullLayout = gd._fullLayout; + var gs = fullLayout._size; + + var isPaperX = opts.xref === 'paper'; + var isPaperY = opts.yref === 'paper'; + + // Calculate anchor multipliers + var anchorX = getXAnchorFraction(opts); + var anchorY = getYAnchorFraction(opts); + + var lx, ly; + + if(isPaperX) { + lx = gs.l + gs.w * opts.x - anchorX * width; + } else { + lx = fullLayout.width * opts.x - anchorX * width; + } + + if(isPaperY) { + ly = gs.t + gs.h * (1 - opts.y) - anchorY * height; + } else { + ly = fullLayout.height * (1 - opts.y) - anchorY * height; + } + + Drawing.setTranslate(legend, Math.round(lx), Math.round(ly)); +} + +function getXAnchorFraction(opts) { + var xanchor = opts.xanchor; + if(xanchor === 'auto') { + // Auto anchor based on x position + if(opts.x <= 0.33) return 0; // left + if(opts.x >= 0.67) return 1; // right + return 0.5; // center + } + return {left: 0, center: 0.5, right: 1}[xanchor] || 0; +} + +function getYAnchorFraction(opts) { + var yanchor = opts.yanchor; + if(yanchor === 'auto') { + // Auto anchor based on y position + if(opts.y <= 0.33) return 1; // bottom + if(opts.y >= 0.67) return 0; // top + return 0.5; // middle + } + return {top: 0, middle: 0.5, bottom: 1}[yanchor] || 0; +} + +function setupClickHandlers(gd, items, opts) { + if(opts.itemclick === false && opts.itemdoubleclick === false) { + items.style('cursor', 'default'); + items.on('mousedown', null); + items.on('mouseup', null); + items.on('click', null); + return; + } + + var doubleClickDelay = gd._context.doubleClickDelay || 300; + var lastClickTime = 0; + var clickCount = 0; + + items.on('mousedown', function() { + var now = Date.now(); + if(now - lastClickTime < doubleClickDelay) { + clickCount++; + } else { + clickCount = 1; + } + lastClickTime = now; + }); + + items.on('mouseup', function() { + var d = d3.select(this).datum(); + var now = Date.now(); + if(now - lastClickTime > doubleClickDelay) { + clickCount = Math.max(clickCount - 1, 1); + } + + handleClick(gd, d, clickCount, opts); + }); +} + +function handleClick(gd, itemData, numClicks, opts) { + var action = numClicks === 2 ? opts.itemdoubleclick : opts.itemclick; + if(!action) return; + + var fullLayout = gd._fullLayout; + var hiddenSymbols = fullLayout._hiddenSymbollegendValues || {}; + var id = opts._id; + + if(!hiddenSymbols[id]) { + hiddenSymbols[id] = []; + } + + var valueKey = String(itemData.value); + var isHidden = hiddenSymbols[id].indexOf(valueKey) !== -1; + + if(action === 'toggle') { + if(isHidden) { + // Show this value + hiddenSymbols[id] = hiddenSymbols[id].filter(function(v) { return v !== valueKey; }); + } else { + // Hide this value + hiddenSymbols[id].push(valueKey); + } + } else if(action === 'toggleothers') { + // Get all values for this legend + var legendData = getSymbollegendData(gd, id); + var allValues = legendData.map(function(d) { return String(d.value); }); + + if(isHidden && hiddenSymbols[id].length === allValues.length - 1) { + // Only this one showing, show all + hiddenSymbols[id] = []; + } else { + // Hide all except this one + hiddenSymbols[id] = allValues.filter(function(v) { return v !== valueKey; }); + } + } + + fullLayout._hiddenSymbollegendValues = hiddenSymbols; + + // Trigger redraw via Registry + Registry.call('_guiRelayout', gd, {}); +} diff --git a/src/components/symbollegend/get_data.js b/src/components/symbollegend/get_data.js new file mode 100644 index 00000000000..23cb27ae9b0 --- /dev/null +++ b/src/components/symbollegend/get_data.js @@ -0,0 +1,90 @@ +'use strict'; + +var Drawing = require('../drawing'); + +/** + * Collect unique symbol values from all traces referencing this symbollegend + * + * @param {object} gd - graph div + * @param {string} symbollegendId - e.g., 'symbollegend', 'symbollegend2' + * @returns {Array} - array of {value, symbolNumber, label, traces, points} + */ +module.exports = function getSymbollegendData(gd, symbollegendId) { + var fullData = gd._fullData; + + var valueMap = {}; // value -> {symbolNumber, label, traces, points} + var values = []; // ordered list of unique values + + for(var i = 0; i < fullData.length; i++) { + var trace = fullData[i]; + if(!trace.visible || trace.visible === 'legendonly') continue; + + var marker = trace.marker; + if(!marker || marker.symbollegend !== symbollegendId) continue; + + var symbols = marker.symbol; + if(!Array.isArray(symbols)) { + // Single symbol value - treat as one item + symbols = [symbols]; + } + + for(var j = 0; j < symbols.length; j++) { + var symbolVal = symbols[j]; + var key = String(symbolVal); + + if(!valueMap[key]) { + var symbolNumber = Drawing.symbolNumber(symbolVal); + var label = getSymbolLabel(symbolVal, symbolNumber); + + valueMap[key] = { + value: symbolVal, + symbolNumber: symbolNumber, + label: label, + traces: [], + points: [] + }; + values.push(key); + } + + if(valueMap[key].traces.indexOf(i) === -1) { + valueMap[key].traces.push(i); + } + valueMap[key].points.push({trace: i, i: j}); + } + } + + // Return discrete values + return values.map(function(key) { + return valueMap[key]; + }); +}; + +/** + * Get a human-readable label for a symbol + * @param {string|number} symbolVal - the symbol value (name or number) + * @param {number} symbolNumber - the resolved symbol number + * @returns {string} - human-readable label + */ +function getSymbolLabel(symbolVal, symbolNumber) { + // If it's already a string name, use it + if(typeof symbolVal === 'string') { + return symbolVal; + } + + // If it's a number, try to get the name from symbolList + var symbolList = Drawing.symbolList; + if(symbolList && symbolNumber < symbolList.length) { + // symbolList entries are [name, number, ...] + // We need to find the entry with this number + for(var i = 0; i < symbolList.length; i++) { + if(symbolList[i] === symbolVal || Drawing.symbolNumber(symbolList[i]) === symbolNumber) { + if(typeof symbolList[i] === 'string') { + return symbolList[i]; + } + } + } + } + + // Fallback to the value itself + return String(symbolVal); +} diff --git a/src/components/symbollegend/index.js b/src/components/symbollegend/index.js new file mode 100644 index 00000000000..539a24e30e5 --- /dev/null +++ b/src/components/symbollegend/index.js @@ -0,0 +1,12 @@ +'use strict'; + + +module.exports = { + moduleType: 'component', + name: 'symbollegend', + + layoutAttributes: require('./attributes'), + supplyLayoutDefaults: require('./defaults'), + + draw: require('./draw') +}; diff --git a/src/core.js b/src/core.js index 99d86862ef6..a1d26e61ada 100644 --- a/src/core.js +++ b/src/core.js @@ -44,6 +44,9 @@ register([ require('./components/errorbars'), require('./components/colorscale'), require('./components/colorbar'), + require('./components/colorlegend'), + require('./components/sizelegend'), + require('./components/symbollegend'), require('./components/legend'), // legend needs to come after shape | legend defaults depends on shapes require('./components/fx'), // fx needs to come after legend | unified hover defaults depends on legends require('./components/modebar') diff --git a/src/lib/coerce.js b/src/lib/coerce.js index 9fa59255bc1..dd9f0efa644 100644 --- a/src/lib/coerce.js +++ b/src/lib/coerce.js @@ -236,10 +236,20 @@ exports.valObjectMeta = { }, validateFunction: function(v, opts) { var dflt = opts.dflt; + var regex = opts.regex; + + // Handle regex being a string (from schema JSON) vs RegExp object + if(typeof regex === 'string') { + // Extract pattern from string like "/^colorlegend([2-9]|...)$/" + var match = regex.match(/^\/(.*)\/$/); + regex = match ? new RegExp(match[1]) : counterRegex(dflt); + } else if(!regex) { + regex = counterRegex(dflt); + } if(v === dflt) return true; if(typeof v !== 'string') return false; - if(counterRegex(dflt).test(v)) return true; + if(regex.test(v)) return true; return false; } diff --git a/src/plot_api/subroutines.js b/src/plot_api/subroutines.js index 7cb735ce338..f811a926393 100644 --- a/src/plot_api/subroutines.js +++ b/src/plot_api/subroutines.js @@ -668,6 +668,9 @@ exports.doTraceStyle = function(gd) { exports.doColorBars = function(gd) { Registry.getComponentMethod('colorbar', 'draw')(gd); + Registry.getComponentMethod('colorlegend', 'draw')(gd); + Registry.getComponentMethod('sizelegend', 'draw')(gd); + Registry.getComponentMethod('symbollegend', 'draw')(gd); return Plots.previousPromises(gd); }; @@ -859,4 +862,7 @@ exports.drawMarginPushers = function(gd) { Registry.getComponentMethod('sliders', 'draw')(gd); Registry.getComponentMethod('updatemenus', 'draw')(gd); Registry.getComponentMethod('colorbar', 'draw')(gd); + Registry.getComponentMethod('colorlegend', 'draw')(gd); + Registry.getComponentMethod('sizelegend', 'draw')(gd); + Registry.getComponentMethod('symbollegend', 'draw')(gd); }; diff --git a/src/plots/plots.js b/src/plots/plots.js index 6550eb353a8..56068508a9a 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -53,6 +53,9 @@ plots.redrawText = function(gd) { Registry.getComponentMethod('annotations', 'draw')(gd); Registry.getComponentMethod('legend', 'draw')(gd); Registry.getComponentMethod('colorbar', 'draw')(gd); + Registry.getComponentMethod('colorlegend', 'draw')(gd); + Registry.getComponentMethod('sizelegend', 'draw')(gd); + Registry.getComponentMethod('symbollegend', 'draw')(gd); resolve(plots.previousPromises(gd)); }, 300); }); diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 18231a889b7..2a3b4c31a1d 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -11,6 +11,7 @@ var Drawing = require('../../components/drawing'); var constants = require('./constants'); var extendFlat = require('../../lib/extend').extendFlat; +var counterRegex = require('../../lib/regex').counter; var makeFillcolorAttr = require('./fillcolor_attribute'); @@ -580,6 +581,45 @@ module.exports = { }, editType: 'calc' }, + colorlegend: { + valType: 'subplotid', + regex: counterRegex('colorlegend'), + dflt: null, + editType: 'calc', + description: [ + 'Sets a reference to a shared color legend.', + 'Traces referencing the same color legend will contribute', + 'their unique color values to that legend.', + 'Set to *colorlegend* to use `layout.colorlegend`,', + 'or *colorlegend2*, *colorlegend3*, etc. for additional legends.' + ].join(' ') + }, + sizelegend: { + valType: 'subplotid', + regex: counterRegex('sizelegend'), + dflt: null, + editType: 'calc', + description: [ + 'Sets a reference to a shared size legend.', + 'Traces referencing the same size legend will contribute', + 'their size values to that legend.', + 'Set to *sizelegend* to use `layout.sizelegend`,', + 'or *sizelegend2*, *sizelegend3*, etc. for additional legends.' + ].join(' ') + }, + symbollegend: { + valType: 'subplotid', + regex: counterRegex('symbollegend'), + dflt: null, + editType: 'calc', + description: [ + 'Sets a reference to a shared symbol legend.', + 'Traces referencing the same symbol legend will contribute', + 'their unique symbol values to that legend.', + 'Set to *symbollegend* to use `layout.symbollegend`,', + 'or *symbollegend2*, *symbollegend3*, etc. for additional legends.' + ].join(' ') + }, editType: 'calc' }, colorScaleAttrs('marker', { anim: true }) diff --git a/src/traces/scatter/marker_defaults.js b/src/traces/scatter/marker_defaults.js index c4358730534..01276b77539 100644 --- a/src/traces/scatter/marker_defaults.js +++ b/src/traces/scatter/marker_defaults.js @@ -41,6 +41,13 @@ module.exports = function markerDefaults(traceIn, traceOut, defaultColor, layout colorscaleDefaults(traceIn, traceOut, layout, coerce, {prefix: 'marker.', cLetter: 'c'}); } + // Attribute legend support (only for trace types with these attributes defined) + if(!opts.noAttrLegends) { + coerce('marker.colorlegend'); + coerce('marker.sizelegend'); + coerce('marker.symbollegend'); + } + if(!opts.noSelect) { coerce('selected.marker.color'); coerce('unselected.marker.color'); diff --git a/src/traces/scatter3d/defaults.js b/src/traces/scatter3d/defaults.js index e99182fef69..c1567bd198c 100644 --- a/src/traces/scatter3d/defaults.js +++ b/src/traces/scatter3d/defaults.js @@ -32,7 +32,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('mode'); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noSelect: true, noAngle: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noSelect: true, noAngle: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/scattercarpet/defaults.js b/src/traces/scattercarpet/defaults.js index 2b710791df7..1a871c3b68a 100644 --- a/src/traces/scattercarpet/defaults.js +++ b/src/traces/scattercarpet/defaults.js @@ -43,7 +43,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('mode', defaultMode); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/scattergeo/defaults.js b/src/traces/scattergeo/defaults.js index 88e20a17ff7..b27d2b19442 100644 --- a/src/traces/scattergeo/defaults.js +++ b/src/traces/scattergeo/defaults.js @@ -62,7 +62,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('mode'); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/scattergl/defaults.js b/src/traces/scattergl/defaults.js index efaecbb5e63..16c5652e1e3 100644 --- a/src/traces/scattergl/defaults.js +++ b/src/traces/scattergl/defaults.js @@ -41,7 +41,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('mode', defaultMode); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noAngleRef: true, noStandOff: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noAngleRef: true, noStandOff: true, noAttrLegends: true }); coerce('marker.line.width', isOpen || isBubble ? 1 : 0); } diff --git a/src/traces/scattermap/defaults.js b/src/traces/scattermap/defaults.js index faeb083ac08..2374978e84a 100644 --- a/src/traces/scattermap/defaults.js +++ b/src/traces/scattermap/defaults.js @@ -35,7 +35,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('below'); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noLine: true, noAngle: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noLine: true, noAngle: true, noAttrLegends: true }); coerce('marker.allowoverlap'); coerce('marker.angle'); diff --git a/src/traces/scattermapbox/defaults.js b/src/traces/scattermapbox/defaults.js index faeb083ac08..2374978e84a 100644 --- a/src/traces/scattermapbox/defaults.js +++ b/src/traces/scattermapbox/defaults.js @@ -35,7 +35,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('below'); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noLine: true, noAngle: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noLine: true, noAngle: true, noAttrLegends: true }); coerce('marker.allowoverlap'); coerce('marker.angle'); diff --git a/src/traces/scatterpolar/defaults.js b/src/traces/scatterpolar/defaults.js index a3260367ce7..3f0cd55ccee 100644 --- a/src/traces/scatterpolar/defaults.js +++ b/src/traces/scatterpolar/defaults.js @@ -33,7 +33,7 @@ function supplyDefaults(traceIn, traceOut, defaultColor, layout) { } if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/scatterpolargl/defaults.js b/src/traces/scatterpolargl/defaults.js index 7f095c4ea71..1c5c5ab9040 100644 --- a/src/traces/scatterpolargl/defaults.js +++ b/src/traces/scatterpolargl/defaults.js @@ -33,7 +33,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout } if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noAngleRef: true, noStandOff: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noAngleRef: true, noStandOff: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/scattersmith/defaults.js b/src/traces/scattersmith/defaults.js index 7765e0212e6..201c0e8d379 100644 --- a/src/traces/scattersmith/defaults.js +++ b/src/traces/scattersmith/defaults.js @@ -32,7 +32,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout } if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/scatterternary/defaults.js b/src/traces/scatterternary/defaults.js index 6be1e2d0363..84011af5eb2 100644 --- a/src/traces/scatterternary/defaults.js +++ b/src/traces/scatterternary/defaults.js @@ -58,7 +58,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('mode', defaultMode); if (subTypes.hasMarkers(traceOut)) { - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { gradient: true, noAttrLegends: true }); } if (subTypes.hasLines(traceOut)) { diff --git a/src/traces/splom/defaults.js b/src/traces/splom/defaults.js index d3c4154b9ba..ffc8db179cd 100644 --- a/src/traces/splom/defaults.js +++ b/src/traces/splom/defaults.js @@ -37,7 +37,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('xhoverformat'); coerce('yhoverformat'); - handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noAngleRef: true, noStandOff: true }); + handleMarkerDefaults(traceIn, traceOut, defaultColor, layout, coerce, { noAngleRef: true, noStandOff: true, noAttrLegends: true }); var isOpen = isOpenSymbol(traceOut.marker.symbol); var isBubble = subTypes.isBubble(traceOut); diff --git a/test/image/baselines/colorlegend_basic.png b/test/image/baselines/colorlegend_basic.png new file mode 100644 index 00000000000..2e201ea7d6f Binary files /dev/null and b/test/image/baselines/colorlegend_basic.png differ diff --git a/test/image/baselines/sizelegend_basic.png b/test/image/baselines/sizelegend_basic.png new file mode 100644 index 00000000000..bd6d1ee8417 Binary files /dev/null and b/test/image/baselines/sizelegend_basic.png differ diff --git a/test/image/baselines/symbollegend_basic.png b/test/image/baselines/symbollegend_basic.png new file mode 100644 index 00000000000..05780293ce9 Binary files /dev/null and b/test/image/baselines/symbollegend_basic.png differ diff --git a/test/image/mocks/colorlegend_basic.json b/test/image/mocks/colorlegend_basic.json new file mode 100644 index 00000000000..197e6acd234 --- /dev/null +++ b/test/image/mocks/colorlegend_basic.json @@ -0,0 +1,28 @@ +{ + "data": [{ + "type": "scatter", + "mode": "markers", + "x": [1, 2, 3, 4, 5, 6, 7, 8], + "y": [2, 4, 3, 5, 4, 6, 5, 7], + "marker": { + "color": ["red", "blue", "red", "green", "blue", "red", "green", "blue"], + "colorlegend": "colorlegend", + "size": 15 + }, + "name": "Sample Data" + }], + "layout": { + "title": { + "text": "Color Legend Example" + }, + "colorlegend": { + "title": { "text": "Category" }, + "x": 1.02, + "y": 1, + "visible": true + }, + "width": 600, + "height": 400, + "showlegend": false + } +} diff --git a/test/image/mocks/sizelegend_basic.json b/test/image/mocks/sizelegend_basic.json new file mode 100644 index 00000000000..e2d0ce7bf15 --- /dev/null +++ b/test/image/mocks/sizelegend_basic.json @@ -0,0 +1,28 @@ +{ + "data": [{ + "type": "scatter", + "mode": "markers", + "x": [1, 2, 3, 4, 5, 6, 7, 8], + "y": [2, 4, 3, 5, 4, 6, 5, 7], + "marker": { + "size": [10, 20, 15, 30, 25, 35, 40, 50], + "sizelegend": "sizelegend", + "color": "#1f77b4" + }, + "name": "Sample Data" + }], + "layout": { + "title": { + "text": "Size Legend Example" + }, + "sizelegend": { + "title": { "text": "Weight (kg)" }, + "x": 1.02, + "y": 0.5, + "visible": true + }, + "width": 600, + "height": 400, + "showlegend": false + } +} diff --git a/test/image/mocks/symbollegend_basic.json b/test/image/mocks/symbollegend_basic.json new file mode 100644 index 00000000000..0af300afb7f --- /dev/null +++ b/test/image/mocks/symbollegend_basic.json @@ -0,0 +1,29 @@ +{ + "data": [{ + "type": "scatter", + "mode": "markers", + "x": [1, 2, 3, 4, 5, 6, 7, 8], + "y": [2, 4, 3, 5, 4, 6, 5, 7], + "marker": { + "symbol": ["circle", "square", "diamond", "circle", "square", "diamond", "circle", "square"], + "symbollegend": "symbollegend", + "size": 12, + "color": "#1f77b4" + }, + "name": "Sample Data" + }], + "layout": { + "title": { + "text": "Symbol Legend Example" + }, + "symbollegend": { + "title": { "text": "Region" }, + "x": 1.02, + "y": 0, + "visible": true + }, + "width": 600, + "height": 400, + "showlegend": false + } +} diff --git a/test/jasmine/bundle_tests/plotschema_test.js b/test/jasmine/bundle_tests/plotschema_test.js index 7a89f628bce..31c75f88a21 100644 --- a/test/jasmine/bundle_tests/plotschema_test.js +++ b/test/jasmine/bundle_tests/plotschema_test.js @@ -130,7 +130,10 @@ describe('plot schema', function() { // ... counters, so list it here 'xaxis.rangeslider.yaxis', 'legend', - 'coloraxis' + 'coloraxis', + 'colorlegend', + 'sizelegend', + 'symbollegend' ]; // check if the subplot objects have '_isSubplotObj' diff --git a/test/jasmine/tests/colorlegend_test.js b/test/jasmine/tests/colorlegend_test.js new file mode 100644 index 00000000000..30ade32a0d7 --- /dev/null +++ b/test/jasmine/tests/colorlegend_test.js @@ -0,0 +1,296 @@ +'use strict'; + +var Plotly = require('../../../lib/index'); +var Lib = require('../../../src/lib'); +var Plots = require('../../../src/plots/plots'); + +var d3Select = require('../../strict-d3').select; +var d3SelectAll = require('../../strict-d3').selectAll; +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var supplyAllDefaults = require('../assets/supply_defaults'); + +describe('Color legend', function() { + 'use strict'; + + describe('supplyDefaults', function() { + function _supply(traces, layout) { + var gd = { + data: traces, + layout: layout || {} + }; + supplyAllDefaults(gd); + return gd; + } + + it('should create colorlegend when trace references it', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'a'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { visible: true } + }); + + expect(gd._fullData[0].marker.colorlegend).toBe('colorlegend'); + expect(gd._fullLayout.colorlegend).toBeDefined(); + expect(gd._fullLayout.colorlegend.visible).toBe(true); + }); + + it('should support multiple colorlegends', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'a'], + colorlegend: 'colorlegend' + } + }, { + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + marker: { + color: ['x', 'y', 'z'], + colorlegend: 'colorlegend2' + } + }], { + colorlegend: { title: { text: 'Legend 1' } }, + colorlegend2: { title: { text: 'Legend 2' } } + }); + + expect(gd._fullLayout.colorlegend).toBeDefined(); + expect(gd._fullLayout.colorlegend2).toBeDefined(); + expect(gd._fullLayout._colorlegends).toContain('colorlegend'); + expect(gd._fullLayout._colorlegends).toContain('colorlegend2'); + }); + + it('should apply default values', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'a'], + colorlegend: 'colorlegend' + } + }]); + + var colorlegend = gd._fullLayout.colorlegend; + expect(colorlegend.visible).toBe(true); + expect(colorlegend.x).toBe(1.02); + expect(colorlegend.y).toBe(1); + expect(colorlegend.xanchor).toBe('left'); + expect(colorlegend.yanchor).toBe('auto'); + expect(colorlegend.orientation).toBe('v'); + expect(colorlegend.itemwidth).toBe(30); + expect(colorlegend.itemclick).toBe('toggle'); + expect(colorlegend.itemdoubleclick).toBe('toggleothers'); + expect(colorlegend.binning).toBe('auto'); + expect(colorlegend.nbins).toBe(5); + }); + + it('should not create colorlegend when no traces reference it', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { color: 'red' } + }], { + colorlegend: { visible: true } + }); + + expect(gd._fullLayout._colorlegends).toEqual([]); + }); + }); + + describe('rendering', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should render colorlegend with unique values', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + marker: { + color: ['red', 'blue', 'red', 'green'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { visible: true } + }) + .then(function() { + var legendGroup = d3Select('.colorlegend'); + expect(legendGroup.size()).toBe(1); + + var items = d3SelectAll('.colorlegend-item'); + expect(items.size()).toBe(3); // red, blue, green + }) + .then(done, done.fail); + }); + + it('should render title when specified', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'c'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { + visible: true, + title: { text: 'My Colors' } + } + }) + .then(function() { + var title = d3Select('.colorlegend .legendtitle'); + expect(title.size()).toBe(1); + expect(title.text()).toBe('My Colors'); + }) + .then(done, done.fail); + }); + + it('should handle horizontal orientation', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'c'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { + visible: true, + orientation: 'h' + } + }) + .then(function() { + var items = d3SelectAll('.colorlegend-item'); + expect(items.size()).toBe(3); + + // Check that items are horizontally positioned + // Items should exist and be laid out - we verify count is correct + // Horizontal layout is verified by the legend being wider than tall + var bg = d3Select('.colorlegend .bg'); + var width = parseFloat(bg.attr('width')); + var height = parseFloat(bg.attr('height')); + expect(width).toBeGreaterThan(height); + }) + .then(done, done.fail); + }); + + it('should hide legend when visible is false', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'c'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { visible: false } + }) + .then(function() { + var legendGroup = d3Select('.colorlegend'); + expect(legendGroup.size()).toBe(0); + }) + .then(done, done.fail); + }); + + it('should update on relayout', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'c'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { visible: true } + }) + .then(function() { + return Plotly.relayout(gd, 'colorlegend.x', 0.5); + }) + .then(function() { + // Legend should still exist after relayout + var legend = d3Select('.colorlegend'); + expect(legend.size()).toBe(1); + }) + .then(done, done.fail); + }); + + it('should handle multiple traces referencing same colorlegend', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2], + y: [1, 2], + marker: { + color: ['a', 'b'], + colorlegend: 'colorlegend' + } + }, { + type: 'scatter', + mode: 'markers', + x: [3, 4], + y: [3, 4], + marker: { + color: ['b', 'c'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { visible: true } + }) + .then(function() { + var items = d3SelectAll('.colorlegend-item'); + expect(items.size()).toBe(3); // a, b, c (combined from both traces) + }) + .then(done, done.fail); + }); + + it('should apply border styling', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + color: ['a', 'b', 'c'], + colorlegend: 'colorlegend' + } + }], { + colorlegend: { + visible: true, + borderwidth: 2, + bordercolor: 'red' + } + }) + .then(function() { + var bg = d3Select('.colorlegend .bg'); + expect(bg.node().style.strokeWidth).toBe('2px'); + }) + .then(done, done.fail); + }); + }); + +}); diff --git a/test/jasmine/tests/sizelegend_test.js b/test/jasmine/tests/sizelegend_test.js new file mode 100644 index 00000000000..1bc6300c39b --- /dev/null +++ b/test/jasmine/tests/sizelegend_test.js @@ -0,0 +1,309 @@ +'use strict'; + +var Plotly = require('../../../lib/index'); +var Lib = require('../../../src/lib'); +var Plots = require('../../../src/plots/plots'); + +var d3Select = require('../../strict-d3').select; +var d3SelectAll = require('../../strict-d3').selectAll; +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var supplyAllDefaults = require('../assets/supply_defaults'); + +describe('Size legend', function() { + 'use strict'; + + describe('supplyDefaults', function() { + function _supply(traces, layout) { + var gd = { + data: traces, + layout: layout || {} + }; + supplyAllDefaults(gd); + return gd; + } + + it('should create sizelegend when trace references it', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { visible: true } + }); + + expect(gd._fullData[0].marker.sizelegend).toBe('sizelegend'); + expect(gd._fullLayout.sizelegend).toBeDefined(); + expect(gd._fullLayout.sizelegend.visible).toBe(true); + }); + + it('should support multiple sizelegends', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }, { + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + marker: { + size: [5, 15, 25], + sizelegend: 'sizelegend2' + } + }], { + sizelegend: { title: { text: 'Legend 1' } }, + sizelegend2: { title: { text: 'Legend 2' } } + }); + + expect(gd._fullLayout.sizelegend).toBeDefined(); + expect(gd._fullLayout.sizelegend2).toBeDefined(); + expect(gd._fullLayout._sizelegends).toContain('sizelegend'); + expect(gd._fullLayout._sizelegends).toContain('sizelegend2'); + }); + + it('should apply default values', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }]); + + var sizelegend = gd._fullLayout.sizelegend; + expect(sizelegend.visible).toBe(true); + expect(sizelegend.x).toBe(1.02); + expect(sizelegend.y).toBe(0.5); + expect(sizelegend.xanchor).toBe('left'); + expect(sizelegend.yanchor).toBe('middle'); + expect(sizelegend.orientation).toBe('v'); + expect(sizelegend.nsamples).toBe(4); + expect(sizelegend.symbolcolor).toBe('#444'); + expect(sizelegend.symboloutlinecolor).toBe('#444'); + expect(sizelegend.symboloutlinewidth).toBe(1); + expect(sizelegend.itemclick).toBe('toggle'); + expect(sizelegend.itemdoubleclick).toBe('toggleothers'); + }); + + it('should not create sizelegend when no traces reference it', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { size: 10 } + }], { + sizelegend: { visible: true } + }); + + expect(gd._fullLayout._sizelegends).toEqual([]); + }); + }); + + describe('rendering', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should render sizelegend with size samples', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3, 4, 5], + y: [1, 2, 3, 4, 5], + marker: { + size: [10, 20, 30, 40, 50], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { visible: true } + }) + .then(function() { + var legendGroup = d3Select('.sizelegend'); + expect(legendGroup.size()).toBe(1); + + var items = d3SelectAll('.sizelegend-item'); + expect(items.size()).toBe(4); // default nsamples + }) + .then(done, done.fail); + }); + + it('should render circles for size symbols', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { visible: true } + }) + .then(function() { + var circles = d3SelectAll('.sizelegend-item circle.symbol'); + expect(circles.size()).toBeGreaterThan(0); + }) + .then(done, done.fail); + }); + + it('should render title when specified', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { + visible: true, + title: { text: 'Size Values' } + } + }) + .then(function() { + var title = d3Select('.sizelegend .legendtitle'); + expect(title.size()).toBe(1); + expect(title.text()).toBe('Size Values'); + }) + .then(done, done.fail); + }); + + it('should respect nsamples setting', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + y: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + marker: { + size: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { + visible: true, + nsamples: 6 + } + }) + .then(function() { + var items = d3SelectAll('.sizelegend-item'); + expect(items.size()).toBe(6); + }) + .then(done, done.fail); + }); + + it('should hide legend when visible is false', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { visible: false } + }) + .then(function() { + var legendGroup = d3Select('.sizelegend'); + expect(legendGroup.size()).toBe(0); + }) + .then(done, done.fail); + }); + + it('should update on relayout', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { visible: true } + }) + .then(function() { + return Plotly.relayout(gd, 'sizelegend.x', 0.5); + }) + .then(function() { + // Legend should still exist after relayout + var legend = d3Select('.sizelegend'); + expect(legend.size()).toBe(1); + }) + .then(done, done.fail); + }); + + it('should apply symbol colors', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + size: [10, 20, 30], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { + visible: true, + symbolcolor: 'blue', + symboloutlinecolor: 'red', + symboloutlinewidth: 2 + } + }) + .then(function() { + var circles = d3SelectAll('.sizelegend-item circle.symbol'); + expect(circles.size()).toBeGreaterThan(0); + }) + .then(done, done.fail); + }); + + it('should handle multiple traces referencing same sizelegend', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2], + y: [1, 2], + marker: { + size: [10, 20], + sizelegend: 'sizelegend' + } + }, { + type: 'scatter', + mode: 'markers', + x: [3, 4], + y: [3, 4], + marker: { + size: [30, 40], + sizelegend: 'sizelegend' + } + }], { + sizelegend: { visible: true } + }) + .then(function() { + var items = d3SelectAll('.sizelegend-item'); + expect(items.size()).toBe(4); // combined range sampled + }) + .then(done, done.fail); + }); + }); +}); diff --git a/test/jasmine/tests/symbollegend_test.js b/test/jasmine/tests/symbollegend_test.js new file mode 100644 index 00000000000..7c9f9989486 --- /dev/null +++ b/test/jasmine/tests/symbollegend_test.js @@ -0,0 +1,322 @@ +'use strict'; + +var Plotly = require('../../../lib/index'); +var Lib = require('../../../src/lib'); +var Plots = require('../../../src/plots/plots'); + +var d3Select = require('../../strict-d3').select; +var d3SelectAll = require('../../strict-d3').selectAll; +var createGraphDiv = require('../assets/create_graph_div'); +var destroyGraphDiv = require('../assets/destroy_graph_div'); +var supplyAllDefaults = require('../assets/supply_defaults'); + +describe('Symbol legend', function() { + 'use strict'; + + describe('supplyDefaults', function() { + function _supply(traces, layout) { + var gd = { + data: traces, + layout: layout || {} + }; + supplyAllDefaults(gd); + return gd; + } + + it('should create symbollegend when trace references it', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }); + + expect(gd._fullData[0].marker.symbollegend).toBe('symbollegend'); + expect(gd._fullLayout.symbollegend).toBeDefined(); + expect(gd._fullLayout.symbollegend.visible).toBe(true); + }); + + it('should support multiple symbollegends', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }, { + type: 'scatter', + x: [1, 2, 3], + y: [2, 3, 4], + marker: { + symbol: ['star', 'cross', 'x'], + symbollegend: 'symbollegend2' + } + }], { + symbollegend: { title: { text: 'Legend 1' } }, + symbollegend2: { title: { text: 'Legend 2' } } + }); + + expect(gd._fullLayout.symbollegend).toBeDefined(); + expect(gd._fullLayout.symbollegend2).toBeDefined(); + expect(gd._fullLayout._symbollegends).toContain('symbollegend'); + expect(gd._fullLayout._symbollegends).toContain('symbollegend2'); + }); + + it('should apply default values', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }]); + + var symbollegend = gd._fullLayout.symbollegend; + expect(symbollegend.visible).toBe(true); + expect(symbollegend.x).toBe(1.02); + expect(symbollegend.y).toBe(0); + expect(symbollegend.xanchor).toBe('left'); + expect(symbollegend.yanchor).toBe('bottom'); + expect(symbollegend.orientation).toBe('v'); + expect(symbollegend.symbolsize).toBe(12); + expect(symbollegend.symbolcolor).toBe('#444'); + expect(symbollegend.itemclick).toBe('toggle'); + expect(symbollegend.itemdoubleclick).toBe('toggleothers'); + }); + + it('should not create symbollegend when no traces reference it', function() { + var gd = _supply([{ + type: 'scatter', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { symbol: 'circle' } + }], { + symbollegend: { visible: true } + }); + + expect(gd._fullLayout._symbollegends).toEqual([]); + }); + }); + + describe('rendering', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + it('should render symbollegend with unique symbols', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3, 4], + y: [1, 2, 3, 4], + marker: { + symbol: ['circle', 'square', 'circle', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }) + .then(function() { + var legendGroup = d3Select('.symbollegend'); + expect(legendGroup.size()).toBe(1); + + var items = d3SelectAll('.symbollegend-item'); + expect(items.size()).toBe(3); // circle, square, diamond + }) + .then(done, done.fail); + }); + + it('should render symbol paths', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }) + .then(function() { + var paths = d3SelectAll('.symbollegend-item path.symbol'); + expect(paths.size()).toBe(3); + }) + .then(done, done.fail); + }); + + it('should render title when specified', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { + visible: true, + title: { text: 'Marker Shapes' } + } + }) + .then(function() { + var title = d3Select('.symbollegend .legendtitle'); + expect(title.size()).toBe(1); + expect(title.text()).toBe('Marker Shapes'); + }) + .then(done, done.fail); + }); + + it('should handle numeric symbol values', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: [0, 1, 2], // circle, square, diamond + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }) + .then(function() { + var items = d3SelectAll('.symbollegend-item'); + expect(items.size()).toBe(3); + }) + .then(done, done.fail); + }); + + it('should hide legend when visible is false', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: false } + }) + .then(function() { + var legendGroup = d3Select('.symbollegend'); + expect(legendGroup.size()).toBe(0); + }) + .then(done, done.fail); + }); + + it('should update on relayout', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }) + .then(function() { + return Plotly.relayout(gd, 'symbollegend.x', 0.5); + }) + .then(function() { + // Legend should still exist after relayout + var legend = d3Select('.symbollegend'); + expect(legend.size()).toBe(1); + }) + .then(done, done.fail); + }); + + it('should apply symbol size setting', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { + visible: true, + symbolsize: 20 + } + }) + .then(function() { + var paths = d3SelectAll('.symbollegend-item path.symbol'); + expect(paths.size()).toBe(3); + }) + .then(done, done.fail); + }); + + it('should handle multiple traces referencing same symbollegend', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2], + y: [1, 2], + marker: { + symbol: ['circle', 'square'], + symbollegend: 'symbollegend' + } + }, { + type: 'scatter', + mode: 'markers', + x: [3, 4], + y: [3, 4], + marker: { + symbol: ['square', 'diamond'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }) + .then(function() { + var items = d3SelectAll('.symbollegend-item'); + expect(items.size()).toBe(3); // circle, square, diamond (combined from both traces) + }) + .then(done, done.fail); + }); + + it('should handle open symbols', function(done) { + Plotly.newPlot(gd, [{ + type: 'scatter', + mode: 'markers', + x: [1, 2, 3], + y: [1, 2, 3], + marker: { + symbol: ['circle', 'circle-open', 'circle-dot'], + symbollegend: 'symbollegend' + } + }], { + symbollegend: { visible: true } + }) + .then(function() { + var items = d3SelectAll('.symbollegend-item'); + expect(items.size()).toBe(3); + }) + .then(done, done.fail); + }); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 53d05bd9ee5..b0cc4aae04f 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -1850,6 +1850,356 @@ "valType": "boolean" } }, + "colorlegend": { + "_isSubplotObj": true, + "bgcolor": { + "description": "Sets the background color.", + "editType": "legend", + "valType": "color" + }, + "binning": { + "description": "For numeric color data, *auto* creates bins while *discrete* treats each unique value as a category.", + "dflt": "auto", + "editType": "calc", + "valType": "enumerated", + "values": [ + "auto", + "discrete" + ] + }, + "bordercolor": { + "description": "Sets the border color.", + "dflt": "#444", + "editType": "legend", + "valType": "color" + }, + "borderwidth": { + "description": "Sets the border width.", + "dflt": 0, + "editType": "legend", + "min": 0, + "valType": "number" + }, + "editType": "legend", + "font": { + "color": { + "editType": "legend", + "valType": "color" + }, + "description": "Sets the font for legend item text.", + "editType": "legend", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "legend", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "legend", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "none", + "editType": "legend", + "valType": "string" + }, + "size": { + "editType": "legend", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "legend", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "itemclick": { + "description": "Determines the behavior on legend item click.", + "dflt": "toggle", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "itemdoubleclick": { + "description": "Determines the behavior on legend item double-click.", + "dflt": "toggleothers", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "itemsizing": { + "description": "Determines if legend items symbols scale with their corresponding data values or remain constant.", + "dflt": "constant", + "editType": "legend", + "valType": "enumerated", + "values": [ + "trace", + "constant" + ] + }, + "itemwidth": { + "description": "Sets the width of the legend item symbols.", + "dflt": 30, + "editType": "legend", + "min": 30, + "valType": "number" + }, + "nbins": { + "description": "Sets the number of bins for continuous data when binning is *auto*.", + "dflt": 5, + "editType": "calc", + "min": 1, + "valType": "integer" + }, + "orientation": { + "description": "Sets the orientation of the legend items.", + "dflt": "v", + "editType": "legend", + "valType": "enumerated", + "values": [ + "v", + "h" + ] + }, + "role": "object", + "title": { + "editType": "legend", + "font": { + "color": { + "editType": "legend", + "valType": "color" + }, + "description": "Sets the font for the color legend title.", + "editType": "legend", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "legend", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "legend", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "none", + "editType": "legend", + "valType": "string" + }, + "size": { + "editType": "legend", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "legend", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "role": "object", + "side": { + "description": "Determines the location of the legend title.", + "dflt": "top", + "editType": "legend", + "valType": "enumerated", + "values": [ + "top", + "left", + "right" + ] + }, + "text": { + "description": "Sets the title of the color legend.", + "dflt": "", + "editType": "legend", + "valType": "string" + } + }, + "visible": { + "description": "Determines whether this color legend is visible.", + "dflt": true, + "editType": "legend", + "valType": "boolean" + }, + "x": { + "description": "Sets the x position with respect to `xref`.", + "dflt": 1.02, + "editType": "legend", + "valType": "number" + }, + "xanchor": { + "description": "Sets the horizontal anchor.", + "dflt": "left", + "editType": "legend", + "valType": "enumerated", + "values": [ + "auto", + "left", + "center", + "right" + ] + }, + "xref": { + "description": "Sets the container `x` refers to.", + "dflt": "paper", + "editType": "legend", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + }, + "y": { + "description": "Sets the y position with respect to `yref`.", + "dflt": 1, + "editType": "legend", + "valType": "number" + }, + "yanchor": { + "description": "Sets the vertical anchor.", + "dflt": "auto", + "editType": "legend", + "valType": "enumerated", + "values": [ + "auto", + "top", + "middle", + "bottom" + ] + }, + "yref": { + "description": "Sets the container `y` refers to.", + "dflt": "paper", + "editType": "legend", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + } + }, "colorscale": { "diverging": { "description": "Sets the default diverging colorscale. Note that `autocolorscale` must be true for this attribute to work.", @@ -9973,6 +10323,349 @@ "editType": "legend", "valType": "boolean" }, + "sizelegend": { + "_isSubplotObj": true, + "bgcolor": { + "description": "Sets the background color.", + "editType": "legend", + "valType": "color" + }, + "bordercolor": { + "description": "Sets the border color.", + "dflt": "#444", + "editType": "legend", + "valType": "color" + }, + "borderwidth": { + "description": "Sets the border width.", + "dflt": 0, + "editType": "legend", + "min": 0, + "valType": "number" + }, + "editType": "legend", + "font": { + "color": { + "editType": "legend", + "valType": "color" + }, + "description": "Sets the font for legend item text.", + "editType": "legend", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "legend", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "legend", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "none", + "editType": "legend", + "valType": "string" + }, + "size": { + "editType": "legend", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "legend", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "itemclick": { + "description": "Determines the behavior on legend item click.", + "dflt": "toggle", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "itemdoubleclick": { + "description": "Determines the behavior on legend item double-click.", + "dflt": "toggleothers", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "nsamples": { + "description": "Number of size samples to display in the legend.", + "dflt": 4, + "editType": "legend", + "max": 10, + "min": 2, + "valType": "integer" + }, + "orientation": { + "description": "Sets the orientation of the legend items.", + "dflt": "v", + "editType": "legend", + "valType": "enumerated", + "values": [ + "v", + "h" + ] + }, + "role": "object", + "symbolcolor": { + "description": "Fill color for size symbols.", + "dflt": "#444", + "editType": "legend", + "valType": "color" + }, + "symboloutlinecolor": { + "description": "Outline color for size symbols.", + "dflt": "#444", + "editType": "legend", + "valType": "color" + }, + "symboloutlinewidth": { + "description": "Outline width for size symbols.", + "dflt": 1, + "editType": "legend", + "min": 0, + "valType": "number" + }, + "title": { + "editType": "legend", + "font": { + "color": { + "editType": "legend", + "valType": "color" + }, + "description": "Sets the font for the size legend title.", + "editType": "legend", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "legend", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "legend", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "none", + "editType": "legend", + "valType": "string" + }, + "size": { + "editType": "legend", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "legend", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "role": "object", + "side": { + "description": "Determines the location of the legend title.", + "dflt": "top", + "editType": "legend", + "valType": "enumerated", + "values": [ + "top", + "left", + "right" + ] + }, + "text": { + "description": "Sets the title of the size legend.", + "dflt": "", + "editType": "legend", + "valType": "string" + } + }, + "visible": { + "description": "Determines whether this size legend is visible.", + "dflt": true, + "editType": "legend", + "valType": "boolean" + }, + "x": { + "description": "Sets the x position with respect to `xref`.", + "dflt": 1.02, + "editType": "legend", + "valType": "number" + }, + "xanchor": { + "description": "Sets the horizontal anchor.", + "dflt": "left", + "editType": "legend", + "valType": "enumerated", + "values": [ + "auto", + "left", + "center", + "right" + ] + }, + "xref": { + "description": "Sets the container `x` refers to.", + "dflt": "paper", + "editType": "legend", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + }, + "y": { + "description": "Sets the y position with respect to `yref`.", + "dflt": 0.5, + "editType": "legend", + "valType": "number" + }, + "yanchor": { + "description": "Sets the vertical anchor.", + "dflt": "middle", + "editType": "legend", + "valType": "enumerated", + "values": [ + "auto", + "top", + "middle", + "bottom" + ] + }, + "yref": { + "description": "Sets the container `y` refers to.", + "dflt": "paper", + "editType": "legend", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + } + }, "sliders": { "items": { "slider": { @@ -11126,6 +11819,335 @@ "min": -1, "valType": "integer" }, + "symbollegend": { + "_isSubplotObj": true, + "bgcolor": { + "description": "Sets the background color.", + "editType": "legend", + "valType": "color" + }, + "bordercolor": { + "description": "Sets the border color.", + "dflt": "#444", + "editType": "legend", + "valType": "color" + }, + "borderwidth": { + "description": "Sets the border width.", + "dflt": 0, + "editType": "legend", + "min": 0, + "valType": "number" + }, + "editType": "legend", + "font": { + "color": { + "editType": "legend", + "valType": "color" + }, + "description": "Sets the font for legend item text.", + "editType": "legend", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "legend", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "legend", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "none", + "editType": "legend", + "valType": "string" + }, + "size": { + "editType": "legend", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "legend", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "itemclick": { + "description": "Determines the behavior on legend item click.", + "dflt": "toggle", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "itemdoubleclick": { + "description": "Determines the behavior on legend item double-click.", + "dflt": "toggleothers", + "editType": "legend", + "valType": "enumerated", + "values": [ + "toggle", + "toggleothers", + false + ] + }, + "orientation": { + "description": "Sets the orientation of the legend items.", + "dflt": "v", + "editType": "legend", + "valType": "enumerated", + "values": [ + "v", + "h" + ] + }, + "role": "object", + "symbolcolor": { + "description": "Color to use for symbol display.", + "dflt": "#444", + "editType": "legend", + "valType": "color" + }, + "symbolsize": { + "description": "Size of symbols in the legend.", + "dflt": 12, + "editType": "legend", + "min": 1, + "valType": "number" + }, + "title": { + "editType": "legend", + "font": { + "color": { + "editType": "legend", + "valType": "color" + }, + "description": "Sets the font for the symbol legend title.", + "editType": "legend", + "family": { + "description": "HTML font family - the typeface that will be applied by the web browser. The web browser can only apply a font if it is available on the system where it runs. Provide multiple font families, separated by commas, to indicate the order in which to apply fonts if they aren't available.", + "editType": "legend", + "noBlank": true, + "strict": true, + "valType": "string" + }, + "lineposition": { + "description": "Sets the kind of decoration line(s) with text, such as an *under*, *over* or *through* as well as combinations e.g. *under+over*, etc.", + "dflt": "none", + "editType": "legend", + "extras": [ + "none" + ], + "flags": [ + "under", + "over", + "through" + ], + "valType": "flaglist" + }, + "role": "object", + "shadow": { + "description": "Sets the shape and color of the shadow behind text. *auto* places minimal shadow and applies contrast text font color. See https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow for additional options.", + "dflt": "none", + "editType": "legend", + "valType": "string" + }, + "size": { + "editType": "legend", + "min": 1, + "valType": "number" + }, + "style": { + "description": "Sets whether a font should be styled with a normal or italic face from its family.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "italic" + ] + }, + "textcase": { + "description": "Sets capitalization of text. It can be used to make text appear in all-uppercase or all-lowercase, or with each word capitalized.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "word caps", + "upper", + "lower" + ] + }, + "variant": { + "description": "Sets the variant of the font.", + "dflt": "normal", + "editType": "legend", + "valType": "enumerated", + "values": [ + "normal", + "small-caps", + "all-small-caps", + "all-petite-caps", + "petite-caps", + "unicase" + ] + }, + "weight": { + "description": "Sets the weight (or boldness) of the font.", + "dflt": "normal", + "editType": "legend", + "extras": [ + "normal", + "bold" + ], + "max": 1000, + "min": 1, + "valType": "integer" + } + }, + "role": "object", + "side": { + "description": "Determines the location of the legend title.", + "dflt": "top", + "editType": "legend", + "valType": "enumerated", + "values": [ + "top", + "left", + "right" + ] + }, + "text": { + "description": "Sets the title of the symbol legend.", + "dflt": "", + "editType": "legend", + "valType": "string" + } + }, + "visible": { + "description": "Determines whether this symbol legend is visible.", + "dflt": true, + "editType": "legend", + "valType": "boolean" + }, + "x": { + "description": "Sets the x position with respect to `xref`.", + "dflt": 1.02, + "editType": "legend", + "valType": "number" + }, + "xanchor": { + "description": "Sets the horizontal anchor.", + "dflt": "left", + "editType": "legend", + "valType": "enumerated", + "values": [ + "auto", + "left", + "center", + "right" + ] + }, + "xref": { + "description": "Sets the container `x` refers to.", + "dflt": "paper", + "editType": "legend", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + }, + "y": { + "description": "Sets the y position with respect to `yref`.", + "dflt": 0, + "editType": "legend", + "valType": "number" + }, + "yanchor": { + "description": "Sets the vertical anchor.", + "dflt": "bottom", + "editType": "legend", + "valType": "enumerated", + "values": [ + "auto", + "top", + "middle", + "bottom" + ] + }, + "yref": { + "description": "Sets the container `y` refers to.", + "dflt": "paper", + "editType": "legend", + "valType": "enumerated", + "values": [ + "container", + "paper" + ] + } + }, "template": { "description": "Default attributes to be applied to the plot. Templates can be created from existing plots using `Plotly.makeTemplate`, or created manually. They should be objects with format: `{layout: layoutTemplate, data: {[type]: [traceTemplate, ...]}, ...}` `layoutTemplate` and `traceTemplate` are objects matching the attribute structure of `layout` and a data trace. Trace templates are applied cyclically to traces of each type. Container arrays (eg `annotations`) have special handling: An object ending in `defaults` (eg `annotationdefaults`) is applied to each array item. But if an item has a `templateitemname` key we look in the template array for an item with matching `name` and apply that instead. If no matching `name` is found we mark the item invisible. Any named template item not referenced is appended to the end of the array, so you can use this for a watermark annotation or a logo image, for example. To omit one of these items on the plot, make an item with matching `templateitemname` and `visible: false`.", "editType": "calc", @@ -60483,6 +61505,13 @@ ] } }, + "colorlegend": { + "description": "Sets a reference to a shared color legend. Traces referencing the same color legend will contribute their unique color values to that legend. Set to *colorlegend* to use `layout.colorlegend`, or *colorlegend2*, *colorlegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^colorlegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "colorscale": { "description": "Sets the colorscale. Has an effect only if in `marker.color` is set to a numerical array. The colorscale must be an array containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named color string. At minimum, a mapping for the lowest (0) and highest (1) values are required. For example, `[[0, 'rgb(0,0,255)'], [1, 'rgb(255,0,0)']]`. To control the bounds of the colorscale in color space, use `marker.cmin` and `marker.cmax`. Alternatively, `colorscale` may be a palette name string of the following list: Blackbody,Bluered,Blues,Cividis,Earth,Electric,Greens,Greys,Hot,Jet,Picnic,Portland,Rainbow,RdBu,Reds,Viridis,YlGnBu,YlOrRd.", "dflt": null, @@ -60664,6 +61693,13 @@ "min": 0, "valType": "number" }, + "sizelegend": { + "description": "Sets a reference to a shared size legend. Traces referencing the same size legend will contribute their size values to that legend. Set to *sizelegend* to use `layout.sizelegend`, or *sizelegend2*, *sizelegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^sizelegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "sizemin": { "description": "Has an effect only if `marker.size` is set to a numerical array. Sets the minimum size (in px) of the rendered marker points.", "dflt": 0, @@ -61201,6 +62237,13 @@ "arrow-wide-open" ] }, + "symbollegend": { + "description": "Sets a reference to a shared symbol legend. Traces referencing the same symbol legend will contribute their unique symbol values to that legend. Set to *symbollegend* to use `layout.symbollegend`, or *symbollegend2*, *symbollegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^symbollegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "symbolsrc": { "description": "Sets the source reference on Chart Studio Cloud for `symbol`.", "editType": "none", @@ -75851,6 +76894,13 @@ ] } }, + "colorlegend": { + "description": "Sets a reference to a shared color legend. Traces referencing the same color legend will contribute their unique color values to that legend. Set to *colorlegend* to use `layout.colorlegend`, or *colorlegend2*, *colorlegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^colorlegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "colorscale": { "description": "Sets the colorscale. Has an effect only if in `marker.color` is set to a numerical array. The colorscale must be an array containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named color string. At minimum, a mapping for the lowest (0) and highest (1) values are required. For example, `[[0, 'rgb(0,0,255)'], [1, 'rgb(255,0,0)']]`. To control the bounds of the colorscale in color space, use `marker.cmin` and `marker.cmax`. Alternatively, `colorscale` may be a palette name string of the following list: Blackbody,Bluered,Blues,Cividis,Earth,Electric,Greens,Greys,Hot,Jet,Picnic,Portland,Rainbow,RdBu,Reds,Viridis,YlGnBu,YlOrRd.", "dflt": null, @@ -76028,6 +77078,13 @@ "min": 0, "valType": "number" }, + "sizelegend": { + "description": "Sets a reference to a shared size legend. Traces referencing the same size legend will contribute their size values to that legend. Set to *sizelegend* to use `layout.sizelegend`, or *sizelegend2*, *sizelegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^sizelegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "sizemin": { "description": "Has an effect only if `marker.size` is set to a numerical array. Sets the minimum size (in px) of the rendered marker points.", "dflt": 0, @@ -76564,6 +77621,13 @@ "arrow-wide-open" ] }, + "symbollegend": { + "description": "Sets a reference to a shared symbol legend. Traces referencing the same symbol legend will contribute their unique symbol values to that legend. Set to *symbollegend* to use `layout.symbollegend`, or *symbollegend2*, *symbollegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^symbollegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "symbolsrc": { "description": "Sets the source reference on Chart Studio Cloud for `symbol`.", "editType": "none", @@ -80353,6 +81417,13 @@ ] } }, + "colorlegend": { + "description": "Sets a reference to a shared color legend. Traces referencing the same color legend will contribute their unique color values to that legend. Set to *colorlegend* to use `layout.colorlegend`, or *colorlegend2*, *colorlegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^colorlegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "colorscale": { "description": "Sets the colorscale. Has an effect only if in `marker.color` is set to a numerical array. The colorscale must be an array containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named color string. At minimum, a mapping for the lowest (0) and highest (1) values are required. For example, `[[0, 'rgb(0,0,255)'], [1, 'rgb(255,0,0)']]`. To control the bounds of the colorscale in color space, use `marker.cmin` and `marker.cmax`. Alternatively, `colorscale` may be a palette name string of the following list: Blackbody,Bluered,Blues,Cividis,Earth,Electric,Greens,Greys,Hot,Jet,Picnic,Portland,Rainbow,RdBu,Reds,Viridis,YlGnBu,YlOrRd.", "dflt": null, @@ -80530,6 +81601,13 @@ "min": 0, "valType": "number" }, + "sizelegend": { + "description": "Sets a reference to a shared size legend. Traces referencing the same size legend will contribute their size values to that legend. Set to *sizelegend* to use `layout.sizelegend`, or *sizelegend2*, *sizelegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^sizelegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "sizemin": { "description": "Has an effect only if `marker.size` is set to a numerical array. Sets the minimum size (in px) of the rendered marker points.", "dflt": 0, @@ -81066,6 +82144,13 @@ "arrow-wide-open" ] }, + "symbollegend": { + "description": "Sets a reference to a shared symbol legend. Traces referencing the same symbol legend will contribute their unique symbol values to that legend. Set to *symbollegend* to use `layout.symbollegend`, or *symbollegend2*, *symbollegend3*, etc. for additional legends.", + "dflt": null, + "editType": "calc", + "regex": "/^symbollegend([2-9]|[1-9][0-9]+)?$/", + "valType": "subplotid" + }, "symbolsrc": { "description": "Sets the source reference on Chart Studio Cloud for `symbol`.", "editType": "none",