Skip to content

Instantly share code, notes, and snippets.

@ThomasThoren
Last active June 26, 2023 13:30
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save ThomasThoren/5e676d0de41bac8a0e96 to your computer and use it in GitHub Desktop.
Save ThomasThoren/5e676d0de41bac8a0e96 to your computer and use it in GitHub Desktop.
New York Times map reproduction
height: 800

An attempt to reproduce this New York Times map of Afghanistan: http://www.nytimes.com/interactive/2015/09/29/world/asia/afghanistan-taliban-maps.html

It combines vector data and raster data to display the country's borders and terrain (shaded relief). The vector data comes from Princeton University's Empirical Studies of Conflict Project. The raster imagery used for shaded relief is from NASA's Shuttle Radar Topography Mission. Use Derek Watkins' tool for selecting topographic map tiles.

Geographic data conversion is documented in the Makefile. It uses the GDAL library to convert the shapfiles to TopoJSON and reproject the raster images into the Mercator projection. You can run the entire processing script to see the intermediate files, or use the supplied JSON and PNG files for the final product.

Sources

Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
<!DOCTYPE html>
<html>
<head>
<title>New York Times map reproduction</title>
<meta charset="utf-8">
<style>
body {
padding: 0;
margin: 0;
font-family: helvetica, arial, sans-serif;
}
.bold {
font-weight: bold;
}
.raster {
fill: none;
opacity: 1;
}
.neutral-district {
fill: #FFF;
opacity: 0.8;
}
.taliban-district {
fill: #C00;
opacity: 0.8;
}
.contested-district {
fill: #FDBF4F;
opacity: 0.8;
}
.neutral-district,
.taliban-district,
.contested-district {
stroke: #6E6E6E;
stroke-opacity: 0.2;
stroke-width: 0.5px;
}
.provinces {
fill: none;
stroke: #6E6E6E;
stroke-opacity: 0.4;
stroke-width: 0.5px;
}
.country-border {
fill: none;
stroke: #6E6E6E;
stroke-opacity: 0.7;
stroke-width: 1px;
}
.city-label {
text-anchor: middle;
margin: 0;
font-size: 15px;
line-height: 14px;
font-weight: 500;
text-align: right;
opacity: 0.6;
color: #000;
}
.city-marker {
fill: none;
stroke: #000;
opacity: 0.6;
stroke-width: 2px;
}
.text-note {
font-size: 15px;
font-weight: 500;
color: #000;
opacity: 0.6;
line-height: 18px;
margin: 0;
}
.legend {
font-size: 15px;
line-height: 24px;
font-weight: 500;
color: #333;
}
.district-line,
.city-line {
stroke: #000;
stroke-width: 1.2px;
stroke-opacity: 0.5;
opacity: 0.8;
fill: #000;
shape-rendering: crispEdges;
}
.country-label {
font-weight: 500;
text-transform: uppercase;
text-anchor: middle;
opacity: 0.4;
color: #000;
font-size: 24px;
line-height: 28px;
letter-spacing: 0.3em;
}
.distance-scale {
font-size: 11px;
line-height: 11px;
position: absolute;
font-weight: 500;
text-transform: uppercase;
color: #000;
}
.distance-scale-line {
stroke: #000;
stroke-width: 1;
stroke-opacity: 1;
opacity: 1;
fill: #000;
shape-rendering: crispEdges;
}
.byline {
font-weight: 400;
font-size: 12px;
opacity: 0.6;
color: #000;
}
</style>
</head>
<body>
<svg id="map"></svg>
<div class="byline">
Thomas Thoren | Source: The New York Times
</div>
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="//d3js.org/queue.v1.min.js"></script>
<script src="//d3js.org/topojson.v1.min.js"></script>
<script>
var keeper_cities = [
'Herat',
'Kandahar',
'Kabul',
'Gardiz',
'Baghlan',
'Kondoz',
'Jalalabad'
];
var taliban_districts = [
'Aliabad',
'Azra',
'Baghran',
'Baharak',
'Bala Buluk',
'Bangi',
'Baraki Barak',
'Chahar Dara',
'Charkh',
'Charsada',
'Dara',
'Dashte Archi',
'Dishu',
'Ghorak',
'Ishkamish',
'Jawand',
'Kakar',
'Khaki Safed',
'Kham Ab',
'Khanabad',
'Kharwar',
'Kohistanat',
'Nawa',
'Pashtun Kot',
'Registan',
'Saydabad',
'Shorabak',
'Tagab',
'Warduj\n',
'Waygal',
'Waza Khwa',
'Yamgan (Girwan)',
'Yangi Qala',
'Zurmat'
];
var contested_districts = [
'Ajristan',
'Argo',
'Baghlani Jadid',
'Barmal',
'Dahana-I- Ghuri',
'Dangam',
'Darayim',
'Dihrawud',
'Dila',
'Ghazni',
'Gizab',
'Gulistan',
'Gurziwan',
'Imam Sahib',
'Jurm',
'Kajaki',
'Khas Uruzgan',
'Khwaja Ghar',
'Kishim',
'Kunduz',
'Marawara',
'Musa Qala',
'Naw Zad',
'Nijrab',
'Nika',
'Qalay-I- Zal',
'Qaysar',
'Sangin',
'Shahidi Hassas',
'Shindand',
'Sozma Qala',
'Tala Wa Barfak',
'Tulak',
'Urgun',
'Yahya Khel',
'Yosuf Khel',
'Ziruk'
];
// Make sure at least one dimension is smaller than raster image (874 x 670).
var map_width = 850,
map_height = 700;
var svg = d3.selectAll("#map")
.attr("width", map_width)
.attr("height", map_height);
// Create a unit projection
var map_projection = d3.geo.mercator()
.scale(1)
.translate([0, 0]);
var map_path = d3.geo.path()
.projection(map_projection);
queue()
.defer(d3.json, "afghanistan-districts.json")
.defer(d3.json, "afghanistan-provinces.json")
.defer(d3.json, "afghanistan-cities.json")
.await(ready);
function ready(error, districts, provinces, cities) {
if (error) throw error;
// Scale and center the map to fit into the given dimensions.
var b = map_path.bounds(topojson.feature(districts, districts.objects['afghanistan-districts']));
// Pixels per map-path-degree, for both directions:
// TODO: Back to 1
var s = 0.95 / Math.max((b[1][0] - b[0][0]) / map_width, (b[1][1] - b[0][1]) / map_height); // 0.95 is for padding. 1.0 would fill entire bounding box.
var t = [(map_width - s * (b[1][0] + b[0][0])) / 2, (map_height - s * (b[1][1] + b[0][1])) / 2];
// Scale and center vector
map_projection
.scale(s)
.translate(t);
// Scale and position shaded relief raster image. Assumes already cropped.
var raster_width = (b[1][0] - b[0][0]) * s;
var raster_height = (b[1][1] - b[0][1]) * s;
var rtranslate_x = (map_width - raster_width) / 2;
var rtranslate_y = (map_height - raster_height) / 2;
// Shaded relief
svg.append("image")
.attr('id', 'Raster')
.attr("clip-path", "url(#afghanistan_clip)")
.attr("xlink:href", "afghanistan.png")
.attr("class", "raster")
.attr("width", raster_width)
.attr("height", raster_height)
.attr("transform", "translate(" + rtranslate_x + ", " + rtranslate_y + ")");
// Draw districts
svg.append("g")
.attr('id', 'Districts')
.selectAll("path")
.data(topojson.feature(districts, districts.objects['afghanistan-districts']).features)
.enter().append("path")
.attr("class", function(d) {
var taliban_condition = taliban_districts.indexOf(d.properties.DIST_34_NA) > -1;
var contested_condition = contested_districts.indexOf(d.properties.DIST_34_NA) > -1;
if (taliban_condition) {
return "taliban-district";
} else if (contested_condition) {
return "contested-district";
} else {
return "neutral-district";
}
})
.attr("d", map_path);
// Draw provinces (made up of districts)
svg.append("g")
.attr('id', 'Provinces')
.selectAll("path")
.data(topojson.feature(provinces, provinces.objects['afghanistan-provinces']).features)
.enter().append("path")
.attr("class", "provinces")
.attr("d", map_path);
// Draw country border
svg.append("g")
.attr('id', 'CountryBorder')
.datum(topojson.mesh(districts, districts.objects['afghanistan-districts'], function(a, b) { return a === b; }))
.append("path")
.attr("class", "country-border")
.attr("id", "afghanistan_border") // For shaded relief
.attr("d", map_path);
// Draw city markers
svg.append('g')
.attr('id', 'CityMarkers')
.selectAll("circle")
.data(cities.features)
.enter().append("circle")
.attr("class", "city-marker")
.attr('r', '4px')
.attr("transform", function(d) { return "translate(" + map_projection(d.geometry.coordinates) + ")"; })
.filter(function(d) {
var condition = (
keeper_cities.indexOf(d.properties.NAME) === -1
|| d.properties.NAME === 'Balkh'
);
return condition;
}).remove();
// Write city label text
svg.append('g').attr('id', 'CityLabels').selectAll('.city-label')
.data(cities.features)
.enter().append('text')
.attr("class", "city-label")
.each(function(d) {
d3.select(this)
.attr("transform", function(d) { return "translate(" + map_projection(d.geometry.coordinates) + ")"; })
.attr("dx", "5")
.attr("dy", "15")
.style("text-anchor", "start")
.text(function(d) { return d.properties.NAME; });
})
.filter(function(d) {
var condition = (
keeper_cities.indexOf(d.properties.NAME) === -1
|| d.properties.NAME === 'Balkh'
);
return condition;
}).remove();
// Country label
svg.append("text")
.attr("class", "country-label")
.attr("x", map_width * 0.4)
.attr("y", map_height * 0.5)
.text('Afghanistan');
// Draw line between district and text
// Line path generator
var line = d3.svg.line()
.x(function(d) { return d.x; })
.y(function(d) { return d.y; })
.interpolate("basis");
svg.selectAll(".district-line")
.data(topojson.feature(districts, districts.objects['afghanistan-districts']).features)
.enter().append('path')
.attr("class", "district-line")
.attr("d", function(d) {
var centroid = map_path.centroid(d);
if (d.properties.DIST_34_NA === 'Waygal') {
var lineData = [
{"x": centroid[0], "y": centroid[1]},
{"x": centroid[0] + 70, "y": centroid[1] + 70}
];
} else if (d.properties.DIST_34_NA === 'Warduj\n') { // Bad data
var lineData = [
{"x": centroid[0], "y": centroid[1]},
{"x": centroid[0] + 70, "y": centroid[1] + 70}
];
} else {
return;
}
return line(lineData);
})
.filter(function(d) {
return d.properties.DIST_34_NA !== 'Waygal' && d.properties.DIST_34_NA !== 'Warduj\n';
}).remove();
// Draw line between city and text
svg.selectAll(".city-line")
.data(cities.features)
.enter().append('path')
.attr("class", "city-line")
.attr("d", function(d) {
var centroid = map_projection(d.geometry.coordinates);
if (d.properties.NAME === 'Kondoz') {
var lineData = [
{"x": centroid[0] - 3, "y": centroid[1] - 3},
{"x": centroid[0] - 70, "y": centroid[1] - 70}
];
} else {
return;
}
return line(lineData);
})
.filter(function(d) {
return d.properties.NAME !== 'Kondoz';
}).remove();
// Write districts text note
svg.selectAll('.text-note')
.data(topojson.feature(districts, districts.objects['afghanistan-districts']).features)
.enter().append('text')
.attr("class", "text-note")
.each(function(d) {
if (d.properties.DIST_34_NA === 'Waygal') {
d3.select(this)
.attr("transform", function(d) { return "translate(" + map_path.centroid(d) + ")"; })
.append("tspan")
.attr("dx", "75")
.attr("dy", "85")
.style("text-anchor", "start")
.text("The Taliban took ");
d3.select(this)
.append("tspan")
.attr("x", "75")
.attr("y", "102")
.style("text-anchor", "start")
.text("control of ");
d3.select(this)
.append("tspan")
.attr("class", "bold")
.text("Waygal");
d3.select(this)
.append("tspan")
.attr("x", "75")
.attr("y", "119")
.style("text-anchor", "start")
.text("district in June.");
} else if (d.properties.DIST_34_NA === 'Warduj\n') {
d3.select(this)
.attr("transform", function(d) { return "translate(" + map_path.centroid(d) + ")"; })
.append("tspan")
.attr("dx", "75")
.attr("dy", "85")
.style("text-anchor", "start")
.text("The Taliban overran ");
d3.select(this)
.append("tspan")
.attr("x", "75")
.attr("y", "102")
.style("text-anchor", "start")
.attr("class", "bold")
.text("Wardoj ");
d3.select(this)
.append("tspan")
.text("and ");
d3.select(this)
.append("tspan")
.attr("class", "bold")
.text("Baharak ");
d3.select(this)
.append("tspan")
.attr("x", "75")
.attr("y", "119")
.style("text-anchor", "start")
.text("districts in early ");
d3.select(this)
.append("tspan")
.attr("x", "75")
.attr("y", "136")
.style("text-anchor", "start")
.text("October.");
} else {
return;
}
})
.filter(function(d) {
return d.properties.DIST_34_NA !== 'Waygal' && d.properties.DIST_34_NA !== 'Warduj\n';
}).remove();
// Write text for cities
svg.selectAll('.text-note')
.data(cities.features)
.enter().append('text')
.attr("class", "text-note")
.each(function(d) {
if (d.properties.NAME === 'Kondoz') {
d3.select(this)
.attr("transform", function(d) { return "translate(" + map_path.centroid(d) + ")"; })
.append("tspan")
.attr("dx", "-75")
.attr("dy", "-85")
.style("text-anchor", "end")
.text("Taliban fighters took control of ");
d3.select(this)
.append("tspan")
.attr("class", "bold")
.text("Kondoz, ");
d3.select(this)
.append("tspan")
.text("a provincial");
d3.select(this)
.append("tspan")
.attr("x", "-75")
.attr("y", "-68")
.style("text-anchor", "end")
.text("capital, in September and held it for over two weeks.");
} else {
return;
}
})
.filter(function(d) {
return d.properties.NAME !== 'Kondoz';
}).remove();
// Color legend key
var legend = svg.selectAll("#legend")
.data([{"color": "#C00", "text": "Taliban-controlled districts"}, {"color": "#FDBF4F", "text": "Contested districts"}])
.enter().append("g")
.attr("class", "legend")
.attr("width", map_width / 2)
.attr("height", map_height / 2)
.attr("transform", function(d, i) { return "translate(" + map_width * 0.65 + ", " + ((map_height * 0.85) + (i * 20)) + ")"; })
legend.append('rect')
.attr("width", 32)
.attr("height", 14)
.attr("fill", function(d) { return d.color; });
legend.append('text')
.attr("x", 40)
.attr("y", 0)
.attr("dy", "12")
.attr("fill", "black")
.text(function(d) { return d.text; });
// Distance scale
function pixelLength(topojson, miles) {
// Calculates the window pixel length for a given map distance.
var actual_map_bounds = d3.geo.bounds(topojson);
var radians = d3.geo.distance(actual_map_bounds[0], actual_map_bounds[1]);
var earth_radius = 3959; // miles
var arc_length = radians * earth_radius; // s = r * theta
var projected_map_bounds = [
map_projection(actual_map_bounds[0]),
map_projection(actual_map_bounds[1])
];
var projected_width = projected_map_bounds[1][0] - projected_map_bounds[0][0];
var projected_height = projected_map_bounds[0][1] - projected_map_bounds[1][1];
var projected_map_hypotenuse = Math.sqrt(
(Math.pow(projected_width, 2)) + (Math.pow(projected_height, 2))
);
var pixels_per_mile = projected_map_hypotenuse / arc_length;
var pixel_distance = pixels_per_mile * miles;
return pixel_distance;
}
var pixels_for_hundred_miles = pixelLength(topojson.feature(districts, districts.objects['afghanistan-districts']), 100);
var distance_scale = svg.selectAll("#distance-scale")
.data([pixels_for_hundred_miles])
.enter().append("g")
.attr("class", "distance-scale")
.attr("width", function(d) { return d; });
distance_scale.append('text')
.attr("x", function(d, i) { return map_width * 0.65; })
.attr("y", function(d, i) { return (map_height * 0.95) + (i * 20); })
.text("100 miles");
distance_scale.append('path')
.attr("class", "distance-scale-line")
.attr("d", function(d, i) {
var lineData = [
{"x": map_width * 0.65, "y": (map_height * 0.95) + (i * 20) + 3},
{"x": map_width * 0.65 + d, "y": (map_height * 0.95) + (i * 20) + 3}
];
return line(lineData);
});
}
// Allows iframe on bl.ocks.org.
// d3.select(self.frameElement).style("height", map_height + 25 + "px");
</script>
</body>
</html>
# Requirements: gdal, topojson, imagemagick
.PHONY: all clean
# Eventually, you will want to disable this so intermediate files are removed.
# Many of them are larger than 1 GB.
.SECONDARY:
# Download .zip files
# Afghanistan districts (zip/AFG_district_398.zip) manually downloaded here:
# https://esoc.princeton.edu/files/administrative-boundaries-398-districts
zip/ne_10m_populated_places.zip:
@mkdir -p $(dir $@)
@curl -sS -o $@.download 'http://naciscdn.org/naturalearth/10m/cultural/ne_10m_populated_places.zip'
@mv $@.download $@
zip/srtm_%.zip:
@# 90-meter SRTM tiles
@mkdir -p $(dir $@)
@curl -sS -o $@.download 'http://srtm.csi.cgiar.org/SRT-ZIP/SRTM_V41/SRTM_Data_GeoTiff/$(notdir $@)'
@mv $@.download $@
# Unzip
shp/district398.shp:
@mkdir -p $(dir $@)
@rm -rf tmp && mkdir tmp
@unzip -q -o -d tmp zip/AFG_district_398.zip
@rm -rf tmp/__MACOSX
@cp tmp/* $(dir $@)
@rm -rf tmp
shp/ne_10m_populated_places.shp: zip/ne_10m_populated_places.zip
@mkdir -p $(dir $@)
@rm -rf tmp && mkdir tmp
@unzip -q -o -d tmp $<
@cp tmp/* $(dir $@)
@rm -rf tmp
tif/srtm_%.tif: zip/srtm_%.zip
@mkdir -p $(dir $@)
@rm -rf tmp && mkdir tmp
@unzip -q -o -d tmp $<
@cp tmp/* $(dir $@)
@rm -rf tmp
# Merge districts to form provinces.
shp/provinces-dissolved.shp: shp/district398.shp
@mkdir -p $(dir $@)
@ogr2ogr \
-f 'ESRI Shapefile' \
$@ $< \
-dialect sqlite \
-sql "SELECT ST_Union(ST_buffer(Geometry, 0.001)), PROVID \
FROM district398 \
GROUP BY PROVID"
# Reduce international cities file to only include those in Afghanistan
shp/cities.shp: shp/ne_10m_populated_places.shp
@mkdir -p $(dir $@)
@ogr2ogr \
-f 'ESRI Shapefile' \
$@ $< \
-dialect sqlite \
-sql "SELECT Geometry, ADM0NAME, NAME, SCALERANK, LABELRANK, NATSCALE \
FROM 'ne_10m_populated_places' \
WHERE ADM0NAME = 'Afghanistan' AND \
SCALERANK <= 7"
# Convert SHP to GeoJSON
geojson/afghanistan-districts.json: shp/district398.shp
@mkdir -p $(dir $@)
@ogr2ogr \
-f 'GeoJSON' \
$@ $<
geojson/afghanistan-provinces.json: shp/provinces-dissolved.shp
@mkdir -p $(dir $@)
@ogr2ogr \
-f 'GeoJSON' \
$@ $<
afghanistan-cities.json: shp/cities.shp
@mkdir -p $(dir $@)
@ogr2ogr \
-f 'GeoJSON' \
$@ $<
# Convert GeoJSON to TopoJSON
topojson/afghanistan-districts-fullsize.json: geojson/afghanistan-districts.json
@mkdir -p $(dir $@)
@topojson \
--no-quantization \
--properties \
-o $@ \
-- $<
topojson/afghanistan-provinces-fullsize.json: geojson/afghanistan-provinces.json
@mkdir -p $(dir $@)
@topojson \
--no-quantization \
--properties \
-o $@ \
-- $<
# Simplify TopoJSON
afghanistan-districts.json: topojson/afghanistan-districts-fullsize.json
@mkdir -p $(dir $@)
@topojson \
--spherical \
--properties \
-s 1e-9 \
-q 1e4 \
-o $@ \
-- $<
afghanistan-provinces.json: topojson/afghanistan-provinces-fullsize.json
@mkdir -p $(dir $@)
@topojson \
--spherical \
--properties \
-s 1e-9 \
-q 1e4 \
-o $@ \
-- $<
# Merge topographic tiles.
# Use http://dwtkns.com/srtm/ to find which tiles are needed.
tif/afghanistan-merged-90m.tif: \
tif/srtm_48_07.tif \
tif/srtm_49_07.tif \
tif/srtm_50_07.tif \
tif/srtm_51_07.tif \
tif/srtm_52_07.tif \
tif/srtm_48_06.tif \
tif/srtm_49_06.tif \
tif/srtm_50_06.tif \
tif/srtm_51_06.tif \
tif/srtm_52_06.tif \
tif/srtm_48_05.tif \
tif/srtm_49_05.tif \
tif/srtm_50_05.tif \
tif/srtm_51_05.tif \
tif/srtm_52_05.tif
@mkdir -p $(dir $@)
@gdal_merge.py \
-o $@ \
-init "255" \
tif/srtm_*.tif
# Convert to Mercator
tif/afghanistan-reprojected.tif: tif/afghanistan-merged-90m.tif
@# Comes as WGS 84 (EPSG:4326). Want Mercator (EPSG:3857) for D3 projection.
@mkdir -p $(dir $@)
@gdalwarp \
-co "TFW=YES" \
-s_srs "EPSG:4326" \
-t_srs "EPSG:3857" \
$< \
$@
# Crop raster to shape of Afghanistan
tif/afghanistan-cropped.tif: tif/afghanistan-reprojected.tif
@mkdir -p $(dir $@)
@gdalwarp \
-cutline shp/district398.shp \
-crop_to_cutline \
-dstalpha $< $@
# Shade and color
tif/afghanistan-color-crop.tif: tif/afghanistan-cropped.tif
@rm -rf tmp && mkdir -p tmp
@gdaldem \
hillshade \
$< tmp/hillshade.tmp.tif \
-z 5 \
-az 315 \
-alt 60 \
-compute_edges
@gdal_calc.py \
-A tmp/hillshade.tmp.tif \
--outfile=$@ \
--calc="255*(A>220) + A*(A<=220)"
@gdal_calc.py \
-A tmp/hillshade.tmp.tif \
--outfile=tmp/opacity_crop.tmp.tif \
--calc="1*(A>220) + (256-A)*(A<=220)"
@rm -rf tmp
# Convert to .png
afghanistan.png: tif/afghanistan-color-crop.tif
@convert \
-resize x670 \
$< $@
all: afghanistan-districts.json \
afghanistan-provinces.json \
afghanistan-cities.json \
afghanistan.png
clean:
@rm -rf geojson
@rm -rf hgt
@rm -rf shp
@rm -rf tif
@rm -rf topojson
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment