Кастомная логика кластеризации

Кластеризация точечных данных в GeoJSON-источниках Mapbox GL JS реализуется поверх алгоритма пространственного разбиения, основанного на плотности точек в текущем масштабе карты. Внутри Mapbox GL JS используется подход, близкий к Supercluster, где точки объединяются в группы в зависимости от зума и радиуса кластеризации.

Базовая активация кластеризации включается на уровне источника данных:

map.addSource('points', {
  type: 'geojson',
  data: geojsonData,
  cluster: true,
  clusterRadius: 50,
  clusterMaxZoom: 14
});

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

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

Ключевая проблема заключается в том, что стандартная модель возвращает только базовые агрегаты:

  • point_count — количество точек в кластере
  • cluster_id — идентификатор кластера
  • геометрический центр кластера

Эти данные недостаточны для сложных сценариев анализа.

Кастомные агрегаты через clusterProperties

Расширение логики кластеризации возможно через clusterProperties. Этот механизм позволяет вычислять агрегированные значения на основе свойств исходных точек.

map.addSource('points', {
  type: 'geojson',
  data: geojsonData,
  cluster: true,
  clusterRadius: 60,
  clusterProperties: {
    totalValue: ['+', ['get', 'value']],
    maxPriority: ['max', ['get', 'priority']]
  }
});

Здесь используется выраженческая система Mapbox, позволяющая задавать редукционные функции:

  • + — суммирование значений
  • max — поиск максимума
  • min — поиск минимума
  • логические выражения для кастомных условий

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

Визуализация кластеров через стили слоя

Кластеры в Mapbox GL JS отображаются как отдельные feature с типом point, что позволяет применять стандартные слои:

map.addLayer({
  id: 'clusters',
  type: 'circle',
  source: 'points',
  filter: ['has', 'point_count'],
  paint: {
    'circle-radius': [
      'step',
      ['get', 'point_count'],
      15, 10,
      25, 50,
      35, 100
    ],
    'circle-color': [
      'step',
      ['get', 'point_count'],
      '#51bbd6',
      50,
      '#f1f075',
      100,
      '#f28cb1'
    ]
  }
});

Использование выражений step позволяет динамически изменять стиль кластера в зависимости от его плотности.

Отдельно отображаются одиночные точки:

map.addLayer({
  id: 'unclustered-point',
  type: 'circle',
  source: 'points',
  filter: ['!', ['has', 'point_count']],
  paint: {
    'circle-color': '#11b4da',
    'circle-radius': 6
  }
});

Кастомная логика через предварительную кластеризацию

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

import Supercluster from 'supercluster';

const index = new Supercluster({
  radius: 60,
  maxZoom: 16,
  map: props => ({
    weight: props.weight
  }),
  reduce: (accumulated, props) => {
    accumulated.weight += props.weight;
  }
});

index.load(geojsonData.features);

После этого данные запрашиваются вручную в зависимости от текущего зума:

map.on('moveend', () => {
  const zoom = Math.floor(map.getZoom());
  const bounds = map.getBounds();

  const clusters = index.getClusters(
    [
      bounds.getWest(),
      bounds.getSouth(),
      bounds.getEast(),
      bounds.getNorth()
    ],
    zoom
  );

  map.getSource('clusters').setData({
    type: 'FeatureCollection',
    features: clusters
  });
});

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

Полное отключение кластеризации и ручное управление

В некоторых архитектурах кластеризация выполняется на сервере. В этом случае Mapbox получает уже агрегированные данные:

map.addSource('server-clusters', {
  type: 'geojson',
  data: '/api/clusters'
});

Сервер может учитывать:

  • географическую плотность
  • бизнес-веса объектов
  • временные интервалы
  • пользовательские фильтры

Клиентская часть только визуализирует результат, не выполняя вычислений.

Кастомная логика через clusterProperties и условия

Сложные сценарии часто комбинируют несколько полей:

clusterProperties: {
  highPriorityCount: [
    '+',
    ['case',
      ['>=', ['get', 'priority'], 5],
      1,
      0
    ]
  ]
}

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

Категориальная кластеризация

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

map.addSource('restaurants', {
  type: 'geojson',
  data: data,
  cluster: true,
  filter: ['==', ['get', 'type'], 'restaurant']
});

Либо используют единый источник и выражения фильтрации на слоях:

filter: ['all',
  ['has', 'point_count'],
  ['==', ['get', 'category'], 'food']
]

Временная и взвешенная кластеризация

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

clusterProperties: {
  recentCount: [
    '+',
    ['case',
      ['>', ['get', 'timestamp'], cutoffTime],
      1,
      0
    ]
  ]
}

Взвешенная кластеризация позволяет учитывать значимость точек:

clusterProperties: {
  weightedSum: ['+', ['*', ['get', 'weight'], 1]]
}

Это превращает кластеры в аналитические единицы, а не просто визуальные группы.

Производительность и поведение при масштабировании

Кластеризация напрямую влияет на производительность рендеринга WebGL-контекста. Встроенный механизм оптимизирован под tiled indexing, однако при больших наборах данных возникают ограничения:

  • рост времени пересчёта при изменении зума
  • увеличение памяти при высоком clusterRadius
  • деградация при частых setData

Оптимизация достигается следующими методами:

  • ограничение maxZoom для кластеров
  • предварительная фильтрация данных
  • использование vector tiles вместо GeoJSON
  • уменьшение количества активных источников

Особенно эффективно использование тайловых источников:

map.addSource('tiles', {
  type: 'vector',
  url: 'mapbox://dataset.id'
});

В этом случае кластеризация может выполняться на уровне тайлов, что снижает нагрузку на клиент.

Динамическая кластеризация в зависимости от состояния карты

Кластеризация может адаптироваться к текущему состоянию интерфейса. Например, изменение радиуса в зависимости от масштаба:

map.on('zoom', () => {
  const zoom = map.getZoom();

  const radius = zoom > 10 ? 30 : 80;

  map.getSource('points').setProps({
    clusterRadius: radius
  });
});

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

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