BlackHole.js с привязкой к картам leaflet.js
Приветствую вас, сообщество! Хочу предложить вашему вниманию, все таки доведенную до определенной точки, свою библиотеку для визуализации данных blackHole.js использующую d3.js.Данная библиотека позволяет создавать визуализации подобного плана: картинки кликабельные или
Статья будет посвящена примеру использования blackHole.js совместно с leaflet.js и ей подобными типа mapbox.Но так же будут рассмотрено использование: google maps, leaflet.heat.
Получится вот так =)
Поведение точки зависит от того где я находился по мнению google в определенный момент времени
Посмотрите, а как перемещались вы?…Пример основан на проекте location-history-visualizer от @theopolismeВ тексте статьи будут разобраны только интересные места весь остальной код вы можете «поковырять» на codepen.io.
В статье Подготовка Для начала нам понадобиться: leaflet.js — библиотека с открытым исходным кодом, написанная Владимиром Агафонкиным (CloudMade) на JavaScript, предназначенная для отображения карт на веб-сайтах (© wikipedia). Leaflet.heat — легковесный heatmap палгин для leaflet. Google Maps Api — для подключения google maps персонализированных карт Leaflet-plugins от Павла Шрамова — плагин позволяет подключать к leaflet.js карты google, yandex, bing. Но нам в частности понадобиться только скрипт Google.js d3.js — библиотека для работы с данными, обладающая набором средств для манипуляции над ними и набором методов их отображения. ну и собственно blackHole.js данные о вашей геопозиции собранные бережно за нас Google.Как выгрузить данные Для начала, вы должны перейти Google Takeout чтобы скачать информацию LocationHistory. На странице нажмите кнопку Select none, затем найдите в списке «Location History» и отметьте его. Нажмите на кнопку Next и нажмите на кнопку Create archive. Дождитесь завершения работы. Нажмите кнопку Download и распакуйте архив в нужную вам директорию. Пример состоит из трех файлов index.html, index.css и index.js.Код первых двух вы можете посмотреть на codepen.ioНо в двух словах могу сказать, что нам потребуется на самом деле вот такая структура DOM:
Приложение на JS Само приложение состоит из нескольких частей.
Класс обертка для blackHole для leaflet Для того чтобы нам совместно использовать blackHole.js и leaflet.js, необходимо создать слой обертку для вывода нашей визуализации поверх карты. При этом мы сохраним все механизмы работы с картой и интерактивные возможности библиотеки blackHole.js.В библиотеке leaflet.js есть необходимые нам средства: L.Class.В нем нам необходимо «перегрузить» методы: initialize, onAdd, onRemove, addTo.На самом деле это просто методы для стандартной работы со слоями в leaflet.js.Класс с описанием ! 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, 0 px)';
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 создаются подобным образом.Вот к примеру элементы управления процессом визуализации для blackHole.js.Персонализация Google Maps Google Maps API предоставляют возможности для персонализации выводимой карты. Для этого можно почитать документацию. Там очень много параметров и их сочетании, которые дадут вам нужный результат. Но быстрей воспользоваться готовыми наборами.Давайте теперь создадим карту и запросим тайтлы от 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); В результате получим вот такую картуК данному решению пришел после некоторого времени использования в проекте MapBox, которая дает инструмент для удобной стилизации карт и много чего еще, но при большем количестве запросов становиться платной.Теплокарта Heatmap или теплокарта позволяет отобразить частоту упоминания определенной координаты выделяя интенсивность градиентом цветов и группировать данные при масштабировании. Получается нечто подобноеДля ее построения мы используем плагин leaflet.heatmap. Но существую и иные.
Для того чтобы наша визуализация была всегда поверх других слоев, а в частности поверх heatmap, и не теряла свои интерактивные особенности, необходимо добавлять blackHole.js после того, когда добавлены другие слои плагинов на карту.
// создаем слой с 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) ; подробней в документацииНо сложилось так что мы живем в мире, где идеальных случаем раз, два и обчелся.Поэтому библиотека предоставляет программистам возможность подготовить blackHole.js к работе с их форматом данных.
В нашем случаем мы имеем дело с LocationHistory.json от 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. За тем запускает два d3.layout.force один отвечает за расчет позиции родительских элементов, другой за дочерние элементы. К дочерним элементам еще применяется методы для разрешения коллизий и кластеризации согласно родительского элемента.При нашей настройке, мы получаем следующие поведение.На каждом шаге, который наступает по истечении 100 миллисекунд (bh.setting.speed = 100) библиотека выбирает всего один элемент из исходных данных, вычисляет его положение относительно родительского элемента, начинает отрисовку и переходить к следующему шаг.Так как дочерний объект у нас один, он начинает летать от одно родителя к другому. И получается картинка, что приведена в самом начале статьи.
Заключение Библиотека делалась для решения собственных задач, так как после публикации GitHub Visualizer, появилось некоторое кол-во заказов переделать его под различные нужды, а некоторые хотели просто разобраться что да как изменить в нем чтоб решить свою проблему.В результате я вынес все необходимое для того чтобы создавать визуализации на подобии GitHub Visualizer в отдельную библиотеку и уже сделал ряд проектов один из которых занял первое место на конкурсе ГосЗатраты.
Собственно упрощенный GitHub Visualizer на blackHole.js работающий с xml Файлами полученными при запуске code_swarm можно пощупать тут.Для генерации файла можно воспользоваться этим руководством
Надеюсь что появятся соавторы которые внесут свои улучшения и поправят мои заблуждения.
На данный момент библиотека состоит из 4 составных частей:
Parser — создание объектов для визуализации из переданных данных Render — занимается отрисовкой картинки Processor — вычисление шагов визуализации Core — собирает в себя все части, управляет ими и занимается расчетом позиции объектов В ближайшее время планирую вынести Parser и Render в отдельные классы, чтоб облегчить задачу подготовки данных и предоставить возможность рисовать не только на canvas, но и при желании на WebGL.Жду полезных комментариев! Спасибо!
P.S. Друзья прошу писать про ошибки в личные сообщения.