Files
flights_web/docs/user-stories-6-flights-map.md
gnezim 71d0c983fd
CI / ci (push) Failing after 28s
Deploy / build-and-deploy (push) Failing after 5s
Fix API calls: bind fetch to globalThis, fix date format for calendar
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.
2026-04-15 22:32:51 +03:00

31 KiB
Raw Permalink Blame History

Пользовательские истории: Карта полётов

US-65: Переход на вкладку "Карта полётов"

Цель: Пользователь может открыть раздел с интерактивной картой маршрутов полётов в качестве альтернативы табличному расписанию.

Путь клиента:

  1. Шаг 1 — пользователь на главной странице — открыта вкладка "Онлайн-Табло" или "Расписание", в заголовке виден навигационный блок с тремя разделами.
  2. Шаг 2 — видит третью вкладку — в заголовке отображается вкладка "Карта полётов" рядом с "Онлайн-Табло" и "Расписание" (только если включён feature flag).
  3. Шаг 3 — кликает по вкладке — происходит переход на страницу карты, URL меняется на маршрут карты полётов.
  4. Шаг 4 — страница инициализируется — отображается индикатор загрузки (isLoading = true), подгружаются справочники городов и аэропортов.
  5. Шаг 5 — карта готова к работе — после ngAfterViewInit и dictService.ready$ карта инициализируется, индикатор загрузки скрывается, пользователь видит фильтр и карту с маркерами.

Критерии приёмки:

  • Вкладка "Карта полётов" видна только при включённом feature flag.
  • Клик по вкладке переводит пользователя на страницу карты без перезагрузки приложения.
  • До готовности справочников и при загрузке маршрутов отображается loader-sheet — полупрозрачный оверлей с анимированной иконкой самолёта поверх карты.
  • После инициализации отображаются компоненты flights-map-filter и flights-map-body.
  • Если feature flag выключен, вкладка не рендерится в навигации.

Примечание: Функция скрыта за feature flag (features.flightsMap) и по умолчанию отключена. Карта построена на Leaflet.


US-66: Отображение маршрутов на карте

Цель: Пользователь видит на интерактивной карте маршруты полётов Аэрофлота в виде линий между городами.

