Карта авиасообщения между аэропортами

Сеть авиасообщения США объединяет сотни аэропортов — от крупных узлов до небольших местных площадок. В этом уроке мы визуализируем аэропорты и маршруты между ними по данным за 2008 год. Цель — создать собственный вариант карты аэропортов Майка Бостока на D3.js.

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

  1. Знакомство с данными
  2. Основа визуализации
  3. Карта штатов США
  4. Все аэропорты на карте
  5. Фильтрация по числу рейсов
  6. Размер аэропортов по числу рейсов
  7. Название аэропорта при наведении
  8. Сортировка аэропортов по размеру
  9. Маршруты между аэропортами
  10. Маршруты при наведении
  11. Удобное наведение с ячейками Вороного
  12. Последний штрих

Проходя урок, собирайте визуализацию по шагам и экспериментируйте с ней в редакторе Vega.

Шаг 1. Знакомство с данными

Сначала изучим исходные данные. Нам понадобятся три набора из папки data; они также доступны в редакторе Vega.

us-10m.json

Файл TopoJSON содержит границы штатов США в масштабе 1:10 000 000. Из него мы построим базовый слой карты. Для подготовки собственных данных TopoJSON можно использовать инструменты проекта U.S. Atlas.

airports.csv

Файл CSV содержит список аэропортов США. Первый столбец, iata, — уникальный код Международной ассоциации воздушного транспорта. Остальные столбцы описывают название и местоположение аэропорта, в том числе координаты latitude и longitude. По ним мы разместим аэропорты на карте.

iata,name,city,state,country,latitude,longitude
00M,Thigpen,Bay Springs,MS,USA,31.95376472,-89.23450472
00R,Livingston Municipal,Livingston,TX,USA,30.68586111,-95.01792778
00V,Meadow Lake,Colorado Springs,CO,USA,38.94574889,-104.5698933
01G,Perry-Warsaw,Perry,NY,USA,42.74134667,-78.05208056

flights-airport.csv

Здесь содержатся сведения о рейсах за 2008 год. Каждая запись указывает аэропорт вылета origin и прибытия destination по кодам IATA, а также число рейсов count на маршруте. По этим данным мы рассчитаем нагрузку аэропортов и построим связи между ними.

origin,destination,count
ABE,ATL,853
ABE,BHM,1
ABE,CLE,805
ABE,CLT,465

Шаг 2. Основа визуализации

Приступим к визуализации. Сначала создадим её основу:

{
  "$schema": "https://vega.github.io/schema/vega/v6.json",
  "width": 900,
  "height": 560,
  "padding": {"top": 0, "left": 0, "right": 0, "bottom": 0},
  "signals": [],
  "data": [],
  "scales": [],
  "projections": [],
  "marks": []
}

Задаём ширину представления 900 и высоту 560 пикселей. Пока все отступы равны нулю; позднее мы их изменим. Также добавляем пустые массивы:

  • signals — для переменных, реагирующих на действия пользователя;
  • data — для загрузки и преобразования данных;
  • scales — для сопоставления значений данных визуальным свойствам;
  • projections — для картографической проекции;
  • marks — для графических элементов визуализации.

На следующих шагах мы заполним каждый из этих массивов.

Шаг 3. Карта штатов США

Добавим карту штатов США как фоновый слой. Для этого подготовим географические данные и нарисуем границы.

Загрузка данных TopoJSON

Добавим географический источник в массив data:

"data": [
  {
    "name": "states",
    "url": "data/us-10m.json",
    "format": {"type": "topojson", "feature": "states"}
  }
],

Запись создаёт набор states и загружает его по указанному URL. Свойство format сообщает, что данные имеют формат topojson и нам нужен географический объект states. Файл TopoJSON может содержать несколько именованных объектов; в us-10m.json есть states и counties.

Vega загрузит нужный объект и преобразует его в набор координат широты и долготы в формате GeoJSON. Чтобы показать штаты, добавим картографическую проекцию: она переводит пару (долгота, широта) в координаты (x, y). Проекция Альберса сохраняет площади, а её вариант albersUsa удобно размещает Аляску и Гавайи рядом с остальными штатами, позволяя видеть маршруты из них.

