Skip to content

Instantly share code, notes, and snippets.

@pbeshai
Last active October 11, 2019 18:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pbeshai/484d6bf04edcdecfc3731e00c062f47e to your computer and use it in GitHub Desktop.
Save pbeshai/484d6bf04edcdecfc3731e00c062f47e to your computer and use it in GitHub Desktop.
Jagged Lines
license: mit
height: 500
border: no
{
"extends": "eslint-config-airbnb",
"rules": {
"no-mixed-operators": 0,
"no-param-reassign": 0,
},
"globals": {
"d3": true,
"dat": true,
},
"env": {
"browser": true,
},
}

Jagged Lines

Given two points, draw a jagged line between them using D3 v4.

You can configure the height of the peaks via maxPeakHeight and the distance between peaks with minPeakDistance.

The logic for computing the jagged points is done in createJaggedPoints(). The basic process is that the two ends points are rotated so that they are in line with the x-axis. Then at random points in between the ends (based on minPeakDistance), the y value is modified (based on maxPeakHeight). Finally, the line is unrotated and you get the desired result.

An alternative approach that does not involve rotation would be computing the slope perpendicular to the line and using that to compute the offset points. It is slightly more challenging to give intuitive inputs like the pixels defined by maxPeakHeight and minPeakDistance if you take that approach, but still possible.

path{fill:none;stroke-width:2px;stroke:#0bb}.baseline{stroke:#ddd;stroke-dasharray:4 4}circle{fill:none;stroke:#888}
function rotate(t,a,n){var e=t[0],i=t[1],r=a[0],d=a[1],h=e+(r-e)*Math.cos(n)-(d-i)*Math.sin(n),o=i+(r-e)*Math.sin(n)+(d-i)*Math.cos(n);return[h,o]}function createJaggedPoints(t,a,n,e){var i=!1;if(t[0]>a[0]){var r=t;t=a,a=r,i=!0}var d=t[0],h=t[1],o=a[0],s=a[1],g=[t],u=s-h,c=o-d,p=-Math.atan(u/c),l=Math.sqrt(Math.pow(o-d,2)+Math.pow(s-h,2));e||(e=.05*l);for(var v=rotate(t,a,p),w=v[0],m=v[1],f=d;f<w-e;){var P=Math.min(f+e+Math.random()*e,w-e),M=n*(Math.random()-.5)+h;g.push([P,M]),f=P}g.push([w,m]);var k=g.map(function(n,e){return 0===e?t:e===g.length-1?a:rotate(t,n,-p)});return i?k.reverse():k}function transitionLine(t,a){var n=t.node().getTotalLength();t.attr("stroke-dasharray","0,100000").transition().duration(n/(a/1e3)).ease(d3.easeQuadOut).attrTween("stroke-dasharray",function(){var t=this.getTotalLength();return d3.interpolateString("0,"+t,t+","+t)}).on("end",function(){d3.select(this).attr("stroke-dasharray","none")})}function drawJaggedPath(t,a,n,e,i,r){var d=createJaggedPoints(t,a,n,e);svg.append("path").datum(d).attr("d",d3.line().curve(r?d3.curveBasis:d3.curveLinear)).call(function(t){return transitionLine(t,i)})}function drawPoints(){for(var t=[],a=arguments.length;a--;)t[a]=arguments[a];var n=svg.selectAll("circle").data(t);n.merge(n.enter().append("circle").attr("r",3)).attr("cx",function(t){return t[0]}).attr("cy",function(t){return t[1]}),n.exit().remove()}function drawBaseline(t,a){svg.append("path").datum([t,a]).classed("baseline",!0).attr("d",d3.line())}function randomPoint(){return[Math.round(Math.random()*plotAreaWidth),Math.round(Math.random()*plotAreaHeight)]}function update(t,a,n,e,i,r){svg.selectAll("path").remove();var d=randomPoint(),h=randomPoint();i?drawPoints(d,h):drawPoints(),r&&drawBaseline(d,h),drawJaggedPath(d,h,t,a,n,e)}function JaggedLines(){this.maxPeakHeight=80,this.minPeakDistance=15,this.pathSpeed=400,this.curved=!1,this.showEndPoints=!0,this.showBaseline=!0,this.makeNewLine=function(){update(Math.round(this.maxPeakHeight),Math.round(this.minPeakDistance),this.pathSpeed,this.curved,this.showEndPoints,this.showBaseline)}}var width=700,height=500,padding=50,plotAreaWidth=width-2*padding,plotAreaHeight=height-2*padding,svg=d3.select("#main-svg").attr("width",width).attr("height",height).append("g").attr("transform","translate("+padding+" "+padding+")");window.onload=function(){function t(){a.makeNewLine()}var a=new JaggedLines,n=new dat.GUI;n.add(a,"maxPeakHeight",10,100).onFinishChange(t),n.add(a,"minPeakDistance",0,50).onFinishChange(t),n.add(a,"pathSpeed",100,1e3).onFinishChange(t),n.add(a,"curved").onFinishChange(t),n.add(a,"showEndPoints").onFinishChange(t),n.add(a,"showBaseline").onFinishChange(t),n.add(a,"makeNewLine"),a.makeNewLine()};
<!DOCTYPE html>
<title>Jagged Lines</title>
<link href="dist.css" rel="stylesheet" />
<body>
<div>
<svg id="main-svg"></svg>
</div>
<script type="text/javascript" src="//d3js.org/d3.v4.min.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/dat-gui/0.6.1/dat.gui.min.js"></script>
<script type="text/javascript" src="dist.js"></script>
</body>
/**
* 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();
};
path {
fill: none;
stroke-width: 2px;
stroke: #0bb;
}
.baseline {
stroke: #ddd;
stroke-dasharray: 4 4;
}
circle {
fill: none;
stroke: #888;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment