block by nbremer db24422abdb20150a9dd

Zoomable Circle Packing with Canvas & D3.js - II

Full Screen

This is the fourth step of my first attempt to learn canvas. I want to improve a piece a made a few weeks ago about the division of occupations. The D3.js version has so many DOM elements due to all the small bar charts that it is very slow. Therefore, I hope that a canvas version might improve things

Thanks to a version of the circle packing that does away with the D3.js data binding made by Stephan Smola I was able to make a few adjustments to make the zooming more linear from point A to B. Next step, add the bar charts…

I wrote a more extensive tutorial around what I learned while doing this project in my blog Learnings from a D3.js addict on starting with Canvas in which this can be seen as step 4. See the previous, more jittery zoomable version here in which the code is still more base on d3.js

And if you want to see the final result, with everything up and running in canvas look here

index.html

<!DOCTYPE html>
<head>
	<meta charset="utf-8">

	<!-- D3.js -->
	<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js" charset="utf-8"></script>
	<script src="//d3js.org/queue.v1.min.js"></script>

	<!-- stats -->
	<script src="https://cdnjs.cloudflare.com/ajax/libs/stats.js/r14/Stats.js"></script>
	
	<!-- jQuery -->
	<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>

	<!-- Stylesheet -->
	<style>
		body { text-align: center; }
	</style>
	