"projections": [
  {
    "name": "projection",
    "type": "albersUsa",
    "scale": 1200,
    "translate": [{"signal": "width / 2"}, {"signal": "height / 2"}]
  }
],

Помимо типа проекции type, параметры scale и translate задают масштаб и положение карты. Выражения сигналов удерживают карту по центру представления. Теперь добавим к набору данных преобразование geopath, создающее контур каждого штата:

"data": [
  {
    "name": "states",
    "url": "data/us-10m.json",
    "format": {"type": "topojson", "feature": "states"},
    "transform": [
      {
        "type": "geopath",
        "projection": "projection"
      }
    ]
  }
],

Преобразование geopath записывает контур каждого штата в свойство path как строку пути SVG.

Отрисовка границ штатов

После подготовки контуров остаётся нарисовать их:

"marks": [
  {
    "type": "path",
    "from": {"data": "states"},
    "encode": {
      "enter": {
        "fill": {"value": "#dedede"},
        "stroke": {"value": "white"}
      },
      "update": {
        "path": {"field": "path"}
      }
    }
  }
]

Добавляем в marks примитив path. В блоке enter его заливка fill светло-серая, а обводка stroke белая. В блоке update контур берётся из свойства path. Размещаем его именно здесь, чтобы границы обновлялись при изменении размера или других параметров визуализации.

Готово: у визуализации есть базовая карта. Попробуйте сменить проекцию, её параметры или размер представления и посмотрите, что произойдёт.

Шаг 4. Все аэропорты на карте

Теперь разместим на карте аэропорты. Добавим запись airports в конец массива data:

{
  "name": "airports",
  "url": "data/airports.csv",
  "format": {"type": "csv", "parse": "auto"}
}

Свойство format указывает формат csv и автоматический разбор значений auto. Vega попробует определить, какие столбцы содержат числа, строки и другие типы.

Для отображения аэропортов нужно перевести longitude и latitude в координаты x и y. Используем преобразование geopoint с прежней проекцией. По умолчанию оно записывает координаты в поля объектов данных x и y; другие имена можно задать параметром as.

{
  "name": "airports",
  "url": "data/airports.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    {
      "type": "geopoint",
      "projection": "projection",
      "fields": ["longitude", "latitude"]
    },
    {
      "type": "filter",
      "expr": "datum.x != null && datum.y != null"
    }
  ]
}

Также добавляем filter, чтобы исключить точки с координатами null. Проекция albersUsa не поддерживает аэропорты за пределами 50 штатов и округа Колумбия, например на территориях США, и возвращает для них null по x и y.

Чтобы показать аэропорты, добавим примитив symbol в массив marks:

{
  "type": "symbol",
  "from": {"data": "airports"},
  "encode": {
    "enter": {
      "size": {"value": 16},
      "fill": {"value": "steelblue"},
      "fillOpacity": {"value": 0.8},
      "stroke": {"value": "white"},
      "strokeWidth": {"value": 1.5}
    },
    "update": {
      "x": {"field": "x"},
      "y": {"field": "y"}
    }
  }
}

Если свойство shape не указано, примитив symbol рисует круг. Каждый аэропорт располагается по координатам x и y из geopoint. Размер, заливку и обводку пока задаём постоянными значениями.

Получилась карта всех аэропортов США. Их действительно много!

Шаг 5. Фильтрация по числу рейсов

Добавим сведения о нагрузке аэропортов в 2008 году. Заодно исключим те, где в этом году не было коммерческих рейсов. Для этого загрузим данные о полётах.

Добавим следующую запись в массив data перед набором airports:

{
  "name": "traffic",
  "url": "data/flights-airport.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    {
      "type": "aggregate",
      "groupby": ["origin"],
      "fields": ["count"], "ops": ["sum"], "as": ["flights"]
    }
  ]
}

Мы загружаем CSV и преобразованием aggregate подсчитываем общее число рейсов из каждого аэропорта. В результате набор traffic содержит два поля: аэропорт вылета origin и число отправленных им рейсов flights.

Теперь объединим сведения о рейсах с исходным списком аэропортов — на языке баз данных выполним соединение. Преобразование lookup ищет ключи основного набора во втором наборе и при совпадении добавляет найденную запись как новое свойство основной записи.

{
  "name": "airports",
  "url": "data/airports.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    {
      "type": "lookup",
      "from": "traffic", "key": "origin",
      "fields": ["iata"], "as": ["traffic"]
    },
    {
      "type": "filter",
      "expr": "datum.traffic != null"
    },
    {
      "type": "geopoint",
      "projection": "projection",
      "fields": ["longitude", "latitude"]
    },
    {
      "type": "filter",
      "expr": "datum.x != null && datum.y != null"
    }
  ]
}

Здесь airports — основной набор, а traffic — таблица поиска. Для каждого аэропорта ищем запись, в которой origin совпадает с его iata. Найденные сведения о рейсах записываются в свойство traffic аэропорта.

Дополнительный filter удаляет аэропорты без совпадения в traffic, для которых это свойство равно null. Так мы исключаем аэропорты без зафиксированных вылетов в 2008 году. Условия можно было бы объединить в один фильтр, но раннее исключение ненужных записей избавляет от лишнего вычисления координат.

Теперь на карте остались только аэропорты из данных flights-airport.csv.

Шаг 6. Размер аэропортов по числу рейсов

Используем число вылетов и для размера каждого аэропорта. Сначала добавим в scales шкалу, сопоставляющую числу рейсов площадь круга:

"scales": [
  {
    "name": "size",
    "type": "linear",
    "domain": {"data": "traffic", "field": "flights"},
    "range": [16, 1000]
  }
],

Линейная шкала переводит область domain числа рейсов в диапазон range от 16 до 1000 квадратных пикселей. Почему линейная? Если сопоставить значение радиусу круга, его площадь начнёт расти слишком быстро и введёт зрителя в заблуждение. Но свойство size примитива symbol задаёт площадь, а не радиус.

{
  "type": "symbol",
  "from": {"data": "airports"},
  "encode": {
    "enter": {
      "size": {"scale": "size", "field": "traffic.flights"},
      "fill": {"value": "steelblue"},
      "fillOpacity": {"value": 0.8},
      "stroke": {"value": "white"},
      "strokeWidth": {"value": 1.5}
    },
    "update": {
      "x": {"field": "x"},
      "y": {"field": "y"}
    }
  }
}

Обновим примитивы аэропортов symbol: свойство size теперь вычисляется по traffic.flights через новую шкалу. Сохраним прозрачность fillOpacity: 0.8 и толщину обводки strokeWidth: 1.5, заданные на предыдущем шаге.

Теперь хорошо видны крупные узлы, маленькие аэропорты и все промежуточные варианты.

Шаг 7. Название аэропорта при наведении

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

"signals": [
  {
    "name": "hover",
    "value": null,
    "on": [
      {"events": "symbol:mouseover", "update": "datum"},
      {"events": "symbol:mouseout", "update": "null"}
    ]
  },
  {
    "name": "title",
    "value": "Аэропорты США, 2008",
    "update": "hover ? hover.name + ' (' + hover.iata + ')' : 'Аэропорты США, 2008'"
  }
],

Сигналы — переменные, которые динамически меняются при действиях пользователя. У каждого сигнала есть имя; начальное значение можно задать свойством value или выражением инициализации. Необязательные правила обновления задают обработчики входных events, которые обновляют значение сигнала через update. Подробнее о выражениях см. в справочнике языка выражений.

Сигнал hover содержит запись аэропорта (datum) под указателем или null, если указатель не находится над аэропортом. Правила on записывают текущую запись при mouseover на примитиве symbol и возвращают null при mouseout. Таким образом, сигнал отслеживает данные выделенного символа.

Сигнал title содержит название выбранного аэропорта или общий заголовок, если ничего не выделено. Выражение update реагирует на изменение hover и собирает строку из полей name и iata.

Для отображения текста добавим примитив text в конец массива marks:

 {
  "type": "text",
  "interactive": false,
  "encode": {
    "enter": {
      "x": {"signal": "width", "offset": -5},
      "y": {"value": 0},
      "fill": {"value": "black"},
      "fontSize": {"value": 20},
      "align": {"value": "right"}
    },
    "update": {
      "text": {"signal": "title"}
    }
  }
}

У этого текстового примитива нет свойства from с источником данных. Без него Vega создаёт один экземпляр. Мы задаём оформление текста и размещаем заголовок на пять пикселей левее правой границы представления с помощью выражения сигнала и смещения. Содержимое text берётся из title и автоматически обновляется вместе с сигналом.

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

"padding": {"top": 25, "left": 0, "right": 0, "bottom": 0},

Исправим это, добавив сверху 25 пикселей отступа top.

Теперь при наведении на аэропорт видна дополнительная информация.

Шаг 8. Сортировка аэропортов по размеру

Заметили проблему? В Чикаго О’Хара полностью закрывает Мидуэй, а в Далласе DFW перекрывает DAL. Когда точки различаются по размеру, их обычно сортируют так, чтобы маленькие рисовались поверх больших.

{
  "name": "airports",
  "url": "data/airports.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    {
      "type": "lookup",
      "from": "traffic", "key": "origin",
      "fields": ["iata"], "as": ["traffic"]
    },
    {
      "type": "filter",
      "expr": "datum.traffic != null"
    },
    {
      "type": "geopoint",
      "projection": "projection",
      "fields": ["longitude", "latitude"]
    },
    {
      "type": "filter",
      "expr": "datum.x != null && datum.y != null"
    },
    {
      "type": "collect", "sort": {
        "field": "traffic.flights",
        "order": "descending"
      }
    }
  ]
}

Добавим к airports преобразование collect: оно собирает записи потока данных и позволяет их отсортировать. Параметр sort задаёт поле числа рейсов и порядок descending — по убыванию. Поэтому большие точки рисуются раньше маленьких.

Так лучше!

Шаг 9. Маршруты между аэропортами

Теперь покажем маршруты между аэропортами.

Добавим набор routes в конец массива data:

{
  "name": "routes",
  "url": "data/flights-airport.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    {
      "type": "lookup",
      "from": "airports", "key": "iata",
      "fields": ["origin", "destination"], "as": ["source", "target"]
    },
    {
      "type": "filter",
      "expr": "datum.source && datum.target"
    },
    {
      "type": "linkpath",
      "shape": "line"
    }
  ]
}

После загрузки CSV выполняем несколько преобразований. Для аэропортов origin и destination нужны координаты, поэтому делаем lookup в наборе airports. В отличие от поиска в traffic, здесь сразу два ключа: начало и конец маршрута. Затем исключаем маршруты, для которых аэропорт не найден в airports.

Наконец, linkpath создаёт путь SVG между концами связи. По умолчанию это прямая линия; мы явно задаём shape: line для ясности. Попробуйте curve, чтобы получить кривую.

По умолчанию linkpath берёт координаты из свойств source.x, source.y и аналогичных полей target. Дополнительная настройка не нужна: именно поэтому результаты lookup мы назвали source и target.

Добавим примитив path в массив marks, чтобы нарисовать маршруты:

{
  "type": "path",
  "interactive": false,
  "from": {"data": "routes"},
  "encode": {
    "enter": {
      "path": {"field": "path"},
      "stroke": {"value": "black"},
      "strokeOpacity": {"value": 0.15}
    }
  }
}

Значение interactive: false не даёт линиям перехватывать события мыши у символов аэропортов. Поскольку маршрутов много и они накладываются друг на друга, задаём низкую непрозрачность strokeOpacity: 0.15.

Получилось слишком густо. Отфильтруем маршруты и будем показывать их при наведении.

Шаг 10. Маршруты при наведении

Добавить фильтрацию по наведению теперь просто:

{
  "name": "routes",
  "url": "data/flights-airport.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    {
      "type": "filter",
      "expr": "hover && hover.iata == datum.origin"
    },
    ...
  ]
}

