Продолжаю изучать JS, параллельно решая практические задачи, и с некоторыми решениями есть непреодолимое желание поделиться с сообществом, как говориться — хоть чуть чуть но должок вернуть.


В прошлой своей статье я рассказал про построение простых графиков с помощью библиотеки d3, с ее же помощью планировал отрисовывать и карты, но поэкспериментировав с d3, Raphael и paper.js понял что велосипедостроения избежать не удастся и переделал отрисовку на HTML Canvas, о чем и хочу рассказать в данной статье.


На самом деле карты — это конечно же слишком громко сказано, т.к. самих карт как таковых то и нет. Задача такая — отрисовывать по координатам точечные объекты и контуры. Рисование контуров я оставлю за рамками этой статьи и расскажу о банальной в общем то (как мне казалась раньше) задаче — выводе точек по координатам.


Первая мысль была — конечно же воспользоваться готовой картографической библиотекой (еще даже до плотного знакомства с d3 ставил с ними опыты). Сложностей было две, первое — условные координаты и второе — большое количество объектов (до 10 тыс. точек на карту). И если с условными координатами leaflet.js справился то отображение на нем огромного количества точек в рамках одной карты показал что нужно смотреть в другую сторону.


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


Следующим был Raphael.js — прекрасная графическая библиотека и в сравнении с той же d3 очень простая и понятная (простая в смысле простоты использования). На Рафаэле я реализовал практически все что мне было нужно, плюс сама библиотека предоставляла огромное количество плюшек и удобств, и будь у меня задачи немного другие пользовался бы Рафаэлем и радовался. Но снова уткнувшись в ограничения и мельком попробовав paper.js перешел к чистому HTML canvas. Правда к этому времени я переписал уже практически все и для того чтобы перейти с Рафаэля на канвас пришлось заменить максимум с десяток строчек в коде.


Ну хватит со вступлениями, перехожу к реализации. Первое что нам нужно для эффективного отображения карты это иметь собственный Viewport (википедия переводит этот термин как Порт просмотра, что мне кажется корявым, потому буду писать дальше как вьюпорт). Термин этот пришел пришел из 3Д, а так же активно используется в 2Д играх и означает в нашем случае отображение только той части карты, который мы хотим увидеть.


Снова я решил не изобретать велосипед и взять какую-нибудь готовую абстрактную реализацию вьюпорта для 2Д и прикрутить к своим картам. Полдня потратив на активные поиски и так ничего подходящего и не нашел, что до сих пор немного удивляет. Зато теперь у меня собственная полностью абстрактная реализация 2Д вьюпорта — будет с чего начинать если вдруг приспичит собственную игрушку написать без использования фреймворков.


Вот так выглядит вьюпорт (Чтобы не загромождать статью, даю выжимку кода, который целиком можно будет посмотреть на Гитхабе по ссылке в конце статьи).


class Viewport {
  constructor(param) {
    this.updateCallback = param.update;
    this.size = param.size;
    this.map = param.map;
...
  };

  set Center(koordXY) { ... };
  get Center() { ... };
  set Zoom(zoomXY) { ... };
  get Zoom() { ... };
  set Size(sizeWH) { ... };
  get Size() { ... };

  show() {
    this.vp = {
      x1: this.vX,
      x2: this.vX + this.size.w / this.zoom.x,
      y1: this.vY,
      y2: this.vY + this.size.h / this.zoom.y,
      zX: this.zoom.x,
      zY: this.zoom.y
    };
    this.updateCallback(this.vp);
  };

  caclViewPort() { ... };
  calcCenter() { ... };
  calcMaxZoom() { ... };
};

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


Ну вот, самое главное и сложное реализовано — а в остальном уже все просто:


