d3js
February 15, 2021

Карты в визуализациях

Визуализаций с картами встречается очень много. Но карты в них — не какой-то отдельный тип отображения данных, а больше специальный слой со своей координатной системой.

Эта статья у меня получилась большой, потому что хотелось охватить всё и сразу. Здесь и теория с проекциями и координатами, и работа с данными, и несколько способов отображения.

Ссылки:

  1. Codepen c D3 картой
  2. Codepen с Leaflet картой

Проекции

Чтобы понять, как использовать карты в визуализациях, надо понимать, как они работают. А для этого стоит немного посмотреть теорию картографии.

Я буду говорить про карту в контексте географической карты. Это уменьшенная и упрощённая модель реального объекта, например, Земли. Но планета круглая, поэтому первая задача — представить её на плоской поверхности, с которой будет легче работать.

Для этого представления придуманы картографические проекции. Они помогают растянуть трёхмерную поверхность так, чтобы подменить её плоской, делая некоторые упрощения. Любая проекция не может сохранить все параметры изначального объекта, поэтому она всегда искажённая. В ней могут быть неправильные пропорции, размеры или углы. Самая привычная и стандартная проекция — проекция Меркатора, она сохраняет углы, но искажает размеры. Чем область дальше от экватора, тем её площадь будет больше.

Я выкладывала в канале хорошее демо со сравнениями проекций. Там можно наглядно увидеть, какие параметры и как искажаются.

Отдельно упомяну про координаты. Упрощённо Земля воспринимается как шар, поэтому её система координат сферическая, где любая точка задаётся расстоянием от центра сферы и двумя углами. В привычных терминах, расстояние до центра определяет высоту над уровнем моря, а углы — это широта и долгота. Чтобы удобнее было работать на плоскости, в географии принята гауссовская сетка — она превращает систему долгот и широт в ортогональные координаты. Долготы расставлены с одинаковым шагом, а широты с разным, увеличивающимся по мере удаления от экватора. Лично я эти оси вечно путаю, поэтому иногда вместо Москвы получаю Иран, но достаточно запомнить, что экватор — это нулевая широта.

Данные

Для работы с геоданными существуют географические информационные системы или ГИС. Они предоставляют средства для сбора, хранения и анализа данных, могут эти данные визуализировать и преобразовывать.

Данные в этих системах могут представляться в разных форматах. Самый простой — обычные растровые изображения (PNG или TIFF), это, например, снимки местности в определённом масштабе. Кроме них есть формат GeoTIFF, это расширенный TIFF формат с метаданными, геотегами и дополнительной информацией. Ещё есть векторные форматы, например Shapefile, он хранит геометрические объекты: точки, линии или полигоны.

Но с точки зрения программирования работать с этим всем тяжело. И на помощь приходит стандартный текстовый формат представления данных: JSON (JavaScript Object Notation). Он появился в рамках языка JavaScript, но сейчас уже не зависит от него и может использоваться везде. По структуре это либо набор пар «ключ: значение», либо просто перечисление значений.

Для географических данных в 2008 году придумали свой формат, основанный на JSON и назвали его GeoJSON. Этот формат описывает простые типы геоданных (точка, линия, полигон) и позволяет описывать какие-то их параметры. Выглядит он как-то так:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {"type": "Point", "coordinates": [102.0, 0.5]},
      "properties": {"prop0": "value0"}
    }, {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]
        ]
      },
      "properties": {
        "prop0": "value0",
        "prop1": 0.0
      }
    }, {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [[100.0, 0.0], [101.0, 0.0], [101.0, 1.0],
           [100.0, 1.0], [100.0, 0.0]]
        ]
      },
      "properties": {
        "prop0": "value0",
        "prop1": {"this": "that"}
      }
    }
  ]
}

Это пример из википедии. Там же хорошо и подробно описана схема всего формата.

Ещё есть формат TopoJSON, это расширение GeoJSON, которое позволяет определять и переиспользовать элементы. За счёт этого можно сильно уменьшить размер файла.

Сейчас GeoJSON — самый распространённый формат, который используется при программировании географических данных. Его полностью поддерживают большинство современных картографических сервисов и ГИС.

Как сделать простую карту с D3

Практическую часть этой статьи я начну с D3 и работы с GeoJSON. Я хочу сделать небольшую визуализацию, на которой будет видна карта мира и будут выделены отдельные города.

Данные для этой визуализации я возьму из открытых источников, например, отсюда. Это GeoJSON файл со всеми странами мира.

Для начала работы мне нужно подключить только скрипт D3, других зависимостей тут нет:

<script src="https://d3js.org/d3.v6.min.js"></script>

В другом скрипте я сначала создам svg элемент и укажу ему нужные размеры:

const width = 600;
const height = 400;

const svg = d3.select('body').append('svg')
  .attr('width', width)
  .attr('height', height);

const mapGroup = svg.append('g');

А потом подготовлю проекцию:

const projection = d3.geoMercator()
  .scale([width / (3 * Math.PI)]);

С точки зрения D3 проекция — это функция, которая преобразовывает географические координаты в координаты экрана по каким-то своим правилам. Можно задать свою новую функцию, можно использовать готовые преобразования. Я возьму Меркатор и немного подправлю его, чтобы карта полностью помещалась в контейнер.

Следующий шаг — создать функцию, рисующую path элементы, используя подготовленную проекцию:

const path = d3.geoPath().projection(projection);

Эта функция будет получать информацию о каждой стране из загруженных данных, видеть там поле geometry, брать оттуда массив координат и рисовать его, применяя заданную проекцию.

