Skip to content

Contour line labels #1815

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

Merged
merged 39 commits into from
Jun 30, 2017
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
468ef5d
put all contour lines into one group (so we can clip it later)
alexcjohnson Jun 21, 2017
88cc33c
collect contour clipPath in the main <defs>
alexcjohnson Jun 21, 2017
ee7a839
more standard format for contour constants
alexcjohnson Jun 22, 2017
b56f4ea
move lineIntersect -> Lib.geometry2d
alexcjohnson Jun 22, 2017
d534fb2
segmentDistance and tests of geometry2d
alexcjohnson Jun 22, 2017
d1e3448
contour line label basic functionality
alexcjohnson Jun 22, 2017
b8c75c0
better test of alternate Drawing.font syntax
alexcjohnson Jun 23, 2017
2f3a712
use contour/attributes in contourcarpet/attributes
alexcjohnson Jun 23, 2017
6299303
pull path measurement routines out into Lib
alexcjohnson Jun 23, 2017
99f39f8
allow contour labels even without lines
alexcjohnson Jun 23, 2017
d518c83
simple contour label position optimization
alexcjohnson Jun 23, 2017
27f8cfe
use axis formatting for contour labels
alexcjohnson Jun 23, 2017
221b124
include label attrs in contourcarpet to avoid errors but they're noops
alexcjohnson Jun 23, 2017
ca1cced
location -> loc (window global potential confusion)
alexcjohnson Jun 26, 2017
d52cfba
scale min path length for contour labels to width + height
alexcjohnson Jun 26, 2017
0f37648
:cow2: remove obsolete regexp
alexcjohnson Jun 26, 2017
62156dd
:hocho: long-obsolete cache object
alexcjohnson Jun 27, 2017
539ca22
Drawing.bBox "inTester" mode for quicker testing of dummy elements
alexcjohnson Jun 27, 2017
2116a12
contours.font -> contours.labelfont
alexcjohnson Jun 27, 2017
e1880c1
more permissive Drawing.bBox test
alexcjohnson Jun 27, 2017
14c832e
image tests of contour labels
alexcjohnson Jun 27, 2017
1bbdf59
contour line-colored label test
alexcjohnson Jun 27, 2017
e5a2f91
use `editTypes` for contour/contourcarpet-specific attributes
alexcjohnson Jun 27, 2017
cbdf795
no editType nesting - just docalc on editing full containers
alexcjohnson Jun 27, 2017
4db549c
:hocho: obsolete import
alexcjohnson Jun 27, 2017
fb4b690
refactor contour/plot for smaller functions
alexcjohnson Jun 29, 2017
b0f9bb2
updated contour_edge_cases baseline
alexcjohnson Jun 29, 2017
e1caa9e
unify contour.style and contourcarpet.style
alexcjohnson Jun 29, 2017
0ff2dc4
cover contour edge cases with contourcarpet
alexcjohnson Jun 29, 2017
f5873f1
fix contour style when coloring=none
alexcjohnson Jun 29, 2017
b5a674f
a little more stuff contourcarpet can inherit from contour
alexcjohnson Jun 29, 2017
1f85a6f
fix carpet plot clip path
alexcjohnson Jun 30, 2017
6c2043e
:hocho: some obsolete code in carpet/plot
alexcjohnson Jun 30, 2017
bbb9574
update histogram2dcontour for contour.plot API change
alexcjohnson Jun 30, 2017
9495dee
nest scattercontour lines inside a contourlines group
alexcjohnson Jun 30, 2017
7f086e0
fixup font->labelfont in contourcarpet
alexcjohnson Jun 30, 2017
49b8141
labels on contourcarpet
alexcjohnson Jun 30, 2017
8934038
update comment on contourcarpet label bounds
alexcjohnson Jun 30, 2017
dbeecd0
update baselines for findBestTextLocation bugfix
alexcjohnson Jun 30, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions src/components/annotations/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -487,15 +487,15 @@ function drawRaw(gd, options, index, subplotId, xa, ya) {
// to get the parity of the number of intersections.
if(edges.reduce(function(a, x) {
return a ^
!!lineIntersect(headX, headY, headX + 1e6, headY + 1e6,
!!Lib.segmentsIntersect(headX, headY, headX + 1e6, headY + 1e6,
Copy link
Contributor

Choose a reason for hiding this comment

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

Not part of this PR and I'm sure no meaningful performance hit, but is reduce necessary here? Seems like

for (i = 0; i < edges.length; i++) {
  if (!Lib.segmentsIntersect(...)) return;
}

would be more concise and readable and would bail out early when it can.

Copy link
Contributor

Choose a reason for hiding this comment

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

Unless that equates to an all condition where you actually need to check all. Not 100% sure. The ^ is a bit confusing.

x[0], x[1], x[2], x[3]);
}, false)) {
// no line or arrow - so quit drawArrow now
return;
}

edges.forEach(function(x) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is forEach avoidable here?

var p = lineIntersect(tailX, tailY, headX, headY,
var p = Lib.segmentsIntersect(tailX, tailY, headX, headY,
x[0], x[1], x[2], x[3]);
if(p) {
tailX = p.x;
Expand Down Expand Up @@ -701,24 +701,3 @@ function drawRaw(gd, options, index, subplotId, xa, ya) {
}
else annText.call(textLayout);
}

// look for intersection of two line segments
// (1->2 and 3->4) - returns array [x,y] if they do, null if not
function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
var a = x2 - x1,
b = x3 - x1,
c = x4 - x3,
d = y2 - y1,
e = y3 - y1,
f = y4 - y3,
det = a * f - c * d;
// parallel lines? intersection is undefined
// ignore the case where they are colinear
if(det === 0) return null;
var t = (b * f - c * e) / det,
u = (b * d - a * e) / det;
// segments do not intersect?
if(u < 0 || u > 1 || t < 0 || t > 1) return null;

return {x: x1 + a * t, y: y1 + d * t};
}
2 changes: 1 addition & 1 deletion src/components/drawing/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ var drawing = module.exports = {};

drawing.font = function(s, family, size, color) {
// also allow the form font(s, {family, size, color})
if(family && family.family) {
if(Lib.isPlainObject(family)) {
color = family.color;
size = family.size;
family = family.family;
Expand Down
176 changes: 176 additions & 0 deletions src/lib/geometry2d.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Copyright 2012-2017, Plotly, Inc.
* All rights reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

'use strict';

var mod = require('./mod');

/*
* look for intersection of two line segments
* (1->2 and 3->4) - returns array [x,y] if they do, null if not
*/
exports.segmentsIntersect = segmentsIntersect;
function segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
var a = x2 - x1,
b = x3 - x1,
c = x4 - x3,
d = y2 - y1,
e = y3 - y1,
f = y4 - y3,
det = a * f - c * d;
// parallel lines? intersection is undefined
// ignore the case where they are colinear
if(det === 0) return null;
var t = (b * f - c * e) / det,
u = (b * d - a * e) / det;
Copy link
Contributor

Choose a reason for hiding this comment

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

If I were really going to nitpick, I'd suggest comparing bounding boxes first and here multiplying through by det to eliminate division, but this is probably not a bottleneck…

// segments do not intersect?
if(u < 0 || u > 1 || t < 0 || t > 1) return null;

return {x: x1 + a * t, y: y1 + d * t};
}

/*
* find the minimum distance between two line segments (1->2 and 3->4)
*/
exports.segmentDistance = function segmentDistance(x1, y1, x2, y2, x3, y3, x4, y4) {
if(segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)) return 0;

// the two segments and their lengths squared
var x12 = x2 - x1;
var y12 = y2 - y1;
var x34 = x4 - x3;
var y34 = y4 - y3;
var l2_12 = x12 * x12 + y12 * y12;
var l2_34 = x34 * x34 + y34 * y34;

// calculate distance squared, then take the sqrt at the very end
var dist2 = Math.min(
perpDistance2(x12, y12, l2_12, x3 - x1, y3 - y1),
perpDistance2(x12, y12, l2_12, x4 - x1, y4 - y1),
perpDistance2(x34, y34, l2_34, x1 - x3, y1 - y3),
perpDistance2(x34, y34, l2_34, x2 - x3, y2 - y3)
);

return Math.sqrt(dist2);
};

/*
* distance squared from segment ab to point c
* [xab, yab] is the vector b-a
* [xac, yac] is the vector c-a
* l2_ab is the length squared of (b-a), just to simplify calculation
*/
function perpDistance2(xab, yab, l2_ab, xac, yac) {
var fc_ab = (xac * xab + yac * yab);
if(fc_ab < 0) {
// point c is closer to point a
return xac * xac + yac * yac;
}
else if(fc_ab > l2_ab) {
// point c is closer to point b
var xbc = xac - xab;
var ybc = yac - yab;
return xbc * xbc + ybc * ybc;
}
else {
// perpendicular distance is the shortest
var crossProduct = xac * yab - yac * xab;
return crossProduct * crossProduct / l2_ab;
}
}

// a very short-term cache for getTextLocation, just because
// we're often looping over the same locations multiple times
// invalidated as soon as we look at a different path
var locationCache, workingPath, workingTextWidth;

// turn a path and position along it into x, y, and angle for the given text
exports.getTextLocation = function getTextLocation(path, totalPathLen, positionOnPath, textWidth) {
if(path !== workingPath || textWidth !== workingTextWidth) {
locationCache = {};
workingPath = path;
workingTextWidth = textWidth;
}
if(locationCache[positionOnPath]) {
return locationCache[positionOnPath];
}

// for the angle, use points on the path separated by the text width
// even though due to curvature, the text will cover a bit more than that
var p0 = path.getPointAtLength(mod(positionOnPath - textWidth / 2, totalPathLen));
var p1 = path.getPointAtLength(mod(positionOnPath + textWidth / 2, totalPathLen));
// note: atan handles 1/0 nicely
var theta = Math.atan((p1.y - p0.y) / (p1.x - p0.x));
// center the text at 2/3 of the center position plus 1/3 the p0/p1 midpoint
// that's the average position of this segment, assuming it's roughly quadratic
var pCenter = path.getPointAtLength(mod(positionOnPath, totalPathLen));
var x = (pCenter.x * 4 + p0.x + p1.x) / 6;
var y = (pCenter.y * 4 + p0.y + p1.y) / 6;

var out = {x: x, y: y, theta: theta};
locationCache[positionOnPath] = out;
return out;
};

exports.clearLocationCache = function() {
workingPath = null;
};

/*
* Find the segment of `path` that's within the visible area
* given by `bounds` {left, right, top, bottom}, to within a
* precision of `buffer` px
*
* returns {
* min: position where the path first enters bounds, or 0 if it
* starts within bounds
* max: position where the path last exits bounds, or the path length
* if it finishes within bounds
* total: the total path length - just included so the caller doesn't
* need to call path.getTotalLength() again
* }
*
* Works by starting from either end and repeatedly finding the distance from
* that point to the plot area, and if it's outside the plot, moving along the
* path by that distance (because the plot must be at least that far away on
* the path). Note that if a path enters, exits, and re-enters the plot, we
* will not capture this behavior.
*/
exports.getVisibleSegment = function getVisibleSegment(path, bounds, buffer) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh man, I've implemented something similar to this before. It's such a pain!

var left = bounds.left;
var right = bounds.right;
var top = bounds.top;
var bottom = bounds.bottom;

function getDistToPlot(len) {
var pt = path.getPointAtLength(len);
var dx = (pt.x < left) ? left - pt.x : (pt.x > right ? pt.x - right : 0);
var dy = (pt.y < top) ? top - pt.y : (pt.y > bottom ? pt.y - bottom : 0);
return Math.sqrt(dx * dx + dy * dy);
}

var pMin = 0;
var pTotal = path.getTotalLength();
var pMax = pTotal;

var distToPlot = getDistToPlot(pMin);
while(distToPlot) {
pMin += distToPlot + buffer;
if(pMin > pMax) return;
distToPlot = getDistToPlot(pMin);
}

distToPlot = getDistToPlot(pMax);
while(distToPlot) {
pMax -= distToPlot + buffer;
if(pMin > pMax) return;
distToPlot = getDistToPlot(pMax);
}

return {min: pMin, max: pMax, total: pTotal};
};
7 changes: 7 additions & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ lib.rotationXYMatrix = matrixModule.rotationXYMatrix;
lib.apply2DTransform = matrixModule.apply2DTransform;
lib.apply2DTransform2 = matrixModule.apply2DTransform2;

var geom2dModule = require('./geometry2d');
lib.segmentsIntersect = geom2dModule.segmentsIntersect;
lib.segmentDistance = geom2dModule.segmentDistance;
lib.getTextLocation = geom2dModule.getTextLocation;
lib.clearLocationCache = geom2dModule.clearLocationCache;
lib.getVisibleSegment = geom2dModule.getVisibleSegment;

var extendModule = require('./extend');
lib.extendFlat = extendModule.extendFlat;
lib.extendDeep = extendModule.extendDeep;
Expand Down
3 changes: 2 additions & 1 deletion src/plot_api/plot_api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,8 @@ function _restyle(gd, aobj, _traces) {
'line.cmin', 'line.cmax',
'marker.line.cmin', 'marker.line.cmax',
'contours.start', 'contours.end', 'contours.size',
'contours.showlines',
'contours.showlines', 'contours.showlabels', 'contours.labelformat',
Copy link
Contributor

Choose a reason for hiding this comment

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

Wanna try adding editType flags their attribute declarations instead as in #1653 ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@etpinard I had a misstep in e5a2f91 when I thought we could hunt for editType inside containers, but your awesome plot_api tests showed that wasn't going to work - so removed that piece in cbdf795 - look reasonable? Another baby step to cleaning up restyle and relayout...

'contours.font', 'contours.font.family', 'contours.font.size',
'line', 'line.smoothing', 'line.shape',
'error_y.width', 'error_x.width', 'error_x.copy_ystyle',
'marker.maxdisplayed'
Expand Down
29 changes: 28 additions & 1 deletion src/traces/contour/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ var scatterAttrs = require('../scatter/attributes');
var colorscaleAttrs = require('../../components/colorscale/attributes');
var colorbarAttrs = require('../../components/colorbar/attributes');
var dash = require('../../components/drawing/attributes').dash;
var fontAttrs = require('../../plots/font_attributes');
var extendFlat = require('../../lib/extend').extendFlat;

var scatterLineAttrs = scatterAttrs.line;
Expand Down Expand Up @@ -106,7 +107,33 @@ module.exports = extendFlat({}, {
role: 'style',
description: [
'Determines whether or not the contour lines are drawn.',
'Has only an effect if `contours.coloring` is set to *fill*.'
'Has an effect only if `contours.coloring` is set to *fill*.'
].join(' ')
},
showlabels: {
valType: 'boolean',
dflt: false,
role: 'style',
description: [
'Determines whether to label the contour lines with their values.'
].join(' ')
},
font: extendFlat({}, fontAttrs, {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd vote for labelfont here.

cc @cldougl

Copy link
Member

Choose a reason for hiding this comment

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

+1 for labelfont

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

-> labelFont in 2116a12

description: [
'Sets the font used for labeling the contour levels.',
'The default color comes from the lines, if shown.',
// TODO: same size as layout.font, or smaller? 80%?
Copy link
Contributor

Choose a reason for hiding this comment

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

0.8 of layout.font sounds good to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I played with that a little, and ended up feeling like that was a little small:
screen shot 2017-06-26 at 4 57 18 pm

Copy link
Contributor

Choose a reason for hiding this comment

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

OK. Let's keep it as 1 * layout.font 👍

'The default family and size come from `layout.font`.'
].join(' ')
}),
labelformat: {
valType: 'string',
dflt: '',
role: 'style',
description: [
'Sets the contour label formatting rule using d3 formatting',
'mini-language which is very similar to Python, see:',
'https://github.com/d3/d3-format/blob/master/README.md#locale_format.'
].join(' ')
}
},
Expand Down
92 changes: 65 additions & 27 deletions src/traces/contour/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,70 @@
*/

'use strict';
module.exports = {
// some constants to help with marching squares algorithm
// where does the path start for each index?
BOTTOMSTART: [1, 9, 13, 104, 713],
TOPSTART: [4, 6, 7, 104, 713],
LEFTSTART: [8, 12, 14, 208, 1114],
RIGHTSTART: [2, 3, 11, 208, 1114],

// some constants to help with marching squares algorithm
// where does the path start for each index?
module.exports.BOTTOMSTART = [1, 9, 13, 104, 713];
module.exports.TOPSTART = [4, 6, 7, 104, 713];
module.exports.LEFTSTART = [8, 12, 14, 208, 1114];
module.exports.RIGHTSTART = [2, 3, 11, 208, 1114];

// which way [dx,dy] do we leave a given index?
// saddles are already disambiguated
module.exports.NEWDELTA = [
null, [-1, 0], [0, -1], [-1, 0],
[1, 0], null, [0, -1], [-1, 0],
[0, 1], [0, 1], null, [0, 1],
[1, 0], [1, 0], [0, -1]
];

// for each saddle, the first index here is used
// for dx||dy<0, the second for dx||dy>0
module.exports.CHOOSESADDLE = {
104: [4, 1],
208: [2, 8],
713: [7, 13],
1114: [11, 14]
};
// which way [dx,dy] do we leave a given index?
// saddles are already disambiguated
NEWDELTA: [
null, [-1, 0], [0, -1], [-1, 0],
[1, 0], null, [0, -1], [-1, 0],
[0, 1], [0, 1], null, [0, 1],
[1, 0], [1, 0], [0, -1]
],

// for each saddle, the first index here is used
// for dx||dy<0, the second for dx||dy>0
CHOOSESADDLE: {
104: [4, 1],
208: [2, 8],
713: [7, 13],
1114: [11, 14]
},

// after one index has been used for a saddle, which do we
// substitute to be used up later?
SADDLEREMAINDER: {1: 4, 2: 8, 4: 1, 7: 13, 8: 2, 11: 14, 13: 7, 14: 11},

// length of a contour, as a multiple of the plot area diagonal, per label
LABELDISTANCE: 2,

// after one index has been used for a saddle, which do we
// substitute to be used up later?
module.exports.SADDLEREMAINDER = {1: 4, 2: 8, 4: 1, 7: 13, 8: 2, 11: 14, 13: 7, 14: 11};
// number of contour levels after which we start increasing the number of
// labels we draw. Many contours means they will generally be close
// together, so it will be harder to follow a long way to find a label
LABELINCREASE: 10,

// minimum length of a contour line, as a multiple of the label length,
// at which we draw *any* labels
LABELMIN: 3,

// max number of labels to draw on a single contour path, no matter how long
LABELMAX: 10,

// constants for the label position cost function
LABELOPTIMIZER: {
// weight given to edge proximity
EDGECOST: 1,
// weight given to the angle off horizontal
ANGLECOST: 1,
// weight given to distance from already-placed labels
NEIGHBORCOST: 5,
// cost multiplier for labels on the same level
SAMELEVELFACTOR: 10,
// minimum distance (as a multiple of the label length)
// for labels on the same level
SAMELEVELDISTANCE: 5,
// maximum cost before we won't even place the label
MAXCOST: 100,
// number of evenly spaced points to look at in the first
// iteration of the search
INITIALSEARCHPOINTS: 10,
// number of binary search iterations after the initial wide search
ITERATIONS: 5
}
};
Loading