Root cause of search not working: globalThis.fetch stored as a class field loses its Window binding, causing 'Illegal invocation'. Fixed with fetch.bind(globalThis). Also fix calendar days endpoint date format from yyyyMMdd to yyyy-MM-ddT00:00:00 matching Angular's ApiFormatterService.
31 KiB
Пользовательские истории: Карта полётов
US-65: Переход на вкладку "Карта полётов"
Цель: Пользователь может открыть раздел с интерактивной картой маршрутов полётов в качестве альтернативы табличному расписанию.
Путь клиента:
- Шаг 1 — пользователь на главной странице — открыта вкладка "Онлайн-Табло" или "Расписание", в заголовке виден навигационный блок с тремя разделами.
- Шаг 2 — видит третью вкладку — в заголовке отображается вкладка "Карта полётов" рядом с "Онлайн-Табло" и "Расписание" (только если включён feature flag).
- Шаг 3 — кликает по вкладке — происходит переход на страницу карты, URL меняется на маршрут карты полётов.
- Шаг 4 — страница инициализируется — отображается индикатор загрузки (
isLoading = true), подгружаются справочники городов и аэропортов. - Шаг 5 — карта готова к работе — после
ngAfterViewInitиdictService.ready$карта инициализируется, индикатор загрузки скрывается, пользователь видит фильтр и карту с маркерами.
Критерии приёмки:
- ✅ Вкладка "Карта полётов" видна только при включённом feature flag.
- ✅ Клик по вкладке переводит пользователя на страницу карты без перезагрузки приложения.
- ✅ До готовности справочников и при загрузке маршрутов отображается
loader-sheet— полупрозрачный оверлей с анимированной иконкой самолёта поверх карты. - ✅ После инициализации отображаются компоненты
flights-map-filterиflights-map-body. - ✅ Если feature flag выключен, вкладка не рендерится в навигации.
Примечание: Функция скрыта за feature flag (features.flightsMap) и по умолчанию отключена. Карта построена на Leaflet.
US-66: Отображение маршрутов на карте
Цель: Пользователь видит на интерактивной карте маршруты полётов Аэрофлота в виде линий между городами.
Путь клиента:
- Шаг 1 — открытие карты — пользователь на странице "Карта полётов", инициализируется компонент
FlightsMapBodyComponentс Leaflet-картой (центр[53, 45], zoom 5). - Шаг 2 — отрисовка маркеров городов — из справочника
dictService.citiesAllна карте размещаются маркеры городов (иконкаmarkerBlueSmall) с подписями-тултипами. - Шаг 3 — загрузка маршрутов — после выбора фильтров выполняется запрос
apiService.getDestinations(...), возвращающий массив маршрутов. - Шаг 4 — построение линий — прямые рейсы отрисовываются сплошной линией (
directRoutePolyLine, цвет#2457ff), рейсы с пересадками — пунктиром (dashRoutePolyLine, цвет#2433ff,dashArray: '4 14'). - Шаг 5 — дуги большого круга — каждая линия строится как геодезическая дуга (
buildGreatCircle) из 64 сегментов между координатами городов. - Шаг 6 — группировка на слое — все полилинии добавляются на
destinationsLayerповерх базовой тайловой подложки.
Критерии приёмки:
- ✅ Карта инициализируется с базовым тайловым слоем (
L.tileLayer, zoom 3–6). - ✅ Прямые маршруты отображаются сплошной линией синего цвета (
#2457ff). - ✅ Маршруты с пересадками отображаются пунктирной линией (
#2433ff,dashArray: '4 14'). - ✅ Линии строятся по дуге большого круга, а не прямыми отрезками на плоскости.
- ✅ При смене фильтров старые линии очищаются (
destinationsLayer.clearLayers()) и рисуются заново.
Примечание: Все маршруты используют оттенки синего — различие только в стиле линии (сплошная/пунктир). Других цветов кода не предусмотрено.
US-67: Выбор города отправления
Цель: Пользователь выбирает город вылета и сразу видит на карте все доступные направления из этого города ("паук").
Путь клиента:
- Шаг 1 — фокус на поле "Откуда" — пользователь кликает по полю
city-autocompleteс labelSHARED.DEPARTURE_CITYв фильтре. - Шаг 2 — ввод запроса — начинает вводить название города, компонент автодополнения показывает подходящие варианты из справочника.
- Шаг 3 — выбор города — выбирает вариант, в
FlightsMapFiltersStateServiceвызываетсяsetDeparture(code). - Шаг 4 — подсветка маркера — маркер выбранного города переключается на иконку
markerOrangeи переносится вhighlightedLayer. - Шаг 5 — запрос направлений — без города прибытия срабатывает
fetchAndDrawSpider(departure, dateFrom, dateTo)— запрос всех доступных направлений на 6 месяцев вперёд. - Шаг 6 — отрисовка "паука" — для каждого уникального города назначения строится прямая линия от города отправления (
directRoutePolyLine).
Критерии приёмки:
- ✅ Поле "Откуда" использует компонент
city-autocompleteс автодополнением по справочнику городов; при наличии нескольких аэропортов у города каждый показываетpTooltipс полным названием. - ✅ Выбор города вызывает
filterStateService.setDeparture(code). - ✅ Маркер выбранного города меняет иконку на оранжевую.
- ✅ При выборе только города отправления рисуется "паук" из всех доступных направлений.
- ✅ Если геолокация пользователя определена и фильтры пусты, город отправления подставляется автоматически.
Примечание: Клик по маркеру города на карте также устанавливает его как отправление через handleMarkerClick.
US-68: Выбор города прибытия
Цель: Пользователь уточняет направление, выбирая город прибытия, чтобы увидеть конкретные маршруты между двумя точками.
Путь клиента:
- Шаг 1 — город отправления уже выбран — в поле "Откуда" указан город, на карте виден "паук" направлений.
- Шаг 2 — фокус на поле "Куда" — пользователь кликает по второму
city-autocompleteс labelSHARED.ARRIVAL_CITY. - Шаг 3 — выбор города — из автодополнения выбирается город, вызывается
setArrival(code). - Шаг 4 — подсветка обоих маркеров — маркеры отправления и прибытия получают иконку
markerOrangeи переносятся вhighlightedLayer. - Шаг 5 — запрос маршрута — срабатывает
fetchAndDrawRoute(departure, arrival, dateFrom, dateTo, connections); если прямых рейсов нет, автоматически делается повторный запрос сconnections: 1. - Шаг 6 — отрисовка маршрута — отображаются все найденные маршруты (прямые сплошной линией, с пересадками — пунктиром) и показываются popup в точках отправления и прибытия.
- Шаг 7 — отсутствие направлений — если ни прямых, ни пересадочных маршрутов не найдено, поверх карты показывается
no-directions-sheet— полупрозрачный оверлей с карточкойFLIGHTS-MAP.NO_DIRECTIONS_INFO. Клик вне карточки закрывает оверлей.
Критерии приёмки:
- ✅ Поле "Куда" работает только после выбора города отправления.
- ✅ Выбор города вызывает
filterStateService.setArrival(code). - ✅ Оба маркера (отправления и прибытия) подсвечиваются оранжевой иконкой.
- ✅ При отсутствии прямых рейсов автоматически делается fallback-запрос с пересадками.
- ✅ Если fallback вернул рейсы, тумблер "Соединительные" автоматически включается (
setConnections(true)). - ✅ Если маршрутов нет вообще, показывается оверлей
no-directions-sheetс информационным сообщением.
Примечание: Клик по маркеру при уже выбранном отправлении автоматически устанавливает его как прибытие.
US-69: Обмен городов
Цель: Пользователь может одним кликом поменять местами города отправления и прибытия, чтобы посмотреть обратное направление.
Путь клиента:
- Шаг 1 — оба города выбраны — в полях "Откуда" и "Куда" указаны два разных города, на карте видны маршруты.
- Шаг 2 — видит кнопку обмена — между двумя полями отображается кнопка
.button-changeсо SVG-иконкой#changeCity. - Шаг 3 — клик по кнопке — вызывается метод
exchange()компонента фильтра. - Шаг 4 — сброс ошибок валидации —
validationService.departureErrorиvalidationService.arrivalErrorобнуляются. - Шаг 5 — обмен значений — значения полей меняются местами через деструктуризацию
[departure, arrival] = [arrival, departure]. - Шаг 6 — перерисовка маршрута — сервис состояния эмитит изменения,
watchRouteChangesзапускает новый запрос и карта обновляется.
Критерии приёмки:
- ✅ Кнопка обмена видна между полями "Откуда" и "Куда".
- ✅ Клик меняет местами значения двух
city-autocomplete. - ✅ Ошибки валидации обоих полей сбрасываются при обмене.
- ✅ Маршрут на карте автоматически перестраивается для нового направления.
- ✅ Обмен доступен даже если заполнено только одно из полей (второе станет пустым).
US-70: Выбор даты
Цель: Пользователь выбирает дату полёта, чтобы сформировать корректную ссылку на бронирование и контекст для popup маршрута.
Путь клиента:
- Шаг 1 — фильтр развёрнут — в нижней части фильтра виден блок
flighs-map-filter-dateс компонентомcalendar-input. - Шаг 2 — клик по полю даты — открывается календарь с ограничениями
minDate,maxDateиdisabledDatesиз состояния фильтра. - Шаг 3 — выбор даты — пользователь кликает по доступному дню, вызывается
filterStateService.setDate(date). - Шаг 4 — реакция карты —
watchDateChangesчерезdistinctUntilChangedпо полюdateловит изменение. - Шаг 5 — обновление popup — если на карте уже есть маршруты (
destinations.data.routes.length > 0), вызываетсяshowRoutePopup(routes)с новой датой. - Шаг 6 — обновление ссылки "Купить билет" — в popup прибытия ссылка
getLink()пересобирается с новой датой в форматеYYYYMMDD.
Критерии приёмки:
- ✅ Поле даты — компонент
calendar-inputс labelSHARED.FLIGHT_DATE. - ✅ Календарь учитывает
minDate,maxDate,disabledDatesиз состояния фильтра. - ✅ Изменение даты вызывает
filterStateService.setDate(date). - ✅ При наличии маршрутов popup с ссылкой на бронирование обновляется под новую дату.
- ✅ Изменение даты не приводит к повторному запросу списка маршрутов (карта не меняется, меняется только ссылка).
- ✅ При изменении доступных дат (
disabledDates), если текущая выбранная дата стала недоступной, автоматически выбирается ближайшая доступная дата (date snapping).
Примечание: Сам запрос направлений использует окно [вчера; +6 месяцев] — выбранная дата влияет только на ссылку "Купить билет".
US-71: Фильтр "Внутренние рейсы"
Цель: Пользователь может отключить или включить отображение внутрироссийских маршрутов на карте.
Путь клиента:
- Шаг 1 — фильтр открыт — в блоке
flights-map-filter-content-checkboxesвиден тумблерtoggle-switchс labelFLIGHTS-MAP.DOMESTIC_FLIGHTS. - Шаг 2 — тумблер активен — тумблер доступен только при выбранном городе отправления (
[disabled]="departure ? false : true"). - Шаг 3 — клик по тумблеру — вызывается
filterStateService.setDomestic(value). - Шаг 4 — обновление слоёв маркеров —
updateMarkersскрывает слои российских городов (zoomLayers.ru), когда тумблер "Международные" включён без "Внутренних". - Шаг 5 — пересчёт маршрутов —
filterRoutesприменяет предикатisDomesticи оставляет только маршруты, где все города изruCitiesCodes. - Шаг 6 — перерисовка карты — линии и маркеры обновляются на новом наборе.
Критерии приёмки:
- ✅ Тумблер "Внутренние рейсы" отображается в блоке фильтров.
- ✅ Тумблер заблокирован, если не выбран город отправления.
- ✅ Клик вызывает
filterStateService.setDomestic(value). - ✅ При активном фильтре на карте остаются только маршруты между российскими городами.
- ✅ Маркеры нероссийских городов скрываются/показываются в соответствии со значением тумблера.
US-72: Фильтр "Международные рейсы"
Цель: Пользователь может отключить или включить отображение международных маршрутов.
Путь клиента:
- Шаг 1 — фильтр открыт — в блоке фильтров виден второй тумблер
toggle-switchс labelFLIGHTS-MAP.INTERNATIONAL_FLIGHTS. - Шаг 2 — доступность тумблера — работает только при выбранном городе отправления.
- Шаг 3 — клик по тумблеру — вызывается
filterStateService.setInternational(value). - Шаг 4 — обновление видимости слоёв —
updateMarkersскрывает слои с российскими маркерами, если включены только международные. - Шаг 5 — фильтрация маршрутов —
filterRoutesприменяет предикатisInternational(хотя бы один город изotherCitiesCodes). - Шаг 6 — перерисовка — линии и маркеры на карте обновляются.
Критерии приёмки:
- ✅ Тумблер "Международные рейсы" отображается в блоке фильтров.
- ✅ Тумблер заблокирован, если не выбран город отправления.
- ✅ Клик вызывает
filterStateService.setInternational(value). - ✅ При одновременно выключенных "Внутренних" и активных "Международных" остаются только маршруты с зарубежным городом.
- ✅ Комбинация обоих тумблеров (оба ON) показывает все маршруты без предикатов категории.
US-73: Фильтр "Соединительные рейсы"
Цель: Пользователь управляет показом маршрутов с пересадками между выбранными городами.
Путь клиента:
- Шаг 1 — выбраны отправление и прибытие — тумблер
toggle-switchс labelFLIGHTS-MAP.CONNECTING_FLIGHTSактивируется ([disabled]="departure && arrival ? false : true"). - Шаг 2 — клик по тумблеру — вызывается
filterStateService.setConnections(value). - Шаг 3 — новый запрос —
fetchAndDrawRouteвызывается с параметромconnections: 1. - Шаг 4 — фильтрация на клиенте —
filterRoutesприменяет предикатhasConnections(!r.isDirect), если тумблер активен. - Шаг 5 — отрисовка пунктирных линий — найденные маршруты с пересадками рисуются стилем
dashRoutePolyLine. - Шаг 6 — автоматическое включение — если прямых рейсов нет, система сама делает fallback-запрос и включает тумблер (
skipNextFetchOnceпредотвращает повторный запрос).
Критерии приёмки:
- ✅ Тумблер "Соединительные рейсы" отображается в блоке фильтров.
- ✅ Тумблер доступен только если выбраны оба города.
- ✅ Клик вызывает
filterStateService.setConnections(value). - ✅ При включённом тумблере на карте отображаются только непрямые маршруты пунктиром.
- ✅ При отсутствии прямых рейсов тумблер включается автоматически, и это не вызывает повторного сетевого запроса.
US-74: Зуммирование карты
Цель: Пользователь может изменять масштаб карты стандартными средствами Leaflet для более детального или общего просмотра.
Путь клиента:
- Шаг 1 — карта инициализирована — начальный zoom = 5, ограничения тайлового слоя
maxZoom: 6,minZoom: 3. - Шаг 2 — изменение масштаба — пользователь использует колесо мыши, кнопки
+/-или жест щипка на мобильном. - Шаг 3 — событие
zoomend— Leaflet эмитит событие, подписка вызываетupdateVisibility(). - Шаг 4 — перерасчёт видимости маркеров — в
updateMarkersсравнивается текущий zoom с уровнем слоя (zoomLayers[countryType][z]): слои с уровнем<= zдобавляются, остальные удаляются. - Шаг 5 — логика тултипов — при
zoom <= 3все неподсвеченные тултипы закрываются; при наличии двух подсвеченных маркеров неактивные тоже скрываются; иначе — все тултипы открываются.
Критерии приёмки:
- ✅ Начальный zoom карты равен 5, диапазон 3–6.
- ✅ Управление масштабом работает через стандартные средства Leaflet (колесо, кнопки, жесты).
- ✅ При изменении zoom видимость маркеров пересчитывается в соответствии с уровнем категории города.
- ✅ При
zoom <= 3отображаются только тултипы подсвеченных маркеров. - ✅ Тайловый слой не запрашивает тайлы вне диапазона
3..6.
Примечание: Видимость маркера зависит от cityCategoryService.zoomLevel(code) — крупные города видны на меньших масштабах.
US-75: Панорамирование карты
Цель: Пользователь может перемещать карту drag-ом, оставаясь в пределах заданных географических границ.
Путь клиента:
- Шаг 1 — карта инициализирована — при создании Leaflet-карты задаются
maxBounds: [[-70, -185], [80, 200]]иmaxBoundsViscosity: 1. - Шаг 2 — захват карты — пользователь зажимает ЛКМ или касается экрана на мобильном.
- Шаг 3 — перетаскивание — Leaflet перемещает карту за курсором/пальцем, стандартное поведение.
- Шаг 4 — ограничение границ — при попытке выйти за
maxBoundsвязкость 1 жёстко удерживает камеру внутри прямоугольника. - Шаг 5 — отпускание — карта останавливается в новом положении, событий кастомных обработок для движения нет.
Критерии приёмки:
- ✅ Панорамирование работает мышью и сенсорным вводом через стандартное поведение Leaflet.
- ✅ Движение жёстко ограничено
maxBoundsс вязкостью 1 (нельзя вытащить карту за границы). - ✅
attributionControlотключён (никакой надписи Leaflet в углу). - ✅ Маркеры и линии перемещаются вместе с подложкой без рассинхронизации.
- ✅ На мобильных устройствах drag работает одним пальцем.
US-76: Информационные popups при выборе маршрута
Цель: После построения маршрута пользователь видит всплывающие подсказки в точках отправления и прибытия с названиями городов и кнопкой покупки билета.
Путь клиента:
- Шаг 1 — маршрут построен —
buildRouteуспешно отрисовал полилинии и вызываетshowRoutePopup(routes). - Шаг 2 — вычисление городов — из первого маршрута берутся коды первого и последнего элементов
route, черезairportToCityCodeнаходятся объекты городов в справочнике. - Шаг 3 — очистка старых popup —
clearPopup()удаляет предыдущиеdeparturePopupиroutePopup. - Шаг 4 — popup отправления — создаётся
L.popupс HTMLpopup-header-testи названием города отправления, привязывается к координатам маркера отправления. - Шаг 5 — popup прибытия — создаётся аналогичный popup с названием города прибытия и ссылкой
<a class="popup-buy-ticket">Купить билет</a>, ведущей на сайт Аэрофлота. - Шаг 6 — отображение — оба popup открываются через
openOn(map)с настройкамиcloseButton: true, autoClose: false, closeOnClick: false.
Критерии приёмки:
- ✅ Popup появляются только после успешной отрисовки маршрута.
- ✅ Popup отправления содержит название города отправления.
- ✅ Popup прибытия содержит название города прибытия и ссылку "Купить билет".
- ✅ Ссылка "Купить билет" формируется методом
getLink()с датой из фильтра в форматеYYYYMMDD. - ✅ Popup не закрываются при клике по карте (
autoClose: false,closeOnClick: false). - ✅ При смене маршрута старые popup удаляются перед созданием новых.
Примечание: Popup строится из содержимого первого маршрута в ответе API, даже если маршрутов несколько.
US-79: Переход к покупке билета с карты
Цель: Пользователь может перейти со страницы карты на сайт бронирования Аэрофлота с предзаполненными параметрами маршрута и даты.
Путь клиента:
- Шаг 1 — маршрут и popup отображены — на карте виден построенный маршрут, в popup прибытия есть ссылка "Купить билет".
- Шаг 2 — формирование ссылки — метод
getLink()собирает URL:https://www.aeroflot.ru/sb/app/ru-ru#/search?...routes={departure}.{date}.{arrival}.... - Шаг 3 — дата и коды — дата форматируется через
moment(...).format('YYYYMMDD'), коды городов берутся из текущего состояния фильтра. - Шаг 4 — UTM-метки — к ссылке добавляются
utm_source=aflwebbot,utm_medium=referral,utm_campaign=ref_3015_general_rf_button.index__all_flight.mapдля аналитики источника. - Шаг 5 — клик по ссылке — пользователь нажимает "Купить билет", ссылка открывается через
target="_blank"в новой вкладке. - Шаг 6 — завершение на сайте Аэрофлота — на внешнем сайте автоматически запускается поиск (
autosearch=Y) и выводятся результаты.
Критерии приёмки:
- ✅ Ссылка "Купить билет" присутствует только в popup прибытия.
- ✅ URL содержит сегмент
routes=<departure>.<YYYYMMDD>.<arrival>. - ✅ Если дата не выбрана, используется текущая дата (обнулённая до полуночи).
- ✅ Ссылка открывается в новой вкладке (
target="_blank"). - ✅ К ссылке приклеены UTM-метки источника
aflwebbot/ref_3015_general_rf_button.index__all_flight.map.
Примечание: В приложении нет внутреннего списка рейсов карты — это намеренный deep link на основной сайт Аэрофлота для бронирования.