- PVSM.RU - https://www.pvsm.ru -

BlackHole.js с привязкой к картам leaflet.js

Приветствую вас, сообщество!

Хочу предложить вашему вниманию, все таки доведенную до определенной точки, свою библиотеку для визуализации данных blackHole.js [1] использующую d3.js [2].
Данная библиотека позволяет создавать визуализации подобного плана:
картинки кликабельные
image [3] или BlackHole.js с привязкой к картам leaflet.js [4]

Статья будет посвящена примеру использования blackHole.js [5] совместно с leaflet.js [6] и ей подобными типа mapbox [7].
Но так же будут рассмотрено использование: google maps [8], leaflet.heat [9].

Получится вот так =)
BlackHole.js с привязкой к картам leaflet.js [10]

Поведение точки зависит от того где я находился по мнению google в определенный момент времени

Посмотрите, а как перемещались вы?.. [10].

Пример основан на проекте location-history-visualizer [11] от @theopolisme [12]

В тексте статьи будут разобраны только интересные места весь остальной код вы можете «поковырять» на codepen.io [10].

В статье

Подготовка


Для начала нам понадобиться:

  • leaflet.js [19] — библиотека с открытым исходным кодом, написанная Владимиром Агафонкиным (CloudMade) на JavaScript, предназначенная для отображения карт на веб-сайтах (© wikipedia).
  • Leaflet.heat [9] — легковесный heatmap палгин для leaflet.
  • Google Maps Api [20] — для подключения google maps персонализированных карт
  • Leaflet-plugins от Павла Шрамова [21] — плагин позволяет подключать к leaflet.js [6] карты google, yandex, bing. Но нам в частности понадобиться только скрипт Google.js [22]
  • d3.js [2] — библиотека для работы с данными, обладающая набором средств для манипуляции над ними и набором методов их отображения.
  • ну и собственно blackHole.js [23]
  • данные о вашей геопозиции собранные бережно за нас Google.
    Как выгрузить данные

    Для начала, вы должны перейти Google Takeout [24] чтобы скачать информацию LocationHistory. На странице нажмите кнопку Select none, затем найдите в списке «Location History» и отметьте его. Нажмите на кнопку Next и нажмите на кнопку Create archive. Дождитесь завершения работы. Нажмите кнопку Download и распакуйте архив в нужную вам директорию.

Пример состоит из трех файлов index.html, index.css и index.js.
Код первых двух вы можете посмотреть на codepen.io [10]
Но в двух словах могу сказать, что нам потребуется на самом деле вот такая структура DOM:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <div id="map"></div>
        <!-- здесь подключаем скрипты -->
    </body>
</html>

Приложение на JS

Само приложение состоит из нескольких частей.

Класс обертка для blackHole для leaflet


Для того чтобы нам совместно использовать blackHole.js [23] и leaflet.js [6], необходимо создать слой обертку для вывода нашей визуализации поверх карты. При этом мы сохраним все механизмы работы с картой и интерактивные возможности библиотеки blackHole.js.
В библиотеке leaflet.js есть необходимые нам средства: L.Class.
В нем нам необходимо «перегрузить» методы: initialize, onAdd, onRemove, addTo.
На самом деле это просто методы для стандартной работы со слоями в leaflet.js [6].

Класс с описанием