Теперь можно загрузить данные и показать страны:

d3.json('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/world.geojson')
  .then(data => {
    mapGroup.selectAll('path')
      .data(data.features)
      .enter()
      .append('path')
      .attr('d', path)
      .attr('fill', '#0aa0aa')
      .style('stroke', '#ffffff');
});

Для загрузки я воспользуюсь методом json, а цвета пропишу прямо в аттрибутах. Созданную выше функцию path я передам аттрибуту d. Получается вот такая картинка:

Кстати, тут очень наглядно видно искажение Меркатора — огромная Антарктида внизу.

Как показать дополнительные данные на простой D3 карте

Обычно недостаточно просто нарисовать области на карте, нужно ещё что-то закрасить или пометить маркерами. И сейчас я покажу, как это можно сделать.

Допустим, я хочу раскрасить страны в соответствии с численностью их населения. Для этого сначала мне опять нужны данные. Я возьму их отсюда.

Данных больше, чем мне нужно, поэтому я их отфильтрую и превращу в удобный мне для работы объект. Возьму только те страны, которые есть на моей карте:

d3.csv('https://raw.githubusercontent.com/datasets/population/master/data/population.csv')
  .then(population => {
    const ids = data.features.map(d => d.id);
    const populationData = population
      .filter(d => d['Year'] === '2018' &&
        ids.includes(d['Country Code']))
      .reduce((res, d) => {
        res[d['Country Code']] = +d['Value'];
        return res;
      }, {});
  });

Шкалу я сделаю линейную, где ноль или отсутствие данных будет белым цветом, а максимальное значение — сине-зелёным.

const colorDomain = [
  d3.min(Object.keys(populationData), d => populationData[d]),
  d3.max(Object.keys(populationData), d => populationData[d]),
];
const colorScale = d3.scaleLinear()
  .range(['#ffffff', '#0aa0aa'])
  .domain(colorDomain);

Теперь осталось только добавить цвет областям при рисовании. Если значение существует, то шкала его определит, если не существует (Антарктида, например), то я указываю белый цвет явно:

attr('fill', d => (populationData[d.id]
  ? colorScale(populationData[d.id])
  : '#ffffff'
))

Итоговый результат:

Кроме закрашивания областей иногда может быть нужно нарисовать что-то на карте. Для этого пригодится объект projection, работающий как шкала преобразования систем координат.

Например, следующий код добавляет маркер на место Москвы:

const moscow = projection([37.6173, 55.7558]);

svg.append('circle')
  .attr('cx', moscow[0])
  .attr('cy', moscow[1]);

Как сделать карту c Leaflet

Иногда схематических контуров оказывается недостаточно, и нужна полноценная карта со всеми странами, городами, дорогами и прочим. В этом случае на помощь приходят библиотеки для рисования карт. Их довольно много, но я буду рассказывать про Leaflet, потому что обычно использую именно её.

Сначала я подготовлю вёрстку и зависимости:

<html>
  <head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/leaflet.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
  </head>
  <body>
    <div id="map"></div>
  </body>
</html>

Это минимальный код, с которым можно будет работать. Обратите внимание, что я подгружаю не только скрипт, но и стили, это важно. У div элемента (будущего контейнера карты) я сразу задам идентификатор. Он будет нужен для инициализации.

Чтобы показать простую карту, нужно вызвать метод map:

const tilesUrl = 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png';
const map = L.map('map').setView([39.25, -48.16], 3);

L.tileLayer(tilesUrl).addTo(map);

L — это Leaflet, имя, по которому я могу обращаться к библиотеке. Методу map я передаю идентификатор контейнера. SetView устанавливает центр видимой области и уровень приближения. Он может быть целым положительным числом.

Недостаточно просто создать карту и указать ей область видимости. Нужны тайлы — небольшие картинки одинаковых размеров, которые вместе составляют большое изображение. В контексте карты тайлы — это отрисованные участки области на разных уровнях приближения.

У Leaflet это отдельный слой данных, который мы в явном виде добавляем на карту. Определяются они шаблонной строкой как tilesUrl в коде выше. Кроме этого можно указать копирайт и какие-то дополнительные аттрибуты.

На OpenStreetMaps есть неплохой список открытых и доступных для использования тайлов.

Когда карта есть, можно легко добавить на неё маркеры:

const cities = [
  { name: 'Rome', coords: [41.89, 12.51] },
  { name: 'Chicago', coords: [41.74, -87.55] },
  { name: 'San Francisco', coords: [37.77, -122.42] },
  { name: 'Athens', coords: [37.98, 23.73] },
  { name: 'Toronto', coords: [43.67, -79.42] },
  { name: 'Toulouse', coords: [43.60, 1.44] },
];
const markers = cities.map(c => 
  L.marker(c.coords)
    .addTo(map)
    .bindPopup(`${c.name}: [${c.coords.join(', ')}]`)
);

Маркер добавляется с помощью функции L.marker, которой передаётся массив из двух координат. Здесь я никак не меняю внешний вид маркеров, но их можно сделать совершенно любыми. Ещё я добавила стандартный тултип, который будет появляться при клике и показывать название города и его координаты.

Выглядит всё это в результате так:


Это поверхностный обзор работы с картами. Если копнуть глубже, можно делать очень сложные вещи, интерактивные или динамические. Например, можно объединить Leaflet с D3 или добавить изометрию и рисовать трёхмерные визуализации на обычной карте.