...

вторник, 21 октября 2014 г.

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

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

Хочу предложить вашему вниманию, все таки доведенную до определенной точки, свою библиотеку для визуализации данных blackHole.js использующую d3.js.

Данная библиотека позволяет создавать визуализации подобного плана:

картинки кликабельные

image или


Статья будет посвящена примеру использования 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, 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 создаются подобным образом.

Вот к примеру элементы управления процессом визуализации для 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. Друзья прошу писать про ошибки в личные сообщения.


This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.



Комментариев нет:

Отправить комментарий