Skip to content

Instantly share code, notes, and snippets.

@fabiovalse
Last active November 8, 2019 06:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save fabiovalse/546e3a5080dc6d6f86b08d5635929d1c to your computer and use it in GitHub Desktop.
Save fabiovalse/546e3a5080dc6d6f86b08d5635929d1c to your computer and use it in GitHub Desktop.
SVG political map of Italy

This gist shows a political map of Italy. It represents a baseline for constructing more sophisticated map such as choropleth or geographic bubble maps.

The visualization shows regions, provinces and towns (i.e., comuni). The level of detail of the map changes on zoom. Labels of administrative area are loaded according to zoom level in a way that tries to avoid collision and overcrowding.

In order to improve the visualization performance, a viewport filtering technique has been adopted. The SVG objects visible to the user are rendered while the ones outside the viewport are filtered out from the DOM.

2016 administrative boundaries have been downloaded from ISTAT archive. The following procedure shows how to obtain a topojson file containing towns, provinces, regions and country data together and mantaining their metadata.

## Installing topojson & ogr2ogr
sudo npm install -g topojson
sudo npm install -g ndjson-cli
sudo npm install -g json-to-ndjson

## Download official boundaries
wget http://www.istat.it/storage/cartografia/confini_amministrativi/non_generalizzati/2016/Limiti_2016_WGS84.zip

## Unzip package
unzip Limiti_2016_WGS84.zip

## Convert Shapefile to GeoJSON
ogr2ogr -f GeoJSON -s_srs Limiti_2016_WGS84/Com2016_WGS84/Com2016_WGS84.prj -t_srs EPSG:4326 towns.geojson Limiti_2016_WGS84/Com2016_WGS84/Com2016_WGS84.shp

ogr2ogr -f GeoJSON -s_srs Limiti_2016_WGS84/CMProv2016_WGS84/CMprov2016_WGS84.prj -t_srs EPSG:4326 provinces.geojson Limiti_2016_WGS84/CMProv2016_WGS84/CMprov2016_WGS84.shp

ogr2ogr -f GeoJSON -s_srs Limiti_2016_WGS84/Reg2016_WGS84/Reg_2016_WGS84.prj -t_srs EPSG:4326 regions.geojson Limiti_2016_WGS84/Reg2016_WGS84/Reg_2016_WGS84.shp

## Convert GeoJSON to NDJSON
json-to-ndjson -p 'features.*' -o towns.ndjson towns.geojson
json-to-ndjson -p 'features.*' -o provinces.ndjson provinces.geojson
json-to-ndjson -p 'features.*' -o regions.ndjson regions.geojson

## Create final NDJSON
cat towns.ndjson > final.ndjson
cat provinces.ndjson >> final.ndjson
cat regions.ndjson >> final.ndjson

## Create final GeoJSON
cat final.ndjson | ndjson-reduce 'p.features.push(d), p' '{type: "FeatureCollection", features: []}' > final.geojson

## Convert GeoJSON to TopoJSON
geo2topo --out final.topo.json final.geojson

## Simplify TopoJSON
toposimplify -p 1 -f < final.topo.json > final.simplified.topo.json