Путь клиента:

  1. Шаг 1 — открытие карты — пользователь на странице "Карта полётов", инициализируется компонент FlightsMapBodyComponent с Leaflet-картой (центр [53, 45], zoom 5).
  2. Шаг 2 — отрисовка маркеров городов — из справочника dictService.citiesAll на карте размещаются маркеры городов (иконка markerBlueSmall) с подписями-тултипами.
  3. Шаг 3 — загрузка маршрутов — после выбора фильтров выполняется запрос apiService.getDestinations(...), возвращающий массив маршрутов.
  4. Шаг 4 — построение линий — прямые рейсы отрисовываются сплошной линией (directRoutePolyLine, цвет #2457ff), рейсы с пересадками — пунктиром (dashRoutePolyLine, цвет #2433ff, dashArray: '4 14').
  5. Шаг 5 — дуги большого круга — каждая линия строится как геодезическая дуга (buildGreatCircle) из 64 сегментов между координатами городов.
  6. Шаг 6 — группировка на слое — все полилинии добавляются на destinationsLayer поверх базовой тайловой подложки.

Критерии приёмки:

  • Карта инициализируется с базовым тайловым слоем (L.tileLayer, zoom 36).
  • Прямые маршруты отображаются сплошной линией синего цвета (#2457ff).
  • Маршруты с пересадками отображаются пунктирной линией (#2433ff, dashArray: '4 14').
  • Линии строятся по дуге большого круга, а не прямыми отрезками на плоскости.
  • При смене фильтров старые линии очищаются (destinationsLayer.clearLayers()) и рисуются заново.

Примечание: Все маршруты используют оттенки синего — различие только в стиле линии (сплошная/пунктир). Других цветов кода не предусмотрено.


US-67: Выбор города отправления

Цель: Пользователь выбирает город вылета и сразу видит на карте все доступные направления из этого города ("паук").

Путь клиента:

  1. Шаг 1 — фокус на поле "Откуда" — пользователь кликает по полю city-autocomplete с label SHARED.DEPARTURE_CITY в фильтре.
  2. Шаг 2 — ввод запроса — начинает вводить название города, компонент автодополнения показывает подходящие варианты из справочника.
  3. Шаг 3 — выбор города — выбирает вариант, в FlightsMapFiltersStateService вызывается setDeparture(code).
  4. Шаг 4 — подсветка маркера — маркер выбранного города переключается на иконку markerOrange и переносится в highlightedLayer.
  5. Шаг 5 — запрос направлений — без города прибытия срабатывает fetchAndDrawSpider(departure, dateFrom, dateTo) — запрос всех доступных направлений на 6 месяцев вперёд.
  6. Шаг 6 — отрисовка "паука" — для каждого уникального города назначения строится прямая линия от города отправления (directRoutePolyLine).

Критерии приёмки:

  • Поле "Откуда" использует компонент city-autocomplete с автодополнением по справочнику городов; при наличии нескольких аэропортов у города каждый показывает pTooltip с полным названием.
  • Выбор города вызывает filterStateService.setDeparture(code).
  • Маркер выбранного города меняет иконку на оранжевую.
  • При выборе только города отправления рисуется "паук" из всех доступных направлений.
  • Если геолокация пользователя определена и фильтры пусты, город отправления подставляется автоматически.

Примечание: Клик по маркеру города на карте также устанавливает его как отправление через handleMarkerClick.


US-68: Выбор города прибытия

Цель: Пользователь уточняет направление, выбирая город прибытия, чтобы увидеть конкретные маршруты между двумя точками.

Путь клиента:

  1. Шаг 1 — город отправления уже выбран — в поле "Откуда" указан город, на карте виден "паук" направлений.
  2. Шаг 2 — фокус на поле "Куда" — пользователь кликает по второму city-autocomplete с label SHARED.ARRIVAL_CITY.
  3. Шаг 3 — выбор города — из автодополнения выбирается город, вызывается setArrival(code).
  4. Шаг 4 — подсветка обоих маркеров — маркеры отправления и прибытия получают иконку markerOrange и переносятся в highlightedLayer.
  5. Шаг 5 — запрос маршрута — срабатывает fetchAndDrawRoute(departure, arrival, dateFrom, dateTo, connections); если прямых рейсов нет, автоматически делается повторный запрос с connections: 1.
  6. Шаг 6 — отрисовка маршрута — отображаются все найденные маршруты (прямые сплошной линией, с пересадками — пунктиром) и показываются popup в точках отправления и прибытия.
  7. Шаг 7 — отсутствие направлений — если ни прямых, ни пересадочных маршрутов не найдено, поверх карты показывается no-directions-sheet — полупрозрачный оверлей с карточкой FLIGHTS-MAP.NO_DIRECTIONS_INFO. Клик вне карточки закрывает оверлей.

Критерии приёмки:

  • Поле "Куда" работает только после выбора города отправления.
  • Выбор города вызывает filterStateService.setArrival(code).
  • Оба маркера (отправления и прибытия) подсвечиваются оранжевой иконкой.
  • При отсутствии прямых рейсов автоматически делается fallback-запрос с пересадками.
  • Если fallback вернул рейсы, тумблер "Соединительные" автоматически включается (setConnections(true)).
  • Если маршрутов нет вообще, показывается оверлей no-directions-sheet с информационным сообщением.

Примечание: Клик по маркеру при уже выбранном отправлении автоматически устанавливает его как прибытие.


US-69: Обмен городов

Цель: Пользователь может одним кликом поменять местами города отправления и прибытия, чтобы посмотреть обратное направление.

Путь клиента:

  1. Шаг 1 — оба города выбраны — в полях "Откуда" и "Куда" указаны два разных города, на карте видны маршруты.
  2. Шаг 2 — видит кнопку обмена — между двумя полями отображается кнопка .button-change со SVG-иконкой #changeCity.
  3. Шаг 3 — клик по кнопке — вызывается метод exchange() компонента фильтра.
  4. Шаг 4 — сброс ошибок валидацииvalidationService.departureError и validationService.arrivalError обнуляются.
  5. Шаг 5 — обмен значений — значения полей меняются местами через деструктуризацию [departure, arrival] = [arrival, departure].
  6. Шаг 6 — перерисовка маршрута — сервис состояния эмитит изменения, watchRouteChanges запускает новый запрос и карта обновляется.

Критерии приёмки:

  • Кнопка обмена видна между полями "Откуда" и "Куда".
  • Клик меняет местами значения двух city-autocomplete.
  • Ошибки валидации обоих полей сбрасываются при обмене.
  • Маршрут на карте автоматически перестраивается для нового направления.
  • Обмен доступен даже если заполнено только одно из полей (второе станет пустым).

US-70: Выбор даты

Цель: Пользователь выбирает дату полёта, чтобы сформировать корректную ссылку на бронирование и контекст для popup маршрута.

Путь клиента:

  1. Шаг 1 — фильтр развёрнут — в нижней части фильтра виден блок flighs-map-filter-date с компонентом calendar-input.
  2. Шаг 2 — клик по полю даты — открывается календарь с ограничениями minDate, maxDate и disabledDates из состояния фильтра.
  3. Шаг 3 — выбор даты — пользователь кликает по доступному дню, вызывается filterStateService.setDate(date).
  4. Шаг 4 — реакция картыwatchDateChanges через distinctUntilChanged по полю date ловит изменение.
  5. Шаг 5 — обновление popup — если на карте уже есть маршруты (destinations.data.routes.length > 0), вызывается showRoutePopup(routes) с новой датой.
  6. Шаг 6 — обновление ссылки "Купить билет" — в popup прибытия ссылка getLink() пересобирается с новой датой в формате YYYYMMDD.

Критерии приёмки:

  • Поле даты — компонент calendar-input с label SHARED.FLIGHT_DATE.
  • Календарь учитывает minDate, maxDate, disabledDates из состояния фильтра.
  • Изменение даты вызывает filterStateService.setDate(date).
  • При наличии маршрутов popup с ссылкой на бронирование обновляется под новую дату.
  • Изменение даты не приводит к повторному запросу списка маршрутов (карта не меняется, меняется только ссылка).
  • При изменении доступных дат (disabledDates), если текущая выбранная дата стала недоступной, автоматически выбирается ближайшая доступная дата (date snapping).

Примечание: Сам запрос направлений использует окно [вчера; +6 месяцев] — выбранная дата влияет только на ссылку "Купить билет".


US-71: Фильтр "Внутренние рейсы"

Цель: Пользователь может отключить или включить отображение внутрироссийских маршрутов на карте.

Путь клиента:

  1. Шаг 1 — фильтр открыт — в блоке flights-map-filter-content-checkboxes виден тумблер toggle-switch с label FLIGHTS-MAP.DOMESTIC_FLIGHTS.
  2. Шаг 2 — тумблер активен — тумблер доступен только при выбранном городе отправления ([disabled]="departure ? false : true").
  3. Шаг 3 — клик по тумблеру — вызывается filterStateService.setDomestic(value).
  4. Шаг 4 — обновление слоёв маркеровupdateMarkers скрывает слои российских городов (zoomLayers.ru), когда тумблер "Международные" включён без "Внутренних".
  5. Шаг 5 — пересчёт маршрутовfilterRoutes применяет предикат isDomestic и оставляет только маршруты, где все города из ruCitiesCodes.
  6. Шаг 6 — перерисовка карты — линии и маркеры обновляются на новом наборе.

Критерии приёмки:

  • Тумблер "Внутренние рейсы" отображается в блоке фильтров.
  • Тумблер заблокирован, если не выбран город отправления.
  • Клик вызывает filterStateService.setDomestic(value).
  • При активном фильтре на карте остаются только маршруты между российскими городами.
  • Маркеры нероссийских городов скрываются/показываются в соответствии со значением тумблера.

US-72: Фильтр "Международные рейсы"

Цель: Пользователь может отключить или включить отображение международных маршрутов.

Путь клиента:

  1. Шаг 1 — фильтр открыт — в блоке фильтров виден второй тумблер toggle-switch с label FLIGHTS-MAP.INTERNATIONAL_FLIGHTS.
  2. Шаг 2 — доступность тумблера — работает только при выбранном городе отправления.
  3. Шаг 3 — клик по тумблеру — вызывается filterStateService.setInternational(value).
  4. Шаг 4 — обновление видимости слоёвupdateMarkers скрывает слои с российскими маркерами, если включены только международные.
  5. Шаг 5 — фильтрация маршрутовfilterRoutes применяет предикат isInternational (хотя бы один город из otherCitiesCodes).
  6. Шаг 6 — перерисовка — линии и маркеры на карте обновляются.

Критерии приёмки:

  • Тумблер "Международные рейсы" отображается в блоке фильтров.
  • Тумблер заблокирован, если не выбран город отправления.
  • Клик вызывает filterStateService.setInternational(value).
  • При одновременно выключенных "Внутренних" и активных "Международных" остаются только маршруты с зарубежным городом.
  • Комбинация обоих тумблеров (оба ON) показывает все маршруты без предикатов категории.

US-73: Фильтр "Соединительные рейсы"

Цель: Пользователь управляет показом маршрутов с пересадками между выбранными городами.

Путь клиента:

  1. Шаг 1 — выбраны отправление и прибытие — тумблер toggle-switch с label FLIGHTS-MAP.CONNECTING_FLIGHTS активируется ([disabled]="departure && arrival ? false : true").
  2. Шаг 2 — клик по тумблеру — вызывается filterStateService.setConnections(value).
  3. Шаг 3 — новый запросfetchAndDrawRoute вызывается с параметром connections: 1.
  4. Шаг 4 — фильтрация на клиентеfilterRoutes применяет предикат hasConnections (!r.isDirect), если тумблер активен.
  5. Шаг 5 — отрисовка пунктирных линий — найденные маршруты с пересадками рисуются стилем dashRoutePolyLine.
  6. Шаг 6 — автоматическое включение — если прямых рейсов нет, система сама делает fallback-запрос и включает тумблер (skipNextFetchOnce предотвращает повторный запрос).

Критерии приёмки:

  • Тумблер "Соединительные рейсы" отображается в блоке фильтров.
  • Тумблер доступен только если выбраны оба города.
  • Клик вызывает filterStateService.setConnections(value).
  • При включённом тумблере на карте отображаются только непрямые маршруты пунктиром.
  • При отсутствии прямых рейсов тумблер включается автоматически, и это не вызывает повторного сетевого запроса.

US-74: Зуммирование карты

Цель: Пользователь может изменять масштаб карты стандартными средствами Leaflet для более детального или общего просмотра.

Путь клиента:

  1. Шаг 1 — карта инициализирована — начальный zoom = 5, ограничения тайлового слоя maxZoom: 6, minZoom: 3.
  2. Шаг 2 — изменение масштаба — пользователь использует колесо мыши, кнопки +/- или жест щипка на мобильном.
  3. Шаг 3 — событие zoomend — Leaflet эмитит событие, подписка вызывает updateVisibility().
  4. Шаг 4 — перерасчёт видимости маркеров — в updateMarkers сравнивается текущий zoom с уровнем слоя (zoomLayers[countryType][z]): слои с уровнем <= z добавляются, остальные удаляются.
  5. Шаг 5 — логика тултипов — при zoom <= 3 все неподсвеченные тултипы закрываются; при наличии двух подсвеченных маркеров неактивные тоже скрываются; иначе — все тултипы открываются.

Критерии приёмки:

  • Начальный zoom карты равен 5, диапазон 3–6.
  • Управление масштабом работает через стандартные средства Leaflet (колесо, кнопки, жесты).
  • При изменении zoom видимость маркеров пересчитывается в соответствии с уровнем категории города.
  • При zoom <= 3 отображаются только тултипы подсвеченных маркеров.
  • Тайловый слой не запрашивает тайлы вне диапазона 3..6.

Примечание: Видимость маркера зависит от cityCategoryService.zoomLevel(code) — крупные города видны на меньших масштабах.


US-75: Панорамирование карты

Цель: Пользователь может перемещать карту drag-ом, оставаясь в пределах заданных географических границ.

Путь клиента:

  1. Шаг 1 — карта инициализирована — при создании Leaflet-карты задаются maxBounds: [[-70, -185], [80, 200]] и maxBoundsViscosity: 1.
  2. Шаг 2 — захват карты — пользователь зажимает ЛКМ или касается экрана на мобильном.
  3. Шаг 3 — перетаскивание — Leaflet перемещает карту за курсором/пальцем, стандартное поведение.
  4. Шаг 4 — ограничение границ — при попытке выйти за maxBounds вязкость 1 жёстко удерживает камеру внутри прямоугольника.
  5. Шаг 5 — отпускание — карта останавливается в новом положении, событий кастомных обработок для движения нет.

Критерии приёмки:

  • Панорамирование работает мышью и сенсорным вводом через стандартное поведение Leaflet.
  • Движение жёстко ограничено maxBounds с вязкостью 1 (нельзя вытащить карту за границы).
  • attributionControl отключён (никакой надписи Leaflet в углу).
  • Маркеры и линии перемещаются вместе с подложкой без рассинхронизации.
  • На мобильных устройствах drag работает одним пальцем.

US-76: Информационные popups при выборе маршрута

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

Путь клиента:

  1. Шаг 1 — маршрут построенbuildRoute успешно отрисовал полилинии и вызывает showRoutePopup(routes).
  2. Шаг 2 — вычисление городов — из первого маршрута берутся коды первого и последнего элементов route, через airportToCityCode находятся объекты городов в справочнике.
  3. Шаг 3 — очистка старых popupclearPopup() удаляет предыдущие departurePopup и routePopup.
  4. Шаг 4 — popup отправления — создаётся L.popup с HTML popup-header-test и названием города отправления, привязывается к координатам маркера отправления.
  5. Шаг 5 — popup прибытия — создаётся аналогичный popup с названием города прибытия и ссылкой <a class="popup-buy-ticket">Купить билет</a>, ведущей на сайт Аэрофлота.
  6. Шаг 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. Шаг 1 — маршрут и popup отображены — на карте виден построенный маршрут, в popup прибытия есть ссылка "Купить билет".
  2. Шаг 2 — формирование ссылки — метод getLink() собирает URL: https://www.aeroflot.ru/sb/app/ru-ru#/search?...routes={departure}.{date}.{arrival}....
  3. Шаг 3 — дата и коды — дата форматируется через moment(...).format('YYYYMMDD'), коды городов берутся из текущего состояния фильтра.
  4. Шаг 4 — UTM-метки — к ссылке добавляются utm_source=aflwebbot, utm_medium=referral, utm_campaign=ref_3015_general_rf_button.index__all_flight.map для аналитики источника.
  5. Шаг 5 — клик по ссылке — пользователь нажимает "Купить билет", ссылка открывается через target="_blank" в новой вкладке.
  6. Шаг 6 — завершение на сайте Аэрофлота — на внешнем сайте автоматически запускается поиск (autosearch=Y) и выводятся результаты.

Критерии приёмки:

  • Ссылка "Купить билет" присутствует только в popup прибытия.
  • URL содержит сегмент routes=<departure>.<YYYYMMDD>.<arrival>.
  • Если дата не выбрана, используется текущая дата (обнулённая до полуночи).
  • Ссылка открывается в новой вкладке (target="_blank").
  • К ссылке приклеены UTM-метки источника aflwebbot / ref_3015_general_rf_button.index__all_flight.map.

Примечание: В приложении нет внутреннего списка рейсов карты — это намеренный deep link на основной сайт Аэрофлота для бронирования.