Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Plotly - Hide data on hover tooltip depending on value?

When I hover over a stacked line chart, it shows zeroes for all lines not in range. Is there a way to hide these values rather than adding noise to the hover tool?

Minimal Example

Plotly.newPlot('test', [{
    line: { shape : 'vh' },
    stackgroup: '1',
    x: [1, 2],
    y: [1, 1],
}, {
    line: { shape : 'vh' },
    stackgroup: '1',
    x: [3, 4],
    y: [2, 2],
}, {
    line: { shape : 'vh' },
    stackgroup: '1',
    x: [3, 4, 5, 6],
    y: [3, 3, 3, 3],
}], {
    hovermode: 'x unified',
    width: '100%',
});

As a jsfiddle and image:

Minimal example - stacked plotly graph showing legend noise

Context

I have a time-series graph stretching ~5yr containing individual lines that each span 6-12mo. Plotly pads each line with zeroes, which makes the hover tool very noisy.

Plotly hover graph

I want to hide the "0 hours" entries at each x-axis date, either by making sure Plotly doesn't 0-pad the lines or by configuring the tooltip to dynamically hide values.

like image 219
PattimusPrime Avatar asked Apr 13 '21 15:04

PattimusPrime


1 Answers

TL;DR

Here is the final product, below. Take a look at how my logic makes use of CSS custom properties work against Plotly's persistent nature. Unfortunately, after digging into Plotly's documentation for quite some time, there doesn't appear to be a simple or native way to achieve this using Plotly's functions, but that won't stop us from using our knowledge to work around their ecosystem's laws of nature.

const testPlot = document.getElementById('test');

Plotly.newPlot('test', [{
  line: { shape : 'vh' },
  stackgroup: '1',
  x: [1, 2, null, null],
  y: [1, 1, null, null],
}, {
  line: { shape : 'vh' },
  stackgroup: '1',
  x: [3, 4, null, null],
  y: [2, 2, null, null],
}, {
  line: { shape : 'vh' },
  stackgroup: '1',
  x: [3, 4, 5, 6],
  y: [3, 3, 3, 3],
}], {
  hovermode: 'x unified',
  width: '100%'
});

const addPxToTransform = transform => 'translate(' + transform.replace(/[^\d,.]+/g, '').split(',').map(e => e + 'px').join(',') + ')';

['plotly_hover', 'plotly_click', 'plotly_legendclick', 'plotly_legenddoubleclick'].forEach(trigger => {
  testPlot.on(trigger, function(data) {
    if (document.querySelector('.hoverlayer > .legend text.legendtext')) {
      const legend = document.querySelector('.hoverlayer > .legend');
      legend.style.setProperty('--plotly-legend-transform', addPxToTransform(legend.getAttribute('transform')));
      const legendTexts = Array.from(document.querySelectorAll('.hoverlayer > .legend text.legendtext'));
      const legendTextGroups = Array.from(document.querySelectorAll('.hoverlayer > .legend text.legendtext')).map(text => text.parentNode);
      const transformValues = [];
      legendTexts.filter(label => (transformValues.push(addPxToTransform(label.parentNode.getAttribute('transform'))), label.textContent.includes(' : 0'))).forEach(zeroValue => zeroValue.parentNode.classList.add('zero-value'));
      legendTextGroups.filter(g => !g.classList.contains('zero-value')).forEach((g,i) => g.style.setProperty('--plotly-transform', transformValues[i]));
      const legendBG = document.querySelector('.hoverlayer > .legend > rect.bg');
      legendBG.style.setProperty('--plotly-legend-bg-height', Math.floor(legendBG.nextElementSibling.getBBox().height + 10) + 'px');
    }
  });
});
.hoverlayer > .legend[style*="--plotly-legend-transform"] {
  transform: var(--plotly-legend-transform) !important;
}
.hoverlayer > .legend > rect.bg[style*="--plotly-legend-bg-height"] {
  height: var(--plotly-legend-bg-height) !important;
}
.hoverlayer > .legend [style*="--plotly-transform"] {
  transform: var(--plotly-transform) !important;
}
.hoverlayer > .legend .zero-value {
  display: none !important;
}
<div id="test"></div>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>

The full explanation

This one really threw me for a loop (no pun intended). I scoured the Plotly docs only to find that while they're quite comprehensive, they don't offer all the flexibility one like yourself could hope for as far as customization goes. They do have a page with basic information on their Plotly JS Filters, full with a basic example, but it won't actually remove any of the points from your legend as you're looking to do.

Next, I went the route of setting up a mousemove event listener on the div you assigned the plot to. This started to work, but there was a lot of flickering going on between my styles and those Plotly seemed to persist on using. That brought my attention to the fact that Plotly's scripting was very persistent, which came in handy later. Next, I tried something similar with MutationObserver since it's supposed to be faster, but alas, more of the same.

At this point, I dug back into the Plotly docs and found a page on their custom built-in JS Event Handlers. I started off using the plotly_hover event and I was making progress. This event runs far less frequently than mousemove so it was less exhausting for the browser, and it only runs on Plotly JS events, so it really only runs exactly when you need it to so that was convenient. However, I was still experiencing some of the flickering.

