Tutorials: HTML/CSS/JS | Leaflet | D3 | Visualizing spatial-temporal data 1 | Visualizing spatial-temporal data 2

Interactive Mapping

Tutorial 4. Interactive Visualization of Spatial-Temporal Data

Ningchuan Xiao

The goals of this workshop include

Spatial-temporal data

We are going to use the data of 24 hours of Blitz (bombing of London) on September 7, 1940. The data source is http://bombsight.org/data/sources/. A former student, Maxfield Barach, has geocoded the original data and reported that only 708 of the 843 bomb sights can be geocoded. My further clean-up of the data found that only 706 were good. This geocoded data is saved in a tab separated file (TSV) called bombs.tsv and the first three lines are like this:

order time location type details lng lat 1 0:08 43 Southwark Park Road, SE16, London, UK IB Grocers: 3x2 roof damaged -0.068463 51.494531 2 0:10 49 Southwark Park road, Bermondsey, SE16, London, UK IB Bakers: 3x2 roof damaged -0.058463 51.494532

Map the bombs

We first need to write the following HTML file. Let's call it bombs1.html.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <style> #map { height: 500px; } body { padding: 0; margin: 0 } </style> <title>Bombs map</title> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css" integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ==" crossorigin=""/> <!-- Make sure you put this AFTER Leaflet's CSS --> <script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js" integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ==" crossorigin=""></script> <script src="https://d3js.org/d3.v7.min.js"></script> </head> <body> <h2>Bombs Map</h2> <div id="map"></div> <div id="debug"></div> <script src="bombs1.js" type="text/javascript"> </script> </body> </html>

Now as indicated in the above HTML file, we write the javascript file called bombs1.js. We especially focus on how the data is processed by called d3.tsv.

// Data source: http://bombsight.org/data/sources/ // 24 hours of Blitz Sept 7th 1940 var parseTime = d3.timeParse("%B %d, %Y %I:%M %Z"); var bombs_data; var bombs_geojson; var map; var bombs_layer; var default_bombs_style = { color: "none", radius: 6, fillColor: "red", opacity: 1, fillOpacity: 0.3, weight: 0 }; var highlight_bombs_style = { fillColor: 'yellow', color: "grey", fillOpacity: 0.8, weight: 2 } d3.tsv("bombs.tsv").then(function handle_bombs(data) { alert(Object.keys(data)); alert(data) bombs_geojson = { "type": "FeatureCollection", "features": [] }; data.forEach(function(d, i) { d.order = +d.order; d.lng = +d.lng; d.lat = +d.lat; d.type = (d=="-"?"Other":d.type); d.time = parseTime("September 7, 1940 " + d.time + " -0000"); bombs_geojson.features[i] = { "type": "Feature", "geometry": { "type": "Point", "coordinates": [d.lng, d.lat] }, "properties": { "id": i } }; }); bombs_data = data; make_maps(); }); function make_maps() { map = L.map('map'); // create a map in the "map" div map.setView([51.5, -0.1], 10); // set the view to central Ohio and zoom // create an OpenStreetMap tile layer var osmMapnik = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a>contributors' }); // create another layer using the cartodb service var cartoLight = L.tileLayer("https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png", { attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <a href="https://cartodb.com/attributions">CartoDB</a>' }); var baseMaps = { "Street map light": cartoLight, "OpenStreetMap": osmMapnik }; var layersControl = new L.Control.Layers(baseMaps, null, {collapsed: true, position: 'bottomleft'}); map.addControl(layersControl); map.addLayer(cartoLight, true); bombs_layer = L.geoJson(bombs_geojson, { pointToLayer: function (feature, latlng) { return L.circleMarker(latlng); }, style: function (feature) { return { color: "none", radius: 6, fillColor: "#F65", opacity: 1, fillOpacity: 0.3, weight: 0 }; }, onEachFeature: onEachBombsFeature }).addTo(map); layersControl.addOverlay(bombs_layer, "Bombs"); } function onEachBombsFeature(feature, layer) { layer.on({ mouseover: function(e) { var layer = e.target; var feature = layer.feature; var feature_id = feature.properties.id; layer.setStyle(highlight_bombs_style); if (!L.Browser.ie && !L.Browser.opera) { layer.bringToFront(); } d3.select("#debug").html(bombs_data[feature_id].details); }, mouseout: function(e) { bombs_layer.resetStyle(e.target); } }) }

Draw a barchart for types of bombs

We save the following code to a file called bombs2.html.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <style> .axis path, .axis line { fill: none; stroke: black; shape-rendering: crispEdges; } .axis text { font-family: sans-serif; font-size: 11px; } .x.axis path { display: none; } </style> <title>Bomb types</title> <script src="https://d3js.org/d3.v7.min.js"></script> </head> <body> <h2>Bomb Types</h2> <script src="bombs2.js" type="text/javascript"> </script> </body> </html>

