Create a gist now

Instantly share code, notes, and snippets.

Embed
A Simple D3 Line chart with Legend and Tooltips
/********************
* TOOLTIP CSS
*/
.nvtooltip {
position: absolute;
background-color: rgba(255,255,255,1);
padding: 10px;
border: 1px solid #ddd;
font-family: Arial;
font-size: 13px;
transition: opacity 500ms linear;
-moz-transition: opacity 500ms linear;
-webkit-transition: opacity 500ms linear;
transition-delay: 500ms
-moz-transition-delay: 500ms;
-webkit-transition-delay: 500ms;
-moz-box-shadow: 4px 4px 12px rgba(0,0,0,.5);
-webkit-box-shadow: 4px 4px 12px rgba(0,0,0,.5);
box-shadow: 4px 4px 12px rgba(0,0,0,.5);
-moz-border-radius: 15px;
border-radius: 15px;
}
.nvtooltip h3 {
margin: 0;
padding: 0;
text-align: center;
}
.nvtooltip p {
margin: 0;
padding: 0;
text-align: center;
}
.nvtooltip span {
display: inline-block;
margin: 2px 0;
}
/**********
* General SVG CSS
*/
text {
font: 12px sans-serif;
}
/**********
* Legend
*/
.legend .series {
cursor: pointer;
}
.legend circle {
stroke-width: 2px;
}
.legend .disabled circle {
fill-opacity: 0;
}
/**********
* Axes
*/
.axis path {
fill: none;
stroke: #000;
stroke-opacity: .75;
shape-rendering: crispEdges;
}
.axis path.domain {
stroke-opacity: .75;
}
.axis line {
fill: none;
stroke: #000;
stroke-opacity: .25;
shape-rendering: crispEdges;
}
.axis line.zero {
stroke-opacity: .75;
}
/**********
* Line chart
*/
.point-paths path {
/*
fill: #eee;
stroke: #aaa;
*/
stroke-opacity: 0;
fill-opacity: 0;
}
.lines path {
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
transition: stroke-width 250ms linear;
-moz-transition: stroke-width 250ms linear;
-webkit-transition: stroke-width 250ms linear;
transition-delay: 250ms
-moz-transition-delay: 250ms;
-webkit-transition-delay: 250ms;
}
.line.hover path {
stroke-width: 6px;
}
.lines .point {
transition: stroke-width 250ms linear;
-moz-transition: stroke-width 250ms linear;
-webkit-transition: stroke-width 250ms linear;
}
.lines .point.hover {
stroke-width: 20px;
stroke-opacity: .5;
}
function d3Legend() {
var margin = {top: 5, right: 0, bottom: 5, left: 10},
width = 400,
height = 20,
color = d3.scale.category10().range(),
dispatch = d3.dispatch('legendClick', 'legendMouseover', 'legendMouseout');
function chart(selection) {
selection.each(function(data) {
/**
* Legend curently is setup to automaticaly expand vertically based on a max width.
* Should implement legend where EITHER a maxWidth or a maxHeight is defined, then
* the other dimension will automatically expand to fit, and anything that exceeds
* that will automatically be clipped.
**/
var wrap = d3.select(this).selectAll('g.legend').data([data]);
var gEnter = wrap.enter().append('g').attr('class', 'legend').append('g');
var g = wrap.select('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
var series = g.selectAll('.series')
.data(function(d) { return d });
var seriesEnter = series.enter().append('g').attr('class', 'series')
.on('click', function(d, i) {
dispatch.legendClick(d, i);
})
.on('mouseover', function(d, i) {
dispatch.legendMouseover(d, i);
})
.on('mouseout', function(d, i) {
dispatch.legendMouseout(d, i);
});
seriesEnter.append('circle')
.style('fill', function(d, i){ return d.color || color[i % 10] })
.style('stroke', function(d, i){ return d.color || color[i % 10] })
.attr('r', 5);
seriesEnter.append('text')
.text(function(d) { return d.label })
.attr('text-anchor', 'start')
.attr('dy', '.32em')
.attr('dx', '8');
series.classed('disabled', function(d) { return d.disabled });
series.exit().remove();
var ypos = 5,
newxpos = 5,
maxwidth = 0,
xpos;
series
.attr('transform', function(d, i) {
var length = d3.select(this).select('text').node().getComputedTextLength() + 28;
xpos = newxpos;
//TODO: 1) Make sure dot + text of every series fits horizontally, or clip text to fix
// 2) Consider making columns in line so dots line up
// --all labels same width? or just all in the same column?
// --optional, or forced always?
if (width < margin.left + margin.right + xpos + length) {
newxpos = xpos = 5;
ypos += 20;
}
newxpos += length;
if (newxpos > maxwidth) maxwidth = newxpos;
return 'translate(' + xpos + ',' + ypos + ')';
});
//position legend as far right as possible within the total width
g.attr('transform', 'translate(' + (width - margin.right - maxwidth) + ',' + margin.top + ')');
height = margin.top + margin.bottom + ypos + 15;
});
return chart;
}
chart.dispatch = dispatch;
chart.margin = function(_) {
if (!arguments.length) return margin;
margin = _;
return chart;
};
chart.width = function(_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function(_) {
if (!arguments.length) return height;
height = _;
return chart;
};
chart.color = function(_) {
if (!arguments.length) return color;
color = _;
return chart;
};
return chart;
}
function d3Line() {
var margin = {top: 0, right: 0, bottom: 0, left: 0},
width = 960,
height = 500,
dotRadius = function() { return 2.5 },
color = d3.scale.category10().range(),
id = Math.floor(Math.random() * 10000), //Create semi-unique ID incase user doesn't select one
x = d3.scale.linear(),
y = d3.scale.linear(),
dispatch = d3.dispatch("pointMouseover", "pointMouseout"),
x0, y0;
function chart(selection) {
selection.each(function(data) {
var seriesData = data.map(function(d) { return d.data });
x0 = x0 || x;
y0 = y0 || y;
//TODO: reconsider points {x: #, y: #} instead of [x,y]
//TODO: data accessors so above won't really matter, but need to decide for internal use
//add series data to each point for future ease of use
data = data.map(function(series, i) {
series.data = series.data.map(function(point) {
point.series = i;
return point;
});
return series;
});
x .domain(d3.extent(d3.merge(seriesData), function(d) { return d[0] } ))
.range([0, width - margin.left - margin.right]);
y .domain(d3.extent(d3.merge(seriesData), function(d) { return d[1] } ))
.range([height - margin.top - margin.bottom, 0]);
var vertices = d3.merge(data.map(function(line, lineIndex) {
return line.data.map(function(point, pointIndex) {
var pointKey = line.label + '-' + point[0];
return [x(point[0]), y(point[1]), lineIndex, pointIndex]; //adding series index to point because data is being flattened
})
})
);
var wrap = d3.select(this).selectAll('g.d3line').data([data]);
var gEnter = wrap.enter().append('g').attr('class', 'd3line').append('g');
gEnter.append('g').attr('class', 'lines');
gEnter.append('g').attr('class', 'point-clips');
gEnter.append('g').attr('class', 'point-paths');
var g = wrap.select('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
var voronoiClip = gEnter.append('g').attr('class', 'voronoi-clip')
.append('clipPath')
.attr('id', 'voronoi-clip-path-' + id) //this id should probably be set on update, unless ID is always set before render
.append('rect');
wrap.select('.voronoi-clip rect')
.attr('x', -10)
.attr('y', -10)
.attr('width', width - margin.left - margin.right + 20)
.attr('height', height - margin.top - margin.bottom + 20);
wrap.select('.point-paths')
.attr('clip-path', 'url(#voronoi-clip-path-' + id + ')');
//var pointClips = wrap.select('.point-clips').selectAll('clipPath') // **BROWSER BUG** can't reselect camel cased elements
var pointClips = wrap.select('.point-clips').selectAll('.clip-path')
.data(vertices);
pointClips.enter().append('clipPath').attr('class', 'clip-path')
.append('circle')
.attr('r', 25);
pointClips.exit().remove();
pointClips
.attr('id', function(d, i) { return 'clip-' + id + '-' + d[2] + '-' + d[3] })
.attr('transform', function(d) { return 'translate(' + d[0] + ',' + d[1] + ')' });
var voronoi = d3.geom.voronoi(vertices).map(function(d,i) {
return { 'data': d, 'series': vertices[i][2], 'point': vertices[i][3] }
});
//TODO: Add very small amount of noise to prevent duplicates
var pointPaths = wrap.select('.point-paths').selectAll('path')
.data(voronoi);
pointPaths.enter().append('path')
.attr('class', function(d,i) { return 'path-' + i; });
pointPaths.exit().remove();
pointPaths
.attr('clip-path', function(d) { return 'url(#clip-' + id + '-' + d.series + '-' + d.point + ')'; })
.attr('d', function(d) { return 'M' + d.data.join(',') + 'Z'; })
.on('mouseover', function(d) {
dispatch.pointMouseover({
point: data[d.series].data[d.point],
series: data[d.series],
pos: [x(data[d.series].data[d.point][0]) + margin.left, y(data[d.series].data[d.point][1]) + margin.top],
pointIndex: d.point,
seriesIndex: d.series
});
})
.on('mouseout', function(d) {
dispatch.pointMouseout({
point: d,
series: data[d.series],
pointIndex: d.point,
seriesIndex: d.series
});
});
dispatch.on('pointMouseover.point', function(d) {
wrap.select('.line-' + d.seriesIndex + ' .point-' + d.pointIndex)
.classed('hover', true);
});
dispatch.on('pointMouseout.point', function(d) {
wrap.select('.line-' + d.seriesIndex + ' .point-' + d.pointIndex)
.classed('hover', false);
});
var lines = wrap.select('.lines').selectAll('.line')
.data(function(d) { return d }, function(d) { return d.label });
lines.enter().append('g')
.style('stroke-opacity', 1e-6)
.style('fill-opacity', 1e-6);
d3.transition(lines.exit())
.style('stroke-opacity', 1e-6)
.style('fill-opacity', 1e-6)
.remove();
lines.attr('class', function(d,i) { return 'line line-' + i })
.classed('hover', function(d) { return d.hover })
.style('fill', function(d,i) { return color[i % 10] })
.style('stroke', function(d,i) { return color[i % 10] })
d3.transition(lines)
.style('stroke-opacity', 1)
.style('fill-opacity', .5);
var paths = lines.selectAll('path')
.data(function(d, i) { return [d.data] });
paths.enter().append('path')
.attr('d', d3.svg.line()
.x(function(d) { return x0(d[0]) })
.y(function(d) { return y0(d[1]) })
);
paths.exit().remove();
d3.transition(paths)
.attr('d', d3.svg.line()
.x(function(d) { return x(d[0]) })
.y(function(d) { return y(d[1]) })
);
var points = lines.selectAll('circle.point')
.data(function(d) { return d.data });
points.enter().append('circle')
.attr('cx', function(d) { return x0(d[0]) })
.attr('cy', function(d) { return y0(d[1]) });
points.exit().remove();
points.attr('class', function(d,i) { return 'point point-' + i });
d3.transition(points)
.attr('cx', function(d) { return x(d[0]) })
.attr('cy', function(d) { return y(d[1]) })
.attr('r', dotRadius());
});
x0 = x;
y0 = y;
return chart;
}
chart.dispatch = dispatch;
chart.margin = function(_) {
if (!arguments.length) return margin;
margin = _;
return chart;
};
chart.width = function(_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function(_) {
if (!arguments.length) return height;
height = _;
return chart;
};
chart.dotRadius = function(_) {
if (!arguments.length) return dotRadius;
dotRadius = d3.functor(_);
return chart;
};
chart.color = function(_) {
if (!arguments.length) return color;
color = _;
return chart;
};
chart.id = function(_) {
if (!arguments.length) return id;
id = _;
return chart;
};
return chart;
}
function d3LineWithLegend() {
var margin = {top: 30, right: 10, bottom: 40, left: 60},
width = 960,
height = 500,
dotRadius = function() { return 2.5 },
xAxisLabelText = false,
yAxisLabelText = false,
color = d3.scale.category10().range(),
dispatch = d3.dispatch('showTooltip', 'hideTooltip');
var x = d3.scale.linear(),
y = d3.scale.linear(),
xAxis = d3.svg.axis().scale(x).orient('bottom'),
yAxis = d3.svg.axis().scale(y).orient('left'),
legend = d3Legend().height(30).color(color),
lines = d3Line();
function chart(selection) {
selection.each(function(data) {
var series = data.filter(function(d) { return !d.disabled })
.map(function(d) { return d.data });
x .domain(d3.extent(d3.merge(series), function(d) { return d[0] } ))
.range([0, width - margin.left - margin.right]);
y .domain(d3.extent(d3.merge(series), function(d) { return d[1] } ))
.range([height - margin.top - margin.bottom, 0]);
lines
.width(width - margin.left - margin.right)
.height(height - margin.top - margin.bottom)
.color(data.map(function(d,i) {
return d.color || color[i % 10];
}).filter(function(d,i) { return !data[i].disabled }))
xAxis
.ticks( width / 100 )
.tickSize(-(height - margin.top - margin.bottom), 0);
yAxis
.ticks( height / 36 )
.tickSize(-(width - margin.right - margin.left), 0);
var wrap = d3.select(this).selectAll('g.wrap').data([data]);
var gEnter = wrap.enter().append('g').attr('class', 'wrap d3lineWithLegend').append('g');
gEnter.append('g').attr('class', 'legendWrap');
gEnter.append('g').attr('class', 'x axis');
gEnter.append('g').attr('class', 'y axis');
gEnter.append('g').attr('class', 'linesWrap');
legend.dispatch.on('legendClick', function(d, i) {
d.disabled = !d.disabled;
if (!data.filter(function(d) { return !d.disabled }).length) {
data.forEach(function(d) {
d.disabled = false;
});
}
selection.transition().call(chart)
});
legend.dispatch.on('legendMouseover', function(d, i) {
d.hover = true;
selection.transition().call(chart)
});
legend.dispatch.on('legendMouseout', function(d, i) {
d.hover = false;
selection.transition().call(chart)
});
lines.dispatch.on('pointMouseover.tooltip', function(e) {
dispatch.showTooltip({
point: e.point,
series: e.series,
pos: [e.pos[0] + margin.left, e.pos[1] + margin.top],
seriesIndex: e.seriesIndex,
pointIndex: e.pointIndex
});
});
lines.dispatch.on('pointMouseout.tooltip', function(e) {
dispatch.hideTooltip(e);
});
legend
.color(color)
.width(width / 2 - margin.right);
wrap.select('.legendWrap')
.datum(data)
.attr('transform', 'translate(' + (width/2 - margin.left) + ',' + (-legend.height()) +')')
.call(legend);
//TODO: maybe margins should be adjusted based on what components are used: axes, axis labels, legend
margin.top = legend.height(); //need to re-render to see update
var g = wrap.select('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
var linesWrap = wrap.select('.linesWrap')
.datum(data.filter(function(d) { return !d.disabled }));
d3.transition(linesWrap).call(lines);
var xAxisLabel = g.select('.x.axis').selectAll('text.axislabel')
.data([xAxisLabelText || null]);
xAxisLabel.enter().append('text').attr('class', 'axislabel')
.attr('text-anchor', 'middle')
.attr('x', x.range()[1] / 2)
.attr('y', margin.bottom - 20);
xAxisLabel.exit().remove();
xAxisLabel.text(function(d) { return d });
g.select('.x.axis')
.attr('transform', 'translate(0,' + y.range()[0] + ')')
.call(xAxis)
.selectAll('line.tick')
.filter(function(d) { return !d })
.classed('zero', true);
var yAxisLabel = g.select('.y.axis').selectAll('text.axislabel')
.data([yAxisLabelText || null]);
yAxisLabel.enter().append('text').attr('class', 'axislabel')
.attr('transform', 'rotate(-90)')
.attr('text-anchor', 'middle')
.attr('y', 20 - margin.left);
yAxisLabel.exit().remove();
yAxisLabel
.attr('x', -y.range()[0] / 2)
.text(function(d) { return d });
g.select('.y.axis')
.call(yAxis)
.selectAll('line.tick')
.filter(function(d) { return !d })
.classed('zero', true);
});
return chart;
}
chart.dispatch = dispatch;
chart.margin = function(_) {
if (!arguments.length) return margin;
margin = _;
return chart;
};
chart.width = function(_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function(_) {
if (!arguments.length) return height;
height = _;
return chart;
};
chart.color = function(_) {
if (!arguments.length) return color;
color = _;
return chart;
};
chart.dotRadius = function(_) {
if (!arguments.length) return dotRadius;
dotRadius = d3.functor(_);
lines.dotRadius = d3.functor(_);
return chart;
};
//TODO: consider directly exposing both axes
//chart.xAxis = xAxis;
//Expose the x-axis' tickFormat method.
chart.xAxis = {};
d3.rebind(chart.xAxis, xAxis, 'tickFormat');
chart.xAxis.label = function(_) {
if (!arguments.length) return xAxisLabelText;
xAxisLabelText = _;
return chart;
}
// Expose the y-axis' tickFormat method.
//chart.yAxis = yAxis;
chart.yAxis = {};
d3.rebind(chart.yAxis, yAxis, 'tickFormat');
chart.yAxis.label = function(_) {
if (!arguments.length) return yAxisLabelText;
yAxisLabelText = _;
return chart;
}
return chart;
}
<!DOCTYPE html>
<meta charset="utf-8">
<link href="d3.css" rel="stylesheet" type="text/css">
<style>
#test1 {
margin: 0;
padding: 0;
overflow: none;
}
</style>
<body>
<div id="test1">
<svg></svg>
</div>
<script src="http://mbostock.github.com/d3/d3.v2.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script>
<script src="nvtooltip.js"></script>
<script src="d3legend.js"></script>
<script src="d3line.js"></script>
<script src="d3linewithlegend.js"></script>
<script>
function log(text) {
if (console && console.log) console.log(text);
return text;
}
$(document).ready(function() {
var margin = {top: 30, right: 10, bottom: 50, left: 60},
chart = d3LineWithLegend()
.xAxis.label('Time (ms)')
.width(width(margin))
.height(height(margin))
.yAxis.label('Voltage (v)');
var svg = d3.select('#test1 svg')
.datum(generateData())
svg.transition().duration(500)
.attr('width', width(margin))
.attr('height', height(margin))
.call(chart);
chart.dispatch.on('showTooltip', function(e) {
var offset = $('#test1').offset(), // { left: 0, top: 0 }
left = e.pos[0] + offset.left,
top = e.pos[1] + offset.top,
formatter = d3.format(".04f");
var content = '<h3>' + e.series.label + '</h3>' +
'<p>' +
'<span class="value">[' + e.point[0] + ', ' + formatter(e.point[1]) + ']</span>' +
'</p>';
nvtooltip.show([left, top], content);
});
chart.dispatch.on('hideTooltip', function(e) {
nvtooltip.cleanup();
});
$(window).resize(function() {
var margin = chart.margin();
chart
.width(width(margin))
.height(height(margin));
d3.select('#test1 svg')
.attr('width', width(margin))
.attr('height', height(margin))
.call(chart);
});
function width(margin) {
var w = $(window).width() - 20;
return ( (w - margin.left - margin.right - 20) < 0 ) ? margin.left + margin.right + 2 : w;
}
function height(margin) {
var h = $(window).height() - 20;
return ( h - margin.top - margin.bottom - 20 < 0 ) ?
margin.top + margin.bottom + 2 : h;
}
//data
function generateData() {
var sin = [],
sin2 = [],
cos = [],
cos2 = [],
r1 = Math.random(),
r2 = Math.random(),
r3 = Math.random(),
r4 = Math.random();
for (var i = 0; i < 100; i++) {
sin.push([ i, r1 * Math.sin( r2 + i / (10 * (r4 + .5) ))]);
cos.push([ i, r2 * Math.cos( r3 + i / (10 * (r3 + .5) ))]);
sin2.push([ i, r3 * Math.sin( r1 + i / (10 * (r2 + .5) ))]);
cos2.push([ i, r4 * Math.cos( r4 + i / (10 * (r1 + .5) ))]);
}
return [
{
data: sin,
label: "Sine Wave"
},
{
data: cos,
label: "Cosine Wave"
},
{
data: sin2,
label: "Sine2 Wave"
},
{
data: cos2,
label: "Cosine2 Wave"
}
];
}
});
</script>
/*****
* Really simple tooltip implementation.
* I may build upon it, but really trying to keep it minimal.
*****/
(function($) {
var nvtooltip = window.nvtooltip = {};
nvtooltip.show = function(pos, content, gravity, dist) {
var container = $('<div class="nvtooltip">');
gravity = gravity || 's';
dist = dist || 20;
container
.html(content)
.css({left: -1000, top: -1000, opacity: 0})
.appendTo('body');
var height = container.height() + parseInt(container.css('padding-top')) + parseInt(container.css('padding-bottom')),
width = container.width() + parseInt(container.css('padding-left')) + parseInt(container.css('padding-right')),
windowWidth = $(window).width(),
windowHeight = $(window).height(),
scrollTop = $('body').scrollTop(), //TODO: also adjust horizontal scroll
left, top;
//TODO: implement other gravities
switch (gravity) {
case 'e':
case 'w':
case 'n':
left = pos[0] - (width / 2);
top = pos[1] + dist;
if (left < 0) left = 5;
if (left + width > windowWidth) left = windowWidth - width - 5;
if (scrollTop + windowHeight < top + height) top = pos[1] - height - dist;
break;
case 's':
left = pos[0] - (width / 2);
top = pos[1] - height - dist;
if (left < 0) left = 5;
if (left + width > windowWidth) left = windowWidth - width - 5;
if (scrollTop > top) top = pos[1] + dist;
break;
}
container
.css({
left: left,
top: top,
opacity: 1
});
};
nvtooltip.cleanup = function() {
var tooltips = $('.nvtooltip');
// remove right away, but delay the show with css
tooltips.css({
'transition-delay': '0 !important',
'-moz-transition-delay': '0 !important',
'-webkit-transition-delay': '0 !important'
});
tooltips.css('opacity',0);
setTimeout(function() {
tooltips.remove()
}, 500);
};
})(jQuery);
@pdwinkel

This comment has been minimized.

Show comment
Hide comment
@pdwinkel

pdwinkel Apr 3, 2013

Looks good!
I tried your gist with the latest D3 version 3, but it gives a type error:

Uncaught TypeError: Cannot read property '1' of undefined

when you put the mouse over the legend.
Can you make this gist also for D3 version 3?

pdwinkel commented Apr 3, 2013

Looks good!
I tried your gist with the latest D3 version 3, but it gives a type error:

Uncaught TypeError: Cannot read property '1' of undefined

when you put the mouse over the legend.
Can you make this gist also for D3 version 3?

@karmiphuc

This comment has been minimized.

Show comment
Hide comment
@karmiphuc

karmiphuc Apr 12, 2013

Really amazing! But as a novice of D3, I find it impossible to replace all the transition functions from d3.v2 to d3.v3.
Please rewrite this into d3.v3.

Anyway, thank you for your beautiful and reusable charts.

Really amazing! But as a novice of D3, I find it impossible to replace all the transition functions from d3.v2 to d3.v3.
Please rewrite this into d3.v3.

Anyway, thank you for your beautiful and reusable charts.

@kikz4life

This comment has been minimized.

Show comment
Hide comment
@kikz4life

kikz4life May 3, 2013

hi, your work is really great. I have a question, how do you convert the yAxis result to percentage value?

hi, your work is really great. I have a question, how do you convert the yAxis result to percentage value?

@charleszheng44

This comment has been minimized.

Show comment
Hide comment
@charleszheng44

charleszheng44 Jul 12, 2013

awesome. really great piece of code

awesome. really great piece of code

@pragyajha

This comment has been minimized.

Show comment
Hide comment
@pragyajha

pragyajha Feb 20, 2017

Thanks for this amazing piece of code! I have reused your code and works great. But i am facing a problem when i try to pass x-axis data in string, but works perfectly for number. Does x axis need to be integer for plotting this chart?

Thanks for this amazing piece of code! I have reused your code and works great. But i am facing a problem when i try to pass x-axis data in string, but works perfectly for number. Does x axis need to be integer for plotting this chart?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment