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>
Related
I am trying to do a zoomable heatmap and the community here on SO have helped massively, however I am now stuck the whole day today trying to fix a glitch and I am hitting the wall every single time.
The issue is that the zoom looks jumpy, ie the plot is rendered fine, however when the zoom event is triggered some kind of transformation happens that changes the axes and the scaling in an abrupt way. The code below demonstrates this issue. The problem does not always happen, it depends on the heatmap dimension and/or the number of the dots.
Some similar cases from people with the same problem here on SO turned out to be that the zoom was not applied to the correct object but I think I am not doing that mistake. Many thanks in advance
<!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>Heatmap 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>
<script src='heatmap.js' type='text/javascript'></script>
</head>
<body>
<div id="chart">
<svg width="550" height="1000"></svg>
</div>
<script>
var dataset = [];
for (let i = 1; i < 60; i++) { //360
for (j = 1; j < 70; j++) { //75
dataset.push({
xKey: i,
xLabel: "xMark " + i,
yKey: j,
yLabel: "yMark " + j,
val: Math.random() * 25,
})
}
};
var svg = d3.select("#chart")
.select("svg")
var xLabels = [],
yLabels = [];
for (i = 0; i < dataset.length; i++) {
if (i == 0) {
xLabels.push(dataset[i].xLabel);
var j = 0;
while (dataset[j + 1].xLabel == dataset[j].xLabel) {
yLabels.push(dataset[j].yLabel);
j++;
}
yLabels.push(dataset[j].yLabel);
} else {
if (dataset[i - 1].xLabel == dataset[i].xLabel) {
//do nothing
} else {
xLabels.push(dataset[i].xLabel);
}
}
};
var margin = {
top: 0,
right: 25,
bottom: 40,
left: 75
};
var width = +svg.attr("width") - margin.left - margin.right,
height = +svg.attr("height") - margin.top - margin.bottom;
var dotSpacing = 0,
dotWidth = width / (2 * (xLabels.length + 1)),
dotHeight = height / (2 * yLabels.length);
// var dotWidth = 1,
// dotHeight = 3,
// dotSpacing = 0.5;
var daysRange = d3.extent(dataset, function(d) {
return d.xKey
}),
days = daysRange[1] - daysRange[0];
var hoursRange = d3.extent(dataset, function(d) {
return d.yKey
}),
hours = hoursRange[1] - hoursRange[0];
var tRange = d3.extent(dataset, function(d) {
return d.val
}),
tMin = tRange[0],
tMax = tRange[1];
// var width = (dotWidth * 2 + dotSpacing) * days,
// height = (dotHeight * 2 + dotSpacing) * hours;
// var width = +svg.attr("width") - margin.left - margin.right,
// height = +svg.attr("height") - margin.top - margin.bottom;
var colors = ['#2C7BB6', '#00A6CA', '#00CCBC', '#90EB9D', '#FFFF8C', '#F9D057', '#F29E2E', '#E76818', '#D7191C'];
// the scale
var scale = {
x: d3.scaleLinear()
.domain([-1, d3.max(dataset, d => d.xKey)])
.range([-1, width]),
y: d3.scaleLinear()
.domain([0, d3.max(dataset, d => d.yKey)])
.range([height, 0]),
//.range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]),
};
var xBand = d3.scaleBand().domain(xLabels).range([0, width]),
yBand = d3.scaleBand().domain(yLabels).range([height, 0]);
var axis = {
x: d3.axisBottom(scale.x).tickFormat((d, e) => xLabels[d]),
y: d3.axisLeft(scale.y).tickFormat((d, e) => yLabels[d]),
};
function updateScales(data) {
scale.x.domain([-1, d3.max(data, d => d.xKey)]),
scale.y.domain([0, d3.max(data, d => d.yKey)])
}
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) {
return d.val;
})])
.range(colors);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.on("zoom", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG canvas
svg = d3.select("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 + dotHeight);
// Heatmap dots
var heatDotsGroup = svg.append("g")
.attr("clip-path", "url(#clip)")
.append("g");
heatDotsGroup.call(zoom);
//Create X axis
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + scale.y(-1) + ")")
.call(axis.x)
//Create Y axis
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(axis.y);
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);
d3.event.transform.k = Math.max(d3.event.transform.k, 1);
//console.log(d3.event.transform)
// update: rescale x axis
renderXAxis.call(axis.x.scale(d3.event.transform.rescaleX(scale.x)));
heatDotsGroup.attr("transform", d3.event.transform.toString().replace(/scale\((.*?)\)/, "scale($1, 1)"));
}
svg.call(renderPlot, dataset)
function renderPlot(selection, dataset) {
//updateScales(dataset);
heatDotsGroup.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) {
return scale.x(d.xKey) - xBand.bandwidth();
})
.attr("cy", function(d) {
return scale.y(d.yKey) + yBand.bandwidth();
})
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) {
return colorScale(d.val);
})
.on("mouseover", function(d) {
$("#tooltip").html("X: " + d.xKey + "<br/>Y: " + d.yKey + "<br/>Value: " + Math.round(d.val * 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);
});
}
</script>
</body>
</html>
Change the zoom scaleExtend
var zoom = d3.zoom()
.scaleExtent([1, dotHeight])
.on("zoom", zoomed);
Call the zoom on the whole svg not on the heatDotsGroup because this node receives the tranformation, and also not on the g node that has the graph transformation here variable svg (to keep things a bit obscure)
svg = d3.select("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 + ")");
// heatDotsGroup.call(zoom);
Don't limit the zoom scale k in the tick. Already taken care of by the scaleExtent()
// d3.event.transform.k = Math.max(d3.event.transform.k, 1);
Why calculate all the d3.max() when you already have calculated the d3.extent() of these values?
Let's consider a horizontal bar chart as shown in the attached photo. I need to show the duration of each segment along with the x-axis. I am able to show the tick values using d3.svg.axis().tickValues(vals).
But I need to show the duration in between two ticks. Can anyone help me to achieve this using d3?
Thanks in advance.
Here is my solution. I'm using D3 version 3 (because you wrote d3.svg.axis() in your question) and a linear scale, just to show you the principle. You can easily change it to a time scale.
Given this data:
var data = [0, 10, 40, 45, 85, 100];
We're gonna plot the differences between the ticks, i.e.: 10, 30, 5, 40 and 15.
The first step is setting the scale:
var scale = d3.scale.linear()
.domain(d3.extent(data))
.range([margin, w - margin]);
Then, in the axis generator, we set the ticks to match the data with:
.tickValues(data)
And we calculate the correct numbers with:
.tickFormat(function(d,i){
if(i>0){
return data[i] - data[i-1];
} else { return ""};
});
The last step is translating the text to the middle position:
var ticks = d3.selectAll(".tick text").each(function(d, i) {
d3.select(this).attr("transform", function() {
if (i > 0) {
return "translate(" + (-scale(data[i] - data[i - 1]) / 2 + margin/2) + ",0)";
}
})
})
Check the demo:
var w = 500,
h = 100;
var svg = d3.select("body")
.append("svg")
.attr("width", w)
.attr("height", h);
var data = [0, 10, 40, 45, 85, 100];
var margin = 20;
var scale = d3.scale.linear()
.domain(d3.extent(data))
.range([margin, w - margin]);
var colors = d3.scale.category10();
var rects = svg.selectAll(".rects")
.data(data)
.enter()
.append("rect");
rects.attr("y", 10)
.attr("height", 35)
.attr("fill", (d,i)=> colors(i))
.attr("x", d=>scale(d))
.attr("width", (d,i)=> {
return scale(data[i+1] - data[i]) - margin
});
var axis = d3.svg.axis()
.scale(scale)
.orient("bottom")
.tickValues(data)
.tickFormat(function(d, i) {
if (i > 0) {
return data[i] - data[i - 1];
} else {
return ""
};
});
var gX = svg.append("g")
.attr("transform", "translate(0,50)")
.attr("class", "axis")
.call(axis);
var ticks = d3.selectAll(".tick text").each(function(d, i) {
d3.select(this).attr("transform", function() {
if (i > 0) {
return "translate(" + (-scale(data[i] - data[i - 1]) / 2 + margin / 2) + ",0)";
}
})
})
.axis path,
.axis line {
fill: none;
stroke: black;
shape-rendering: crispEdges;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
I want to add a rectangle in my d3.js graph highlighting a specific data region. The problem is I don't want to specify a starting point and then a height and length.
Rather I would like to specify two points positioned diametral – on the upper left and lower right corner of the rectangle. The highlight area rectangle needs to go from my lowest X value to the highest X value in my dataset and from a specific lower y bound to a specific higher y bound.
If you are just passing the x and y values of the two points, why not using a rect element itself? It's way shorter and easier than drawing a path as in your answer:
function drawRectanglePoints(x1,y1,x2,y2,container,thisClass){
container.append("rect")
.attr("x", x1).attr("y", y1)
.attr("width", x2-x1).attr("height", y2-y1)
.attr("class", thisClass).attr("id", thisId);
}
Here is your demo:
function drawRectanglePoints(x1,y1,x2,y2,container,thisClass, thisId){
container.append("rect").attr("x", x1).attr("y", y1).attr("width", x2-x1).attr("height", y2-y1).attr("class", thisClass).attr("id", thisId);
}
function drawLine(x1,y1,x2,y2, svgContainer, thisClass, thisId){
svgContainer.append("line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2)
.attr("class", thisClass)
.attr("id", thisId);
}
// Set the dimensions of the canvas / graph
var margin = {top: 30, right: 20, bottom: 30, left: 50},
width = 600 - margin.left - margin.right,
height = 270 - margin.top - margin.bottom;
// Adds the svg canvas
var svg = d3.select("#graph")
.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 + ")");
// Parse the date / time
var parseDate = d3.time.format("%Y-%m-%d").parse;
// Set the ranges
var x = d3.time.scale().range([0, width]);
var y = d3.scale.linear().range([height, 0]);
// Define the axes
var xAxis = d3.svg.axis().scale(x)
.orient("bottom").ticks(5);
var yAxis = d3.svg.axis().scale(y)
.orient("left").ticks(5);
// Define the line
var valueline = d3.svg.line()
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.rate); })
.interpolate("monotone");
// Define the div for the tooltip
var div = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
// Get the data
// this is where you would get your data via ajax / read a file / whatever
var resData = JSON.parse('[{"date":"2016-09-23","rate":"11.0707","nbItems":"8"},{"date":"2016-09-24","rate":"12.0317","nbItems":"10"},{"date":"2016-09-25","rate":"14.6562","nbItems":"9"},{"date":"2016-09-26","rate":"12.9523","nbItems":"7"},{"date":"2016-09-27","rate":"11.8636","nbItems":"10"},{"date":"2016-09-28","rate":"14.1731","nbItems":"10"},{"date":"2016-09-30","rate":"14.3167","nbItems":"3"},{"date":"2016-10-01","rate":"14.8398","nbItems":"4"},{"date":"2016-10-02","rate":"10.2088","nbItems":"1"},{"date":"2016-10-03","rate":"12.1985","nbItems":"9"},{"date":"2016-10-04","rate":"16.0133","nbItems":"5"},{"date":"2016-10-05","rate":"15.4206","nbItems":"6"}]');
var sigmaMin = 10; // our fictional lower bound of data highlighting
var sigma = 12.5;
var sigmaMax = 15; // our fictional upper bound of data highlighting
var i = 0;
var startDate = false;
resData.forEach(function(d) {
// console.log(d.date);
d.date = parseDate(String(d.date));
d.rate = +d.rate;
d.nbItems = +d.nbItems;
if(i === 0){
startDate = d.date;
}
endDate = d.date;
i++;
});
// Scale the range of the data
x.domain(d3.extent(resData, function(d) { return d.date; }));
y.domain([0, d3.max(resData, function(d) { return d.rate; })]);
// Add the valueline path for the data
svg.append("path")
.attr("class", "line")
.attr("d", valueline(resData));
drawRectanglePoints(x(startDate), y(sigmaMax), x(endDate), y(sigmaMin), svg, 'sigmaRectangle','sigmaRectangle');
drawLine(0, y(sigmaMin), 530, y(sigmaMin), svg, 'sigma_line', 'sigma_line_min');
drawLine(0, y(sigma), 530, y(sigma), svg, 'sigma_line', 'sigma_line');
drawLine(0, y(sigmaMax), 530, y(sigmaMax), svg, 'sigma_line', 'sigma_line_max');
// Add the scatterplot
svg.selectAll("dot")
.data(resData)
.enter().append("circle")
.attr("r", function(d) { return d.nbItems+7; }) // make size of dots depending on nb items included in this day +7 for min value
.attr("cx", function(d) { return x(d.date); })
.attr("cy", function(d) { return y(d.rate); })
.attr("data-date", function(d) { return d.date; });
// Add the X Axis
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
// Add the Y Axis
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
function drawRectangle(x1,y1,x2,y2,container,thisClass){
var width = x2 - x1, height = y2 - y1;
container.append("rect").attr("x", x1).attr("y", y1).attr("width", width).attr("height", height).attr("class", thisClass);
}
<script src="http://d3js.org/d3.v3.min.js"></script>
<!-- dont do this inside an external css script -->
<style type="text/css">
#graph{
color: red;
width: 100%;
}
#graph path {
stroke: blue;
stroke-width: 4;
fill: none;
}
#graph .axis path,
#graph .axis line {
fill: none;
stroke: grey;
stroke-width: 1;
shape-rendering: crispEdges;
}
#graph circle{
fill: rgba(200, 200, 200,0.7);
cursor: pointer;
}
#graph #sigmaRectangle {
stroke: transparent;
stroke-width: 0;
fill: rgba(200, 200, 200,0.3);
}
#graph .sigma_line{
stroke: rgba(200, 200, 200,0.5);
stroke-width: 1;
fill: none;
}
</style>
<h2>D3.js Highlight area with</h2>
<p>rect with two diametral points from your dataset</p>
<div id="graph"></div>
The only difference between this and your code is that this doesn't check for negative width/height values (but it doesn't matter, because you said that you're passing the top left as the first pair and the bottom right as the second). Besides that, it's worth mentioning that rect has nothing to do with D3, it's an SVG element and its specifications are provided by W3C.
Update: Just use a plain svg rect object.
The trick is to build the rectangle yourself out of lines rather than to use the rect element provided by d3.js.
I use this function:
function drawRectanglePoints(x1, y1, x3, y3, svgContainer, thisClass, thisId){
// The data for the rectangle
var lineData = [
{ "x": x1, "y": y1}, // start at upper-left
{ "x": x3, "y": y1}, // goto upper-right
{ "x": x3, "y": y3}, // goto lower-right
{ "x": x1, "y": y3}, // goto lower-left
{ "x": x1, "y": y1}, // go back to upper-left
];
// accessor function
var lineFunction = d3.svg.line()
.x(function(d) { return d.x; })
.y(function(d) { return d.y; })
.interpolate("linear"); // draw straight lines, not curved
// draw the lines
var lineGraph = svgContainer.append("path") // svgContainer is the svg element initialised already
.attr("d", lineFunction(lineData)) // here we add our lines
.attr("class", thisClass) // give the element a class (performant for css)
.attr("id", thisId); // give the element an id (performant for js)
}
Usage:
drawRectanglePoints(
x(startDate),
y(sigmaMax),
x(endDate),
y(sigmaMin),
svgContainer, // this is the d3.js object of the initialized svg
'sigmaRectangle',
'sigmaRectangle'
);
Complete example:
function drawRectanglePoints(x1, y1, x3, y3, svgContainer, thisClass, thisId){
// this uses two diametral points to draw the rectange instead of a point and width and height
// The data for the rectangle
var lineData = [
{ "x": x1, "y": y1},
{ "x": x3, "y": y1},
{ "x": x3, "y": y3},
{ "x": x1, "y": y3},
{ "x": x1, "y": y1},
];
// accessor function
var lineFunction = d3.svg.line()
.x(function(d) { return d.x; })
.y(function(d) { return d.y; })
.interpolate("linear");
// draw the lines
var lineGraph = svgContainer.append("path")
.attr("d", lineFunction(lineData))
.attr("class", thisClass)
.attr("id", thisId);
}
function drawLine(x1,y1,x2,y2, svgContainer, thisClass, thisId){
svgContainer.append("line")
.attr("x1", x1)
.attr("y1", y1)
.attr("x2", x2)
.attr("y2", y2)
.attr("class", thisClass)
.attr("id", thisId);
}
// Set the dimensions of the canvas / graph
var margin = {top: 30, right: 20, bottom: 30, left: 50},
width = 600 - margin.left - margin.right,
height = 270 - margin.top - margin.bottom;
// Adds the svg canvas
var svg = d3.select("#graph")
.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 + ")");
// Parse the date / time
var parseDate = d3.time.format("%Y-%m-%d").parse;
// Set the ranges
var x = d3.time.scale().range([0, width]);
var y = d3.scale.linear().range([height, 0]);
// Define the axes
var xAxis = d3.svg.axis().scale(x)
.orient("bottom").ticks(5);
var yAxis = d3.svg.axis().scale(y)
.orient("left").ticks(5);
// Define the line
var valueline = d3.svg.line()
.x(function(d) { return x(d.date); })
.y(function(d) { return y(d.rate); })
.interpolate("monotone");
// Define the div for the tooltip
var div = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
// Get the data
// this is where you would get your data via ajax / read a file / whatever
var resData = JSON.parse('[{"date":"2016-09-23","rate":"11.0707","nbItems":"8"},{"date":"2016-09-24","rate":"12.0317","nbItems":"10"},{"date":"2016-09-25","rate":"14.6562","nbItems":"9"},{"date":"2016-09-26","rate":"12.9523","nbItems":"7"},{"date":"2016-09-27","rate":"11.8636","nbItems":"10"},{"date":"2016-09-28","rate":"14.1731","nbItems":"10"},{"date":"2016-09-30","rate":"14.3167","nbItems":"3"},{"date":"2016-10-01","rate":"14.8398","nbItems":"4"},{"date":"2016-10-02","rate":"10.2088","nbItems":"1"},{"date":"2016-10-03","rate":"12.1985","nbItems":"9"},{"date":"2016-10-04","rate":"16.0133","nbItems":"5"},{"date":"2016-10-05","rate":"15.4206","nbItems":"6"}]');
var sigmaMin = 10; // our fictional lower bound of data highlighting
var sigma = 12.5;
var sigmaMax = 15; // our fictional upper bound of data highlighting
var i = 0;
var startDate = false;
resData.forEach(function(d) {
// console.log(d.date);
d.date = parseDate(String(d.date));
d.rate = +d.rate;
d.nbItems = +d.nbItems;
if(i === 0){
startDate = d.date;
}
endDate = d.date;
i++;
});
// Scale the range of the data
x.domain(d3.extent(resData, function(d) { return d.date; }));
y.domain([0, d3.max(resData, function(d) { return d.rate; })]);
// Add the valueline path for the data
svg.append("path")
.attr("class", "line")
.attr("d", valueline(resData));
drawRectanglePoints(x(startDate), y(sigmaMax), x(endDate), y(sigmaMin), svg, 'sigmaRectangle','sigmaRectangle');
drawLine(0, y(sigmaMin), 530, y(sigmaMin), svg, 'sigma_line', 'sigma_line_min');
drawLine(0, y(sigma), 530, y(sigma), svg, 'sigma_line', 'sigma_line');
drawLine(0, y(sigmaMax), 530, y(sigmaMax), svg, 'sigma_line', 'sigma_line_max');
// Add the scatterplot
svg.selectAll("dot")
.data(resData)
.enter().append("circle")
.attr("r", function(d) { return d.nbItems+7; }) // make size of dots depending on nb items included in this day +7 for min value
.attr("cx", function(d) { return x(d.date); })
.attr("cy", function(d) { return y(d.rate); })
.attr("data-date", function(d) { return d.date; });
// Add the X Axis
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis);
// Add the Y Axis
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
<script src="http://d3js.org/d3.v3.min.js"></script>
<!-- dont do this inside an external css script -->
<style type="text/css">
#graph{
color: red;
width: 100%;
}
#graph path {
stroke: blue;
stroke-width: 4;
fill: none;
}
#graph .axis path,
#graph .axis line {
fill: none;
stroke: grey;
stroke-width: 1;
shape-rendering: crispEdges;
}
#graph circle{
fill: rgba(200, 200, 200,0.7);
cursor: pointer;
}
#graph #sigmaRectangle {
stroke: transparent;
stroke-width: 0;
fill: rgba(200, 200, 200,0.3);
}
#graph .sigma_line{
stroke: rgba(200, 200, 200,0.5);
stroke-width: 1;
fill: none;
}
</style>
<h2>D3.js Highlight area with</h2>
<p>rect with two diametral points from your dataset</p>
<div id="graph"></div>
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 looked on the Stack Exchange for how to create a horizontal stacked bar example, and found: http://tributary.io/inlet/4966973
Which is based on: http://bl.ocks.org/mbostock/3943967
To better understand how this code works I got Bostock's example running on my machine (via SimpleHTTPServer, etc.).
However, I couldn't get gelicia's Tributary example to run. I copied gelicia's Tributary example, and added Bostock's html code (leading up to the script), and additionally the functions below where the Tributary example ends, but the svg body and resulting bar rects aren't created. But there's no obvious error message to fix something.
I tried switching the xs and ys in Bostock's function since I read that was the main conversion issue from going from vertical to horizontal stacked bars, but that didn't help and once again no error appeared.
Can someone explain to me how to get the horizontal bar stack example to run, and what I've been done wrong in trying to get it to work within an html document?
<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
margin: auto;
position: relative;
width: 960px;
}
text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
form {
position: absolute;
right: 10px;
top: 10px;
}
</style>
<form>
<label><input type="radio" name="mode" value="grouped"> Grouped</label>
<label><input type="radio" name="mode" value="stacked" checked> Stacked</label>
</form>
<script type="text/javascript" src="d3.v3.js"></script>
<script>
//modified from Mike Bostock at http://bl.ocks.org/3943967 */
var data = [
{"key":"FL", "pop1":3000, "pop2":4000, "pop3":5000},
{"key":"CA", "pop1":3000, "pop2":3000, "pop3":3000},
{"key":"NY", "pop1":12000, "pop2":5000, "pop3":13000},
{"key":"NC", "pop1":8000, "pop2":21000, "pop3":11000},
{"key":"SC", "pop1":30000, "pop2":12000, "pop3":8000},
{"key":"AZ", "pop1":26614, "pop2":6944, "pop3":30778},
{"key":"TX", "pop1":8000, "pop2":12088, "pop3":20000}
];
var n = 3, // number of layers
m = data.length, // number of samples per layer
stack = d3.layout.stack(),
labels = data.map(function(d) {return d.key;}),
//go through each layer (pop1, pop2 etc, that's the range(n) part)
//then go through each object in data and pull out that objects's population data
//and put it into an array where x is the index and y is the number
layers = stack(d3.range(n).map(function(d) {
var a = [];
for (var i = 0; i < m; ++i) {
a[i] = {x: i, y: data[i]['pop' + (d+1)]};
}
return a;
})),
//the largest single layer
yGroupMax = d3.max(layers, function(layer) { return d3.max(layer, function(d) { return d.y; }); }),
//the largest stack
yStackMax = d3.max(layers, function(layer) { return d3.max(layer, function(d) { return d.y0 + d.y; }); });
var margin = {top: 40, right: 10, bottom: 20, left: 50},
width = 677 - margin.left - margin.right,
height = 533 - margin.top - margin.bottom;
var y = d3.scale.ordinal()
.domain(d3.range(m))
.rangeRoundBands([2, height], .08);
var x = d3.scale.linear()
.domain([0, yStackMax])
.range([0, width]);
var color = d3.scale.linear()
.domain([0, n - 1])
.range(["#aad", "#556"]);
var svg = d3.select("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 layer = svg.selectAll(".layer")
.data(layers)
.enter().append("g")
.attr("class", "layer")
.style("fill", function(d, i) { return color(i); });
layer.selectAll("rect")
.data(function(d) { return d; })
.enter().append("rect")
.attr("y", function(d) { return y(d.x); })
.attr("x", function(d) { return x(d.y0); })
.attr("height", y.rangeBand())
.attr("width", function(d) { return x(d.y); });
var yAxis = d3.svg.axis()
.scale(y)
.tickSize(1)
.tickPadding(6)
.tickValues(labels)
.orient("left");
svg.append("g")
.attr("class", "y axis")
.call(yAxis);
//ADD IN BOSTOCK CODE -- replace xs with ys and vice versa
d3.selectAll("input").on("change", change);
var timeout = setTimeout(function() {
d3.select("input[value=\"grouped\"]").property("checked", true).each(change);
}, 2000);
function change() {
clearTimeout(timeout);
if (this.value === "grouped") transitionGrouped();
else transitionStacked();
}
function transitionGrouped() {
x.domain([0, xGroupMax]);
rect.transition()
.duration(500)
.delay(function(d, i) { return i * 10; })
.attr("y", function(d, i, j) { return y(d.y) + y.rangeBand() / n * j; })
.attr("width", y.rangeBand() / n)
.transition()
.attr("x", function(d) { return x(d.x); })
.attr("height", function(d) { return height - x(d.x); });
}
function transitionStacked() {
x.domain([0, xStackMax]);
rect.transition()
.duration(500)
.delay(function(d, i) { return i * 10; })
.attr("x", function(d) { return x(d.x0 + d.x); })
.attr("height", function(d) { return x(d.x0) - x(d.x0 + d.x); })
.transition()
.attr("y", function(d) { return y(d.y); })
.attr("width", y.rangeBand());
}
// Inspired by Lee Byron's test data generator.
function bumpLayer(n, o) {
function bump(a) {
var y = 1 / (.1 + Math.random()),
x = 2 * Math.random() - .5,
z = 10 / (.1 + Math.random());
for (var i = 0; i < n; i++) {
var w = (i / n - x) * z;
a[i] += y * Math.exp(-w * w);
}
}
var a = [], i;
for (i = 0; i < n; ++i) a[i] = o + o * Math.random();
for (i = 0; i < 5; ++i) bump(a);
return a.map(function(d, i) { return {y: i, x: Math.max(0, d)}; });
}
</script>
</body>
</html>
It looks like you don't have an svg element on your page. You can simply add in your body (above or below your form) and it should work.
(tributary creates an svg element for you by default, which is they the code runs there and not in your example)
Yeah, like enjalot said, you'll need an svg element, and then you'll need to wrap the javascript in a function and add an onload to your html so it will execute that function when it loads the page. Something like <body onLoad="loadChart()">