Сигнал hover уже хранит данные выбранного аэропорта. Добавим filter в начало списка преобразований routes: он оставит только маршруты, чей origin совпадает с выбранным аэропортом. Также увеличим непрозрачность линий strokeOpacity до 0.35.

Теперь можно исследовать сеть маршрутов наведением указателя.

Шаг 11. Удобное наведение с ячейками Вороного

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

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

{
  "name": "airports",
  "url": "data/airports.csv",
  "format": {"type": "csv", "parse": "auto"},
  "transform": [
    ...
    {
      "type": "voronoi", "x": "x", "y": "y"
    },
    {
      "type": "collect", "sort": {
        "field": "traffic.flights",
        "order": "descending"
      }
    }
  ]
},

По координатам x и y преобразование voronoi вычисляет ячейку каждого аэропорта и записывает строку пути SVG в свойство path.

Добавим ячейки на карту как невидимые примитивы path. Следующий блок разместим в массиве marks сразу после символов аэропортов:

{
  "type": "path",
  "name": "cell",
  "from": {"data": "airports"},
  "encode": {
    "enter": {
      "fill": {"value": "transparent"}
    },
    "update": {
      "path": {"field": "path"}
    }
  }
},

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

Добавим "autosize": "none", чтобы размер определялся только шириной, высотой и отступами. Иначе ячейки Вороного могут вызвать нежелательное автоматическое изменение размера:

"padding": {...},
"autosize": "none",
"signals": [...],

Наконец, изменим сигнал hover, чтобы он реагировал на события ячеек Вороного вместо круглых примитивов symbol:

{
  "name": "hover",
  "value": null,
  "on": [
    {"events": "@cell:mouseover", "update": "datum"},
    {"events": "@cell:mouseout", "update": "null"}
  ]
},

Заменяем symbol: на @cell:. Шаблон @name выбирает события только от примитива с указанным именем.

Теперь выбирать аэропорты указателем гораздо удобнее.

Шаг 12. Последний штрих

Интерактивная визуализация готова. Напоследок добавим небольшой секрет. Ячейки Вороного невидимы, но было бы интересно на них посмотреть. Пусть двойной щелчок переключает их видимость.

Добавим в массив signals сигнал cell_stroke:

{
  "name": "cell_stroke",
  "value": null,
  "on": [
    {"events": "dblclick", "update": "cell_stroke ? null : 'brown'"},
    {"events": "mousedown!", "update": "cell_stroke"}
  ]
}

Двойной щелчок (dblclick) переключает cell_stroke между null и 'brown'. Но браузер при двойном щелчке может выделить текст страницы. Поэтому добавляем обработчик mousedown: восклицательный знак в mousedown! предписывает подавить стандартное действие события. Сам сигнал при этом сохраняет прежнее значение.

Осталось обновить примитивы ячеек Вороного:

{
  "type": "path",
  "name": "cell",
  "from": {"data": "airports"},
  "encode": {
    "enter": {
      "fill": {"value": "transparent"},
      "strokeWidth": {"value": 0.35}
    },
    "update": {
      "path": {"field": "path"},
      "stroke": {"signal": "cell_stroke"}
    }
  }
},

В блок enter добавляем толщину обводки strokeWidth, а в update — цвет stroke. Он берётся из сигнала cell_stroke, поэтому при двойном щелчке контуры ячеек появляются и исчезают.

Проверьте результат: вернитесь к карте в начале страницы и дважды щёлкните по ней. Также можно изучить полную спецификацию Vega.

Что дальше

Урок завершён, но вы можете самостоятельно попробовать ещё несколько изменений:

  • Сможете добавить к сигналам поддержку касаний наряду с мышью?
  • Сможете при наведении выделять аэропорт вылета цветом вместе с его маршрутами?
  • Сможете выделять аэропорты назначения при выборе аэропорта вылета?
  • Сможете сменить проекцию и посмотреть на маршруты под другим углом?
  • Сможете собрать дополнительные данные и показать аэропорты всего мира?