|
/** |
|
* Setup globals |
|
*/ |
|
const width = 700; |
|
const height = 500; |
|
const padding = 50; |
|
const plotAreaWidth = width - (2 * padding); |
|
const plotAreaHeight = height - (2 * padding); |
|
const svg = d3.select('#main-svg') |
|
.attr('width', width) |
|
.attr('height', height) |
|
.append('g') |
|
.attr('transform', `translate(${padding} ${padding})`); |
|
|
|
/** |
|
* Helper function to rotate a point around an origin by theta radians |
|
*/ |
|
function rotate(origin, point, thetaRadians) { |
|
const [originX, originY] = origin; |
|
const [pointX, pointY] = point; |
|
|
|
const rotatedEndX = originX + |
|
(pointX - originX) * Math.cos(thetaRadians) - |
|
(pointY - originY) * Math.sin(thetaRadians); |
|
const rotatedEndY = originY + |
|
(pointX - originX) * Math.sin(thetaRadians) + |
|
(pointY - originY) * Math.cos(thetaRadians); |
|
|
|
return [rotatedEndX, rotatedEndY]; |
|
} |
|
|
|
/** |
|
* Creates a series of jagged points between start and end based on |
|
* maxPeakHeight for how far away from the midline they get to be and |
|
* minPeakDistance for how often they occur. If minPeakDistance is not |
|
* provided, it will add roughly 18 points to the line (every 5% of the |
|
* line length). |
|
*/ |
|
function createJaggedPoints(start, end, maxPeakHeight, minPeakDistance) { |
|
// we want the one with farthest left X to be 'start' |
|
let reversed = false; |
|
if (start[0] > end[0]) { |
|
const swap = start; |
|
start = end; |
|
end = swap; |
|
reversed = true; |
|
} |
|
|
|
const [startX, startY] = start; |
|
const [endX, endY] = end; |
|
|
|
// keep the start point unmodified |
|
const points = [start]; |
|
|
|
// rotate it so end point is horizontal with start point |
|
const opposite = endY - startY; |
|
const adjacent = endX - startX; |
|
const thetaRadians = -Math.atan(opposite / adjacent); |
|
|
|
// compute the overall length of the line |
|
const length = Math.sqrt(Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2)); |
|
if (!minPeakDistance) { |
|
minPeakDistance = length * 0.05; |
|
} |
|
|
|
// compute rotated end point |
|
const [rotatedEndX, rotatedEndY] = rotate(start, end, thetaRadians); |
|
|
|
// generate the intermediate peak points |
|
let lastX = startX; |
|
while (lastX < rotatedEndX - minPeakDistance) { |
|
// move minPeakDistance from previous X + some random amount, but stop at most at |
|
// minPeakDistance from the end |
|
const nextX = Math.min(lastX + minPeakDistance + (Math.random() * minPeakDistance), |
|
rotatedEndX - minPeakDistance); |
|
|
|
// add some randomness to the expected y position to get peaks |
|
// we can use startY as the expected y position since we rotated the line to be flat |
|
const nextY = (maxPeakHeight * (Math.random() - 0.5)) + startY; |
|
|
|
points.push([nextX, nextY]); |
|
lastX = nextX; |
|
} |
|
|
|
// add in the end point |
|
points.push([rotatedEndX, rotatedEndY]); |
|
|
|
// undo the rotation and return the points as the result |
|
const unrotated = points.map((point, i) => { |
|
if (i === 0) { |
|
return start; |
|
} else if (i === points.length - 1) { |
|
return end; |
|
} |
|
|
|
return rotate(start, point, -thetaRadians); |
|
}); |
|
|
|
// restore original directionality if we reversed it |
|
return reversed ? unrotated.reverse() : unrotated; |
|
} |
|
|
|
|
|
/* |
|
* Animate the line based on pathSpeed. Uses sroke-dasharray. |
|
*/ |
|
function transitionLine(path, pathSpeed) { |
|
const pathLength = path.node().getTotalLength(); |
|
path |
|
.attr('stroke-dasharray', '0,100000') // fix safari flash |
|
.transition() |
|
.duration(pathLength / (pathSpeed / 1000)) |
|
.ease(d3.easeQuadOut) |
|
.attrTween('stroke-dasharray', function tweenDash() { |
|
// Dashed line interpolation trick from https://bl.ocks.org/mbostock/5649592 |
|
const length = this.getTotalLength(); |
|
return d3.interpolateString(`0,${length}`, `${length},${length}`); |
|
}) |
|
// Remove stroke-dasharray property at the end |
|
.on('end', function endDashTransition() { |
|
d3.select(this).attr('stroke-dasharray', 'none'); |
|
}); |
|
} |
|
|
|
/** |
|
* Draw the jagged path with animation |
|
*/ |
|
function drawJaggedPath(start, end, maxPeakHeight, minPeakDistance, pathSpeed, curved) { |
|
// generate the intermediate points to make the jagged line |
|
const points = createJaggedPoints(start, end, maxPeakHeight, minPeakDistance); |
|
|
|
// draw the line |
|
svg.append('path').datum(points) |
|
.attr('d', d3.line().curve(curved ? d3.curveBasis : d3.curveLinear)) |
|
.call(path => transitionLine(path, pathSpeed)); |
|
} |
|
|
|
/** |
|
* Draw the end points as circles so we can verify that the |
|
* path is still going through the expected points. |
|
*/ |
|
function drawPoints(...points) { |
|
const circles = svg.selectAll('circle').data(points); |
|
circles.merge(circles.enter().append('circle') |
|
.attr('r', 3)) |
|
.attr('cx', d => d[0]) |
|
.attr('cy', d => d[1]); |
|
circles.exit().remove(); |
|
} |
|
|
|
/** |
|
* Draw the original line between the two points |
|
*/ |
|
function drawBaseline(start, end) { |
|
svg.append('path').datum([start, end]) |
|
.classed('baseline', true) |
|
.attr('d', d3.line()); |
|
} |
|
|
|
|
|
/** |
|
* Helper function to generate a random point in the plot area |
|
*/ |
|
function randomPoint() { |
|
return [ |
|
Math.round(Math.random() * plotAreaWidth), |
|
Math.round(Math.random() * plotAreaHeight), |
|
]; |
|
} |
|
|
|
function update(maxPeakHeight, minPeakDistance, pathSpeed, curved, showEndPoints, showBaseline) { |
|
// remove existing path |
|
svg.selectAll('path').remove(); |
|
|
|
// generate random start and end points |
|
const start = randomPoint(); |
|
const end = randomPoint(); |
|
|
|
// draw circles for the endpoints |
|
if (showEndPoints) { |
|
drawPoints(start, end); |
|
} else { |
|
drawPoints(); |
|
} |
|
|
|
if (showBaseline) { |
|
drawBaseline(start, end); |
|
} |
|
|
|
// draw the jagged path |
|
drawJaggedPath(start, end, maxPeakHeight, minPeakDistance, pathSpeed, curved); |
|
} |
|
|
|
/** |
|
* Initialize the application with datGUI to control parameters |
|
*/ |
|
function JaggedLines() { |
|
this.maxPeakHeight = 80; |
|
this.minPeakDistance = 15; |
|
this.pathSpeed = 400; // pixels per second |
|
this.curved = false; |
|
|
|
this.showEndPoints = true; |
|
this.showBaseline = true; |
|
|
|
this.makeNewLine = function makeNewLine() { |
|
update(Math.round(this.maxPeakHeight), Math.round(this.minPeakDistance), |
|
this.pathSpeed, this.curved, this.showEndPoints, this.showBaseline); |
|
}; |
|
} |
|
|
|
window.onload = function onLoad() { |
|
const jaggedLines = new JaggedLines(); |
|
const gui = new dat.GUI(); |
|
|
|
// callback so when the input is changed, we make a new line |
|
function newLineOnChange() { |
|
jaggedLines.makeNewLine(); |
|
} |
|
|
|
gui.add(jaggedLines, 'maxPeakHeight', 10, 100).onFinishChange(newLineOnChange); |
|
gui.add(jaggedLines, 'minPeakDistance', 0, 50).onFinishChange(newLineOnChange); |
|
gui.add(jaggedLines, 'pathSpeed', 100, 1000).onFinishChange(newLineOnChange); |
|
gui.add(jaggedLines, 'curved').onFinishChange(newLineOnChange); |
|
gui.add(jaggedLines, 'showEndPoints').onFinishChange(newLineOnChange); |
|
gui.add(jaggedLines, 'showBaseline').onFinishChange(newLineOnChange); |
|
gui.add(jaggedLines, 'makeNewLine'); |
|
|
|
// do first draw with defaults |
|
jaggedLines.makeNewLine(); |
|
}; |