I have this zoomable heatmap, which looks too slow when zooming-in or out. Is there anything to make it faster/smoother or it is just too many points and that is the best I can have. I was wondering if there is some trick to make it lighter for the browser please while keeping enhancements like tooltips. Or maybe my code handling the zoom feature is not great .
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
}
.x.axis path {
//display: none;
}
.chart rect {
fill: steelblue;
}
.chart text {
fill: white;
font: 10px sans-serif;
text-anchor: end;
}
#tooltip {
position:absolute;
background-color: #2B292E;
color: white;
font-family: sans-serif;
font-size: 15px;
pointer-events: none; /*dont trigger events on the tooltip*/
padding: 15px 20px 10px 20px;
text-align: center;
opacity: 0;
border-radius: 4px;
}
</style>
<title>Bar Chart</title>
<!-- Reference style.css -->
<!-- <link rel="stylesheet" type="text/css" href="style.css">-->
<!-- Reference minified version of D3 -->
<script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
</head>
<body>
<div id="chart" style="width: 700px; height: 500px"></div>
<script>
var dataset = [];
for (let i = 1; i < 360; i++) {
for (j = 1; j < 75; j++) {
dataset.push({
day: i,
hour: j,
tOutC: Math.random() * 25,
})
}
};
var days = d3.max(dataset, function(d) {
return d.day;
}) -
d3.min(dataset, function(d) {
return d.day;
});
var hours = d3.max(dataset, function(d) {
return d.hour;
}) -
d3.min(dataset, function(d) {
return d.hour;
});
var tMin = d3.min(dataset, function(d) {
return d.tOutC;
}),
tMax = d3.max(dataset, function(d) {
return d.tOutC;
});
var dotWidth = 1,
dotHeight = 3,
dotSpacing = 0.5;
var margin = {
top: 0,
right: 25,
bottom: 40,
left: 25
},
width = (dotWidth * 2 + dotSpacing) * days,
height = (dotHeight * 2 + dotSpacing) * hours;
var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];
var xScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.day}))
.range([0, width]);
var yScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.hour}))
.range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]);
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) {
return d.tOutC;
})])
.range(colors);
var xAxis = d3.axisBottom().scale(xScale);
// Define Y axis
var yAxis = d3.axisLeft().scale(yScale);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([
[80, 20],
[width, height]
])
.on("zoom", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG canvas
var svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// Clip path
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
// Heatmap dots
svg.append("g")
.attr("clip-path", "url(#clip)")
.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) {
return xScale(d.day);
})
.attr("cy", function(d) {
return yScale(d.hour);
})
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) {
return colorScale(d.tOutC);
})
.on("mouseover", function(d){
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}).on("mouseout", function(){
$("#tooltip").animate({duration: 500}).css("opacity",0);
});
//Create X axis
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis)
//Create Y axis
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(yAxis);
function zoomed() {
// update: rescale x axis
renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
update();
}
function update() {
// update: cache rescaleX value
var rescaleX = d3.event.transform.rescaleX(xScale);
svg.selectAll("ellipse")
.attr('clip-path', 'url(#clip)')
// update: apply rescaleX value
.attr("cx", function(d) {
return rescaleX(d.day);
})
// .attr("cy", function(d) {
// return yScale(d.hour);
// })
// update: apply rescaleX value
.attr("rx", function(d) {
return (dotWidth * d3.event.transform.k);
})
.attr("fill", function(d) {
return colorScale(d.tOutC);
});
}
</script>
</body>
</html>
Thanks
The solution is not to update all the dots for the zoom but to apply the zoom transform to the group containing the dots.
Clipping of the group needs to be done on an additional parent g heatDotsGroup.
The zoom scale of y is taken care of (set it fixed to 1) with a regex replace, limit translate in y by setting the transform.y to 0, and limit the translate of x based on the current scale.
Allow a little translate past 0 to show the first dot complete when zoomed in.
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.on("zoom", zoomed);
// Heatmap dots
var heatDotsGroup = svg.append("g")
.attr("clip-path", "url(#clip)")
.append("g");
heatDotsGroup.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) { return xScale(d.day); })
.attr("cy", function(d) { return yScale(d.hour); })
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) { return colorScale(d.tOutC); })
.on("mouseover", function(d){
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}).on("mouseout", function(){
$("#tooltip").animate({duration: 500}).css("opacity",0);
});
function zoomed() {
d3.event.transform.y = 0;
d3.event.transform.x = Math.min(d3.event.transform.x, 5);
d3.event.transform.x = Math.max(d3.event.transform.x, (1-d3.event.transform.k) * width );
// update: rescale x axis
renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
heatDotsGroup.attr("transform", d3.event.transform.toString().replace(/scale\((.*?)\)/, "scale($1, 1)"));
}
Try Canvas
You have 27 000 nodes. This is probably around the point where SVG performance drops off for most and Canvas starts to really shine. Sure, Canvas isn't stateful like SVG, its just pixels with no nice elements to mouse over in the DOM and tell you where and what they are. But, there are ways to address this shortcoming so that we can retain speed and interactive abilities.
For the initial rendering using your snippet, I have a average rendering time of ~440ms. But, through the magic of canvas, I can render the same heat map with an average rendering time of ~103ms. Those savings can be applied to things like zooming, animation etc.
For very small things like your ellipses there is a risk of aliasing issues that is harder to fix with canvas as opposed to SVG, though how each browser renders this will differ
Design Implications
With Canvas we can retain the enter/exit/update cycle as with SVG, but we also have the option of dropping it. At times the enter/exit/update cycle pairs extremely well with canvas: transitions, dynamic data, heirarcical data, etc. I have previously spent some time on some of the higher level differences between Canvas and SVG with regards to D3 here.
For my answer here, we'll leave the enter cycle. When we want to update the visualization we just redraw everything based on the data array itself.
Drawing the Heat Map
I'm using rectangles for the sake of brevity. Canvas's ellipse method isn't quite ready, but you can emulate it easily enough.
We need a function that draws the dataset. If you had x/y/color hard coded into the dataset we could use a very simple:
function drawNodes()
dataset.forEach(function(d) {
ctx.beginPath();
ctx.rect(d.x,d.y,width,height);
ctx.fillStyle = d.color;
ctx.fill();
})
}
But we need to scale your values, calculate a color, and we should apply the zoom. I ended up with a relatively simple:
function drawNodes()
var k = d3.event ? d3.event.transform.k : 1;
var dw = dotWidth * k;
ctx.clearRect(0,0,width,height); // erase what's there
dataset.forEach(function(d) {
var x = xScale(d.day);
var y = yScale(d.hour);
var fill = colorScale(d.tOutC);
ctx.beginPath();
ctx.rect(x,y,dw,dotHeight);
ctx.fillStyle = fill;
ctx.strokeStyle = fill;
ctx.stroke();
ctx.fill();
})
}
This can be used to initially draw the nodes (when d3.event isn't defined), or on zoom/pan events (after which this function is called each time).
What about the axes?
d3-axis is intended for SVG. So, I've just superimposed an SVG overtop of a Canvas element positioning both absolutely and disabling mouse events on the overlying SVG.
Speaking of axes, I only have one drawing function (no difference between update/initial drawing), so I use a reference x scale and a rendering x scale from the get go, rather than creating a disposable rescaled x scale in the update function
Now I Have a Canvas, How Do I Interact With It?
There are a few methods we could use take a pixel position and convert it to a specific datum:
Use a Voronoi diagram (using the .find method to locate a datum)
Use a Force layout (also using the .find method to locate a datum)
Use a hidden Canvas (using pixel color to indicate datum index)
Use a scale's invert function (when data is gridded)
The third option may be one of the most common, and while the first two look similar the find methods do differ internally (voronoi neighbors vs quad tree). The last method is fairly appropriate in this case: we have a grid of data and we can invert the mouse coordinate to get row and column data. Based on your snippet that might look like:
function mousemove() {
var xy = d3.mouse(this);
var x = Math.round(xScale.invert(xy[0]));
var y = Math.round(yScale.invert(xy[1]));
// For rounding on canvas edges:
if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
if(y > yScale.domain()[1]) y = yScale.domain()[1];
if(y < yScale.domain()[0]) y = yScale.domain()[0];
var index = --x*74 + y-1; // minus ones for non zero indexed x,y values.
var d = dataset[index];
console.log(x,y,index,d)
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}
*I've used mousemove since mouseover will trigger once when moving over the canvas, we need to continuously update, if we wanted to hide the tooltip, we could just check to see if the pixel selected is white:
var p = ctx.getImageData(xy[0], xy[1], 1, 1).data; // pixel data:
if (!p[0] && !p[1] && !p[2]) { /* show tooltip */ }
else { /* hide tooltip */ }
Example
I've explicitly mentioned most of the changes above, but I've made some additional changes below. First, I need to select the canvas, position it, get the context, etc. I also have swapped rects for ellipses, so the positioning is a bit different (but you have other positioning issues to from using a linear scale (the ellipse centroids can fall on the edge of the svg as is), I've not modified this to account for the width/height of the ellipses/rects. This scale issue was far enough from the question that I didn't modify it.
var dataset = [];
for (let i = 1; i < 360; i++) {
for (j = 1; j < 75; j++) {
dataset.push({
day: i,
hour: j,
tOutC: Math.random() * 25,
})
}
};
var days = d3.max(dataset, function(d) { return d.day; }) - d3.min(dataset, function(d) { return d.day; });
var hours = d3.max(dataset, function(d) { return d.hour; }) - d3.min(dataset, function(d) { return d.hour; });
var tMin = d3.min(dataset, function(d) { return d.tOutC; }), tMax = d3.max(dataset, function(d) { return d.tOutC; });
var dotWidth = 1,
dotHeight = 3,
dotSpacing = 0.5;
var margin = { top: 20, right: 25, bottom: 40, left: 25 },
width = (dotWidth * 2 + dotSpacing) * days,
height = (dotHeight * 2 + dotSpacing) * hours;
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];
var xScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.day}))
.range([0, width]);
var xScaleRef = xScale.copy();
var yScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.hour}))
.range([height,0]);
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) { return d.tOutC; })])
.range(colors);
var xAxis = d3.axisBottom().scale(xScale);
var yAxis = d3.axisLeft().scale(yScale);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([
[0,0],
[width, height]
])
.on("zoom", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG & Canvas:
var canvas = d3.select("#chart")
.append("canvas")
.attr("width", width)
.attr("height", height)
.style("left", margin.left + "px")
.style("top", margin.top + "px")
.style("position","absolute")
.on("mousemove", mousemove)
.on("mouseout", mouseout);
var svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform","translate("+[margin.left,margin.top]+")");
var ctx = canvas.node().getContext("2d");
canvas.call(zoom);
// Initial Draw:
drawNodes(dataset);
//Create Axes:
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis)
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(yAxis);
// Handle Zoom:
function zoomed() {
// rescale the x Axis:
xScale = d3.event.transform.rescaleX(xScaleRef); // Use Reference Scale.
// Redraw the x Axis:
renderXAxis.call(xAxis.scale(xScale));
// Clear and redraw the nodes:
drawNodes();
}
// Draw nodes:
function drawNodes() {
var k = d3.event ? d3.event.transform.k : 1;
var dw = dotWidth * k;
ctx.clearRect(0,0,width,height);
dataset.forEach(function(d) {
var x = xScale(d.day);
var y = yScale(d.hour);
var fill = colorScale(d.tOutC);
ctx.beginPath();
ctx.rect(x,y,dw,dotHeight);
ctx.fillStyle = fill;
ctx.strokeStyle = fill;
ctx.stroke();
ctx.fill();
})
}
// Mouse movement:
function mousemove() {
var xy = d3.mouse(this);
var x = Math.round(xScale.invert(xy[0]));
var y = Math.round(yScale.invert(xy[1]));
if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
if(y > yScale.domain()[1]) y = yScale.domain()[1];
if(y < yScale.domain()[0]) y = yScale.domain()[0];
var index = --x*74 + y-1; // minus ones for non zero indexed x,y values.
var d = dataset[index];
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}
function mouseout() {
$("#tooltip").animate({duration: 500}).css("opacity",0);
};
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
}
.x.axis path {
//display: none;
}
.chart rect {
fill: steelblue;
}
.chart text {
fill: white;
font: 10px sans-serif;
text-anchor: end;
}
#tooltip {
position:absolute;
background-color: #2B292E;
color: white;
font-family: sans-serif;
font-size: 15px;
pointer-events: none; /*dont trigger events on the tooltip*/
padding: 15px 20px 10px 20px;
text-align: center;
opacity: 0;
border-radius: 4px;
}
svg {
position: absolute;
top: 0;
left:0;
pointer-events: none;
}
<script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
<div id="chart" style="width: 700px; height: 500px"></div>
The result of all following combined suggestions is not perfect, but it is subjectively slightly better:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
}
.x.axis path {
//display: none;
}
.chart rect {
fill: steelblue;
}
.chart text {
fill: white;
font: 10px sans-serif;
text-anchor: end;
}
#tooltip {
position:absolute;
background-color: #2B292E;
color: white;
font-family: sans-serif;
font-size: 15px;
pointer-events: none; /*dont trigger events on the tooltip*/
padding: 15px 20px 10px 20px;
text-align: center;
opacity: 0;
border-radius: 4px;
}
</style>
<title>Bar Chart</title>
<!-- Reference style.css -->
<!-- <link rel="stylesheet" type="text/css" href="style.css">-->
<!-- Reference minified version of D3 -->
<script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
</head>
<body>
<div id="chart" style="width: 700px; height: 500px"></div>
<script>
var dataset = [];
for (let i = 1; i < 360; i++) {
for (j = 1; j < 75; j++) {
dataset.push({
day: i,
hour: j,
tOutC: Math.random() * 25,
})
}
};
var days = d3.max(dataset, function(d) {
return d.day;
}) -
d3.min(dataset, function(d) {
return d.day;
});
var hours = d3.max(dataset, function(d) {
return d.hour;
}) -
d3.min(dataset, function(d) {
return d.hour;
});
var tMin = d3.min(dataset, function(d) {
return d.tOutC;
}),
tMax = d3.max(dataset, function(d) {
return d.tOutC;
});
var dotWidth = 1,
dotHeight = 3,
dotSpacing = 0.5;
var margin = {
top: 0,
right: 25,
bottom: 40,
left: 25
},
width = (dotWidth * 2 + dotSpacing) * days,
height = (dotHeight * 2 + dotSpacing) * hours;
var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];
var xScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.day}))
.range([0, width]);
var yScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.hour}))
.range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]);
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) {
return d.tOutC;
})])
.range(colors);
var xAxis = d3.axisBottom().scale(xScale);
// Define Y axis
var yAxis = d3.axisLeft().scale(yScale);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([
[80, 20],
[width, height]
])
// .on("zoom", zoomed);
.on("end", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG canvas
var svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// Clip path
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
// Heatmap dots
svg.append("g")
.attr("clip-path", "url(#clip)")
.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) {
return xScale(d.day);
})
.attr("cy", function(d) {
return yScale(d.hour);
})
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) {
return colorScale(d.tOutC);
})
.on("mouseover", function(d){
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}).on("mouseout", function(){
$("#tooltip").animate({duration: 500}).css("opacity",0);
});
//Create X axis
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis)
//Create Y axis
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(yAxis);
function zoomed() {
// update: rescale x axis
renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
update();
}
function update() {
// update: cache rescaleX value
var rescaleX = d3.event.transform.rescaleX(xScale);
var scaledRadius = dotWidth * d3.event.transform.k;
var scaledCxes = [...Array(360).keys()].map(i => rescaleX(i));
svg.selectAll("ellipse")
// .attr('clip-path', 'url(#clip)')
// update: apply rescaleX value
.attr("cx", d => scaledCxes[d.day])
// .attr("cy", function(d) {
// return yScale(d.hour);
// })
// update: apply rescaleX value
.attr("rx", scaledRadius)
// .attr("fill", function(d) {
// return colorScale(d.tOutC);
// });
}
</script>
</body>
</html>
Using on("end", zoomed) instead of on("zoom", zoomed):
First thing we can try is to activate the zoom change only at the end of the zoom event in order not to have these non deterministic updates jumps during a single zoom event. It has for effect to lower the required processing as only one computation happens, and it removes the global jump discomfort:
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([ [80, 20], [width, height] ])
.on("end", zoomed); // instead of .on("zoom", zoomed);
Remove updates of things which remains the same during the zoom:
We can also remove from the nodes update things which stay the same such as the color of a circle which during the zoom remains the same anyway .attr("fill", function(d) { return colorScale(d.tOutC); }); and .attr('clip-path', 'url(#clip)').
Computing only once things used several times:
The new circle radius after the zoom can only be computed once instead of 27K times as it's the same for all circles:
var scaledRadius = dotWidth * d3.event.transform.k;
.attr("rx", scaledRadius)
Same for x positions, we can compute it once per possible x value (360 times) and store it in an array to access them in constant time instead of computing it 27K times:
var scaledCxes = [...Array(360).keys()].map(i => rescaleX(i));
.attr("cx", d => scaledCxes[d.day])
Last obvious option would be to reduce the number of nodes since it's the root of the issue!
If the zoom extent would have been bigger, I would have also suggested filtering nodes not visible anymore.
Do check LightningChart JS heatmaps - it's free to use non-commercially.
Here is a performance comparison of best performing heatmap web charts https://github.com/Arction/javascript-charts-performance-comparison-heatmaps
As you can see over there we are talking about visualizing heatmaps that are in range of billions of data points and user interactions still work just fine.
// Source https://www.arction.com/lightningchart-js-interactive-examples/edit/lcjs-example-0800-heatmapGrid.html
/*
* LightningChartJS example that showcases a simple XY line series.
*/
// Extract required parts from LightningChartJS.
const { lightningChart, PalettedFill, LUT, ColorRGBA, emptyLine, Themes } =
lcjs;
const { createWaterDropDataGenerator } = xydata;
// Specify the resolution used for the heatmap.
const resolutionX = 1000;
const resolutionY = 1000;
// Create a XY Chart.
const chart = lightningChart()
.ChartXY({
// theme: Themes.darkGold
})
.setTitle(
`Heatmap Grid Series ${resolutionX}x${resolutionY} (${(
(resolutionX * resolutionY) /
1000000
).toFixed(1)} million data points)`
)
.setPadding({ right: 40 });
// Create LUT and FillStyle
const palette = new LUT({
units: "intensity",
steps: [
{ value: 0, color: ColorRGBA(255, 255, 0) },
{ value: 30, color: ColorRGBA(255, 204, 0) },
{ value: 45, color: ColorRGBA(255, 128, 0) },
{ value: 60, color: ColorRGBA(255, 0, 0) },
],
interpolate: false,
});
// Generate heatmap data.
createWaterDropDataGenerator()
.setRows(resolutionX)
.setColumns(resolutionY)
.generate()
.then((data) => {
// Add a Heatmap to the Chart.
const heatmap = chart
.addHeatmapGridSeries({
columns: resolutionX,
rows: resolutionY,
start: { x: 0, y: 0 },
end: { x: resolutionX, y: resolutionY },
dataOrder: "columns",
})
// Color Heatmap using previously created color look up table.
.setFillStyle(new PalettedFill({ lut: palette }))
.setWireframeStyle(emptyLine)
.invalidateIntensityValues(data)
.setMouseInteractions(false);
// Add LegendBox.
const legend = chart.addLegendBox()
// Dispose example UI elements automatically if they take too much space. This is to avoid bad UI on mobile / etc. devices.
.setAutoDispose({
type: 'max-height',
maxHeight: 0.70,
})
.add(chart)
});
<script src="http://unpkg.com/#arction/lcjs#3.1.0/dist/lcjs.iife.js"></script>
<script src="http://unpkg.com/#arction/xydata#1.4.0/dist/xydata.iife.js"></script>
I am trying to add a tooltip for my dual line chart graph.
However, instead of using timeScale or scaleLinear, I used scalePoint to graph my chart.
I am trying to achieve the following effect:
https://bl.ocks.org/mbostock/3902569
this.x = d3.scalePoint().range([ this.margin.left, this.width - this.margin.right ]);
this.xAxis = d3.axisBottom(this.x);
this.x.domain(
this.dataArray.map(d => {
return this.format(d[ 'year' ]);
}));
Here is my mouseover function,
function mousemove() {
//d3.mouse(this)[ 0 ]
//x.invert
var x0 = d3.mouse(this)[ 0 ],
i = bisectDate(data, x0, 1),
d0 = data[ i - 1 ],
d1 = data[ i ],
d = x0 - d0.year > d1.year - x0 ? d1 : d0;
console.log(x0);
// focus.attr("transform", "translate(" + x(format(d.year)) + "," + y(d.housing_index_change) + ")");
// focus.select("text").text(d.housing_index_change);
}
Since I am using scalePoint, there is obviously no invert function to map the coordinates to my data. and I am only retrieving the first element in the array and it is the only one that is being display regardless of the position of the mouse.
So my question is, how can I implement the invert functionality here while still using scalePoint?
Thank you :)
You are right, there is no invert for a point scale. But you can create your own function to get the corresponding domain of a given x position:
function scalePointPosition() {
var xPos = d3.mouse(this)[0];
var domain = xScale.domain();
var range = xScale.range();
var rangePoints = d3.range(range[0], range[1], xScale.step())
var yPos = domain[d3.bisect(rangePoints, xPos) -1];
console.log(yPos);
}
Step by step explanation
First, we get the x mouse position.
var xPos = d3.mouse(this)[0];
Then, based on your scale's range and domain...
var domain = xScale.domain();
var range = xScale.range();
...we create an array with all the steps in the point scale using d3.range:
var rangePoints = d3.range(range[0], range[1], xScale.step())
Finally, we get the corresponding domain using bisect:
var yPos = domain[d3.bisect(rangePoints, xPos) -1];
Check the console.log in this demo:
var data = [{
A: "groupA",
B: 10
}, {
A: "groupB",
B: 20
}, {
A: "groupC",
B: 30
}, {
A: "groupD",
B: 10
}, {
A: "groupE",
B: 17
}]
var width = 500,
height = 200;
var svg = d3.selectAll("body")
.append("svg")
.attr("width", width)
.attr("height", height);
var color = d3.scaleOrdinal(d3.schemeCategory10)
.domain(data.map(function(d) {
return d.A
}));
var xScale = d3.scalePoint()
.domain(data.map(function(d) {
return d.A
}))
.range([50, width - 50])
.padding(0.5);
var yScale = d3.scaleLinear()
.domain([0, d3.max(data, function(d) {
return d.B
}) * 1.1])
.range([height - 50, 10]);
var circles = svg.selectAll(".circles")
.data(data)
.enter()
.append("circle")
.attr("r", 8)
.attr("cx", function(d) {
return xScale(d.A)
})
.attr("cy", function(d) {
return yScale(d.B)
})
.attr("fill", function(d) {
return color(d.A)
});
var xAxis = d3.axisBottom(xScale);
var yAxis = d3.axisLeft(yScale);
svg.append("g").attr("transform", "translate(0,150)")
.attr("class", "xAxis")
.call(xAxis);
svg.append("g")
.attr("transform", "translate(50,0)")
.attr("class", "yAxis")
.call(yAxis);
svg.append("rect")
.attr("opacity", 0)
.attr("x", 50)
.attr("width", width - 50)
.attr("height", height)
.on("mousemove", scalePointPosition);
function scalePointPosition() {
var xPos = d3.mouse(this)[0];
var domain = xScale.domain();
var range = xScale.range();
var rangePoints = d3.range(range[0], range[1], xScale.step())
var yPos = domain[d3.bisect(rangePoints, xPos) - 1];
console.log(yPos);
}
.as-console-wrapper { max-height: 20% !important;}
<script src="https://d3js.org/d3.v4.min.js"></script>
I am making an interactive version of this infographic
This is proving difficult in D3. There will be multiple ordinal scales:
One Y scale for each country
One Y scale for each period in each country
One X scale
For each period, there will be two values (visualized as two different colored bars). The main problem is that not all countries will have three periods; the data does not take a completely even shape. This is to say that some segments of the first Y scale will have different heights. I'm not sure how to take care of this.
Suppose I have this data:
CountryName Period ValueA ValueB
Argentina 2004-2008 12 5
Argentina 2004-2013 10 5
Argentina 2008-2013 8 4
Bolivia 2002-2008 4 2
Bolivia 2002-2013 6 18
Brazil 2003-2008 9 2
Brazil 2003-2013 2 19
Brazil 2008-2013 1 3
And I use the d3.nest() function:
d3.nest()
.key(function(d) { return d.CountryName; })
.key(function(d) { return d.Period; })
.map(data);
Now I'll have the data in the form that I want it, but note that there is some missing data - Bolivia only has two periods of data, in this case. I have created a JSFiddle for this, but as you can see, it has some problems. The height of the bars should be the same, always. I want to use yScale.rangeBand(), but the problem is that some countries will have three periods, where some will only have two. I also need to find a way to display the country names and periods to the left of the bars
If anybody has a better means of approaching this problem, please let me know. I have been struggling for this for a couple of days. As you can see from the JSFiddle, I only have one yScale but I'm sure it's preferable to use two given my situation - I do not know how to implement this.
Any and all help is greatly appreciated
You may not really need the nesting.
This is a special case bar chart so you will need to make axis' bars ticks grids all by yourself.
I have added comments in the code for you to follow
var outerWidth = 1000;
var outerHeight = 500;
var margin = {
left: 100,
top: 0,
right: 100,
bottom: 90
};
var barPadding = 0.6;
var barPaddingOuter = 0.3;
var innerWidth = outerWidth - margin.left - margin.right;
var innerHeight = outerHeight - margin.top - margin.bottom;
//make your svg
var svg = d3.select("body").append("svg")
.attr("width", outerWidth)
.attr("height", outerHeight);
var g = svg.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + 30 + ")");
//the axis is on the right
var xAxisG = g.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + 10 + ")")
//the y axis is common for all
var yAxisG = g.append("g")
.attr("class", "y axis");
var xScale = d3.scale.linear().range([0, innerWidth]);
var yScale = d3.scale.ordinal().rangeRoundBands([0, innerHeight], barPadding, barPaddingOuter);
var yAxis = d3.svg.axis().scale(yScale).orient("left")
.outerTickSize(0);
function render(data) {
var xAxis = d3.svg.axis().scale(xScale).orient("top")
//.tickValues([-5, -4, -3, 0, 3, 5, 10, 15, 20, 25])
.outerTickSize(0)
.innerTickSize(-1 * outerHeight);
xScale.domain([0, 30]);
//this will make the y axis labels.
var country = "";
data.forEach(function(d) {
if (d.CountryName == country) {
d.label = d.Period
} else {
d.label = d.Period
d.heading = d.CountryName;
}
country = d.CountryName;
});
var labels = data.map(function(d) {
return d.label
});
//set the domain for all y labels,
yScale.domain(labels);
//makes the x Axis
xAxisG.call(xAxis);
//makes the y Axis
yAxisG.call(yAxis);
//make the bar chart for valueB
var bars = g.selectAll(".ValueA").data(data);
bars.enter().append("rect")
.attr("height", yScale.rangeBand() / 2);
bars
.attr("x", function(d) {
return xScale(0)
})
.attr("y", function(d) {
return yScale(d.label);
})
.attr("width", function(d) {
console.log(d.ValueA)
return xScale(d.ValueA);
})
.style("fill", "red")
.attr("class", "ValueA");
//make the bar chart for valueB
var bars = g.selectAll(".ValueB").data(data);
bars.enter().append("rect")
.attr("height", yScale.rangeBand() / 2);
bars
.attr("x", function(d) {
return xScale(0)
})
.attr("y", function(d) {
return yScale.rangeBand() / 2 + yScale(d.label);
})
.attr("width", function(d) {
return xScale(d.ValueB);
})
.style("fill", "blue")
.attr("class", "ValueA");
//make grid lines
var lines = g.selectAll(".xLine").data(data.filter(function(d){return !(d.heading == undefined) }));
lines.enter().append("line")
lines
.attr("x1", 0)
.attr("y1", function(d) {
return (yScale(d.label) - yScale.rangeBand())
})
.attr("x2", innerWidth)
.attr("y2", function(d) {
return (yScale(d.label) - yScale.rangeBand())
})
.style("stroke", "blue")
.attr("class", "xLine")
.style("display", function(s) {
if((yScale(s.label) - yScale.rangeBand()) < 0){
return "none";//not show grids when y comes negative
}
});
//make heading
var headings = g.selectAll(".heading").data(data.filter(function(d){return !(d.heading == undefined) }));
headings.enter().append("text")
.text(function(d){return d.heading})
.attr("x", -100)
.attr("y", function(d) {
return (yScale(d.label) - yScale.rangeBand()) +30
})
}
function type(d) {
d.ValueA = +d.ValueA;
d.ValueB = +d.ValueB;
console.log(d)
return d;
} d3.csv("https://gist.githubusercontent.com/cyrilcherian/e2dff83329af684cde78/raw/f85ce83497553c360d2af5d5dcf269390c44022f/cities.csv", type, render);
.tick line {
opacity: 0.2;
}
.xLine {
opacity: 0.2;
}
.axis text {
font-size: 10px;
}
.axis .label {}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.y.axis path,
.y.axis line {
stroke: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
Hope this helps!
I am having an issue with d3.js when I try to zoom in and out on a graph. The zoom is very slow and laggy. I am trying to debug by using the profiling tool (Opera/Chrome). I was expecting my zoom callback function to be the limiting factor but it turns out there is a lot of idle time between each mousewheel scroll events.
Motus operandum: I start the profiling, then give a big sharp scroll on the mousewheel (5sec on the graph). The graph lags for several seconds(from 5sec to 8.5sec on the graph) then calls my zoom callback periodically (from 8.5 to 14sec on the graph). I checked the stack calls and all my zooming callbacks are executed in order, synchronously, which makes me think the are done executing during the idle time. I think the profiler does not record some of the system/browser calls and qualifies those as idle, so I tried using interruptions ( event.preventDefault() etc...) to make sure nothing was executed on zoomend. It improved a little bit the performance, but there is still a lot of idle time:
Can someone please help me figure out why there is so much idle time?
Here is my relevant code:
without interruption
d3Zoom = d3.behavior.zoom()
.x(element.self.xScale)
.y(element.self.yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
.on("zoomend", updateSelection);
with interruption
var delayTimer=0;
d3Zoom = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
.on("zoomstart", function () {
//prevent recalculating heavyCalculations too often
window.clearTimeout(delayTimer);
var evt = e ? e : window.event;
return cancelDefaultAction(evt);
})
.on("zoomend", function () {
// only start heavy calculations if user hasn't zoomed for 0.75sec
delayTimer = window.setTimeout(updateSelection, 750);
});
function cancelDefaultAction(e) {
var evt = e ? e : window.event;
if (evt.preventDefault) evt.preventDefault();
evt.returnValue = false;
return false;
}`
EDIT: Here is an example of working code. Both semanticZoom and update selection are more complex in my project than in this example but they involve custom AngularJS directives, d3 brushes, warped geometry, aggregation etc... I have cropped semanticZoom to just perform an enter/exit/update pattern based on a quadtree (it might behave funny in this the example, but it's just to show the kind of operations I do). UpdateSelection updates the visible data to an angular directive to perform calculations (various statistics etc...). I did not populate it here but it is not actually very intensive.
var size = 100;
var dataset = d3.range(10).map(function(d, idx) {
return {
x: d3.random.normal(size / 2, size / 4)(),
y: d3.random.normal(size / 2, size / 4)(),
uuid: idx
};
});
//
// Init Scales
//
var xScale = d3.scale.linear()
.domain([0, size])
.range([0, 100]);
var yScale = d3.scale.linear()
.domain([0, size])
.range([0, 100]);
//
// Init Axes
//
var xAxis = d3.svg.axis()
.scale(xScale)
.ticks(10)
.orient("bottom")
.tickSize(-size);
var yAxis = d3.svg.axis()
.scale(yScale)
.ticks(10)
.orient("left")
.tickSize(-size);
//
// Init Zoom
//
var d3Zoom = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
.on("zoomend", updateSelection);
var quadtree = d3.geom.quadtree(dataset);
//------------------------ Callbacks --------------------------------
function semanticZoom() {
var s = 1;
var t = [0, 0];
if (d3.event) {
s = (d3.event.scale) ? d3.event.scale : 1;
t = (d3.event.translate) ? d3.event.translate : [0, 0];
}
// set zoom boundaries
// center of the zoom in svg coordinates
var center = [(size / 2 - t[0]) / s, (size / 2 - t[1]) / s];
// half size of the window in svg coordinates
var halfsize = size / (2 * s);
// top left corner in svg coordinates
var tl = [center[0] - halfsize, center[1] - halfsize];
// bottom right corner in svg coordinates
var br = [center[0] + halfsize, center[1] + halfsize];
/*
//
// Constrain zoom
//
if (!(tl[0] > -10 &&
tl[1] > -10 &&
br[0] < size + 10 &&
br[1] < size + 10)) {
// limit zoom-window corners
tl = [Math.max(0, tl[0]), Math.max(0, tl[1])];
br = [Math.min(size, br[0]), Math.min(size, br[1])];
// get restrained center
center = [(tl[0] + br[0]) / 2, (tl[1] + br[1]) / 2];
// scale center
t = [size / 2 - s * center[0], size / 2 - s * center[1]];
// update svg
svg.transition()
.duration(1)
.call( d3Zoom.translate(t).event );
}
*/
//
// Store zoom extent
//
d3Zoom.extent = [tl, br];
d3Zoom.scaleFactor = s;
d3Zoom.translation = t;
//
// Update some heavy duty stuff
// (create a quadtree, search that quadtree and update an attribute for the elements found)
//
// Prune non visible data
var displayedData = search(quadtree,
d3Zoom.extent[0][0], d3Zoom.extent[0][1],
d3Zoom.extent[1][0], d3Zoom.extent[1][1]);
redrawSubset(displayedData);
//
// Update axes
//
d3.select(".x.axis").call(xAxis);
d3.select(".y.axis").call(yAxis);
}
function redrawSubset(subset) {
//Attach new data
var elements = d3.select(".data_container")
.selectAll(".datum")
.data(subset, function(d) {
return d.uuid;
});
//enter
elements.enter()
.append("circle")
.attr("class", "datum")
.attr("r", 1)
.style("fill", "black");
//exit
elements.exit().remove();
//update
elements.attr("transform", ScaleData);
}
function updateSelection() {
// some not so heavy duty stuff
}
function ScaleData(d) {
return "translate(" + [xScale(d.x), yScale(d.y)] + ")";
}
//
// search quadtree
//
function search(qt, x0, y0, x3, y3) {
var pts = [];
qt.visit(function(node, x1, y1, x2, y2) {
var p = node.point;
if ((p) && (p.x >= x0) && (p.x <= x3) && (p.y >= y0) && (p.y <= y3)) {
pts.push(p);
}
return x1 >= x3 || y1 >= y3 || x2 < x0 || y2 < y0;
});
return pts;
}
//------------------------- DOM Manipulation -------------------------
var svg = d3.select("body").append("svg")
.attr("width", size)
.attr("height", size)
.append("g")
.attr("class", "data_container")
.call(d3Zoom);
svg.append("rect")
.attr("class", "overlay")
.attr("width", size)
.attr("height", size)
.style("fill", "none")
.style("pointer-events", "all");
var circle = svg.selectAll("circle")
.data(dataset, function(d) {
return d.uuid;
}).enter()
.append("circle")
.attr("r", 1)
.attr("class", "datum")
.attr("transform", ScaleData);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
SemanticZoom and UpdateSelection have both been unit tested and run in times comparable to the profiler graphs above (50-100ms) for large datasets.
If you add a few zeros to the circle count and make the svg big enough to be useful, then the zoom slows down to what you describe. But it's hardly surprising since it has a bunch of work to do visiting the nodes in the quad tree and writing to the DOM to manage the svg components. I don't understand why you are transforming individual circles instead of grouping them and transforming the g. If you did that then you could just let the svg element clip the image and avoid all of the svg overheads which would free up 75% of your budget. If the only purpose of the quad tree is to figure out which nodes are visible then that would also be eliminated.
A key observation I guess is that this profile is markedly different from the pics you posted, judging by the profile of your pics, they seem to be all about the quad tree and the rest is idle time. It would be interesting to see your cpu and gpu loading during the profile.
You can eliminate the need for deleting and re-writing nodes by using a clip path, that way the only overhead is re-writing the transform attributes.
There was also a problem with your search. There is a much simpler way to do it that works fine and that is to use the #linear.invert(y) method of the scale.
Both these are addressed in the sample code below...
var size = 500;
var margin = {top: 30, right: 40, bottom: 30, left: 50},
width = 600 - margin.left - margin.right,
height = 200 - margin.top - margin.bottom;
d3.select("#clipButton").on("click", (function() {
var clipped = false, clipAttr = [null, "url(#clip)"],
value = ["clip", "brush"];
return function() {
circles
.attr("clip-path", clipAttr[(clipped = !clipped, +clipped)]);
this.value = value[+clipped];
}
})());
var dataset = d3.range(1000).map(function(d, idx) {
return {
x: d3.random.normal(100 / 2, 100 / 4)(),
y: d3.random.normal(100 / 2, 100 / 4)(),
uuid: idx
};
});
//
// Init Scales
//
var xScale = d3.scale.linear()
.domain([0, 100])
.range([0, width])
.nice(10);
var yScale = d3.scale.linear()
.domain([0, 100])
.range([height, 0])
.nice(10);
//
// Init Axes
//
var xAxis = d3.svg.axis()
.scale(xScale)
.ticks(10)
.orient("bottom")
.tickSize(-height);
var yAxis = d3.svg.axis()
.scale(yScale)
.ticks(10)
.orient("left")
.tickSize(-width);
//
// Init Zoom
//
var d3Zoom = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
// .on("zoomend", updateSelection);
var Quadtree = d3.geom.quadtree()
.x(function(d){return d.x})
.y(function(d){return d.y});
quadtree = Quadtree(dataset);
//------------------------ Callbacks --------------------------------
function semanticZoom() {
var s = 1;
var t = [0, 0];
if (d3.event) {
s = (d3.event.scale) ? d3.event.scale : 1;
t = (d3.event.translate) ? d3.event.translate : [0, 0];
}
var tl = [xScale.invert(0), yScale.invert(height)];
var br = [xScale.invert(width), yScale.invert(0)];
//
// Store zoom extent
//
d3Zoom.extent = [tl, br];
d3Zoom.scaleFactor = s;
d3Zoom.translation = t;
//
// Update some heavy duty stuff
// (create a quadtree, search that quadtree and update an attribute for the elements found)
//
// Prune non visible data
var displayedData = search(quadtree, d3Zoom.extent);
markSubset(displayedData, circle);
updateSelection(circle);
//
// Update axes
//
d3.select(".x.axis").call(xAxis);
d3.select(".y.axis").call(yAxis);
};
function markSubset(data, nodes){
var marked = nodes.data(data, function(d){return d.uuid;});
marked.enter();
marked.classed("visible", true);
marked.exit().classed("visible", false);
}
function updateSelection(elements) {
// some not so heavy duty stuff
elements.attr("transform", ScaleData);
}
function ScaleData(d) {
return "translate(" + [xScale(d.x), yScale(d.y)] + ")";
}
//
// search quadtree
//
function search(qt, extent) {
var pts = [],
x0=extent[0][0], y0=extent[0][1],
x3=extent[1][0], y3=extent[1][1];
qt.visit(function(node, x1, y1, x2, y2) {
var p = node.point;
if ((p) && (p.x >= x0) && (p.x <= x3) && (p.y >= y0) && (p.y <= y3)) {
pts.push(p);
}
return x1 >= x3 || y1 >= y3 || x2 < x0 || y2 < y0;
});
return pts;
}
//------------------------- DOM Manipulation -------------------------
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("class", "data_container")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(d3Zoom),
plotSurface = svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height)
.style({"fill": "steelblue", opacity: 0.8})
.style("pointer-events", "all"),
gX = svg.append("g") // Add the X Axis
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis),
gY = svg.append("g")
.attr("class", "y axis")
.call(yAxis),
clipRect = svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height),
circles = svg.append("g")/*
.attr("clip-path", "url(#clip)")*/,
circle = circles.selectAll("circle")
.data(dataset, function(d) {
return d.uuid;
});
circle.enter()
.append("circle")
.attr("r", 3)
.attr("class", "datum")
.attr("transform", ScaleData);
semanticZoom();
svg {
outline: 1px solid red;
overflow: visible;
}
.axis path {
stroke: #000;
}
.axis line {
stroke: steelblue;
stroke-opacity: .5;
}
.axis path {
fill: none;
}
.axis text {
font-size: 8px;
}
.datum {
fill: #ccc;
}
.datum.visible {
fill: black;
}
#clipButton {
position: absolute;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<input id="clipButton" type="button" value="clip">