Skip to content

Commit d518c83

Browse files
committed
simple contour label position optimization
1 parent 99f39f8 commit d518c83

File tree

2 files changed

+133
-13
lines changed

2 files changed

+133
-13
lines changed

src/traces/contour/constants.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ module.exports = {
3737
// substitute to be used up later?
3838
SADDLEREMAINDER: {1: 4, 2: 8, 4: 1, 7: 13, 8: 2, 11: 14, 13: 7, 14: 11},
3939

40+
// length of a contour, as a multiple of the plot area diagonal, per label
41+
LABELDISTANCE: 2,
42+
4043
// number of contour levels after which we start increasing the number of
4144
// labels we draw. Many contours means they will generally be close
4245
// together, so it will be harder to follow a long way to find a label
@@ -47,5 +50,27 @@ module.exports = {
4750
LABELMIN: 3,
4851

4952
// max number of labels to draw on a single contour path, no matter how long
50-
LABELMAX: 10
53+
LABELMAX: 10,
54+
55+
// constants for the label position cost function
56+
LABELOPTIMIZER: {
57+
// weight given to edge proximity
58+
EDGECOST: 1,
59+
// weight given to the angle off horizontal
60+
ANGLECOST: 1,
61+
// weight given to distance from already-placed labels
62+
NEIGHBORCOST: 5,
63+
// cost multiplier for labels on the same level
64+
SAMELEVELFACTOR: 10,
65+
// minimum distance (as a multiple of the label length)
66+
// for labels on the same level
67+
SAMELEVELDISTANCE: 5,
68+
// maximum cost before we won't even place the label
69+
MAXCOST: 100,
70+
// number of evenly spaced points to look at in the first
71+
// iteration of the search
72+
INITIALSEARCHPOINTS: 10,
73+
// number of binary search iterations after the initial wide search
74+
ITERATIONS: 5
75+
}
5176
};

src/traces/contour/plot.js

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var makeCrossings = require('./make_crossings');
2020
var findAllPaths = require('./find_all_paths');
2121
var endPlus = require('./end_plus');
2222
var constants = require('./constants');
23+
var costConstants = constants.LABELOPTIMIZER;
2324

2425

2526
module.exports = function plot(gd, plotinfo, cdcontours) {
@@ -379,7 +380,7 @@ function makeLinesAndLabels(plotgroup, pathinfo, gd, cd0, contours, perimeter) {
379380
var plotDiagonal = Math.sqrt(xLen * xLen + yLen * yLen);
380381

381382
// the path length to use to scale the number of labels to draw:
382-
var normLength = plotDiagonal /
383+
var normLength = constants.LABELDISTANCE * plotDiagonal /
383384
Math.max(1, pathinfo.length / constants.LABELINCREASE);
384385

385386
linegroup.each(function(d) {
@@ -401,22 +402,59 @@ function makeLinesAndLabels(plotgroup, pathinfo, gd, cd0, contours, perimeter) {
401402

402403
d3.select(this).selectAll('path').each(function() {
403404
var path = this;
404-
var pathLen = path.getTotalLength();
405+
var pathBounds = Lib.getVisibleSegment(path, bounds, textHeight / 2);
406+
if(!pathBounds) return;
407+
408+
var onPlotMin = pathBounds.min;
409+
var onPlotMax = pathBounds.max;
410+
var totalPathLen = pathBounds.total;
411+
var pathLen = onPlotMax - onPlotMin;
412+
413+
var isOpen = d3.select(this).classed('openline');
405414

406415
if(pathLen < textWidth * constants.LABELMIN) return;
407416

408-
var labelCount = Math.ceil(pathLen / normLength);
409-
for(var i = 0.5; i < labelCount; i++) {
410-
var positionOnPath = i * pathLen / labelCount;
411-
var loc = getLocation(path, pathLen, positionOnPath, textOpts);
412-
// TODO: no optimization yet: just get display mechanics working
413-
labelClipPathData += addLabel(loc, textOpts, labelData);
414-
}
417+
var maxLabels = Math.min(Math.ceil(pathLen / normLength),
418+
constants.LABELMAX);
419+
var dp, p0, pMax, minCost, location, pMin;
420+
421+
for(var i = 0; i < maxLabels; i++) {
422+
// simple optimization by a wide search followed by a binary search
423+
if(isOpen) {
424+
dp = (pathLen - textWidth) / (costConstants.INITIALSEARCHPOINTS + 1);
425+
p0 = onPlotMin + dp + textWidth / 2;
426+
pMax = onPlotMax - (dp + textWidth) / 2;
427+
}
428+
else {
429+
dp = pathLen / costConstants.INITIALSEARCHPOINTS;
430+
p0 = onPlotMin + dp / 2;
431+
pMax = onPlotMax;
432+
}
433+
434+
minCost = Infinity;
435+
for(var j = 0; j < costConstants.ITERATIONS; j++) {
436+
for(var p = p0; p < pMax; p += dp) {
437+
var newLocation = Lib.getTextLocation(path, totalPathLen, p, textWidth);
438+
var newCost = locationCost(newLocation, textOpts, labelData, bounds);
439+
if(newCost < minCost) {
440+
minCost = newCost;
441+
location = newLocation;
442+
pMin = p;
443+
}
444+
}
445+
if(minCost > costConstants.MAXCOST * 2) break;
446+
447+
// subsequent iterations just look half steps away from the
448+
// best we found in the previous iteration
449+
p0 = pMin - dp / 2;
450+
if(j) dp /= 2;
451+
pMax = p0 + dp * 1.5;
452+
}
453+
if(minCost > costConstants.MAXCOST) break;
415454

455+
labelClipPathData += addLabel(location, textOpts, labelData);
456+
}
416457
});
417-
// - iterate over paths for this level, finding the best position(s)
418-
// for label(s) on that path, given all the other labels we've
419-
// already placed
420458
});
421459

422460
dummyText.remove();
@@ -461,6 +499,63 @@ function straightClosedPath(pts) {
461499
return 'M' + pts.join('L') + 'Z';
462500
}
463501

502+
/*
503+
* locationCost: a cost function for label locations
504+
* composed of three kinds of penalty:
505+
* - for open paths, being close to the end of the path
506+
* - the angle away from horizontal
507+
* - being too close to already placed neighbors
508+
*/
509+
function locationCost(location, textOpts, labelData, bounds) {
510+
var halfWidth = textOpts.width / 2;
511+
var halfHeight = textOpts.height / 2;
512+
var x = location.x;
513+
var y = location.y;
514+
var theta = location.theta;
515+
var dx = Math.cos(theta) * halfWidth;
516+
var dy = Math.sin(theta) * halfWidth;
517+
518+
// cost for being near an edge
519+
var normX = ((x > bounds.center) ? (bounds.right - x) : (x - bounds.left)) /
520+
(dx + Math.abs(Math.sin(theta) * halfHeight));
521+
var normY = ((y > bounds.middle) ? (bounds.bottom - y) : (y - bounds.top)) /
522+
(Math.abs(dy) + Math.cos(theta) * halfHeight);
523+
if(normX < 1 || normY < 1) return Infinity;
524+
var cost = costConstants.EDGECOST * (1 / (normX - 1) + 1 / (normY - 1));
525+
526+
// cost for not being horizontal
527+
cost += costConstants.ANGLECOST * theta * theta;
528+
529+
// cost for being close to other labels
530+
var x1 = x - dx;
531+
var y1 = y - dy;
532+
var x2 = x + dx;
533+
var y2 = y + dy;
534+
for(var i = 0; i < labelData.length; i++) {
535+
var labeli = labelData[i];
536+
var dxd = Math.cos(labeli.theta) * labeli.width / 2;
537+
var dyd = Math.sin(labeli.theta) * labeli.width / 2;
538+
var dist = Lib.segmentDistance(
539+
x1, y1,
540+
x2, y2,
541+
labeli.x - dxd, labeli.y - dyd,
542+
labeli.x + dxd, labeli.y + dyd
543+
) * 2 / (textOpts.height + labeli.height);
544+
545+
var sameLevel = labeli.level === textOpts.level;
546+
var distOffset = sameLevel ? costConstants.SAMELEVELDISTANCE : 1;
547+
548+
if(dist <= distOffset) return Infinity;
549+
550+
var distFactor = costConstants.NEIGHBORCOST *
551+
(sameLevel ? costConstants.SAMELEVELFACTOR : 1);
552+
553+
cost += distFactor / (dist - distOffset);
554+
}
555+
556+
return cost;
557+
}
558+
464559
function addLabel(loc, textOpts, labelData) {
465560
var halfWidth = textOpts.width / 2;
466561
var halfHeight = textOpts.height / 2;

0 commit comments

Comments
 (0)