Canvas, Ajax, and the Supertrain
December 23, 2005
Dave Hoover : Home

Update: To learn about how to use canvas to develop interactive, collaborative, graphical applications (on all major browsers), read The Interactive Canvas and Internet Explorer after this article...

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 which 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 (live example). 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 on the whereabouts of their 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 onto 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...

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.

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.

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.beginPath()
    canvas.moveTo(this.startX, this.y)
    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.stroke()
  }
</script>

<div id="status"></div>
...

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 took 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 2000 milliseconds (aka 2 seconds). On to the good part. I'll represent the trains with images, using canvas's 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)
  }
  ...

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 marshall 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 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...

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.fillStyle = this.hotspotColor
    canvas.arc(location, this.y, this.hotspotRadius, 0, Math.PI, false)
    canvas.fill()
  }
  ...
</script>
...

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...

Resources


Home : Dave Hoover Copyright © 2005 Dave Hoover All Rights Reserved.