diff --git a/config-repo/pcp-gateway-service.yaml b/config-repo/pcp-gateway-service.yaml index 33c0611..1637751 100644 --- a/config-repo/pcp-gateway-service.yaml +++ b/config-repo/pcp-gateway-service.yaml @@ -57,6 +57,12 @@ spring: - Path=/api/pcp-complex/** filters: - StripPrefix=2 + - id: slots + uri: ${pcp.services.slots} + predicates: + - Path=/api/pcp-slots/** + filters: + - StripPrefix=2 # --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix. - id: stations uri: ${pcp.services.stations} diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index f256e83..acb036f 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -13,7 +13,7 @@ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). -_Обновлено: 2026-06-10_ +_Обновлено: 2026-06-11_ --- @@ -21,7 +21,7 @@ _Обновлено: 2026-06-10_ ### Карта и отображение - [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца. -- [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. +- [x] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. _Сделано: на вкладке «Редактор» (где рисуются полосы обзора) клик собирает полосы обзора отдельно от прочих объектов — если под точкой нет иного интерактивного объекта (заявки/маршрута/сброса/станции), а полос обзора несколько (наложение соседних витков), они отдаются списком в правую панель (`MapPickPanel`), как у прочих наложений; одиночная полоса — сразу карточка. Выбор/раскрытие элемента подсвечивает полосу на карте (`selectedObjectId`→`mapFocus.objectId`). Полоса обзора получила карточку (КА + виток): новые поля `revolution`/`spacecraftId` у swath-полигона, проброс в `Tgu2DMapSelection`, ветка swath в `MapInfoPanel`. Клик по заявке/маршруту поверх полос не засоряется полосами (полосы — только когда другого объекта под точкой нет). Файлы: `Tgu2DMapView.handleEditorClick`, `mapTypes.ts`, `mapSelectionTypes.ts`, `TguEditorTab` (сборка сцены), `EditorPanels.MapInfoPanel`._ _Доработка (2026-06-11): когда под точкой есть наложение ≥2 интерактивных объектов (напр. маршрут + заявка), в список выбора теперь добавляются и лежащие под кликом полосы обзора, чтобы их данные тоже были видны. Список упорядочен по приоритету (`editorHitRank`): маршрут съёмки → заявка → полоса обзора (маркеры/станции — перед маршрутом, сбросы — между заявкой и полосой). Одиночный клик по маршруту/заявке по-прежнему сразу выбирает объект (без списка), иначе любой клик в редакторе превращался бы в список из-за сплошного покрытия полосами._ - [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться. ### Группировка @@ -38,6 +38,7 @@ _Обновлено: 2026-06-10_ ### Бэкенд / инфраструктура - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). +- [ ] **#45** — Собрать native-образ сервиса (Kotlin/Native, `kompile`) и контейнер на его основе. Цель — быстрый старт и меньший footprint в сравнении с JVM-образом. Уточнить: какой сервис(ы) переводим первым, совместимость с рефлексией/AOT-конфигом (reachability metadata), сборку в CI и базовый образ контейнера. - [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:50–12:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene` → `visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. ### Проверка сервисов @@ -49,10 +50,17 @@ _Обновлено: 2026-06-10_ - [x] Шаги 1–7: репойнт фронта на источники (`tguApi.ts` → `/api/pcp-tgu` + `/test/tgu/*`; flight-line → `ballisticsApi.ts`; `editorApi.ts` modes → `/api/pcp-mission`) и снятие `ui-catch-all` из gateway. Перевод ошибок уже жил во фронте (`errorMessage` в `tguApi.ts`). Typecheck чист. - [ ] Шаг 8: вывод сервиса из эксплуатации (compose/helm/CI/config-repo/реестр `pcp.services.ui`) и удаление кода — **отложено** до проверки репойнта на живом стенде. Пока сервис задеплоен, но без маршрутов. -### Слоты (вкладка «Заявка» и «Редактор») -- [ ] **#41** — Запрос слотового покрытия по заявке. На вкладке «Заявка» (и в «Редакторе») запрашивать у slots-service слотовое покрытие заявки — какие слоты КА покрывают AOI заявки на её интервале. Источник — slots-service (минуя умирающий `pcp-ui-service`, ср. #40). Уточнить контракт ответа (слоты + связь с ячейками/покрытием заявки) и где он отрисовывается. -- [ ] **#42** — Бронирование слотов по заявке. С вкладки «Заявка» (и/или «Редактор») бронировать выбранные слоты под заявку через slots-service. Связано с показом забронированных слотов (#3) и пересчётом комплексного плана при бронировании (#6). Уточнить: единичное/пакетное бронирование, идемпотентность, что возвращается после брони. -- [ ] **#43** — Уточнение статуса слота. На вкладке «Заявка» (и «Редактор») показывать/обновлять актуальный статус слота (свободен / забронирован / занят по заложенному плану). Учитывать, что слоты занимаются по `LAID_IN`, а не по `ACCEPT` (ср. #10). Источник статусов — slots-service; согласовать с #41/#42. +### Слоты (вкладка «Слоты») +- [x] **#41** — Запрос слотового покрытия по заявке. _Сделано (2026-06-11): добавлена отдельная вкладка «Слоты» (вместо размещения на «Заявке»/«Редакторе» — операции со слотами объёмные, их собрали в одном рабочем месте). Оператор выбирает заявку, интервал и фильтры (ветвь витка, признак «Покрытие») и жмёт «Рассчитать слоты» → слоты-кандидаты от slots-service напрямую через gateway (`GET /api/pcp-slots/api/slots/request-cover`, минуя умирающий `pcp-ui-service`, ср. #40). Результат — таблица (КА, виток/ветвь, слот/цикл, время, крен, статус) и отрисовка контуров на 2D-карте поверх контура заявки; клик по строке центрирует карту и подсвечивает контур. Новый роут gateway `id: slots` (`/api/pcp-slots/**`, StripPrefix=2 → slots-service). Файлы: `config-repo/pcp-gateway-service.yaml`, `api/slotsApi.ts`, `model/slotTypes.ts`, `features/tgu-slots/TguSlotsTab.tsx`, регистрация вкладки в `TguActiveTab`/`TguToolbar`/`TguPlanningPage`, стили `.tgu-slots-*`._ +- [x] **#42** — Бронирование слотов по заявке. _Сделано (2026-06-11): на вкладке «Слоты» чекбоксами выбираются слоты → «Забронировать» (пакетно, `POST /api/slots/booking/request` с `{requestId, slots:[{satelliteId, slotNum, cycle}]}`) и «Снять бронь» (`DELETE …/booking/request?requestId=`). После операции перечитываются брони заявки (`GET …/booking/by-request/{id}`), забронированные слоты получают бейдж «бронь» в таблице и отдельный цвет (слой `complexPlan`) на карте. Связано с показом забронированных слотов (#3) и пересчётом комплексного плана при бронировании (#6) — последнее пока на бэкенде не триггерится. Уточнить при необходимости идемпотентность повторной брони._ +- [x] **Фильтр отображения слотов по суткам** _(2026-06-11): на вкладке «Слоты» добавлен ползунок-фильтр «Сутки» — тот же двуручейный селектор, что «Витки» на «Редакторе» (классы `map-pass-selector*`), наложенный на карту. Слоты группируются по локальным суткам начала (`tn`); выбранный диапазон суток сужает и таблицу, и сцену карты (кандидаты), «Выбрать все» работает по видимым слотам. Новый расчёт раскрывает фильтр на весь диапазон. Клик по слоту на карте теперь не только подсвечивает строку, но и подскроливает её в зону видимости таблицы. Файлы: `features/tgu-slots/SlotDaySelector.tsx` (новый), `features/tgu-slots/TguSlotsTab.tsx`._ + - _Доп. (2026-06-11): подсветка активной строки сделана явной (заливка + левый акцентный бар + bold). Скролл к строке — ручной `scrollBy` по `getBoundingClientRect` с центрированием и плавной анимацией: `scrollIntoView({block:"nearest"})` оказался ненадёжен (выбирал не тот скролл-предок / не двигался). Строка ищется по стабильному `data-slot-id`, а не по `ref` (тот при «переезде» между строками мог занулиться). Подсветку ведут только клики по слоту; клик по контуру заявки её не сбрасывает, клик по пустой карте — снимает._ +- [x] **Выбор заявки на карте (вместо списка)** _(2026-06-11): на вкладке «Слоты» убран выпадающий список заявок — он не масштабируется на тысячи заявок. Базово все заявки интервала отображаются на карте; клик по заявке (или по наложению нескольких — список с раскрытием) открывает плавающую панель-инспектор справа над картой (общий `MapObjectInspector`) с данными заявки и кнопкой «Выбрать». До выбора в тулбаре вместо списка — имя выбранной заявки или «—», а левая панель показывает фильтр «Заявки на интервал» (С/По + «Загрузить»/«Сегодня»/«7 дней»/«30 дней», как на «Карте») и подсказку «Кликните заявку на карте». После выбора заявки появляется кнопка «Сброс» справа над картой — возвращает окно в состояние «заявка не выбрана, слоты не показаны». Файлы: `features/tgu-slots/TguSlotsTab.tsx`, `features/tgu-map-2d/MapObjectInspector.tsx` (опц. `onSelectRequest` → кнопка «Выбрать» в карточке заявки), стили `.tgu-slots-request*`/`.tgu-slots-reset`/`.tgu-slots-inspector`/`.tgu-slots-requests-panel`._ +- [x] **Бронь не отражалась в UI + явная подсветка строки** _(2026-06-11): «не работает кнопка бронь» — диагностировано: POST `booking/request` бронирует корректно и в ответе `BookedSlotDTO` несёт верный `cycle`, но `GET booking/by-request/{id}` отдаёт **базовый** слот в другом cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), из-за чего ключ `sat:slotNum:cycle` не совпадал с рассчитанной строкой → бейдж «бронь» не появлялся, на карте — лишний полигон. Фронт-обход: после брони отмечаем забронированными ровно те рассчитанные слоты (с контуром), что вернул POST; by-request на горячем пути не перечитываем; cancel чистит локально. Заодно подсветка активной строки слота сделана явной (фон + левый акцентный бар + bold). Root-fix (на бэке, отдельно): `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime`/перечисление в `request-cover`) — иначе пред-существующие брони не коррелируют с расчётом. Файлы: `features/tgu-slots/TguSlotsTab.tsx`, стили `.tgu-slots-table tbody tr.is-active`._ +- [ ] **#43** — Уточнение статуса слота. На вкладке «Слоты» уже показывается базовый статус слота (`state`: свободен/бронь/занят) и бейдж брони по заявке. Осталось: подтягивать/обновлять актуальный статус занятости по **заложенному** плану (слоты занимаются по `LAID_IN`, а не по `ACCEPT`, ср. #10) и согласовать его источник со slots-service (real-time/после закладки), а также прокинуть статус на вкладки «Карта»/«Редактор» (#3). + +### Аутентификация и роли +- [ ] **#46** — Вывести роли на морду и связать их с аутентификацией. На фронте показывать роль(и) пользователя и разграничивать доступ к действиям/вкладкам по роли; источник ролей — аутентификация (токен/headless-auth, `@nstart/auth`). Уточнить: какие роли есть, где они приходят (claims токена / профиль), как маппятся на UI-разрешения (что скрывать/блокировать), и нужен ли серверный enforcement на стороне сервисов, а не только UI-гейтинг. ### Баги - [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». @@ -105,4 +113,5 @@ _Источник и рекомендации: [аудит-сервис-зада Добавил вручную: -- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают \ No newline at end of file +- ~~убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают~~ _Решено иначе (2026-06-11): кнопки не убрали, а починили — они не срабатывали из-за `setPointerCapture` на контейнере карты в `onPointerDown` (перехватывал жест, и клик по кнопке внутри карты не доходил). Добавлен гард: нажатие в области `.tgu-2d-map__zoom` не запускает панорамирование/захват, плюс `stopPropagation` на кнопках. Теперь «+/−» (зум) и «□» (вернуть обзор) работают на всех вкладках с картой._ +- [x] **Линейка на карте** _(2026-06-11): инструмент измерения расстояния на всех вкладках с картой (Карта, Заявки, Комплексный план, Редактор, Слоты — общий `Tgu2DMapView`). Иконка-линейка в правом нижнем углу рядом с «+/−/□», включается/выключается кликом (тумблер). В активном режиме клик по карте ставит вершины замера, рисуется пунктирная ломаная с подписями накопленного расстояния по дуге большого круга (haversine), в бейдже — итоговая длина; «Сброс» очищает замер, повторный клик по иконке выключает. Реализовано целиком в `Tgu2DMapView` (состояние `rulerActive`/`rulerPoints`), стили `.tgu-2d-map__ruler*`._ \ No newline at end of file diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts new file mode 100644 index 0000000..622b23b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -0,0 +1,115 @@ +import type { + BookedSlotDTO, + RequestSlotsParams, + SlotBookingRequest, + SlotDTO, +} from '../model/slotTypes' +import { apiFetch } from './apiFetch' + +// slots-service через gateway: /api/pcp-slots/** → StripPrefix=2 → slots-service /api/slots/** +const BASE = '/api/pcp-slots/api/slots' + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const text = await response.text() + if (!text) return `${fallback}: ${response.status}` + try { + const body = JSON.parse(text) as { error?: string; message?: string } + return body.error || body.message || text + } catch { + return text + } + } catch { + return `${fallback}: ${response.status}` + } +} + +/** + * Рассчитывает слоты покрытия заявки (slots-service: GET /api/slots/request-cover). + * @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца). + * + * @returns Список слотов-кандидатов с контурами для карты. + */ +export async function fetchRequestSlots(params: RequestSlotsParams): Promise { + const query = new URLSearchParams() + query.set('requestId', params.requestId) + query.set('timeStart', params.timeStart) + query.set('timeStop', params.timeStop) + if (params.revSign) query.set('revSign', params.revSign) + if (params.cov != null) query.set('cov', String(params.cov)) + if (params.sun != null) query.set('sun', String(params.sun)) + params.satellites?.forEach((id) => query.append('satellites', String(id))) + + const response = await apiFetch(`${BASE}/request-cover?${query}`, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось рассчитать слоты')) + } + + return response.json() as Promise +} + +/** + * Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request). + * @param requestId Идентификатор заявки. + * @param slots Список слотов (КА, номер слота, цикл). + * + * @returns Забронированные слоты. + */ +export async function bookRequestSlots( + requestId: string, + slots: SlotBookingRequest[], +): Promise { + const response = await apiFetch(`${BASE}/booking/request`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ requestId, slots }), + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось забронировать слоты')) + } + + return response.json() as Promise +} + +/** + * Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request). + * @param requestId Идентификатор заявки. + * + * @returns Сколько броней снято. + */ +export async function cancelRequestBooking(requestId: string): Promise { + const response = await apiFetch( + `${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`, + { method: 'DELETE', headers: { Accept: 'application/json' } }, + ) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось снять бронь')) + } + + const value = Number(await response.text()) + return Number.isFinite(value) ? value : 0 +} + +/** + * Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}). + * @param requestId Идентификатор заявки. + * + * @returns Список забронированных слотов с контурами. + */ +export async function fetchBookedSlotsByRequest(requestId: string): Promise { + const response = await apiFetch( + `${BASE}/booking/by-request/${encodeURIComponent(requestId)}`, + { headers: { Accept: 'application/json' } }, + ) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки')) + } + + return response.json() as Promise +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx index e0794dd..aba0256 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx @@ -672,6 +672,17 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP )} )} + + {selection.type === "polygon" && selection.layer === "swath" && ( +
+
{selection.name}
+
+ {selection.spacecraftId && (<>
КА
{selection.spacecraftId}
)} +
Виток
+
{selection.revolution ?? "—"}
+
+
+ )} ); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx index 59533fa..30081f5 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -755,7 +755,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planEnd: toLocalIso(selectedPlan.endMs) }); setSurveyRoute(savedRoute); - void refreshDraftModes(draftId); + // Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId + // перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый + // контур мелькает на карте, пока не сделаешь что-нибудь ещё. + await refreshDraftModes(draftId); } catch (error) { setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); return; @@ -794,6 +797,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setTarget(undefined); setTargetPickActive(false); setSelectedShootPass(undefined); + // Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика. + setSurveyRoute(undefined); + setRouteWindow(undefined); }; const stationNameById = useCallback( @@ -852,8 +858,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); savedDropId = savedDrop.id; savedDropVolumeMb = savedDrop.volumeMb; - // Перечитываем включения DRAFT (источник истины) и ревалидируем. - void refreshDraftModes(draftId); + // Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия + // editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились. + await refreshDraftModes(draftId); } catch (error) { setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); return; @@ -1630,6 +1637,8 @@ function buildEditorMapScene( kind: "swath", layer: "swath", points: [...leftEdge, ...rightEdge], + spacecraftId, + revolution, zIndex: zIndex++ }); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx index 0861b75..5205a93 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx @@ -27,6 +27,8 @@ type MapObjectInspectorProps = { requestItems?: RequestMapItem[]; /** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */ priorityCells?: PriorityCell[]; + /** Если задан — в карточке заявки появляется кнопка «Выбрать» (вкладка «Слоты»). */ + onSelectRequest?: (requestId: string) => void; }; export function MapObjectInspector({ @@ -37,6 +39,7 @@ export function MapObjectInspector({ modes = [], requestItems = [], priorityCells = [], + onSelectRequest, }: MapObjectInspectorProps) { const renderCard = (selection: Tgu2DMapSelection): ReactNode => { if (selection.type === "marker") { @@ -49,7 +52,7 @@ export function MapObjectInspector({ case "complexPlan": return ; case "requests": - return ; + return ; case "heatmap": return ; case "planWorks": @@ -129,7 +132,15 @@ function ModeCard({ selection, modes }: { selection: PolygonSelection; modes: Co ); } -function RequestCard({ selection, requestItems }: { selection: PolygonSelection; requestItems: RequestMapItem[] }) { +function RequestCard({ + selection, + requestItems, + onSelectRequest, +}: { + selection: PolygonSelection; + requestItems: RequestMapItem[]; + onSelectRequest?: (requestId: string) => void; +}) { const item = selection.requestId ? requestItems.find((r) => r.id === selection.requestId) : undefined; if (!item) return
{selection.name || selection.id}
; return ( @@ -156,6 +167,17 @@ function RequestCard({ selection, requestItems }: { selection: PolygonSelection;
Приоритет передачи
{item.highPriorityTransmit ? "Да" : "Нет"}
+ {onSelectRequest && ( +
+ +
+ )} ); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index 67f3b11..b0c30e5 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -112,6 +112,10 @@ export function Tgu2DMapView({ const [tooltip, setTooltip] = useState(); // Гео-координаты под курсором — показываем по центру внизу карты. const [cursorGeo, setCursorGeo] = useState(); + // Линейка: включается кнопкой в правом нижнем углу; в активном режиме клик по карте + // добавляет вершину замера, а наложение рисует ломаную с накопленным расстоянием. + const [rulerActive, setRulerActive] = useState(false); + const [rulerPoints, setRulerPoints] = useState([]); const selectedPolygonRef = useRef(undefined); const selectedLineRef = useRef(undefined); const activePolygons = useMemo( @@ -256,6 +260,10 @@ export function Tgu2DMapView({ ); const onPointerDown = (event: React.PointerEvent) => { + // Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание + // и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри + // карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали). + if ((event.target as HTMLElement).closest("button")) return; const screenPoint = screenPointFromEvent(event); hasDraggedRef.current = false; dragRef.current = { @@ -303,6 +311,14 @@ export function Tgu2DMapView({ wrapperRef.current?.releasePointerCapture(event.pointerId); }; + // Линейка: переключаем режим; при выключении замер очищаем. + const toggleRuler = () => { + setRulerActive((active) => { + if (active) setRulerPoints([]); + return !active; + }); + }; + // Применить выбор конкретного объекта (из меню выбора либо при единственном попадании): // выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect. const applyEditorHit = useCallback((hit: EditorHit) => { @@ -313,16 +329,24 @@ export function Tgu2DMapView({ }, [onObjectSelect]); // Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки, - // сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу; + // сбросы, станции). 0 → смотрим на полосы обзора; 1 → выбираем сразу; // ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели). const handleEditorClick = useCallback((screenPoint: ScreenPoint) => { const geoPoint = projection.unproject(screenPoint); const hits: EditorHit[] = []; + // Полосы обзора собираем отдельно: они почти всегда лежат под кликом и не должны + // превращать выбор заявки/маршрута в список. Их список показываем, только когда под точкой + // нет другого интерактивного объекта, а самих полос несколько (наложение соседних витков). + const swathHits: EditorHit[] = []; const marker = hitTestMarkers(activeMarkers, screenPoint, projection); if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { + if (polygon.layer === "swath") { + swathHits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); + continue; + } if ( polygon.layer !== "requests" && polygon.layer !== "planWorks" && @@ -340,19 +364,38 @@ export function Tgu2DMapView({ } if (hits.length >= 2 && onObjectsAtPoint) { - // Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). + // Несколько интерактивных объектов — показываем список. В него добавляем и полосы обзора, + // лежащие под точкой, чтобы их данные тоже были видны. Порядок: маршрут → заявка → полоса. + const combined = [...hits, ...swathHits].sort((a, b) => editorHitRank(a) - editorHitRank(b)); + // Внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). selectedPolygonRef.current = undefined; selectedMarkerRef.current = undefined; selectedLineRef.current = undefined; - onObjectsAtPoint(hits.map((hit) => hit.selection)); + onObjectsAtPoint(combined.map((hit) => hit.selection)); return; } - if (hits.length >= 1) { + if (hits.length === 1) { + // Единственный интерактивный объект — выбираем сразу (полосы обзора лежат под кликом почти + // всегда, поэтому ради одного объекта список не показываем — иначе любой клик стал бы списком). applyEditorHit(hits[0]); return; } - // Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор. + // Другого интерактивного объекта под точкой нет — работаем с полосами обзора: + // ≥2 (наложение витков) → список справа; ровно одна → выбираем её. + if (swathHits.length >= 2 && onObjectsAtPoint) { + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectsAtPoint(swathHits.map((hit) => hit.selection)); + return; + } + if (swathHits.length === 1) { + applyEditorHit(swathHits[0]); + return; + } + + // Совсем ничего интерактивного — выбираем верхний прочий полигон либо снимаем выбор. const fallback = hitTestPolygons(polygonIndex, geoPoint); selectedPolygonRef.current = fallback; selectedMarkerRef.current = undefined; @@ -363,6 +406,11 @@ export function Tgu2DMapView({ const onClick = (event: React.MouseEvent) => { if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); + if (rulerActive) { + // Режим линейки: клик добавляет вершину замера, выбор объектов не трогаем. + setRulerPoints((points) => [...points, projection.unproject(screenPoint)]); + return; + } if (drawMode) { onDrawAddPoint?.(projection.unproject(screenPoint)); return; @@ -449,8 +497,22 @@ export function Tgu2DMapView({ })); }; - const selectedPolygon = selectedPolygonRef.current; - const selectedMarker = selectedMarkerRef.current; + // Внутренние ref-выделения (клик по карте) могут указывать на объект, которого уже нет в сцене — + // например, контур съёмки, скрытый при входе в режим правки (sceneModes исключает редактируемый + // режим). SVG-оверлей рисуется на каждый рендер из ref, поэтому без сверки с живой сценой старая + // подсветка «висит» поверх карты, пока не кликнешь в другое место. Рисуем только то, что есть в сцене. + const selectedPolygon = + selectedPolygonRef.current && activePolygons.some((p) => p.id === selectedPolygonRef.current!.id) + ? selectedPolygonRef.current + : undefined; + const selectedLine = + selectedLineRef.current && scene.lines.some((l) => l.id === selectedLineRef.current!.id) + ? selectedLineRef.current + : undefined; + const selectedMarker = + selectedMarkerRef.current && activeMarkers.some((m) => m.id === selectedMarkerRef.current!.id) + ? selectedMarkerRef.current + : undefined; const externalSelectedPolygon = useMemo(() => { const id = selectedRequestPolygonId ?? selectedObjectId; return id ? activePolygons.find((p) => p.id === id) : undefined; @@ -461,7 +523,7 @@ export function Tgu2DMapView({ [selectedObjectId, scene.lines] ); // Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId). - const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]); + const overlayLines = uniqueLines([selectedLine, externalSelectedLine]); const externalHoveredPolygon = useMemo( () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, [hoveredRequestPolygonId, activePolygons] @@ -469,9 +531,21 @@ export function Tgu2DMapView({ const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]); const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); + // Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга. + const rulerCumKm = useMemo(() => { + const out: number[] = []; + let acc = 0; + rulerPoints.forEach((point, i) => { + if (i > 0) acc += haversineKm(rulerPoints[i - 1], point); + out.push(acc); + }); + return out; + }, [rulerPoints]); + const rulerTotalKm = rulerCumKm.length > 0 ? rulerCumKm[rulerCumKm.length - 1] : 0; + return (
); })()} + {rulerPoints.length > 0 && (() => { + const pts = unwrapX(rulerPoints.map((point) => projection.project(point)), projection.worldSize); + return ( + + {pts.length >= 2 && ( + `${p.x},${p.y}`).join(" ")} + fill="none" + /> + )} + {pts.map((p, i) => ( + + + + {i === 0 ? "0" : formatDistanceKm(rulerCumKm[i])} + + + ))} + + ); + })()} {tooltip && (
@@ -588,15 +684,69 @@ export function Tgu2DMapView({ )} {cursorGeo &&
{formatLatLon(cursorGeo)}
}
© OpenStreetMap contributors · © CARTO
+ {rulerActive && ( +
+ Линейка + + {rulerPoints.length < 2 ? "кликайте по карте" : formatDistanceKm(rulerTotalKm)} + + {rulerPoints.length > 0 && ( + + )} +
+ )}
- - - + + + +
); } +/** Расстояние между двумя гео-точками по дуге большого круга (haversine), км. */ +function haversineKm(a: GeoPoint, b: GeoPoint): number { + const R = 6371; + const toRad = Math.PI / 180; + const dLat = (b.lat - a.lat) * toRad; + const dLon = (b.lon - a.lon) * toRad; + const lat1 = a.lat * toRad; + const lat2 = b.lat * toRad; + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Человекочитаемое расстояние: метры до 1 км, иначе километры. */ +function formatDistanceKm(km: number): string { + if (km < 1) return `${(km * 1000).toFixed(0)} м`; + if (km < 100) return `${km.toFixed(2)} км`; + return `${km.toFixed(1)} км`; +} + /** Подпись координат под курсором: широта/долгота с полушариями. */ function formatLatLon(point: GeoPoint): string { const lat = point.lat; @@ -695,6 +845,26 @@ type EditorHit = | { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection } | { kind: "line"; line: MapLine; selection: Tgu2DMapSelection }; +// Порядок объектов в списке выбора под точкой: сначала маршрут съёмки, затем заявка, +// в конце — полоса обзора (как фон). Остальные объекты — между ними. +function editorHitRank(hit: EditorHit): number { + if (hit.kind === "marker") return 0; // станции / маркеры КА + if (hit.kind === "line") return 3; // сбросы + switch (hit.polygon.layer) { + case "planWorks": // маршрут съёмки + case "complexPlan": + return 1; + case "requests": // заявка на съёмку + return 2; + case "heatmap": + return 4; + case "swath": // полоса обзора — последней + return 5; + default: + return 6; + } +} + function markerToSelection(marker: MapMarker): Tgu2DMapSelection { return { type: "marker", @@ -722,6 +892,7 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { surveyType: polygon.surveyType, status: polygon.status, modeId: polygon.modeId, + revolution: polygon.revolution, importance: polygon.importance, cellNum: polygon.cellNum }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts index 31463fb..e64499f 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -24,6 +24,7 @@ export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): const hits: MapPolygon[] = []; for (const entry of index.entries) { + if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён const inBbox = bboxContainsPoint(entry.bbox, point) || bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) || @@ -46,6 +47,7 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma let selected: MapPolygon | undefined; for (const entry of index.entries) { + if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён // Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped. const inBbox = bboxContainsPoint(entry.bbox, point) || diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts index d774a65..f546e81 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -13,6 +13,8 @@ export type Tgu2DMapSelection = status?: TguPlanStatus; /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ modeId?: number; + /** Номер витка (layer "swath") — для карточки полосы обзора. */ + revolution?: number; /** Важность ячейки тепловой карты (layer "heatmap"). */ importance?: number; /** Номер ячейки сетки заданий (layer "heatmap"). */ diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts index 6c139d8..82dd7b4 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts @@ -34,10 +34,14 @@ export type MapPolygon = { surveyType?: string; /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ modeId?: number; + /** Номер витка (layer "swath") — для карточки полосы обзора. */ + revolution?: number; /** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */ importance?: number; /** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */ cellNum?: number; + /** false — контур рисуется, но исключён из hit-теста/выбора (напр. контур заявки на вкладке «Слоты»). */ + pickable?: boolean; zIndex: number; }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx index cb66811..e88b425 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx @@ -14,6 +14,7 @@ import { ConstellationTab } from "../constellation/ConstellationTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; +import { TguSlotsTab } from "../tgu-slots/TguSlotsTab"; import { abandonDraft, createDraft, @@ -587,6 +588,12 @@ export function TguPlanningPage() {
)} + {visitedTabs.has("slots") && ( +
+ +
+ )} + {visitedTabs.has("constellation") && (
diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx index 56cd096..3311db0 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx @@ -26,6 +26,11 @@ export function TguToolbar({ onApply, onQuickRange }: TguToolbarProps) { + // Глобальный диапазон «С … По» относится только к вкладке «Планы». Остальные вкладки имеют свой + // источник интервала: «Карта» и «Заявки» — фильтр дат в левой панели, «Комплексный план» — свой + // диапазон внутри вкладки, «Редактор» отражает выбранный на «Планах» план (его окно задаётся + // планом), «Группировка» диапазоном не пользуется. Поэтому панель показываем только на «Планах». + const showRange = activeTab === "timeline"; return (
@@ -83,6 +88,15 @@ export function TguToolbar({ > Заявки +
- - - - - - - -
+ + {showRange && ( +
+ + + + + + + +
+ )} {error &&
{error}
} ); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx new file mode 100644 index 0000000..915616e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx @@ -0,0 +1,115 @@ +import { useRef } from "react"; + +/** Сутки расчёта слотов: индекс (1-based), начало суток (мс, локально), метка ДД.ММ и число слотов. */ +export type SlotDay = { index: number; dayMs: number; label: string; count: number }; + +type SlotDaySelectorProps = { + days: SlotDay[]; + fromIndex: number; + toIndex: number; + onFromChange: (idx: number) => void; + onToChange: (idx: number) => void; +}; + +/** + * Двуручейный фильтр отображения слотов по суткам — визуально тот же ползунок, что «Витки» на + * вкладке «Редактор» (классы map-pass-selector*). Накладывается на карту; сужает и таблицу, и сцену. + */ +export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToChange }: SlotDaySelectorProps) { + const trackRef = useRef(null); + const n = days.length; + if (n === 0) return null; + + const fromDay = days[Math.max(0, Math.min(fromIndex - 1, n - 1))]; + const toDay = days[Math.max(0, Math.min(toIndex - 1, n - 1))]; + const isFullRange = fromIndex === 1 && toIndex === n; + const countInRange = days + .slice(fromIndex - 1, toIndex) + .reduce((sum, day) => sum + day.count, 0); + + const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100); + const fromPct = pct(fromIndex); + const toPct = pct(toIndex); + + const indexAt = (clientX: number): number => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || n <= 1) return 1; + return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1; + }; + + const makeHandlers = (isFrom: boolean) => ({ + onPointerDown(e: React.PointerEvent) { + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }, + onPointerMove(e: React.PointerEvent) { + if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return; + const idx = indexAt(e.clientX); + if (isFrom) { + onFromChange(Math.max(1, Math.min(idx, toIndex))); + } else { + onToChange(Math.min(n, Math.max(idx, fromIndex))); + } + }, + onPointerUp(e: React.PointerEvent) { + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + }, + }); + + const onTrackPointerDown = (e: React.PointerEvent) => { + const idx = indexAt(e.clientX); + if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) { + onFromChange(Math.max(1, Math.min(idx, toIndex))); + } else { + onToChange(Math.min(n, Math.max(idx, fromIndex))); + } + }; + + return ( +
+
+ Сутки + + {fromDay.label === toDay.label ? fromDay.label : `${fromDay.label}–${toDay.label}`} + + слотов {countInRange} + {!isFullRange && ( + + )} +
+ +
+
+
+ {days.map((day) => ( +
= fromIndex && day.index <= toIndex ? " is-active" : ""}`} + style={{ left: `${pct(day.index)}%` }} + /> + ))} +
+
+
+
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx new file mode 100644 index 0000000..0819082 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx @@ -0,0 +1,48 @@ +import type { SlotDTO } from "../../model/slotTypes"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; + +/** Один слот в меню выбора: id полигона на карте, сам слот и признак брони. */ +export type SlotPick = { id: string; slot: SlotDTO; booked: boolean }; + +type SlotPickMenuProps = { + picks: SlotPick[]; + activeId?: string; + onPick: (id: string) => void; + onClose: () => void; +}; + +/** + * Меню выбора слота при плотном наложении: клик по карте, под которым лежит несколько слотов, + * раскрывает список (КА · слот · виток/ветвь · время), выбор подсвечивает строку в таблице. + */ +export function SlotPickMenu({ picks, activeId, onPick, onClose }: SlotPickMenuProps) { + return ( + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx new file mode 100644 index 0000000..c5433ff --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -0,0 +1,801 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import { + bookRequestSlots, + cancelRequestBooking, + fetchBookedSlotsByRequest, + fetchRequestSlots +} from "../../api/slotsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"; +import type { TimelineRange } from "../../model/timelineTypes"; +import { DateTime24Field } from "../../components/timeFields"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import type { GeoPoint, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; +import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; +import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Начало локальных суток момента (мс) — для группировки слотов по суткам. */ +function dayStartMs(ms: number): number { + const d = new Date(ms); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** Метка суток ДД.ММ (локально). */ +function dayLabel(ms: number): string { + const d = new Date(ms); + return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +type TguSlotsTabProps = { + /** Диапазон вкладки «Планы» — начальный интервал отбора заявок и расчёта слотов. */ + range: TimelineRange; +}; + +/** Ключ слота: КА + номер слота + цикл — уникален в пределах расчёта и совпадает с ключом брони. */ +function slotKey(slot: { satelliteId: number; slotNumber: number; cycle: number }): string { + return `${slot.satelliteId}:${slot.slotNumber}:${slot.cycle}`; +} + +export function TguSlotsTab({ range }: TguSlotsTabProps) { + // Заявки интервала (показываются на карте, выбираются кликом) и выбранная заявка. + const [requestItems, setRequestItems] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(false); + const [requestsLoaded, setRequestsLoaded] = useState(false); + const [requestsTotal, setRequestsTotal] = useState(0); + const [requestsTruncated, setRequestsTruncated] = useState(false); + const [requestsError, setRequestsError] = useState(); + const [selectedRequestId, setSelectedRequestId] = useState(""); + + // Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»). + const [reqFromValue, setReqFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [reqToValue, setReqToValue] = useState(() => toLocalInputValue(range.toMs)); + + // Интервал и фильтры расчёта слотов выбранной заявки. + const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); + const [revSign, setRevSign] = useState<"" | RevolutionSign>(""); + const [withCoverage, setWithCoverage] = useState(false); + + // Результаты: слоты-кандидаты и уже забронированные слоты заявки. + const [slots, setSlots] = useState([]); + const [bookedSlots, setBookedSlots] = useState([]); + const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); + + // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта. + const [dayFrom, setDayFrom] = useState(1); + const [dayTo, setDayTo] = useState(1); + + const [calculating, setCalculating] = useState(false); + const [booking, setBooking] = useState(false); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + // Подсветка строки/контура по клику на карте и центрирование на слоте. + const [activeSlotId, setActiveSlotId] = useState(); + const [focusPoint, setFocusPoint] = useState(); + const focusTokenRef = useRef(0); + const [focusToken, setFocusToken] = useState(0); + // Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости. + const listRef = useRef(null); + + // Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки). + const [mapHits, setMapHits] = useState([]); + const [activeHitId, setActiveHitId] = useState(); + + // Меню выбора слота при плотном наложении (несколько слотов под точкой клика). + const [slotPicks, setSlotPicks] = useState([]); + + const hasRequest = !!selectedRequestId; + + const selectedRequest = useMemo( + () => requestItems.find((item) => item.id === selectedRequestId), + [requestItems, selectedRequestId] + ); + + const parsedRange = useMemo( + () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }), + [fromValue, toValue] + ); + const rangeInvalid = + !Number.isFinite(parsedRange.fromMs) || + !Number.isFinite(parsedRange.toMs) || + parsedRange.fromMs >= parsedRange.toMs; + + // Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте. + const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]); + + // Слот по ключу — чтобы из выбора на карте (id "slot:key"/"booked:key") восстановить полные данные. + const slotByKey = useMemo(() => { + const map = new Map(); + for (const slot of slots) map.set(slotKey(slot), slot); + for (const slot of bookedSlots) if (!map.has(slotKey(slot))) map.set(slotKey(slot), slot); + return map; + }, [slots, bookedSlots]); + + // Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения). + const days = useMemo(() => { + const buckets = new Map(); + for (const slot of slots) { + const ms = parseLocal(slot.tn); + if (!Number.isFinite(ms)) continue; + const start = dayStartMs(ms); + buckets.set(start, (buckets.get(start) ?? 0) + 1); + } + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) })); + }, [slots]); + + // Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон. + useEffect(() => { + setDayFrom(1); + setDayTo(days.length || 1); + }, [days]); + + const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); + const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); + + // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте. + const filteredSlots = useMemo(() => { + if (days.length === 0) return slots; + const fromMs = days[effDayFrom - 1].dayMs; + const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; + return slots.filter((slot) => { + const ms = parseLocal(slot.tn); + return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; + }); + }, [slots, days, effDayFrom, effDayTo]); + + // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. + const loadRequests = useCallback(async (fromInput: string, toInput: string) => { + const fromMs = parseLocal(fromInput); + const toMs = parseLocal(toInput); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + setRequestsLoading(true); + setRequestsError(undefined); + try { + const result = await fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) }); + setRequestItems(result.items); + setRequestsTotal(result.totalItems); + setRequestsTruncated(result.truncated); + setRequestsLoaded(true); + } catch (caught) { + setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки."); + } finally { + setRequestsLoading(false); + } + }, []); + + // Заявки за диапазон вкладки — грузим на входе и при смене диапазона. + useEffect(() => { + const fromInput = toLocalInputValue(range.fromMs); + const toInput = toLocalInputValue(range.toMs); + setReqFromValue(fromInput); + setReqToValue(toInput); + void loadRequests(fromInput, toInput); + }, [range.fromMs, range.toMs, loadRequests]); + + // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы. + // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой + // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. + useEffect(() => { + if (!activeSlotId) return; + const container = listRef.current; + const row = container?.querySelector(`[data-slot-id="${activeSlotId}"]`); + if (!container || !row) return; + const cRect = container.getBoundingClientRect(); + const rRect = row.getBoundingClientRect(); + if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна + const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2); + container.scrollBy({ top: delta, behavior: "smooth" }); + }, [activeSlotId]); + + const focusOn = useCallback((point?: GeoPoint) => { + if (!point) return; + setFocusPoint(point); + focusTokenRef.current += 1; + setFocusToken(focusTokenRef.current); + }, []); + + // Загружает брони выбранной заявки (для бейджей и слоя карты). + const loadBooked = useCallback(async (requestId: string) => { + try { + const booked = await fetchBookedSlotsByRequest(requestId); + setBookedSlots(booked); + } catch (caught) { + setBookedSlots([]); + setError(caught instanceof Error ? caught.message : "Не удалось загрузить брони заявки."); + } + }, []); + + // Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони. + useEffect(() => { + setSlots([]); + setSelectedKeys(new Set()); + setBookedSlots([]); + setNotice(undefined); + setError(undefined); + setActiveSlotId(undefined); + if (selectedRequestId) void loadBooked(selectedRequestId); + }, [selectedRequestId, loadBooked]); + + const reqFromMs = parseLocal(reqFromValue); + const reqToMs = parseLocal(reqToValue); + const reqRangeInvalid = + !Number.isFinite(reqFromMs) || !Number.isFinite(reqToMs) || reqFromMs >= reqToMs; + + const applyRequests = () => void loadRequests(reqFromValue, reqToValue); + const setQuickRange = (fromMs: number, toMs: number) => { + const fromInput = toLocalInputValue(fromMs); + const toInput = toLocalInputValue(toMs); + setReqFromValue(fromInput); + setReqToValue(toInput); + void loadRequests(fromInput, toInput); + }; + const quickToday = () => { + const now = new Date(); + const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + setQuickRange(fromMs, fromMs + DAY_MS); + }; + const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS); + const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS); + + const calculate = async () => { + if (!selectedRequestId || rangeInvalid || calculating) return; + setCalculating(true); + setError(undefined); + setNotice(undefined); + try { + const result = await fetchRequestSlots({ + requestId: selectedRequestId, + timeStart: toLocalIso(parsedRange.fromMs), + timeStop: toLocalIso(parsedRange.toMs), + revSign: revSign || undefined, + cov: withCoverage + }); + setSlots(result); + setSelectedKeys(new Set()); + setNotice( + result.length > 0 + ? `Найдено слотов: ${result.length}.` + : "Для выбранного интервала слотов покрытия не найдено." + ); + if (result.length > 0) { + focusOn({ lat: result[0].latitude, lon: result[0].longitude }); + } + } catch (caught) { + setSlots([]); + setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты."); + } finally { + setCalculating(false); + } + }; + + const book = async () => { + if (!selectedRequestId || booking || selectedKeys.size === 0) return; + const toBook = slots + .filter((slot) => selectedKeys.has(slotKey(slot))) + .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); + if (toBook.length === 0) return; + setBooking(true); + setError(undefined); + setNotice(undefined); + try { + const booked = await bookRequestSlots(selectedRequestId, toBook); + // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle + // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле + // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и + // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты + // (с контуром), что вернул POST, не перечитывая by-request. + const bookedSet = new Set(booked.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); + const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot))); + setBookedSlots((prev) => { + const merged = new Map(prev.map((slot) => [slotKey(slot), slot])); + for (const slot of justBooked) merged.set(slotKey(slot), slot); + return Array.from(merged.values()); + }); + setSelectedKeys(new Set()); + setNotice(`Забронировано слотов: ${booked.length}.`); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты."); + } finally { + setBooking(false); + } + }; + + const cancelBooking = async () => { + if (!selectedRequestId || booking || bookedSlots.length === 0) return; + setBooking(true); + setError(undefined); + setNotice(undefined); + try { + const cancelled = await cancelRequestBooking(selectedRequestId); + // Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()). + setBookedSlots([]); + setNotice(`Снято броней: ${cancelled}.`); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось снять бронь."); + } finally { + setBooking(false); + } + }; + + const toggleSlot = (key: string) => { + setSelectedKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // «Выбрать все» работает по видимым (отфильтрованным) слотам. + const allSelected = + filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); + const toggleAll = () => { + setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey))); + }; + + const selectRow = (slot: SlotDTO) => { + const id = `slot:${slotKey(slot)}`; + setActiveSlotId(id); + focusOn({ lat: slot.latitude, lon: slot.longitude }); + }; + + // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов. + const chooseRequest = useCallback((requestId: string) => { + setSelectedRequestId(requestId); + setMapHits([]); + setActiveHitId(undefined); + }, []); + + // Сброс окна к состоянию «заявка не выбрана, слоты не показаны». + const resetSelection = useCallback(() => { + setSelectedRequestId(""); + setMapHits([]); + setActiveHitId(undefined); + setActiveSlotId(undefined); + }, []); + + // Сцена карты. До выбора заявки — все заявки интервала (для выбора кликом). После выбора — + // контур заявки (слой requests), слоты-кандидаты (swath) и брони (complexPlan). + const scene = useMemo(() => { + const polygons: MapPolygon[] = []; + let z = 0; + + if (!hasRequest) { + for (const item of requestItems) { + if (!item.geometry) continue; + for (const ring of parseWktPolygon(item.geometry)) { + if (ring.length < 3) continue; + polygons.push({ + id: `request:${item.id}`, + name: item.name, + kind: "request", + layer: "requests", + points: ring, + requestId: item.id, + zIndex: z++ + }); + } + } + return { polygons, lines: [], markers: [] }; + } + + if (selectedRequest?.geometry) { + for (const ring of parseWktPolygon(selectedRequest.geometry)) { + if (ring.length < 3) continue; + polygons.push({ + id: `request:${selectedRequest.id}`, + name: selectedRequest.name, + kind: "request", + layer: "requests", + points: ring, + requestId: selectedRequest.id, + // Контур заявки рисуем, но из выбора исключаем — иначе он «съедает» меню выбора слотов + // (как единственный requests-hit под точкой) и любой клик внутри выбирал бы заявку. + pickable: false, + zIndex: z++ + }); + } + } + for (const slot of filteredSlots) { + const key = slotKey(slot); + for (const ring of parseWktPolygon(slot.contour)) { + if (ring.length < 3) continue; + polygons.push({ + id: `slot:${key}`, + name: `КА ${slot.satelliteId} · слот ${slot.slotNumber}`, + kind: bookedKeys.has(key) ? "complexPlan" : "swath", + layer: bookedKeys.has(key) ? "complexPlan" : "swath", + points: ring, + spacecraftId: String(slot.satelliteId), + revolution: slot.revolution, + zIndex: z++ + }); + } + } + // Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно. + const slotKeysShown = new Set(slots.map(slotKey)); + for (const slot of bookedSlots) { + const key = slotKey(slot); + if (slotKeysShown.has(key)) continue; + for (const ring of parseWktPolygon(slot.contour)) { + if (ring.length < 3) continue; + polygons.push({ + id: `booked:${key}`, + name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`, + kind: "complexPlan", + layer: "complexPlan", + points: ring, + spacecraftId: String(slot.satelliteId), + revolution: slot.revolution, + zIndex: z++ + }); + } + } + return { polygons, lines: [], markers: [] }; + }, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys]); + + const layers = useMemo( + () => ({ + tracks: false, + swath: true, + planWorks: false, + complexPlan: true, + heatmap: false, + stations: false, + spacecraftMarkers: false, + requests: true + }), + [] + ); + + // Клик по карте. С выбранной заявкой — подсветка слота (синхрон со строкой). Без заявки — + // открываем инспектор заявки (одиночный клик) для выбора. + const handleObjectSelect = useCallback( + (selection?: Tgu2DMapSelection) => { + if (hasRequest) { + // Одиночное попадание — подсветку строки ведём только по клику на слот; клик по пустому + // месту снимает подсветку. Меню выбора (наложение) при этом закрываем. + setSlotPicks([]); + const id = selection?.id; + if (!selection) setActiveSlotId(undefined); + else if (id && (id.startsWith("slot:") || id.startsWith("booked:"))) setActiveSlotId(id); + return; + } + if (selection && selection.type === "polygon" && selection.layer === "requests") { + setMapHits([selection]); + setActiveHitId(selection.id); + } else { + setMapHits([]); + setActiveHitId(undefined); + } + }, + [hasRequest] + ); + + // Наложение объектов под точкой. С выбранной заявкой — меню выбора слота (плотные наложения); + // без заявки — список заявок в инспекторе с раскрытием нужной. + const handleObjectsAtPoint = useCallback( + (selections: Tgu2DMapSelection[]) => { + if (hasRequest) { + const seen = new Set(); + const picks: SlotPick[] = []; + for (const sel of selections) { + if (sel.type !== "polygon") continue; + if (!sel.id.startsWith("slot:") && !sel.id.startsWith("booked:")) continue; + if (seen.has(sel.id)) continue; + const key = sel.id.slice(sel.id.indexOf(":") + 1); + const slot = slotByKey.get(key); + if (!slot) continue; + seen.add(sel.id); + picks.push({ id: sel.id, slot, booked: bookedKeys.has(key) }); + } + setSlotPicks(picks); + return; + } + const requests = selections.filter((s) => s.type === "polygon" && s.layer === "requests"); + const hits = requests.length > 0 ? requests : selections; + setMapHits(hits); + setActiveHitId(hits[0]?.id); + }, + [hasRequest, slotByKey, bookedKeys] + ); + + // Выбор слота из меню наложения — подсвечиваем строку, но меню оставляем открытым + // (с пометкой активного пункта), чтобы можно было перебрать наложенные слоты; закрыть — крестиком. + const pickSlot = useCallback((id: string) => { + setActiveSlotId(id); + }, []); + + const closeSlotPick = useCallback(() => setSlotPicks([]), []); + + const toggleHit = useCallback((selection: Tgu2DMapSelection) => { + setActiveHitId((current) => (current === selection.id ? undefined : selection.id)); + }, []); + + const closeInspector = useCallback(() => { + setMapHits([]); + setActiveHitId(undefined); + }, []); + + const canCalculate = hasRequest && !rangeInvalid && !calculating; + const overlayMessage = calculating + ? "Расчёт слотов…" + : !hasRequest + ? requestsLoading + ? "Загрузка заявок…" + : undefined + : slots.length === 0 && bookedSlots.length === 0 + ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»." + : undefined; + + return ( +
+
+
+
Слоты
+
Расчёт покрытия и бронирование слотов заявки
+
+
+ Заявка +
+ {selectedRequest ? selectedRequest.name : "—"} +
+
+ + + + + + + +
+ {(slots.length > 0 || bookedSlots.length > 0) && ( +
+ слотов {slots.length} · забронировано {bookedSlots.length} +
+ )} +
+ +
+
+ {hasRequest ? ( + filteredSlots.length === 0 ? ( +
+ {slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."} +
+ ) : ( + + + + + + + + + + + + + + + {filteredSlots.map((slot) => { + const key = slotKey(slot); + const id = `slot:${key}`; + const booked = bookedKeys.has(key); + const isActive = activeSlotId === id; + return ( + selectRow(slot)} + > + + + + + + + + + + ); + })} + +
+ + КАВитокСлот / циклНачалоКонецКренСтатус
event.stopPropagation()}> + toggleSlot(key)} + aria-label="Выбрать слот" + /> + {slot.satelliteId} + {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} + + {slot.slotNumber} / {slot.cycle} + {formatLocalDateTime(parseLocal(slot.tn))}{formatLocalDateTime(parseLocal(slot.tk))}{slot.roll.toFixed(1)}° + {booked ? ( + бронь + ) : ( + {slotStateLabel(slot.state)} + )} +
+ ) + ) : ( +
+
+
Заявки на интервал
+
+ + +
+ + + + +
+ {requestsError && ( +
{requestsError}
+ )} + {requestsLoaded && !requestsError && ( +
+ {requestItems.length === 0 + ? "Заявок не найдено." + : `Загружено: ${requestItems.length} из ${requestsTotal}`} + {requestsTruncated && ( + (показаны первые 5000 — уточните интервал) + )} +
+ )} + {requestItems.length > 0 && ( +
+ OPTICS + RSA + COMBINED +
+ )} +
+
+
Кликните заявку на карте, чтобы выбрать.
+
+ )} +
+ +
+ {overlayMessage &&
{overlayMessage}
} + {hasRequest && ( + + )} + {hasRequest && days.length > 0 && ( + + )} + + {!hasRequest && mapHits.length > 0 && ( + + )} + {hasRequest && slotPicks.length > 0 && ( + + )} +
+
+ +
+ {notice &&
{notice}
} + {error &&
{error}
} +
+
+ ); +} + +function slotStateLabel(state: string): string { + switch (state) { + case "AVAILABLE": + return "свободен"; + case "BOOKED": + return "бронь"; + case "OCCUPIED": + return "занят"; + default: + return state || "—"; + } +} diff --git a/services/pcp-tgu-ui-service/src/model/slotTypes.ts b/services/pcp-tgu-ui-service/src/model/slotTypes.ts new file mode 100644 index 0000000..ebecf33 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/model/slotTypes.ts @@ -0,0 +1,55 @@ +// Типы слотов покрытия и бронирования. Зеркалят DTO из pcp-types-lib +// (space.nstart.pcp.pcp_types_lib.dto.requests.slots), которые отдаёт slots-service. + +/** Признак ветви витка (восходящая/нисходящая). */ +export type RevolutionSign = "ASC" | "DESC"; + +/** Статус слота из расчёта покрытия. */ +export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string; + +/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */ +export type SlotDTO = { + cycle: number; + satelliteId: number; + tn: string; + tk: string; + roll: number; + /** Контур полосы слота в WKT (POLYGON ...) — рисуем на 2D-карте. */ + contour: string; + revolution: number; + revolutionSign: RevolutionSign; + slotNumber: number; + state: SlotStatus; + latitude: number; + longitude: number; +}; + +/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */ +export type BookedSlotDTO = { + bookedSlotId: number; + satelliteId: number; + slotNum: number; + cycle: number; +}; + +/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */ +export type SlotBookingRequest = { + satelliteId: number; + slotNum: number; + cycle: number; +}; + +/** Параметры расчёта слотов покрытия заявки (GET /api/slots/request-cover). */ +export type RequestSlotsParams = { + requestId: string; + timeStart: string; + timeStop: string; + /** Ветвь витка; не задано — обе ветви. */ + revSign?: RevolutionSign; + /** true — считать ещё и площадь покрытия (дольше); false — только слоты-кандидаты. */ + cov?: boolean; + /** Ограничение по КА; пусто — все доступные. */ + satellites?: number[]; + /** Угол Солнца, град (для оптики); не задано — без ограничения. */ + sun?: number; +}; diff --git a/services/pcp-tgu-ui-service/src/model/timelineTypes.ts b/services/pcp-tgu-ui-service/src/model/timelineTypes.ts index d0c0cbe..9d191e9 100644 --- a/services/pcp-tgu-ui-service/src/model/timelineTypes.ts +++ b/services/pcp-tgu-ui-service/src/model/timelineTypes.ts @@ -22,7 +22,14 @@ export type TimelineRange = { toMs: number; }; -export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation"; +export type TguActiveTab = + | "timeline" + | "map" + | "complex" + | "editor" + | "requests" + | "slots" + | "constellation"; export type TimelineRow = { spacecraftId: string; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index 0d9a2b0..ddd2b77 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -23,6 +23,14 @@ overflow-x: auto; } +.tgu-toolbar__row--range { + min-height: 0; + padding-top: 0.55rem; + padding-bottom: 0.6rem; + gap: 0.5rem; + border-top: 1px solid var(--line); +} + .tgu-toolbar__brand { display: flex; align-items: center; @@ -906,6 +914,215 @@ min-height: 0; } +/* Вкладка «Слоты»: слева список слотов, справа карта. */ +.tgu-slots-body { + display: grid; + grid-template-columns: minmax(22rem, 32rem) minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.tgu-slots-list { + min-width: 0; + min-height: 0; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-slots-empty { + padding: 1rem; + color: var(--text-dim); + font-size: 0.82rem; +} + +/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */ +.tgu-slots-request { + align-items: center; +} + +.tgu-slots-request__name { + min-height: 2rem; + display: flex; + align-items: center; + max-width: 18rem; + padding: 0.38rem 0.55rem; + border: 1px solid var(--line); + border-radius: 6px; + background: #101214; + color: var(--text); + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Левая панель в режиме «заявка не выбрана»: фильтр интервала заявок + подсказка. */ +.tgu-slots-requests-panel { + display: grid; + gap: 0.6rem; + padding: 0.9rem; +} + +/* Кнопка «Сброс» — справа над картой (поверх неё). */ +.tgu-slots-reset { + position: absolute; + top: 0.55rem; + right: 0.55rem; + z-index: 21; + background: rgba(23, 26, 28, 0.93); + backdrop-filter: blur(6px); +} + +/* Меню выбора слота при плотном наложении — плавающий список справа над картой (под «Сбросом»). */ +.tgu-slots-pick { + position: absolute; + top: 3rem; + right: 0.55rem; + z-index: 24; + width: 22rem; + max-width: calc(100% - 1.1rem); + max-height: calc(100% - 3.6rem); + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(23, 26, 28, 0.96); + backdrop-filter: blur(6px); + overflow: hidden; +} + +.tgu-slots-pick__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.65rem; + border-bottom: 1px solid var(--line); + font-size: 0.78rem; + font-weight: 600; + color: var(--text); +} + +.tgu-slots-pick__list { + overflow: auto; + min-height: 0; +} + +.tgu-slots-pick__item { + display: grid; + gap: 0.15rem; + width: 100%; + padding: 0.45rem 0.65rem; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + text-align: left; + cursor: pointer; + color: var(--text); +} + +.tgu-slots-pick__item:hover { + background: rgba(104, 195, 189, 0.1); +} + +.tgu-slots-pick__item.is-active { + background: rgba(104, 195, 189, 0.22); + box-shadow: inset 3px 0 0 var(--accent); +} + +.tgu-slots-pick__title { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; + font-weight: 600; +} + +.tgu-slots-pick__meta { + font-family: var(--mono); + font-size: 0.7rem; + color: var(--text-dim); +} + +/* Инспектор заявки — плавающая панель справа над картой (до выбора заявки). */ +.tgu-slots-inspector { + position: absolute; + top: 0.55rem; + right: 0.55rem; + z-index: 25; + width: 23rem; + max-width: calc(100% - 1.1rem); + max-height: calc(100% - 1.1rem); + overflow: auto; + padding: 0.9rem; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(23, 26, 28, 0.96); + backdrop-filter: blur(6px); +} + +.tgu-slots-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.tgu-slots-table th, +.tgu-slots-table td { + padding: 0.35rem 0.5rem; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--line); +} + +.tgu-slots-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg-panel); + color: var(--text-dim); + font-weight: 600; +} + +.tgu-slots-table tbody tr { + cursor: pointer; +} + +.tgu-slots-table tbody tr:hover { + background: rgba(104, 195, 189, 0.08); +} + +.tgu-slots-table tbody tr.is-active { + background: rgba(104, 195, 189, 0.3); + box-shadow: inset 3px 0 0 var(--accent); +} + +.tgu-slots-table tbody tr.is-active td { + color: var(--text); + font-weight: 600; +} + +.tgu-slots-table tbody tr.is-active:hover { + background: rgba(104, 195, 189, 0.36); +} + +.tgu-slots-badge { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: #101214; + border: 1px solid var(--line); + color: var(--text-muted); + font-size: 0.72rem; +} + +.tgu-slots-badge--booked { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + .tgu-map-stage { position: relative; min-width: 0; @@ -1052,6 +1269,75 @@ background: var(--bg-panel); color: var(--text); font-weight: 700; + cursor: pointer; +} + +.tgu-2d-map__zoom button:hover { + border-color: rgba(104, 195, 189, 0.5); +} + +/* Кнопка-линейка: активный режим подсвечиваем как тумблер. */ +.tgu-2d-map__zoom-ruler.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.18); + color: var(--accent-strong); +} + +/* Замер линейки на оверлее: ломаная, вершины и подписи расстояния. */ +.tgu-2d-map__ruler-line { + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__ruler-vertex { + fill: #f0ad2e; + stroke: #1a1305; + stroke-width: 1; +} + +.tgu-2d-map__ruler-label { + fill: #ffe7b5; + font-size: 11px; + font-weight: 600; + paint-order: stroke; + stroke: rgba(10, 12, 14, 0.85); + stroke-width: 3; +} + +/* Бейдж итоговой длины — в правом нижнем углу, над кнопками управления. */ +.tgu-2d-map__ruler-total { + position: absolute; + right: 0.75rem; + bottom: 9rem; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.55rem; + border: 1px solid rgba(240, 173, 46, 0.42); + border-radius: 7px; + background: rgba(17, 19, 21, 0.9); + color: #ffe7b5; + font-size: 0.78rem; + backdrop-filter: blur(6px); +} + +.tgu-2d-map__ruler-total-label { + color: var(--text-dim); +} + +.tgu-2d-map__ruler-total-value { + font-weight: 700; +} + +.tgu-2d-map__ruler-reset { + border: 1px solid var(--line); + border-radius: 5px; + padding: 0.1rem 0.45rem; + background: #101214; + color: var(--text-muted); + font-size: 0.72rem; + cursor: pointer; } .tgu-map-overlay {