!function(){
L.BlackHoleLayer = L.Class.extend({
    // выполняется при инициализации слоя
    initialize: function () {
    },

    // когда слой добавляется на карту то вызывается данный метод
    onAdd: function (map) {
        // Если слой уже был инициализирован значит, мы его хотим снова показать
        if (this._el) {
            this._el.style('display', null);
            // проверяем не приостановлена ли была визуализация
            if (this._bh.IsPaused())
                this._bh.resume();
            return;
        }

        this._map = map;

        //выбираем текущий контейнер для слоев и создаем в нем наш div,
        //в котором будет визуализация 
        this._el = d3.select(map.getPanes().overlayPane).append('div');
      
        // создаем объект blackHole
        this._bh = d3.blackHole(this._el);

        //задаем класс для div
        var animated = map.options.zoomAnimation && L.Browser.any3d;
        this._el.classed('leaflet-zoom-' + (animated ? 'animated' : 'hide'), true);
        this._el.classed('leaflet-blackhole-layer', true);

        // определяем обработчики для событии
        map.on('viewreset', this._reset, this)
            .on('resize', this._resize, this)
            .on('move', this._reset, this)
            .on('moveend', this._reset, this)
        ;

        this._reset();
    },

    // соответственно при удалении слоя leaflet вызывает данный метод
    onRemove: function (map) {
        // если слой удаляется то мы на самом деле его просто скрываем.
        this._el.style('display', 'none');
        // если визуализация запущена, то ее надо остановить
        if (this._bh.IsRun())
            this._bh.pause();
    },

    // вызывается для того чтоб добывать данный слой на выбранную карту.
    addTo: function (map) {
        map.addLayer(this);
        return this;
    },

    // внутренний метод используется для события resize
    _resize : function() {
        // выполняем масштабирование визуализации согласно новых размеров.
        this._bh.size([this._map._size.x, this._map._size.y]);
        this._reset();
    },

    // внутренний метод используется для позиционирования слоя с визуализацией корректно на экране
    _reset: function () {
        var topLeft = this._map.containerPointToLayerPoint([0, 0]);

        var arr = [-topLeft.x, -topLeft.y];

        var t3d = 'translate3d(' + topLeft.x + 'px, ' + topLeft.y + 'px, 0px)';

        this._bh.style({
            "-webkit-transform" : t3d,
            "-moz-transform" : t3d,
            "-ms-transform" : t3d,
            "-o-transform" : t3d,
            "transform" : t3d
        });
        this._bh.translate(arr);
    }
});


L.blackHoleLayer = function() {
    return new L.BlackHoleLayer();
};
}();

Ничего особенного сложного в этом нет, любой плагин, или слой, или элемент управления для leaflet.js [6] создаются подобным образом.
Вот к примеру элементы управления [25] процессом визуализации для blackHole.js [23].

Персонализация Google Maps


Google Maps API предоставляют возможности для персонализации выводимой карты. Для этого можно почитать документацию [26]. Там очень много параметров и их сочетании, которые дадут вам нужный результат. Но быстрей воспользоваться готовыми наборами [27].

Давайте теперь создадим карту и запросим тайтлы от google в нужном для нас стиле.

Код добавления google maps

// создаем объект карты в div#map
var map = new L.Map('map', {
  maxZoom : 19, // Указываем максимальный масштаб
  minZoom : 2 // и минимальный
}).setView([0,0], 2); // и говорим сфокусироваться в нужной точке

// создаем слой с картой google c типом ROADMAP и параметрами стиля.
var ggl = new L.Google('ROADMAP', {
	mapOptions: {
    backgroundColor: "#19263E",
    styles : [
    {
        "featureType": "water",
        "stylers": [
            {
                "color": "#19263E"
            }
        ]
    },
    {
        "featureType": "landscape",
        "stylers": [
            {
                "color": "#0E141D"
            }
        ]
    },
    {
        "featureType": "poi",
        "elementType": "geometry",
        "stylers": [
            {
                "color": "#0E141D"
            }
        ]
    },
    {
        "featureType": "road.highway",
        "elementType": "geometry.fill",
        "stylers": [
            {
                "color": "#21193E"
            }
        ]
    },
    {
        "featureType": "road.highway",
        "elementType": "geometry.stroke",
        "stylers": [
            {
                "color": "#21193E"
            },
            {
                "weight": 0.5
            }
        ]
    },
    {
        "featureType": "road.arterial",
        "elementType": "geometry.fill",
        "stylers": [
            {
                "color": "#21193E"
            }
        ]
    },
    {
        "featureType": "road.arterial",
        "elementType": "geometry.stroke",
        "stylers": [
            {
                "color": "#21193E"
            },
            {
                "weight": 0.5
            }
        ]
    },
    {
        "featureType": "road.local",
        "elementType": "geometry",
        "stylers": [
            {
                "color": "#21193E"
            }
        ]
    },
    {
        "elementType": "labels.text.fill",
        "stylers": [
            {
                "color": "#365387"
            }
        ]
    },
    {
        "elementType": "labels.text.stroke",
        "stylers": [
            {
                "color": "#fff"
            },
            {
                "lightness": 13
            }
        ]
    },
    {
        "featureType": "transit",
        "stylers": [
            {
                "color": "#365387"
            }
        ]
    },
    {
        "featureType": "administrative",
        "elementType": "geometry.fill",
        "stylers": [
            {
                "color": "#000000"
            }
        ]
    },
    {
        "featureType": "administrative",
        "elementType": "geometry.stroke",
        "stylers": [
            {
                "color": "#19263E"
            },
            {
                "lightness": 0
            },
            {
                "weight": 1.5
            }
        ]
    }
]
	}
});
// добавляем слой на карту.
map.addLayer(ggl);