class XyMap {
  constructor(container) {
    this.container = (typeof container === 'string') ? document.getElementById(container) : container;
    ...
    this.objects = []; //{ id: 1, caption: 'Obj1', type: 'circe', x: 0, y: 0, r: 5, color: 'red' }
    this.viewPort = null;
  };
    //add object for draw to array
  add(obj) {  ... };
  init() {
    let id = this.container.id +'_canvas';
    this.container.innerHTML = `<canvas id="${id}" width="${this.container.offsetWidth-1}" height="${this.container.offsetHeight-1}"></canvas>`;
    this.canvas = document.getElementById(id);
    this.viewPort = new Viewport({
      update: (vp) => { this.drawViewport(vp); },
      size: { w: this.container.offsetWidth-1, h: this.container.offsetHeight-1},
      map: this.limit,
      oneZoom: true
    });
    this.handleEvent = function(ev) {
      switch(ev.type) {
        case 'mousedown':
          ...
        case 'mousemove':
          ...
        case 'mouseup':
          ...
        case 'wheel':
          ...
      }
    };
    ...
  };
  scroll(x, y) { ... };
  show() { this.viewPort.show(); };
  zoomIn(value) {
    ...
    this.viewPort.Zoom = z;
    this.viewPort.show();    
  };
  zoomOut(value) {
    ...
    this.viewPort.Zoom = z;
    this.viewPort.show();
  };
  //callback for viewport vp = { x1, x2, y1, y2, zX, zY }
  drawViewport(vp) {
    let x,y,obj,objT;
    let other = this;
    let ctx = this.canvas.getContext('2d');
    let pi2 = Math.PI*2;
    ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.objects.filter(d => d.x>=vp.x1 && d.x<=vp.x2 && d.y>=vp.y1 && d.y<=vp.y2).forEach(function(d) {
      x = (d.x - vp.x1) * vp.zX; y = (d.y - vp.y1) * vp.zY;
      if (d.type === 'circe') {
        ctx.beginPath();
        ctx.arc(x,y,d.r,0,pi2);
        ctx.fillStyle = d.color;
        ctx.fill();
        ctx.lineWidth = 0.5;
        ctx.strokeStyle = 'black';        
        ctx.stroke();
        ctx.fillStyle = 'black';
        ctx.font = '8pt arial';
        ctx.fillText(d.caption, x-20, y-9);
      };
    });
  };
};

Создаем экземпляр класса XyMap, методом add передаем ему объекты для отрисовки, после чего вызываем инициализацию в процессе которой создается вьюпорт. После этого вызываем метод show — и вуаля, карта у нас на экране.


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


Саму программу и пример использования можно посмотреть на Гитхабе.


UPD.: Онлайн пример в JS-песочнице.
UPD 2.: Доработал компонент: появились слои, объекты теперь можно выбирать как по клику так и извне компонента по Id, для пространственного поиска используются R-деревья (JS библиотека RBush ).

Поделиться с друзьями
-->

Комментарии (9)


  1. prostofilya
    08.12.2016 10:08

    1) И что в итоге, как с быстродействием то? На картинке у вас не так много точек, по крайней мере, точно не те цифры, о которых вы говорите(10 тыс.).
    2) А как дела с проекцией? Возможна ли привязка точек к проекции?
    Я вот для карт использую openlayers. Сейчас как раз в одной работе отображаю грозы на карте с помощью неё, при зуме, который охватывает всю карту — клиент начинает жутко тормозить. Поэтому пришлось уменьшить максимальный зум. В данный момент чуть больше 23 тыс. точек.


    1. Petrichuk
      08.12.2016 10:40

      Сорри, промазал по кнопке — ответ ниже.


  1. Petrichuk
    08.12.2016 10:39

    1. Картинка просто для демонстрации. Вот онлайн-пример на 10 тыс.точек — попробуйте сами. (обнаружил проблему — в Firefox не правильно обрабатывается колесико мыши, сорри — пока еще не исправил).
    2. Проекциями пока еще не занимался но думаю доработать не сложно при необходимости.


    1. prostofilya
      08.12.2016 11:04

      Вот интересно будет посмотреть на результат с проекциями.


      1. Petrichuk
        08.12.2016 11:16

        У меня пока нет задач с реальными координатами, но тема интересна — потому могу реализовать если сформулируете ТЗ с контрольным примером.


  1. mad_god
    08.12.2016 16:57

    Интересно, в каком формате хранится карта. Когда делал свою поделку, придумал велосипед в виде дробления карты на квадранты и подгрузки квадрантов, которые во вьюпорте и вокруг него, для сдвига карты.
    А как это делают нормальные люди?


    1. Petrichuk
      08.12.2016 17:03

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


  1. trir
    11.12.2016 08:54

    1. Petrichuk
      11.12.2016 12:56

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