|
<!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> |