Хороплеты: привязка данных к географическим объектам

Хороплетная карта строится на принципе окрашивания географических объектов в зависимости от значения некоторой переменной. Геометрия территории задаётся через GeoJSON или TopoJSON, а данные статистики привязываются к этим объектам через ключевые идентификаторы. В D3.js этот процесс реализуется через связку географических проекций, генераторов пути и механизмов объединения данных.

Географическая основа хороплета представляется набором Feature-объектов:

{
  "type": "Feature",
  "properties": {
    "id": "RU-KG",
    "name": "Kyrgyzstan region"
  },
  "geometry": {
    "type": "Polygon",
    "coordinates": [...]
  }
}

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

const data = [
  { id: "RU-KG", value: 42 },
  { id: "RU-MO", value: 87 }
];

Связывание статистики с геометрией

Процесс привязки данных осуществляется через создание индексной структуры. Наиболее эффективный подход — использование Map:

const dataMap = new Map(data.map(d => [d.id, d.value]));

После этого географические объекты обогащаются значениями:

geojson.features.forEach(feature => {
  feature.properties.value = dataMap.get(feature.properties.id) || 0;
});

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

Географические проекции

Отображение координат на плоскость выполняется через проекции D3:

const projection = d3.geoMercator()
  .scale(120)
  .translate([width / 2, height / 2]);

Проекция преобразует долготу и широту в координаты SVG. Для генерации контуров используется geoPath:

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

geoPath принимает геометрию и возвращает SVG-пути, готовые для отрисовки.

Построение SVG-карты

Создание хороплета начинается с контейнера:

const svg = d3.select("svg")
  .attr("width", width)
  .attr("height", height);

Далее выполняется привязка географических объектов:

svg.selectAll("path")
  .data(geojson.features)
  .enter()
  .append("path")
  .attr("d", path);

На этом этапе отображается базовая карта без окрашивания.

Цветовое кодирование данных

Цветовая шкала задаёт соответствие между значениями и визуальными оттенками. Для хороплетов чаще используется непрерывная или квантильная шкала:

const colorScale = d3.scaleSequential()
  .domain([0, d3.max(data, d => d.value)])
  .interpolator(d3.interpolateBlues);

Применение шкалы к геометрии:

svg.selectAll("path")
  .attr("fill", d => colorScale(d.properties.value));

Цвет становится функцией значения, что создаёт визуальное распределение данных по территории.

Квантильные и категориальные шкалы

При необходимости дискретизации используется квантильное разбиение:

const colorScale = d3.scaleQuantile()
  .domain(data.map(d => d.value))
  .range(["#deebf7", "#9ecae1", "#3182bd"]);

Такой подход снижает влияние выбросов и упрощает восприятие распределений.

Для категориальных данных применяется:

const colorScale = d3.scaleOrdinal()
  .domain(["low", "medium", "high"])
  .range(["#f0f0f0", "#bdbdbd", "#636363"]);

Привязка через внешние идентификаторы

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

const normalize = id => id.trim().toUpperCase();

const dataMap = new Map(data.map(d => [normalize(d.id), d.value]));

geojson.features.forEach(f => {
  f.properties.value = dataMap.get(normalize(f.properties.id)) || 0;
});

Такая схема снижает риск расхождения форматов данных.

Использование TopoJSON

Для оптимизации размера данных применяется TopoJSON. Он хранит общие границы между полигонами, уменьшая дублирование координат.

Конвертация в GeoJSON:

const geojson = topojson.feature(topoData, topoData.objects.regions);

После преобразования структура используется так же, как стандартный GeoJSON.

Обновление данных и реактивность

При изменении набора данных выполняется пересчёт цветовой шкалы и обновление атрибутов:

function upd ate(newData) {
  const dataMap = new Map(newData.map(d => [d.id, d.value]));

  svg.selectAll("path")
    .transition()
    .duration(600)
    .attr("fill", d => colorScale(dataMap.get(d.properties.id) || 0));
}

Плавные переходы уменьшают визуальный шум при изменении состояния карты.

Масштабирование и адаптация проекции

Размер карты зависит от контейнера, поэтому проекция часто пересчитывается:

projection
  .scale(width / 6)
  .translate([width / 2, height / 2]);

При изменении размеров окна выполняется пересчёт пути:

svg.selectAll("path").attr("d", path);

Обработка отсутствующих данных

Отсутствие значений требует отдельной логики отображения:

.attr("fill", d => {
  const v = d.properties.value;
  return v == null ? "#ccc" : colorScale(v);
});

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

Масштабирование значений

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

const valueScale = d3.scaleLog()
  .domain([1, d3.max(data, d => d.value)])
  .range([0, 1]);

После нормализации значения подаются в цветовую шкалу.

Взаимодействие с элементами карты

Наведение и подсветка реализуются через события:

svg.selectAll("path")
  .on("mouseover", function() {
    d3.select(this).attr("opacity", 0.7);
  })
  .on("mouseout", function() {
    d3.select(this).attr("opacity", 1);
  });

Для отображения значений используется всплывающий слой:

.on("mousemove", (event, d) => {
  tooltip
    .style("left", event.pageX + "px")
    .style("top", event.pageY + "px")
    .text(d.properties.value);
});

Сложные схемы агрегации

При работе с иерархическими территориями выполняется агрегация значений:

const grouped = d3.rollups(
  data,
  v => d3.sum(v, d => d.value),
  d => d.region
);

Результат используется для обновления геометрии на более высоком уровне детализации.

Оптимизация отрисовки

При больших наборах данных используется кэширование путей:

const cache = new Map();

svg.selectAll("path")
  .attr("d", d => {
    if (!cache.has(d)) cache.se t(d, path(d));
    return cache.get(d);
  });

Это снижает вычислительную нагрузку при повторных рендерах.

Комбинация нескольких слоёв данных

Хороплет часто дополняется точечными слоями:

svg.selectAll("circle")
  .data(points)
  .enter()
  .append("circle")
  .attr("cx", d => projection([d.lon, d.lat])[0])
  .attr("cy", d => projection([d.lon, d.lat])[1])
  .attr("r", 3);

Такая комбинация позволяет сопоставлять агрегированные и локальные данные на одной карте.