I realized I was able to inject my own styles, but Plotly was very quick to overwrite any attributes I tried to overwrite. If I overwrite property, it would change it back. If I removed a zero-value legend key, it refreshed the legend to re-include it. I had to get creative. I finally started making serious headway when I discovered that Plotly wouldn't overwrite custom CSS properties I added to the legend keys or their ancestors. Leveraging this and the property values I already had available to me from the SVG elements Plotly built out, I was able to manipulate them accordingly.

There were still a few tricky points, but I managed to work them all out. I created an array of all the original transform property values for all legend keys, including those with zero-value, and then after hiding the zero-value keys, I iterated back through those still visible and reset their transform properties using a custom CSS property as I mentioned. If I tried to add my CSS inline, Plotly was very quick to overwrite, so I used a separate stylesheet.

The transform values stored in the SVG attributes didn't have px in them, so I wrote a quick regex function to clean this up and then pushed those updated values to the array before setting the custom properties on the keys. EVen with this step, I wasn't out of the woods yet. This allowed me to hide zero-value keys and re-organize the visible keys to stack at the top again (getting rid of awkward white-space) but there was still a bit of a flicker on the legend container itself, and the legend box was still it's original height.

To resolve the legend's flickering position, I gave it a similar treatment to that of the keys. Before any of my other logic, I set the legend's transform property using a CSS custom property to keep it persistent, even as Plotly tried to adapt to my changes and overwrite my styles. Then, to resolve the height issue, at the end of all my other logic, I recalculated the height of the background (a separate rectangle element) using—you guessed it—a custom CSS property. We also can't use offsetHeight or clientHeight on SVG elements, so I calculated the height using a bounding box elem.getBBox().height.

And the very last change— I also noticed that even though my styles worked for the most part, if you clicked on the Plotly canvas, Plotly seemed to do a quick reset, The only explanation for how this could be counteracting my styles is that is was a separate event and not triggering my changes. I re-visisted the docs and added a few other listeners that were being triggered to event handler list.

For the sake of clean code clean, I put all the handler names into and array and looped through them using forEach(). The event handlers I am supporting are plotly_hover, plotly_click, plotly_legendclick, and plotly_legenddoubleclick.

Here's the finished product:

const testPlot = document.getElementById('test');

Plotly.newPlot('test', [{
  line: { shape : 'vh' },
  stackgroup: '1',
  x: [1, 2, null, null],
  y: [1, 1, null, null],
}, {
  line: { shape : 'vh' },
  stackgroup: '1',
  x: [3, 4, null, null],
  y: [2, 2, null, null],
}, {
  line: { shape : 'vh' },
  stackgroup: '1',
  x: [3, 4, 5, 6],
  y: [3, 3, 3, 3],
}], {
  hovermode: 'x unified',
  width: '100%'
});

const addPxToTransform = transform => 'translate(' + transform.replace(/[^\d,.]+/g, '').split(',').map(e => e + 'px').join(',') + ')';

['plotly_hover', 'plotly_click', 'plotly_legendclick', 'plotly_legenddoubleclick'].forEach(trigger => {
  testPlot.on(trigger, function(data) {
    if (document.querySelector('.hoverlayer > .legend text.legendtext')) {
      const legend = document.querySelector('.hoverlayer > .legend');
      legend.style.setProperty('--plotly-legend-transform', addPxToTransform(legend.getAttribute('transform')));
      const legendTexts = Array.from(document.querySelectorAll('.hoverlayer > .legend text.legendtext'));
      const legendTextGroups = Array.from(document.querySelectorAll('.hoverlayer > .legend text.legendtext')).map(text => text.parentNode);
      const transformValues = [];
      legendTexts.filter(label => (transformValues.push(addPxToTransform(label.parentNode.getAttribute('transform'))), label.textContent.includes(' : 0'))).forEach(zeroValue => zeroValue.parentNode.classList.add('zero-value'));
      legendTextGroups.filter(g => !g.classList.contains('zero-value')).forEach((g,i) => g.style.setProperty('--plotly-transform', transformValues[i]));
      const legendBG = document.querySelector('.hoverlayer > .legend > rect.bg');
      legendBG.style.setProperty('--plotly-legend-bg-height', Math.floor(legendBG.nextElementSibling.getBBox().height + 10) + 'px');
    }
  });
});
.hoverlayer > .legend[style*="--plotly-legend-transform"] {
  transform: var(--plotly-legend-transform) !important;
}
.hoverlayer > .legend > rect.bg[style*="--plotly-legend-bg-height"] {
  height: var(--plotly-legend-bg-height) !important;
}
.hoverlayer > .legend [style*="--plotly-transform"] {
  transform: var(--plotly-transform) !important;
}
.hoverlayer > .legend .zero-value {
  display: none !important;
}
<div id="test"></div>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
like image 111
Brandon McConnell Avatar answered Nov 04 '22 03:11

Brandon McConnell