Then we need to following javascript code saved in bombs2.js. This is a typical use of D3 for barcharts. We will pay attention to how the behaviors of mouseover and mouseout are defined.

// This is where we put the good stuff using D3 // Data source: http://bombsight.org/data/sources/ // 24 hours of Blitz Sept 7th 1940 var parseTime = d3.timeParse("%B %d, %Y %I:%M %Z"); var bombs_data; var bombs_types = {}; var bombs_types_array; var keys; var svg; var rects; d3.tsv("bombs.tsv").then(function handle_bombs(data) { data.forEach(function(d, i) { d.order = +d["order"]; d.lng = +d["lng"]; d.lat = +d["lat"]; d.type = (d.type=="-"?"Other":d.type); d.time = parseTime("September 7, 1940 " + d.time + " -0400"); if (d.type in bombs_types) bombs_types[d.type] += 1; else bombs_types[d.type] = 1 }); bombs_data = data; // alert(data[0].time + " " + data[0].details); keys = Object.keys(bombs_types); bombs_types_array = keys.map(function(v) { return [v, bombs_types[v]]; }); make_barchart(); }); function make_barchart() { var total_w = 500; var total_h = 200; var margin = {top: 15, right: 20, bottom: 50, left: 20}; var w = total_w - margin.left - margin.right; var h = total_h - margin.top - margin.bottom; svg = d3.select("body") .append("svg") .attr("width", w + margin.left + margin.right) .attr("height", h + margin.top + margin.bottom); var yScale = d3.scaleLinear(). domain([0, d3.max(bombs_types_array, function(d) { return d[1]; })]). range([h, margin.top]); var yAxis = d3.axisLeft() .scale(yScale) .ticks(3); xScale = d3.scaleBand() .domain(bombs_types_array.map(function(d) { return d[0]; })) // .domain(keys) .rangeRound([margin.left, w], 0.05); xAxis = d3.axisBottom() .scale(xScale); rects = svg.selectAll("rect") .data(bombs_types_array) .enter() .append("rect") .attr("x", function(d, i) {return xScale(d[0]);}) .attr("y", function(d) {return yScale(d[1]);} ) .attr("height", function(d) {return h-yScale(d[1]);}) .attr("width", xScale.bandwidth()) .attr("fill", "steelblue"); rects.on("mouseover", function(d,i) { d3.select(this).style('fill', 'red'); }) .on("mouseout", function(d, i) { d3.select(this).style("fill", "steelblue"); }) svg.append("g") .attr("class", "axis") .attr("transform", "translate(" + margin.left + "," + 0 +")") .call(yAxis); svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + h + ")") .call(xAxis) .selectAll("text") .attr("y", 5) .attr("x", 5) .attr("transform", "rotate(45)") .style("text-anchor", "start"); }

Draw the time series

Now we look at how to draw time series as a histogram.

Here is the HTML (save this in bombs3.html):

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <style> .axis path, .axis line { fill: none; stroke: black; shape-rendering: crispEdges; } .bar { fill: steelblue; shape-rendering: crispEdges; } .axis text { font-family: sans-serif; font-size: 11px; } .x.axis path { display: none; } </style> <title>D3 Time Series</title> <script src="https://d3js.org/d3.v7.min.js"></script> </head> <body> <h2>Time series</h2> <div id="timehisto"></div> <script src="bombs3.js" type="text/javascript"></script> <div id="debug"></div> </body> </html>

And the javascript code is below (saved in bombs3.js):

var bombs_data; var svg; var rects; var parseTime = d3.timeParse("%B %d, %Y %I:%M %Z"); d3.tsv("bombs.tsv").then(function(data) { data.forEach(function(d, i) { d.id = i; d.order = +d["order"]; d.lng = +d.lng; d.lat = +d.lat; d.type = (d.type=="-"?"Other":d.type); d.time = parseTime("September 7, 1940 " + d.time + " -0400"); }); bombs_data = data; make_timechart(); }); function make_timechart() { var total_w = 500; var total_h = 200; var margin = {top: 15, right: 20, bottom: 50, left: 30}; var w = total_w - margin.left - margin.right; var h = total_h - margin.top - margin.bottom; var formatTime = d3.timeFormat("%H"); var hourExtent = d3.extent(bombs_data, function(d) { return d.time; }); // one bin per hour, for 24 hours in the day var hourBins = d3.timeHours(d3.timeHour.offset(hourExtent[0],-1), d3.timeHour.offset(hourExtent[1],1)); console.log(hourBins); // a function to bin the data var binByHour = d3.histogram() .value(function(d) { return d.time; }) .thresholds(hourBins); // construct the histogram bins from the data var histData = binByHour(bombs_data); console.log(Object.keys(histData)); console.log(histData); var svg = d3.select("#timehisto") .append("svg") .attr("width", w + margin.left + margin.right) .attr("height", h + margin.top + margin.bottom) .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); var x = d3.scaleTime().range([0, w]); x.domain(d3.extent(hourBins)); var xAxis = d3.axisBottom().scale(x) .tickFormat(formatTime); var y = d3.scaleLinear().range([h, 0]); y.domain([0, d3.max(histData, function(d) { return d.length; })]); var yAxis = d3.axisLeft().scale(y).ticks(6); rects = svg.selectAll("rect") .data(histData) .enter() .append("rect") .attr("class", "bar") .attr("x", function(d) { return x(d.x0); }) .attr("transform", function(d) { return "translate(" + 0 + "," + y(d.length) + ")"; }) // move them down, otherwise will be hanging .attr("width", function(d) { return x(d.x1) - x(d.x0) -1 ; }) .attr("height", function(d) { return h - y(d.length); }) rects.on("mouseover", function(e, d) { // event, datum d3.select(this).style("fill", "red"); d3.select("#debug").html("Over: " + d.length + " " + d + "; event=" + e + ". End."); }) .on("mouseout", function(e, d) { d3.select(this).style("fill", "steelblue"); d3.select("#debug").html("Out: d.length=" + d.length + ", d " + d + "; event=" + e); }); svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + h + ")") .call(xAxis); svg.append("g") .attr("class", "y axis") .call(yAxis) .append("text") .attr("transform", "rotate(-90)") .attr("y", 6) .attr("dy", ".71em") .style("text-anchor", "end") .text("Number of Bombs"); }

Linking space and time

Finally we will link bombs on the map and the bars on the histogram.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <style> #map { height: 350px; } .axis path, .axis line { fill: none; stroke: black; shape-rendering: crispEdges; } .bar { shape-rendering: crispEdges; } .axis text { font-family: sans-serif; font-size: 11px; } .x.axis path { display: none; } </style> <title>D3 Test</title> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css" integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ==" crossorigin=""/> <!-- Make sure you put this AFTER Leaflet's CSS --> <script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js" integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ==" crossorigin=""></script> <script src="https://d3js.org/d3.v7.min.js"></script> <!-- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.0.0-rc.3/dist/leaflet.css" /> <script src="https://unpkg.com/leaflet@1.0.0-rc.3/dist/leaflet.js"></script> <script src="http://d3js.org/d3.v3.min.js"></script> --> </head> <body> <h2>24 hours of Blitz Sept 7th 1940: Map and Time</h2> <div id="map"></div> <div id="timehisto"></div> <script src="bombs4.js" type="text/javascript"></script> <div id="debug"></div> </body> </html>

And here is the JS:

var bombs_data; var bombs_geojson; var hourBins; var layer_geojsons = {}; var svg; var rects; var map; var bombs_layer; var histData; var ll_ids; var parseTime = d3.timeParse("%B %d, %Y %I:%M %Z"); var bisectTime = d3.bisector(function(d) { return d; }).left; var default_bombs_style = { color: "none", radius: 6, fillColor: "red", opacity: 1, fillOpacity: 0.3, weight: 0 }; var highlight_bombs_style = { fillColor: 'yellow', color: "grey", fillOpacity: 0.8, weight: 2 } d3.tsv("bombs.tsv").then(function(data) { bombs_geojson = { "type": "FeatureCollection", "features": [] }; data.forEach(function(d, i) { d.id = i; d.order = +d["order"]; d.lng = +d.lng; d.lat = +d.lat; d.type = (d.type=="-"?"Other":d.type); d.time = parseTime("September 7, 1940 " + d.time + " -0400"); bombs_geojson.features[i] = { "type": "Feature", "geometry": { "type": "Point", "coordinates": [d.lng, d.lat] }, "properties": { "id": i } }; }); bombs_data = data; make_timechart(); make_maps(); }); function make_timechart() { var total_w = 500; var total_h = 200; var margin = {top: 15, right: 20, bottom: 50, left: 30}; var w = total_w - margin.left - margin.right; var h = total_h - margin.top - margin.bottom; var formatTime = d3.timeFormat("%H"); var hourExtent = d3.extent(bombs_data, function(d) { return d.time; }); // one bin per hour, for 24 hours in the day hourBins = d3.timeHours(d3.timeHour.offset(hourExtent[0],-1), d3.timeHour.offset(hourExtent[1],1)); console.log(hourBins); // a function to bin the data var binByHour = d3.histogram() .value(function(d) { return d.time; }) .thresholds(hourBins); // construct the histogram bins from the data histData = binByHour(bombs_data); console.log(Object.keys(histData)); console.log(histData); var svg = d3.select("#timehisto") .append("svg") .attr("width", w + margin.left + margin.right) .attr("height", h + margin.top + margin.bottom) .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); var x = d3.scaleTime().range([0, w]); x.domain(d3.extent(hourBins)); var xAxis = d3.axisBottom().scale(x) .tickFormat(formatTime); var y = d3.scaleLinear().range([h, 0]); y.domain([0, d3.max(histData, function(d) { return d.length; })]); var yAxis = d3.axisLeft().scale(y).ticks(6); rects = svg.selectAll("rect") .data(histData) .enter() .append("rect") .attr("fill", default_bombs_style["fillColor"]) .attr("class", "bar") .attr("x", function(d) { return x(d.x0); }) .attr("transform", function(d) { return "translate(" + 0 + "," + y(d.length) + ")"; }) // move them down, otherwise will be hanging .attr("width", function(d) { return x(d.x1) - x(d.x0) -1 ; }) .attr("height", function(d) { return h - y(d.length); }) rects.on("mouseover", function(e, d) { // event, datum d3.select(this).style("fill", highlight_bombs_style["fillColor"]); d3.select("#debug").html("Over: " + d.length + " " + d.x0 + "; event=" + e + ". End."); // get the index ix ix = bisectTime(hourBins, d.x0); ll_ids[ix].forEach(function(dd) { l = bombs_layer.getLayer(dd); l.setStyle(highlight_bombs_style); if (!L.Browser.ie && !L.Browser.opera) { l.bringToFront(); } }) }) .on("mouseout", function(e, d) { d3.select(this).style("fill", default_bombs_style["fillColor"]); d3.select("#debug").html("Out: d.length=" + d.length + ", d " + d + "; event=" + e); ix = bisectTime(hourBins, d.x0); ll_ids[ix].forEach(function(dd) { bombs_layer.resetStyle(bombs_layer.getLayer(dd));}) }); svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + h + ")") .call(xAxis); svg.append("g") .attr("class", "y axis") .call(yAxis) .append("text") .attr("transform", "rotate(-90)") .attr("y", 6) .attr("dy", ".71em") .style("text-anchor", "end") .text("Number of Bombs"); } function onEachBombsFeature(feature, layer) { // find the bin that contains this point and push the id to ll_ids id = L.stamp(layer); i = bisectTime(hourBins, bombs_data[feature.properties.id].time) - 1; // find the right bin ll_ids[i].push(id); layer.on({ mouseover: function(e) { var layer = e.target; feature = layer.feature; feature_id = feature.properties.id; var t = bombs_data[feature_id].time; hist_index = bisectTime(hourBins, t) - 1; ll_ids[hist_index].forEach(function(dd) { l = bombs_layer.getLayer(dd); l.setStyle(highlight_bombs_style); if (!L.Browser.ie && !L.Browser.opera) { l.bringToFront(); } }) // highlight more on self layer.setStyle({ radius: default_bombs_style["radius"]+3 }); if (!L.Browser.ie && !L.Browser.opera) { layer.bringToFront(); } d3.select("#debug").html(t + ": " + bombs_data[feature_id].details); d3.selectAll("rect") .filter(function(d, i) { hist_index = bisectTime(hourBins, t) - 1; return (hist_index == i); }) .style("fill", highlight_bombs_style["fillColor"]); }, mouseout: function(e) { // bombs_layer.resetStyle(e.target); var t = bombs_data[feature_id].time; hist_index = bisectTime(hourBins, t) - 1; ll_ids[hist_index].forEach(function(dd) { bombs_layer.resetStyle(bombs_layer.getLayer(dd));}) d3.selectAll("rect") .filter(function(d, i) { hist_index = bisectTime(hourBins, t) - 1; return (hist_index == i); }) .style("fill", default_bombs_style["fillColor"]); } }) } function make_maps() { map = L.map('map'); map.setView([51.5, -0.1], 10); // create an OpenStreetMap tile layer var osmMapnik = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '&copy; <a href="http://osm.org/copyright">OpenStreetMap</a>contributors' }); // create another layer using the cartodb service var cartoLight = L.tileLayer("https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png", { attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <a href="https://cartodb.com/attributions">CartoDB</a>' }); var baseMaps = { "Street map light": cartoLight, "OpenStreetMap": osmMapnik }; var layersControl = new L.Control.Layers(baseMaps, null, {collapsed: true, position: 'bottomleft'}); map.addControl(layersControl); map.addLayer(cartoLight, true); ll_ids = []; // leaflet layer ids, very important for (i=0; i<histData.length; i++) ll_ids[i] = []; bombs_layer = L.geoJson(bombs_geojson, { pointToLayer: function (feature, latlng) { return L.circleMarker(latlng); }, style: function (feature) { return default_bombs_style; }, onEachFeature: onEachBombsFeature }); bombs_layer.addTo(map); layersControl.addOverlay(bombs_layer, "Bombs"); }

When we are done, we should see a webpage like this:

Discussion

What are the ways to improve the visualization we have explored so far?