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

Interactive Mapping

Tutorial 5. More 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 data is saved in a tab separated file (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

The link to this file is here.

Map the bombs

We first need to write the following HTML file. Let's call it bombs5.html. Note that in addition to what we have used so far, a new library is used here. It is the jquery library and we will use its inArray function to test if a value is in an array.

<!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.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> <script src="https://code.jquery.com/jquery-3.0.0.min.js"></script> </head> <body> <h2>24 hours of Blitz Sept 7th 1940: Map and Time</h2> <div id="map"></div> <div style="float:right" id="bombstype"></div> <div id="timehisto"></div> <script src="bombs5.js" type="text/javascript"></script> <div id="debug"></div> </body> </html>

Now as indicated in the above HTML file, we write the javascript file called bombs5.js.

// 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 bombs_data; var bombs_geojson; var hourBins; var layer_geojsons = {}; var svg; var rects; var map; var bombs_layer; var bombs_types = {}; var bombs_types_array; var cat_keys; var cat_svg; var cat_rects; var c10; var histData; // // TODO: ll_ids needs to be handled as ll_ids2 // This will enable highlighting type barchart when hovering on time chart // var ll_ids; var ll_ids2 = {}; // ids linked to the barchart: barchart id -> time chart ids, layer ids. var parseTime = d3.time.format("%B %d, %Y %I:%M %Z").parse; var bisectTime = d3.bisector(function(d) { return d; }).left; var default_bombs_style = { color: "none", radius: 6, fillColor: "steelblue", opacity: 1, fillOpacity: 0.6, weight: 0 }; var highlight_bombs_style = { fillColor: 'yellow', color: "grey", fillOpacity: 0.8, weight: 2 } d3.tsv("bombs.tsv", handle_bombs); function handle_bombs(error, 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 } }; if (d.type in bombs_types) { bombs_types[d.type] += 1; } else { bombs_types[d.type] = 1; } }); bombs_data = data; cat_keys = Object.keys(bombs_types); bombs_types_array = cat_keys.map(function(v) { return [v, bombs_types[v]]; }); cat_keys.forEach(function(d, i) { ll_ids2[d] = { layer_ids: [], time_ids: [] }; }); c10 = d3.scale.category10().domain(cat_keys); make_timechart(); make_barchart(); make_maps(); } function make_barchart() { 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; cat_svg = d3.select("#bombstype") .append("svg") .attr("width", w + margin.left + margin.right) .attr("height", h + margin.top + margin.bottom); var yScale = d3.scale.linear(). domain([0, d3.max(bombs_types_array, function(d) { return d[1]; })]). range([h, margin.top]); var yAxis = d3.svg.axis() .scale(yScale) .orient("left") .ticks(3); xScale = d3.scale.ordinal() .domain(bombs_types_array.map(function(d) { return d[0]; })) // .domain(keys) .rangeRoundBands([margin.left, w], 0.05); xAxis = d3.svg.axis() .scale(xScale) .orient("bottom"); cat_rects = cat_svg.selectAll("rect") .data(bombs_types_array) .enter() .append("rect") .attr("class", "barchart") .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.rangeBand()) .attr("fill", function(d, i) {return c10(d[0]);}); cat_rects.on("mouseover", function(d, i) { d3.select(this).style('fill', 'yellow'); d3.select("#debug").html("over: " + d + ", " + i); bombs_layer.eachLayer(function (layer) { layer.setStyle({fillColor :'#afafaf'}) }); ll_ids2[d[0]].layer_ids.forEach(function(dd) { l = bombs_layer.getLayer(dd); bombs_layer.resetStyle(l); if (!L.Browser.ie && !L.Browser.opera) { l.bringToFront(); } }); d3.selectAll(".timechart") .filter(function(dd, ii) {return ($.inArray(ii, ll_ids2[d[0]].time_ids)!=-1); }) .style("fill", highlight_bombs_style["fillColor"]); }) .on("mouseout", function(d, i) { d3.select(this).style("fill", function(d, i) {return c10(d[0]);}); bombs_layer.eachLayer(function (layer) { bombs_layer.resetStyle(layer); }); d3.selectAll(".timechart") .filter(function(dd, ii) {return ($.inArray(ii, ll_ids2[d[0]].time_ids)!=-1); }) .style("fill", default_bombs_style["fillColor"]); }) cat_svg.append("g") .attr("class", "axis") .attr("transform", "translate(" + margin.left + "," + 0 +")") .call(yAxis); cat_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"); } function make_timechart() { var total_w = 500; var total_h = 200; var margin = {top: 15, right: 20, bottom: 60, left: 30}; var w = total_w - margin.left - margin.right; var h = total_h - margin.top - margin.bottom; var formatTime = d3.time.format("%H"); var hourExtent = d3.extent(bombs_data, function(d) { return d.time; }); // one bin per hour, for 24 hours in the day hourBins = d3.time.hours(d3.time.hour.offset(hourExtent[0],-1), d3.time.hour.offset(hourExtent[1],1)); // a function to bin the data var binByHour = d3.layout.histogram() .value(function(d) { return d.time; }) .bins(hourBins); // construct the data for histogram histData = binByHour(bombs_data); histData.forEach(function(d, i) { d.forEach(function(dd, ii) { if ($.inArray(i, ll_ids2[dd.type].time_ids) == -1) // if (! (i in ll_ids2[dd.type].time_ids)) ll_ids2[dd.type].time_ids.push(i); }); }); 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.time.scale().domain(d3.extent(hourBins)).range([0, w]); var xAxis = d3.svg.axis().scale(x).orient("bottom").tickFormat(formatTime); var y = d3.scale.linear().domain([0, d3.max(histData, function(d) { return d.y; })]).range([h, 0]); var yAxis = d3.svg.axis().scale(y).orient("left").ticks(6); rects = svg.selectAll("rect") .data(histData) .enter() .append("rect") .attr("class", "timechart") .style("fill", default_bombs_style["fillColor"]) .attr("x", function(d) { return x(d.x); }) .attr("width", function(d) { return x(new Date(d.x.getTime() + d.dx))-x(d.x)-1; }) .attr("y", function(d) { return y(d.y); }) .attr("height", function(d) { return h - y(d.y); }); rects.on("mouseover", function(d, i) { d3.select(this).style("fill", highlight_bombs_style["fillColor"]); d3.select("#debug").html("over: " + d.length + " " + d + ", " + i); bombs_layer.eachLayer(function (layer) { layer.setStyle({fillColor :'#ababab'}) }); ll_ids[i].forEach(function(dd) { bombs_layer.resetStyle(bombs_layer.getLayer(dd)); l = bombs_layer.getLayer(dd); if (!L.Browser.ie && !L.Browser.opera) { l.bringToFront(); } }); }) .on("mouseout", function(d, i) { d3.select(this).style("fill", default_bombs_style["fillColor"]); d3.select("#debug").html("OUT: " + d.length + " " + d[0]["id"] + ", " + i); bombs_layer.eachLayer(function (layer) { bombs_layer.resetStyle(layer); }); }); 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; ll_ids[i].push(id); ll_ids2[bombs_data[feature.properties.id].type].layer_ids.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; var cat = bombs_data[feature_id].type; // highlight more on self layer.setStyle(highlight_bombs_style); 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) {dt=t-d.x; return (d[0]==cat || (dt < d.dx && dt>=0)); }) .style("fill", highlight_bombs_style["fillColor"]); }, mouseout: function(e) { bombs_layer.resetStyle(e.target); var t = bombs_data[feature_id].time; var cat = bombs_data[feature_id].type; hist_index = bisectTime(hourBins, t) - 1; ll_ids[hist_index].forEach(function(dd) { bombs_layer.resetStyle(bombs_layer.getLayer(dd));}) d3.selectAll(".timechart") .filter(function(d, i) {dt=t-d.x; return (dt < d.dx && dt>=0); }) // .filter(function(d, i) {dt=t-d.x; return (dt < d.dx && dt>=0); }) .style("fill", "steelblue"); d3.selectAll(".barchart") .filter(function(d, i) {dt=t-d.x; return (d[0]==cat); }) // .filter(function(d, i) {dt=t-d.x; return (dt < d.dx && dt>=0); }) .style("fill", c10(cat)); } }) } 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) { c = c10(bombs_data[feature.properties.id].type); s = Object.create(default_bombs_style); // make a deepcopy, not a reference s["fillColor"] = c; return s; }, onEachFeature: onEachBombsFeature }); bombs_layer.addTo(map); layersControl.addOverlay(bombs_layer, "Bombs"); }

The result should look like the following screenshot:

Discussion

Any more improvements we can do to make the web app better?