Pull to refresh

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

Reading time 12 min
Views 23K
Приветствую вас, сообщество!

Хочу предложить вашему вниманию, все таки доведенную до определенной точки, свою библиотеку для визуализации данных 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:
<!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. Друзья прошу писать про ошибки в личные сообщения.
Only registered users can participate in poll. Log in, please.
Встречали ли вы подобные визуализации?
10.53% Да (в комментарии где мне просто интересно) 14
89.47% Нет 119
133 users voted. 58 users abstained.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+34
Comments 4
Comments Comments 4

Articles