Приветствую вас, сообщество!
Хочу предложить вашему вниманию, все таки доведенную до определенной точки, свою библиотеку для визуализации данных blackHole.js использующую d3.js.
Данная библиотека позволяет создавать визуализации подобного плана:
картинки кликабельные
или
Статья будет посвящена примеру использования blackHole.js совместно с leaflet.js и ей подобными типа mapbox.
Но так же будут рассмотрено использование: google maps, leaflet.heat.
Поведение точки зависит от того где я находился по мнению google в определенный момент времени
Посмотрите, а как перемещались вы?...
Пример основан на проекте location-history-visualizer от @theopolisme
В тексте статьи будут разобраны только интересные места весь остальной код вы можете «поковырять» на codepen.io.
В статье
- Подготовка
- Приложение на JS
- Подключение слоя с blackHole.js
- Персонализация и вывод карты Google Maps
- Подключение слоя c heatmap
- Подготовка и обработка данных
- Заключение
Подготовка
- 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:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<div id="map"></div>
<!-- здесь подключаем скрипты -->
</body>
</html>
Приложение на JS
Само приложение состоит из нескольких частей.
- Подключение слоя с blackHole.js.
- Персонализация и вывод карты Google Maps.
- Подключение слоя c heatmap.
- Подготовка и обработка данных.
Класс обертка для 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 в нужном для нас стиле.
// создаем объект карты в 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 для получения значения объекта, мы можем всегда получить точные координаты родительских объектов на карте.
// настройка некоторых параметров подробно по каждому в документации
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. Друзья прошу писать про ошибки в личные сообщения.
Автор: artzub