Skip to content
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

Add options to display text over heatmaps & histogram2d #6028

Merged
merged 16 commits into from Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions draftlogs/6028_add.md
@@ -0,0 +1,2 @@
- Add `texttemplate` and `textfont` to `heatmap` and `histogram2d` traces as well as
`histogram2dcontour` and `contour` traces when `coloring` is set "heatmap" [[#6028](https://github.com/plotly/plotly.js/pull/6028)]
3 changes: 3 additions & 0 deletions src/plots/font_attributes.js
Expand Up @@ -53,6 +53,9 @@ module.exports = function(opts) {
description: '' + (opts.description || '') + ''
};

if(opts.autoSize) attrs.size.dflt = 'auto';
if(opts.autoColor) attrs.color.dflt = 'auto';

if(opts.arrayOk) {
attrs.family.arrayOk = true;
attrs.size.arrayOk = true;
Expand Down
12 changes: 12 additions & 0 deletions src/traces/contour/attributes.js
Expand Up @@ -42,6 +42,18 @@ module.exports = extendFlat({
yhoverformat: axisHoverFormat('y'),
zhoverformat: axisHoverFormat('z', 1),
hovertemplate: heatmapAttrs.hovertemplate,
texttemplate: extendFlat({}, heatmapAttrs.texttemplate, {
description: [
'For this trace it only has an effect if `coloring` is set to *heatmap*.',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this restriction? Not a big deal, probably won't get a lot of use in contour maps anyway. But the only issue I can think of is what autocolor to give the text - for coloring='fill' it should work pretty well with the same logic as heatmap, and for other coloring values we'd just contrast with the plot_bgcolor. No?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we may try to add this option for other coloring values in another PR.
But that would require extra work as those use different plot paths.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A contour plot with a coloring other than heatmap has different plotting path. So enabling this feature requires extra work. So it could potentially be added later on the road.

heatmapAttrs.texttemplate.description
].join(' ')
}),
textfont: extendFlat({}, heatmapAttrs.textfont, {
description: [
'For this trace it only has an effect if `coloring` is set to *heatmap*.',
heatmapAttrs.textfont.description
].join(' ')
}),
hoverongaps: heatmapAttrs.hoverongaps,
connectgaps: extendFlat({}, heatmapAttrs.connectgaps, {
description: [
Expand Down
10 changes: 9 additions & 1 deletion src/traces/contour/defaults.js
Expand Up @@ -7,6 +7,7 @@ var handlePeriodDefaults = require('../scatter/period_defaults');
var handleConstraintDefaults = require('./constraint_defaults');
var handleContoursDefaults = require('./contours_defaults');
var handleStyleDefaults = require('./style_defaults');
var handleHeatmapLabelDefaults = require('../heatmap/label_defaults');
var attributes = require('./attributes');


Expand All @@ -31,8 +32,8 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout

coerce('text');
coerce('hovertext');
coerce('hovertemplate');
coerce('hoverongaps');
coerce('hovertemplate');

var isConstraint = (coerce('contours.type') === 'constraint');
coerce('connectgaps', Lib.isArray1D(traceOut.z));
Expand All @@ -43,4 +44,11 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
handleContoursDefaults(traceIn, traceOut, coerce, coerce2);
handleStyleDefaults(traceIn, traceOut, coerce, layout);
}

if(
traceOut.contours &&
traceOut.contours.coloring === 'heatmap'
) {
handleHeatmapLabelDefaults(coerce, layout);
}
};
16 changes: 16 additions & 0 deletions src/traces/heatmap/attributes.js
Expand Up @@ -2,8 +2,10 @@

var scatterAttrs = require('../scatter/attributes');
var baseAttrs = require('../../plots/attributes');
var fontAttrs = require('../../plots/font_attributes');
var axisHoverFormat = require('../../plots/cartesian/axis_format_attributes').axisHoverFormat;
var hovertemplateAttrs = require('../../plots/template_attributes').hovertemplateAttrs;
var texttemplateAttrs = require('../../plots/template_attributes').texttemplateAttrs;
var colorScaleAttrs = require('../../components/colorscale/attributes');

var extendFlat = require('../../lib/extend').extendFlat;
Expand Down Expand Up @@ -116,6 +118,20 @@ module.exports = extendFlat({
zhoverformat: axisHoverFormat('z', 1),

hovertemplate: hovertemplateAttrs(),
texttemplate: texttemplateAttrs({
arrayOk: false,
editType: 'plot'
}, {
keys: ['x', 'y', 'z', 'text']
}),
textfont: fontAttrs({
editType: 'plot',
autoSize: true,
autoColor: true,
colorEditType: 'style',
description: 'Sets the text font.'
}),

showlegend: extendFlat({}, baseAttrs.showlegend, {dflt: false})
}, {
transforms: undefined
Expand Down
2 changes: 2 additions & 0 deletions src/traces/heatmap/defaults.js
Expand Up @@ -3,6 +3,7 @@
var Lib = require('../../lib');

var handleXYZDefaults = require('./xyz_defaults');
var handleHeatmapLabelDefaults = require('./label_defaults');
var handlePeriodDefaults = require('../scatter/period_defaults');
var handleStyleDefaults = require('./style_defaults');
var colorscaleDefaults = require('../../components/colorscale/defaults');
Expand All @@ -28,6 +29,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout
coerce('hovertext');
coerce('hovertemplate');

handleHeatmapLabelDefaults(coerce, layout);
handleStyleDefaults(traceIn, traceOut, coerce, layout);

coerce('hoverongaps');
Expand Down
13 changes: 13 additions & 0 deletions src/traces/heatmap/label_defaults.js
@@ -0,0 +1,13 @@
'use strict';

var Lib = require('../../lib');

module.exports = function handleHeatmapLabelDefaults(coerce, layout) {
coerce('texttemplate');

var fontDflt = Lib.extendFlat({}, layout.font, {
color: 'auto',
size: 'auto'
});
Lib.coerceFont(coerce, 'textfont', fontDflt);
};
207 changes: 203 additions & 4 deletions src/traces/heatmap/plot.js
Expand Up @@ -4,9 +4,27 @@ var d3 = require('@plotly/d3');
var tinycolor = require('tinycolor2');

var Registry = require('../../registry');
var Drawing = require('../../components/drawing');
var Axes = require('../../plots/cartesian/axes');
var Lib = require('../../lib');
var svgTextUtils = require('../../lib/svg_text_utils');
var formatLabels = require('../scatter/format_labels');
var Color = require('../../components/color');
var extractOpts = require('../../components/colorscale').extractOpts;
var makeColorScaleFuncFromTrace = require('../../components/colorscale').makeColorScaleFuncFromTrace;
var xmlnsNamespaces = require('../../constants/xmlns_namespaces');
var alignmentConstants = require('../../constants/alignment');
var LINE_SPACING = alignmentConstants.LINE_SPACING;

var labelClass = 'heatmap-label';

function selectLabels(plotGroup) {
return plotGroup.selectAll('g.' + labelClass);
}

function removeLabels(plotGroup) {
selectLabels(plotGroup).remove();
}

module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
var xa = plotinfo.xaxis;
Expand All @@ -16,6 +34,8 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
var plotGroup = d3.select(this);
var cd0 = cd[0];
var trace = cd0.trace;
var xGap = trace.xgap || 0;
var yGap = trace.ygap || 0;

var z = cd0.z;
var x = cd0.x;
Expand All @@ -31,7 +51,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
var xrev = false;
var yrev = false;

var left, right, temp, top, bottom, i;
var left, right, temp, top, bottom, i, j, k;

// TODO: if there are multiple overlapping categorical heatmaps,
// or if we allow category sorting, then the categories may not be
Expand Down Expand Up @@ -112,6 +132,8 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
if(isOffScreen) {
var noImage = plotGroup.selectAll('image').data([]);
noImage.exit().remove();

removeLabels(plotGroup);
return;
}

Expand Down Expand Up @@ -167,7 +189,7 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
var gcount = 0;
var bcount = 0;

var xb, j, xi, v, row, c;
var xb, xi, v, row, c;

function setColor(v, pixsize) {
if(v !== undefined) {
Expand Down Expand Up @@ -278,8 +300,6 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
} else { // zsmooth = false -> filling potentially large bricks works fastest with fillRect
// gaps do not need to be exact integers, but if they *are* we will get
// cleaner edges by rounding at least one edge
var xGap = trace.xgap;
var yGap = trace.ygap;
var xGapLeft = Math.floor(xGap / 2);
var yGapTop = Math.floor(yGap / 2);

Expand Down Expand Up @@ -332,6 +352,185 @@ module.exports = function(gd, plotinfo, cdheatmaps, heatmapLayer) {
y: top,
'xlink:href': canvas.toDataURL('image/png')
});

removeLabels(plotGroup);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In principle we should be able to do this with the d3 add/remove/update idiom, rather than removing and re-adding everything. The performance seems fine with all the mocks you added text to here, but I worry about it bogging down during interactions in more intense cases, like if we update https://dash.plotly.com/dash-bio/alignmentchart with this feature (try dragging the rangeslider there right now, it's terrible)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When trying

Plotly.relayout(gd, 'xaxis.rangeslider', {});
Plotly.restyle(gd, 'texttemplate', '%{z}');

e.g. on earth_heatmap the performance seems good.

So I suggest we move forward with this feature and come back to further optimize it if we hit a performance problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK - it is a little fiddly to get the add/remove/update idiom working properly, and anyway perhaps the higher-value addition to this feature would be a way to disable showing labels at all beyond a certain scale as you zoom out, which we have already decided not to do in this PR.


var texttemplate = trace.texttemplate;
if(texttemplate) {
// dummy axis for formatting the z value
var cOpts = extractOpts(trace);
var dummyAx = {
type: 'linear',
range: [cOpts.min, cOpts.max],
_separators: xa._separators,
_numFormat: xa._numFormat
};

var aHistogram2dContour = trace.type === 'histogram2dcontour';
var aContour = trace.type === 'contour';
var iStart = aContour ? 1 : 0;
var iStop = aContour ? m - 1 : m;
var jStart = aContour ? 1 : 0;
var jStop = aContour ? n - 1 : n;

var textData = [];
for(i = iStart; i < iStop; i++) {
var yVal;
if(aContour) {
yVal = cd0.y[i];
} else if(aHistogram2dContour) {
if(i === 0 || i === m - 1) continue;
yVal = cd0.y[i];
} else if(cd0.yCenter) {
yVal = cd0.yCenter[i];
} else {
if(i + 1 === m && cd0.y[i + 1] === undefined) continue;
yVal = (cd0.y[i] + cd0.y[i + 1]) / 2;
}

var _y = Math.round(ya.c2p(yVal));
if(0 > _y || _y > ya._length) continue;

for(j = jStart; j < jStop; j++) {
var xVal;
if(aContour) {
xVal = cd0.x[j];
} else if(aHistogram2dContour) {
if(j === 0 || j === n - 1) continue;
xVal = cd0.x[j];
} else if(cd0.xCenter) {
xVal = cd0.xCenter[j];
} else {
if(j + 1 === n && cd0.x[j + 1] === undefined) continue;
xVal = (cd0.x[j] + cd0.x[j + 1]) / 2;
}

var _x = Math.round(xa.c2p(xVal));
if(0 > _x || _x > xa._length) continue;

var obj = formatLabels({
x: xVal,
y: yVal
}, trace, gd._fullLayout);

obj.x = xVal;
obj.y = yVal;

var zVal = cd0.z[i][j];
if(zVal === undefined) {
obj.z = '';
obj.zLabel = '';
} else {
obj.z = zVal;
obj.zLabel = Axes.tickText(dummyAx, zVal, 'hover').text;
}

var theText = cd0.text && cd0.text[i] && cd0.text[i][j];
if(theText === undefined || theText === false) theText = '';
obj.text = theText;

var _t = Lib.texttemplateString(texttemplate, obj, gd._fullLayout._d3locale, obj, trace._meta || {});
if(!_t) continue;

var lines = _t.split('<br>');
var nL = lines.length;
var nC = 0;
for(k = 0; k < nL; k++) {
nC = Math.max(nC, lines[k].length);
}

textData.push({
l: nL, // number of lines
c: nC, // maximum number of chars in a line
t: _t, // text
x: _x,
y: _y,
z: zVal
});
}
}

var font = trace.textfont;
var fontFamily = font.family;
var fontSize = font.size;

if(!fontSize || fontSize === 'auto') {
var minW = Infinity;
var minH = Infinity;
var maxL = 0;
var maxC = 0;

for(k = 0; k < textData.length; k++) {
var d = textData[k];
maxL = Math.max(maxL, d.l);
maxC = Math.max(maxC, d.c);

if(k < textData.length - 1) {
var nextD = textData[k + 1];
var dx = Math.abs(nextD.x - d.x);
var dy = Math.abs(nextD.y - d.y);

if(dx) minW = Math.min(minW, dx);
if(dy) minH = Math.min(minH, dy);
}
}

if(
!isFinite(minW) ||
!isFinite(minH)
) {
fontSize = 12;
} else {
minW -= xGap;
minH -= yGap;

minW /= maxC;
minH /= maxL;

minW /= LINE_SPACING / 2;
minH /= LINE_SPACING;

fontSize = Math.min(
Math.floor(minW),
Math.floor(minH)
);
}
}
if(fontSize <= 0 || !isFinite(fontSize)) return;

var xFn = function(d) { return d.x; };
var yFn = function(d) {
return d.y - fontSize * ((d.l * LINE_SPACING) / 2 - 1);
};

var labels = selectLabels(plotGroup).data(textData);

labels
.enter()
.append('g')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need each text element wrapped in a separate group? Could we get away with one group with all the text elements inside it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This quite similar to the way we draw ticklabels:

tickLabels.enter().append('g')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I think this is important for MathJax purposes, so I guess if we think supporting that in heatmap text is something we want to do in the future we can leave it.

.classed(labelClass, 1)
.append('text')
.attr('text-anchor', 'middle')
.each(function(d) {
var thisLabel = d3.select(this);

var fontColor = font.color;
if(!fontColor || fontColor === 'auto') {
fontColor = Color.contrast(
'rgba(' +
sclFunc(d.z).join() +
')'
);
}

thisLabel
.attr('data-notex', 1)
.call(svgTextUtils.positionText, xFn(d), yFn(d))
.call(Drawing.font, fontFamily, fontSize, fontColor)
.text(d.t)
.call(svgTextUtils.convertToTspans, gd);
});
}
});
};

Expand Down