Маршруты с пересадками, дуги большого круга и лейблы прямо на линиях

14.05.2026
Маршруты с пересадками, дуги большого круга и лейблы прямо на линиях

Поиск маршрутов с пересадками

Раньше карта работала в одном измерении: выбрал авиакомпанию — увидел её сеть, кликнул на аэропорт — увидел маршруты из него. Этого было достаточно, чтобы изучить любого перевозчика, но не хватало для ответа на простой вопрос: «как добраться из А в Б?». Хотелось ввести два города и получить варианты с пересадками.

Теперь в тулбаре есть кнопка поиска маршрутов. Открывается модалка, вводишь откуда и куда, жмёшь поиск — и API возвращает сгруппированные варианты: прямые рейсы, с одной пересадкой, с двумя. Каждый маршрут разбит на сегменты, для каждого сегмента — список авиакомпаний, которые его выполняют.

Самое приятное — интерактивность при выборе. Не нужно сначала собрать весь маршрут, а потом нажать «показать». Достаточно кликнуть на чип авиакомпании для одного сегмента — и этот кусок маршрута сразу появляется на карте, не дожидаясь остальных. Когда выбраны все сегменты, модалка закрывается сама.

typescript Copy
const pickAirline = (ri: number, si: number, al: { iata: string; name: string }) => {
  const prefix = `${ri}-`;
  const next = new Map(
    [...routeSelections.value].filter(([k]) => k.startsWith(prefix)),
  );
  next.set(`${ri}-${si}`, al);
  routeSelections.value = next;
  // ...
};

Строчка с фильтром — это защита от незаметного, но неприятного бага. Если начать выбирать сегменты одного маршрута, а потом переключиться на другой, старые выборы должны слететь. Без этого Map накапливал бы перемешанные ключи от разных маршрутов, и на карте отображалась бы какая-то химера из двух путей одновременно.


Специфический маршрут как состояние

Для хранения выбранного маршрута потребовалось новое поле в сторе — specificRoute. Раньше там жили только airline и city. Эти два режима взаимоисключающие: setSpecificRoute обнуляет airline и city, а setAirline — обнуляет specificRoute. Никакой явной логики переключения снаружи, инварианты соблюдаются прямо внутри мутаций.

Тип сегмента получился простым:

typescript Copy
export type SpecificRouteSegment = {
  dep: string;
  arr: string;
  airlineIata?: string;
  airlineName?: string;
};
export type SpecificRoute = SpecificRouteSegment[];

Поля airlineIata и airlineName опциональные — на случай, если маршрут восстанавливается из старой ссылки, где этой информации ещё не было.


Маршрут в URL: ?r=SVO:S7-FRA:LH-JFK

Первая версия URL-кодирования просто перечисляла аэропорты через дефис: ?r=SVO-FRA-JFK. Когда понадобились лейблы с названием авиакомпании на каждом сегменте — стало ясно, что IATA авиакомпании тоже должен жить в URL. Иначе после перезагрузки по шаред-ссылке маршрут отрисуется, но лейблы окажутся без подписей.

Решение — добавить опциональный суффикс через двоеточие к каждой точке вылета:

Copy
/2d?r=SVO:S7-FRA:LH-JFK

Парсинг прямолинейный — каждый элемент после split('-') дополнительно разбивается по ':':

typescript Copy
const parts = routeRaw.split('-').map(s => {
  const [iata, airline] = s.split(':');
  return { iata: iata.toUpperCase(), airlineIata: airline?.toUpperCase() };
});

Если IATA авиакомпании неизвестен — точка кодируется просто как IATA аэропорта, без суффикса. Старые ссылки работают как прежде.


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

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

Алгоритм строится на квадратичной кривой Безье. Нужно, чтобы середина кривой проходила через географическую середину ортодромии. Для квадратичного Безье в точке t=0.5:

Copy
B(0.5) = 0.25·P1 + 0.5·CP + 0.25·P2

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

Copy
CP = 2·GC − 0.5·(P1+P2)

Где GC — проекция середины большого круга на карту Миллера. Середину находим через нормализованный вектор на единичной сфере:

