Карта на Canvas

Не так давно, для одного проекта потребовалось написать карту, которая будет отвечать следующим требованиям:
  • Плавная прокрутка
  • Подгрузка областей карты


Мне пришлось потратить несколько дней на то, чтобы определиться в том, как лучше всего решить данную задачу.
В итоге я остановился на canvas.
Я потратил долгое время на поиски в интернете аналогичных решений, но к моему удивлению ничего подобного не нашлось.
В результате я решил написать все сам, с нуля.
К сожалению первая версия оказалась тормознутой слишком медленной, движения карты, в некоторых браузерах, были скачкообразными.

В новой версии я учел все ошибки, и в итоге мне удалось добиться того, что карта соответсвовала заявленным требования.

Подготовка


Я не буду описывать подготовительные этапы, они уже много раз описывались на хабре, по этому я уделю внимание тому, где у меня возникли проблемы.
Основа, ядро карты, лежит в файле core.js, для работы с canvas у меня имеется отдельный файл canvas.js.

Для инициализации карты, в файле index.html я создаю объект, в который передаю размер карты, и начальные координаты.
    var map = new Zig.Map.Core($('body').width(), $('body').height(), 100, 100);
    map.addEventListener('change', function(data){
        $('#coord').html('Выбранные координаты: ' + data.x + ':' + data.y);
    });


В процессе инициализации создается объект, отвечающий за работу с canvas. На данный момент, все функции для работы с ним публичные,
но в дальнейшем я планирую сделать большинство функций приватными, дабы никто не мог рисовать на моем холсте.

У меня создается массив canvas-ов, где первый это основной, расположенный на экране, а все остальные это буфера, позже я объясню зачем их так много.
Сразу после инициализации, вызывается функция перехода на определенные координаты goto(x, y, callback), которая подгружает область карты вокруг запрошенных координат.
В связи с тем что это прототип, я не стал делать полноценное получение карты по ajax, заменив неким аналогом:

_get_ajax_map : function(coords, callback) {
    setTimeout(function(){
        // Генегируем ответ аякса
        var map = {};
        for(var x = Math.min(coords.x1, coords.x2); x <= Math.max(coords.x1, coords.x2); x++) {
            for(var y = Math.min(coords.y1, coords.y2); y <= Math.max(coords.y1, coords.y2); y++) {
                if (typeof map[x] == 'undefined') {
                    map[x] = {};
                }

                if (x < 0 || y < 0) {
                    // пустота (море, пустыня, космос, на ваше усмотрение)
                    map[x][y] = { image : null };
                } else {
                    map[x][y] = { image : 'img/' + (((y * 200 + x) % 7 + 2) + '.png') };
                }
            }
        }

        callback && callback(map);
    }.bind(this), 0);
}

Используя setTimeout я эмулирую получения ответа асинхронно.

Рендеринг


Рендеринг разбит на несколько частей, вызов последующая отрисовка на экран происходит в canvas.js, а оснонная работа, связанныя
со всевозможными вычислениями производится в core.js.

render : function(buffer, buffer2, mouse) {
    this._checkMoveMap(mouse);

    if (this._rebuild_buffer) {
        // Перестраиваем буфер
        this._rebuild_buffer  = false;
        this._rebuild_buffer2 = false;

        this._rebuildBuffer(buffer);
        this._rebuildBuffer2(buffer2);
    } else if (this._rebuild_buffer2) {
        this._rebuild_buffer2 = false;
        this._rebuildBuffer2(buffer2);
    }

    return this._options.pos.offset;
}


Первым делом у меня заполняются 2 буфера, присваивается переменной this._rebuild_buffer = false;, которая указывает на то, что в
следующем такте не нужно обновлять буфера.
В случае, если эта переменная, станет true, при следующем такте перестроится буфер. Сделал я это затем, чтобы не нагружать лишний раз бразуер
ненужной работой.

После перестройки выполнения этой функции, я просто чищу основной буфер, и рисую поверх него 2 буфера, с некоторым смещением, которое получил в ответ.

Отлов событий мыши


В первой версии карты, у меня была большая проблема. Сразу после получения события о том что произошло движение по окну с нажатой кнопкой мыши,
я запускал кучу пересчетов, и даже перестроение буферов. Я думаю не нужно говорить, что события от мыши, могут приходить чаще чем 60 раз в секунду.
В новой версии я учел ошибку, и стал запоминать все действия мыши, и забирать их при рендеринге. В итоге сколько бы событий не произошло,
обработка все равно будет происходить не чаще чем 60 раз в секунду.

Вот так я запоминаю движение мыши по экрану:
_move: function(e) {
    var x = e.offsetX || e.layerX,
    y = e.offsetY || e.layerY;

    this.diff.x += Math.abs(this.pos.x - x);
    this.diff.y += Math.abs(this.pos.y - y);

    if (this.pressed) {
        this._addToAction('drag', this.pos.x - x, this.pos.y - y);
    } else {
        this._action.move = {x : x, y : y};
    }

    this.pos.x = x;
    this.pos.y = y;
},

_addToAction : function(key, x, y) {
    if (typeof this._action[key] == 'undefined') {
        this._action[key] = {x : 0, y : 0};
    }

    this._action[key].x += x;
    this._action[key].y += y;
}


Как видите, у меня есть два события drag и move, чтобы я мог отличать где таскают карту, а где просто водят мышкой.
Забирая эти события, переменная чистится:

getAction : function() {
    var action = this._action;
    this._action = {};

    return action;
}


Движение карты


