Статьи

Визуализация географических данных с использованием AngularJS и Mapbox

Одна из лучших вещей при работе с географическими наборами данных — это то, что вы можете играть с картами. Для всех моих интерактивных визуализаций я использовал LeafletJS   для достижения этой цели в веб-браузерах, но недавно я столкнулся с некоторыми проблемами с производительностью, делая это вместе с AngularJS. В этой статье описывается технологический стек, который я использовал для создания своего приложения, а также рассказывается о некоторых проблемах с производительностью.

Основы 

Для любого веб-приложения, которое я создаю, я всегда обращаюсь к NodeJS , поэтому сначала убедитесь, что оно установлено на вашем компьютере. Далее, чтобы начать работу очень быстро, я собираюсь использовать Yeoman, чтобы использовать генератор для создания основ приложения. Установите это глобально, используя npm: 

npm install -g yo

Yeoman — это фреймворк для веб-скаффолдинга, который сильно облегчает запуск веб-приложения; все эти скучные вещи, которые вы делаете каждый раз. Сказав это, это не для всех, поскольку оно может принести много зависимостей и кода, который вы можете не использовать. Для наших целей Йомен хорош. 

Вам также понадобится установить Grunt , менеджер задач JavaScript и Bower , для управления зависимостями на стороне клиента. 

npm install -g grunt-cli bower 

Далее мы будем использовать генератор, который предоставляет нам Express и AngularJS, который называется generator-angular-fullstack

npm install -g generator-angular-fullstack

Теперь у нас есть все необходимое, перейдите в новый каталог для вашего проекта и используйте генератор для создания нового приложения. В командной строке введите:

yo angular-fullstack maps

Выше я использовал карты в качестве имени приложения. Все, что вам нужно сделать сейчас, это ответить на вопросы, соответствующие вашему проекту. Для этого примера MongoDB не нужен, но я использовал Bootstrap для своего пользовательского интерфейса. Опять же, это зависит от вас, и не относится к нашему примеру карты.

Вы можете запустить свое приложение, используя следующую команду с grunt:

grunt serve

Приложение работает по умолчанию на порту 9000 и будет выглядеть следующим образом

Создание конечной точки API для извлечения ваших данных 

Следующее, что вы должны сделать, это добавить в приложение конечную точку, которая будет возвращать ваши географические данные. Создание конечной точки с генератором тривиально. Все, что вам нужно сделать, это предоставить имя конечной точки, в нашем случае, местоположения:

yo angular-fullstack:endpoint location

Если вы укажете браузеру на http: // localhost: 9000 / api / location, вы получите пустой массив. Давайте заполним это нашими данными о местоположении.

The endpoint’s controller is located server/api/location/location.controller.js, and you can see that it just returns an empty array as a response: 

'use strict';

var _ = require('lodash');

// Get list of locations
exports.index = function(req, res) {
  res.json([]);
};

What we need to do now is read pass back our real location data. Ideally you will have your own data, but for this example, I went to the Stanford Network Analysis Project and took some of their (anonymized) Gowalla location data.  This provides a large set of longitude and latitude points to attempt to visualize on a map, but rather than take the entire file I just copied the first 5000 records. 

As we are reading the file in our NodeJS server, the easiest thing to do is place the file in the root directory. Next, install the CSV-streamer package using npm to provide us with the ability to read the file and convert it into JSON objects. Note that we are using —save here so that the package information is saved to package.json.

npm install csv-streamer --save

Your controller now looks as follows, taking the latitude and longitude from the tab separated file in indexes 2 and 3, returning the data once you read 4999 lines:

var _ = require('lodash');
var fs = require('fs');
var CSVSteam = require('csv-streamer');


// Get list of locations
exports.index = function(req, res) {

  var csv = new CSVSteam({headers: false, delimiter:'\t'});

  var count = 0; 
  var data = []; 

  csv.on('data', function(line){

 data.push({lat: line[2], lon: line[3]});
  if(data.length == 4999){
  res.json(data);
  }
  });

  fs.createReadStream('locs.txt').pipe(csv);

};

With our API returning location data, it’s time to get to the real work.

Using Leaflet with AngularJS 

Let’s get started by displaying a simple map in our AngularJS application. To do this with a minimum of fuss, we’ll use the angular-leaflet-directive

First, install the package using bower, and save it to your bower.json file: 

bower install angular-leaflet-directive --save

I also installed the leaflet-plugins package: 

bower install leaflet-plugins --save

Restart the grunt process using grunt serve to ensure that the dependency is loaded. You can double check this by opening index.html and checking that the scripts are loaded as follows at the end of the file: 

      <script src="bower_components/leaflet/dist/leaflet-src.js"></script>
      <script src="bower_components/angular-leaflet-directive/dist/angular-leaflet-directive.js"></script>

For more familiar maps, it’s a good idea to include the Google Maps API here too, but outside of the bower sections of your HTML

 <script type="text/javascript" src=
    "https://maps.googleapis.com/maps/api/js?libraries=geometry">
    <!--[if lt IE 9]>
    <script src="bower_components/es5-shim/es5-shim.js"></script>
    <script src="bower_components/json3/lib/json3.min.js"></script>
    <![endif]-->
    <!-- build:js({client,node_modules}) app/vendor.js -->
      <!-- bower:js -->

Add the dependency on the leaflet directive to your app.js file, which defines the Angular module: 

angular.module('mapsApp', [
  'ngCookies',
  'ngResource',
  'ngSanitize',
  'ui.router',
  'ui.bootstrap', 
  'leaflet-directive'
])

Next, move to the Angular controller for the main page, under client/app/main.controller.js. Here you can set some defaults for the map that will be displayed. 

angular.module('mapsApp')
.controller('MainCtrl', function($scope, $http) {

angular.extend($scope, {
center: {
lat: 37.7532511,
lng: -122.4512832,
zoom: 3
},
defaults: {
scrollWheelZoom: false
},
events: {
map: {
enable: ['zoomstart', 'drag', 'click', 'mousemove'],
logic: 'emit'
}
},
layers: {
baselayers: {
googleRoadmap: {
name: 'Google Streets',
layerType: 'ROADMAP',
type: 'google',
"layerOptions": {
"showOnSelector": false
},
}
}
},
markers: {}

});

The code above simply sets up the map to use the Google Streets base layer and sets the center point to be San Francisco. Now, to have a simple map displayed we can add the directive to main.html 

<div class="container">
  <div class="row">
    <div class="col-lg-12">
      <leaflet id="mymap" defaults="defaults" markers="markers" center="center" height="600px" layers="layers" width="100%"></leaflet>
    </div>
  </div>
</div>

The application is finally taking shape! 

Display Location Markers 

Now we need to make a call to our API to retrieve the markers and display them on our map. You can do this in your AngularJS controller with minimum fuss, first adding a data attribute to our scope so it can be shared around the page.

$scope.data = null; 

$http.get('/api/locations').success(function(data) {
  $scope.data = data;
});

To display the marker clusters, you might consider using the standard marker cluster solution for Leaflet (Leaflet.MarkerCluster), which is well supported by the angular-directive. However, as this example shows, this can be a little slow when you’re dealing with a large amount of markers. I hit such a limitation, but found a few recommendations for PruneCluster which performs much better when faced with a large amount of markers. The 10,000 marker test runs very fast with this library.

The problem is that there’s no ‘native’ support for PruneCluster in the AngularJS leaflet directive, so you’ll need to do a few things differently to get it working. 

Integrating PruneCluster 

First, you’ll need to download the PruneCluster dependency using bower 

bower install PruneCluster --save

Bower wouldn’t automatically add the library to my HTML, so I had to do it manually. The CSS stylesheet for PruneCluster will need to be included on index.html 

     <!-- endbower -->
    <!-- endbuild -->
    <!-- build:css({.tmp,client}) app/app.css -->
   <link rel="stylesheet" href="bower_components/PruneCluster/dist/LeafletStyleSheet.css" />

    <link rel="stylesheet" href="app/app.css">
      <!-- injector:css -->

And the JavaScript needs to be included at the end of the same file

<!-- endbuild -->
<script src="bower_components/PruneCluster/dist/PruneCluster.js"></script>
<!-- build:js({.tmp,client}) app/app.js -->

Now, because you don’t have access to PruneCluster in our directive, you’ll need to get direct access to the Leaflet Map object. You can do this by adding a dependency on the leafletData service on your controller

angular.module('mapsApp')
.controller('MainCtrl', function($scope, $http, leafletData) {

When the map loads, the leafletData.getMap function will be executed. When that happens you can store a reference to the map in your controller’s scope 

$scope.map = null; 
//called when the map is loaded
leafletData.getMap("mymap").then(function(map) {
  $scope.map = map;
});

With access to the Map object you can do whatever you want with Leaflet. Let’s finish by using PruneCluster to render the markers.

The renderMarkers function creates a layer with PruneCluster, registers each marker individually, and processes the view once it has all of that completed:

/**
 * Render the markers onto the map 
 * @param  {Array} data the geographic data to be displayed
 * @param  {Leaflet.Map} map  the map to render the markers on 
 */
function renderMarkers(data, map){

//create layer for the markers 
        var markerLayer = new PruneClusterForLeaflet();

for(var i =0 ; i < data.length; i++){

var marker = new PruneCluster.Marker(data[i].lat, data[i].lon);
            markerLayer.RegisterMarker(marker);
}
//add the layer to the map 
  map.addLayer(markerLayer);
  //need to be called when any changes to markers are made 
  markerLayer.ProcessView();
};

Now when we retrieve the geographic data through the API, we can simply call the renderMarkers function 

$http.get('/api/locations').success(function(data) {
  $scope.data = data;
  renderMarkers(data, $scope.map);
});

Which results in the markers appearing really quickly.

You will find many more examples of how to use PruneCluster on the GitHub page, including the ability to use custom markers, filtering and lots more.