# Пользовательские истории: Карта полётов ## 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 на основной сайт Аэрофлота для бронирования. ---