Сначала немного теории.
У меня на экране имеется canvas, размеры которого я задал при инициализации, а так же в памяти имеется еще 3 буфера, размеры которых в два раза больше основного.
Сделано это для того, чтобы не перестраивать буфера при малейшем движении карты. Так буфера построены с запасом, и могут спокойно двигаться по сторонам.
Для того чтобы их разместить правильно, я использую смещение. Т.е. там где у основного canvas-а 0:0, у буферов будет какое-то значение, допустим 512:512.



На картинке, желтый квадрат это основной canvas, красный — буфер, черная точка — запрошенные координаты.
Чтобы сдвинуть карту вбок, на нужно просто буфер немного передвинуть.
Для того, чтобы точно знать насколько смещена карта, у меня имеется 2 переменные, которые по умолчанию равны:

offset : {
    x : ШИРИНА_КВАДРАТА * 4,
    y : ВЫСОТА_КВАДРАТА * 4
}

Фактически, дефолтное смещение равно расстоянию между верхними левыми углами красного и желтого квадрата.

При движении карты, я к этим значений просто добавляю дельту:
    this._options.pos.offset.x += act.drag.x;
    this._options.pos.offset.y += act.drag.y;


А так же изменяю положение верхнего левого квадрата:
    this._options.pos.px.x += act.drag.x;
    this._options.pos.px.y += act.drag.y;

Делается это для того, чтобы я всегда, без проблем, мог вычислить над каким квадратом находится мышка, просто добавив к его
значению координаты мыши.

Таким образом я всегда знаю, где рисовать буфер, так чтобы видимые точки оставались на своих местах.

Но, если двинуть карту далеко, буфер кончится. И чтобы этого не произошло, нужно вовремя обновить буфер, т.е. перестроить его так,
чтобы видимые клетки внешне остались на своих местах.
И чтобы этого добиться, я не просто присваиваю смещению дефолтное значение, но и выполняю расчет по формуле, чтобы узнать
насколько и в какую сторону нужно изменить дефолтное значение смещения, так, чтобы видимые клетки остались на своих местах.
Для того, чтобы понятно это объяснить, давай запомним что, «углом» я буду называть видимый верхний левый угол основного canvas-a, а
«квадратом» — квадрат, которому принадлежит точка, лежащая в «углу», т.е. координаты «угла», находятся где-то внутри этого «квадрата».

Шансов, что координаты «угла» совпадут с координатами верхнего левого угола «квадрата», близки к нулю.
И в связи с этим мы просто вычисляем разницу между ними, которую затем прибавляем к дефолтному смещению.

    this._options.pos.offset.x = w * 4 + (p.px.x - (xy.x + 4) * w);
    this._options.pos.offset.y = h * 4 + (p.px.y - (xy.y + 4) * h);

где
  • w, h — ширина и высота квадрата
  • p.px.x, p.px.y — пиксельные координаты, которые расположены в верхнем левом углу основного канваса
  • (xy.x + 4), (xy.y + 4) — внутренние координаты квадрата, который косается верхнего левого угла канваса


Третий буфер


Третий буфер на данный момент у меня не используется, но создал я его для того, чтобы не обновлять буфер полностью, когда
происходит перемещение карты. Я планирую сделать, чтобы первый буфер не чистился весь, а вставлялся в третий со смещением,
и только пустота смещения заполнялась.
Так будет работать еще быстрее.

Заключение


Мне было интересно заниматься данным проектом. Интересно было на практике изучить canvas в JavaScript, без использования
сторонних библиотек.
Надеюсь вам поможет моя статья измежать таких же ошибок, как допустил я в первой версии.

Исходники


BitBucket
Demo

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 10

    0
    Используйте событие contextmenu, чтобы по правому клику не показывалась менюшка браузера.
    Плюс можно использовать встроенные функции translate и scale для перемещения и масштабирования карты.
      0
      Про меню совсем забыл, спасибо что напомнили.
      Изменение масштаба было в планах на будущее. Это скорее некий скелет, который в дальнейшем будет обрастать необходимыми функциями.
      Перемещение используя translate у меня, честно говоря, не получилось сделать хорошо. По этому решил делать смещением буфера.
      В планах сделать не перестройку полного буфера при движении, а достройку, после смещения, как я уже писал в статье.
        0
        А какие проблемы у вас с translate возникли? Если что, могу помочь, занимаюсь сейчас аналогичным проектом, только частного характера.
        +2
        Плюс можно использовать встроенные функции translate и scale для перемещения и масштабирования карты.

        А какой смысл их использовать? Лучше отрисовать тот же векторный квадрат в изменённый размер — будут чёткие границы и получше контроль, чем у трансформаций
          0
          Смысл лишь в простоте их использования. А разница на самом деле невелика: то ли изменять размер фигуры, то ли устанавливать толщину линии в (1 / scale).
            +1
            Лично для меня смысл чисто эстетический. Как у google или яндекс карт. Сначала уменьшается масштаб, а потом просто подгружаются регионы в новом масштабе.
              0
              Кстати да, для этого как раз scale и translate использовать не стоит, верно. Прошу прощения, думал немного в своем контексте.
          0
          Может лучше Modernizr использовать в минимальной сборке для определения префиксов и фич, а не перебирать список браузеров? А в остальном интересно как концепция.
            0
            Я потратил долгое время на поиски в интернете аналогичных решений, но к моему удивлению ничего подобного не нашлось.
            Чем не подошёл Leaflet? Kothik JS?
              0
              Я имел ввиду не готовых решений, а именно советов/уроков, как это можно сделать самому.
              Я не люблю использовать сторонние библиотеки в принципе, и предпочту написать самостоятельно аналог. И пусть первоначально он будет в разы хуже, но зато он это будет своё, то что я знаю от корки до корки. Заточенное именно под мою задачу, и не имеющую ничего лишнего.

            Only users with full accounts can post comments. Log in, please.