## country
topomerge -f "d.properties.REGIONE != undefined" -k "d.type" country=final < final.simplified.topo.json > italy.topo.json
italy = undefined
region_capitals = ["Aosta", "Torino", "Genova", "Milano", "Trento", "Trieste", "Venezia", "Bologna", "Firenze", "Ancona", "Perugia", "Roma", "L'Aquila", "Campobasso", "Napoli", "Potenza", "Bari", "Catanzaro", "Palermo", "Cagliari"]
TH_1 = 5
TH_2 = 25
# SVG group structure
svg = d3.select 'svg'
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
zoomable_layer = svg.append 'g'
base_layer = zoomable_layer.append 'g'
boundaries_layer = zoomable_layer.append 'g'
labels_layer = zoomable_layer.append 'g'
# Zoom behaviour
zoom = d3.zoom()
.scaleExtent [1, Infinity]
.on 'zoom', () ->
# Update level of details
lod d3.event.transform
# Transform SVG
zoomable_layer
.attrs
transform: d3.event.transform
svg.call(zoom)
# Geographic projection
projection = d3.geoAzimuthalEqualArea()
.clipAngle 180-1e-3
.scale 3000
.rotate [-12.22, -42, 0]
.translate [width/2, height/2]
.precision 0.1
path = d3.geoPath().projection(projection)
# Returns a transform for center a bounding box in the browser viewport
# - W and H are the witdh and height of the window
# - w and h are the witdh and height of the bounding box
# - center cointains the coordinates of the bounding box center
# - margin defines the margin of the bounding box once zoomed
to_bounding_box = (W, H, center, w, h, margin) ->
kw = (W - margin) / w
kh = (H - margin) / h
k = d3.min [kw, kh]
x = W/2 - center.x*k
y = H/2 - center.y*k
return d3.zoomIdentity
.translate x, y
.scale k
# Filters data outside the current viewport
in_viewport = (d, x0, x1, y0, y1) -> d.properties.bbox.x0 < x1 and d.properties.bbox.x1 > x0 and d.properties.bbox.y0 < y1 and d.properties.bbox.y1 > y0
# Prepares data for being visualized
transform = (data) ->
regions = {type: 'GeometryCollection', geometries: data.objects.final.geometries.filter (d) -> d.properties.REGIONE?}
provinces = {type: 'GeometryCollection', geometries: data.objects.final.geometries.filter (d) -> d.properties.PROVINCIA?}
towns = {type: 'GeometryCollection', geometries: data.objects.final.geometries.filter (d) -> d.properties.COMUNE?}
data.objects = {
country: data.objects.country
regions: regions
provinces: provinces
towns: towns
}
for key, obj of data.objects
topojson.feature(data, obj).features.forEach (d,i) ->
bounds = path.bounds d
if d.properties.COMUNE?
d.properties.label = d.properties.COMUNE
if d.properties.PROVINCIA?
if d.properties.PROVINCIA is '-'
d.properties.label = d.properties.DEN_CMPRO
else
d.properties.label = d.properties.PROVINCIA
if d.properties.REGIONE?
d.properties.label = d.properties.REGIONE
d.properties.capital = d.properties.label in region_capitals
if data.objects[key].geometries[i].properties?
data.objects[key].geometries[i].properties.area = d3.geoArea d
data.objects[key].geometries[i].properties.bbox = {
x0: bounds[0][0]
y0: bounds[0][1]
x1: bounds[1][0]
y1: bounds[1][1]
}
return data
draw_labels = (data, cls, k) ->
labels = labels_layer.selectAll ".#{cls}"
.data data, (d) -> d.properties.label
en_labels = labels.enter().append 'text'
.attrs
class: cls
all_labels = en_labels.merge(labels)
all_labels
.attrs
x: (d) -> d.properties.bbox.x0 + (d.properties.bbox.x1-d.properties.bbox.x0)/2
y: (d) -> d.properties.bbox.y0 + (d.properties.bbox.y1-d.properties.bbox.y0)/2
dy: '0.35em'
.styles
'font-size': (d) -> if d.properties.region_capital? and d.properties.region_capital is 1 then 18/k else 14/k
.classed 'capital', (d) -> d.properties.capital
.on 'click', (d) ->
w = d.properties.bbox.x1-d.properties.bbox.x0
h = d.properties.bbox.y1-d.properties.bbox.y0
center = {
x: d.properties.bbox.x0 + w/2
y: d.properties.bbox.y0 + h/2
}
transform = to_bounding_box(width, height, center, w, h, height/10)
svg.transition().duration(2000).call(zoom.transform, transform)
.text (d) -> d.properties.label
labels.exit().remove()
# Level of Details
# filters useless data from the visualization according to the current viewport
lod = (transform) ->
x0 = -transform.x/transform.k
y0 = -transform.y/transform.k
x1 = x0 + width/transform.k
y1 = y0 + height/transform.k
k = transform.k
### Region labels
###
data = []
if k <= TH_1
data = topojson.feature(italy, italy.objects.regions).features
.filter (d) -> in_viewport(d, x0, x1, y0, y1) and d.properties.area > Math.pow(0.015/k, 2)
draw_labels data, 'region_label', k
### Provinces labels
###
data = []
if TH_2 > k > TH_1
data = topojson.feature(italy, italy.objects.provinces).features
.filter (d) -> in_viewport(d, x0, x1, y0, y1) and d.properties.area > Math.pow(0.01/k, 2)
draw_labels data, 'province_label', k
### Towns labels
###
data = []
if k > TH_2
data = topojson.feature(italy, italy.objects.towns).features
.filter (d) -> in_viewport(d, x0, x1, y0, y1) and d.properties.area > Math.pow(0.025/k, 2)
draw_labels data, 'town_label', k
# Initialize the layout displaying
# - the base layer of Italy
# - the internal boundaries of regions, provinces and towns
init = (zoomable_layer) ->
italy or= await fetch 'italy.topo.json'
.then (response) -> response.json()
.then (data) -> transform data
### Base Layer
###
shapes = base_layer.selectAll '.italy'
.data topojson.feature(italy, italy.objects.country).features
en_shapes = shapes.enter().append 'g'
.attrs
class: 'italy'
en_shapes.append 'path'
all_shapes = en_shapes.merge(shapes)
all_shapes.select 'path'
.attrs
d: path
shapes.exit().remove()
### Boundaries Layer
###
# Regions
boundaries_layer.append 'path'
.datum topojson.mesh(italy, italy.objects.regions, (a, b) -> a != b)
.attr 'class', 'boundaries region'
.attr 'd', path
# Provinces
boundaries_layer.append 'path'
.datum topojson.mesh(italy, italy.objects.provinces, (a, b) -> a isnt b and a.properties.COD_REG is b.properties.COD_REG)
.attr 'class', 'boundaries province'
.attr 'd', path
# Towns
boundaries_layer.append 'path'
.datum topojson.mesh(italy, italy.objects.towns, (a, b) -> a isnt b and a.properties.COD_PRO is b.properties.COD_PRO)
.attr 'class', 'boundaries town'
.attr 'd', path
lod d3.zoomIdentity
init()
body, html {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
font-family: "Lacuna";
}
svg {
width: 100%;
height: 100%;
}
@font-face {
font-family: "Lacuna";
src: url("lacuna.ttf");
}
.hidden {
display: none;
}
/* Labels
*/
.region_label, .province_label, .town_label {
fill: #596E71;
font-weight: bold;
text-anchor: middle;
cursor: pointer;
}
/* PALETTE-1
*/
svg {
background: #0D3346;
}
.boundaries {
fill: none;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.boundaries.country {
stroke-width: 0px;
}
.boundaries.region {
stroke: #596E71;
stroke-width: 1.5px;
}
.boundaries.province {
stroke: #ABAA8B;
stroke-width: 1px;
}
.boundaries.town {
stroke: rgb(221, 220, 188);
stroke-width: 0.5px;
}
svg text.region_label {
letter-spacing: 1px;
}
svg text.capital {
text-transform: uppercase;
font-size: 18px;
}
.italy {
fill: #F5F4E6;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Italy</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-selection-multi.v0.4.min.js"></script>
<script src="https://d3js.org/d3-geo-projection.v2.min.js"></script>
<script src="https://unpkg.com/topojson-client@3"></script>
</head>
<body>
<svg></svg>
<script src="index.js"></script>
</body>
</html>
// Generated by CoffeeScript 2.0.1
(function() {
var TH_1, TH_2, base_layer, boundaries_layer, draw_labels, height, in_viewport, init, italy, labels_layer, lod, path, projection, region_capitals, svg, to_bounding_box, transform, width, zoom, zoomable_layer,
indexOf = [].indexOf;
italy = void 0;
region_capitals = ["Aosta", "Torino", "Genova", "Milano", "Trento", "Trieste", "Venezia", "Bologna", "Firenze", "Ancona", "Perugia", "Roma", "L'Aquila", "Campobasso", "Napoli", "Potenza", "Bari", "Catanzaro", "Palermo", "Cagliari"];
TH_1 = 5;
TH_2 = 25;
// SVG group structure
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
zoomable_layer = svg.append('g');
base_layer = zoomable_layer.append('g');
boundaries_layer = zoomable_layer.append('g');
labels_layer = zoomable_layer.append('g');
// Zoom behaviour
zoom = d3.zoom().scaleExtent([1, 2e308]).on('zoom', function() {
// Update level of details
lod(d3.event.transform);
// Transform SVG
return zoomable_layer.attrs({
transform: d3.event.transform
});
});
svg.call(zoom);
// Geographic projection
projection = d3.geoAzimuthalEqualArea().clipAngle(180 - 1e-3).scale(3000).rotate([-12.22, -42, 0]).translate([width / 2, height / 2]).precision(0.1);
path = d3.geoPath().projection(projection);
// Returns a transform for center a bounding box in the browser viewport
// - W and H are the witdh and height of the window
// - w and h are the witdh and height of the bounding box
// - center cointains the coordinates of the bounding box center
// - margin defines the margin of the bounding box once zoomed
to_bounding_box = function(W, H, center, w, h, margin) {
var k, kh, kw, x, y;
kw = (W - margin) / w;
kh = (H - margin) / h;
k = d3.min([kw, kh]);
x = W / 2 - center.x * k;
y = H / 2 - center.y * k;
return d3.zoomIdentity.translate(x, y).scale(k);
};
// Filters data outside the current viewport
in_viewport = function(d, x0, x1, y0, y1) {
return d.properties.bbox.x0 < x1 && d.properties.bbox.x1 > x0 && d.properties.bbox.y0 < y1 && d.properties.bbox.y1 > y0;
};
// Prepares data for being visualized
transform = function(data) {
var key, obj, provinces, ref, regions, towns;
regions = {
type: 'GeometryCollection',
geometries: data.objects.final.geometries.filter(function(d) {
return d.properties.REGIONE != null;
})
};
provinces = {
type: 'GeometryCollection',
geometries: data.objects.final.geometries.filter(function(d) {
return d.properties.PROVINCIA != null;
})
};
towns = {
type: 'GeometryCollection',
geometries: data.objects.final.geometries.filter(function(d) {
return d.properties.COMUNE != null;
})
};
data.objects = {
country: data.objects.country,
regions: regions,
provinces: provinces,
towns: towns
};
ref = data.objects;
for (key in ref) {
obj = ref[key];
topojson.feature(data, obj).features.forEach(function(d, i) {
var bounds, ref1;
bounds = path.bounds(d);
if (d.properties.COMUNE != null) {
d.properties.label = d.properties.COMUNE;
}
if (d.properties.PROVINCIA != null) {
if (d.properties.PROVINCIA === '-') {
d.properties.label = d.properties.DEN_CMPRO;
} else {
d.properties.label = d.properties.PROVINCIA;
}
}
if (d.properties.REGIONE != null) {
d.properties.label = d.properties.REGIONE;
}
d.properties.capital = (ref1 = d.properties.label, indexOf.call(region_capitals, ref1) >= 0);
if (data.objects[key].geometries[i].properties != null) {
data.objects[key].geometries[i].properties.area = d3.geoArea(d);
return data.objects[key].geometries[i].properties.bbox = {
x0: bounds[0][0],
y0: bounds[0][1],
x1: bounds[1][0],
y1: bounds[1][1]
};
}
});
}
return data;
};
draw_labels = function(data, cls, k) {
var all_labels, en_labels, labels;
labels = labels_layer.selectAll(`.${cls}`).data(data, function(d) {
return d.properties.label;
});
en_labels = labels.enter().append('text').attrs({
class: cls
});
all_labels = en_labels.merge(labels);
all_labels.attrs({
x: function(d) {
return d.properties.bbox.x0 + (d.properties.bbox.x1 - d.properties.bbox.x0) / 2;
},
y: function(d) {
return d.properties.bbox.y0 + (d.properties.bbox.y1 - d.properties.bbox.y0) / 2;
},
dy: '0.35em'
}).styles({
'font-size': function(d) {
if ((d.properties.region_capital != null) && d.properties.region_capital === 1) {
return 18 / k;
} else {
return 14 / k;
}
}
}).classed('capital', function(d) {
return d.properties.capital;
}).on('click', function(d) {
var center, h, w;
w = d.properties.bbox.x1 - d.properties.bbox.x0;
h = d.properties.bbox.y1 - d.properties.bbox.y0;
center = {
x: d.properties.bbox.x0 + w / 2,
y: d.properties.bbox.y0 + h / 2
};
transform = to_bounding_box(width, height, center, w, h, height / 10);
return svg.transition().duration(2000).call(zoom.transform, transform);
}).text(function(d) {
return d.properties.label;
});
return labels.exit().remove();
};
// Level of Details
// filters useless data from the visualization according to the current viewport
lod = function(transform) {
var data, k, x0, x1, y0, y1;
x0 = -transform.x / transform.k;
y0 = -transform.y / transform.k;
x1 = x0 + width / transform.k;
y1 = y0 + height / transform.k;
k = transform.k;
/* Region labels
*/
data = [];
if (k <= TH_1) {
data = topojson.feature(italy, italy.objects.regions).features.filter(function(d) {
return in_viewport(d, x0, x1, y0, y1) && d.properties.area > Math.pow(0.015 / k, 2);
});
}
draw_labels(data, 'region_label', k);
/* Provinces labels
*/
data = [];
if ((TH_2 > k && k > TH_1)) {
data = topojson.feature(italy, italy.objects.provinces).features.filter(function(d) {
return in_viewport(d, x0, x1, y0, y1) && d.properties.area > Math.pow(0.01 / k, 2);
});
}
draw_labels(data, 'province_label', k);
/* Towns labels
*/
data = [];
if (k > TH_2) {
data = topojson.feature(italy, italy.objects.towns).features.filter(function(d) {
return in_viewport(d, x0, x1, y0, y1) && d.properties.area > Math.pow(0.025 / k, 2);
});
}
return draw_labels(data, 'town_label', k);
};
// Initialize the layout displaying
// - the base layer of Italy
// - the internal boundaries of regions, provinces and towns
init = async function(zoomable_layer) {
var all_shapes, en_shapes, shapes;
italy || (italy = (await fetch('italy.topo.json').then(function(response) {
return response.json();
}).then(function(data) {
return transform(data);
})));
/* Base Layer
*/
shapes = base_layer.selectAll('.italy').data(topojson.feature(italy, italy.objects.country).features);
en_shapes = shapes.enter().append('g').attrs({
class: 'italy'
});
en_shapes.append('path');
all_shapes = en_shapes.merge(shapes);
all_shapes.select('path').attrs({
d: path
});
shapes.exit().remove();
/* Boundaries Layer
*/
// Regions
boundaries_layer.append('path').datum(topojson.mesh(italy, italy.objects.regions, function(a, b) {
return a !== b;
})).attr('class', 'boundaries region').attr('d', path);
// Provinces
boundaries_layer.append('path').datum(topojson.mesh(italy, italy.objects.provinces, function(a, b) {
return a !== b && a.properties.COD_REG === b.properties.COD_REG;
})).attr('class', 'boundaries province').attr('d', path);
// Towns
boundaries_layer.append('path').datum(topojson.mesh(italy, italy.objects.towns, function(a, b) {
return a !== b && a.properties.COD_PRO === b.properties.COD_PRO;
})).attr('class', 'boundaries town').attr('d', path);
return lod(d3.zoomIdentity);
};
init();
}).call(this);
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.
View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment