|
|
|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8" /> |
|
<style> |
|
path { |
|
fill: #ccc; |
|
stroke: #444; |
|
stroke-width: 1px; |
|
} |
|
|
|
line { |
|
stroke-width: 1px; |
|
stroke: black; |
|
} |
|
|
|
circle { |
|
stroke: black; |
|
stroke-width: 1px; |
|
fill: rgba(255, 0, 255, 0.25); |
|
} |
|
|
|
.scribble { |
|
fill: none; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<svg width="960" height="500"></svg> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.2.3/d3.min.js"></script> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/topojson/1.6.20/topojson.min.js"></script> |
|
<script> |
|
|
|
let svg = d3.select("svg"), |
|
border = svg.append("path"), |
|
line = d3.line(), |
|
closedLine = d3.line().curve(d3.curveLinearClosed); |
|
|
|
d3.json("us.topo.json", function(err, topo){ |
|
const states = topojson.feature(topo, topo.objects.states).features.map(d => d.geometry.coordinates[0]); |
|
scribbleStates(d3.shuffle(states)); |
|
}); |
|
|
|
function scribbleStates(states) { |
|
let points = states.shift(), |
|
scribbleAngle = Math.PI * (1 / 16 + Math.random() * 3 / 8) * (Math.random() < 0.5 ? -1 : 1), |
|
midpoint = getMidpoint(points), |
|
rotator = rotateAround(midpoint, scribbleAngle), |
|
rotated = points.map(rotator), |
|
gridlines = getGridlines(getBounds(rotated)), |
|
intersections = getIntersections(gridlines, rotated); |
|
|
|
svg.selectAll(".scribble") |
|
.remove(); |
|
|
|
border.style("opacity", 1) |
|
.datum(points) |
|
.attr("d", closedLine); |
|
|
|
rotateShape() |
|
.then(drawGridlines) |
|
.then(drawScribbles) |
|
.then(clearGuides) |
|
.then(unrotate) |
|
.then(wobble) |
|
.then(function(){ |
|
scribbleStates([...states, points]); |
|
}); |
|
|
|
function rotateShape() { |
|
return new Promise(function(resolve) { |
|
border.datum(rotated) |
|
.transition() |
|
.delay(500) |
|
.duration(1000) |
|
.attr("d", closedLine) |
|
.on("end", resolve); |
|
}); |
|
} |
|
|
|
|
|
function drawGridlines() { |
|
svg.selectAll("line") |
|
.data(gridlines) |
|
.enter() |
|
.append("line") |
|
.attr("x1", d => d[0][0]) |
|
.attr("x2", d => d[1][0]) |
|
.attr("y1", d => d[0][1]) |
|
.attr("y2", d => d[1][1]); |
|
|
|
svg.selectAll("circle") |
|
.data(d3.merge(intersections)) |
|
.enter() |
|
.append("circle") |
|
.attr("cx", d => d[0]) |
|
.attr("cy", d => d[1]) |
|
.attr("r", 5); |
|
} |
|
|
|
function drawScribbles() { |
|
let scribbles = getScribbles(intersections, rotated); |
|
|
|
svg.selectAll(".scribble") |
|
.data(scribbles) |
|
.enter() |
|
.append("path") |
|
.attr("class", "scribble") |
|
.attr("d", line); |
|
|
|
return new Promise(resolve => d3.timeout(resolve, 500)); |
|
} |
|
|
|
function clearGuides() { |
|
return new Promise(function(resolve){ |
|
svg.selectAll("line, circle") |
|
.transition() |
|
.duration(500) |
|
.style("opacity", 0) |
|
.remove() |
|
.on("end", resolve); |
|
}); |
|
} |
|
|
|
function unrotate() { |
|
let unrotator = rotateAround(midpoint, -scribbleAngle), |
|
paths = svg.selectAll("path"); |
|
|
|
paths.each(function(d){ |
|
d3.select(this).datum(d.map(unrotator)); |
|
}); |
|
|
|
return new Promise(function(resolve){ |
|
paths.transition() |
|
.duration(1000) |
|
.attr("d", (d, i) => i ? line(d) : closedLine(d)) |
|
.transition() |
|
.filter((d, i) => !i) |
|
.style("opacity", 0.1) |
|
.on("end", resolve); |
|
}); |
|
} |
|
|
|
function wobble() { |
|
let scribbles = svg.selectAll(".scribble") |
|
.attr("d", d3.line().curve(d3.curveCardinal.tension(0.25))) |
|
.style("stroke", d3.interpolateRainbow(Math.random())) |
|
.style("stroke-width", 3); |
|
|
|
return new Promise(function(resolve){ |
|
scribbles.transition() |
|
.duration(1000) |
|
.on("end", resolve); |
|
}); |
|
} |
|
|
|
} |
|
|
|
function getScribbles(rows, ring) { |
|
let top = 0, |
|
bottom = 1, |
|
i = j = 0, |
|
p1 = rows[top][i], |
|
p2 = rows[bottom][j], |
|
scribbles = []; |
|
|
|
checkSegment(); |
|
return scribbles; |
|
|
|
function checkSegment() { |
|
if (isInFront() && isContained()) { |
|
addSegment(); |
|
} else { |
|
nextBottom(); |
|
} |
|
} |
|
|
|
function addSegment() { |
|
let found = scribbles.find(scribble => distanceBetween(scribble[scribble.length - 1], p1) < 1e-6); |
|
if (found) { |
|
found.push(p2); |
|
} else { |
|
scribbles.push([p1, p2]); |
|
} |
|
scribbles.sort((a, b) => scribbleLength(b) - scribbleLength(a)); |
|
nextTop(); |
|
} |
|
|
|
function isInFront() { |
|
return (top % 2 ? -1 : 1) * (p2[0] - p1[0]) > 0 |
|
} |
|
|
|
function isContained() { |
|
if (p1[2] === p2[2] || !d3.polygonContains(ring, pointBetween(p1, p2, 0.5))) { |
|
return false; |
|
} |
|
|
|
return ring.every(function(a, segmentIndex){ |
|
const b = ring[segmentIndex + 1] || ring[0]; |
|
return segmentIndex === p1[2] || segmentIndex === p2[2] || !segmentsIntersect([a, b], [p1, p2]); |
|
}); |
|
|
|
} |
|
|
|
function nextRow() { |
|
if (bottom + 1 < rows.length) { |
|
p1 = rows[++top][i = 0]; |
|
p2 = rows[++bottom][j = 0]; |
|
checkSegment(); |
|
} |
|
} |
|
|
|
function nextTop() { |
|
if (i + 1 >= rows[top].length) { |
|
nextRow(); |
|
} else { |
|
p1 = rows[top][++i]; |
|
checkSegment(); |
|
} |
|
} |
|
|
|
function nextBottom() { |
|
if (j + 1 >= rows[bottom].length) { |
|
nextTop(); |
|
} else { |
|
p2 = rows[bottom][++j]; |
|
checkSegment(); |
|
} |
|
} |
|
|
|
function scribbleLength(points) { |
|
return points.reduce(function(length, point, i){ |
|
return i ? length + distanceBetween(point, points[i - 1]) : 0; |
|
}, 0); |
|
} |
|
} |
|
|
|
function getGridlines(bounds) { |
|
let lineFrequency = 7 + Math.random() * 3, |
|
lineVariation = lineFrequency * 3 / 8; |
|
i = bounds[0][1], |
|
gridY = [], |
|
space = lineFrequency - lineVariation / 2 + Math.random() * lineVariation; |
|
|
|
while (i + space < bounds[1][1]) { |
|
i += space; |
|
gridY.push(i); |
|
space = lineFrequency - lineVariation + Math.random() * lineVariation * 2; |
|
} |
|
|
|
return gridY.map(y => [[bounds[0][0] - 5, y], [bounds[1][0] + 5, y]]); |
|
} |
|
|
|
function getIntersections(gridlines, ring){ |
|
return gridlines.map(function(gridline, i){ |
|
const y = gridline[0][1], |
|
row = [], |
|
direction = i % 2 ? - 1 : 1; |
|
|
|
ring.forEach(function(p1, j){ |
|
const p2 = ring[j + 1] || ring[0], |
|
m = (p2[1] - p1[1]) / (p2[0] - p1[0]), |
|
b = p2[1] - m * p2[0], |
|
x = (y - b) / m; |
|
|
|
if ((p1[1] <= y && p2[1] > y) || (p1[1] >= y && p2[1] < y)) { |
|
row.push([x, y, j]); |
|
} |
|
|
|
}); |
|
|
|
row.sort((a, b) => direction * (a[0] - b[0])); |
|
|
|
return row; |
|
|
|
}); |
|
} |
|
|
|
function rotateAround(center, angle) { |
|
const cos = Math.cos(angle), |
|
sin = Math.sin(angle); |
|
|
|
return function(p) { |
|
return [ |
|
center[0] + (p[0] - center[0]) * cos - (p[1] - center[1]) * sin, |
|
center[1] + (p[0] - center[0]) * sin + (p[1] - center[1]) * cos |
|
]; |
|
}; |
|
} |
|
|
|
function getBounds(ring) { |
|
let x0 = y0 = Infinity, |
|
x1 = y1 = -Infinity; |
|
|
|
ring.forEach(function(point){ |
|
if (point[0] < x0) x0 = point[0]; |
|
if (point[0] > x1) x1 = point[0]; |
|
if (point[1] < y0) y0 = point[1]; |
|
if (point[1] > y1) y1 = point[1]; |
|
}); |
|
|
|
return [ |
|
[x0, y0], |
|
[x1, y1] |
|
]; |
|
} |
|
|
|
function getMidpoint(ring) { |
|
const bounds = getBounds(ring); |
|
|
|
return [ |
|
(bounds[1][0] + bounds[0][0]) / 2, |
|
(bounds[1][1] + bounds[0][1]) / 2 |
|
]; |
|
} |
|
|
|
function segmentsIntersect(a, b) { |
|
if (orientation(a[0], a[1], b[0]) === orientation(a[0], a[1], b[1])) { |
|
return false; |
|
} |
|
|
|
return orientation(b[0], b[1], a[0]) !== orientation(b[0], b[1], a[1]); |
|
} |
|
|
|
function orientation(p, q, r) { |
|
const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]); |
|
return val > 0 ? 1 : val < 0 ? -1 : 0; |
|
} |
|
|
|
function pointBetween(a, b, pct) { |
|
const point = [ |
|
a[0] + (b[0] - a[0]) * pct, |
|
a[1] + (b[1] - a[1]) * pct |
|
]; |
|
|
|
return point; |
|
} |
|
|
|
function distanceBetween(a, b) { |
|
const dx = a[0] - b[0], |
|
dy = a[1] - b[1]; |
|
|
|
return Math.sqrt(dx * dx + dy * dy); |
|
} |
|
|
|
</script> |