Dopo un’interminabile campagna elettorale è arrivato il momento di votare per il referendum costituzionale. E quindi di gestire la presentazione dei dati del voto in real time sulle pagine del sito del Sole 24 Ore: uno speciale con tutti i comuni di Italia, navigabile tramite mappe geografiche che si basano sulla libreria D3.js (e più specificamente su dati in formato topojson). Anche la testatina presente nella home page del sito dipende dal meccanismo di elaborazione dei dati, provenienti direttamente dal Ministero dell’Interno.
L’emozione di vivere l’evento dall’interno di un grande giornale è difficile da raccontare. Il susseguirsi delle notizie, gli exit poll, mantenere sotto controllo le diverse pagine (anche sul mobile site) che riportano affluenze e voti... e alle 6 di mattina, chiusura degli scrutini. Con l'adrenalina che ti tiene sveglio. Grazie a tutti i collaboratori per la professionalità dimostrata.
Nella pagina di accesso dello Speciale Referendum Costituzionale la mappa italiana divisa per regioni con i risultati del voto.
Сегодня я расскажу вам, как с помощью JavaScript и d3 нарисовать карту, подобную моей.
Утилиты (Prerequisites)
Для начала работы нам понадобятся две утилиты ogr2ogr и topojson. ogr2ogr входит в состав GDAL — библиотеки и набора утилит для работы с гео‑данными. topojson — небольшая утилита, написанная на JavaScript и работающая на node.js.
Установить GDAL (и, соответственно, ogr2ogr)
Под Linux тоже есть соответствующий пакет:
sudo apt-get install gdal-bin
Далее необходимо установить node.js:
sudo apt-get install nodejs
Устанавливаем npm:
sudo apt-get install npm
Вторая утилита ставится через npm :
sudo npm install -g topojson
Всё, теперь можно приступать к работе.
Данные
Вся суть любой карты — это данные. Не будет данных, нечего будет рисовать. Я рекомендую данные Natural Earth из‑за их доступности и открытости. Итак, идём в раздел 1:10m, Cultural и скачиваем карту с делением по странам (первый раздел, Admin 0 — Countries, любая ссылка).
Скачали, распаковали. Теперь с этим добром нужно что‑то делать, данные‑то в формате Shapefile. Для преобразования данных в читаемый вид нам и понадобится ogr2ogr — утилита, которая преобразует данные из одного векторного формата в другой. У неё огромное количество различных параметров, но я облегчу вам задачу.
После вызова этой команды должен появиться файл countries.json, содержащий те же данные, но в формате GeoJSON. Размер файла, конечно, не маленький — 24 Мб. Но не стоит отчаиваться: для работы понадобится его более оптимизированный собрат — TopoJSON.
topojson -o world.json countries.json
Если во время выполнения скрипта выпало сообщение:
/usr/bin/env: node: Нет такого файла или каталога
или
/usr/bin/env: node: No such file or directory
Это связано с тем что некоторые дистрибутивы линукс включают node.js под именем nodejs. Поэтому нужно создать сначала создать ссылку:
ln -s /usr/bin/nodejs /usr/bin/node
После снова попробовать переконвертировать файл. В результате имеем файл размером 2.3 Мб, с ним и будем работать. Этот размер можно ещё значительно уменьшить, но об этом я расскажу чуть позже.
Первая картинка
Начнём с создания новой страницы:
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Map Tutorial 01</title> <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/3.0.1/normalize.min.css"/> <script src="http://d3js.org/d3.v3.min.js"></script> <script src="http://d3js.org/topojson.v1.min.js"></script> </head> <body> <script> // Здесь будет код </script> </body> </html>
Теперь можно открывать всё это в браузере. Подойдёт любой статический сервер. Например, в PhpStorm, который я использую, можно просто нажать на файл правой клавишей и выбрать «Открыть в браузере» — выбранный файл отобразится с помощью небольшого встроенного в IDE сервера.
Создаём элемент <svg> размером с окно, в котором и будет происходить вся отрисовка. Для этого заменяем комментарий на подходящий код:
var width = window.innerWidth;
var height = window.innerHeight;
var svg = d3.select("body").append("svg") .attr("width", width) .attr("height", height);
Эта инструкция создаёт html‑элемент <svg>, добавляет его к <body> и прописывает подходящие размеры в атрибуты width и height.
Пришла пора загрузить данные. Дописываем в конец:
d3.json("/js/world.json", function (error, world) {
if (error) {
console.log(error);
return;
}
На самом деле это я быстро набросал, чтобы было что показать. Теперь будем постепенно менять, приводя к виду, который на самом деле хочется получить.
После каждого логического куска поста есть две ссылки: на результат, который должен был получиться, и на написанный мной код. Первые две можете наблюдать под картинкой чуть выше. Если вдруг что‑то не получается, всегда можно подсмотреть или даже скопировать мою версию.
Проекция
Пока не начинаешь рисовать карту, даже не задумываешься, какое огромное количество проекций придумали люди, чтобы покрасивее отобразить объёмную землю на плоской бумаге. Меркатор, Winkel Tripel, Aitoff, Dymaxion, проекция Гуда… Их десятки, если не сотни. В контексте d3 проекция — это просто функция, которая переводит географические координаты (широту и долготу) в координаты на экране.
На картинке выше используется Меркатор. Это можно увидеть и в коде.
Вынесем код создания проекции в самый верх и поместим проекцию в отдельную переменную, она нам ещё пригодится.
var projection = d3.geo.winkel3();
Код отрисовки карты чуть поменялся и стал ссылаться на новую переменную.
Как, может быть, вы уже заметили, я заменил Меркатора на более аккуратную Winkel Tripel (именно эту проекцию использует National Geographic для своих изданий). К сожалению, проекция не входит в стандартную поставку d3, поэтому придётся подключить ещё отдельный плагин с её определением.
Возвращаемся к данным. Помните, что мы преобразовали наши данные в компактный TopoJSON? Перед отрисовкой эти данные всё равно придётся преобразовать обратно в GeoJSON, для этого у нас и подключена библиотека TopoJSON, посмотрите в заголовке нашего html.
var countries = topojson.feature(world, world.objects.countries).features;
В переменной countries теперь содержится массив с описанием стран в формате GeoJSON. Нарисуем их с помощью раздельных элементов <path>, благо d3 позволяет сделать это быстро и просто.
Создаём группу (элемент <g>), которая очень пригодится нам в будущем. Это нужно вставить сразу после объявления переменной svg.
Если вы ещё не знаете, что делает enter c выборкой из selectAll, то быстренько сходите и восполните этот пробел.
Переменная path в последней строчке — это функция, которая переводит описание страны из GeoJSON в строку, подходящую для отображения в svg. Её можно объявить в самом верху, сразу после объявления проекции.
var path = d3.geo.path().projection(projection);
Обновляем страницу (Результат, Исходники). Теперь границы государств стали более различимыми. Сделаем их ещё более видимыми с помощью css. Добавим в заголовок страницы блок <style>:
Пришла пора добавить интерактива к карте. Но для начала разместим карту по центру и растянем на весь экран.
Для растягивания проекции нужно указать параметр scale, который по‑умолчанию равен 150. К сожалению, все соотношения меняются от проекции к проекции, поэтому просто посчитаем нужные параметры для нашей. Для этого в инспекторе браузера нужно глянуть ширину и высоту элемента path, соответствующего фону. В случае Winkel Triple ширина равна 772, а высота — 472.
Максимальное увеличение, при котором карта будет помещаться на экран, по горизонтали равно 150 / 772 * width, по вертикали — 150 / 472 * height. Выбираем меньшее из двух.
Всё, теперь можно добавлять интерактив. Как и положено, в таких библиотеках уже много всего придумано и реализовано за вас, остаётся только вызвать нужную функцию в нужный момент.
Создаём объект behavior.zoom, который будет отвечать за отслеживание событий:
var zoom = d3.behavior.zoom()
.scaleExtent([1, 60])
.size([width, height])
.on('zoom', onZoom);
Присоединяем созданный объект к нашему элементу <svg>:
function onZoom () { var t = d3.event.translate; var s = d3.event.scale; t[0] = Math.max(Math.min(t[0], 0), width * (1 - s)); t[1] = Math.max(Math.min(t[1], 0), height * (1 - s)); zoom.translate(t); g.style("stroke-width", 1 / s) .attr('transform', 'translate(' + t + ')scale(' + s + ')'); }
В d3 параметры события сохраняются в специальном объекте d3.event, в случае события zoom нас интересуют два параметра: translate — смещение карты и scale — текущее увеличение. После получения этих параметров мы накладываем ограничение на смещение, чтобы пользователь не мог сдвинуть карту дальше её границ. Затем мы записываем новое смещение обратно в объект zoom: без этого он будет продолжать считать, что текущее смещение другое и сообразно обрабатывать новые события от мыши. И последнее действие — применить новые параметры смещения и увеличения к карте: нужно же, чтобы картинка менялась соответственно нашим действиям.
Кстати, именно для правильной работы масштабирования мы и создавали элемент <g> выше. Атрибут transform не работает с элементом <svg>.
Результат, Исходники.
Дополнительные данные
Идём дальше. Добавим к нашей карте немножко больше данных. Я создавал карту посещённых стран и городов, о чём и буду рассказывать. Я думаю, некоторые идеи можно применить и для других целей и наборов данных.
Такие совсем специфические данные, как список посещённых стран, взять негде, их придётся создавать самостоятельно. Я для этих целей создал файл data.json в таком формате:
{ // Список стран "AUT": { // Код страны "name": "Австрия", // Название страны по-русски "color": "turquoise", // Цвет подсветки: blue, green, orange, pink, purple, red, turquoise, yellow "cities": [ // Список городов внутри страны { "name": "Вена", // Название города по-русски "lat": 48.216667, // Широта "lon": 16.373333 // Долгота }, { "name": "Грац", "lat": 47.066667, "lon": 15.433333 } ] }, ... }
Чтобы вам не заморачиваться с созданием собственного файла, я предлагаю пока воспользоваться моим, потом переделаете всё так, как нравится. Скачать файлик можно по этой ссылке.
Загружаем данные из data.json сразу после загрузки карты и добавляем правильный фон к странам.
var visitedData = {};
d3.json("world.json", function (error, world) {
if (error) {
console.log(error);
return;
}
d3.json("data.json", function (error, data) {
if (error) {
console.log(error);
} else {
visitedData = data;
}
var countries = topojson.feature(world, world.objects.countries).features;
g.selectAll('.country').data(countries).enter()
.append('path')
.attr('class', 'country')
.attr('d', path)
.style('fill', function (d) {
var color = visitedData[d.id] && visitedData[d.id].color;
return color && COLORS[color] || '#ffffdd';
});
});
});
В d3, если в методы attr и style вторым параметром передать функцию, то значения атрибутов и стилей соответственно будут браться из результатов вызова этой функции. Первым параметром передаётся значение элемента массива, ранее заданного с помощью метода data.
Остаётся добавить массив констант с вариантами цвета фона где‑нибудь в начале файла:
Запускаем и видим, что ничего не поменялось (Результат, Исходники). На самом деле причина проста: в файле world.json нет информации о кодах стран. Ошибка исправляется передачей дополнительного параметра в вызов topojson. Перегенерировать world.json нужно такой командой:
Теперь можно нарисовать города. Для этого сразу после кода, ответственного за рисование стран, добавляем код для рисования городов:
// Собираем все города в один массив. for (var i in visitedData) { if (visitedData.hasOwnProperty(i) && visitedData[i].cities) { cities.push.apply(cities, visitedData[i].cities); } } // Рисуем города g.selectAll('.city').data(cities).enter() .append('path') .attr('class', 'city') .attr('d', function (d) { return path({ 'type': 'Point', 'coordinates': [d.lon, d.lat] }); });
Обратите внимание, что координаты в path передаются в формате [широта, долгота].
Не забываем в начале объявить массив cities:
var cities = [];
И добавить стиль для городов:
.city { fill: #dd3d30; }
Смотрим результат:
Результат, Исходники
Да, точки для городов хотелось бы сделать поменьше. Добавляем правило рисования точек в path:
var path = d3.geo.path().projection(projection).pointRadius(1);
А ещё неплохо было бы сделать так, чтобы размер точек менялся с масштабом. Значит, добавляем код в конец функции onZoom.
Осталось написать обработчики событий мыши mousemove и mouseout. Для этого в операцию создания стран добавляем новые инструкции (новый код начинается с вызова метода on).
g.selectAll('.country').data(countries).enter() .append('path') .attr('class', 'country') .attr('d', path) .style('fill', function (d) { var color = visitedData[d.id] && visitedData[d.id].color; return color && COLORS[color] || '#ffffdd'; }) .on('mousemove', function (d) { var mouse = d3.mouse(svg.node()); var name = visitedData[d.id] && visitedData[d.id].name; if (!name) { return; } tooltip.style("display", "block") .style("left", mouse[0] + "px") .style("top", mouse[1] + "px") .html(name); }) .on('mouseout', function () { tooltip.style('display', 'none'); });
Из‑за того, что метод on вызван после вызова enter, события вешаются на каждый элемент <path> в отдельности, а в параметры передаётся соответствующий элемент из массива countries.
Метод d3.mouse возвращает текущие координаты мыши относительно контейнера, переданного в параметрах. В нашем случае это элемент <svg>. Дальше все просто: записываем в tooltip нужное содержимое и устанавливаем стили left, top и display.
В обработчике события mouseout прячем наш элемент с подписью. И всё работает как надо!
Добавим аналогичный код для отображения подписей к городам.
Некоторые из вас, должно быть, заметили, что, например, Франция имеет территорию в Южной Америке, которая тоже подсвечивается. А от этого хочется избавиться, ведь нас там не было. Чтобы получить правильный результат, можно воспользоваться другим набором данных с Natural Earth. Снова идём в раздел 1:10m, Cultural и скачиваем карту с делением по subunits (Admin 0 — Details, Download map subunits).
Если мы просто заменим файл с исходными данным, то быстро заметим, что кроме Франции изменениям подверглись ещё несколько стран. Например, Бельгия оказалась разделена на три части: Фландрию, Валлонию и Брюссель, да и на Великобритании тоже появились границы между Англией, Шотландией и Уэльсом. Получается, чтобы получить желаемый результат, придётся составить карту из двух источников.
Возьмём Францию из ne_10m_admin_0_map_subunits. Обратите внимание на параметр -where, который накладывает нужные ограничения на выборку.
Обратите внимание на то, как изменился вызов topojson, особенно параметр id-property. Все дело в том, что атрибут ADM_A3 содержит код страны, то есть у всех частей Франции код будет один и тот же. Поэтому для неё нужен другой атрибут в качестве идентификатора. Атрибуты в параметре id-property берутся в обратном порядке: сначала утилита пытается взять SU_A3, а в случае, если его нет, берет предыдущий, то есть ADM_A3.
Теперь для правильной отрисовки нужно добавить в массив countries новые элементы.
var countries = topojson.feature(world, world.objects.countries).features; countries.push.apply(countries, topojson.feature(world, world.objects.subunits).features);
Конструкция push.apply добавляет все элементы одного массива в другой. Более подробно про this, call и apply можно почитать в моей старой статье про ООП в JavaScript (раздел «Ключевое слово this»).
Осталось исправить в нашем файле с данными код Франции (новый код — «FXX») и наслаждаться результатом.
Результат, Исходники
Разбиение стран на регионы
Следующее пожелание: крупные страны, например, США и Россию, поделить на штаты и субъекты соответственно. Для этого нам понадобится новый источник данных. Снова идём на привычный адрес и скачиваем файл из раздела Admin 1 — States, Provinces.
Пересобираем файлы, не забыв предварительно удалить старые (org2org отказывается перезаписывать файлы, если вы ещё не заметили).
Так же как и раньше, добавляем новые данные для рисования в массив countries.
var countries = topojson.feature(world, world.objects.countries).features; countries.push.apply(countries, topojson.feature(world, world.objects.subunits).features); countries.push.apply(countries, topojson.feature(world, world.objects.regions).features);
Вообще говоря, подсветку штатов и субъектов я писал отдельно, но можно просто добавить нужные идентификаторы в файл data.json.
Результат, Исходники
Атрибуты
В файлах, скачанных с Natural Earth, есть множество дополнительных данных, не только форма границ и код страны. Большая часть этих данных лежит в файле .dbf, который представляет собой файл базы данных dBase. И если у вас есть просмотрщик для этого формата, то можете смело его открывать. Если же нет, то можно воспользоваться Online‑конвертером из dbf в csv. Я использовал именно этот способ. После конвертации сsv‑файл можно открыть LibreOffice или же Microsoft Excel.
ogr2ogr тоже копирует все данные из файлов атрибутов внутрь результирующего файла, а topojson может копировать некоторые необходимые атрибуты в файл TopoJSON. Вот таким образом можно добавить атрибут NAME, который содержит английское название объекта:
Теперь можно использовать значение поля, например, для отображения названий непосещённых стран. Вот эту строчку нужно заменить в обработчике события mousemove для стран:
var name = visitedData[d.id] && visitedData[d.id].name || d.properties.name || d.properties.NAME;
Результат, Исходники.
Уменьшение размера файла с картой
Последний нерешенный вопрос: размер файла с картой. После всех наших манипуляций он стал весить 2.6 Мб, что, согласитесь, несколько многовато. topojson предоставляет несколько параметров для оптимизации. Один из них так и называется — simplify. Для получения оптимального результата придётся, конечно, немного поэкспериментировать с величиной, в нашем же случае вполне подойдёт 1e-6.
Получившийся файл имеет размер 444 Кб и всё ещё приемлемую детализацию.
Результат, Исходники.
Благодарности
Спасибо Mike Bostock за такую прекрасную библиотеку. И отдельное спасибо ему же за прекрасные записи в блоге, некоторые идеи из которых перекочевали в этот пост.
Спасибо всем тем, кто дочитал до конца. Надеюсь, этот пост поможет вам.
I question that people often ask about topojson is, what's the algorithm for creation the topology. Hell it's a question I asked. Here as best as I can manage, is how the sausage is made for topojson, warning, lots of JavaScript to follow.
First a refresher on GeoJSON, the following is from the spec:
https://gist.github.com/calvinmetcalf/6373239
Before we get into topojson, a disclaimer, at most one of your reading this is Mike Bostock, the rest of you, DON'T CODE IN THIS STYLE it is tricky to following, easy to make mistakes in and it wouldn't be close to passing the lint step on any of my projects. That being said I can't really fault Mike he ships some incredible stuff very frequently, certainly more then me and as far as I'm concerned when you have the results he does you can code in whatever style you want, but until then use jshint.
Anywho TopoJSON uses this function 'each' a lot:
https://gist.github.com/calvinmetcalf/6373252
which references this type function which I am linking because it's long. These two objects are in many ways the bread and butter of TopoJSON, types takes an objects containing functions for what to do to the input geojson, so this bounds function
https://gist.github.com/calvinmetcalf/6373287
specifies what to do for each point so it can compute the bounding box..
One of the things that TopoJSON does is quantizes the input, this more or less like rasterizing the input coordinates. settings the quantization factor to 1e4 is another way of setting it to 10,000, which says set up my coordinates to be a box 10,000x10,000. Then remember that bounding box we just computed? We can now set it up so that whatever our minimum x and y are equal to 0, and the maximum ones to 10,000 and scale everything in between accordingly,
https://gist.github.com/calvinmetcalf/6373341
which means that you can quantize a value by subtracting the minimum, and then dividing by the scale value, this is then rounded to the nearest integer. This is an important part of the algorithm for finding arcs that match because it means that the arcs are simplified in a way that they will converge on the same values. Especially with this next function which looks for lines that go through the same point multiple times and removes the dups
https://gist.github.com/calvinmetcalf/6373367
Do you see how it works? Of course not, I haven't explained the 'coincidences' function there. Its some sort of homebrew hashtable which I think allows easy access to sets based on their members, it's also used to find arcs by the lines that make up them, and lines by the points that make up them. I haven't fully wrapped my brain around this one yet.
As an aside, the features get looped through 4 times plus more times before the function is called, i.e. if you want to simplify it. The minimum number of loops is 2 if you are able to supply a bounding box yourself or 3 if you can't. Before the next steps can happen the coincidences have to be dealt with in the above function. That function relies on quantization which needs a bounding box. So in theory if you had a bounding box you could do one loop that simplified, quantilizied and found coincidences, and then loop again with the bellow stuff.
We then have another big each call, which does a lot of stuff including transforming properties, the relevant part that changes the geometries is here. Which calls two functions lineClosed and lineOpen which are just shortcuts to line.
If were were on cribs, this would be the bedroom.
https://gist.github.com/calvinmetcalf/6373449
This is the difference between open and closed lines, in geojson a polygon is closed and thus repeates the first and last point. Next it tries to find matches in the line segments and if it can't it rotates any loops so you start from the bottom, it helps to remember that 'a' is the in progress arc, 'n' is the length of the arc, but 'p' and 't' are either the arc, or all arcs depending on where you are in the function,
https://gist.github.com/calvinmetcalf/6373459
Then we dedup points and finish up here
https://gist.github.com/calvinmetcalf/6373541
i'm going to be honest the previous section of code only makes sense to me around 11am when I am so caffeinated I have a speech impediment. This section checks whether a point should be added to a current arc or be the start of a new arc, this makes somewhat more sense in the python port
The actual arc creation is relatively straightforward
https://gist.github.com/calvinmetcalf/6373554
The bulk of it happends in the matchForward and matchBackwards functions which return true if the arc is a dupe and take care of putting it into the object. This is by no means the only way to do this portion, the python port uses a hash table here (not the one used for point and arc concurrences, that one has is a very different type of hash function which will collide when used on arcs for this)
The last steps of assembling the Topojson and delta encoding the arcs happens here
https://gist.github.com/calvinmetcalf/6377118
Note the map function where the actual delta encoding occurs.
So to sum up, first I really wouldn't consider that there is an algorithm for deduping the arcs because; A: It's a heuristic and I'm that kind of asshole and B: It isn't one discrete thing, it's several steps each one relying on the one before but also modular and replaceable. To summarize those steps are:
Quantize the points, I hesitate to actually include this as it is optional for deduping the arcs, but it does help by increasing the changes of a match being found by removing the cartographers worst enemy: points closed very close together but not exactly in the same spot.
Find the concurrent lines and make sure we break up our arcs at the same point, no matter how good our arc matching algorythm is it'll fail to match the arc a->b->c to the arc z->a->b->c->d. For this a homebrew hashtable is used.
Find the arcs that are matching.
From the man himself:
linearize (extract lines & rings)
cut (at junctions)
dedup (canonicalize duplicate arcs)
I'd like to thank Mike Bostock for both writing this awesome library and making it available to the world as free software.
I hope you, the reader, are marginally less confused then before and seriously kids, don't use one letter variable names, always use curly brackets even when they are optional, and don't assign variables inside comparisons.
Checkout my python port and if you like using TopoJSON help us write the spec.
One of the main reasons I ported topojson to python was so that I could add topojson as a format to esri2open.
But after I ported it I found myself with a problem, esri2open works on each feature one at a time writing directly to disc. The reason is simple, to avoid caring about the size of the data set, when you write directly to disc like this if doesn't matter how many features there are, you could write out a terabyte sized geojson file.
The original node.js topojson parser doesn't work like this at all, it loops through the input data 4 or more times depending on the options. From the looks of it it seems you need to loop through at least 3 times, though if you know the bounding box in advance you can skip one loop.
So if I have to loop through it multiple times I need to at least limit the amount of data I have in memory at once, which brings me to one of the nice things about python, a grotesquely large standard library which includes like 7 database formats. This means I can use the database to store the features I'm not using and use python generators when I'm done to prevent to much getting into memory while writing it to disc at the end.
You can grab it on the incremental branch of topojson.py or from this branch of esri2open.
Currently not working well on windows. Currently not working well on windows when tested exclusively on gigantic datasets.
Going forward the incremental version will probably live separately from the main one as it can only handle a subset of the topojson spec (coincidentally the subset that can be exported from desktop GIS software)