3D-режим отображения маршрутных сетей авиакомпаний на основе Three.js

14.03.2026
3D-режим отображения маршрутных сетей авиакомпаний на основе Three.js

В этой статье разберём устройство модуля src/view/composables/draw-airlines-3d: архитектуру, технологии, алгоритмы построения геометрии и подходы к оптимизации.

Технологический стек

Для 3D-рендеринга выбран Three.js — де-факто стандарт для WebGL в браузере. Он даёт удобные абстракции над сырым WebGL: сцена, камера, объекты, материалы, шейдеры — без необходимости писать всё с нуля.

Дополнительно используются:

  • topojson-client — разбор геоданных в формате TopoJSON (карта мира)
  • Earcut (встроен в Three.js) — триангуляция полигонов для построения меша суши
  • OrbitControls из three/examples/jsm — управление камерой мышью/тачем

Фронтенд, как и прежде, написан на Vue 3 + TypeScript.


Структура модуля

Модуль состоит из двух файлов и одного датасета:

Copy
draw-airlines-3d/
├── index.ts        — Vue-composable, оркестрация сцены
└── scene.ts        — построители геометрии и GLSL-шейдеры

Разделение намеренное: scene.ts — чистые функции без реактивности, только геометрия. index.ts — связывает Three.js с реактивными данными Vue, управляет жизненным циклом сцены.


Построение глобуса

Глобус — это не один объект, а стек из нескольких независимых слоёв, каждый из которых отрисовывается поверх предыдущего.

Океан

Базовая сфера с радиусом 1.0 и кастомным GLSL-шейдером, задающим вертикальный градиент цвета:

glsl Copy
// fragment shader (упрощённо)
vec3 colorNorth = vec3(2.0/255.0, 26.0/255.0, 74.0/255.0);
vec3 colorSouth = vec3(9.0/255.0, 1.0/255.0, 24.0/255.0);
gl_FragColor = vec4(mix(colorSouth, colorNorth, vUv.y), 1.0);

Северный полюс — синеватый, южный — почти чёрный фиолетовый. Простой приём, создающий ощущение глубины без сложного освещения.

Суша

Слой суши строится из TopoJSON-данных. Загружаются три уровня детализации:

Уровень Файл Размер Когда используется
lo countries-110m ~99 КБ Глобальный вид
mid countries-50m ~1 МБ Средний зум
hi land-10m.json ~3 МБ Вблизи

lo-уровень загружается синхронно при инициализации. mid — с нулевой задержкой через setTimeout. hi — через requestIdleCallback с таймаутом 4 секунды: браузер загрузит тяжёлые данные в момент простоя, не блокируя интерфейс.

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

Триангуляция полигонов — нетривиальная задача на сфере. Стандартная 2D-триангуляция на плоских координатах ломается у антимеридиана и на сложных контурах. Решение:

  1. Для каждого полигона вычисляется центроид.
  2. Применяется гномоническая проекция относительно центроида — она переводит дуги большого круга в прямые линии.
  3. Запускается Earcut на спроецированных координатах.
  4. Полученные треугольники делятся рекурсивно: если сторона треугольника длиннее 5°, она делится пополам — это «прижимает» грани к поверхности сферы.
  5. Треугольники, пересекающие антимеридиан, отфильтровываются как невалидные.

Меш суши рендерится на радиусе 1.002 — чуть выше океана — с полупрозрачным белым материалом (opacity: 0.12).

Границы стран

Отдельный слой линий на радиусе 1.003. topojson.mesh() возвращает общие границы между полигонами, что автоматически исключает дублирование рёбер. Непрозрачность — 0.2.

Атмосфера

Сфера радиусом 1.12 (больше глобуса) с шейдером кромочного свечения:

glsl Copy
float intensity = 1.0 - dot(vNormal, vec3(0.0, 0.0, 1.0));
gl_FragColor = vec4(0.3, 0.6, 1.0, intensity * 0.4);

Используется аддитивное смешивание (AdditiveBlending) — цвета суммируются, что даёт правдоподобный эффект рассеивания света на границе планеты.

Звёзды

1800 точек, равномерно распределённых на сфере радиусом от 28 до 46 единиц. Генерируются через равномерное случайное распределение на сфере.


Маршруты авиакомпаний

Дуги большого круга

Маршруты отрисовываются как дуги большого круга (great-circle arcs) — кратчайший путь между двумя точками на поверхности сферы. Именно так летят самолёты в реальности.

Для каждого маршрута (departure → arrival):

  1. Координаты аэропортов переводятся в 3D-векторы на единичной сфере через latLonToVec3.
  2. Вычисляется угловое расстояние между точками.
  3. Строятся 48 промежуточных точек вдоль дуги через сферическую интерполяцию (slerp).
  4. Каждая точка «поднимается» над поверхностью пропорционально длине маршрута:
typescript Copy
const lift = ARC_LIFT * Math.sin(Math.PI * i / (segments - 1));
const r = ARC_BASE_R + arcLen * lift;
// ARC_BASE_R = 1.004, ARC_LIFT = 0.06

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

Обычные маршруты рисуются белым с прозрачностью 0.22. Выделенные маршруты (выбранная авиакомпания) — оранжевым (0xeb610b) с полной непрозрачностью.

Точки аэропортов

Аэропорты отображаются как облако точек (THREE.Points) на радиусе 1.007. Фиксированный размер 5px независимо от зума — это достигается отключением затухания размера с расстоянием (sizeAttenuation: false).

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


Интерактивность

Управление камерой

OrbitControls с включённым enableDamping — вращение, масштабирование и панорамирование с инерцией. Скорость вращения и масштабирования адаптируется к текущему расстоянию до глобуса.

Ограничения:

  • Минимальное расстояние: 1.15 * GLOBE_RADIUS — нельзя «провалиться» внутрь
  • Максимальное расстояние: 7 * GLOBE_RADIUS — нельзя улететь слишком далеко

При выборе авиакомпании камера автоматически подстраивается так, чтобы охватить все маршруты сети. Реализовано как плавная интерполяция (lerp с коэффициентом 7% за кадр) к целевой позиции.

Клик по аэропорту

Трёхмерный Raycaster для кликов по точкам был бы ненадёжен из-за их малого размера. Поэтому используется проекция: координаты каждого аэропорта переводятся в экранные пиксели через матрицу камеры, затем ищется ближайший аэропорт к позиции курсора в радиусе пикселей.

Лейблы

HTML-лейблы с названиями городов позиционируются через Vue: для каждого аэропорта вычисляется экранная позиция через проекцию Three.js, затем устанавливается через CSS transform: translate(). Аэропорты, смотрящие «от камеры» (обратная сторона глобуса), отфильтровываются через dot(cameraDir, cityNormal).


Архитектура Vue-composable

useDrawAirlines3d возвращает функции init, destroy и реактивные данные для лейблов. Весь жизненный цикл Three.js инкапсулирован внутри.

typescript Copy
// Упрощённо
export function useDrawAirlines3d() {
  const scene = new THREE.Scene()
  const renderer = new THREE.WebGLRenderer({ antialias: true })
  const camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 100)
  const controls = new OrbitControls(camera, renderer.domElement)

  // Реактивность Vue → перестройка маршрутов
  watch(selectedAirline, () => rebuildRoutes())
  watch(selectedCity, () => rebuildHighlight())

  // Рендер только по необходимости
  controls.addEventListener('change', () => { needsRender = true })

  function animate() {
    requestAnimationFrame(animate)
    if (controls.update() || needsRender) {
      renderer.render(scene, camera)
      needsRender = false
    }
  }
}

Рендер по требованию (needsRender флаг) — важная оптимизация. Браузер не тратит ресурсы GPU на перерисовку статичной сцены; рендер происходит только при взаимодействии или изменении данных.


Производительность

Несколько решений, существенно влияющих на плавность работы:

Кэширование геометрии LOD. Каждый уровень детализации суши строится один раз и переиспользуется при переключениях. Без кэша переключение уровня при зуме вызывало бы пересборку меша из 3 МБ JSON на каждый кадр.

Отложенная загрузка тяжёлых данных. Файл land-10m.json (~3 МБ) загружается через requestIdleCallback. Пользователь видит глобус немедленно с lo-геометрией, пока в фоне грузится детальная версия.

Рендер по требованию. Рендеринг запускается только при взаимодействии с OrbitControls или изменении выбранных данных — не на каждый requestAnimationFrame.

Переиспользование материалов. Однотипные объекты (все маршруты одного цвета) разделяют один материал, а не создают по экземпляру на каждую линию.


Итог

3D-режим добавляет принципиально другой способ восприятия маршрутных сетей: видно, как трансатлантические маршруты дугой уходят через север, насколько плотно покрыта Европа, где расположены хабы относительно друг друга. При этом технически это отдельный, изолированный модуль — 2D-режим продолжает работать независимо.

Интереснее всего в реализации оказалась геометрия: правильная триангуляция сферических полигонов с гномонической проекцией и Earcut, построение дуг большого круга с динамическим подъёмом — это тот класс задач, где «в лоб» не работает и нужно думать о природе данных.

Посмотреть в действии можно тут airlines.macrulez.ru, сверху справа есть переключатель режима отображения 2D | 3D

Читать далее

22.03.2026

2D-режим на Three.js и всплывающий список авиакомпаний

Продолжаю развивать интерактивную карту маршрутных сетей авиакомпаний. В этот раз — две заметные вещи: переписал 2D-режим на Three.js и добавил удобный способ выбирать авиакомпанию прямо из карточки аэропорта.

Метки
three.jswebglавиациявизуализацияfrontend
22.03.2026

Автоматизация деплоя: от пуша до прогретого кеша

Ручной деплой — это всегда лотерея. Забытый npm run build перед копированием файлов, кеш старой версии на сервере, тесты которые «ну и так понятно что работает». В какой-то момент надоело играть в эту игру и я собрал нормальный пайплайн на GitHub Actions.

Метки
CI/CDGitHub ActionsDevOpsавтоматизациядеплой
23.03.2026

Каталог авиакомпаний и аэропортов: модальное окно с навигацией по маршрутной сети

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

Метки
vue3three.jspostgresqlавиациявизуализация-данных