Skip to content

Instantly share code, notes, and snippets.

@michaschwab
Last active January 28, 2019 01:08
Show Gist options
  • Save michaschwab/bb0cd5c05fa61aa257b1e5c453cbb987 to your computer and use it in GitHub Desktop.
Save michaschwab/bb0cd5c05fa61aa257b1e5c453cbb987 to your computer and use it in GitHub Desktop.
Stock-Style Dynamic Line Chart With Zoom
date close
1-May-12 58.13
30-Apr-12 53.98
27-Apr-12 67.00
26-Apr-12 89.70
25-Apr-12 99.00
24-Apr-12 130.28
23-Apr-12 166.70
20-Apr-12 234.98
19-Apr-12 345.44
18-Apr-12 443.34
17-Apr-12 543.70
16-Apr-12 580.13
13-Apr-12 605.23
12-Apr-12 622.77
11-Apr-12 626.20
10-Apr-12 628.44
9-Apr-12 636.23
5-Apr-12 633.68
4-Apr-12 624.31
3-Apr-12 629.32
2-Apr-12 618.63
30-Mar-12 599.55
29-Mar-12 609.86
28-Mar-12 617.62
27-Mar-12 614.48
26-Mar-12 606.98
<!DOCTYPE html>
<meta charset="utf-8">
<link href="stock-linechart-styles.css" rel="stylesheet" />
<body>
<svg width="960" height="500" id="stock-linechart"></svg>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script src="stock-linechart.js"></script>
<script>
var chart = new StockLineChart();
var parseTime = d3.timeParse("%d-%b-%y");
d3.csv("data.csv").then(function(data) {
data.forEach(function(d) {
d.date = parseTime(d.date);
d.close = +d.close;
});
chart.addBand(data.map(d => {
return {
date: d.date,
yMin: d.close - Math.random() * 50,
yMax: d.close + Math.random() * 50
}
}), 'band', {
mark: 'circle',
markSize: 3
});
var monthNames = [
"January", "February", "March",
"April", "May", "June", "July",
"August", "September", "October",
"November", "December"
];
chart.addLine(data, 'moving-average', {
mark: 'circle',
markSize: 5,
showValue: function(date, value) {
return monthNames[date.getMonth()].substr(0,3) + ' ' + date.getDate() + ': ' + value;
}
});
chart.addLine(data.map(d => {
return {
date: d.date,
close: d.close - 200
};
}), 'other-line');
chart.addPoints([{date: new Date('Thu Apr 05 2012 11:26:01 GMT-0400'), close: 600, label: 'Quantity: 1'},
{date: new Date('Thu Apr 19 2012 11:26:01 GMT-0400'), close: 500, label: 'Quantity: 3'}], 'buy', 'triangle');
chart.addPoints([{date: new Date('Thu Apr 05 2012 11:26:01 GMT-0400'), close: 400, label: 'Quantity: 2'},
{date: new Date('Thu Apr 19 2012 11:26:01 GMT-0400'), close: 300, label: 'Quantity: 5'}], 'sell', 'triangle');
}, function(error) {
throw error;
});
</script>
</body>
.tick, .lines {
user-select: none;
}
.moving-average {
stroke-dasharray: 10px;
fill: none;
stroke: steelblue;
stroke-width: 2px;
}
circle.moving-average-hover {
fill: steelblue;
}
.band {
fill: #a3c4e4;
}
.band-hover {
fill: #71889e;
}
.brush {
fill: steelblue;
opacity: 0.3;
}
.hover-line {
stroke: steelblue;
pointer-events: none;
}
.buy polygon, .sell polygon {
stroke: #fff;
}
.buy polygon {
fill: #0a0;
transition: transform 200ms ease;
}
.sell polygon {
fill: #a00;
transform: rotate(180deg);
transition: transform 200ms ease;
}
.buy polygon:hover {
transform: scale(2);
}
.sell polygon:hover {
transform: scale(2)rotate(180deg);
}
.other-line {
fill: none;
stroke: #aa0000;
}
.x-axis, .y-axis {
font-size: 12pt;
}
var StockLineChart = function() {
var svg = d3.select('#stock-linechart');
var plotData = [];
var margin = {top: 20, right: 20, bottom: 30, left: 50},
width = svg.attr('width') - margin.left - margin.right,
height = svg.attr('height') - margin.top - margin.bottom;
var x = d3.scaleTime().range([0, width]);
var y = d3.scaleLinear().range([height, 0]);
var valueline = d3.line()
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.close); });
var valueArea = d3.area()
.x(function(d) { return x(d.date); })
.y0(function(d) { return y(d.yMin); })
.y1(function(d) { return y(d.yMax); });
var content = svg
.append("g")
.attr("transform",
"translate(" + margin.left + "," + margin.top + ")");
content.append('g')
.classed('lines', true);
// Hide content on top margin
content.append('rect')
.attr('fill', 'white')
.attr('x', margin.left * -1)
.attr('y', margin.top * -1)
.attr('width', svg.attr('width'))
.attr('height', margin.top);
// Hide content on left margin
content.append('rect')
.attr('fill', 'white')
.attr('x', margin.left * -1)
.attr('y', 0)
.attr('width', margin.left - 1)
.attr('height', svg.attr('height'));
// Hide content on bottom margin
content.append('rect')
.attr('fill', 'white')
.attr('x', 0)
.attr('y', height + 1)
.attr('width', svg.attr('width'))
.attr('height', margin.bottom);
content.append("g")
.attr("transform", "translate(0," + height + ")")
.attr('class', 'x-axis')
.call(d3.axisBottom(x).ticks(6));
content.append("g")
.attr('class', 'y-axis')
.call(d3.axisLeft(y));
resetScales();
addBrush();
addHover();
function addBrush() {
var brushStart = -1;
var brush = content.append('rect')
.classed('brush', true)
.attr('y', 0)
.attr('height', height);
svg.on('mousedown', function() {
brushStart = d3.event.offsetX - margin.left;
});
svg.on('mousemove.brush', function() {
if(brushStart !== -1) {
var brushEnd = d3.event.offsetX - margin.left;
var brushLeft = brushStart < brushEnd ? brushStart : brushEnd;
var brushRight = brushStart < brushEnd ? brushEnd : brushStart;
brush.attr('x', brushLeft)
.attr('width', brushRight - brushLeft);
}
});
svg.on('mouseup', function() {
if(brushStart !== -1 && Math.abs(brushStart - (d3.event.offsetX - margin.left)) > 2) {
var brushEnd = d3.event.offsetX - margin.left;
var brushLeft = brushStart < brushEnd ? brushStart : brushEnd;
var brushRight = brushStart < brushEnd ? brushEnd : brushStart;
var domainBefore = x.domain().map(d => d.getTime());
var domain = [x.invert(brushLeft), x.invert(brushRight)].map(d => d.getTime());
brush.attr('width', 0);
animateZoom(domainBefore, domain);
}
brushStart = -1;
});
svg.on('dblclick', function() {
var domainBefore = x.domain().map(d => d.getTime());
var domain = getMaxXdomain();
animateZoom(domainBefore, domain);
});
function animateZoom(domainBefore, domain) {
new Animation(600, function(t) {
var currentDomain = [domainBefore[0] + (domain[0] - domainBefore[0]) * t,
domainBefore[1] + (domain[1] - domainBefore[1]) * t];
x.domain(currentDomain);
resetYScale();
redraw();
/*brush.attr('x', x(currentDomain[0]))
.attr('width', x(currentDomain[1]) - x(currentDomain[0]));*/
}).start();
}
}
var hoverG = content.append('g')
.classed('hovers', true);
function addHover() {
var hover = content.append('line')
.attr('y1', 0)
.attr('y2', height)
.classed('hover-line', true);
svg.on('mousemove.hover', function() {
var currentX = x.invert(d3.event.offsetX - margin.left);
hover.attr('x1', x(currentX))
.attr('x2', x(currentX));
for(let plot of plotData.filter(plot => plot.hover)) {
if(plot.hover) {
var sortedLeft = plot.data.concat().filter(p => p.date < currentX)
.sort((a, b) => Math.abs(a.date - currentX) - Math.abs(b.date - currentX));
var sortedRight = plot.data.concat().filter(p => p.date > currentX)
.sort((a, b) => Math.abs(a.date - currentX) - Math.abs(b.date - currentX));
var closestTwo = [sortedLeft[0], sortedRight[0]];
if(closestTwo[0] === undefined || closestTwo[1] === undefined) return;
var distances = closestTwo.map(p => Math.abs(p.date - currentX));
var distanceRatio = distances[1] / (distances[0] + distances[1]);
if(plot.type === 'line') {
var currentY = closestTwo[0].close * distanceRatio + closestTwo[1].close * (1 - distanceRatio);
if(plot.hover.mark && plot.hover.mark === 'circle') {
plot.hover.markNode.select('circle')
.attr('cx', x(currentX))
.attr('cy', y(currentY));
}
if(plot.hover.showValue) {
plot.hover.showValueNode
.attr('x', x(currentX) + 15)
.attr('y', y(currentY) + 5)
.text(plot.hover.showValue(currentX, Math.round(currentY)));
plot.hover.markNode.select('rect')
.attr('x', x(currentX) + 10)
.attr('y', y(currentY) - 15)
.attr('width', plot.hover.showValue(currentX, Math.round(currentY)).length * 10);
}
} else if(plot.type === 'band') {
var currentYs = [closestTwo[0].yMin * distanceRatio + closestTwo[1].yMin * (1 - distanceRatio),
closestTwo[0].yMax * distanceRatio + closestTwo[1].yMax * (1 - distanceRatio)];
if(plot.hover.mark && plot.hover.mark === 'circle') {
plot.hover.markNode.select('circle:nth-child(1)')
.attr('cx', x(currentX))
.attr('cy', y(currentYs[0]));
plot.hover.markNode.select('circle:nth-child(2)')
.attr('cx', x(currentX))
.attr('cy', y(currentYs[1]));
}
}
}
}
});
}
this.addLine = function(data, className, hover) {
var obj = {
type: 'line',
className: className,
data: data,
hover: hover
};
if(obj.hover) {
if(obj.hover.mark && obj.hover.mark === 'circle') {
var markSize = obj.hover.markSize || 5;
obj.hover.markNode = hoverG.append('g');
obj.hover.markNode.append('circle')
.attr('r', markSize)
.style('pointer-events', 'none')
.classed(className + '-hover', true);
}
if(obj.hover.showValue) {
obj.hover.markNode.append('rect')
.attr('fill', 'rgba(255,255,255,0.5)')
.attr('stroke', '#ccc')
.attr('rx', 10)
.attr('ry', 10)
.attr('height', 30);
obj.hover.showValueNode = hoverG
.append('text')
.classed(className + '-hover', true);
}
}
plotData.push(obj);
resetScales();
redraw();
};
this.addBand = function(data, className, hover) {
var obj = {
type: 'band',
className: className,
data: data,
hover: hover
};
if(obj.hover) {
if(obj.hover.mark && obj.hover.mark === 'circle') {
var markSize = obj.hover.markSize || 5;
obj.hover.markNode = hoverG.append('g');
obj.hover.markNode.append('circle')
.attr('r', markSize)
.style('pointer-events', 'none')
.classed(className + '-hover', true);
obj.hover.markNode.append('circle')
.attr('r', markSize)
.style('pointer-events', 'none')
.classed(className + '-hover', true);
}
if(obj.hover.showValue) {
obj.hover.showValueNode = hoverG
.append('text')
.classed(className + '-hover', true);
}
}
plotData.push(obj);
resetScales();
redraw();
};
this.addPoints = function(data, className, shape) {
plotData.push({
type: 'points',
className: className,
shape: shape,
data: data
});
resetScales();
redraw();
};
this.clearData = function() {
plotData = [];
resetScales();
redraw();
};
function resetScales() {
x.domain(getMaxXdomain());
resetYScale();
}
function getMaxXdomain() {
var xExtents = plotData.filter(p => p.type === 'line').map(plot => {
return d3.extent(plot.data, function(d) { return d.date; });
});
return [Math.min.apply(null, xExtents.map(e => e[0])),
Math.max.apply(null, xExtents.map(e => e[1]))];
}
function resetYScale() {
var yExtents = plotData.filter(p => p.type === 'line').map(plot => {
var dataInX = plot.data.filter(d => d.date >= x.domain()[0] && d.date <= x.domain()[1]);
return d3.extent(dataInX, function(d) { return d.close; });
}).concat(plotData.filter(p => p.type === 'band').map(plot => {
var dataInX = plot.data.filter(d => d.date >= x.domain()[0] && d.date <= x.domain()[1]);
return d3.extent(dataInX, function(d) { return d.yMin; });
})).concat(plotData.filter(p => p.type === 'band').map(plot => {
var dataInX = plot.data.filter(d => d.date >= x.domain()[0] && d.date <= x.domain()[1]);
return d3.extent(dataInX, function(d) { return d.yMax; });
}));
y.domain([Math.min.apply(null, yExtents.map(e => e[0])),
Math.max.apply(null, yExtents.map(e => e[1]))]);
}
var redraw = function() {
var linesG = content.select('.lines').html('');
for(let plot of plotData) {
if(plot.type === 'line') {
linesG.append("path")
.data([plot.data])
.classed(plot.className, true)
.attr("d", valueline);
} else if(plot.type === 'band') {
linesG.append("path")
.data([plot.data])
.classed(plot.className, true)
.attr("d", valueArea);
} else if(plot.type === 'points') {
var el = linesG.append("g")
.classed(plot.className, true)
.selectAll('g')
.data(plot.data)
.enter()
.append('g')
.attr('transform', function(d) {
return 'translate(' + x(d.date) + ', ' + y(d.close) + ')';
});
var label = el.append('g')
.attr('opacity', 0);
el
.append('polygon')
.attr('points', function() {
if(plot.shape === 'triangle') {
return '0,-10 -10,10 10,10';
} else {
console.error('shape ', plot.shape, ' not yet supported');
return '';
}
})
.on('mouseover', function() {
d3.select(this.parentElement).select('g')
.transition()
.attr('opacity', 1);
for(let plot of plotData.filter(plot => plot.hover)) {
if(plot.hover) {
if(plot.type === 'line') {
if(plot.hover.showValue) {
plot.hover.showValueNode.attr('opacity', 0);
plot.hover.markNode.select('rect').attr('opacity', 0);
}
}
}
}
})
.on('mouseout', function() {
d3.select(this.parentElement).select('g')
.transition()
.attr('opacity', 0);
for(let plot of plotData.filter(plot => plot.hover)) {
if(plot.hover) {
if(plot.type === 'line') {
if(plot.hover.showValue) {
plot.hover.showValueNode.attr('opacity', 1);
plot.hover.markNode.select('rect').attr('opacity', 1);
}
}
}
}
});
label.append('rect')
.attr('fill', 'rgba(255,255,255,0.5)')
.attr('stroke', '#ccc')
.attr('y', -15)
.attr('rx', 10)
.attr('ry', 10)
.attr('x', 20)
.attr('height', 30)
.attr('width', d => d.label.length * 10);
label.append('text')
.text(d => d.label)
.attr('x', 26)
.attr('y', 6);
}
}
svg.select('.x-axis')
.call(d3.axisBottom(x).ticks(6));
svg.select('.y-axis')
.call(d3.axisLeft(y));
};
};
var Animation = function(duration, onTick, onEnd) {
var tStart;
var tEnd;
this.start = function() {
tStart = performance.now();
tEnd = tStart + duration;
this.tick();
};
this.tick = function() {
var t = performance.now();
var percentage = (t - tStart) / duration;
if(percentage > 1) {
percentage = 1;
}
onTick(percentage);
if(t < tEnd) {
requestAnimationFrame(() => this.tick());
} else if(onEnd) {
onEnd();
}
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment