-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Clickable Legend Titles #7698
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Clickable Legend Titles #7698
Changes from 11 commits
88a6c0d
b2ef711
cdb570a
089fa73
207910e
2f934ad
cd92fea
53073d7
7c2e878
459e229
6bec6d1
fdf1d65
0cde808
3a1336f
42c40f6
2b5d2af
6cbdb49
6f6fd91
e3a068e
28990fb
a9809d2
1fc81de
fb5b11f
f820e7f
8ee735e
e0be8c5
0223993
e7a1915
dca2e38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,8 @@ var dragElement = require('../dragelement'); | |
| var Drawing = require('../drawing'); | ||
| var Color = require('../color'); | ||
| var svgTextUtils = require('../../lib/svg_text_utils'); | ||
| var handleClick = require('./handle_click'); | ||
| var handleClick = require('./handle_click').handleClick; | ||
| var handleTitleClick = require('./handle_click').handleTitleClick; | ||
|
|
||
| var constants = require('./constants'); | ||
| var alignmentConstants = require('../../constants/alignment'); | ||
|
|
@@ -180,8 +181,14 @@ function drawOne(gd, opts) { | |
| .text(title.text); | ||
|
|
||
| textLayout(titleEl, scrollBox, gd, legendObj, MAIN_TITLE); // handle mathjax or multi-line text and compute title height | ||
|
|
||
| // Set up title click if enabled and not in hover mode | ||
| if(!inHover && (legendObj.titleclick || legendObj.titledoubleclick)) { | ||
| setupTitleToggle(scrollBox, gd, legendObj, legendId); | ||
| } | ||
| } else { | ||
| scrollBox.selectAll('.' + legendId + 'titletext').remove(); | ||
| scrollBox.selectAll('.' + legendId + 'titletoggle').remove(); | ||
| } | ||
|
|
||
| var scrollBar = Lib.ensureSingle(legend, 'rect', 'scrollbar', function(s) { | ||
|
|
@@ -198,7 +205,22 @@ function drawOne(gd, opts) { | |
| traces.exit().remove(); | ||
|
|
||
| traces.style('opacity', function(d) { | ||
| var trace = d[0].trace; | ||
| const legendItem = d[0]; | ||
| const trace = legendItem.trace; | ||
|
|
||
| // Toggle opacity of legend group titles if all items in the group are hidden | ||
| if(legendItem.groupTitle) { | ||
| const groupName = trace.legendgroup; | ||
| const shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; }); | ||
| const anyVisible = gd._fullData.concat(shapes).some(function(item) { | ||
| return item.legendgroup === groupName && | ||
| (item.legend || 'legend') === legendId && | ||
| item.visible === true; | ||
| }); | ||
|
|
||
| return anyVisible ? 1 : 0.5; | ||
| } | ||
|
|
||
| if(Registry.traceIs(trace, 'pie-like')) { | ||
| return hiddenSlices.indexOf(d[0].label) !== -1 ? 0.5 : 1; | ||
| } else { | ||
|
|
@@ -207,7 +229,12 @@ function drawOne(gd, opts) { | |
| }) | ||
| .each(function() { d3.select(this).call(drawTexts, gd, legendObj); }) | ||
| .call(style, gd, legendObj) | ||
| .each(function() { if(!inHover) d3.select(this).call(setupTraceToggle, gd, legendId); }); | ||
| .each(function(d) { | ||
| if(inHover) return; | ||
| // Don't create a click targets for group titles when groupclick is 'toggleitem' | ||
| if(d[0].groupTitle && legendObj.groupclick === 'toggleitem') return; | ||
|
alexshoe marked this conversation as resolved.
|
||
| d3.select(this).call(setupTraceToggle, gd, legendId); | ||
| }); | ||
|
|
||
| Lib.syncOrAsync([ | ||
| Plots.previousPromises, | ||
|
|
@@ -221,6 +248,20 @@ function drawOne(gd, opts) { | |
| // re-calculate title position after legend width is derived. To allow for horizontal alignment | ||
| if(title.text) { | ||
| horizontalAlignTitle(titleEl, legendObj, bw); | ||
|
|
||
| // Position click target for the title after dimensions are computed | ||
| if(!inHover && (legendObj.titleclick || legendObj.titledoubleclick)) { | ||
| positionTitleToggle(scrollBox, legendObj, legendId); | ||
| } | ||
|
|
||
| // Toggle opacity of legend titles if all items in the legend are hidden | ||
| const shapes = (fullLayout.shapes || []).filter(function(s) { return s.showlegend; }); | ||
| const anyVisible = gd._fullData.concat(shapes).some(function(item) { | ||
| const inThisLegend = (item.legend || 'legend') === legendId; | ||
| return inThisLegend && item.visible === true; | ||
| }); | ||
|
|
||
| titleEl.style('opacity', anyVisible ? 1 : 0.5); | ||
| } | ||
|
|
||
| if(!inHover) { | ||
|
|
@@ -624,6 +665,92 @@ function setupTraceToggle(g, gd, legendId) { | |
| }); | ||
| } | ||
|
|
||
| function setupTitleToggle(scrollBox, gd, legendObj, legendId) { | ||
| // For now, skip title click for legends containing pie-like traces | ||
| const hasPie = gd._fullData.some(function(trace) { | ||
| const legend = trace.legend || 'legend'; | ||
| const inThisLegend = Array.isArray(legend) ? legend.includes(legendId) : legend === legendId; | ||
| return inThisLegend && Registry.traceIs(trace, 'pie-like'); | ||
| }); | ||
| if(hasPie) return; | ||
|
|
||
| const doubleClickDelay = gd._context.doubleClickDelay; | ||
| var newMouseDownTime; | ||
| var numClicks = 1; | ||
|
|
||
| const titleToggle = Lib.ensureSingle(scrollBox, 'rect', legendId + 'titletoggle', function(s) { | ||
| if(!gd._context.staticPlot) { | ||
| s.style('cursor', 'pointer').attr('pointer-events', 'all'); | ||
| } | ||
| s.call(Color.fill, 'rgba(0,0,0,0)'); | ||
| }); | ||
|
|
||
| if(gd._context.staticPlot) return; | ||
|
|
||
| titleToggle.on('mousedown', function() { | ||
| newMouseDownTime = (new Date()).getTime(); | ||
| if(newMouseDownTime - gd._legendMouseDownTime < doubleClickDelay) { | ||
| // in a click train | ||
| numClicks += 1; | ||
| } else { | ||
| // new click train | ||
| numClicks = 1; | ||
| gd._legendMouseDownTime = newMouseDownTime; | ||
| } | ||
| }); | ||
| titleToggle.on('mouseup', function() { | ||
| if(gd._dragged || gd._editing) return; | ||
|
|
||
| if((new Date()).getTime() - gd._legendMouseDownTime > doubleClickDelay) { | ||
| numClicks = Math.max(numClicks - 1, 1); | ||
| } | ||
|
|
||
| const evtData = { | ||
| event: d3.event, | ||
| legendId: legendId, | ||
| data: gd.data, | ||
| layout: gd.layout, | ||
| fullData: gd._fullData, | ||
| fullLayout: gd._fullLayout | ||
| }; | ||
|
|
||
| if(numClicks === 1 && legendObj.titleclick) { | ||
| const clickVal = Events.triggerHandler(gd, 'plotly_legendtitleclick', evtData); | ||
| if(clickVal === false) return; | ||
|
|
||
| legendObj._titleClickTimeout = setTimeout(function() { | ||
| if(gd._fullLayout) handleTitleClick(gd, legendObj, legendObj.titleclick); | ||
| }, doubleClickDelay); | ||
| } else if(numClicks === 2) { | ||
| if(legendObj._titleClickTimeout) clearTimeout(legendObj._titleClickTimeout); | ||
| gd._legendMouseDownTime = 0; | ||
|
|
||
| const dblClickVal = Events.triggerHandler(gd, 'plotly_legendtitledoubleclick', evtData); | ||
| if(dblClickVal !== false && legendObj.titledoubleclick) handleTitleClick(gd, legendObj, legendObj.titledoubleclick); | ||
| } | ||
| }); | ||
|
Comment on lines
+719
to
+733
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @alexshoe Could some of the logic in the |
||
| } | ||
|
|
||
| function positionTitleToggle(scrollBox, legendObj, legendId) { | ||
| const titleToggle = scrollBox.select('.' + legendId + 'titletoggle'); | ||
| if(!titleToggle.size()) return; | ||
|
|
||
| const side = legendObj.title.side || 'top'; | ||
| const bw = legendObj.borderwidth; | ||
| var x = bw; | ||
| const width = legendObj._titleWidth + 2 * constants.titlePad; | ||
| const height = legendObj._titleHeight + 2 * constants.titlePad; | ||
|
|
||
|
|
||
| if(side === 'top center') { | ||
| x = bw + 0.5 * (legendObj._width - 2 * bw - width); | ||
| } else if(side === 'top right') { | ||
| x = legendObj._width - bw - width; | ||
| } | ||
|
|
||
| titleToggle.attr({ x: x, y: bw, width: width, height: height }); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, likewise this function seems like it's duplicating a lot of the title placement logic. I don't think we should be referencing parameters like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok I refactored a few things so that the |
||
|
|
||
| function textLayout(s, g, gd, legendObj, aTitle) { | ||
| if(legendObj._inHover) s.attr('data-notex', true); // do not process MathJax for unified hover | ||
| svgTextUtils.convertToTspans(s, gd, function() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,14 +6,21 @@ var pushUnique = Lib.pushUnique; | |
|
|
||
| var SHOWISOLATETIP = true; | ||
|
|
||
| module.exports = function handleClick(g, gd, numClicks) { | ||
| exports.handleClick = function handleClick(g, gd, numClicks) { | ||
|
alexshoe marked this conversation as resolved.
Outdated
|
||
| var fullLayout = gd._fullLayout; | ||
|
|
||
| if(gd._dragged || gd._editing) return; | ||
|
|
||
| var itemClick = fullLayout.legend.itemclick; | ||
| var itemDoubleClick = fullLayout.legend.itemdoubleclick; | ||
| var groupClick = fullLayout.legend.groupclick; | ||
|
|
||
| const legendItem = g.data()[0][0]; | ||
| if(legendItem.groupTitle && legendItem.noClick) return; | ||
|
|
||
| const legendId = legendItem.trace.legend || 'legend'; | ||
|
alexshoe marked this conversation as resolved.
Outdated
|
||
| const legendObj = fullLayout[legendId]; | ||
|
|
||
| const itemClick = legendObj.itemclick; | ||
| const itemDoubleClick = legendObj.itemdoubleclick; | ||
| const groupClick = legendObj.groupclick; | ||
|
|
||
| if(numClicks === 1 && itemClick === 'toggle' && itemDoubleClick === 'toggleothers' && | ||
| SHOWISOLATETIP && gd.data && gd._context.showTips | ||
|
|
@@ -35,9 +42,6 @@ module.exports = function handleClick(g, gd, numClicks) { | |
| fullLayout.hiddenlabels.slice() : | ||
| []; | ||
|
|
||
| var legendItem = g.data()[0][0]; | ||
| if(legendItem.groupTitle && legendItem.noClick) return; | ||
|
|
||
| var fullData = gd._fullData; | ||
| var shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; }); | ||
| var allLegendItems = fullData.concat(shapesWithLegend); | ||
|
|
@@ -269,3 +273,64 @@ module.exports = function handleClick(g, gd, numClicks) { | |
| } | ||
| } | ||
| }; | ||
|
|
||
| exports.handleTitleClick = function handleTitleClick(gd, legendObj, mode) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also similar comment here to above -- check whether you can share some of this logic with the
alexshoe marked this conversation as resolved.
|
||
| const fullLayout = gd._fullLayout; | ||
| const fullData = gd._fullData; | ||
| const legendId = legendObj._id || 'legend'; | ||
|
alexshoe marked this conversation as resolved.
Outdated
|
||
| const shapesWithLegend = (fullLayout.shapes || []).filter(function(d) { return d.showlegend; }); | ||
| const allLegendItems = fullData.concat(shapesWithLegend); | ||
|
|
||
| function isInLegend(item) { | ||
| return (item.legend || 'legend') === legendId; | ||
| } | ||
|
|
||
| var toggleThisLegend; | ||
| var toggleOtherLegends; | ||
|
|
||
| if(mode === 'toggle') { | ||
|
alexshoe marked this conversation as resolved.
|
||
| // If any item is visible in this legend, hide all. If all are hidden, show all | ||
| const anyVisibleHere = allLegendItems.some(function(item) { | ||
| return isInLegend(item) && item.visible === true; | ||
| }); | ||
|
|
||
| toggleThisLegend = !anyVisibleHere; | ||
|
alexshoe marked this conversation as resolved.
|
||
| toggleOtherLegends = null; | ||
|
alexshoe marked this conversation as resolved.
Outdated
|
||
| } else { | ||
| // isolate this legend or set all legends to visible | ||
| const anyVisibleElsewhere = allLegendItems.some(function(item) { | ||
| return !isInLegend(item) && item.visible === true && item.showlegend !== false; | ||
| }); | ||
|
|
||
| toggleThisLegend = true; | ||
| toggleOtherLegends = !anyVisibleElsewhere; | ||
| } | ||
|
|
||
| const dataUpdate = { visible: [] }; | ||
| const dataIndices = []; | ||
| const updatedShapes = (fullLayout.shapes || []).map(function(d) { return d._input; }); | ||
| var shapesUpdated = false; | ||
|
|
||
| for(var i = 0; i < allLegendItems.length; i++) { | ||
| const item = allLegendItems[i]; | ||
| const shouldShow = isInLegend(item) ? toggleThisLegend : toggleOtherLegends; | ||
|
alexshoe marked this conversation as resolved.
Outdated
|
||
| const newVis = shouldShow ? true : 'legendonly'; | ||
|
|
||
| // Only update if the item is visible and the visibility is different from the new visibility | ||
| if ((item.visible !== false) && (shouldShow !== null) && (item.visible !== newVis)) { | ||
| if(item._isShape) { | ||
| updatedShapes[item._index].visible = newVis; | ||
| shapesUpdated = true; | ||
| } else { | ||
| dataIndices.push(item.index); | ||
| dataUpdate.visible.push(newVis); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if(shapesUpdated) { | ||
| Registry.call('_guiUpdate', gd, dataUpdate, {shapes: updatedShapes}, dataIndices); | ||
| } else if(dataIndices.length) { | ||
| Registry.call('_guiRestyle', gd, dataUpdate, dataIndices); | ||
| } | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.