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. Дочекайтеся завершення роботи. Натисніть кнопку Завантажити і розпакуйте архів в потрібну вам папку.



Приклад складається з трьох файлів index.html, index.css і index.js.
Код перших двох ви можете подивитися на codepen.io
Але в двох словах можу сказати, що нам потрібно насправді ось така структура DOM:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<div id="map"></div>
<!-- тут підключаємо скрипти -->
</body>
</html>


Додаток на 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. Друзі прошу писати про помилки в особисті повідомлення.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.