Skip to content

Commit

Permalink
Merge pull request #1815 from plotly/contour-labels
Browse files Browse the repository at this point in the history
Contour line labels
  • Loading branch information
alexcjohnson committed Jun 30, 2017
2 parents 70de159 + dbeecd0 commit fbc0296
Show file tree
Hide file tree
Showing 62 changed files with 1,517 additions and 389 deletions.
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,
x[0], x[1], x[2], x[3]);
}, false)) {
// no line or arrow - so quit drawArrow now
return;
}

edges.forEach(function(x) {
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};
}
50 changes: 32 additions & 18 deletions 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 Expand Up @@ -569,9 +569,6 @@ drawing.steps = function(shape) {

// off-screen svg render testing element, shared by the whole page
// uses the id 'js-plotly-tester' and stores it in drawing.tester
// makes a hash of cached text items in tester.node()._cache
// so we can add references to rendered text (including all info
// needed to fully determine its bounding rect)
drawing.makeTester = function() {
var tester = d3.select('body')
.selectAll('#js-plotly-tester')
Expand Down Expand Up @@ -601,25 +598,37 @@ drawing.makeTester = function() {
fill: 'black'
});

if(!tester.node()._cache) {
tester.node()._cache = {};
}

drawing.tester = tester;
drawing.testref = testref;
};

/*
* use our offscreen tester to get a clientRect for an element,
* in a reference frame where it isn't translated and its anchor
* point is at (0,0)
* in a reference frame where it isn't translated (or transformed) and
* its anchor point is at (0,0)
* always returns a copy of the bbox, so the caller can modify it safely
*
* @param {SVGElement} node: the element to measure. If possible this should be
* a <text> or MathJax <g> element that's already passed through
* `convertToTspans` because in that case we can cache the results, but it's
* possible to pass in any svg element.
*
* @param {boolean} inTester: is this element already in `drawing.tester`?
* If you are measuring a dummy element, rather than one you really intend
* to use on the plot, making it in `drawing.tester` in the first place
* allows us to test faster because it cuts out cloning and appending it.
*
* @param {string} hash: for internal use only, if we already know the cache key
* for this element beforehand.
*
* @return {object}: a plain object containing the width, height, left, right,
* top, and bottom of `node`
*/
drawing.savedBBoxes = {};
var savedBBoxesCount = 0;
var maxSavedBBoxes = 10000;

drawing.bBox = function(node, hash) {
drawing.bBox = function(node, inTester, hash) {
/*
* Cache elements we've already measured so we don't have to
* remeasure the same thing many times
Expand Down Expand Up @@ -652,7 +661,7 @@ drawing.bBox = function(node, hash) {
if(!transform) {
// in this case, just varying x and y, don't bother caching
// the final bBox because the alteration is quick.
var innerBB = drawing.bBox(innerNode, hash);
var innerBB = drawing.bBox(innerNode, false, hash);
if(x) {
innerBB.left += x;
innerBB.right += x;
Expand All @@ -679,12 +688,17 @@ drawing.bBox = function(node, hash) {
if(out) return Lib.extendFlat({}, out);
}
}
var testNode, tester;
if(inTester) {
testNode = node;
}
else {
tester = drawing.tester.node();

var tester = drawing.tester.node();

// copy the node to test into the tester
var testNode = node.cloneNode(true);
tester.appendChild(testNode);
// copy the node to test into the tester
testNode = node.cloneNode(true);
tester.appendChild(testNode);
}

// standardize its position (and newline tspans if any)
d3.select(testNode)
Expand All @@ -696,7 +710,7 @@ drawing.bBox = function(node, hash) {
.node()
.getBoundingClientRect();

tester.removeChild(testNode);
if(!inTester) tester.removeChild(testNode);

var bb = {
height: testRect.height,
Expand Down
195 changes: 195 additions & 0 deletions src/lib/geometry2d.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* 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;
// 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: undefined if nothing is visible, else object:
* {
* 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
* len: max - min, ie the length of visible path
* total: the total path length - just included so the caller doesn't
* need to call path.getTotalLength() again
* isClosed: true iff the start and end points of the path are both visible
* and are at the same point
* }
*
* 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) {
var left = bounds.left;
var right = bounds.right;
var top = bounds.top;
var bottom = bounds.bottom;

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

var pt0, ptTotal;

function getDistToPlot(len) {
var pt = path.getPointAtLength(len);

// hold on to the start and end points for `closed`
if(len === 0) pt0 = pt;
else if(len === pTotal) ptTotal = pt;

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 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,
len: pMax - pMin,
total: pTotal,
isClosed: pMin === 0 && pMax === pTotal &&
Math.abs(pt0.x - ptTotal.x) < 0.1 &&
Math.abs(pt0.y - ptTotal.y) < 0.1
};
};
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
Loading

0 comments on commit fbc0296

Please sign in to comment.