В результате получим вот такую карту
BlackHole.js с привязкой к картам leaflet.js
К данному решению пришел после некоторого времени использования в проекте MapBox [28], которая дает инструмент для удобной стилизации карт и много чего еще, но при большем количестве запросов становиться платной.

Теплокарта


Heatmap или теплокарта [29] позволяет отобразить частоту упоминания определенной координаты выделяя интенсивность градиентом цветов и группировать данные при масштабировании. Получается нечто подобное
BlackHole.js с привязкой к картам leaflet.js

Для ее построения мы используем плагин leaflet.heatmap [9]. Но существую и иные [30].

Для того чтобы наша визуализация была всегда поверх других слоев, а в частности поверх heatmap, и не теряла свои интерактивные особенности, необходимо добавлять blackHole.js [23] после того, когда добавлены другие слои плагинов на карту.

// создаем слой с blackHole.js
var visLayer = L.blackHoleLayer()
  , heat = L.heatLayer( [], { // создаем слой с heatmap
        opacity: 1, // непрозрачность
        radius: 25, // радиус 
        blur: 15 // и размытие
    }).addTo( map ) // сперва добавляем слой с heatmap
  ;
visLayer.addTo(map); // а теперь добавляем blackHole.js
Подготовка и визуализация данных


Библиотека готова работать сразу из «коробки» с определенным форматом данных а именно:

var rawData  = [
  {
    "key": 237,
    "category": "nemo,",
    "parent": {
      "name": "cumque5",
      "key": 5
    },
    "date": "2014-01-30T12:25:14.810Z"
  },
  //... и еще очень много данных
]

Тогда для запуска визуализации потребуется всего ничего кода на js:

var data = rawData.map(function(d) {
        d.date = new Date(d.date);
        return d;
    })
    , stepDate = 864e5
    , d3bh = d3.blackHole("#canvas")
    ;

d3bh.setting.drawTrack = true;

d3bh.on('calcRightBound', function(l) {
        return +l + stepDate;
    })
    .start(data)
    ;

подробней в документации [23]

Но сложилось так что мы живем в мире, где идеальных случаем раз, два и обчелся.
Поэтому библиотека предоставляет программистам возможность [31] подготовить blackHole.js [23] к работе с их форматом данных.

В нашем случаем мы имеем дело с LocationHistory.json [32] от Google.

{
  "somePointsTruncated" : false,
  "locations" : [ {
    "timestampMs" : "1412560102986",
    "latitudeE7" : 560532385,
    "longitudeE7" : 929207681,
    "accuracy" : 10,
    "velocity" : -1,
    "heading" : -1,
    "altitude" : 194,
    "verticalAccuracy" : 1
  }, {
    "timestampMs" : "1412532992732",
    "latitudeE7" : 560513299,
    "longitudeE7" : 929186602,
    "accuracy" : 10,
    "velocity" : -1,
    "heading" : -1,
    "altitude" : 203,
    "verticalAccuracy" : 2
  },
  //... и тд
]}

Давайте подготовим данные и настроим blackHole.js для работы с ними.

Функция запуска/перезапуска

function restart() {
  bh.stop();
  
  if ( !locations || !locations.length)
    return;
  
  // очищаем старую информацию о позициях на heatmap
  heat.setLatLngs([]);
  
  // запускаем визуализацию с пересчетом всех объектов
  bh.start(locations, map._size.x, map._size.y, true);
  visLayer._resize();
}

Теперь парсинг данных

Функция чтения файла и подготовка данных

var parentHash;
// функция вызывается для когда выбран файл для загрузки.
function stageTwo ( file ) {
  bh.stop(); // останавливаем визуализацию если она была запущена
  
  // Значение для конвертации координат из LocationHistory в привычные для leaflet.js
  var SCALAR_E7 = 0.0000001; 

  // Запускаем чтение файла
  processFile( file );

  function processFile ( file ) {
    //Создаем FileReader
    var reader = new FileReader();
    
    reader.onprogress = function ( e ) {
      // здесь отображаем ход чтения файла
    };

    reader.onload = function ( e ) {
      try {
        locations = JSON.parse( e.target.result ).locations;
        if ( !locations || !locations.length ) {
          throw new ReferenceError( 'No location data found.' );
        }
      } catch ( ex ) {
        // вывод ошибки
        console.log(ex);
        return;
      }
      
      parentHash = {};
      
      // для вычисления оптимальных границ фокусирования карты
      var sw = [-Infinity, -Infinity]
          , se = [Infinity, Infinity];      

      locations.forEach(function(d, i) {
        d.timestampMs = +d.timestampMs; // конвертируем в число
        
        // преобразуем координаты
        d.lat = d.latitudeE7 * SCALAR_E7;
        d.lon = d.longitudeE7 * SCALAR_E7;
        // формируем уникальный ключ для parent
        d.pkey = d.latitudeE7 + "_" + d.longitudeE7;
        
        // определяем границы
        sw[0] = Math.max(d.lat, sw[0]);
        sw[1] = Math.max(d.lon, sw[1]);
        se[0] = Math.min(d.lat, se[0]);
        se[1] = Math.min(d.lon, se[1]);        
        
        // создаем родительский элемент, куда будет лететь святящаяся точка.
        d.parent = parentHash[d.pkey] || makeParent(d);
      });
      
      // сортируем согласно параметра даты
      locations.sort(function(a, b) {
        return a.timestampMs - b.timestampMs;
      });
      
      // и формируем id для записей
      locations.forEach(function(d, i) {
        d._id = i;
      });
      
      // устанавливаем отображение карты в оптимальных границах
      map.fitBounds([sw, se]);
      
      // запускаем визуализацию
      restart();
    };

    reader.onerror = function () {
      console.log(reader.error);
    };
    
    // читаем файл как текстовый
    reader.readAsText(file);
  }
}

function makeParent(d) {
  var that = {_id : d.pkey};
  // создаем объект координат для leaflet
  that.latlng = new L.LatLng(d.lat, d.lon);
  
  // получаем всегда актуальную информацию о позиции объекта на карте
  // в зависимости от масштаба
  that.x = {
    valueOf : function() {
      var pos = map.latLngToLayerPoint(that.latlng);
      return pos.x;
    }
  };
    
  that.y = {
    valueOf : function() {
      var pos = map.latLngToLayerPoint(that.latlng);
      return pos.y;
    }
  };

  return parentHash[that.id] = that;
}

Благодаря возможности задавать функцию valueOf для получения значения объекта, мы можем всегда получить точные координаты родительских объектов на карте.

Настройка blackHole.js

