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

318 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Пользовательские истории: Карта полётов
## 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 — очистка старых popup**`clearPopup()` удаляет предыдущие `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 на основной сайт Аэрофлота для бронирования.
---