Rendering in Canvas on the server

How about we do another one of those things right now? As mentioned before, the output from our little server app is pretty dull. Let's render a map using Canvas.

We haven't really talked about it much, but Canvas is another cool way D3 can be used to render data. Instead of writing a bunch of elements into a DOM tree format like SVG, Canvas renders pixels in a 2D plane, which can be much better for performance. It's a much different workflow than what we're used to, and I don't focus on it too much in this book because it tends to be harder to debug for beginners, but let's give it a shot and deep dive into it for one example.

By the way, it's worth noting this is a super weird way to use Canvas compared to how we can in the browser; normally, we'd just rely on the browser's built-in Canvas renderer, but we don't have that luxury on the server. Note, however, that the Canvas code we're writing for node-canvas will work the same way in the browser, in case you want to use it there.

Create a new function in chapter8/index.js:

function canvasMap(ctx, next) {
const { airport } = ctx.state;
const scale = ctx.query.scale || 1200;
const projectionName = d3.hasOwnProperty(ctx.query.projection) ?
ctx.query.projection : 'geoStereographic';
const canvas = new Canvas(960, 500);
const canvasCtx = canvas.getContext('2d');
const projection = d3[projectionName]()
.center([airport.data.longitude, airport.data.latitude])
.scale(scale);
}

We start by getting the airport object from context's state object. We want to be able to use query arguments to set the scale and projection of the output, so we assign them from context's query object, offering a default value if that argument doesn't exist. We then instantiate a new 960px by 500px Canvas object, get its drawing context, and create a new projection (defaulting to d3.geoStereographic) centered on the user's location. In order to differentiate the Canvas context (which is where and how you do your drawing) from the Koa context (which is all about how the request is handled), we're going to call the former canvasCtx.

Next, add the following to canvasMap:

  const path = d3.geoPath() 
.projection(projection)
.context(canvasCtx);

canvasCtx.beginPath();
path(topojson.mesh(land));
path(topojson.mesh(boundaries));
canvasCtx.stroke();

This creates a path generator using our projection, and tells it to output to the Canvas context. We then create a path, supplying our land and boundaries as Topojson meshes, which we stroke with the default values. Unlike what we normally do in D3, we don't chain these methods -- we run them one right after the other, or procedurally.

If we were doing this in the browser, we'd now have a map of the world centered on the user. Let's add an indicator showing where the user's nearest airport is:

  const airportProjected = projection([airport.data.longitude,      
airport.data.latitude]);
canvasCtx.fillStyle = '#f00';
canvasCtx.fillRect(airportProjected[0] - 5,
airportProjected[1] - 5, 10, 10);

This gives us the projected x and y values for the airport, which we provide to the Canvas context's fillRect method, which simply draws a filled rectangle (the arguments supplied are x position, y position, width, and height).

We're not done yet, though -- even though our server has rendered the Canvas drawing, we can't yet see it because it currently only exists in an ephemeral form, inside the web server's memory. In order to display it, we need to convert it to a base64-encoded string, which can be given to an img tag to render the Canvas element. Add the following to the end of drawCanvas():

  ctx.state.canvasOutput = canvas.toDataURL(); 
next();

We convert the Canvas element to a base64 and attach that to the state object. We then call next() to invoke the other downstream middleware.

Let's keep our original page rendering middleware intact and just write a new one. Copy and paste renderView below the original renderView, rename it renderViewCanvas, and add an image tag in the second template. I've highlighted the parts you need to worry about in the following code:

async function renderViewCanvas(ctx, next) { 
if (ctx.method === 'GET') {
ctx.body =
&grave;<!doctype html>
<html>
<head>
<title>Find your nearest airport!</title>
</head>
<body>
<form method="POST" action="#">
<h1>Enter your latitude and longitude, or allow your browser to check.
</h1>
<input type="text" name="location" /> <br />
<input type="submit" value="Check" />
</form>
<script type="text/javascript">
navigator.geolocation.getCurrentPosition(function(position) {
document.querySelector('[name="location"]').value =
position.coords.latitude + ',' + position.coords.longitude;
});
</script>
</body>
</html>&grave;;
} else if (ctx.method === 'POST') {
await next(); // This ensures the other middleware runs first!
const airport = ctx.state.airport.data;
const { canvasOutput } = ctx.state;
ctx.body = &grave;<!doctype html>
<html>
<head>
<title>Your nearest airport is: ${airport.name}</title>
</head>
<body style="text-align: center;">
<h1>
The airport closest to your location is: ${airport.name}
</h1>
<img style="width: 480px; height: 250px"
src="${canvasOutput}">

<table style="margin: 0 auto;">
<tr>
${Object.keys(airport).map(v => &grave;<th>${v}</th>&grave;).join('')}
</tr>
<tr>
${Object.keys(airport).map(v =>
&grave;<td>${airport[v]}</td>&grave;).join('')}
</tr>
</body>
</html>&grave;;
}
}

In addition to our airport, we also now pull the canvasOutput property from state, and output it as an image. The key bit is here:

<img style="width: 480px; height: 250px;" src="${canvasOutput}" /> 

Note that Canvas produces raster images -- unlike SVG, which can be scaled as big or tiny as you want without quality degradation -- the size of a canvas element is dictated by the resolution it's rendered at, and you will start to see pixels if you scale it past the usual image thresholds. We use a popular trick here to ensure that our image renders nicely even on Retina displays -- when we instantiated the Canvas object back in drawCanvas, we made it double the size we wanted it to eventually render as. Here, in renderViewCanvas, we explicitly set the width and height to half of the original values, which results in a sharper image.

Go down to your app.use section and replace it with the following:

app.use(bodyParser()) 
.use(renderViewCanvas)
.use(nearestAirport)
.use(canvasMap);

Press Ctrl+C if you still have Node running and then restart it by typing:

$ npm run server

Open up localhost:5555, enter a latitude/longitude pair (or let the browser geolocate it), and now your results page has a handy little static map!:

If we add query arguments, our URL looks as follows:

http://localhost:5555/?scale=400&projection=geoMercator

It will change the scale to 400 and the projection to Mercator. Pretty cool, huh?

Canvas, despite being a bit weird to use in D3, is a super powerful technology, particularly when rendering huge amounts of data (any DOM-based display language tends to get really slow after about 1000 elements; Canvas is effectively just a 2D drawing plane, so suffers less from that problem).
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset