# Пользовательские истории: Карта полётов
## 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 3–6).
- ✅ Прямые маршруты отображаются сплошной линией синего цвета (`#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 с названием города прибытия и ссылкой ``, ведущей на сайт Аэрофлота.
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=..`.
- ✅ Если дата не выбрана, используется текущая дата (обнулённая до полуночи).
- ✅ Ссылка открывается в новой вкладке (`target="_blank"`).
- ✅ К ссылке приклеены UTM-метки источника `aflwebbot` / `ref_3015_general_rf_button.index__all_flight.map`.
**Примечание:** В приложении нет внутреннего списка рейсов карты — это намеренный deep link на основной сайт Аэрофлота для бронирования.
---