Tutorials: HTML/CSS/JS | Leaflet | D3 | Visualizing spatial-temporal data 1 | Visualizing spatial-temporal data 2
Ningchuan Xiao
The goals of this workshop include
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
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: '© <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: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, © <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);
}
})
}
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");
}
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");
}
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: '© <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: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, © <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:
What are the ways to improve the visualization we have explored so far?