HTML-элемент
canvas («холст»), впервые появившийся в браузере
Safari фирмы Apple, позволяет веб-разработчикам создавать двумерные
рисунки при помощи JavaScript. Теперь, когда его поддержка
появилась и в недавно вышедшем Firefox 1.5, HTML-холст стал
использоваться ещё шире (этот элемент даже планируется включить в
стандарт HTML 5). Дело осталось только за Microsoft: неизвестно,
сколько времени пройдёт, прежде чем поддержка элемента
canvas появится и в Internet Explorer. Однако я верю,
что при продолжающемся сейчас движении в сторону Web 2.0 HTML-холст
оказывается неожиданно полезным ресурсом для всех тех приложений,
которые могут пожертвовать поддержкой IE, — особенно в свете
появления сейчас JavaScript-библиотек, позволяющих на полную мощь
использовать класс
XMLHttpRequest. Я попробовал использовать
canvas и XHR совместно, и это произвело на меня
сильное впечатление (интересно отметить забавное совпадение: их
разработчики — фирмы Apple и Microsoft соответственно) :-)
Моим первым экспериментом по сочетанию
canvas и AJAX была попытка воспроизвести
наглядный словарь синонимов от Thinkmap, используя только HTML, JavaScript, и (на
серверной стороне) базу данных WordNet. У HTML-холста обнаружились
некоторые ограничения, с которыми я был вынужден столкнуться — в
первую очередь, его неспособность отображать текст. С другой
стороны, обнаружились и его сильные стороны — такие, как простота и
удобство интеграции с AJAX. То, что у меня получилось, вы можете
увидеть по адресу
awordlike.com.
В этой статье я собираюсь продемонстрировать вам менее сложный эксперимент, в котором HTML-холст будет использоваться для отображения в реальном времени состояния воображаемой железной дороги (загрузить файлы с примерами). Я не буду углубляться в детали использования JavaScript и Ruby; если вам требуется помощь в освоении этих языков, то материалы, с которыми вам стоит ознакомиться, приведены в разделе «Ссылки». Теперь перейдём к делу.
Штат Вашингтон завершил, наконец, создание Суперпоезда — системы
пригородных поездов, призванной избавить окрестности Сиэтла от их
знаменитых транспортных пробок. Частью этого проекта являлась
разработка программного комплекса, передающего в центр управления
Суперпоездом координаты каждого поезда. Команда разработчиков этого
комплекса потрудилась на славу: создана система рассылки сообщений,
позволяющая любому пользователю в сети Суперпоезда получать
оповещения о состоянии поездов. К несчастью, они уделили меньше
внимания пользовательскому интерфейсу этого комплекса, и диспетчеры
Суперпоезда вынуждены довольствоваться текстовой веб-страничкой,
которую всякий раз, когда требуется получить свежую информацию,
нужно обновлять в браузере вручную. Моё задание состояло в создании
нового пользовательского интерфейса, который бы графически
отображал состояние всех поездов Суперпоезда в реальном времени.
Сначала, в качестве эксперимента, требовалось создать новый
интерфейс только для одной линии Суперпоезда. Но прежде чем я смог
бы задействовать с клиентской стороны AJAX и
canvas, нужен был способ опрашивать состояние поездов
через веб-сервер. Я начал с малого: первым делом запустил Ruby
WEBrick, и подключил замыкание 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
Если вы запустите этот скрипт (
ruby server.rb) и откроете в браузере адрес
http://localhost:8053/train/line,
то вы должны увидеть это:
Рисунок 1.
Я создал папку docroot, в которой буду размещать HTML-файлы. Пока что пусть там лежит заглушка:
docroot/redwood.html
<html>
<body>
hello woodinville!
</body>
</html>
Теперь я создам замыкание /train/line, которое будет выводить чуть более полезную информацию. В качестве протокола между клиентом и сервером я использую JSON, потому что это самое простое, что только можно использовать из 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
Открыв в браузере страницу по адресу http://localhost:8053/train/line, теперь можно увидеть нечто лишь чуть более полезное но всё ещё впереди.
Рисунок 2.
То, что мне на самом деле нужно — чтобы объект
TrainSpotter вёл себя так, как будто он содержит
постоянно обновляющийся отчёт о состоянии поезда. Пока что я задам
ему простую систему поведения, которая давала бы мне достаточно
правдоподобные данные:
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
...
Теперь, когда я открываю в браузере адрес http://localhost:8053/train/line и обновляю страницу несколько раз подряд, я вижу, как данные изменяются! Прекрасно видно, как один поезд едет на юг, из Вудинвилля в Редмонд, а другой — на север, из Редмонда в Вудинвилль.
Рисунок 3.
Следующий этап — задействование AJAX в redwood.html, чтобы не приходилось постоянно обновлять страницу вручную. Идеально подходит для этого невероятно простая библиотека Prototype:
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>
Открыв в браузере страницу
http://localhost:8053/redwood.html,
я теперь вижу, что состояние поездов обновляется каждые две секунды
(таков интервал опроса по умолчанию у объекта
Ajax.PeriodicalUpdater библиотеки Prototype). Здорово!
Но думаю, что всего этого ещё недостаточно, чтобы поразить
заказчика. Настало время заменить отчёт о состоянии динамически
обновляющимся рисунком на клиентской стороне. Я по-прежнему начинаю
с малого — то есть, как и следует в любом железнодорожном проекте,
с рельсов:
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>
...
Рисунок 4.
Обратите внимание, что я не пользуюсь для организации циклов
обычным синтаксисом JavaScript. Раз уж я использую для AJAX
JavaScript-библиотеку Prototype (1.4.0), то логично воспользоваться
встроенными в неё итераторами коллекций в стиле Ruby и другими
синтаксическими приятностями, такими как
$(),
$H().values() и
each(). Сейчас, когда рельсы уложены, нужно пустить по
ним сами поезда. Первым делом я откажусь от использования объекта
Ajax.PeriodicalUpdater и заменю его вызовом
window.setInterval (
setIntervalиграет важную роль в организации
динамического обновления HTML-холста). Ещё я оформлю
drawTracks в виде функции более высокого уровня —
updateCanvas.
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() {
...
Холст уже автоматически обновляется каждые 2000 миллисекунд
(т.е. 2 секунды), но поездов ещё нет. Займёмся теперь ими.
Картинки, изображающие поезда, можно наносить на HTML-холст методом
drawImage элемента
canvas. Сейчас, когда регулярное обновление
производится методом
window.setInterval, я могу считывать состояние поездов
при помощи самого низкоуровневого объекта библиотеки Prototype —
Ajax.Request. Как только я получу от сервера данные, я
обновляю расположения картинок, изображающих поезда. Приведённый
здесь код динамически обновляет положение поездов, отображая их
движение в реальном времени:
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)
}
...
Рисунок 5.
На этом этапе мы соединили вместе несколько механизмов.
Используя Prototype, мы выполняем асинхронный вызов, получаем в
ответ JSON-строку, и вызовом функции
eval() преобразуем эту строку в массив
JavaScript-объектов. Объекты
Train описывают состояние каждого поезда и действия,
необходимые для его отображения. У приведённого кода есть
недостаток — неприятное мерцание изображений поездов. Оно
происходит из-за того, что между очисткой экрана и вызовом события
onComplete , обработчик которого рисует поезда,
проходит некоторое время. Так что избавиться от надоедливого
мерцания просто — достаточно вызывать
clearScreen в самый последний момент перед отрисовкой
поездов.
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()
}
})
}
...
Осталось добавить последнюю деталь. Расположения поездов — это
лишь один из типов информации о линии Суперпоезда, которую мы можем
отображать в нашей системе. Кроме этого, программный комплекс
Суперпоезда отслеживает «горячие точки» — те участки
железнодорожного пути, на которых происходили столкновения или
незапланированные остановки. Я начну добавление в нашу систему этих
горячих точек с того, что жёстко задам их расположения в классе
TrainSpotter и подключу к
WEBrick новое замыкание, которое бы передавало эти
расположения веб-клиенту.
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
Надеюсь, вы не удовлетворены приведённым кодом замыкания
/train/hotspots — действительно, он практически совпадает
с кодом
/train/line. Можно избавиться от возникшего дублирования,
выделив в отдельную функцию преобразование объектов
Status в JSON-строки.
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
...
Если вы сейчас откроете в браузере адрес http://localhost:8053/train/hotspots, то вы увидите это:
Рисунок 6.
Теперь надо добавить отображение горячих точек на клиентской
стороне. Поскольку их расположения меняются не так часто, как
расположения поездов, то я буду использовать отдельный вызов
window.setInterval, чтобы запрашивать у сервера данные
о горячих точках раз в час.
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>
...
Рисунок 7.
Эксперимент успешно завершён! Заказчик, представляющий штат
Вашингтон, полностью удовлетворён созданной системой. Теперь моё
задание состоит в интеграции класса
TrainSpotter с используемой в сети Суперпоезда
системой рассылки сообщений. Если моя система покажет себя на
настоящей линии Вудинвилль—Редмонд достаточно хорошо, то мне
предстоит ещё немало работы над этим комплексом…