All Aboard AJAX, HTML Canvas, and the Supertrain
January 18, 2006
Apple's Safari browser introduced the canvas
HTML element, allowing web
developers to create two-dimensional drawings using a simple JavaScript API. With
the recent
release of Firefox 1.5, canvas
took a significant step toward the mainstream
(canvas
is currently being considered for inclusion in HTML 5).
Unfortunately, Microsoft holds most of the cards in this game, meaning that it could
be a
long time before they release a canvas
-friendly version of Internet Explorer.
In the meantime, though, the Web 2.0 revolution continues, and I believe that for
applications that can sacrifice IE users, the canvas
element is an untapped
resource, particularly in light of the emergence of JavaScript libraries that provide
simple
interfaces to XMLHttpRequest
. I dabbled with canvas
and XHR, and I
was impressed. (The irony that canvas
and XHR come respectively from Apple and
Microsoft should not be overlooked.) :-)
My first exploration into combining canvas
and AJAX was an experiment to clone
Thinkmap's Visual Thesaurus using HTML,
JavaScript, and the WordNet database (on the server side). I ran into some of the
limitations of canvas
, specifically its inability to handle text. But I also
discovered some of its strengths, particularly its simplicity and how well it plays
with
AJAX. You can view my experiment at awordlike.com.
The Supertrain
In this article I'm going to walk through a less complex experiment, using
canvas
to graphically represent the real-time state of a fictional railway
system (download example files).
I'm not going to deconstruct the details of the JavaScript and Ruby code; there are
better
and more comprehensive resources available if you need to get up to speed with these
languages (see the Resources section). OK. Let's get to it:
The State of Washington has finally completed its Supertrain, a public light-rail
system
that has solved the Seattle area's horrendous traffic problems. Part of the Supertrain
infrastructure is a software system that informs the Supertrain command center of
the
whereabouts of the various trains. The team of developers that built this software
did an
exceptional job on the back end, providing a message-based system that allows anyone
in the
network to listen for train status events. Unfortunately, they didn't put much focus
on the
front end of the system, providing their users with a text-based web page that requires
a
manual browser refresh to view the latest state of the system. I have been asked to
write a
new front end that dynamically and graphically represents the status of each Supertrain
line. The State of Washington has asked me to do this for just one of their Supertrain
lines
initially, as a proof of concept. Before I can start working with AJAX and
canvas
on the client side, I need a way to poll a train line's status via a
web server. I like to take small steps, so I'll start by getting Ruby's WEBrick
up and running as soon as possible, mounting a closure and a docroot.
server.rb
require 'webrick' include WEBrick server = HTTPServer.new( :Port => 8053 ) server.mount("/", HTTPServlet::FileHandler, "./docroot") server.mount_proc("/train/line") do |request, response| response['Content-Type'] = "text/plain" response.body = "toot, toot" end trap("INT") { server.shutdown } server.start
If you execute this script (ruby server.rb
) and point your browser to http://localhost:8053/train/line, you should
see:
Figure 1.
I have created a local directory named docroot, which is where I'll stick my HTML. For now, I'll drop in a placeholder.
docroot/redwood.html
<html> <body> hello woodinville! </body> </html>
Now I will develop my /train/line closure in order to output something slightly more useful. I'm using JSON as the protocol between server and client because it's dead simple in JavaScript.
server.rb
... require 'trainspotter' ... train_spotter = TrainSpotter.new server.mount_proc("/train/line") do |request, response| response['Content-Type'] = "text/plain" json = train_spotter.status_report. map { |train| '{"track": "' + train.track.to_s + '", "location": ' + train.location.to_s + '}' }. join ',' response.body = "[ #{json} ]" end ...
trainspotter.rb
class TrainSpotter def status_report [ Status.new("south", 20) ] end end class Status attr_reader :track, :location def initialize(track, location) @track = track @location = location end end
Pointing my browser to http://localhost:8053/train/line now yields something only slightly more useful, but it's progress:
Figure 2.
What I want is to have my TrainSpotter
object act as if it contained a
constantly updated status report. For now I'll implement a simplistic version of this
behavior to give me some realistic data:
trainspotter.rb
TRACKS = [:north, :south] TRAINS_PROGRESS = {:north => 5, :south => 420} MAX_SPEED = 5 class TrainSpotter def status_report report = [] TRAINS_PROGRESS[:north] += rand(MAX_SPEED) report << Status.new("north", TRAINS_PROGRESS[:north]) TRAINS_PROGRESS[:south] -= rand(MAX_SPEED) report << Status.new("south", TRAINS_PROGRESS[:south]) end end ...
Now when I point my browser to http://localhost:8053/train/line and repeatedly refresh, I see the data changing! It shows the apparent progress of a train heading south from Woodinville to Redmond, along with a train heading north from Redmond to Woodinville.
Figure 3.
Next I will AJAX-ificate the redwood.html page to save me from having to repeatedly click Refresh. To accomplish this, I'll use the insanely simple Prototype library.
docroot/redwood.html
<html> <head> <script type="text/javascript" src="prototype-1.4.0.js"></script> </head> <body> <div id="status"></div> <script type="text/javascript"> new Ajax.PeriodicalUpdater($("status"), "/train/line") </script> </body> </html>
Pointing my browser to http://localhost:8053/redwood.html, I now see my trains' status updating every 2
seconds (the default polling period for Prototype's Ajax.PeriodicalUpdater
).
Cool! Unfortunately my customer won't be so easily impressed. It's time to turn that
server-side state into dynamically updating client-side graphics. I'll continue taking
small
steps, so, like any good railroad project should, I'll start with some tracks:
docroot/redwood.html
... <body> <canvas id="redwood" width="500" height="120" style="border: 1px solid black"> </canvas> <script type="text/javascript"> var tracks = { north: new Track(30), south: new Track(85) } var canvas = undefined // IE will return false here if ($("redwood").getContext) { canvas = $("redwood").getContext("2d") drawTracks() } function drawTracks() { $H(tracks).values().each(function(track) { track.draw() }) } function Track(y) { this.y = y this.startX = 10 this.endX = 490 this.tieSize = 3 this.tieGap = 5 this.draw = drawTrack } function drawTrack() { canvas.moveTo(this.startX, this.y) canvas.beginPath() var x = this.startX while (x < this.endX) { canvas.lineTo(x, this.y) canvas.lineTo(x, this.y + this.tieSize) canvas.moveTo(x, this.y) canvas.lineTo(x, this.y - this.tieSize) canvas.moveTo(x, this.y) x = x + this.tieGap } canvas.closePath() canvas.stroke() } </script> <div id="status"></div> ...
Figure 4.
Note that I'm not using the standard JavaScript for loops. Since I'm using the Prototype
(1.4.0) JavaScript library for AJAX, I'm taking advantage of its Ruby-like collection
iterators and syntactic sugar: $()
, $H().values()
, and
each()
. Now that my tracks are laid, I need to drop in some trains. First,
I'll remove my Ajax.PeriodicalUpdater
example, replacing it with a
window.setInterval
call (setInterval
is an essential ingredient
of creating a dynamic canvas). I'll also refactor drawTracks
into a
higher-level updateCanvas
function:
docroot/redwood.html
... if ($("redwood").getContext) { canvas = $("redwood").getContext("2d") window.setInterval(updateCanvas, 1000 * 2) updateCanvas() } function updateCanvas() { clearScreen() drawTracks() } function clearScreen() { canvas.clearRect(0, 0, $("redwood").width, $("redwood").height) } function drawTracks() { ...
No trains yet, but our canvas is now redrawing itself every 2,000 milliseconds (aka
2
seconds). On to the good part. I'll represent the trains with images, using
canvas
' drawImage
method. Now that I have
window.setInterval
handling my periodic executions, I can use Prototype's
vanilla Ajax.Request
to grab the trains' status. Once I have the data from the
server, I update the location of my train images. The following code should animate
the
trains, showing their real-time progress:
docroot/redwood.html
var trains = { north: new Train("train-lr.png", 5), south: new Train("train-rl.png", 60) } ... function updateCanvas() { clearScreen() drawTracks() new Ajax.Request("/train/line", { onComplete: function(request) { var jsonData = eval(request.responseText) if (jsonData == undefined) { return } jsonData.each(function(data) { trains[data.track].update(data.location) }) } }) } ... function Train(image, y) { this.image = new Image() this.image.src = image this.y = y this.update = updateTrain } function updateTrain(location) { canvas.drawImage(this.image, location, this.y) } ...
Figure 5.
We've pulled several concepts together in this latest step. We have made an asynchronous
call using Prototype, received the JSON string in the response and called
eval()
on it to marshal it into an array of JavaScript objects. We use
Train
objects to hold the initial train state and update behavior.
Unfortunately, the latest code creates an undesirable flicker in the train images.
This is
caused by the time that elapses between clearing the screen and the AJAX
onComplete
callback that draws the images. By moving the
clearScreen
call up to the last possible moment before the trains are drawn,
the annoying flicker is removed.
docroot/redwood.html
... function updateCanvas() { new Ajax.Request("/train/line", { onComplete: function(request) { var jsonData = eval(request.responseText) if (jsonData == undefined) { return } clearScreen() jsonData.each(function(data) { trains[data.track].update(data.location) }) drawTracks() drawHotspots() } }) } ...
One final piece of functionality is needed. Train locations are just one of the possible
status items that we can display. The Supertrain system also tracks "hotspots," places
along
the train line where there have been slowdowns or incidents. I'll begin implementing
hotspots by hardcoding a few hotspot locations in the TrainSpotter
, and mount a
new closure on WEBrick
to expose the hotspots to the web client.
server.rb
... server.mount_proc("/train/line") do |request, response| response['Content-Type'] = "text/plain" json = train_spotter.status_report. map { |train| '{"track": "' + train.track.to_s + '", "location": ' + train.location.to_s + '}' }. join ',' response.body = "[ #{json} ]" end server.mount_proc("/train/hotspots") do |request, response| response['Content-Type'] = "text/plain" json = train_spotter.hot_spots map { |train| '{"track": "' + train.track.to_s + '", "location": ' + train.location.to_s + '}' }. join ',' response.body = "[ #{json} ]" end ...
trainspotter.rb
class TrainSpotter ... def hot_spots [ Status.new(:north, 125), Status.new(:south, 250), Status.new(:south, 150) ] end end
When you look a the /train/hotspots closure, hopefully you're feeling
uncomfortable. I just introduced a bunch of duplication. Let's fix that by extracting
a
function to convert Status
objects into JSON strings.
server.rb
... def status_list_to_json(list) json = list. map { |train| '{"track": "' + train.track.to_s + '", "location": ' + train.location.to_s + '}' }. join ',' "[ #{json} ]" end server.mount_proc("/train/line") do |request, response| response['Content-Type'] = "text/plain" response.body = status_list_to_json(train_spotter.status_report) end server.mount_proc("/train/hotspots") do |request, response| response['Content-Type'] = "text/plain" response.body = status_list_to_json(train_spotter.hot_spots) end ...
Point your browser to http://localhost:8053/train/hotspots and you'll see:
Figure 6.
Now I'll update the client side to display the hotspots. Because hotspot status changes
much less frequently than train status, I'll use a separate window.setInterval
to poll for hotspot data once an hour.
docroot/redwood.html
... <script type="text/javascript"> ... var hotspots = [] var canvas = undefined if ($("redwood").getContext) { canvas = $("redwood").getContext("2d") window.setInterval(updateCanvas, 1000 * 2) window.setInterval(updateHotspots, 1000 * 60 * 60) updateCanvas() updateHotspots() } function updateHotspots() { new Ajax.Request("/train/hotspots", { onComplete: function(request) { hotspots = eval(request.responseText) } }) } function updateCanvas() { new Ajax.Request("/train/line", { onComplete: function(request) { var jsonData = eval(request.responseText) if (jsonData == undefined) { return } clearScreen() jsonData.each(function(data) { trains[data.track].update(data.location) }) drawTracks() drawHotspots() } }) } ... function drawHotspots() { hotspots.each(function(hotspot) { tracks[hotspot.track].drawHotspot(hotspot.location) }) } function Track(y) { ... this.hotspotRadius = 6 this.hotspotColor = "red" this.drawHotspot = drawHotspot } ... function drawHotspot(location) { canvas.moveTo(location, this.y) canvas.beginPath() canvas.fillStyle = this.hotspotColor canvas.arc(location, this.y, this.hotspotRadius, 0, Math.PI, false) canvas.closePath() canvas.fill() } ... </script> ...
Figure 7.
That completes the proof of concept! My customer from the State of Washington is
satisfied
and has asked me to hook the TrainSpotter
class into the production messaging
service for a test run against the real Woodinville-Redmond Supertrain line. If all
goes
well, I'm going to have a lot of work to do...