Skip to content

Instantly share code, notes, and snippets.

@veltman
Created December 27, 2017 18:28
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save veltman/3195a6c9f7426ee699b18ee4c8e99181 to your computer and use it in GitHub Desktop.
Save veltman/3195a6c9f7426ee699b18ee4c8e99181 to your computer and use it in GitHub Desktop.
Automatic label placement along a path #2

A method for automatically finding the best eligible label position and font size for a label that's going to go along a path inside of an area, similar to this example but with two embellishments:

  • Using d3plus.polygonRayCast() to more accurately measure the vertical clearance available at a given x position with any rotation.
  • Avoiding label positions that would cause the text to be overlapped by another shape for a case like this bump chart.

The measurement gets thrown off a little bit by the curve function and the fact that text is rotated letter by letter instead of continuously, but the results seem good enough.

See also: Automatic label placement along a path Streamgraph label positions #2 Stacked area label placement #2

<!DOCTYPE html>
<meta charset="utf-8">
<link href="https://fonts.googleapis.com/css?family=Spectral" rel="stylesheet">
<style>
text {
font-family: 'Spectral', serif;
font-size: 16px;
fill: #222;
text-anchor: middle;
text-transform: uppercase;
letter-spacing: 0.02em;
}
path {
stroke: none;
}
.measurable {
visibility: hidden;
}
.midline {
fill: none;
stroke: black;
display: none;
}
</style>
<svg width="960" height="500"></svg>
<script src="//d3js.org/d3.v4.min.js"></script>
<script src="https://d3plus.org/js/d3plus-shape.v0.13.full.min.js"></script>
<script>
const margin = { top: 10, right: 0, bottom: 10, left: 0 },
chartWidth = 960 - margin.left - margin.right,
chartHeight = 500 - margin.top - margin.bottom,
random = d3.randomNormal(0, 0.1),
fontSizeRange = [8, 64],
colors = ["#7496be", "#fda95d", "#c598b9", "#ffb6bd", "#7eb876", "#f8da73", "#ef7c7d", "#96cbc7"],
films = [
"The Fast and the Furious",
"2 Fast 2 Furious",
"The Fast and the Furious: Tokyo Drift",
"Fast & Furious",
"Fast Five",
"Fast & Furious 6",
"Furious 7",
"The Fate of the Furious"
];
const svg = d3
.select("svg")
.append("g")
.attr("transform", "translate(" + margin.left + " " + margin.top + ")");
const x = d3.scaleLinear().range([0, chartWidth]),
y = d3.scaleLinear().range([chartHeight, 0]);
const series = svg
.selectAll(".area")
.data(films)
.enter()
.append("g")
.attr("class", "area");
series
.append("path")
.attr("class", "area")
.attr("fill", (d, i) => colors[i]);
series
.append("path")
.attr("class", "midline")
.attr("id", (d, i) => "midline-" + i);
series
.append("text")
.attr("class", "measurable")
.attr("dy", "0.35em")
.text(d => d);
series
.append("text")
.attr("class", "along-path")
.attr("dy", "0.35em")
.append("textPath")
.attr("xlink:href", (d, i) => "#midline-" + i)
.text(d => d);
const line = d3.line().curve(d3.curveMonotoneX);
randomize();
function randomize() {
let data = [];
// Random-ish walk
for (let i = 0; i < 10; i++) {
data[i] = {};
films.forEach(function(film) {
data[i][film] = Math.max(0, random() + (i ? data[i - 1][film] : 10));
});
}
let stacked = bumpArea(data);
x.domain([0, data.length - 1]);
y.domain([
d3.min(stacked.map(d => d3.min(d.map(p => p[0])))),
d3.max(stacked.map(d => d3.max(d.map(p => p[1]))))
]);
let scaled = stacked.map((series, i) => {
let points = series.map((p, j) => {
let px = x(j),
py1 = y(p[0]),
py2 = y(p[1]),
crossed;
if (j) {
// Disqualify points where another area will overlap on top of it
stacked.slice(i + 1).some(function(otherSeries, k) {
if (otherSeries[j - 1][2] < series[j - 1][2] !== otherSeries[j][2] < p[2]) {
return (crossed = true);
}
});
}
return {
bottom: [px, py1],
top: [px, py2],
mid: [px, (py1 + py2) / 2],
crossed
};
});
let topLine = points.map(p => p.top),
bottomLine = points.map(p => p.bottom).reverse(),
midLine = points.map(p => p.mid),
polygon = topLine.concat(bottomLine);
// Get the actual gap available for rotated text at this point
points.forEach(function(point, i) {
point.gap = getGap(point, points[i - 1], points[i + 1], polygon);
});
return {
topLine,
bottomLine,
midLine,
points,
polygon
};
});
// Draw filled areas
series
.data(scaled)
.select(".area")
.attr("d", area => [line(area.topLine), line(area.bottomLine).replace("M", "L"), "Z"].join(""));
// Update invisible midlines
series.select(".midline").attr("d", area => line(area.midLine));
// Position labels along midlines
series.each(positionLabel);
setTimeout(randomize, 900);
}
function positionLabel({ points } = {}, j) {
let area = d3.select(this),
pathText = area.select(".along-path");
let { width, height } = area
.select(".measurable")
.node()
.getBBox();
let distances = [],
gaps = [];
// Convert list of midpoints into a list of distances between them and their vertical gaps,
// also interpolate the halfway point between each pair to get higher resolution
points.forEach(function(point, i) {
let distanceBefore = i ? distanceBetween(points[i - 1].mid, point.mid) : 0,
gap = point.gap;
if (i) {
// Take this point off the table if it's a sharp turn
if (i < points.length - 1 && sharpAngle(points[i - 1].mid, point.mid, points[i + 1].mid)) {
gap = 0;
}
gaps.push(point.crossed ? 0 : (gaps[gaps.length - 1] + gap) / 2);
distances.push(distanceBefore / 2);
}
gaps.push(gap);
distances.push(distanceBefore / 2);
});
let { fontSize, offset, index } = getTextPathPosition(distances, gaps, width, height);
// It fits!
if (fontSize) {
pathText
.style("display", "block")
.style("font-size", fontSize + "px")
.select("textPath")
.attr("startOffset", offset + "%");
} else {
pathText.style("display", "none");
}
}
function getTextPathPosition(distances, gaps, width, height) {
let bestPos, bestSize, bestMargin;
for (let i = 1; i < distances.length - 1; i++) {
let [min, max] = fontSizeRange,
margin;
// Binary search for the largest usable font size at center point i
while (min <= max) {
let size = Math.floor((min + max) / 2);
// Text fits
if ((margin = testSize(size, i) > 0)) {
// If it's bigger than the previous winner, or same size with a bigger margin, new winner
if (!bestSize || size > bestSize || (size === bestSize && margin >= bestMargin)) {
bestSize = size;
bestPos = i;
bestMargin = margin;
}
// Try a larger font size
min = size + 1;
} else {
// Try a smaller font size
max = size - 1;
}
}
}
// Return the estimated startOffset and font size
return bestSize
? {
offset: 100 * d3.sum(distances.slice(0, bestPos + 1)) / d3.sum(distances),
index: bestPos,
fontSize: bestSize
}
: {};
// Test a given font size at a given center index
function testSize(s, center) {
let halfWidth = width * s / 32, // estimated half of text width at this size
h = height * s / 16, // estimated text height at this size
paddedHeight = h + Math.max(6, h / 6); // desired vertical clearance - at least 2px, more if it's big
let minHeight = gaps[center],
leftPos = center,
left = 0,
rightPos = center,
right = 0;
// Work left from the center, finding the minimum vertical clearance
// Stop before the first two points to leave some padding on the end
while (leftPos >= 2 && left < halfWidth) {
left += distances[leftPos];
leftPos--;
minHeight = Math.min(gaps[leftPos], minHeight);
// If there's not enough clearance, give up
if (minHeight < paddedHeight) {
break;
}
}
// Work right from the center, finding the minimum vertical clearance
// Stop before the last two points to leave some padding on the end
while (minHeight >= paddedHeight && rightPos < distances.length - 2 && right < halfWidth) {
right += distances[rightPos];
rightPos++;
minHeight = Math.min(gaps[rightPos], minHeight);
// If there's not enough clearance, give up
if (minHeight < paddedHeight) {
break;
}
}
// If we have room on all sides, return the remaining vertical margin
if (left >= halfWidth && right >= halfWidth && minHeight >= paddedHeight) {
return minHeight - h;
}
// Doesn't fit
return 0;
}
}
function getGap(point, before, after, polygon) {
if (before && after) {
let bisected = bisect(before.mid, point.mid, after.mid);
let points = d3plus.polygonRayCast(polygon, point.mid, bisected);
if (points && points.every(p => p)) {
return distanceBetween(...points);
}
return 0;
}
return point.bottom[1] - point.top[1];
}
// Turn data into a set of top/bottom for a bumped area chart
function bumpArea(data) {
let sorted = data.map(function(d) {
return d3.entries(d).sort((a, b) => a.value - b.value);
});
return d3.keys(data[0]).map(function(key) {
return sorted.map(function(set) {
let index = set.findIndex(d => d.key === key),
sumBefore = d3.sum(set.slice(0, index).map(d => d.value));
return [
sumBefore, // bottom
sumBefore + set[index].value, // top
sumBefore + set[index].value / 2 // mid
];
});
});
}
function distanceBetween(a, b) {
let dx = a[0] - b[0],
dy = a[1] - b[1];
return Math.sqrt(dx * dx + dy * dy);
}
// Avoid excessively sharp angle turns in text
function sharpAngle(a, b, c) {
let ab = Math.sqrt(Math.pow(b[0] - a[0], 2) + Math.pow(b[1] - a[1], 2)),
bc = Math.sqrt(Math.pow(b[0] - c[0], 2) + Math.pow(b[1] - c[1], 2)),
ac = Math.sqrt(Math.pow(c[0] - a[0], 2) + Math.pow(c[1] - a[1], 2)),
angle = Math.abs(Math.acos((bc * bc + ab * ab - ac * ac) / (2 * bc * ab))) * 180 / Math.PI;
return angle < 130 || angle > 230;
}
function bisect(a, b, c) {
var at = getAngle(a, b),
bt = getAngle(b, c),
adjusted = bt - at;
return (adjusted + Math.PI / 2) % (2 * Math.PI);
}
function getAngle(a, b) {
let t = Math.atan2(b[1] - a[1], b[0] - a[0]);
return t > 0 ? t : 2 * Math.PI + t;
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment