paint-brush
How to Implement GIS Techniques with JavaScript and HTMLby@vibemap
1,020 reads
1,020 reads

How to Implement GIS Techniques with JavaScript and HTML

by VibemapJanuary 6th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

There are now a wealth of useful, open-source JavaScript tools for handling advanced cartography and geospatial analysis. The tools I’ll cover are based on services such as Mapbox, CloudMade, and MapZen, but these are all modular libraries that can be added as packages to Node.js or used for analysis in a web browser. WebGL and HTML5 canvas have also opened up 3D techniques for web developers. The following libraries work with [GeoJSON](http://geojson.org/) formatted objects representing geographic space.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - How to Implement GIS Techniques with JavaScript and HTML
Vibemap HackerNoon profile picture

Until recently, developing geospatial apps beyond a 2D map required a comprehensive GIS (Geospatial Information System) service such as ArcGIS, Nokia Here, or Google Maps. While these APIs are powerful, they are also expensive, onerous to learn and lock the map developer to a single solution. Fortunately, there are now a wealth of useful, open-source JavaScript tools for handling advanced cartography and geospatial analysis.


Everyone knows and loves maps, but what’s GIS, you say:

Geospatial Information Systems (GIS) is an area of cartography and information technology concerned with the storage, manipulation, analysis, and presentation of geographic and spatial data. You are probably most familiar with GIS services that produce dynamic, two-dimensional tile maps which have been prominent on the web since the days of MapQuest.


In this article, I’ll examine how to implement GIS techniques with JavaScript and HTML, focusing on lightweight tools for specific tasks. Many of the tools I’ll cover are based on services such as Mapbox, CloudMade, and MapZen, but these are all modular libraries that can be added as packages to Node.js or used for analysis in a web browser.


Note: The CodePen examples embedded in this post are best viewed on CodePen directly.

Geometry & 3D

Distance and Measurement

It is especially useful to have small, focused libraries that perform distance measurement, and conversion operations, such as finding the area of a geo-fence or converting miles to kilometers. The following libraries work with GeoJSON formatted objects representing geographic space.

  • Geolib provides distance (and estimated time) calculations between two latitude-latitude coordinates. A handy feature of Geolib is order by distance, which sorts a list or array by distance. The library also supports elevation.
  • Turf.js, which is described in the next section, also provides a distance function to calculate the great-circle distance between two points. Additionally, Turf.js calculates area, distance along a path, and the midpoint between points.
  • Sylvester is a library for geometry, vector, and matrix math in JavaScript. This library is helpful when basic measurement of lines and planes is not enough.

3D

While the above libraries work well for 2D projections of geography, three-dimensional GIS is an exciting and expansive field—which is natural because we live 3D space. Fortunately, WebGL and the HTML5 canvas have also opened up new 3D techniques for web developers.


Here’s an example of how to display GeoJSON Features on a 3D object:


CSS JSResult Skip Results Iframe
EDIT ON
// Render GeoJSON features on a spherical object.
// Create Three.js scene, camera, & light
var WIDTH = window.innerWidth - 30,
    HEIGHT = window.innerHeight - 30;
 
var angle = 75,
    aspect = WIDTH / HEIGHT,
    near = 0.5,
    far = 1000;

var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(angle, aspect, near, far);

// Renderer the canvas
var renderer = new THREE.WebGLRenderer();
renderer.setSize( WIDTH, HEIGHT);
renderer.setClearColor( 0x555555, 1);
document.body.appendChild(renderer.domElement);

// create a point light (goes in all directions)
scene.add(new THREE.AmbientLight(0x71ABEF));
var pointLight = new THREE.PointLight(0x666666);

// set its position
pointLight.position.x = 60;
pointLight.position.y = 50;
pointLight.position.z = 230;
scene.add(pointLight);

// Create a sphere to make visualization easier.
var geometry = new THREE.SphereGeometry(10,32,32);
var material = new THREE.MeshPhongMaterial({
  color: 0xDDDDDD,
  wireframe: false,
  transparent: true
});
 
var sphere = new THREE.Mesh(geometry, material);
scene.add(sphere);
sphere.castShadow = true;
sphere.receiveShadow = true;

//Draw the GeoJSON at THREE.ParticleSystemMaterial
var countries = $.getJSON("https://s3-us-west-2.amazonaws.com/s.cdpn.io/230399/countries_states.geojson", function (data) { 
  drawThreeGeo(data, 10, 'sphere', {
    color: 0x8B572A,
    skinning: true
  }); 
});

var rivers = $.getJSON("https://s3-us-west-2.amazonaws.com/s.cdpn.io/230399/rivers.geojson", function (data) { 
  drawThreeGeo(data, 10, 'sphere', {
    color: '#4A90E2'
  }); 
});

//Set the camera position
camera.position.z = 20;  

//Enable controls
controls = new THREE.TrackballControls(camera);

// Slow down zooming
controls.zoomSpeed = 0.1;

//Render the image
function render() {
  controls.update();
  requestAnimationFrame(render);    
  renderer.render(scene, camera);
 }

render();


You can also check out Byron Houwen’s article on WebGL and JavaScript, which shows how to create a terrain map of earth with Three.js

Geo Features & Points

Much of the work in GIS involves dealing with points, shapes, symbols, and other features. The most basic task is to add a shape or point features to a map. The well-established Leaflet library and newcomer Turf.js make this much easier and allow users to work with feature collections.


  • Leaflet is simply the best option for working with the display of points, symbols, and all types of features on web and mobile devices. The library supports rectanglescirclespolygonspointscustom markers, and a wide variety of layers. It performs quickly and handles a variety of formats. The library also has a rich ecosystem of third-party plug-ins.

  • Turf.js is a library from Mapbox for geospatial analysis. One of the great things about Turf.js is that you can create a collection of features and then spatially analyze, modify (geoprocess), and simplify them, before using Leaflet to present the data. Like Geolib, Turf.js will calculate the path length, feature center, points inside a feature.

  • Simple Map D3 creates choropleths and other symbology by simply defining a GeoJSON object and data attribute.


The following is an example of using Turf.js to calculate the population density of all counties in the state of California and then displaying the results as a Leaflet choropleth map.


// Public auth for initializing the map
var mapMain = 'stevepepple.lkojhhoh';
L.mapbox.accessToken = 'pk.eyJ1Ijoic3RldmVwZXBwbGUiLCJhIjoiTmd4T0wyNCJ9.1-jWg2J5XmFfnBAhyrORmw';

// Quantile or Jenks classification
var type = $("#type").val();
var breaks;

// Color scale using chroma.js
var scale = chroma.scale('YlOrBr');

// Create the base map using Mapbox
map = L.mapbox.map('map', mapMain, {
   attributionControl: false,
   maxZoom: 12,
   minZoom: 5
}).setView([34.0261899, -118.2455643], 8);

// Calculate population density for each county
_.each(counties.features, function(feature) {
   // Population from 2010 Census
   var pop = feature.properties.POP;
   // Calcualate desnity as popluation / square meters 
   var area = turf.area(feature.geometry);
   var sq_miles = area * 0.000000386102158542;
   feature.properties.pop_density = (pop / sq_miles);
});

updateMap();

// Show the Choropleth of Population Density
function updateMap() {
   // Make a Turf collection for analysis
   collection = turf.featurecollection(counties.features);

   // Basic UI for select Jenks or Quantile classification
   if (type == "jenks") {
      breaks = turf.jenks(collection, "pop_density", 8);
   } else {
      breaks = turf.quantile(collection, "pop_density", [25, 50, 75, 99]);
   }
   // Get the color when adding to the map
   var layer = L.geoJson(counties, {
      style: getStyle
   })
   layer.addTo(map);
   // Fit to map to counties and set the legend
   map.fitBounds(layer.getBounds());
   updateLegend();

   function getStyle(feature) {
      var pop = feature.properties.POP;
      var pop_density = feature.properties.pop_density;
      return {
         fillColor: getColor(pop_density),
         fillOpacity: 0.7,
         weight: 1,
         opacity: 0.2,
         color: 'black'
      }
   }

   function updateLegend() {
      $(".breaks").html();
      for (var i = 0; i < breaks.length; i++) {
         var density = Math.round(breaks[i] * 100) / 100;
         var background = scale(i / (breaks.length));
         $(".breaks").append("<p><div class='icon' style='background: " + background + "'></div><span> " + density + " <span class='sub'>pop / mi<sup>2</sup></span></span></p>");
      }
   }
}

/* Get color depending on population density value */
function getColor(d) {
   // Select a color scale from Color Brewer 
   var color = scale(0);
   // Place the feature based upon breaks
   for (var i = breaks.length - 1; i >= 0; i--) {
      if (d < breaks[i]) {
         // Automatic way to select the color by class
         var percentage = (i / (breaks.length));
         color = scale(percentage);
      }
   }
   return color;
}


A key concept in Turf.js is a collection of geographic features, such as polygons. These feature are typically GeoJSON features that you want to analyze, manipulate, or display on a map. You start with a GeoJSON object with an array of county features. Then, create a collection from this object:


collection = turf.featurecollection(counties.features);


With this collection you can perform many useful operations. You can transform one or more collections with joins, intersections, interpolation, and exclusion. You can calculate descriptive statistics, classifications, and sample distributions.


In the case of population density, you can calculate the natural breaks (Jenks optimization) or quantile classifications for population density:


breaks = turf.jenks(collection, "pop_density", 8);


The population density (population divided by area) value was calculated and stored as a property of each county, but the operation works for any feature property.

Working with Points

Points are a special type of geographic feature representing a latitude-longitude coordinate (and associated data). These features are frequently used in web applications, e.g. to display a set of nearby businesses on a map.


Turf.js provides a number of different operations for points, including finding the centroid point in a feature and creating a rectangle or polygon that encompasses all points. You can also calculate statistics from points, such as the average based on a data value for each point.


There are also extensions for Leaflet.js that help when dealing with a large number of points:


  • Marker Cluster for Leaflet is great for visualizing the results from Turf, or a collection of points that is large. The library itself handles hundreds of points, but there are plugins like Marker Cluster and Mask Canvas for handling hundreds of thousands of points.
  • Heat for Leaflet creates a dynamic heat map from point data. It even works for datasets with thousands of points.

Geocoding & Routing

Routing, geocoding, and reverse geocoding locations requires an online service, such as Google or Nokia Here, but recent libraries have made the implementation easier. There are also suitable open source alternatives.


The HTML5 Geolocation API provides a simple method of getting a device’s GPS location (with user permission):

navigator.geolocation.getCurrentPosition(function(result){
  // do something with result.coords
);

Location-aware web applications can use Turf.js spatial analysis methods for advanced techniques such as geofencing a location inside or outside of a map feature. For instance, you can take the result from the above example and use the turf.inside method to see if that coordinate is within the boundaries of a given neighborhood.


  • GeoSeach is a Leaflet plugin for geocoding that allows the developer to choose between the ArcGIS, Google, and OpenStreetMaps geocoder. Once the control is added to the base map, it will automatically use the selected geocoding service to show the best search result on the map. The library is designed to be extensible to other third-party services.
  • Geo for Node.js is a geocoding library that uses Google’s Geocode API for geocoding and reverse geocoding. It additionally supports the Geohash system for URL encoding of latitude-longitude coordinates.


As with geocoding, there are myriad routing and direction services, but they will cost you. A reliable, open source alternative is the Open Source Routing Machine (OSRM) service by MapZen. It provides a free service for routing car, bicycle, and pedestrian directions. Transit Mix cleverly uses the OSRM Routing tool for creating routes in their transportation planning tool.

Spatial and Network Analysis

I’ve mentioned a few spatial analysis methods you can implement with Turf.js and other libraries, but I’ve only covered a small part of a vast world. I’ve created an example application that illustrates several of the techniques I’ve introduced.


/* Calculate the center of all points */
function findCenter() {
  var centroidPt = turf.centroid(points);
  var marker = L.geoJson(centroidPt)
  markers.push(marker)
  marker.addTo(map);
}

/* Find the rectangular boundary of all points */
function findBounds() {
  bbox = turf.extent(points);
  bboxPolygon = turf.bboxPolygon(bbox);

  var bounds = L.geoJson(bboxPolygon);
  map.fitBounds(bounds.getBounds());
}

// Show a pseudo-random sample of points in the box
function randomSample() {
  var sample = L.geoJson(turf.sample(points, 100), {
    style: circle_options
  })
  sample.addTo(map);
  markers.push(sample);
}

/* Find the Most Central POI by Neighborhood */
function findCentralPOIs() {
  hoods = turf.featurecollection(neighborhoods.features);
  display = L.geoJson(hoods, {
    style: hood_options
  }).addTo(map);

  _.each(hoods.features, function(feature) {
    try {
      var center = turf.centroid(feature);
      var nearest = turf.nearest(center, points);
      var marker = L.geoJson(center)
      markers.push(marker)
      marker.addTo(map);
    } catch (e) {
      console.log(e)
    }
  });
}

/* Find the Most Central POI by Neighborhood */
function hoodByPOI() {
  hoods = turf.featurecollection(neighborhoods.features);
  display = L.geoJson(hoods, {
    style: hood_options
  }).addTo(map);

  _.each(hoods.features, function(feature) {
    try {
      console.log(feature)
      var collection = turf.featurecollection(feature);
      //var aggregated = turf.sum( hoods, points, 'population', 'sum');

      var counted = turf.count(collection, points, 'pt_count');
      console.log(counted)
    } catch (e) {
      console.log(e)
    }
  });
}

/* Triangular Irregular Network */
function makeTIN() {
  // THis collection was pre-made using the spatial join (turf.tag) feature
  var collection = westlake;

  var tin = turf.tin(collection);
  //var markers = L.geoJson(collection).addTo(map);
  var display = L.geoJson(tin, tin_options);
  display.addTo(map);
  markers.push(display);
  map.fitBounds(display.getBounds());

  _.each(collection.features, function(x) {
    // Convert lat/long to Leaflet coord 
    var coord = L.latLng(x.geometry.coordinates[1], x.geometry.coordinates[0]);
    var radius = 30;
    var circle = L.circle(coord, radius, circle_options).addTo(map);
    markers.push(circle);
  });
}

// Heat map of all points using Leaflet Heatmap
function makeHeatMap() {
  coords = []; //define an array to store coordinates
  heat_options = {
    radius: 14,
    gradient: {
      0.4: '#80cdc1',
      0.65: '#b8e186',
      1.0: '#d01c8b'
    }
  }

  _.each(points.features, function(feature) {
    coords.push([feature.geometry.coordinates[1], feature.geometry.coordinates[0]]);
  });

  var scale = chroma.scale('PuBuGn');

  try {
    var heat = L.heatLayer(coords, heat_options)
    markers.push(heat);
    heat.addTo(map);
    map.setZoom(14);
  } catch (e) {
    console.log(e)
  }
}

// Show or Hide LA Neighborhoods
function showNeighborhoods(hide) {
  if (hide == true) {
    map.removeLayer(display_hoods)
  } else {
    display_hoods = L.geoJson(hoods, {
      style: hood_options
    });
    display_hoods.addTo(map);
  }
}

// Util function to clear all features/markers
function clearMap() {
  for (i = 0; i < markers.length; i++) {
    map.removeLayer(markers[i])
  }
}

// UI for calling the different functions 
function bindActions() {
  doAnalysis(showNeighborhoods);
  
  $("#show_hoods").change(function() {
    var checked = $(this).prop("checked");
    if (checked == true) {
      showNeighborhoods();
    } else {
      showNeighborhoods(true);
    }
  });
}

function doAnalysis(which, arg) {
  clearMap();
  which.call(this, arg);
}

/* Map Setup, which is less interesting */
var mapMain = 'osaez.kp2ddba3';
L.mapbox.accessToken = 'pk.eyJ1Ijoib3NhZXoiLCJhIjoiOExKN0RWQSJ9.Hgewe_0r7gXoLCJHuupRfg';

map = L.mapbox.map('map', mapMain, {
  attributionControl: false,
  maxZoom: 14,
  minZoom: 5
}).setView([34.0261899, -118.2455643], 10);

var markers = [];
var layers = [];
var hoods, points, display_hoods;

var circle_options = {
  color: '#053275',
  opacity: 0,
  weight: .5,
  fillColor: '#3490E7 ',
  fillOpacity: .5
};

var hood_options = {
  color: "#9013FE",
  weight: .4,
  opacity: 0.65,
  fillColor: "#9013FE",
  fillOpacity: .01
};

var tin_options = {
  color: "#417505",
  weight: .4,
  opacity: 0.65,
  fillColor: "#417505 ",
  fillOpacity: .1
};

//Convert Shapefile to GeoJSON using Shapefile.js
shp("https://s3-us-west-2.amazonaws.com/s.cdpn.io/230399/la-poi-only.zip").then(function(geojson) {
  json = geojson;
  points = turf.featurecollection(json.features);
  hoods = turf.featurecollection(neighborhoods.features);
  prepAnalysis();
});

function prepAnalysis() {
  // Show a box around all possible points of interest
  hull = turf.convex(points);
  layer = L.geoJson(hull);
  bindActions();
}

Conclusion

In this article, I hope to have provided a comprehensive overview of the tools which are available to perform geospatial analysis and geoprocessing with JavaScript. Are you using these libraries in your projects already? Did I miss any out? Let me know in the comments.


If you want to go even further with geospatial analysis and geoprocessing with JavaScript, here are a few more resources and utilities:


  • NetworkX and D3.js — Mike Dewars’ book on D3.js includes a number of examples of using D3 with maps and spatial analysis. One of the more interesting examples is creating a directed graph of the New York Metro, which is done by analyzing the Google Transit specification for MTA with NetworkX.
  • Simply.js — Turf uses Vladimir Agafonkin’s Simply.js to perform shape simplification. That library can also be installed as an independent Node.js package for online or offline processing of files.
  • d3 Geo Exploder — Ben Southgate’s d3.geo.exploder allows you to transition geographic features (geoJSON) to another shape, like a grid or a scatter plot.
  • Shp — Use this library to convert a shapefile (and data files) to GeoJSON
  • ToGeoJSON — Use this library to convert KML & GPX to GeoJSON
  • Shp2stl – Use this library to convert geodata into 3D models that can be rendered or 3D printed.
  • MetaCRS and Proj4js— use these libraries to convert between coordinate systems.


Also published on: https://www.sitepoint.com/javascript-geospatial-advanced-maps/