// настройка некоторых параметров подробно по каждому в документации
bh.setting.increaseChild = false;
bh.setting.createNearParent = false;
bh.setting.speed = 100; // чем меньше тем быстрее
bh.setting.zoomAndDrag = false;
bh.setting.drawParent = false; // не показывать parent
bh.setting.drawParentLabel = false; // не показывать подпись родителя
bh.setting.padding = 0; // отступ от родительского элемента
bh.setting.parentLife = 0; // родительский элемент бессмертен
bh.setting.blendingLighter = true; // принцип наложения слове в Canvas
bh.setting.drawAsPlasma = true; // частицы рисуются как шарики при использовании градиента
bh.setting.drawTrack = true; // рисовать треки частицы

var stepDate = 1; // шаг визуализации

// во все, практически, функции передается исходные обработанные выше элементы (d)
bh.on('getGroupBy', function (d) {
    // параметр по которому осуществляется выборка данных для шага визуализации
    return d._id //d.timestampMs; 
  })
  .on('getParentKey', function (d) {
    return d._id; // ключи идентификации родительского элемента
  })
  .on('getChildKey', function (d) {
    return 'me'; // ключ для дочернего элемента, то есть он будет только один
  })
  .on('getCategoryKey', function (d) {
    return 'me; // ключ для категории дочернего элемента, по сути определяет его цвет
  })
  .on('getCategoryName', function (d) {
    return 'location'; // наименование категории объекта
  })
  .on('getParentLabel', function (d) {
    return ''; // подпись родительского элемента нам не требуется
  })
  .on('getChildLabel', function (d) {
    return 'me'; // подпись дочернего элемента
  })
  .on('calcRightBound', function (l) {
    // пересчет правой границы для выборки дочерних элементов из набора для шага визуализации.
    return l + stepDate; 
  })
  .on('getVisibleByStep', function (d) {
    return true; // всегда отображать объект 
  })
  .on('getParentRadius', function (d) {
    return 1; // радиус родительского элемента
  })
  .on('getChildRadius', function (d) {
    return 10; // радиус летающей точки
  })
  .on('getParentPosition', function (d) {
    return [d.x, d.y]; // возвращает позицию родительского элемента на карте
  })
  .on('getParentFixed', function (d) {
    return true; // говорит что родительский объект неподвижен
  })
  .on('processing', function(items, l, r) {
    // запускаем таймер чтобы пересчитать heatmap
    setTimeout(setMarkers(items), 10);
  })
  .sort(null)
;

// возвращает функцию для пересчета heatmap
function setMarkers(arr) {
  return function() {
    arr.forEach(function (d) {
      var tp = d.parentNode.nodeValue;
      // добавляем координаты родительского объекта в heatmap
      heat.addLatLng(tp.latlng);
    });
  }
}

Как работает библиотека. При запуске она анализирует предоставленные ей данные выявляя родительские и дочерние уникальные элементы. Определяет границы визуализации согласно функции переданной для события getGroupBy [33]. За тем запускает два d3.layout.force [34] один отвечает за расчет позиции родительских элементов, другой за дочерние элементы. К дочерним элементам еще применяется методы для разрешения коллизий и кластеризации согласно родительского элемента.

При нашей настройке, мы получаем следующие поведение.
На каждом шаге, который наступает по истечении 100 миллисекунд (bh.setting.speed [35] = 100) библиотека выбирает всего один элемент из исходных данных, вычисляет его положение относительно родительского элемента, начинает отрисовку и переходить к следующему шаг.
Так как дочерний объект у нас один, он начинает летать от одно родителя к другому. И получается картинка, что приведена в самом начале статьи.
BlackHole.js с привязкой к картам leaflet.js [10]

Заключение

Библиотека делалась для решения собственных задач, так как после публикации GitHub Visualizer [36], появилось некоторое кол-во заказов переделать его под различные нужды, а некоторые хотели просто разобраться что да как изменить в нем чтоб решить свою проблему.
В результате я вынес все необходимое для того чтобы создавать визуализации на подобии GitHub Visualizer [36] в отдельную библиотеку и уже сделал ряд проектов один из которых занял первое место на конкурсе ГосЗатраты [37].

