Интерактивный глобус — SVG versus Canvas

  • Tutorial
Доброго времени суток, уважаемый читатель! В прошлый раз мы изучали процесс создания интерактивной карты-хороплета, теперь предлагаю немного усложнить задачу и перейти к трёхмерной модели Земли, именуемой в народе глобусом. Глобус делать будем двух видов: SVG версия и Canvas версия. В обоих случаях будем использовать JavaScript библиотеку d3.js. У каждого варианта свои преимущества. В моём исполнении Голубая планета выглядит следующим образом:

Планета Земля

А как создать свой собственный Мир с материками и океанами можно узнать под катом.

Начало


Сперва нам нужно найти геоданные. Как и в прошлый раз мы будем использовать TopoJSON для этих целей. О том как его получить можно прочитать в предыдущей статье в разделе «Дела картографические». И так у нас есть TopoJSON файл world-110m.json для карты с масштабом 1:110,000,000, или 1 см = 1,100 км (1″ = 1,736 миль) и файл world-110m-country-names.tsv с названиями стран вида id — название страны. Внешний файл с названиями используется для удобства, так как в этом случае можно легко перевести названия на любой язык. Всё, можно приступать непосредственно к созданию глобуса.
Замечание:
В выбранном нами масштабе некоторые маленькие страны «вырождаются» в геометрическом смысле, поэтому в нашем списке всего 174 страны.

Рисуем интерактивный глобус


Нашей целью будет глобус, который можно:

  • вращать мышкой «хватая» за сушу
  • центрировать глобус на страну, выбранную из списка

Шаблон, для тех кому нужно.
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Nice title</title>
  <script src="http://d3js.org/d3.v3.min.js"></script>
  <script src="http://d3js.org/queue.v1.min.js"></script>
  <script src="http://d3js.org/topojson.v1.min.js"></script>
</head>
<style>
  Your awesome CSS       
</style>
<body>
  <h1>Cool Header</h1>
  <script>
    Your awesome d3.js code
  </script>
</body>
</html>


Определим основные переменные и добавим DOM элементы.

  var width = 600,
  height = 500,
  sens = 0.25,
  focused;

  //Setting projection

  var projection = d3.geo.orthographic()
  .scale(245)
  .rotate([0, 0])
  .translate([width / 2, height / 2])
  .clipAngle(90);

  var path = d3.geo.path()
  .projection(projection);

  //SVG container

  var svg = d3.select("body").append("svg")
  .attr("width", width)
  .attr("height", height);

  //Adding water

  svg.append("path")
  .datum({type: "Sphere"})
  .attr("class", "water")
  .attr("d", path);

  var countryTooltip = d3.select("body").append("div").attr("class", "countryTooltip"),
  countryList = d3.select("body").append("select").attr("name", "countries");

Переменная sens отвечает за точность при вращении мышкой, а focused используется как триггер для выбранной (центрированной) страны. Про используемую проекцию можно почитать на wikipedia: Orthographic projection. Метод .clipAngle() определяет какую часть сферы мы будем отображать (а точнее видеть), про это опять же можно почитать на wikipedia: small-circle clipping. Остальное вроде в разъяснениях не нуждается.

Далее мы загружаем наши файлы при помощи библиотеки queue.js, которая позволяет делать нам это асинхронно.

  queue()
  .defer(d3.json, "data/world-110m.json")
  .defer(d3.tsv, "data/world-110m-country-names.tsv")
  .await(ready);

Теперь перейдём к главной функции, в нашем случае она называется ready. Вначале, мы добавляем названия стран в наш dropdown list и отрисовываем страны на глобусе.

  function ready(error, world, countryData) {

    var countryById = {},
    countries = topojson.feature(world, world.objects.countries).features;

    //Adding countries to select

    countryData.forEach(function(d) {
      countryById[d.id] = d.name;
      option = countryList.append("option");
      option.text(d.name);
      option.property("value", d.id);
    });

    //Drawing countries on the globe

    var world = svg.selectAll("path.land")
    .data(countries)
    .enter().append("path")
    .attr("class", "land")
    .attr("d", path)

Перейдём к обработке событий мыши. Здесь пояснений требует drag.origin(), он позволяет нам задать «оригинальные» (действительные) стартовые координаты при захвате элемента, в нашем случае широту и долготу.

    //Drag event

    .call(d3.behavior.drag()
      .origin(function() { var r = projection.rotate(); return {x: r[0] / sens, y: -r[1] / sens}; })
      .on("drag", function() {
        var rotate = projection.rotate();
        projection.rotate([d3.event.x * sens, -d3.event.y * sens, rotate[2]]);
        svg.selectAll("path.land").attr("d", path);
        svg.selectAll(".focused").classed("focused", focused = false);
      }))

    //Mouse events

    .on("mouseover", function(d) {
      countryTooltip.text(countryById[d.id])
      .style("left", (d3.event.pageX + 7) + "px")
      .style("top", (d3.event.pageY - 15) + "px")
      .style("display", "block")
      .style("opacity", 1);
    })
    .on("mouseout", function(d) {
      countryTooltip.style("opacity", 0)
      .style("display", "none");
    })
    .on("mousemove", function(d) {
      countryTooltip.style("left", (d3.event.pageX + 7) + "px")
      .style("top", (d3.event.pageY - 15) + "px");
    });

Для реализации фокусировки на стране нам необходимо написать функцию, которая бы возвращала нам геоданные для страны по её id'шнику. Собственно вот она.

    function country(cnt, sel) { 
      for(var i = 0, l = cnt.length; i < l; i++) {
        if(cnt[i].id == sel.value) {return cnt[i];}
      }
    };

Теперь можно непосредственно перейти к реализации фокусировки (центровки) на стране, выбранной из списка.

    //Country focus on option select

    d3.select("select").on("change", function() {
      var rotate = projection.rotate(),
      focusedCountry = country(countries, this),
      p = d3.geo.centroid(focusedCountry);

      svg.selectAll(".focused").classed("focused", focused = false);

    //Globe rotating

    (function transition() {
      d3.transition()
      .duration(2500)
      .tween("rotate", function() {
        var r = d3.interpolate(projection.rotate(), [-p[0], -p[1]]);
        return function(t) {
          projection.rotate(r(t));
          svg.selectAll("path").attr("d", path)
          .classed("focused", function(d, i) { return d.id == focusedCountry.id ? focused = d : false; });
        };
      })
      .transition();
      })();
    });

Здесь вся соль кроется в transition.tween(), который позволяет нам вызывать заданную функцию (поворот) для каждого интерполированного значения.

Крутится, вертится шар голубой.

Всё — SVG глобус готов. Исходники можно найти на GitHub (там же можно задать вопросы тем у кого read-only на Хабрахабре), а пощупать результат можно через сервис bl.ocks.org.

Давайте рассмотрим преимущества SVG:

  • Возможность взаимодействовать с DOM элементами, в частности path
  • Возможность использовать CSS (как следствие из предыдущего пункта)
  • Текст является текстом, со всеми вытекающими отсюда плюсами

Анимация планеты Земля


С SVG реализацией вроде разобрались, давайте посмотрим как сделать что-то подобное на canvas. Создадим простую анимацию вращения Земли. Тут многое будет аналогично предыдущему примеру. Кода мало, поэтому приведу его весь сразу.

  var width = 800,
  height = 500;

  var projection = d3.geo.orthographic()
  .scale(245)
  .rotate([180, 0])
  .translate([width / 2, height / 2])
  .clipAngle(90);

  var canvas = d3.select("body").append("canvas")
  .attr("width", width)
  .attr("height", height);

  var c = canvas.node().getContext("2d");

  var path = d3.geo.path()
  .projection(projection)
  .context(c);

  function getImage(path, callback) {
    var img = new Image();
    img.src = path;
    img.onload = callback(null, img);
  }

  queue()
  .defer(d3.json, "data/world-110m.json")
  .defer(d3.tsv, "data/world-110m-country-names.tsv")
  .defer(getImage, "data/space.jpg")
  .await(ready);

  //Main function

  function ready(error, world, countryData, space) {

    var globe = {type: "Sphere"},
    land = topojson.feature(world, world.objects.land),
    borders = topojson.mesh(world, world.objects.countries, function(a, b) { return a !== b; });

    //Earth rotating

    (function transition() {
      d3.transition()
      .duration(15000)
      .ease("linear")
      .tween("rotate", function() {
        var r = d3.interpolate(projection.rotate(), [-180, 0]);
        return function(t) {
          projection.rotate(r(t));
          c.clearRect(0, 0, width, height);
          c.drawImage(space, 0, 0);
          c.fillStyle = "#00006B", c.beginPath(), path(globe), c.fill();
          c.fillStyle = "#29527A", c.beginPath(), path(land), c.fill();
          c.strokeStyle = "#fff", c.lineWidth = .5, c.beginPath(), path(borders), c.stroke();
          projection.rotate([180, 0]);
        };
      })     
      .transition().duration(30).ease("linear")
      .each("end", transition);
    })();
  };

Вращение реализовано как поворот из точки [180, 0] в точку [-180, 0], которые совпадают. Таким образом, «интерполятор», не заметив подвоха, сделает то что нам нужно. Потом мы начинаем рисовать на canvas, предварительно очистив его. Рисуем фон, сферу, материки и границы стран. Бесконечное вращение получаем за счёт рекурсивного вызова функции transition.

«И всё-таки она вертится!»

Ну вот мы и создали анимацию. Исходники можно найти на GitHub, а полюбоваться космическими видами можно через сервис bl.ocks.org.

Рассмотрим преимущества Canvas:

  • Более быстрая/плавная работа по сравнению с SVG
  • Возможность интеграции с анимацией, видео, играми и прочими штуками, которые реализуют сегодня с его помощью

Заключение


Вот мы и рассмотрели ещё пару интересных примеров созданных с помощью замечательной библиотеки d3.js. Я старался, чтобы примеры были в меру просты для понимания, наглядны и довольно-таки интересны. В борьбе SVG и Canvas в итоге победила дружба, так использование той или иной технологии зависит от типа вашего проекта. Например, если ваш проект связан с картографией, то целесообразно использовать SVG, если же вы работаете с мультимедиа, то Canvas вам в помощь. Надеюсь, вам было интересно. Удачи и успехов в дальнейшем освоении d3.js!
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 20

    +6
    Спасибо за прекрасный пост перед началом рабочей недели :)

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

    К сожалению, в чистом виде использовать d3 не получается, для себя я цикл enter-update-exit заменил на написанный вручную SVG с привязкой к angular. Это помогает интереснее привязаться к модели (в частности анимировать добавление/удаление элементов в списке) и полноценно взаимодействовать с пользователем. Кроме того, можно эти привязки переиспользовать как ng-директивы.

    P.S. А слабо реализовать в Canvas-версии тот же функционал, что и с SVG? :-)
      0
      Рад, что вам понравилось. Пост опубликовал перед началом рабочей недели, потому что на неделе не до этого было бы, а откладывать до следующих выходных не хотелось. Было бы интересно посмотреть на ваш подход и его преимущества в живую, на примере :-)

      P.S. Реализовать центровку на выбранную из списка страну довольно легко, как и вращение мышкой, а вот показывать tooltip при наведении мыши…
        0
        Можно каждый регион нарисовать в отдельном canvas'е, наложить их друг на друга и tooltip показывать когда пиксели под мышкой непрозрачные :)
      0
      Плюсанул в карму. Спасибо за отличный пост! Раньше не очень интересовался форматом SVG, но благодаря вам я начну больше изучать этот формат.
        0
        Это хорошо, значит одна из главных целей статьи достигнута, удачи вам в ваших начинаниях and «May the Force be with you».
        0
        В Хроме на Андроид ужасные тормоза. Это особенность библиотеки или конкретной реализации?
          +1
            0
            Тут скорее — Why SVG is slow…
            0
            Как тут уже заметили, это скорее всего неповоротливость SVG (174 страны всё же) + отсутствие оптимизации под touch интерфейсы, что, на сколько я помню, увеличивает время обработки событий примерно на 300 мс.
            0
            Оффтопик: Как неестественно выглядит вид планеты «из космоса» с нанесенными на нее государственными границами, существующими только в воображении (некоторых) людей.
              0
              Согласен с вами, для анимации хорошо бы покрыть всё тайлами, но это тянет на отдельную статью, в принципе в планах есть рассказать про это.
              +1
              Это какая-то новая форма безграмотности на «Хабре», я чувствую. Всё больше постов с полумегабайтными, а то и мегабайтными картинками там, где в этом никакой нужды нет. JPEG тут бы раз в 5—10 меньше получился.
                0
                Вы правы, в самой анимации используется JPEG, а для иллюстрации к статье я что-то упустил этот момент. Спасибо за бдительность, пережал. (размер уменьшился почти в 5 раз)
                0
                Если перевернуть глобус «вверх ногами», то лево и право меняются местами. Тянешь влево — глобус крутится вправо.
                  0
                  Вы, видимо, имеете ввиду поворот вверх ногами планшета, оптимизация под этот класс устройств не проводилась, так что всё в ваших руках =)
                    0
                    Не, я про
                    вот это
                      0
                      Хехе, теперь понял, Месье знает толк в извpащениях. Надо будет программно запретить так издеваться над глобусом =).
                  +1
                  Панельки справа очень не хватает:
                  sites.psu.edu/jmrrclr1b5/files/2013/03/xcom_geoscape.png
                    0
                    А где можно подробнее посмотреть на работу функций origin и interpolate? Не вполне ясно, как именно они работают.
                      0
                      Вот API Reference тут есть всё. Про origin я объяснял в статье, он нужен чтобы перейти от координат холста к координатам нашей системы (то бишь географическим координатам), а по interpolate вопрос видимо связан с параметром t. Вот что про него написано в API:
                      Given a parameter t in the range [0,1], returns the associated interpolation value.

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