</head>
<body>

	<div id="chart"></div>
	<script>
		queue() 
			.defer(d3.json, "occupation.json")
			.await(drawAll);
			
		function drawAll(error, dataset) {
			////////////////////////////////////////////////////////////// 
			////////////////// Create Set-up variables  ////////////////// 
			////////////////////////////////////////////////////////////// 

			var width = Math.max($("#chart").width(),350) - 20,
				height = (window.innerWidth < 768 ? width : window.innerHeight - 20);

			var mobileSize = (window.innerWidth < 768 ? true : false);

			var centerX = width/2,
				centerY = height/2;
			////////////////////////////////////////////////////////////// 
			/////////////////////// Create SVG  /////////////////////// 
			////////////////////////////////////////////////////////////// 
			
			//Create the visible canvas and context
			var canvas  = d3.select("#chart").append("canvas")
				.attr("id", "canvas")
				.attr("width", width)
				.attr("height", height);
			var context = canvas.node().getContext("2d");
				context.clearRect(0, 0, width, height);
			
			//Create a hidden canvas in which each circle will have a different color
			//We can use this to capture the clicked on circle
			var hiddenCanvas  = d3.select("#chart").append("canvas")
				.attr("id", "hiddenCanvas")
				.attr("width", width)
				.attr("height", height)
				.style("display","none");	
			var hiddenContext = hiddenCanvas.node().getContext("2d");
				hiddenContext.clearRect(0, 0, width, height);

			//Create a custom element, that will not be attached to the DOM, to which we can bind the data
			var detachedContainer = document.createElement("custom");
			var dataContainer = d3.select(detachedContainer);

			////////////////////////////////////////////////////////////// 
			/////////////////////// Create Scales  /////////////////////// 
			////////////////////////////////////////////////////////////// 

			var colorCircle = d3.scale.ordinal()
					.domain([0,1,2,3])
					.range(['#bfbfbf','#838383','#4c4c4c','#1c1c1c']);

			var diameter = Math.min(width*0.9, height*0.9),
				radius = diameter / 2;
				
			var zoomInfo = {
				centerX: width / 2,
				centerY: height / 2,
				scale: 1
			};

			//Dataset to swtich between color of a circle (in the hidden canvas) and the node data	
			var colToCircle = {};
			
			var pack = d3.layout.pack()
				.padding(1)
				.size([diameter, diameter])
				.value(function(d) { return d.size; })
				.sort(function(d) { return d.ID; });

			////////////////////////////////////////////////////////////// 
			////////////////// Create Circle Packing /////////////////////
			////////////////////////////////////////////////////////////// 

			var nodes = pack.nodes(dataset),
				root = dataset,
				focus = root;		
			
			////////////////////////////////////////////////////////////// 
			///////////////// Canvas draw function ///////////////////////
			////////////////////////////////////////////////////////////// 
			
			var cWidth = canvas.attr("width");
			var cHeight = canvas.attr("height");
			var nodeCount = nodes.length;

			//The draw function of the canvas that gets called on each frame
			function drawCanvas(chosenContext, hidden) {

				//Clear canvas
				chosenContext.fillStyle = "#fff";
				chosenContext.rect(0,0,cWidth,cHeight);
				chosenContext.fill();
			  
				//Select our dummy nodes and draw the data to canvas.
				var node = null;
				// It's slightly faster than nodes.forEach()
				for (var i = 0; i < nodeCount; i++) {
					node = nodes[i];

					//If the hidden canvas was send into this function and it does not yet have a color, generate a unique one
					if(hidden) {
						if(node.color == null) {
							// If we have never drawn the node to the hidden canvas get a new color for it and put it in the dictionary.
							node.color = genColor();
							colToCircle[node.color] = node;
						}//if
						// On the hidden canvas each rectangle gets a unique color.
						chosenContext.fillStyle = node.color;
					} else {
						chosenContext.fillStyle = node.children ? colorCircle(node.depth) : "white";
					}//else
				
					//Draw each circle
					chosenContext.beginPath();
					chosenContext.arc(((node.x - zoomInfo.centerX) * zoomInfo.scale) + centerX,
									  ((node.y - zoomInfo.centerY) * zoomInfo.scale) + centerY,
										node.r * zoomInfo.scale, 0,  2 * Math.PI, true);				
					chosenContext.fill();
				
				}//for i
				
			}//function drawCanvas

			////////////////////////////////////////////////////////////// 
			/////////////////// Click functionality ////////////////////// 
			////////////////////////////////////////////////////////////// 
			
			// Listen for clicks on the main canvas
			document.getElementById("canvas").addEventListener("click", function(e){
				// We actually only need to draw the hidden canvas when there is an interaction. 
				// This sketch can draw it on each loop, but that is only for demonstration.
				drawCanvas(hiddenContext, true);

				//Figure out where the mouse click occurred.
				var mouseX = e.layerX;
				var mouseY = e.layerY;

				// Get the corresponding pixel color on the hidden canvas and look up the node in our map.
				// This will return that pixel's color
				var col = hiddenContext.getImageData(mouseX, mouseY, 1, 1).data;
				//Our map uses these rgb strings as keys to nodes.
				var colString = "rgb(" + col[0] + "," + col[1] + ","+ col[2] + ")";
				var node = colToCircle[colString];

				if(node) {
					if (focus !== node) zoomToCanvas(node); else zoomToCanvas(root);
				}//if
			});

			////////////////////////////////////////////////////////////// 
			///////////////////// Zoom Function //////////////////////////
			////////////////////////////////////////////////////////////// 
			
			//Based on the generous help by Stephan Smola
			////bl.ocks.org/smoli/d7e4f9199c15d71258b5
			
			var ease = d3.ease("cubic-in-out"),
				duration = 2000,
				timeElapsed = 0,
				interpolator = null,
				vOld = [focus.x, focus.y, focus.r * 2.05];
				
			//Create the interpolation function between current view and the clicked on node
			function zoomToCanvas(focusNode) {
				focus = focusNode;
				var v = [focus.x, focus.y, focus.r * 2.05]; //The center and width of the new "viewport"
				
				interpolator = d3.interpolateZoom(vOld, v); //Create interpolation between current and new "viewport"
				
				duration = 	interpolator.duration; //Interpolation gives back a suggested duration	 		
				timeElapsed = 0; //Set the time elapsed for the interpolateZoom function to 0	
				vOld = v; //Save the "viewport" of the next state as the next "old" state
			}//function zoomToCanvas
			
			//Perform the interpolation and continuously change the zoomInfo while the "transition" occurs
			function interpolateZoom(dt) {
				if (interpolator) {
					timeElapsed += dt;
					var t = ease(timeElapsed / duration);
					
					zoomInfo.centerX = interpolator(t)[0];
					zoomInfo.centerY = interpolator(t)[1];
					zoomInfo.scale = diameter / interpolator(t)[2];
				
					if (timeElapsed >= duration) interpolator = null;
				}//if
			}//function zoomToCanvas
			
			////////////////////////////////////////////////////////////// 
			//////////////////// Other Functions /////////////////////////
			////////////////////////////////////////////////////////////// 
			
			//Generates the next color in the sequence, going from 0,0,0 to 255,255,255.
			//From: https://bocoup.com/weblog/2d-picking-in-canvas
			var nextCol = 1;
			function genColor(){
				var ret = [];
				// via //stackoverflow.com/a/15804183
				if(nextCol < 16777215){
				  ret.push(nextCol & 0xff); // R
				  ret.push((nextCol & 0xff00) >> 8); // G 
				  ret.push((nextCol & 0xff0000) >> 16); // B

				  nextCol += 100; // This is exagerated for this example and would ordinarily be 1.
				}
				var col = "rgb(" + ret.join(',') + ")";
				return col;
			}//function genColor

			////////////////////////////////////////////////////////////// 
			/////////////////////// FPS Stats box //////////////////////// 
			////////////////////////////////////////////////////////////// 
			
			var stats = new Stats();
			stats.setMode(0); // 0: fps, 1: ms, 2: mb

			// align top-left
			stats.domElement.style.position = 'absolute';
			stats.domElement.style.left = '0px';
			stats.domElement.style.top = '0px';

			document.body.appendChild( stats.domElement );

			////////////////////////////////////////////////////////////// 
			/////////////////////// Initiate ///////////////////////////// 
			////////////////////////////////////////////////////////////// 
					
			//First zoom to get the circles to the right location
			zoomToCanvas(root);
			
			var dt = 0;
			d3.timer(function(elapsed) {
				stats.begin();
				interpolateZoom(elapsed - dt);
				dt = elapsed;
				drawCanvas(context);
				stats.end();
			});

		}//drawAll
	</script>
	
</body>
</html>