typescript Copy
const cx1 = Math.cos1) * Math.cos1);
// ...
let smx = (cx1 + cx2) / 2, smy = ..., smz = ...;
const slen = Math.sqrt(smx*smx + smy*smy + smz*smz);
smx /= slen; smy /= slen; smz /= slen;
const midLat = Math.asin(smz) / R;
const midLon = Math.atan2(smy, smx) / R;

Когда дуга оказывается слишком крутой

Маршрут Ташкент→Нью-Йорк идёт по ортодромии через примерно 62°N. В проекции Миллера это даёт колоссальный подъём: середина дуги оказывалась почти на 0.17 мировых единицы выше середины отрезка при длине хорды ~0.8. На экране — огромная парабола, нависающая над Гренландией. Красиво, наверное, но неправдоподобно.

Пришлось ограничить подъём долей от длины хорды:

typescript Copy
const cmx = (x1 + x2) / 2, cmy = (y1 + y2) / 2;
let devx = pxRaw - cmx, devy = pyRaw - cmy;
const devLen = Math.sqrt(devx*devx + devy*devy);
if (devLen > 1e-9) {
  const chordLen = Math.sqrt((x2-x1)**2 + (y2-y1)**2);
  const maxDev = chordLen * ARC_GC_MAX_LIFT_RATIO; // 0.15
  if (devLen > maxDev) {
    const scale = maxDev / devLen;
    devx *= scale; devy *= scale;
  }
}

Для Франкфурт→Нью-Йорк (хорда ~0.46, реальный подъём ~0.08) ограничение не срабатывает — там дуга в пределах. Для Ташкент→Нью-Йорк (хорда ~0.80) подъём обрезается с 0.17 до 0.12 — заметно, но без перегибов.


Лейблы прямо на линиях

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

Позиция лейбла — визуальная середина дуги Безье при t=0.5. Это та самая точка B(0.5) = GC_clamped, которую мы вычислили выше. Важная деталь: лейбл должен стоять именно на нарисованной дуге, а не на «сырой» GC-точке до ограничения подъёма. Иначе для длинных маршрутов лейбл будет висеть заметно выше реальной линии. Поэтому для позиционирования используется arcMidpointFlat — та же логика, что и для построения кривой, с применением клиппинга.

В 3D ситуация сложнее, потому что дуга там рисуется не на поверхности глобуса, а чуть выше неё, с параболическим подъёмом:

typescript Copy
const alt = ARC_BASE_R + maxLift * Math.sin(t * Math.PI);

Если поставить лейбл на фиксированном радиусе 1.05, при вращении глобуса возникает заметный параллакс — лейбл плавает относительно линии. Чтобы этого не было, для каждого сегмента вычисляется угловое расстояние между аэропортами, и из него — точный радиус дуги в середине:

typescript Copy
const arcMidR = ARC_BASE_R + ARC_LIFT * (angDist / Math.PI) + 0.005;
const p = latLonToVec3(midLat, midLon, arcMidR);

С таким радиусом лейбл следует ровно за серединой дуги при любом ракурсе.


Клик на аэропорт в режиме маршрута

В обычном режиме клик на аэропорт выставляет selectedCity, что перестраивает подсветку через getConnections(selectedAirline, ...). В режиме специфического маршрута selectedAirline равен null, getConnections возвращает пустой массив — и маршрут стирался.

Хотелось другого поведения: маршрут остаётся нетронутым, аэропорт подсвечивается и показывает иконку ℹ — точно как в обычном режиме. Для этого исправили watch(selectedCity, ...): если активен specificRoute, используем его сегменты как источник соединений и перестраиваем подсветку по маршруту заново:

typescript Copy
const specific = specificRoute?.value;
const connections = specific ? specific : getConnections(selectedAirline?.value, airlines);
// ...
if (specific) {
  removeDispose(highlightObj);
  highlightObj = buildFlatHighlightLines(specific, coords, '', specific, true);
  scene!.add(highlightObj);
} else {
  rebuildHighlight(connections, coords);
}

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

https://airlines.macrulez.ru/

Читать далее

19.05.2026

Admin-панель мониторинга: дашборд, аналитика, логи и API-эксплорер

Обзор admin-панели для Node.js-сервиса: от сводного дашборда с метриками сервера и кэша до интерактивного API-эксплорера, в котором можно формировать запросы и смотреть ответы прямо в браузере.

Метки
adminmonitoringnodejsvue3devtools