Собственно упрощенный GitHub Visualizer [36] на blackHole.js [1] работающий с xml Файлами полученными при запуске code_swarm можно пощупать тут [38].
Для генерации файла можно воспользоваться этим руководством [39]

Надеюсь что появятся соавторы которые внесут свои улучшения и поправят мои заблуждения.

На данный момент библиотека состоит из 4 составных частей:

  • Parser [40] — создание объектов для визуализации из переданных данных
  • Render [41] — занимается отрисовкой картинки
  • Processor [42] — вычисление шагов визуализации
  • Core [43] — собирает в себя все части, управляет ими и занимается расчетом позиции объектов

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

Жду полезных комментариев!
Спасибо!

P.S. Друзья прошу писать про ошибки в личные сообщения.

Автор: artzub

Источник [44]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/javascript/72321

Ссылки в тексте:

[1] blackHole.js: https://github.com/artzub/blackHole.js#introduce

[2] d3.js: http://d3js.org

[3] Image: http://clearspending.artzub.com/

[4] Image: http://codepen.io/artzub/pen/vcfyd

[5] blackHole.js: https://github.com/artzub/blackhole.js#introduce

[6] leaflet.js: http://leafletjs.com/

[7] mapbox: https://www.mapbox.com/

[8] google maps: https://developers.google.com/maps/

[9] leaflet.heat: https://github.com/Leaflet/Leaflet.heat

[10] Image: http://codepen.io/artzub/pen/molHj

[11] location-history-visualizer: http://theopolis.me/location-history-visualizer/

[12] @theopolisme: http://theopolis.me/

[13] Подготовка: #begin

[14] Приложение на JS: #appjs

[15] Подключение слоя с blackHole.js: #wbh

[16] Персонализация и вывод карты Google Maps: #gmap

[17] Подключение слоя c heatmap: #heatmap

[18] Подготовка и обработка данных: #data

[19] leaflet.js: https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js

[20] Google Maps Api: http://maps.google.com/maps/api/js?v=3&sensor=false

[21] Leaflet-plugins от Павла Шрамова: https://github.com/shramov/leaflet-plugins

[22] Google.js: https://github.com/shramov/leaflet-plugins/blob/master/layer/tile/Google.js

[23] blackHole.js: https://github.com/artzub/blackhole.js

[24] Google Takeout: https://google.com/takeout

[25] элементы управления: https://github.com/artzub/clearspending/blob/master/js/L.Control.ActionConsole.js

[26] документацию: https://developers.google.com/maps/documentation/javascript/styling

[27] готовыми наборами: http://snazzymaps.com/

[28] MapBox: http://mapbox.com

[29] Heatmap или теплокарта: https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D0%BF%D0%BB%D0%BE%D0%BA%D0%B0%D1%80%D1%82%D0%B0

[30] иные: http://leafletjs.com/plugins.html

[31] возможность: https://github.com/artzub/blackhole.js#on

[32] LocationHistory.json: https://www.google.com/settings/takeout

[33] getGroupBy: https://github.com/artzub/blackhole.js#on-get-group-by

[34] d3.layout.force: https://github.com/mbostock/d3/wiki/Force-Layout

[35] speed: https://github.com/artzub/blackhole.js#setting-speed

[36] GitHub Visualizer: http://ghv.artzub.com/

[37] конкурсе ГосЗатраты: http://clearspending.ru/page/contest/result/

[38] тут: http://codepen.io/artzub/pen/pygqo

[39] руководством: https://github.com/rictic/code_swarm/tree/master/bin

[40] Parser: https://github.com/artzub/blackhole.js/blob/master/src/parser.js

[41] Render: https://github.com/artzub/blackhole.js/blob/master/src/render.js

[42] Processor: https://github.com/artzub/blackhole.js/blob/master/src/processor.js

[43] Core: https://github.com/artzub/blackhole.js/blob/master/src/core.js

[44] Источник: http://habrahabr.ru/post/241023/