Leaflet как оболочка для Яндекс-карт. Отображаем 100000 маркеров на карте

Я очень люблю Leaflet. С его помощью можно очень быстро строить свои интерактивные карты. Однако, практически все доступные поставщики тайлов (слоёв для карт) предоставляют свои услуги за весьма внушительные деньги. Существуют такие OpenSource-проекты, как OSM, но не всегда их тайлы удовлетворяют своим внешним видом.

Цель


Цель заключалась в том, чтобы слепить своего полностью бесплатного кентавра. Мне всегда нравились Yandex-карты, но не их API. Поэтому я заинтересовался вопросом внедрения Яндекс-карты, как слоя для Leaflet.

Пример готового приложения. В репозитории 48 Мбайт дамп базы.

Беглое исследование


Проинспектировав запросы легальной Яндекс-карты, я вычислил сервер тайлов с которым идет общение.

'http://vec{s}.maps.yandex.net/tiles?l=map&v=4.55.2&z={z}&x={x}&y={y}&scale=2&lang=ru_RU'

{s} - поддомен (subdomain), необходим для того, чтобы не попасть в лимит браузера по запросам к одному и  тому же домену. Эмпирическим путем удалось вычислить, что это 01, 02, 03, 04
{z} - масштаб слоя (zoom)
{x - широта (latitude)
{y} - долгота (longitude)


Это все данные, которые нам необходимы, чтобы использовать тайлы Яндекс-карты внутри Leaflet.

Реализация


Для бэкенде я буду использовать Ruby On Rails, чтобы слегка развеять миф о том, что рельсы медленные. Ведь выводить на карту мы будем 100 тысяч маркеров!

Первым делом создадим модель Marker:

rails g model marker


Содержимое миграции
class CreateMarkers < ActiveRecord::Migration
  def change
    create_table :markers do |t|
      t.float :lat
      t.float :lng
      t.string :name
      t.string :avatar
      t.string :website
      t.string :email
      t.string :city
      t.string :address
      t.string :phone
      t.text :about

      t.timestamps null: false
    end
  end
end


rake db:create
rake db:migrate

Я написал небольшую фабрику, генерирующую 100000 маркеров с заполненными Фейкером полями. Я использую PostgreSQL. Дамп базы можно найти в db/db.dump.

Фабрика
# test/factories/markers.rb
FactoryGirl.define do
  factory :marker do
    lat {Faker::Address.latitude}
    lng {Faker::Address.longitude}
    avatar {Faker::Avatar.image}
    name {Faker::Name.name}
    website {Faker::Internet.url}
    email {Faker::Internet.email}
    city {Faker::Address.city}
    address {Faker::Address.street_address}
    about {Faker::Hipster.paragraph}
    phone {Faker::PhoneNumber.cell_phone}
  end
end

# db/seeds.rb
100000.times do |num|
  FactoryGirl.create(:marker)
  ap "#{num}"
end


Для управления моделью Marker сгенерируем контроллер markers:

rails g controller markers

Код контроллера
class MarkersController < ApplicationController
  before_action :set_marker, only: [:show]

  def index
    respond_to do |format|
      format.html
      format.json {
        pluck_fields = Marker.pluck(:id, :lat, :lng)
        render json: Oj.dump(pluck_fields)
      }
    end
  end

  def show
    render "show", layout: false
  end

  private
    def set_marker
      @marker = Marker.find(params[:id])
    end
end



Чтобы не терять время на построении AR-объекта, я вызываю метод pluck, который выполняет SELECT-запрос только к нужным мне полям. Это дает значительный прирост в производительности. Результат представляет из себя массив массивов:

[
  [1,68.324,-168.542],
  [2,55.522,59.454],
  [3,-19.245,-79.233]
]

Так же я использую гем Oj для быстрой генерации json. Потери на view не превышают 2 мс для 100000 объектов.

Не забываем указать новый ресурс в routes.rb:

Rails.application.routes.draw do
  root to: "markers#index"
  resources :markers, only: [:index, :show]
end

Приступаем к самой карте.

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

Подключаем все необходимые библиотеки:

application.css
/*
 *= normalize
 *= require leaflet
 *= require prune_cluster
 *= require_tree .
 *= require_self
 */



application.js
//= require jquery
//= require leaflet
//= require prune_cluster
//= require_self
//= require_tree .


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

markers/index.html.slim


application.css
#map {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
}


Теперь мы можем нарисовать leaflet-карту:

var map = L.map('map').setView([54.762,37.375], 8), // Карта внутри блока #map
      leafletView = new PruneClusterForLeaflet(); // Кластер, в который мы будем складывать маркеры

Так как карта не имеет ни одного слоя, мы увидим только серый фон. Добавить слой на карту очень просто:

L.tileLayer(
  'http://vec{s}.maps.yandex.net/tiles?l=map&v=4.55.2&z={z}&x={x}&y={y}&scale=2&lang=ru_RU', {
    subdomains: ['01', '02', '03', '04'],
    attribution: 'Яндекс',
    reuseTiles: true,
    updateWhenIdle: false
  }
).addTo(map);

Теперь внутри контейнера #map отображается привычная нам Яндекс-карта. Однако, нам необходимо переопределить проекцию на карту с меркатора на координатную, иначе будет заметный сдвиг по координатам. Заодно укажем, откуда leaflet должен забирать дефолтные иконки для маркеров.

map.options.crs = L.CRS.EPSG3395;
L.Icon.Default.imagePath = "/leaflet";

Осталось запросить все маркеры и отрисовать их на карте:

jQuery.getJSON("/markers.json", {}, function(res){
  res.forEach(function (item) {
    leafletView.RegisterMarker(new PruneCluster.Marker(item[1], item[2], {id: item[0]}));
  });
  map.addLayer(leafletView);
})

Сейчас наша карта не несет никакого смысла, так как нельзя получить никакой информации о маркере. Добавим Popup, который будет вызываться при клике по маркеру и забирать содержимое с сервера:

leafletView.PrepareLeafletMarker = function (marker, data) {
  marker.on('click', function () {
    jQuery.ajax({
      url: "/markers/"+data.id
    }).done(function (res) {
      marker.bindPopup(res);
      marker.openPopup();
    })
  })
}

Создадим соответствующую разметку для Popup:

markers/show.html.slim
h1
  | #{@marker.name}
.popup__address
  | #{@marker.city}, #{@marker.address}

.nowrap
  .popup__avatar
    img src="#{@marker.avatar}" width="120" height="120"
  .popup__contacts
    .popup__contact
      b Телефон:
      div
        | #{@marker.phone}
    .popup__contact
      b Эл. почта:
      div
        a href="mailto:#{@marker.email}"
          | #{@marker.email}
    .popup__contact
      b Вебсайт:
      div
        a href="${website}" target="_blank"
          | #{@marker.website}
p
  | #{@marker.about}


Итог


Мы интегрировали Leaflet c Яндекс-картами, а значит нам стали доступны все плагины для leaflet-карт. Написанное приложение не только выдерживает нагрузку в 100000 маркеров, но еще при этом обладает достаточно полезным функционалом.

Пример готового приложения. В репозитории 48 Мбайт дамп базы.

© Habrahabr.ru